Compare commits

12 Commits
Author SHA1 Message Date
JesseMarkowitzandClaude Opus 5.5 3108319a2b Add chance and a cast that changes between runs, and a pack to try them
An option used to do exactly what it said. Now it may carry a check: one of
four attributes (Nerve, Wits, Charm, Resolve) against a set difficulty or
against a named person's own attribute, leaning on how they regard you, with a
success and a failure. The player sees only the odds in words ("a long shot",
"likely"), never a number. The roll is one draw from the run's generator, so a
run still replays exactly from its seed and choices.

A character may be a role with several authored candidates, one drawn per run,
or a group of two to four drawn into numbered slots. Everyone carries a name, a
description and the same four attributes as the player, so a replayed pack
meets a different office. Text names people as {partner} or {clerk}, and an
event about "a clerk" binds one present clerk for the turn. The draw and the
binding are saved with the run, and a save from before a pack drew a cast gets
the same one every time it is loaded.

All of it is generic and optional: Corporate Ladder and the Frontier use none
of it and play as before. The Counting House, a one-stage office about 1910
built from the story-to-pack catalogue, exercises it, and was tuned against the
conformance suite by measurement. DECISIONS #41. Version 0.4.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018MhABSrqkwTdcybXmheDPr
2026-09-23 22:12:26 -04:00
JesseMarkowitzandClaude Opus 5 112e558dbb Fix repayments that overcharged, and act on the first Frontier playtest
A debt repayment took its whole instalment even when less was owed: four
dollars owed at the store cost twenty. Content could not express "pay what is
owed", so an add effect's amount may now be read from state, with a cap. Five
repayments across both packs use it, and a conformance test holds every pack to
it. The turn record now says which options were hidden, so the analyser stops
counting doors nobody saw, and it reads only feedback logs.

From the same session: wages are labelled as wages, a clamped change says where
it stopped, two unreadable option labels are rewritten, the sale barn no longer
offers what the player already owns, Ward hints at what the promotion waits for,
and the office has five more events. DECISIONS #38-40.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6UDQ9o6L6Ey173U7XVou6
2026-09-15 10:23:15 -04:00
JesseMarkowitzandClaude Opus 5 d84ae495f4 Keep money mattering at the top, and fix the instruments that hid it
Both settings now carry a charge levied by standing itself, which no purchase
ends. Corporate Ladder's steps on money (1200/-60, 2400/-120, 3600/-240): a flat
charge cannot flatten a slope, since late income scales with how well the player
plays, and the flat charge needed to stop an optimiser accumulating put ordinary
archetypes below zero for 44% of a run. Stepped, the balance settles around
$4,250 and ordinary play comes out better than before it existed. The Frontier's
is flat at -4 a run, because its late game never accumulated in the first place.

Found in the first two human sessions of this work:

- A career saved before a pack gained a stat had no path for it, and conditions
  compare numbers only, so every event, option and upkeep entry gated on a new
  stat silently did not exist for that run. Loading now fills those in.
- 'Wrap up my career' sat immediately after the last option in tab order, so one
  Tab too many and Enter read as the game ending at day 31. It goes last, and
  the retrospective says plainly that it is a summary.
- A 181-turn session ran against a stale bundle and the change under test never
  fired. Every logged turn now records app_version and build.
- src/version.js had reported 0.3.0 since 0.3.0, while playtesters were told to
  check the status bar. A test now holds it to package.json.
- 'Put money against the book' was shown locked to a player who owed nothing,
  six times. Options gated on a state the player cannot choose to enter are now
  hidden rather than greyed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxdJFbLq1rBKsUeticGV1g
2026-09-12 23:07:35 -04:00
JesseMarkowitzandClaude Opus 5 5d645e6452 Make standing cost money to keep, in both settings
Each pack declares a lifestyle tier, bought on the second rung and charged for
every cycle after: Corporate Ladder's car and address, the Frontier's bay and
the room over the Ellis place. Falling behind what the job expects costs
standing, in steps that steepen as standing rises. A Dispatch coordinator
keeping up fully nets -21 a week and earns the difference out of the days. No
engine change; the only interface change is a tier display format.

Sized by measurement, not feel (DECISIONS #36): a -2/week penalty is invisible
against Dispatch's standing gains, and a player who chases standing every turn
beats any flat penalty — 99 standing while buying nothing, and $2,245 richer
than one who kept up. Hence the top step.

Two defects fixed on the way. The conformance suite excluded debt from the
economic baseline by matching /debt|owed|loan/i on path names, which would have
counted every lifestyle tier into it; packs now declare tuning.baselineExcludes.
And 'the promotion leaves a player better off' pooled end balances across
archetypes that do not spend alike, so it measured the mix — it is now derived
from the upkeep arithmetic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxdJFbLq1rBKsUeticGV1g
2026-09-12 21:53:03 -04:00
JesseMarkowitzandClaude Opus 5 7f20be83e8 Add The Frontier, a second setting, and a picker to choose between them
Cold Fork is a freight yard: 29 events across two stages, a four-day cycle, and
its own relationship and skill vocabulary. src/engine/ was not touched. Adding it
needed three changes outside the engine: one save slot per pack, app.js holding
the current pack, and turn.js no longer naming the mailroom (DECISIONS #34).
build.js now handles imports wrapped across lines. A conformance suite runs over
every registered pack. DECISIONS #35 records three calls on open questions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxdJFbLq1rBKsUeticGV1g
2026-09-12 21:03:50 -04:00
JesseMarkowitzandClaude Opus 5 4957c24e31 Stamp the build into the output, not into a tracked file
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
2026-09-10 06:12:58 -04:00
JesseMarkowitzandClaude Opus 5 5c1784d990 Fix an inescapable debt trap the playtest logs exposed
A 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 got promoted out of
the problem before it bit, and the dominance test passed because rent_bounced
was only 17% of turns.

Fixes: mailroom wages 980 -> 1120, restoring the -$3/day baseline; 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, because being broke
should colour a run rather than replace it; debt_collector from weight 25 to 45,
since at 25 it appeared six times in 262 turns and debt became a ratchet.

Three guards so this class of bug cannot recur quietly.

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 not so much better that money stops mattering. That
would have failed the moment rent changed. Outcome tests over simulated play are
a slow and noisy way to detect a number that is simply wrong.

A `lifer` archetype 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; no other archetype can.

And no archetype may spend 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.

Also fixed: the analyser reported "every ~-5 turns". One export can hold several
playthroughs and turn numbers restart with each, so spans are now accumulated
per run and pooled rather than measured across the seam between two.

121 tests. Reasoning in docs/DECISIONS.md §29-30.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VMSFHyVPitUoosW5wyEADj
2026-09-10 06:01:33 -04:00
JesseMarkowitzandClaude Opus 5 76072edf4e Show the build, play from the keyboard, and clean up the ledger
Five things from the second playtest.

A status line at the top of every screen: the game, the pack, your current
title, and the version plus the commit it came from. `tools/stamp.js` writes the
commit into src/build-info.js before serving and before building, so a
distributed dist/theladder.html states exactly which commit produced it. An
uncommitted working tree gets a `+` suffix — "4cab9fc+" is honest in a way a
bare SHA would not be.

The whole game is now playable on Enter. Every screen marks one control 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.
Tab and Shift+Tab reach the other options. Locked options needed no special
handling — the browser's tab order already skips disabled controls, so they stay
visible without being in the way.

"What is notable? under What changed?" — two fixes. `notable` is the
retrospective's own list of moments; a display rule can now say
`inLedger: false` and it is left out, because "Notable — changed" tells nobody
anything. Flags are the opposite and stay, but as sentences the pack supplies
rather than as booleans: "The envelope is in your locker." instead of "the
envelope: now true".

Exported logs live in logs/, gitignored, and `npm run analyze` reads the newest
one there when given no argument. A log records what a real person did turn by
turn, including whatever they typed into the feedback box — committing one once
would put it in the history for good.

Version set to 0.3.0 on the reasoning that the engine was 0.1 and the interface
0.2; say if you want a different scheme and I will renumber.

119 tests. Reasoning in docs/DECISIONS.md §25-28.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VMSFHyVPitUoosW5wyEADj
2026-09-10 05:41:32 -04:00
JesseMarkowitzandClaude Opus 5 4cab9fc41e Add promotion to Dispatch, and measure how repetitive a run is
The mailroom is no longer the whole game. Around turn 50, a player who has
built standing and either Marlene's goodwill or the nerve to ask gets offered
the Dispatch post — and can take it, take it and put Trevor up for the mailroom,
or turn it down, which is a real strategy rather than a mistake and comes back
after a cooldown.

Dispatch is eleven events of its own. The money problem is largely solved up
there and replaced by other people's days depending on yours, including the
mailroom's — which you used to be. Trevor branches on whether you recommended
him: he either runs the mailroom and has opinions about how it used to be run,
or he stays put and starts addressing you by your full title, kindly, in front
of people.

None of this is a promotion mechanic in the engine. It is
`{ path: 'stage', op: 'set', value: 'dispatch' }` on an option, since `stage`
was already how events are filtered. The reserved-write rule now allows it
narrowly — `set` only, and only to a stage that has events, both checked by the
validator, so a typo cannot strand the player in an empty pool. Wages differ by
rank through two upkeep entries gated on `stage`. The hard-times events dropped
their stage entirely: rent is due wherever you work.

The second half of this is the thing the last playtest asked for. A 183-turn
session, and no way to see how much of it was new ground.

`npm run analyze <exported-log.json>` now 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.

It found a regression in this very change on its first run. Dispatch started
with eight events against the mailroom's nineteen, so 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.
A test now holds that line.

Also corrected: the coverage test was asking whether an archetype ever *took* an
option, which is a property of the bot, not the pack. It had flagged
`rent_bounced.ask_trevor` as dead content when it had been offered, unlocked,
419 times across a sweep and simply never chosen. Options are now checked on
availability; events are still checked on firing.

114 tests. Reasoning in docs/DECISIONS.md §22-24.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VMSFHyVPitUoosW5wyEADj
2026-09-10 05:05:01 -04:00
JesseMarkowitzandClaude Opus 5 e482f07157 Add the interface, then rebuild the economy around the first playtest
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VMSFHyVPitUoosW5wyEADj
2026-09-10 04:44:49 -04:00
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
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
72 changed files with 13342 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
node_modules/
dist/
# Exported playtest logs are somebody's real session, including whatever
# they typed into the feedback box. Kept local; see logs/README.md.
logs/*
!logs/README.md
*.log
.DS_Store
+245
View File
@@ -0,0 +1,245 @@
# 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.
Two ship: **Corporate Ladder**, a modern office and a mailroom, and **The
Frontier**, a freight yard at the end of a stage line. They share every line of
engine code and no content at all.
## Status
Engine, interface and two content packs, all built and tested, and played in a
real browser.
| Piece | State |
| --- | --- |
| Path-addressed state, conditions, effects | done |
| Seeded RNG, save/resume | done |
| Event selection, option gating | done |
| Turn loop, per-turn upkeep | done |
| Content validator + reachability tests | done |
| Corporate Ladder pack — 32 events, two tiers | done |
| Turn screen, retrospective, feedback widget | done |
| Local-storage save, file export/import | done |
| Single-file build | done |
| Calendar, scheduled wages and rent | done |
| Promotion from the mailroom to Dispatch | done |
| Repetition analytics (`npm run analyze`) | done |
| Version and build shown on screen | done |
| Keyboard play (Enter / Tab) | done |
| Second content pack — The Frontier, 37 events, two tiers | done |
| Setting picker, one save slot per pack | done |
| Pack conformance suite (every pack, every check) | done |
| Played in a real browser | Corporate Ladder since 2026-09-09; The Frontier 2026-09-15 (112 turns, 0.3.3) |
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 run serve # then open http://localhost:8080
```
The game plays from the keyboard: Enter takes the focused option, Enter again
moves to the next day, and Tab / Shift+Tab pick a different option. The top of
every screen names the game, the pack, your current title, and the build —
`0.3.4 · dev` when running from source, and the commit it came from when built.
A server is needed during development because browsers refuse ES module imports
over `file://`. If port 8080 is taken, run `python3 -m http.server <port>`
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 # 162 tests: engine, both packs, UI wiring, and the build
```
Tests are in three layers. `test/conformance.test.js` runs over **every pack in
the registry** and asserts only what is true of a pack because it is a pack —
validity, no dead content, the declared economic baseline, variety, replay from
seed, a way out of every event. `test/content.test.js` and
`test/frontier.test.js` hold each pack to its own tuning. The engine suite runs
against a fixture pack, so tuning a setting can never break it.
To see what a playtest actually did — turns against distinct situations, how
often each recurred, which locked gates players kept meeting, and anything they
typed into the feedback box:
```sh
npm run analyze # the newest export in logs/
npm run analyze -- <file> # a specific one
```
Drop exports into `logs/`. They are gitignored — a log is a record of what a
real person did, including anything they typed into the feedback box.
There are no runtime dependencies and no build step for development. Node is
used only to run the tests, the single-file build, and the log analyser.
## Where things stand
The MVP is complete and has survived four playtests, and a second setting exists
to prove the engine/content split holds. Adding it required **no engine change**
— and three interface changes, two of which were latent defects a second pack
made visible (a single global save slot, and the one place the UI still named a
setting). See DECISIONS.md #34.
The Frontier had its first human session on 2026-09-15: 112 turns, promoted on
turn 44, both lifestyle purchases taken and the standing charge firing as
designed. What it found — an overcharging debt repayment in both packs, wages no
one could recognise, a sale barn offering what the player already owned, and an
office stage too thin — is what 0.3.4 fixes. The repayment fix needed one small
engine addition: an effect's amount may be read from state. See DECISIONS #38–40.
Open questions, in the order they are likely to matter:
- **0.3.4 has not been played.** Everything in it is asserted by tests and
archetype runs, which is the evidence that was not enough before. In simulation
promoted Frontier runs now end with about twice the money they did ($164 against
$88) and less standing, and why is not yet isolated. The next Frontier session
should watch whether money stops mattering at the top. See DECISIONS #40.
- **Dispatch is twelve events against the mailroom's eighteen.** It passes the
variety guard, but it has had far less play than the mailroom. The Frontier's
office had the same thinness in a real session; Dispatch may too.
- **A third tier in either pack.** Two rungs is what both settings have. The
split is now proven across settings; it is not proven across a long ladder. The
Frontier has now been played once, so this is the designer's call to open.
Settled, and not open: Corporate Ladder's negotiation gate stays as it is, and
the feedback widget stays as it is (DECISIONS #35). Money mattering late is
built rather than open (DECISIONS #36).
## Layout
```
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
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
src/ui/ screens, formatting and the app controller
app.js the only module that knows there is more than one pack
screens/ the turn screen, the retrospective, the setting picker
format.js state paths and conditions rendered as English
src/io/ local-storage save (one slot per pack), export/import, feedback log
content/
index.js the pack registry — the only list of settings
corporateladder/ a modern office and a mailroom
frontier/ a freight yard at the end of a stage line
tools/build.js flattens everything into one self-contained HTML file
test/ engine tests against a fixture pack; conformance, per-pack, UI
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.
A pack also declares `upkeep` — a list of `{ label, requires?, effects }`
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
fallback event, all of which otherwise fail silently mid-playthrough.
+30
View File
@@ -0,0 +1,30 @@
// The mailroom, as staffed.
//
// `description` is pure colour — the engine never reads it. What the engine
// does read is `startingRelationship`, where a character departs from the
// pack's defaults: nobody fears a new hire, and Trevor started two weeks
// earlier and has priced that in.
export const characters = [
{
id: 'marlene',
name: 'Marlene Hobb',
title: 'Mailroom Supervisor',
role_type: 'boss',
description:
'Has worked here nineteen years. Refers to the building as "the plant." ' +
'Keeps a laminated card of the org chart, updated in pen.',
},
{
id: 'trevor',
name: 'Trevor Pike',
title: 'Mail Associate II',
role_type: 'rival',
description:
'Arrived two weeks before you and has never once let that go. ' +
'Photographs his lunch.',
startingRelationship: { likes_you: 35, wants_to_help_you: 25 },
},
];
export default characters;
+549
View File
@@ -0,0 +1,549 @@
// Dispatch: the second tier.
//
// Same mechanics, different pressure. The mailroom's problem was money; up here
// the money is fine and the problem is that other people's days now depend on
// yours — including the mailroom's, which you used to be.
//
// The first two events carry no conditions, no cooldown and no `once`, so this
// stage has a floor of its own. Trevor's two chains are mutually exclusive
// branches on whether you put him up for your old job.
export const dispatch = [
{
id: 'dispatch_first_day',
stage: 'dispatch',
weight: 99999,
once: true,
description:
'The Dispatch desk has three phones and a laminated card taped to it, ' +
'listing what to do when each one rings. The handwriting is Marlene\'s.',
options: [
{
id: 'read_card',
label: 'Read the card.',
result: 'It is nineteen years of somebody else\'s hard-won afternoons, in eleven lines.',
effects: [
{ path: 'skills.logistics', op: 'add', value: 3 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: 2 },
{ path: 'notable', op: 'push', value: 'Found Marlene\'s handwriting taped to the Dispatch desk.' },
],
},
{
id: 'answer_everything',
label: 'Answer all three phones and work it out.',
result: 'By four o\'clock you have worked out what six of the eleven lines meant.',
effects: [
{ path: 'skills.logistics', op: 'add', value: 2 },
{ path: 'skills.negotiation', op: 'add', value: 2 },
{ path: 'reputation', op: 'add', value: -2 },
],
},
{
id: 'take_it_slow',
label: 'Let the phones ring and learn the board first.',
result: 'Two callers give up. The board, however, you now understand.',
effects: [
{ path: 'skills.logistics', op: 'add', value: 4 },
{ path: 'reputation', op: 'add', value: -3 },
],
},
],
},
{
id: 'routing_board',
stage: 'dispatch',
weight: 70,
description:
'Two runs are booked against the same van at the same hour. Both floors ' +
'have written "URGENT" on the docket, one of them twice.',
options: [
{
id: 'triage',
label: 'Work out which one is actually urgent.',
result: 'Neither. You send the van to a third job nobody had flagged at all.',
effects: [
{ path: 'skills.logistics', op: 'add', value: 3 },
{ path: 'reputation', op: 'add', value: 3 },
],
},
{
id: 'split_van',
label: 'Split the run and make both floors wait half as long.',
result: 'Both floors complain about the half they waited. Neither escalates it.',
effects: [
{ path: 'skills.logistics', op: 'add', value: 1 },
{ path: 'reputation', op: 'add', value: 1 },
],
},
{
id: 'first_come',
label: 'Whoever wrote URGENT twice can go second.',
result: 'They notice. They are the sort of floor that notices.',
effects: [
{ path: 'reputation', op: 'add', value: -2 },
{ path: 'skills.logistics', op: 'add', value: 1 },
],
},
],
},
{
id: 'late_freight',
stage: 'dispatch',
weight: 55,
description:
'A freight job has come in past the cutoff. Taking it means somebody stays, ' +
'and the somebody is either you or a person you now have the standing to ask.',
options: [
{
id: 'stay_yourself',
label: 'Stay and do it yourself.',
result: 'It takes ninety minutes. Nobody sees it, which is most of the job now.',
effects: [
{ path: 'money', op: 'add', value: 90 },
{ path: 'skills.logistics', op: 'add', value: 1 },
{ path: 'reputation', op: 'add', value: 2 },
],
},
{
id: 'ask_the_floor',
label: 'Ask the mailroom to cover it.',
requires: [{ path: 'skills.negotiation', op: '>=', value: 25 }],
result:
'They cover it. You remember precisely how this felt from the other end, ' +
'and ask anyway.',
effects: [
{ path: 'skills.negotiation', op: 'add', value: 2 },
{ path: 'reputation', op: 'add', value: -1 },
{ path: 'relationships.trevor.likes_you', op: 'add', value: -4 },
{ path: 'relationships.trevor.fears_you', op: 'add', value: 3 },
],
},
{
id: 'bounce_it',
label: 'Send it back as past cutoff.',
result: 'The cutoff exists. You have simply never seen anyone use it before.',
effects: [
{ path: 'reputation', op: 'add', value: -3 },
{ path: 'skills.negotiation', op: 'add', value: 1 },
],
},
],
},
{
id: 'mailroom_calls',
stage: 'dispatch',
weight: 60,
cooldown: 4,
description:
'The mailroom is short-handed and the bins are backing up. From up here it ' +
'is a number on a board. You know exactly what it is down there.',
options: [
{
id: 'go_down',
label: 'Go down and sort bins for an hour.',
result:
'You are a coordinator sorting bins. Two people find this funny and one ' +
'finds it reassuring.',
effects: [
{ path: 'skills.logistics', op: 'add', value: 2 },
{ path: 'reputation', op: 'add', value: 3 },
{ path: 'relationships.trevor.likes_you', op: 'add', value: 5 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: 4 },
],
},
{
id: 'send_help',
label: 'Pull someone off a run to help them.',
result: 'The run is late. The bins are not. You will hear about the run.',
effects: [
{ path: 'skills.logistics', op: 'add', value: 1 },
{ path: 'reputation', op: 'add', value: -1 },
{ path: 'relationships.trevor.wants_to_help_you', op: 'add', value: 4 },
],
},
{
id: 'leave_them',
label: 'It is a number on a board.',
result: 'The number goes down by itself, eventually, the way it always did.',
effects: [
{ path: 'relationships.trevor.likes_you', op: 'add', value: -5 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: -3 },
],
},
],
},
{
id: 'trevor_promoted',
stage: 'dispatch',
weight: 55,
cooldown: 5,
requires: [{ path: 'flags.recommended_trevor', op: '==', value: true }],
description:
'Trevor runs the mailroom now and has opinions about how it was run before. ' +
'Some of the opinions are about you. All of them are correct.',
options: [
{
id: 'concede',
label: 'Tell him he is right.',
result: 'He is so unprepared for this that he agrees with you about something else.',
effects: [
{ path: 'relationships.trevor.likes_you', op: 'add', value: 6 },
{ path: 'relationships.trevor.wants_to_help_you', op: 'add', value: 4 },
{ path: 'skills.negotiation', op: 'add', value: 1 },
],
},
{
id: 'back_him',
label: 'Back him upstairs when it matters.',
requires: [{ path: 'reputation', op: '>=', value: 55 }],
result: 'His changes go through. He knows exactly why they went through.',
effects: [
{ path: 'relationships.trevor.wants_to_help_you', op: 'add', value: 10 },
{ path: 'reputation', op: 'add', value: 2 },
{ path: 'notable', op: 'push', value: 'Backed Trevor\'s changes to the mailroom.' },
],
},
{
id: 'pull_rank',
label: 'Remind him whose desk the board is on.',
result: 'It works. It works the way a door works when you take it off its hinges.',
effects: [
{ path: 'relationships.trevor.fears_you', op: 'add', value: 8 },
{ path: 'relationships.trevor.likes_you', op: 'add', value: -8 },
{ path: 'reputation', op: 'add', value: -2 },
],
},
],
},
{
id: 'trevor_passed_over',
stage: 'dispatch',
weight: 55,
cooldown: 5,
requires: [{ path: 'flags.recommended_trevor', op: '==', value: false }],
description:
'Trevor is still in the mailroom and has started addressing you by your ' +
'full title. He does it in front of people. He does it kindly, which is worse.',
options: [
{
id: 'address_it',
label: 'Say the thing out loud.',
requires: [{ path: 'skills.negotiation', op: '>=', value: 25 }],
result:
'He denies it, then stops doing it. Both of you agree never to refer ' +
'to the conversation again.',
effects: [
{ path: 'skills.negotiation', op: 'add', value: 3 },
{ path: 'relationships.trevor.likes_you', op: 'add', value: 6 },
{ path: 'relationships.trevor.fears_you', op: 'add', value: 2 },
],
},
{
id: 'find_him_something',
label: 'Find him something worth doing.',
result:
'You give him the freight schedule, which is a real job. He is careful ' +
'with it, and the title stops.',
effects: [
{ path: 'relationships.trevor.likes_you', op: 'add', value: 8 },
{ path: 'relationships.trevor.wants_to_help_you', op: 'add', value: 6 },
{ path: 'reputation', op: 'add', value: 2 },
{ path: 'notable', op: 'push', value: 'Found Trevor something worth doing.' },
],
},
{
id: 'let_it_run',
label: 'Let him have it.',
result: 'He has it. It does not appear to be making him any happier.',
effects: [
{ path: 'relationships.trevor.likes_you', op: 'add', value: -3 },
{ path: 'reputation', op: 'add', value: -1 },
],
},
],
},
{
id: 'marlene_upstairs',
stage: 'dispatch',
weight: 45,
cooldown: 6,
description:
'Marlene comes up with a docket she could have sent. She looks at the three ' +
'phones for slightly too long before she says anything.',
options: [
{
id: 'ask_her',
label: 'Ask whether she ever wanted this desk.',
result:
'She says nobody offered, which is not the same as no. Then she asks ' +
'whether the third-floor bins have gone up.',
effects: [
{ path: 'relationships.marlene.likes_you', op: 'add', value: 6 },
{ path: 'relationships.marlene.wants_to_help_you', op: 'add', value: 4 },
{ path: 'notable', op: 'push', value: 'Asked Marlene whether she ever wanted the Dispatch desk.' },
],
},
{
id: 'take_the_docket',
label: 'Take the docket and get on with it.',
result: 'She goes back down. The card is still taped to the desk.',
effects: [{ path: 'skills.logistics', op: 'add', value: 1 }],
},
{
id: 'put_her_up',
label: 'Put her name up for the next post that opens.',
requires: [{ path: 'reputation', op: '>=', value: 60 }],
result:
'She does not thank you. Two days later there is a second card taped ' +
'to the desk, with the four lines she had left out.',
effects: [
{ path: 'relationships.marlene.likes_you', op: 'add', value: 10 },
{ path: 'relationships.marlene.wants_to_help_you', op: 'add', value: 10 },
{ path: 'reputation', op: 'add', value: 3 },
{ path: 'notable', op: 'push', value: 'Put Marlene up for the next post that opened.' },
],
},
],
},
{
id: 'dispatch_overtime',
stage: 'dispatch',
weight: 40,
cooldown: 6,
description:
'The quarterly stocktake needs a coordinator on site for the weekend. It ' +
'pays coordinator rates, which are new to you.',
options: [
{
id: 'take_it',
label: 'Take the weekend.',
result: 'Two days, and the rate is genuinely different. You check the slip twice.',
effects: [
{ path: 'money', op: 'add', value: 320 },
{ path: 'skills.logistics', op: 'add', value: 2 },
{ path: 'reputation', op: 'add', value: 2 },
],
},
{
id: 'send_trevor',
label: 'Offer it to the mailroom instead.',
result:
'Trevor takes it. He has never been paid that for a weekend and it ' +
'shows on Monday.',
effects: [
{ path: 'relationships.trevor.likes_you', op: 'add', value: 8 },
{ path: 'relationships.trevor.wants_to_help_you', op: 'add', value: 5 },
{ path: 'reputation', op: 'add', value: 2 },
],
},
{
id: 'pass',
label: 'Pass.',
result: 'Somebody from Finance does it and gets it wrong in an interesting way.',
effects: [],
},
],
},
{
id: 'van_breakdown',
stage: 'dispatch',
weight: 55,
cooldown: 4,
description:
'The long-wheelbase van is off the road until Thursday. Everything booked ' +
'against it is still booked against it, and the board does not know that yet.',
options: [
{
id: 'rebuild_board',
label: 'Rebuild the day around what you have.',
result:
'Four jobs move, one is dropped, and nobody upstairs ever learns the ' +
'van was off the road.',
effects: [
{ path: 'skills.logistics', op: 'add', value: 4 },
{ path: 'reputation', op: 'add', value: 3 },
],
},
{
id: 'hire_one',
label: 'Hire one in for the day.',
requires: [{ path: 'money', op: '>=', value: 200 }],
result: 'It costs what it costs. The day runs as though nothing happened.',
effects: [
{ path: 'money', op: 'add', value: -180 },
{ path: 'reputation', op: 'add', value: 2 },
{ path: 'skills.negotiation', op: 'add', value: 1 },
],
},
{
id: 'tell_them',
label: 'Tell every floor their job is late and why.',
result:
'Two floors are fine about it. The third writes an email with a ' +
'subject line that uses the word "again".',
effects: [
{ path: 'reputation', op: 'add', value: -2 },
{ path: 'skills.negotiation', op: 'add', value: 2 },
],
},
],
},
{
id: 'new_starter',
stage: 'dispatch',
weight: 50,
cooldown: 5,
description:
'There is somebody new in the mailroom. They have been handed a laminated ' +
'card with the org chart on it, and their name is not on it either.',
options: [
{
id: 'walk_them_through',
label: 'Go down and walk them through it.',
result:
'You explain the card. Then you explain it again, because that is how ' +
'it works and you know that now.',
effects: [
{ path: 'reputation', op: 'add', value: 3 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: 4 },
{ path: 'skills.negotiation', op: 'add', value: 1 },
{ path: 'notable', op: 'push', value: 'Walked a new starter through the org chart.' },
],
},
{
id: 'give_advice',
label: 'Tell them what you wish somebody had told you.',
requires: [{ path: 'skills.negotiation', op: '>=', value: 30 }],
result:
'They write it down, which nobody did for you. Whether it helps is ' +
'not something you will find out.',
effects: [
{ path: 'reputation', op: 'add', value: 2 },
{ path: 'skills.negotiation', op: 'add', value: 2 },
{ path: 'notable', op: 'push', value: 'Told a new starter what nobody had told you.' },
],
},
{
id: 'leave_them_to_it',
label: 'Leave them to work it out. Everyone else did.',
result: 'They work it out. It takes them eleven days.',
effects: [{ path: 'reputation', op: 'add', value: -1 }],
},
],
},
{
id: 'finance_query',
stage: 'dispatch',
weight: 45,
cooldown: 6,
description:
'Finance has queried the courier spend. The query is three lines long and ' +
'took somebody a whole morning to write.',
options: [
{
id: 'answer_properly',
label: 'Answer it properly, with the numbers.',
result:
'The numbers are fine. Finance says so in one line, which took them ' +
'a whole afternoon.',
effects: [
{ path: 'skills.logistics', op: 'add', value: 2 },
{ path: 'reputation', op: 'add', value: 4 },
],
},
{
id: 'push_back',
label: 'Ask them what they think the alternative is.',
requires: [{ path: 'skills.negotiation', op: '>=', value: 35 }],
result:
'There is no alternative. Establishing this in writing turns out to ' +
'be worth more than the money was.',
effects: [
{ path: 'skills.negotiation', op: 'add', value: 3 },
{ path: 'reputation', op: 'add', value: 5 },
{ path: 'notable', op: 'push', value: 'Won an argument with Finance in writing.' },
],
},
{
id: 'cut_the_spend',
label: 'Cut the spend and let the service slip.',
result: 'Finance is delighted. Three floors are not, and one of them notices why.',
effects: [
{ path: 'money', op: 'add', value: 60 },
{ path: 'reputation', op: 'add', value: -4 },
],
},
],
},
{
// The cost of standing, offered rather than imposed. Buying nothing is
// always playable and always cheaper; it is Dispatch's weekly "looking the
// part" charge that makes it a real decision instead of a shop.
id: 'the_car_lot',
stage: 'dispatch',
weight: 45,
cooldown: 6,
requires: [{ path: 'lifestyle', op: '<', value: 2 }],
description:
'Marlene mentions, not quite to you, that the coordinator before last ' +
'drove something with a roof rack, and that people noticed when he ' +
'stopped. There is a lot on Ninth with a sign about payments.',
options: [
{
id: 'buy_the_car',
label: 'Put money down on the car.',
requires: [
{ path: 'lifestyle', op: '==', value: 0 },
{ path: 'money', op: '>=', value: 450 },
],
result:
'It is four years old and the radio only gets one station, and on ' +
'Monday two people who have never spoken to you ask about it.',
effects: [
{ path: 'money', op: 'add', value: -450 },
{ path: 'lifestyle', op: 'set', value: 1 },
{ path: 'reputation', op: 'add', value: 3 },
{ path: 'notable', op: 'push', value: 'Bought a car with a roof rack.' },
],
},
{
id: 'trade_up',
label: 'Trade up, and take the lease on the better side of the park.',
requires: [
{ path: 'lifestyle', op: '==', value: 1 },
{ path: 'money', op: '>=', value: 900 },
],
result:
'The address is two stops closer and four hundred a month dearer. ' +
'Nobody says anything about it, which is the entire point of it.',
effects: [
{ path: 'money', op: 'add', value: -900 },
{ path: 'lifestyle', op: 'set', value: 2 },
{ path: 'reputation', op: 'add', value: 5 },
{ path: 'notable', op: 'push', value: 'Moved to the better side of the park.' },
],
},
{
id: 'keep_the_bus_pass',
label: 'Keep the bus pass. The bus is fine.',
result:
'The bus is fine. You read two chapters a day on it, which is two ' +
'more than anybody on Ninth manages.',
effects: [{ path: 'skills.logistics', op: 'add', value: 1 }],
},
],
},
];
export default dispatch;
@@ -0,0 +1,140 @@
// Running out of money.
//
// There is no game over here and no separate failure mode in the engine — this
// is an ordinary event whose conditions happen to be "your money is negative",
// carrying enough weight to crowd out an ordinary day. Every route out costs
// something other than money, and every one of them leaves you still playing.
//
// Both events always offer at least one ungated option, so a player with no
// skills and no friends is never stuck.
export const hardTimes = [
{
id: 'rent_bounced',
// No stage: rent is due wherever you work, and the branch has to follow
// the player up rather than vanishing on promotion.
//
// Weighted heavily but not overwhelmingly, and on a cooldown: being broke
// should colour a run, not replace it. At weight 5000 with no cooldown a
// player who could not climb out saw this and almost nothing else, 45
// times in one session.
weight: 900,
cooldown: 3,
requires: [{ path: 'money', op: '<', value: 0 }],
description:
'Rent came out and did not clear. The letter uses the word ' +
'"unfortunately" four times and encloses a form.',
options: [
{
id: 'take_loan',
label: 'Take the loan the letter suggests.',
// They stop lending eventually, which is when the spiral gets real:
// past this, the only ways out cost standing rather than interest.
requires: [{ path: 'debt', op: '<', value: 2000 }],
result:
'The money arrives the same afternoon, which tells you something about ' +
'the rate.',
effects: [
{ path: 'money', op: 'add', value: 500 },
{ path: 'debt', op: 'add', value: 500 },
{ path: 'flags.took_loan', op: 'set', value: true },
{ path: 'reputation', op: 'add', value: -2 },
{ path: 'notable', op: 'push', value: 'Took the loan.' },
],
},
{
id: 'ask_trevor',
label: 'Ask Trevor for a float.',
requires: [{ path: 'relationships.trevor.likes_you', op: '>=', value: 40 }],
result:
'He lends it without making a thing of it, and from then on he is a ' +
'man who lent you money.',
effects: [
{ path: 'money', op: 'add', value: 150 },
{ path: 'reputation', op: 'add', value: -3 },
{ path: 'relationships.trevor.fears_you', op: 'add', value: -8 },
{ path: 'relationships.trevor.wants_to_help_you', op: 'add', value: -5 },
{ path: 'notable', op: 'push', value: 'Borrowed money from Trevor.' },
],
},
{
id: 'ask_marlene',
label: 'Ask Marlene for an advance.',
requires: [{ path: 'relationships.marlene.wants_to_help_you', op: '>=', value: 55 }],
result:
'She processes it herself and does not put it through the system. ' +
'Neither of you mentions it again.',
effects: [
{ path: 'money', op: 'add', value: 200 },
{ path: 'relationships.marlene.wants_to_help_you', op: 'add', value: -10 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: -3 },
],
},
{
id: 'extra_shifts',
label: 'Take every shift going.',
result:
'You do eleven days without one off. The bins stop being individual ' +
'bins after a while.',
// Has to be worth more than a week of rent, or it is not a way out of
// the hole — it is the hole with extra steps.
effects: [
{ path: 'money', op: 'add', value: 220 },
{ path: 'skills.logistics', op: 'add', value: 2 },
{ path: 'reputation', op: 'add', value: -1 },
],
},
],
},
{
id: 'debt_collector',
// The chance to pay down has to come round often enough to matter; at
// weight 25 it appeared six times in 262 turns and debt became a ratchet.
weight: 45,
cooldown: 4,
requires: [{ path: 'debt', op: '>', value: 0 }],
description:
'A man from the lender calls the mailroom phone. Marlene answers it. She ' +
'hands it to you without expression and then, notably, without leaving.',
options: [
// Both take what is owed up to their size, never more — the same defect
// a real Frontier session found at the store, present here unplayed.
// Money first, because both effects read `debt` before it moves. See #38.
{
id: 'pay_chunk',
label: 'Pay a real chunk of it down, up to $350.',
requires: [{ path: 'money', op: '>=', value: 350 }],
result: 'He is audibly disappointed.',
effects: [
{ path: 'money', op: 'add', value: { from: 'debt', times: -1, cap: 350 } },
{ path: 'debt', op: 'add', value: { from: 'debt', times: -1, cap: 350 } },
{ path: 'reputation', op: 'add', value: 1 },
],
},
{
id: 'pay_minimum',
label: 'Pay what you can.',
requires: [{ path: 'money', op: '>=', value: 60 }],
result: 'Up to fifty against the loan, and ten for the privilege of the call.',
effects: [
{ path: 'money', op: 'add', value: -10 },
{ path: 'money', op: 'add', value: { from: 'debt', times: -1, cap: 50 } },
{ path: 'debt', op: 'add', value: { from: 'debt', times: -1, cap: 50 } },
],
},
{
id: 'stall',
label: 'Tell him next month.',
result: 'He says next month. Marlene goes back to her desk.',
effects: [
{ path: 'debt', op: 'add', value: 25 },
{ path: 'reputation', op: 'add', value: -3 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: -4 },
],
},
],
},
];
export default hardTimes;
+8
View File
@@ -0,0 +1,8 @@
import { routine } from './routine.js';
import { people } from './people.js';
import { promotion } from './promotion.js';
import { dispatch } from './dispatch.js';
import { hardTimes } from './hard-times.js';
export const events = [...routine, ...people, ...promotion, ...dispatch, ...hardTimes];
export default events;
+313
View File
@@ -0,0 +1,313 @@
// Events that turn on where you stand with someone.
//
// These are gated on relationship state rather than skills, which is the point
// of tracking three separate numbers per character: a door can open because
// somebody likes you, or because they would rather you did not make a thing of
// it. The envelope chain is the pack's first taste of leverage — held, then
// spent or put back.
export const people = [
{
id: 'marlene_late',
stage: 'mailroom',
weight: 80,
cooldown: 3,
description:
'Marlene needs someone to stay until the six o\'clock courier. She is not ' +
'asking you specifically. She is looking at you specifically.',
options: [
{
id: 'stay',
label: 'Stay.',
result: 'The courier is twenty minutes late. Marlene waits with you.',
effects: [
{ path: 'money', op: 'add', value: 45 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: 5 },
{ path: 'relationships.marlene.wants_to_help_you', op: 'add', value: 4 },
],
},
{
id: 'decline',
label: 'Say you have somewhere to be.',
requires: [{ path: 'skills.negotiation', op: '>=', value: 15 }],
result: 'She says "of course" in a way that files it somewhere.',
effects: [
{ path: 'skills.negotiation', op: 'add', value: 2 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: -3 },
],
},
{
id: 'volunteer_trevor',
label: 'Suggest Trevor is free.',
result: 'Trevor is, as it turns out, free. He finds out the way you did.',
effects: [
{ path: 'relationships.trevor.likes_you', op: 'add', value: -6 },
{ path: 'relationships.trevor.fears_you', op: 'add', value: 4 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: 1 },
],
},
],
},
{
id: 'trevor_credit',
stage: 'mailroom',
weight: 60,
cooldown: 6,
description:
'The floor manager thanks Trevor for the reorganised outbound shelf. ' +
'Trevor accepts this. You built the shelf on Tuesday.',
options: [
{
id: 'let_it_go',
label: 'Let it go.',
result: 'Trevor is warmer to you all week, which is somehow worse.',
effects: [
{ path: 'reputation', op: 'add', value: -1 },
{ path: 'relationships.trevor.likes_you', op: 'add', value: 4 },
],
},
{
id: 'correct',
label: 'Correct the record, politely.',
requires: [{ path: 'skills.negotiation', op: '>=', value: 15 }],
result:
'You do it well enough that nobody is embarrassed except Trevor, who ' +
'is embarrassed thoroughly.',
effects: [
{ path: 'skills.negotiation', op: 'add', value: 2 },
{ path: 'reputation', op: 'add', value: 4 },
{ path: 'relationships.trevor.likes_you', op: 'add', value: -8 },
{ path: 'relationships.trevor.fears_you', op: 'add', value: 5 },
],
},
{
id: 'private',
label: 'Mention it to Trevor later, alone.',
result: 'He apologises twice, which is once more than he needed to.',
effects: [
{ path: 'skills.negotiation', op: 'add', value: 3 },
{ path: 'relationships.trevor.likes_you', op: 'add', value: -2 },
{ path: 'relationships.trevor.fears_you', op: 'add', value: 6 },
],
},
],
},
{
id: 'misrouted_envelope',
stage: 'mailroom',
weight: 35,
cooldown: 8,
requires: [{ path: 'flags.holds_the_envelope', op: '==', value: false }],
description:
'A sealed envelope, misrouted. Wrong floor, wrong name — and the name is ' +
'one you have seen on the quarterly. It is not sealed especially well.',
options: [
{
id: 'deliver',
label: 'Deliver it. Say nothing.',
result: 'It goes where it was going. You think about it on the way back.',
effects: [
{ path: 'reputation', op: 'add', value: 2 },
{ path: 'notable', op: 'push', value: 'Delivered the misrouted envelope unopened.' },
],
},
{
id: 'hold',
label: 'Put it in your locker for now.',
result: 'For now.',
effects: [
{ path: 'flags.holds_the_envelope', op: 'set', value: true },
{ path: 'notable', op: 'push', value: 'Kept an envelope that was not addressed to you.' },
],
},
{
id: 'to_marlene',
label: 'Take it to Marlene.',
result: 'She reroutes it without looking at it, which took her years.',
effects: [
{ path: 'relationships.marlene.likes_you', op: 'add', value: 4 },
{ path: 'relationships.marlene.wants_to_help_you', op: 'add', value: 3 },
],
},
],
},
{
id: 'envelope_pressure',
stage: 'mailroom',
weight: 45,
cooldown: 6,
requires: [{ path: 'flags.holds_the_envelope', op: '==', value: true }],
description:
'The envelope is still in your locker. It has begun to feel heavier than paper.',
options: [
{
id: 'return_it',
label: 'Put it back into the system, late.',
result: 'Late mail is nobody\'s scandal. It clears.',
effects: [
{ path: 'flags.holds_the_envelope', op: 'set', value: false },
{ path: 'reputation', op: 'add', value: -1 },
],
},
{
id: 'let_it_be_known',
label: 'Let it be known, quietly, that you have it.',
requires: [{ path: 'skills.negotiation', op: '>=', value: 30 }],
result:
'You never say what is in it. You never have to. The mailroom gets ' +
'noticeably more punctual.',
effects: [
{ path: 'relationships.marlene.fears_you', op: 'add', value: 10 },
{ path: 'relationships.trevor.fears_you', op: 'add', value: 10 },
{ path: 'reputation', op: 'add', value: -4 },
{ path: 'notable', op: 'push', value: 'Let it be known you had the envelope.' },
],
},
{
id: 'leave_it',
label: 'Leave it where it is.',
result: 'You check the locker twice on your way out.',
effects: [{ path: 'reputation', op: 'add', value: -1 }],
},
],
},
{
id: 'trevor_favor',
stage: 'mailroom',
weight: 55,
cooldown: 5,
requires: [
{ path: 'relationships.trevor.likes_you', op: '>=', value: 48 },
{ path: 'flags.covered_for_trevor', op: '==', value: false },
],
description:
'Trevor asks if you would cover the last hour on Thursday. He does not ' +
'say why. He does say it twice.',
options: [
{
id: 'cover',
label: 'Cover it.',
result: 'Thursday is quiet. Trevor is not there to see how quiet.',
effects: [
{ path: 'flags.covered_for_trevor', op: 'set', value: true },
{ path: 'relationships.trevor.likes_you', op: 'add', value: 6 },
{ path: 'relationships.trevor.wants_to_help_you', op: 'add', value: 8 },
],
},
{
id: 'trade',
label: 'Cover it, and say what you want in return.',
requires: [{ path: 'skills.negotiation', op: '>=', value: 15 }],
result: 'He agrees quickly, and then thinks about how quickly he agreed.',
effects: [
{ path: 'flags.covered_for_trevor', op: 'set', value: true },
{ path: 'skills.negotiation', op: 'add', value: 3 },
{ path: 'relationships.trevor.wants_to_help_you', op: 'add', value: 5 },
{ path: 'relationships.trevor.fears_you', op: 'add', value: 4 },
],
},
{
id: 'refuse',
label: 'Say no.',
result: 'He says it is fine. He asks Marlene, who says no as well.',
effects: [{ path: 'relationships.trevor.likes_you', op: 'add', value: -5 }],
},
],
},
{
id: 'call_in_favor',
stage: 'mailroom',
weight: 50,
cooldown: 4,
requires: [{ path: 'flags.covered_for_trevor', op: '==', value: true }],
description:
'Thursday was a while ago now. Trevor has not brought it up, which is its ' +
'own kind of answer.',
options: [
{
id: 'call_it_in',
label: 'Ask him to take the afternoon bins.',
result: 'He takes them. The ledger is closed and both of you know it.',
effects: [
{ path: 'flags.covered_for_trevor', op: 'set', value: false },
{ path: 'skills.logistics', op: 'add', value: 1 },
{ path: 'reputation', op: 'add', value: 1 },
{ path: 'relationships.trevor.wants_to_help_you', op: 'add', value: -4 },
],
},
{
id: 'bank_it',
label: 'Leave it. It keeps.',
result: 'It keeps. He knows it keeps.',
effects: [{ path: 'relationships.trevor.fears_you', op: 'add', value: 2 }],
},
{
id: 'forgive',
label: 'Tell him to forget it.',
result: 'He does not forget it, but he does stop bringing you coffee wrong.',
effects: [
{ path: 'flags.covered_for_trevor', op: 'set', value: false },
{ path: 'relationships.trevor.likes_you', op: 'add', value: 7 },
],
},
],
},
{
id: 'marlene_confides',
stage: 'mailroom',
weight: 50,
cooldown: 8,
requires: [{ path: 'relationships.marlene.likes_you', op: '>=', value: 65 }],
description:
'Marlene tells you, without preamble, that she was told nineteen years ago ' +
'that the mailroom was a two-year posting. She then asks whether the ' +
'third-floor bins have gone up.',
options: [
{
id: 'ask_about_it',
label: 'Ask her about it.',
result: 'She talks for a while. She has not told this to Trevor.',
effects: [
{ path: 'skills.negotiation', op: 'add', value: 2 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: 5 },
{ path: 'relationships.marlene.wants_to_help_you', op: 'add', value: 6 },
{
path: 'notable',
op: 'push',
value: 'Marlene told you about the two-year posting.',
},
],
},
{
id: 'answer_the_bins',
label: 'Tell her the bins have gone up.',
result: 'She writes it down. The subject is closed with visible relief.',
effects: [
{ path: 'skills.logistics', op: 'add', value: 1 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: 2 },
],
},
{
id: 'ask_how_to_leave',
label: 'Ask her how to get out of the mailroom.',
requires: [{ path: 'skills.negotiation', op: '>=', value: 15 }],
result:
'She gives you three names and a piece of advice she says she wishes ' +
'someone had given her, which is how you know it is good.',
effects: [
{ path: 'skills.negotiation', op: 'add', value: 4 },
{ path: 'reputation', op: 'add', value: 2 },
{ path: 'relationships.marlene.wants_to_help_you', op: 'add', value: 5 },
],
},
],
},
];
export default people;
@@ -0,0 +1,87 @@
// Leaving the mailroom.
//
// The offer is an ordinary event whose effects happen to include
// `{ path: 'stage', op: 'set', value: 'dispatch' }` — the engine has no notion
// of a promotion, only of a stage that determines which events are in the pool.
//
// Declining is a real option, not a soft no. It comes back after a cooldown,
// and the brainstorm is explicit that staying lower to build skills or
// relationships should be a strategy rather than a mistake.
export const promotion = [
{
id: 'dispatch_offer',
stage: 'mailroom',
weight: 800,
cooldown: 12,
// Deliberately a long way in. The mailroom is where most of this pack
// lives, and a promotion at turn 25 would skip it — the offer should read
// as the end of a career there, not an early exit from it.
requires: [
{ path: 'reputation', op: '>=', value: 72 },
{ path: 'turn', op: '>=', value: 40 },
// Two ways to be in the room: somebody upstairs wants to help you, or
// you have got good enough at asking.
{
any: [
{ path: 'relationships.marlene.wants_to_help_you', op: '>=', value: 68 },
{ path: 'skills.negotiation', op: '>=', value: 42 },
],
},
],
description:
'The Dispatch post is real, it is open, and Marlene has put your name on ' +
'a list of one. She says the coordinator job is mostly the same work with ' +
'a different phone. She does not say it is more money, because she assumes ' +
'you can read.',
options: [
{
id: 'accept',
label: 'Take it.',
result:
'You are given a desk with a window in it. Trevor helps you carry ' +
'your things up and does not say anything the whole way.',
effects: [
{ path: 'stage', op: 'set', value: 'dispatch' },
{ path: 'flags.promoted', op: 'set', value: true },
{ path: 'reputation', op: 'add', value: 5 },
{ path: 'relationships.trevor.likes_you', op: 'add', value: -12 },
{ path: 'relationships.trevor.fears_you', op: 'add', value: 6 },
{ path: 'notable', op: 'push', value: 'Took the Dispatch post.' },
],
},
{
id: 'accept_and_recommend',
label: 'Take it, and put Trevor up for the mailroom.',
requires: [{ path: 'relationships.trevor.likes_you', op: '>=', value: 50 }],
result:
'Marlene raises an eyebrow and writes his name down. Trevor finds out ' +
'from the notice board and is unbearable about it for a week.',
effects: [
{ path: 'stage', op: 'set', value: 'dispatch' },
{ path: 'flags.promoted', op: 'set', value: true },
{ path: 'flags.recommended_trevor', op: 'set', value: true },
{ path: 'reputation', op: 'add', value: 3 },
{ path: 'relationships.trevor.likes_you', op: 'add', value: 12 },
{ path: 'relationships.trevor.wants_to_help_you', op: 'add', value: 8 },
{ path: 'notable', op: 'push', value: 'Took Dispatch, and put Trevor up for the mailroom.' },
],
},
{
id: 'decline',
label: 'Not yet.',
result:
'Marlene takes the list back. She has been on a list of one before ' +
'and does not ask why.',
effects: [
{ path: 'skills.negotiation', op: 'add', value: 2 },
{ path: 'reputation', op: 'add', value: -2 },
{ path: 'relationships.marlene.wants_to_help_you', op: 'add', value: -6 },
{ path: 'notable', op: 'push', value: 'Turned down Dispatch.' },
],
},
],
},
];
export default promotion;
+436
View File
@@ -0,0 +1,436 @@
// Ordinary days in the mailroom.
//
// The first three carry no conditions, no cooldown and no `once`, which makes
// them the stage's floor: whatever else is excluded, there is always a turn to
// play. Everything else in the pack leans on that.
export const routine = [
{
id: 'first_day',
stage: 'mailroom',
weight: 99999,
once: true,
description:
'Marlene Hobb hands you a laminated card with the org chart on it. Your ' +
'name is not on the card. She says it will be, eventually, in pen.',
options: [
{
id: 'listen',
label: 'Ask her to walk you through it.',
result: 'She walks you through it. Then she walks you through it again.',
effects: [
{ path: 'skills.negotiation', op: 'add', value: 2 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: 4 },
{ path: 'notable', op: 'push', value: 'Let Marlene explain the org chart. Twice.' },
],
},
{
id: 'wing_it',
label: "Say you'll pick it up as you go.",
result: 'She puts the card back in her pocket without comment.',
effects: [
{ path: 'skills.logistics', op: 'add', value: 2 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: -2 },
],
},
{
id: 'ask_trevor',
label: 'Ask Trevor where you actually sit.',
result:
'He shows you a desk, then mentions he has been here two weeks longer ' +
'than you. This will not be the last time.',
effects: [
{ path: 'relationships.trevor.likes_you', op: 'add', value: 3 },
{ path: 'relationships.trevor.wants_to_help_you', op: 'add', value: 2 },
],
},
],
},
{
id: 'mail_cart',
stage: 'mailroom',
weight: 100,
description:
"The mail cart's left front wheel has developed a shriek. Facilities has " +
'classified it as "cosmetic."',
options: [
{
id: 'push',
label: "Push it anyway. Everyone will know you're coming.",
result: 'By Thursday, two floors can time their morning by you.',
effects: [
{ path: 'skills.logistics', op: 'add', value: 1 },
{ path: 'reputation', op: 'add', value: -2 },
],
},
{
id: 'requisition',
label: 'Spend the morning on a requisition form.',
result:
'The form requires the cart\'s asset number. The asset number is on the ' +
'wheel. You fill it in from underneath.',
effects: [
{ path: 'skills.negotiation', op: 'add', value: 2 },
{ path: 'skills.logistics', op: 'add', value: 1 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: 1 },
],
},
{
id: 'swap',
label: 'Swap it with the good cart from the third floor.',
result: 'The third floor has no way of proving anything. They try anyway.',
effects: [
{ path: 'skills.logistics', op: 'add', value: 3 },
{ path: 'reputation', op: 'add', value: -3 },
{ path: 'relationships.trevor.fears_you', op: 'add', value: 3 },
],
},
],
},
{
id: 'sorting_backlog',
stage: 'mailroom',
weight: 100,
description:
'Two bins came in overnight and neither is labelled. Trevor has decided ' +
'this is a good day to reorganise his desk.',
options: [
{
id: 'grind',
label: 'Sort both bins yourself.',
result: 'It takes until four. Marlene notices, which is the point.',
effects: [
{ path: 'skills.logistics', op: 'add', value: 3 },
{ path: 'reputation', op: 'add', value: 2 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: 3 },
// Trevor's bin got sorted too, and he is not too proud to notice.
{ path: 'relationships.trevor.likes_you', op: 'add', value: 2 },
],
},
{
id: 'split',
label: 'Ask Trevor to take one.',
requires: [{ path: 'skills.negotiation', op: '>=', value: 15 }],
result: 'He takes one. He takes the lighter one, but he takes one.',
effects: [
{ path: 'skills.negotiation', op: 'add', value: 2 },
{ path: 'skills.logistics', op: 'add', value: 1 },
{ path: 'relationships.trevor.likes_you', op: 'add', value: -2 },
],
},
{
id: 'halfway',
label: 'Sort what fits before lunch and leave the rest.',
result: 'The rest is still there on Monday, and so are you.',
effects: [
{ path: 'reputation', op: 'add', value: -2 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: -2 },
],
},
],
},
{
id: 'franking_machine',
stage: 'mailroom',
weight: 70,
description:
'The franking machine is making a noise it has not made before. The manual ' +
'is in a drawer that is locked, and the key is a subject nobody will be ' +
'drawn on.',
options: [
{
id: 'fix',
label: 'Take the panel off and have a look.',
requires: [{ path: 'skills.logistics', op: '>=', value: 20 }],
result: 'It was a spring. It is now a spring in the right place.',
effects: [
{ path: 'skills.logistics', op: 'add', value: 3 },
{ path: 'reputation', op: 'add', value: 4 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: 3 },
{
path: 'notable',
op: 'push',
value: 'Fixed the franking machine, which nobody had managed since 2019.',
},
],
},
{
id: 'cycle',
label: 'Turn it off and on.',
result: 'The noise stops. Nothing has been learned by anyone.',
effects: [{ path: 'skills.logistics', op: 'add', value: 1 }],
},
{
id: 'escalate',
label: 'Log it with Facilities and wait.',
result: 'Facilities classifies it as cosmetic.',
effects: [
{ path: 'skills.negotiation', op: 'add', value: 1 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: -1 },
],
},
],
},
{
id: 'birthday_card',
stage: 'mailroom',
weight: 45,
cooldown: 5,
description:
'A card is going round for someone on the fourth floor. You have never met ' +
'them. The envelope already has two twenties in it and your name is next ' +
'on the list.',
options: [
{
id: 'contribute',
label: 'Put something in.',
result: 'You are now a person who puts something in.',
effects: [
{ path: 'money', op: 'add', value: -20 },
{ path: 'reputation', op: 'add', value: 3 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: 2 },
{ path: 'relationships.trevor.likes_you', op: 'add', value: 2 },
],
},
{
id: 'sign_only',
label: 'Sign it and pass it on.',
result: 'You write "all the best" and immediately regret the phrasing.',
effects: [{ path: 'reputation', op: 'add', value: -1 }],
},
{
id: 'pass',
label: 'Pass it on unsigned.',
result: 'Trevor checks the list. Trevor notices the list.',
effects: [
{ path: 'reputation', op: 'add', value: -3 },
{ path: 'relationships.trevor.likes_you', op: 'add', value: -2 },
],
},
],
},
{
id: 'elevator',
stage: 'mailroom',
weight: 30,
cooldown: 10,
description:
'You are alone in the elevator with a man whose name is on the quarterly. ' +
'He looks at your cart. He asks how long the mail takes to get to eleven.',
options: [
{
id: 'honest',
label: 'Tell him the truth: forty minutes, on a good day.',
result: 'He says "forty" once, to himself, and gets out on nine.',
effects: [
{ path: 'reputation', op: 'add', value: 5 },
{ path: 'skills.negotiation', op: 'add', value: 2 },
{
path: 'notable',
op: 'push',
value: 'Told an executive the truth about the eleventh floor.',
},
],
},
{
id: 'optimistic',
label: 'Tell him twenty.',
result: 'He seems pleased. Somebody, eventually, will have to make it twenty.',
effects: [
{ path: 'reputation', op: 'add', value: -3 },
{ path: 'skills.negotiation', op: 'add', value: 1 },
],
},
{
id: 'unsure',
label: "Say you're not sure.",
result: 'The rest of the ride is four floors long.',
effects: [{ path: 'reputation', op: 'add', value: -1 }],
},
],
},
{
id: 'overtime_week',
stage: 'mailroom',
weight: 40,
cooldown: 7,
description:
'There is a weekend inventory. It pays. Marlene mentions it in the tone of ' +
'someone who has been told to mention it.',
options: [
{
id: 'take',
label: 'Take the shift.',
result: 'Two days of counting things that were already counted.',
effects: [
{ path: 'money', op: 'add', value: 180 },
{ path: 'skills.logistics', op: 'add', value: 2 },
{ path: 'reputation', op: 'add', value: 2 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: 2 },
],
},
{
id: 'negotiate',
label: 'Take it, for a bit more.',
requires: [{ path: 'skills.negotiation', op: '>=', value: 30 }],
result:
'Marlene does not enjoy the conversation, but she does have the ' +
'authority, and you both find that out at the same time.',
effects: [
{ path: 'money', op: 'add', value: 260 },
{ path: 'skills.negotiation', op: 'add', value: 3 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: -1 },
{
path: 'notable',
op: 'push',
value: 'Negotiated a better rate for the weekend inventory.',
},
],
},
{
id: 'decline',
label: 'Pass.',
result: 'You have a weekend. It is the only one anyone gets.',
effects: [],
},
],
},
{
id: 'promotion_rumor',
stage: 'mailroom',
weight: 25,
cooldown: 12,
requires: [{ path: 'turn', op: '>=', value: 30 }],
description:
'There is a post going in Dispatch. Trevor has heard about it. Trevor ' +
'mentions that he has heard about it.',
options: [
{
id: 'ask_marlene',
label: 'Ask Marlene to put your name forward.',
requires: [{ path: 'relationships.marlene.wants_to_help_you', op: '>=', value: 60 }],
result: 'She says she will see. From Marlene this is close to a contract.',
effects: [
{ path: 'reputation', op: 'add', value: 5 },
{ path: 'relationships.marlene.wants_to_help_you', op: 'add', value: -5 },
{
path: 'notable',
op: 'push',
value: 'Asked Marlene to put your name forward for Dispatch.',
},
],
},
{
id: 'ask_around',
label: 'Ask around on your own.',
result: 'You learn the post has been open for eleven months.',
effects: [
{ path: 'skills.negotiation', op: 'add', value: 3 },
{ path: 'reputation', op: 'add', value: 2 },
],
},
{
id: 'ignore',
label: 'Stay where you are for now.',
result: 'Trevor mentions it again on Thursday.',
effects: [],
},
],
},
{
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;
+313
View File
@@ -0,0 +1,313 @@
// Corporate Ladder — the default content pack for The Ladder.
//
// A modern office, the mailroom, and two people who have been there longer
// than you. Everything the engine needs to run this setting is in this file
// and the ones it imports; the engine itself knows nothing about mailrooms.
import { characters } from './characters.js';
import { events } from './events/index.js';
export const corporateLadder = {
id: 'corporateladder',
name: 'Corporate Ladder',
// One sentence for the setting picker. The interface reads it and knows
// nothing else about what this pack is.
blurb:
'A modern office and a mailroom, two people who have been here longer ' +
'than you, and a laminated org chart with your name not on it.',
version: '0.1.1',
turnUnit: 'day',
startingStage: 'mailroom',
// What the interface calls the panel of people. The UI used to hardcode
// "The mailroom" here, which was the one place it named a setting.
peopleHeading: 'The mailroom',
stages: {
mailroom: { label: 'Mailroom', title: 'Mail Associate I' },
dispatch: { label: 'Dispatch', title: 'Dispatch Coordinator' },
},
// 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: 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 },
'skills.logistics': { initial: 5, min: 0, max: 100 },
// What standing costs to keep. Nothing forces a player to climb this, but
// Dispatch charges standing for staying at the bottom of it — see the
// "Looking the part" entries in upkeep, and DECISIONS #36.
'lifestyle': { initial: 0, min: 0, max: 2 },
'flags.took_loan': { initial: false },
'flags.holds_the_envelope': { initial: false },
'flags.covered_for_trevor': { initial: false },
'flags.promoted': { initial: false },
'flags.recommended_trevor': { initial: false },
// Moments the retrospective reads back. Content decides what is memorable;
// the full turn-by-turn record lives in history regardless.
'notable': { initial: [] },
},
// 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: { 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 },
},
// Money moves on a schedule the player can feel: paid on alternate Fridays,
// rent out every Monday, and only small change in between.
//
// Keep this arithmetic honest when tuning: wages, minus two rents, minus
// fourteen days of small change. Per week that is 560 - 455 - 126 = -21, so
// about forty dollars down over a fortnight — a drift, not a bleed. Rent was
// once raised without re-deriving it, which quietly made the baseline four
// times steeper and trapped a real playtester below zero for 30% of a
// 182-turn run. The range is asserted from `tuning` below.
upkeep: [
{ 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.
// Wages depend on the job. Two entries rather than one, gated on stage:
// the engine has no idea what a promotion is, and does not need to.
{
label: 'Wages',
every: 14,
offset: 4,
requires: [{ path: 'stage', op: '==', value: 'mailroom' }],
effects: [{ path: 'money', op: 'add', value: 1120 }],
},
{
label: 'Wages',
every: 14,
offset: 4,
// A real raise, but not a different life — the coordinator job is, as
// Marlene puts it, mostly the same work with a different phone. Net of
// rent it comes to a few dollars a day ahead instead of behind.
requires: [{ path: 'stage', op: '==', value: 'dispatch' }],
effects: [{ path: 'money', op: 'add', value: 1260 }],
},
{ label: 'Rent', every: 7, offset: 7, effects: [{ path: 'money', op: 'add', value: -455 }] },
// What the job expects of you, and what meeting that expectation costs.
// Dispatch nets +49 a week; a coordinator with the car is +19, and one
// with the car and the address is -21 and has to earn the difference out
// of the days themselves. That is the answer to "money stops mattering
// late": it stops mattering only if standing is free to keep.
{
label: 'Car payment and parking',
every: 7,
offset: 7,
requires: [{ path: 'lifestyle', op: '>=', value: 1 }],
effects: [{ path: 'money', op: 'add', value: -30 }],
},
{
label: 'The better address',
every: 7,
offset: 7,
requires: [{ path: 'lifestyle', op: '>=', value: 2 }],
effects: [{ path: 'money', op: 'add', value: -40 }],
},
// The pressure that makes the spending a choice rather than a vanity. Both
// are gated on Dispatch: the mailroom pays too little for this to be
// anything but a trap, and "later in a career" is the point.
{
label: 'Looking the part',
every: 7,
offset: 7,
requires: [
{ path: 'stage', op: '==', value: 'dispatch' },
{ path: 'lifestyle', op: '<', value: 1 },
],
effects: [{ path: 'reputation', op: 'add', value: -4 }],
},
{
// Standing above sixty-five is a thing you are seen to be, and being seen
// to be it runs on a better address than a coordinator's first one.
//
// Sized by measurement, not by feel. At -2 a week this was invisible:
// Dispatch hands out standing several points at a time, so a coordinator
// who bought nothing still finished pinned at 100 and the charge may as
// well not have existed. At -6, with the entry above, a non-buyer settles
// in the low seventies while somebody keeping up finishes at 100 — the
// ceiling is real and money is what lifts it.
label: 'Looking the part',
every: 7,
offset: 7,
requires: [
{ path: 'stage', op: '==', value: 'dispatch' },
{ path: 'reputation', op: '>=', value: 65 },
{ path: 'lifestyle', op: '<', value: 2 },
],
effects: [{ path: 'reputation', op: 'add', value: -6 }],
},
{
// The top step, and the one that makes the whole thing a choice rather
// than a tax on carelessness. Without it, a player who spends every turn
// chasing standing simply out-earns the charge: measured, they held 99
// while never buying anything, and finished $2,245 richer than a player
// who kept up — so the lifestyle was a pure loss to anybody paying
// attention. With it they plateau in the low eighties. What money buys
// up here is the last fifteen points, which cannot be worked for.
label: 'Looking the part',
every: 7,
offset: 7,
requires: [
{ path: 'stage', op: '==', value: 'dispatch' },
{ path: 'reputation', op: '>=', value: 80 },
{ path: 'lifestyle', op: '<', value: 2 },
],
effects: [{ path: 'reputation', op: 'add', value: -14 }],
},
// The charge that never stops, and the only one with no purchase that ends
// it. Everything above is a ladder with a top: buy both tiers, reach 100
// standing, and every charge switches off. A real 96-turn session arrived
// there at turn 84 with nothing left to spend on and a balance that only
// climbed.
//
// It steps on money rather than being flat, and that is the whole design.
// A flat charge cannot flatten a slope: late income comes from choices and
// scales with how well the player is playing, so a charge big enough to
// stop an optimiser accumulating (-360 a week, measured) put ordinary
// archetypes below zero for 44% of a run and ended them $1,385 in debt.
// Stepping on money lands it on whoever has it. Measured: the balance
// settles around $4,250 and stops climbing (-3 a week), while ordinary play
// gets *better* than before it existed — 13% of a run below zero against
// 17%, and a tier-2 run ending +$175 rather than -$25.
{
label: 'What the standing costs',
every: 7,
offset: 7,
requires: [
{ path: 'stage', op: '==', value: 'dispatch' },
{ path: 'reputation', op: '>=', value: 85 },
{ path: 'money', op: '>=', value: 1200 },
],
effects: [{ path: 'money', op: 'add', value: -60 }],
},
{
label: 'What the standing costs',
every: 7,
offset: 7,
requires: [
{ path: 'stage', op: '==', value: 'dispatch' },
{ path: 'reputation', op: '>=', value: 85 },
{ path: 'money', op: '>=', value: 2400 },
],
effects: [{ path: 'money', op: 'add', value: -120 }],
},
{
label: 'What the standing costs',
every: 7,
offset: 7,
requires: [
{ path: 'stage', op: '==', value: 'dispatch' },
{ path: 'reputation', op: '>=', value: 85 },
{ path: 'money', op: '>=', value: 3600 },
],
effects: [{ path: 'money', op: 'add', value: -240 }],
},
// 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: -70 }],
},
{
label: 'Loan service charge (arrears)',
every: 7,
offset: 7,
requires: [{ path: 'debt', op: '>=', value: 1000 }],
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: -70 }],
},
],
// What the conformance suite holds this pack to. The numbers a setting must
// hit are the setting's business, so they are declared here rather than
// written into a test that names this pack. See DECISIONS.md #31.
tuning: {
// Net movement of `money` from upkeep alone, per calendar cycle — here a
// week — at each stage, with no debt outstanding.
baselinePerCycle: {
mailroom: [-45, 5],
dispatch: [5, 125],
},
// Upkeep gated on these is excluded from that baseline: a debt taken on, a
// lifestyle bought, a standing earned. The baseline is the floor underneath
// everybody — what a player faces having none of the three.
baselineExcludes: ['debt', 'lifestyle', 'reputation'],
},
// 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 },
// Hidden until it is bought: a mailroom associate has no lifestyle to
// speak of, and a row reading "The bus" from day one is noise.
'lifestyle': {
label: 'Lifestyle', format: 'tier', order: 6, hideWhenZero: true,
labels: ['The bus', 'A car', 'The address'],
},
// Kept out of the header, and out of the ledger too: `notable` is the
// retrospective's own list, and "Notable — changed" tells nobody anything.
'notable': { hidden: true, inLedger: false },
// Flags do belong in the ledger — a flag flipping is a fact about the
// world — but as a sentence rather than as "now true".
'flags.took_loan': {
label: 'The loan', hidden: true,
whenTrue: 'You are carrying a loan now.',
whenFalse: 'The loan is settled.',
},
'flags.holds_the_envelope': {
label: 'The envelope', hidden: true,
whenTrue: 'The envelope is in your locker.',
whenFalse: 'The envelope is back in the system.',
},
'flags.covered_for_trevor': {
label: "Trevor's Thursday", hidden: true,
whenTrue: 'Trevor owes you a Thursday.',
whenFalse: 'You and Trevor are square.',
},
'flags.promoted': {
label: 'Promotion', hidden: true,
whenTrue: 'You are out of the mailroom.',
whenFalse: 'Back in the mailroom.',
},
'flags.recommended_trevor': {
label: "Trevor's recommendation", hidden: true,
whenTrue: "Trevor's name is in for the mailroom.",
whenFalse: "Trevor's name is not in for the mailroom.",
},
},
characters,
events,
};
export default corporateLadder;
+122
View File
@@ -0,0 +1,122 @@
// Harrow & Pike's counting house, as it might be staffed.
//
// Nobody here is fixed. Each role has several people who could fill it, and a
// run draws one partner, one chief clerk and two to four clerks from them
// (DECISIONS #41), so a second career in the same pack meets a different
// office. Every candidate carries the same four attributes the player does,
// and a description that should agree with them: a partner written as sharp
// has the wits to see through a bluff, and one written as easily flattered
// does not.
//
// Descriptions may name another role in braces, filled from whoever was drawn
// into it, so a nephew can be the nephew of whichever partner this run has.
export const characters = [
{
id: 'partner',
role_type: 'boss',
title: 'Senior Partner',
candidates: [
{
name: 'Josiah Crane',
description:
'Built the house from one room and a borrowed desk. Remembers every figure ' +
'he has ever been shown, and forgives very few of them.',
attributes: { nerve: 7, wits: 8, charm: 3, resolve: 8 },
startingRelationship: { trust: 40 },
},
{
name: 'Harriet Voss',
description:
'Took over the firm when her husband died and has doubled it since. ' +
'Listens to everything and says about a fifth of it.',
attributes: { nerve: 6, wits: 9, charm: 5, resolve: 7 },
},
{
name: 'Lionel Ashby',
description:
'Inherited the chair and would rather be at his club. Easily flattered, ' +
'easily bored, and sharper than he lets anyone see.',
attributes: { nerve: 4, wits: 6, charm: 7, resolve: 3 },
startingRelationship: { regard: 55 },
},
],
},
{
id: 'chief',
role_type: 'supervisor',
title: 'Chief Clerk',
candidates: [
{
name: 'Silas Pruett',
description:
'Thirty years at the same high desk. Counts the pen nibs on Fridays and ' +
'knows to the penny what the stationery cupboard should hold.',
attributes: { nerve: 5, wits: 6, charm: 2, resolve: 9 },
},
{
name: 'Ada Kemble',
description:
'Runs the floor with a pencil behind her ear and a list, kept in the ' +
'same pencil, of everybody\'s lapses.',
attributes: { nerve: 6, wits: 7, charm: 4, resolve: 7 },
startingRelationship: { trust: 40 },
},
{
name: 'Horace Lumb',
description:
'Wanted your desk for somebody else\'s nephew and has not forgotten it. ' +
'Polite to your face, in a way that takes some effort.',
attributes: { nerve: 4, wits: 5, charm: 5, resolve: 6 },
startingRelationship: { regard: 35 },
},
],
},
{
id: 'clerk',
role_type: 'peer',
title: 'Clerk',
count: [2, 4],
candidates: [
{
name: 'Tom Bassett',
description: 'Cheerful, generous with other people\'s time, and a borrower of small sums he means to repay.',
attributes: { nerve: 5, wits: 4, charm: 7, resolve: 3 },
},
{
name: 'Nell Farrow',
description: 'The quickest pen on the floor and does not mind who knows it. Wants the next desk up, and soon.',
attributes: { nerve: 5, wits: 8, charm: 5, resolve: 6 },
startingRelationship: { regard: 40 },
},
{
name: 'Arthur Quill',
description: 'Timid, exact, and never wrong about a column of figures. Apologises to doors.',
attributes: { nerve: 2, wits: 7, charm: 3, resolve: 7 },
},
{
name: 'Joe Madden',
description: 'The loudest laugh in the building and most of the jokes, not all of them kind.',
attributes: { nerve: 7, wits: 4, charm: 6, resolve: 3 },
},
{
name: 'Lottie Swain',
description: 'Types the partner\'s letters and so knows everything a week early. Tells almost none of it.',
attributes: { nerve: 4, wits: 7, charm: 7, resolve: 5 },
},
{
name: 'Percy Dyer',
description: 'A nephew of {partner}\'s, sent to learn the business. Has so far learned where the kettle is.',
attributes: { nerve: 3, wits: 3, charm: 6, resolve: 2 },
startingRelationship: { trust: 40 },
},
{
name: 'Walt Rudge',
description: 'Twenty years a clerk and content with it. The one everyone asks, and the last to be thanked.',
attributes: { nerve: 6, wits: 6, charm: 4, resolve: 8 },
},
],
},
];
export default characters;
+882
View File
@@ -0,0 +1,882 @@
// A clerk's days at Harrow & Pike.
//
// Every situation here comes from the story-to-pack catalogue (tools/
// story-to-pack/probe/catalogue/v3), where it recurred across public-domain
// office fiction: Torchy's door, Keefe's pay talk, Pooter's cheeky juniors,
// Lorimer's hard boss. The id names the catalogue entry where one fits.
//
// This pack exists to try checks and a drawn cast (DECISIONS #41). Most options
// test one of the four attributes, against a named person or against a set
// difficulty, and say what happens either way; the player sees only the odds
// in words. Names are written as `{partner}`, `{chief}` and `{clerk}` and filled
// from whoever this run drew. An event about "a clerk" lists `clerk` in
// `involves`, and the engine picks one present clerk for the turn.
const moment = (text) => ({ path: 'notable', op: 'push', value: text });
export const events = [
{
id: 'first_day',
stage: 'desk',
weight: 1000,
once: true,
involves: ['clerk'],
description:
'Your first morning at Harrow & Pike. {chief} shows you a stool, a pen, a ' +
'blotter and the clock, in that order, and leaves you to draw your own ' +
'conclusions about which matters most.',
options: [
{
id: 'before_the_clock',
label: 'Be on the stool before the clock is.',
result: '{chief} notices, and writes something down. It may even be good.',
effects: [
{ path: 'relationships.chief.regard', op: 'add', value: 4 },
moment('Began at Harrow & Pike, early.'),
],
},
{
id: 'meet_the_floor',
label: 'Go round and introduce yourself to {clerk}.',
result: '{clerk} shakes your hand and tells you which of the pens leak.',
effects: [
{ path: 'relationships.{clerk}.regard', op: 'add', value: 5 },
moment('Began at Harrow & Pike, and met {clerk} first.'),
],
},
],
},
// The floor: the one event nothing can exclude.
{
id: 'the_ledgers',
stage: 'desk',
weight: 40,
description:
'The ledgers are the same as last week\'s, only longer. {chief} has left a ' +
'note on your blotter about the width of your margins.',
options: [
{
id: 'head_down',
label: 'Keep your head down and work.',
result: 'The day goes into the columns, as days here do.',
effects: [
{ path: 'standing', op: 'add', value: 1 },
{ path: 'relationships.chief.trust', op: 'add', value: 1 },
],
},
{
id: 'stay_late',
label: 'Stay on after six to finish the day book.',
result: 'Overtime is paid, at the rate the firm thinks overtime is worth.',
effects: [
{ path: 'money', op: 'add', value: 2 },
{ path: 'relationships.chief.regard', op: 'add', value: 1 },
],
},
{
id: 'ease_off',
label: 'Make the day last.',
result: 'Nobody notices, today.',
effects: [{ path: 'standing', op: 'add', value: -1 }],
},
],
},
{
id: 'ask_for_more',
stage: 'desk',
weight: 25,
cooldown: 12,
requires: [
{ path: 'turn', op: '>=', value: 10 },
{ path: 'rises', op: '<', value: 3 },
],
description:
'You have been at the desk long enough to know what the work is worth, and ' +
'your lodgings have not got any cheaper. {partner} is in, and alone.',
options: [
{
id: 'ask',
label: 'Knock, and ask {partner} for a rise.',
check: { attribute: 'charm', against: 'partner', opposedBy: 'wits', relationship: 'trust' },
success: {
result: '{partner} looks at you for a long moment and says the firm can manage something.',
effects: [
{ path: 'rises', op: 'add', value: 1 },
{ path: 'standing', op: 'add', value: 3 },
moment('Asked {partner} for a rise, and got it.'),
],
},
failure: {
result: '{partner} asks whether you have any idea what a clerk is worth. You find you do not.',
effects: [
{ path: 'relationships.partner.trust', op: 'add', value: -5 },
{ path: 'standing', op: 'add', value: -2 },
],
},
},
{
id: 'hint',
label: 'Say nothing directly; mention the price of lodgings.',
check: { attribute: 'wits', difficulty: 5 },
success: {
result: '{partner} takes the point and, better, thinks it was a delicate one.',
effects: [{ path: 'relationships.partner.regard', op: 'add', value: 4 }],
},
failure: {
result: '{partner} agrees that lodgings are very dear and goes back to the post.',
effects: [],
},
},
{
id: 'not_yet',
label: 'Wait for a better moment.',
result: 'There will be one. There usually is.',
effects: [],
},
],
},
{
id: 'called_in',
stage: 'desk',
weight: 30,
cooldown: 8,
involves: ['clerk'],
description:
'{chief} has found a column that does not add up, and it is in your hand. You ' +
'are to explain it, standing, in front of the high desk.',
options: [
{
id: 'own_up',
label: 'Say plainly that it was your mistake.',
check: { attribute: 'resolve', difficulty: 4 },
success: {
result: 'It is noted, and then, which is rarer, it is let go.',
effects: [
{ path: 'relationships.partner.trust', op: 'add', value: 4 },
{ path: 'attributes.resolve', op: 'add', value: 1 },
],
},
failure: {
result: 'You start well and finish apologising for the weather.',
effects: [{ path: 'standing', op: 'add', value: -2 }],
},
},
{
id: 'argue',
label: 'Argue that the figures were right.',
check: { attribute: 'wits', against: 'chief' },
success: {
result: 'They were. {chief} checks twice and hands the page back without a word.',
effects: [
{ path: 'standing', op: 'add', value: 3 },
{ path: 'relationships.chief.regard', op: 'add', value: -4 },
],
},
failure: {
result: 'They were not, and now everybody knows you thought they were.',
effects: [
{ path: 'standing', op: 'add', value: -4 },
{ path: 'relationships.partner.trust', op: 'add', value: -3 },
],
},
},
{
id: 'blame',
label: 'Say the figures came from {clerk}\'s desk.',
check: { attribute: 'wits', against: 'chief' },
success: {
result: '{chief} goes to find {clerk}. You do not watch.',
effects: [
{ path: 'standing', op: 'add', value: 1 },
{ path: 'relationships.{clerk}.trust', op: 'add', value: -8 },
],
},
failure: {
result: '{chief} has the day book open at your page before you finish the sentence.',
effects: [
{ path: 'standing', op: 'add', value: -5 },
{ path: 'relationships.{clerk}.trust', op: 'add', value: -10 },
{ path: 'relationships.partner.trust', op: 'add', value: -5 },
moment('Tried to put a mistake on {clerk}, and was found out.'),
],
},
},
],
},
{
id: 'the_door',
stage: 'desk',
weight: 30,
cooldown: 6,
description:
'A caller in a good coat wants {partner} and will not give his name. You ' +
'were told the partner was not to be disturbed before three.',
options: [
{
id: 'turn_away',
label: 'Tell him the partner is out.',
check: { attribute: 'nerve', difficulty: 5 },
success: {
result: 'He goes. {partner} hears of it later and approves of the lie.',
effects: [{ path: 'relationships.partner.trust', op: 'add', value: 3 }],
},
failure: {
result: 'He sits down to wait, and {partner} comes out at a quarter past and finds him there.',
effects: [
{ path: 'relationships.partner.regard', op: 'add', value: -3 },
{ path: 'standing', op: 'add', value: -1 },
],
},
},
{
id: 'size_him_up',
label: 'Size him up and decide for yourself.',
check: { attribute: 'wits', difficulty: 6 },
success: {
result: 'You read him right. He mattered, and he got in, and {partner} knows who let him.',
effects: [
{ path: 'relationships.partner.trust', op: 'add', value: 5 },
{ path: 'standing', op: 'add', value: 2 },
moment('Judged a caller at the door, and judged him right.'),
],
},
failure: {
result: 'He was selling encyclopaedias, and {partner} bought one to get rid of him.',
effects: [{ path: 'relationships.partner.trust', op: 'add', value: -4 }],
},
},
{
id: 'pass_it_on',
label: 'Fetch {chief} and let it be somebody else\'s decision.',
result: '{chief} deals with it, and with you, briefly.',
effects: [{ path: 'standing', op: 'add', value: -1 }],
},
],
},
{
id: 'needled',
stage: 'desk',
weight: 40,
cooldown: 4,
involves: ['clerk'],
description:
'{clerk} reads your last letter aloud in a voice meant to be yours, and the ' +
'floor laughs, and keeps laughing longer than it deserves.',
options: [
{
id: 'let_it_go',
label: 'Let it pass.',
result: 'It passes. They will remember that you let it.',
effects: [
{ path: 'relationships.{clerk}.regard', op: 'add', value: 1 },
{ path: 'standing', op: 'add', value: -1 },
],
},
{
id: 'retort',
label: 'Answer {clerk} back, with the floor listening.',
check: { attribute: 'wits', against: '{clerk}', relationship: 'regard' },
success: {
result: 'It gets a bigger laugh than the letter did, and {clerk} laughs too.',
effects: [
{ path: 'standing', op: 'add', value: 2 },
{ path: 'relationships.{clerk}.regard', op: 'add', value: 3 },
],
},
failure: {
result: 'It comes out too sharp, and the laugh turns on you.',
effects: [
{ path: 'standing', op: 'add', value: -2 },
{ path: 'relationships.{clerk}.regard', op: 'add', value: -3 },
],
},
},
{
id: 'report',
label: 'Mention it to {chief}.',
result: '{chief} has a word. {clerk} knows where it came from.',
effects: [
{ path: 'relationships.chief.trust', op: 'add', value: 2 },
{ path: 'relationships.{clerk}.trust', op: 'add', value: -6 },
{ path: 'standing', op: 'add', value: -1 },
],
},
],
},
{
id: 'red_ink',
stage: 'desk',
weight: 30,
cooldown: 6,
description:
'{chief} has gone over your morning\'s work in red ink, and left the page ' +
'open on the high desk where the whole floor can see it.',
options: [
{
id: 'swallow_it',
label: 'Take it, and do the page again.',
result: 'You do it again. {chief} initials it without looking up.',
effects: [
{ path: 'relationships.chief.regard', op: 'add', value: 2 },
{ path: 'standing', op: 'add', value: -1 },
],
},
{
id: 'answer_back',
label: 'Point out that the red ink is wrong.',
check: { attribute: 'wits', against: 'chief' },
success: {
result: 'It is, in two places. {chief} says nothing, which from {chief.first} is a concession.',
effects: [
{ path: 'standing', op: 'add', value: 2 },
{ path: 'relationships.chief.regard', op: 'add', value: 3 },
],
},
failure: {
result: 'The red ink is right. It usually is.',
effects: [
{ path: 'standing', op: 'add', value: -2 },
{ path: 'relationships.chief.regard', op: 'add', value: -5 },
],
},
},
{
id: 'over_the_head',
label: 'Take the page to {partner}.',
check: { attribute: 'charm', against: 'partner', opposedBy: 'resolve', relationship: 'trust' },
success: {
result: '{partner} reads it, and has a word with {chief}, and {chief} has one with you.',
effects: [
{ path: 'relationships.partner.trust', op: 'add', value: 3 },
{ path: 'relationships.chief.regard', op: 'add', value: -8 },
{ path: 'standing', op: 'add', value: 2 },
moment('Went over {chief}\'s head, and it worked.'),
],
},
failure: {
result: '{partner} says the chief clerk runs the floor, and asks whether there was anything else.',
effects: [
{ path: 'relationships.partner.trust', op: 'add', value: -4 },
{ path: 'relationships.chief.regard', op: 'add', value: -8 },
{ path: 'standing', op: 'add', value: -3 },
],
},
},
],
},
{
id: 'sure_thing',
stage: 'desk',
weight: 15,
cooldown: 20,
description:
'A pleasant man at the chop-house has a share in a tramway in Brazil going ' +
'begging. Twelve dollars today, he says, will be forty by Christmas.',
options: [
{
id: 'buy_in',
label: 'Put in twelve dollars.',
effects: [{ path: 'money', op: 'add', value: -12 }],
check: { attribute: 'wits', difficulty: 7 },
success: {
result: 'Against every expectation, including his, it pays.',
effects: [{ path: 'money', op: 'add', value: 30 }, moment('Bought a share in a tramway, and it paid.')],
},
failure: {
result: 'There is no tramway. There is, briefly, a man.',
effects: [moment('Bought a share in a tramway that did not exist.')],
},
},
{
id: 'see_through',
label: 'Ask him the one question he cannot answer.',
check: { attribute: 'wits', difficulty: 6 },
success: {
result: 'He finds he has an appointment. You find you are sharper than you were.',
effects: [{ path: 'attributes.wits', op: 'add', value: 1 }, moment('Saw through a tramway in Brazil.')],
},
failure: {
result: 'He answers it smoothly, and you are left not knowing.',
effects: [],
},
},
{
id: 'decline',
label: 'Keep your money in your pocket.',
result: 'He is sorry for you, he says, and goes to find someone less careful.',
effects: [],
},
],
},
{
id: 'the_round',
stage: 'desk',
weight: 30,
cooldown: 5,
involves: ['clerk'],
description:
'{clerk} and the others are going to the chop-house after work, and they ' +
'have kept you a place. The round there is not cheap.',
options: [
{
id: 'go',
label: 'Go, and stand your round.',
result: 'A good evening, and a dear one.',
effects: [
{ path: 'money', op: 'add', value: -5 },
{ path: 'relationships.{clerk}.regard', op: 'add', value: 4 },
{ path: 'standing', op: 'add', value: 1 },
],
},
{
id: 'talk_your_way',
label: 'Go, and talk your way out of the round.',
check: { attribute: 'charm', against: '{clerk}', opposedBy: 'wits' },
success: {
result: 'Nobody notices you never reached for your purse, and {clerk} laughs at all your jokes.',
effects: [{ path: 'relationships.{clerk}.regard', op: 'add', value: 3 }],
},
failure: {
result: '{clerk} notices, and says so, and you pay for the round and the remark.',
effects: [
{ path: 'money', op: 'add', value: -5 },
{ path: 'relationships.{clerk}.regard', op: 'add', value: -2 },
],
},
},
{
id: 'excuse',
label: 'Make an excuse and go home.',
result: 'They go without you. They will ask again, perhaps.',
effects: [{ path: 'relationships.{clerk}.regard', op: 'add', value: -2 }],
},
],
},
{
id: 'night_school',
stage: 'desk',
weight: 25,
cooldown: 5,
requires: [{ path: 'money', op: '>=', value: 3 }],
description:
'A card in the tram window: evening classes in bookkeeping, in the art of ' +
'address, and at the boxing club behind the Institute. Three dollars a course.',
options: [
{
id: 'bookkeeping',
label: 'Take the bookkeeping course.',
result: 'Double entry, twice a week. Your columns begin to add up before you do.',
effects: [{ path: 'money', op: 'add', value: -3 }, { path: 'attributes.wits', op: 'add', value: 1 }],
},
{
id: 'address',
label: 'Take the art of address.',
result: 'You learn when to say nothing, which is most of it.',
effects: [{ path: 'money', op: 'add', value: -3 }, { path: 'attributes.charm', op: 'add', value: 1 }],
},
{
id: 'boxing',
label: 'Join the boxing club.',
result: 'You are hit in the face by a draper. It does you a world of good.',
effects: [{ path: 'money', op: 'add', value: -3 }, { path: 'attributes.nerve', op: 'add', value: 1 }],
},
{
id: 'not_this_term',
label: 'Not this term.',
result: 'The card is still there next week.',
effects: [],
},
],
},
{
id: 'stand_in',
stage: 'desk',
weight: 15,
cooldown: 10,
requires: [{ path: 'standing', op: '>=', value: 45 }],
description:
'{partner} is laid up and sends you, of all people, to sit in on the meeting ' +
'with the shipping agents. You are to listen, and to say as little as possible.',
options: [
{
id: 'listen',
label: 'Listen, and take good notes.',
result: 'Your notes are better than the minutes. {partner} keeps them.',
effects: [
{ path: 'relationships.partner.trust', op: 'add', value: 2 },
{ path: 'standing', op: 'add', value: 1 },
],
},
{
id: 'speak_up',
label: 'Say the thing you noticed in their figures.',
check: { attribute: 'wits', difficulty: 7 },
success: {
result: 'You were right, the agents know it, and so, by the afternoon, does {partner}.',
effects: [
{ path: 'relationships.partner.trust', op: 'add', value: 6 },
{ path: 'standing', op: 'add', value: 5 },
moment('Spoke up at the agents\' meeting in {partner}\'s place, and was right.'),
],
},
failure: {
result: 'You were wrong in front of people who were paid to be right.',
effects: [
{ path: 'relationships.partner.trust', op: 'add', value: -4 },
{ path: 'standing', op: 'add', value: -3 },
],
},
},
{
id: 'charm_them',
label: 'Charm the agents over lunch.',
check: { attribute: 'charm', difficulty: 6 },
success: {
result: 'They like you. They say so to {partner}, which is the point.',
effects: [
{ path: 'relationships.partner.regard', op: 'add', value: 5 },
{ path: 'standing', op: 'add', value: 3 },
],
},
failure: {
result: 'The lunch is long, and you pay for it.',
effects: [
{ path: 'money', op: 'add', value: -4 },
{ path: 'standing', op: 'add', value: -1 },
],
},
},
],
},
{
id: 'lost_bill',
stage: 'desk',
weight: 20,
cooldown: 12,
involves: ['clerk'],
description:
'{clerk} has lost a bill of lading, and {chief} is talking about a letter to ' +
'the partner. {clerk} has not looked up from the desk all morning.',
options: [
{
id: 'speak_for',
label: 'Put {clerk}\'s case to {partner} yourself.',
check: { attribute: 'charm', against: 'partner', opposedBy: 'resolve', relationship: 'trust' },
success: {
result: '{partner} hears you out and lets it go. {clerk} does not forget who spoke.',
effects: [
{ path: 'relationships.{clerk}.trust', op: 'add', value: 10 },
{ path: 'relationships.partner.regard', op: 'add', value: 2 },
moment('Spoke up for {clerk}, and it held.'),
],
},
failure: {
result: '{partner} asks whether you would like to share the letter.',
effects: [
{ path: 'relationships.partner.trust', op: 'add', value: -3 },
{ path: 'relationships.{clerk}.trust', op: 'add', value: 4 },
],
},
},
{
id: 'help_look',
label: 'Stay late and help look for it.',
check: { attribute: 'resolve', difficulty: 5 },
success: {
result: 'It is in the wrong drawer, under the right one. You find it at nine.',
effects: [
{ path: 'relationships.{clerk}.trust', op: 'add', value: 8 },
{ path: 'relationships.{clerk}.regard', op: 'add', value: 4 },
],
},
failure: {
result: 'It is not found, but {clerk} saw you look.',
effects: [{ path: 'relationships.{clerk}.regard', op: 'add', value: 2 }],
},
},
{
id: 'keep_out',
label: 'Keep out of it.',
result: 'The letter goes. So, very nearly, does {clerk}.',
effects: [{ path: 'relationships.{clerk}.trust', op: 'add', value: -3 }],
},
],
},
{
id: 'the_butt',
stage: 'desk',
weight: 20,
cooldown: 8,
involves: ['clerk'],
description:
'Somebody has pinned a card to your back, and you have walked the length of ' +
'the floor to {chief}\'s desk wearing it. {clerk} cannot keep a straight face.',
options: [
{
id: 'laugh_along',
label: 'Laugh louder than anybody.',
check: { attribute: 'charm', difficulty: 4 },
success: {
result: 'It becomes your joke instead of theirs.',
effects: [
{ path: 'standing', op: 'add', value: 1 },
{ path: 'relationships.{clerk}.regard', op: 'add', value: 3 },
],
},
failure: {
result: 'The laugh comes out wrong, and everyone hears that it did.',
effects: [{ path: 'standing', op: 'add', value: -2 }],
},
},
{
id: 'dignity',
label: 'Take it off and say nothing at all.',
check: { attribute: 'resolve', difficulty: 4 },
success: {
result: 'It is not funny for long, when nobody is laughing at the front.',
effects: [{ path: 'standing', op: 'add', value: 1 }],
},
failure: {
result: 'Your ears give you away.',
effects: [
{ path: 'standing', op: 'add', value: -1 },
{ path: 'relationships.{clerk}.regard', op: 'add', value: -2 },
],
},
},
{
id: 'get_even',
label: 'Resolve to do something about {clerk} tomorrow.',
result: 'You do. It is not as funny, and {clerk} knows exactly who it was.',
effects: [
{ path: 'relationships.{clerk}.regard', op: 'add', value: -2 },
{ path: 'relationships.{clerk}.trust', op: 'add', value: -3 },
],
},
],
},
{
id: 'saturday',
stage: 'desk',
weight: 60,
cooldown: 5,
requires: [{ path: 'calendar.name', op: '==', value: 'Saturday' }],
description:
'Saturday, and the office shuts at one. The pay envelope is in your inside ' +
'pocket, and the whole of the afternoon is in front of you.',
options: [
{
id: 'new_collar',
label: 'Buy the collar the senior clerks wear.',
result: 'It pinches, and it is noticed.',
effects: [
{ path: 'money', op: 'add', value: -5 },
{ path: 'standing', op: 'add', value: 2 },
],
},
{
id: 'good_dinner',
label: 'Dine somewhere with a tablecloth.',
result: 'A dinner you will describe, later, to people who were not there.',
effects: [{ path: 'money', op: 'add', value: -4 }],
},
{
id: 'pay_back',
label: 'Pay back what you owe the floor.',
requires: [{ path: 'owed', op: '>', value: 0 }],
whenLocked: 'hide',
result: 'Squared, or nearer it. It is a better feeling than the dinner would have been.',
// The money is read from the balance before either moves, so a small
// debt costs what it is and no more (DECISIONS #38).
effects: [
{ path: 'money', op: 'add', value: { from: 'owed', times: -1, cap: 10 } },
{ path: 'owed', op: 'add', value: { from: 'owed', times: -1, cap: 10 } },
],
},
{
id: 'straight_home',
label: 'Straight home, envelope sealed.',
result: 'The envelope is lighter than it was in your imagination, but it is all there.',
effects: [],
},
],
},
{
id: 'family_errand',
stage: 'desk',
weight: 20,
cooldown: 8,
involves: ['clerk'],
description:
'A parcel for {partner}\'s sister has to cross the city before four, in the ' +
'rain, and {partner} has asked for you by name. It is not in the job.',
options: [
{
id: 'take_it',
label: 'Take it yourself, and pay your own tram.',
result: 'The sister gives you tea, and a message for {partner} that is more about you than the parcel.',
effects: [
{ path: 'money', op: 'add', value: -1 },
{ path: 'relationships.partner.regard', op: 'add', value: 4 },
{ path: 'standing', op: 'add', value: -1 },
],
},
{
id: 'make_an_impression',
label: 'Take it, and make yourself agreeable when you get there.',
check: { attribute: 'charm', difficulty: 5 },
success: {
result: 'The sister writes to {partner} that evening. You are mentioned warmly.',
effects: [
{ path: 'relationships.partner.regard', op: 'add', value: 6 },
{ path: 'relationships.partner.trust', op: 'add', value: 2 },
moment('Carried a parcel for {partner}\'s sister, and made a friend of her.'),
],
},
failure: {
result: 'You drip on the carpet and say something about the weather twice.',
effects: [{ path: 'relationships.partner.regard', op: 'add', value: 1 }],
},
},
{
id: 'hand_it_on',
label: 'Hand it on to {clerk}, who is going that way.',
result: '{clerk} goes, and gets the tea, and does not say whose errand it was.',
effects: [
{ path: 'relationships.{clerk}.regard', op: 'add', value: -3 },
{ path: 'relationships.partner.regard', op: 'add', value: -2 },
],
},
],
},
{
id: 'window_desk',
stage: 'desk',
weight: 20,
cooldown: 10,
involves: ['clerk'],
description:
'The desk by the window has come free. {chief} says it will go to whoever ' +
'deserves it, and {clerk} has been in before the clock all week.',
options: [
{
id: 'outwork',
label: 'Come in earlier than {clerk} does.',
check: { attribute: 'resolve', against: '{clerk}' },
success: {
result: 'You are there before the cleaners. The desk is yours, and so is the draught.',
effects: [
{ path: 'standing', op: 'add', value: 4 },
{ path: 'relationships.{clerk}.regard', op: 'add', value: -3 },
moment('Won the desk by the window from {clerk}.'),
],
},
failure: {
result: '{clerk} was there before you, every morning, and has the desk.',
effects: [{ path: 'standing', op: 'add', value: -1 }],
},
},
{
id: 'word_with_chief',
label: 'Have a quiet word with {chief}.',
check: { attribute: 'charm', against: 'chief', opposedBy: 'wits', relationship: 'regard' },
success: {
result: '{chief} decides the matter in your favour, and makes sure {clerk} knows why.',
effects: [
{ path: 'standing', op: 'add', value: 3 },
{ path: 'relationships.{clerk}.trust', op: 'add', value: -6 },
],
},
failure: {
result: '{chief} writes your name down, next to the word "canvassing".',
effects: [
{ path: 'relationships.chief.trust', op: 'add', value: -4 },
{ path: 'standing', op: 'add', value: -2 },
],
},
},
{
id: 'let_them_have_it',
label: 'Let {clerk} have it.',
result: '{clerk} takes the desk and, to your surprise, remembers who did not fight for it.',
effects: [
{ path: 'relationships.{clerk}.regard', op: 'add', value: 5 },
{ path: 'relationships.{clerk}.trust', op: 'add', value: 3 },
],
},
],
},
{
id: 'hard_times',
stage: 'desk',
weight: 400,
cooldown: 2,
requires: [{ path: 'money', op: '<', value: 0 }],
involves: ['clerk'],
description:
'The landlady has knocked twice about the week, and the purse under your ' +
'pillow is empty. It is a long walk to Saturday.',
options: [
{
id: 'copying',
label: 'Take in copying work to do at night.',
result: 'Law papers, by lamp, at a halfpenny a folio. It keeps the landlady quiet.',
effects: [
{ path: 'money', op: 'add', value: 8 },
{ path: 'attributes.resolve', op: 'add', value: 1 },
],
},
{
id: 'borrow',
label: 'Ask {clerk} for the loan of ten dollars.',
check: { attribute: 'charm', against: '{clerk}', opposedBy: 'resolve', relationship: 'trust' },
success: {
result: '{clerk} lends it, and tells you not to mention it, and you will not forget.',
effects: [
{ path: 'money', op: 'add', value: 10 },
{ path: 'owed', op: 'add', value: 10 },
{ path: 'relationships.{clerk}.trust', op: 'add', value: -2 },
],
},
failure: {
result: '{clerk} is short too, but finds you three, and makes a face about it.',
effects: [
{ path: 'money', op: 'add', value: 3 },
{ path: 'owed', op: 'add', value: 3 },
{ path: 'relationships.{clerk}.regard', op: 'add', value: -3 },
],
},
},
{
id: 'pawn_the_watch',
label: 'Pawn your father\'s watch.',
requires: [{ path: 'flags.pawned_watch', op: '==', value: false }],
whenLocked: 'hide',
result: 'Twelve dollars and a ticket. You will get it back. Everybody says that.',
effects: [
{ path: 'money', op: 'add', value: 12 },
{ path: 'flags.pawned_watch', op: 'set', value: true },
moment('Pawned your father\'s watch to pay the landlady.'),
],
},
],
},
];
export default events;
+136
View File
@@ -0,0 +1,136 @@
// The Counting House — a prototype pack for checks and a drawn cast.
//
// Harrow & Pike, a trading firm's counting house, somewhere about 1910. You
// are the newest clerk. The situations are the ones public-domain office
// fiction kept returning to (tools/story-to-pack, catalogue v3); the people are
// drawn fresh each run from several candidates per role, two to four clerks
// among them; and most choices are a check, shown to the player only as odds
// in words. See DECISIONS #41.
//
// One stage, deliberately: this pack exists to find out whether chance and a
// varying cast make the same situations worth replaying, before a whole career
// is built on them.
import { characters } from './characters.js';
import { events } from './events.js';
export const countingHouse = {
id: 'countinghouse',
name: 'The Counting House',
blurb:
'Harrow & Pike, about 1910: a stool, a pen, a chief clerk with red ink, and a ' +
'different office every time you start.',
version: '0.1.0',
turnUnit: 'day',
startingStage: 'desk',
peopleHeading: 'Harrow & Pike',
stages: {
desk: { label: 'The Desk', title: 'Junior Clerk' },
},
// A six-day week: the office shuts at one on Saturday, and that is pay day.
calendar: {
cycleLength: 6,
cycleName: 'Week',
unitNames: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'],
},
// The four attributes, the same for the player and for everyone in the cast.
// A pack may call them what it likes; these are the prototype's names.
attributes: {
nerve: { label: 'Nerve' },
wits: { label: 'Wits' },
charm: { label: 'Charm' },
resolve: { label: 'Resolve' },
},
// How attributes turn into chances. The player sees only the words.
// chance = 50 + 8 x (yours - theirs) + 0.4 x (their regard - 50), from 5 to 95
// A new clerk (3) against a sharp partner (8) is 10%, a long shot; against an
// ordinary task (4) it is 42%. Each point of training is worth eight.
checks: {
base: 50,
perPoint: 8,
unopposed: 4,
relationshipPerPoint: 0.4,
relationshipCentre: 50,
floor: 5,
ceiling: 95,
odds: [
{ upTo: 20, words: 'a long shot' },
{ upTo: 40, words: 'unlikely' },
{ upTo: 60, words: 'an even chance' },
{ upTo: 80, words: 'likely' },
{ upTo: 95, words: 'a safe bet' },
],
said: { success: 'and it came off.', failure: 'and it did not come off.' },
},
state: {
'money': { initial: 20, min: -200, max: 100000 },
'standing': { initial: 40, min: 0, max: 100 },
'attributes.nerve': { initial: 3, min: 0, max: 10 },
'attributes.wits': { initial: 3, min: 0, max: 10 },
'attributes.charm': { initial: 3, min: 0, max: 10 },
'attributes.resolve': { initial: 3, min: 0, max: 10 },
// Borrowed from the other clerks when times are hard, repaid on a Saturday.
'owed': { initial: 0, min: 0, max: 100000 },
// Rises won from the partner: each is a permanent addition to the wage.
'rises': { initial: 0, min: 0, max: 3 },
'flags.pawned_watch': { initial: false },
'notable': { initial: [] },
},
// Two axes, not one: whether they believe you, and what they think of you.
relationshipStats: {
trust: { label: 'Trusts you', initial: 50, min: 0, max: 100 },
regard: { label: 'Thinks well of you', initial: 50, min: 0, max: 100 },
},
characters,
events,
// Per six-day week: -12 board and lodging +16 wages = +4.
// Each rise adds four; a rise is earned, so it is outside the baseline.
upkeep: [
{ label: 'Board and lodging', effects: [{ path: 'money', op: 'add', value: -2 }] },
{
label: 'Wages from Harrow & Pike',
every: 6,
offset: 5,
effects: [{ path: 'money', op: 'add', value: 16 }],
},
...[1, 2, 3].map((n) => ({
label: n === 1 ? 'Your rise' : `Your rise (${n})`,
every: 6,
offset: 5,
requires: [{ path: 'rises', op: '>=', value: n }],
effects: [{ path: 'money', op: 'add', value: 4 }],
})),
],
tuning: {
baselinePerCycle: { desk: [0, 10] },
baselineExcludes: ['rises'],
},
display: {
'money': { label: 'Money', format: 'money', order: 1 },
'owed': { label: 'Owed to the floor', format: 'money', order: 2, hideWhenZero: true },
'standing': { label: 'Standing', format: 'meter', order: 2 },
'attributes.nerve': { label: 'Nerve', format: 'meter', order: 3 },
'attributes.wits': { label: 'Wits', format: 'meter', order: 4 },
'attributes.charm': { label: 'Charm', format: 'meter', order: 5 },
'attributes.resolve': { label: 'Resolve', format: 'meter', order: 6 },
'rises': { label: 'Rises', order: 7, hideWhenZero: true },
'notable': { hidden: true, inLedger: false },
'flags.pawned_watch': {
label: 'Your father\'s watch', hidden: true,
whenTrue: 'Your father\'s watch is at the pawnbroker\'s.',
whenFalse: 'You have your father\'s watch back.',
},
},
};
export default countingHouse;
+43
View File
@@ -0,0 +1,43 @@
// Cold Fork, as staffed.
//
// `description` is pure colour — the engine never reads it. What it does read
// is `startingRelationship`, where a character departs from the pack defaults.
// Nobody in a freight yard trusts a new hand on sight, and Dutch has had the
// good runs to himself for two years and can count.
export const characters = [
{
id: 'hollis',
name: 'Hollis Vane',
title: 'Vane & Co. Freight',
role_type: 'boss',
description:
'Owns the outfit, the yard, and the note on both. Settles up in the ' +
'doorway of the office rather than inside it, which saves having to ' +
'offer anyone a chair.',
startingRelationship: { trusts_you: 35 },
},
{
id: 'dutch',
name: 'Dutch Rennels',
title: 'Teamster',
role_type: 'rival',
description:
'Two years on the Ridge road and has driven it in every kind of weather ' +
'there is, as he will tell you. Keeps his own tally of who took which ' +
'run, in a book, in pencil.',
startingRelationship: { trusts_you: 30, wary_of_you: 25 },
},
{
id: 'ward',
name: 'Marshal Ward',
title: 'Town Marshal',
role_type: 'authority',
description:
'Came to Cold Fork to retire and has been six years about it. Knows who ' +
'owes the store and how much, and considers that most of the job.',
startingRelationship: { trusts_you: 25, wary_of_you: 30 },
},
];
export default characters;
+929
View File
@@ -0,0 +1,929 @@
// The second rung: the marshal's office.
//
// `rounds`, `office_day` and `the_cells` are the floor here — no conditions,
// no cooldown, no `once` — so this stage can never run dry. The pay is real
// and the days are worse, which is the trade the offer did not mention.
export const deputy = [
{
id: 'rounds',
stage: 'deputy',
weight: 100,
description:
'The evening round: the length of the street, the back of the store, ' +
'the yard gate, and the Cordwood twice. Mostly it is a walk. The ' +
'difference between the nights it is a walk and the nights it is not ' +
'announces itself late.',
options: [
{
id: 'walk_it',
label: 'Walk it properly, both ends.',
result:
'Two doors unlocked that should not have been, and the storekeeper ' +
'hears about both from you rather than from a stranger.',
effects: [
{ path: 'standing', op: 'add', value: 2 },
{ path: 'skills.nerve', op: 'add', value: 1 },
{ path: 'relationships.ward.trusts_you', op: 'add', value: 2 },
],
},
{
id: 'sit_the_cordwood',
label: 'Do it from a chair at the Cordwood.',
result:
'You hear more in two hours than the street gives up in a week, and ' +
'everybody in the room adjusts what they say in front of you.',
effects: [
{ path: 'skills.trade', op: 'add', value: 2 },
{ path: 'standing', op: 'add', value: -2 },
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 2 },
],
},
{
id: 'walk_the_yard',
label: 'Take the long way past Vane’s yard.',
result:
'The office light is on at eleven and Vane does not come to the ' +
'window. He knows the sound of the round by now.',
effects: [
{ path: 'skills.nerve', op: 'add', value: 2 },
{ path: 'relationships.hollis.wary_of_you', op: 'add', value: 4 },
{ path: 'relationships.ward.trusts_you', op: 'add', value: 3 },
],
},
],
},
{
id: 'office_day',
stage: 'deputy',
weight: 100,
description:
'Paper. The county wants its returns, Ward has not filed since March, ' +
'and the stove is the only warm thing in the building.',
options: [
{
id: 'do_the_returns',
label: 'Do the returns.',
result:
'Four hours and a hand that will not straighten. The county sends a ' +
'letter of acknowledgement, which Ward pins up, which is the most ' +
'sentimental act you have seen from him.',
effects: [
{ path: 'skills.trade', op: 'add', value: 3 },
{ path: 'relationships.ward.trusts_you', op: 'add', value: 5 },
],
},
{
id: 'read_the_paper',
label: 'Read six months of the county paper instead.',
result:
'Three names in it you have loaded freight for, and one of them ' +
'twice. You start a list of your own.',
effects: [
{ path: 'skills.trade', op: 'add', value: 2 },
{ path: 'skills.nerve', op: 'add', value: 1 },
{ path: 'standing', op: 'add', value: 1 },
],
},
{
id: 'ride_out',
label: 'Leave the paper and ride out to the Ridge camp.',
result:
'Nothing is wrong at the Ridge camp. Being seen there when nothing ' +
'is wrong turns out to be most of the job.',
effects: [
{ path: 'money', op: 'add', value: -2 },
{ path: 'standing', op: 'add', value: 4 },
{ path: 'skills.nerve', op: 'add', value: 2 },
{ path: 'relationships.ward.trusts_you', op: 'add', value: -2 },
],
},
],
},
{
id: 'the_cells',
stage: 'deputy',
weight: 100,
description:
'One cell, one occupant, and eleven hours until the county wagon. He ' +
'has been talking since six and has not yet said anything about why he ' +
'is in there.',
options: [
{
id: 'let_him_talk',
label: 'Let him talk.',
result:
'By the ninth hour he has told you about a horse sold twice in ' +
'Bitter Wells, and does not appear to notice that he has.',
effects: [
{ path: 'skills.trade', op: 'add', value: 3 },
{ path: 'relationships.ward.trusts_you', op: 'add', value: 4 },
],
},
{
id: 'feed_him_proper',
label: 'Buy him a proper dinner out of your own pocket.',
result:
'Two dollars forty. He is quiet afterwards, and the county wagon ' +
'takes a man who is not angry, which the driver notices.',
effects: [
{ path: 'money', op: 'add', value: -3 },
{ path: 'standing', op: 'add', value: 3 },
{ path: 'skills.nerve', op: 'add', value: 1 },
],
},
{
id: 'shut_him_up',
label: 'Tell him to be quiet.',
result:
'He is quiet. Eleven hours is a long time in a building with two ' +
'rooms in it and one of you able to leave.',
effects: [
{ path: 'skills.nerve', op: 'add', value: 2 },
{ path: 'standing', op: 'add', value: -2 },
],
},
],
},
{
id: 'saloon_trouble',
stage: 'deputy',
weight: 70,
cooldown: 4,
description:
'Something has gone wrong at the Cordwood and the noise of it reaches ' +
'the office before anybody does. Ward is at the county seat until ' +
'Thursday.',
options: [
{
id: 'wade_in',
label: 'Go in and take hold of the nearest man who is fighting.',
result:
'It stops. Your sleeve does not survive it and neither does a ' +
'table, and the whole street knows by morning which it was.',
effects: [
{ path: 'skills.nerve', op: 'add', value: 4 },
{ path: 'standing', op: 'add', value: 4 },
{ path: 'money', op: 'add', value: -4 },
],
},
{
id: 'wait_outside',
label: 'Stand in the doorway and wait for it to come to you.',
result:
'It comes to you, in ones, which is the entire trick and takes a ' +
'nerve you did not have a year ago.',
effects: [
{ path: 'skills.nerve', op: 'add', value: 2 },
{ path: 'skills.trade', op: 'add', value: 2 },
{ path: 'standing', op: 'add', value: 6 },
],
},
{
id: 'send_for_dutch',
label: 'Send for Dutch.',
requires: [{ path: 'relationships.dutch.owes_you', op: '>=', value: 25 }],
result:
'He arrives inside four minutes, takes one man out by the collar, ' +
'and says afterwards that the book is square now. It is not, and ' +
'both of you let that stand.',
effects: [
{ path: 'skills.trade', op: 'add', value: 2 },
{ path: 'standing', op: 'add', value: 3 },
{ path: 'relationships.dutch.owes_you', op: 'add', value: -20 },
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 5 },
],
},
],
},
{
id: 'vane_across_the_street',
stage: 'deputy',
weight: 55,
cooldown: 6,
description:
'Vane has a load going out that the manifest describes as agricultural ' +
'implements, and it is going out at four in the morning, which no ' +
'agricultural implement in the county has ever needed to do.',
options: [
{
id: 'stop_it',
label: 'Be at the yard gate at four.',
result:
'He does not argue and he does not open the crate. He turns the ' +
'wagon round in front of you and takes it back inside, and that is ' +
'the last civil word between you.',
effects: [
{ path: 'standing', op: 'add', value: 7 },
{ path: 'skills.nerve', op: 'add', value: 3 },
{ path: 'relationships.ward.trusts_you', op: 'add', value: 8 },
{ path: 'relationships.hollis.trusts_you', op: 'add', value: -18 },
{ path: 'relationships.hollis.wary_of_you', op: 'add', value: 15 },
{ path: 'notable', op: 'push', value: 'Stood at Vane’s gate at four in the morning.' },
],
},
{
id: 'let_it_go',
label: 'Be somewhere else at four.',
result:
'The wagon goes. Nothing comes of it, which is the part that stays ' +
'with you, because nothing coming of it means nothing was ever ' +
'going to.',
effects: [
{ path: 'standing', op: 'add', value: -5 },
{ path: 'skills.trade', op: 'add', value: 1 },
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 10 },
{ path: 'relationships.ward.trusts_you', op: 'add', value: -8 },
],
},
{
id: 'go_and_ask',
label: 'Go to the office at nine and ask him straight out.',
requires: [{ path: 'skills.trade', op: '>=', value: 32 }],
result:
'He tells you, in the doorway, the same as he told you about the ' +
'crate. Then he asks whether the star has changed what you intend ' +
'to do about it. It has, and you say so.',
effects: [
{ path: 'skills.trade', op: 'add', value: 4 },
{ path: 'skills.nerve', op: 'add', value: 3 },
{ path: 'standing', op: 'add', value: 5 },
{ path: 'relationships.hollis.trusts_you', op: 'add', value: -4 },
{ path: 'relationships.ward.trusts_you', op: 'add', value: 6 },
{ path: 'notable', op: 'push', value: 'Asked Vane straight out, wearing the star.' },
],
},
],
},
{
id: 'ward_tired',
stage: 'deputy',
weight: 45,
cooldown: 8,
requires: [{ path: 'flags.sworn_in', op: '==', value: true }],
description:
'Ward has been six years retiring and today he says the word out loud ' +
'for the first time, sitting on the office step with his boots off.',
options: [
{
id: 'talk_him_round',
label: 'Tell him the town is not ready.',
result:
'He says the town is never ready and that is not a reason. He stays ' +
'anyway, for now, which both of you understand is a favour to you ' +
'and not to the town.',
effects: [
{ path: 'skills.trade', op: 'add', value: 2 },
{ path: 'relationships.ward.trusts_you', op: 'add', value: 6 },
{ path: 'relationships.ward.owes_you', op: 'add', value: 5 },
],
},
{
id: 'tell_him_go',
label: 'Tell him to go.',
result:
'He looks at you for a while. Then he laughs, once, and puts his ' +
'boots back on, and does not mention it again for a month.',
effects: [
{ path: 'skills.nerve', op: 'add', value: 4 },
{ path: 'standing', op: 'add', value: 3 },
{ path: 'relationships.ward.trusts_you', op: 'add', value: 10 },
{ path: 'notable', op: 'push', value: 'Told Ward to go and retire.' },
],
},
{
id: 'say_nothing_ward',
label: 'Let him sit.',
result: 'You sit on the step with him until it gets dark.',
effects: [
{ path: 'relationships.ward.trusts_you', op: 'add', value: 4 },
{ path: 'relationships.ward.wary_of_you', op: 'add', value: -5 },
{ path: 'skills.nerve', op: 'add', value: 1 },
],
},
],
},
{
id: 'county_wagon',
stage: 'deputy',
weight: 50,
cooldown: 5,
description:
'The county wagon is a day late, there are two men in one cell who ' +
'should not be in one cell, and the second cell has never had a lock ' +
'that works.',
options: [
{
id: 'sit_up_with_them',
label: 'Sit up all night in the office.',
result:
'Nothing happens, twice, at two and at four. In the morning the ' +
'wagon comes and you have been awake for thirty hours.',
effects: [
{ path: 'skills.nerve', op: 'add', value: 3 },
{ path: 'relationships.ward.trusts_you', op: 'add', value: 5 },
{ path: 'standing', op: 'add', value: 2 },
],
},
{
id: 'fix_the_lock',
label: 'Spend the afternoon making the second lock work.',
result:
'It works. It has not worked since before Ward came, and he stands ' +
'in the doorway turning the key for longer than the job took.',
effects: [
{ path: 'money', op: 'add', value: -5 },
{ path: 'skills.trade', op: 'add', value: 3 },
{ path: 'relationships.ward.trusts_you', op: 'add', value: 7 },
],
},
{
id: 'release_one',
label: 'Turn the quieter one loose on his word.',
requires: [{ path: 'skills.nerve', op: '>=', value: 32 }],
result:
'He comes back at eight in the morning, as he said he would, and ' +
'the county never knows there was a decision in it.',
effects: [
{ path: 'skills.nerve', op: 'add', value: 3 },
{ path: 'standing', op: 'add', value: 6 },
{ path: 'relationships.ward.wary_of_you', op: 'add', value: 8 },
{ path: 'notable', op: 'push', value: 'Turned a man loose on his word, and was right.' },
],
},
],
},
{
id: 'dutch_in_the_cells',
stage: 'deputy',
weight: 40,
cooldown: 10,
requires: [{ path: 'turn', op: '>=', value: 12 }],
description:
'Dutch is in the cell. It is the Cordwood and a table and nobody hurt, ' +
'and it is also the third time this season, and the storekeeper has ' +
'started using the word "again."',
options: [
{
id: 'let_him_out',
label: 'Let him out in the morning and say nothing to the county.',
result:
'He goes to work. Neither of you refers to it, then or later, and ' +
'the storekeeper notices that neither of you did.',
effects: [
{ path: 'standing', op: 'add', value: -4 },
{ path: 'relationships.dutch.owes_you', op: 'add', value: 20 },
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 8 },
{ path: 'relationships.ward.wary_of_you', op: 'add', value: 6 },
],
},
{
id: 'book_him',
label: 'Book him properly.',
result:
'Eight dollars and a night, and his name on a county return with ' +
'your hand on it. He pays the eight dollars. He reads the return ' +
'twice.',
effects: [
{ path: 'standing', op: 'add', value: 5 },
{ path: 'relationships.ward.trusts_you', op: 'add', value: 6 },
{ path: 'relationships.dutch.trusts_you', op: 'add', value: -18 },
{ path: 'relationships.dutch.wary_of_you', op: 'add', value: 14 },
{ path: 'notable', op: 'push', value: 'Booked Dutch on a county return.' },
],
},
{
id: 'talk_to_him',
label: 'Sit outside the cell until he says what it is about.',
requires: [{ path: 'relationships.dutch.trusts_you', op: '>=', value: 50 }],
result:
'It is about two years on the Ridge road and a book with nobody ' +
'else’s name in it. He gets most of the way to saying so.',
effects: [
{ path: 'skills.trade', op: 'add', value: 3 },
{ path: 'skills.nerve', op: 'add', value: 2 },
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 12 },
{ path: 'relationships.dutch.wary_of_you', op: 'add', value: -10 },
{ path: 'notable', op: 'push', value: 'Sat outside the cell with Dutch until he talked.' },
],
},
],
},
{
id: 'the_ridge_camp_dispute',
stage: 'deputy',
weight: 45,
cooldown: 7,
description:
'Two men at the Ridge camp both hold paper on the same claim and one of ' +
'them has a brother in Bitter Wells. The camp has asked for the marshal ' +
'and the marshal has sent you.',
options: [
{
id: 'read_the_paper_claim',
label: 'Read both papers all the way through.',
result:
'Both are good. Both were issued by the same clerk eleven days ' +
'apart. The problem is in Bitter Wells and not on the Ridge, and ' +
'saying so ends it.',
effects: [
{ path: 'skills.trade', op: 'add', value: 4 },
{ path: 'standing', op: 'add', value: 5 },
{ path: 'relationships.ward.trusts_you', op: 'add', value: 5 },
],
},
{
id: 'stand_between',
label: 'Stand between them until the county can rule.',
result:
'Six weeks of standing between them, in instalments. Nobody is shot ' +
'and nobody is satisfied, which Ward says is the job described.',
effects: [
{ path: 'skills.nerve', op: 'add', value: 4 },
{ path: 'standing', op: 'add', value: 2 },
{ path: 'money', op: 'add', value: 4 },
],
},
{
id: 'take_a_side',
label: 'Decide it yourself, on the spot.',
result:
'You are probably right. The brother from Bitter Wells arrives on ' +
'Thursday to find out how right, and stays four days.',
effects: [
{ path: 'skills.nerve', op: 'add', value: 3 },
{ path: 'standing', op: 'add', value: -3 },
{ path: 'relationships.ward.wary_of_you', op: 'add', value: 10 },
{ path: 'money', op: 'add', value: 8 },
],
},
],
},
{
id: 'the_star_and_the_yard',
stage: 'deputy',
weight: 30,
cooldown: 14,
requires: [
{ path: 'turn', op: '>=', value: 20 },
{ path: 'relationships.hollis.trusts_you', op: '<=', value: 30 },
],
description:
'Vane & Co. has a note falling due and the bank in Bitter Wells has ' +
'declined it. Vane is fifty-one and has not asked anybody in Cold Fork ' +
'for anything in nineteen years, and is standing in the office doorway ' +
'across the street looking at your building.',
options: [
{
id: 'go_over',
label: 'Cross the street.',
result:
'He does not ask. You do not offer. The two of you stand in the ' +
'doorway of the office for a while, which is where every ' +
'conversation you have ever had with him has happened.',
effects: [
{ path: 'skills.nerve', op: 'add', value: 3 },
{ path: 'standing', op: 'add', value: 3 },
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 12 },
{ path: 'notable', op: 'push', value: 'Crossed the street to Vane when the note fell due.' },
],
},
{
id: 'lend_him',
label: 'Put your own money into the note.',
requires: [{ path: 'money', op: '>=', value: 60 }],
result:
'Sixty dollars, and the outfit keeps its animals. He pays it back ' +
'over a year, in the doorway, in instalments, never once in front ' +
'of Dutch.',
effects: [
{ path: 'money', op: 'add', value: -60 },
{ path: 'relationships.hollis.owes_you', op: 'add', value: 40 },
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 25 },
{ path: 'standing', op: 'add', value: 4 },
{ path: 'notable', op: 'push', value: 'Put sixty dollars of your own into Vane’s note.' },
],
},
{
id: 'stay_put',
label: 'Stay on your own side of the street.',
result:
'The note goes. The yard is sold in the spring and the new outfit ' +
'brings its own men, and the Ridge road is driven by somebody you ' +
'do not know.',
effects: [
{ path: 'skills.nerve', op: 'add', value: 1 },
{ path: 'standing', op: 'add', value: -2 },
{ path: 'relationships.hollis.trusts_you', op: 'add', value: -10 },
{ path: 'relationships.dutch.trusts_you', op: 'add', value: -8 },
{ path: 'notable', op: 'push', value: 'Stayed on your own side of the street.' },
],
},
],
},
{
id: 'quiet_week',
stage: 'deputy',
weight: 60,
cooldown: 4,
description:
'Nothing. Four days of nothing. The stove, the returns, the length of ' +
'the street twice a night, and a town that does not need you at all ' +
'this week and might not next week either.',
options: [
{
id: 'learn_the_law',
label: 'Read the county statutes properly.',
result:
'Most of it is fees. Some of it is not, and the part that is not is ' +
'the part nobody in Cold Fork has ever read.',
effects: [
{ path: 'skills.trade', op: 'add', value: 3 },
{ path: 'relationships.ward.trusts_you', op: 'add', value: 3 },
],
},
{
id: 'work_the_yard_again',
label: 'Go and load freight for a day, for the money.',
result:
'Vane pays you the hand’s rate without comment. Dutch says one ' +
'thing about the star and then works alongside you all afternoon.',
effects: [
{ path: 'money', op: 'add', value: 9 },
{ path: 'standing', op: 'add', value: -3 },
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 5 },
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 5 },
],
},
{
id: 'ride_the_county',
label: 'Ride the county line and look at it.',
result:
'Two days out. Nobody sends for you and nothing needs you, and you ' +
'come back having decided something you do not tell anybody.',
effects: [
{ path: 'money', op: 'add', value: -3 },
{ path: 'skills.nerve', op: 'add', value: 3 },
{ path: 'standing', op: 'add', value: 1 },
],
},
],
},
// Five more days in the office, added after the first real play of this
// stage: 69 turns saw 13 distinct situations, and `office_day` and
// `the_cells` were a sixth of the stage each. See DECISIONS #40.
{
id: 'stage_came_in_short',
stage: 'deputy',
weight: 50,
cooldown: 7,
description:
'The stage came in with the strongbox eleven pounds lighter than the ' +
'manifest, and the driver swears on his mother that he never once ' +
'stopped between the Wells and here.',
options: [
{
id: 'wire_the_county',
label: 'Wire the county and let them send a man.',
result:
'The county sends a man in nine days, by which time the driver has ' +
'told the story often enough to believe every word of it.',
effects: [
{ path: 'skills.trade', op: 'add', value: 2 },
{ path: 'relationships.ward.trusts_you', op: 'add', value: 2 },
{ path: 'standing', op: 'add', value: -1 },
],
},
{
id: 'ride_the_route',
label: 'Ride the route back yourself, looking at the ground.',
result:
'Wheel ruts off the road at the second creek, where the driver says ' +
'he never stopped. You do not find the eleven pounds. You find where ' +
'they got off.',
effects: [
{ path: 'money', op: 'add', value: -3 },
{ path: 'skills.nerve', op: 'add', value: 3 },
{ path: 'standing', op: 'add', value: 3 },
],
},
{
id: 'lean_on_the_driver',
label: 'Lean on the driver until his story changes.',
requires: [{ path: 'skills.nerve', op: '>=', value: 30 }],
result:
'It changes on the fourth telling, at the second creek, and then ' +
'again at the part where somebody was expecting him.',
effects: [
{ path: 'skills.nerve', op: 'add', value: 3 },
{ path: 'standing', op: 'add', value: 4 },
{ path: 'relationships.ward.wary_of_you', op: 'add', value: 5 },
{ path: 'notable', op: 'push', value: 'Leaned on a stage driver until the story changed.' },
],
},
],
},
{
id: 'the_county_fees',
stage: 'deputy',
weight: 45,
cooldown: 8,
description:
'The county wants its licence fees collected from every business on the ' +
'street, and the list it sent includes the woman who sells pies out of ' +
'her kitchen window on Sundays.',
options: [
{
id: 'collect_all',
label: 'Collect from everybody on the list.',
result:
'All of it, pies included. The county is pleased. The street is ' +
'polite to you in a way it was not last week.',
effects: [
{ path: 'standing', op: 'add', value: -4 },
{ path: 'skills.trade', op: 'add', value: 1 },
{ path: 'relationships.ward.trusts_you', op: 'add', value: 4 },
],
},
{
id: 'pay_hers',
label: 'Collect from the rest, and pay hers out of your own pocket.',
result:
'A dollar fifty. Nobody is told, and by Sunday everybody knows, which ' +
'is how the street keeps its accounts.',
effects: [
{ path: 'money', op: 'add', value: -5 },
{ path: 'standing', op: 'add', value: 5 },
{ path: 'notable', op: 'push', value: 'Paid the pie licence out of your own pocket.' },
],
},
{
id: 'strike_her_off',
label: 'Strike her off the list and let the county notice if it can.',
requires: [{ path: 'skills.trade', op: '>=', value: 28 }],
result:
'You find the statute that exempts a kitchen window, and cite it in ' +
'the return. The county never writes back, which is the county ' +
'agreeing.',
effects: [
{ path: 'skills.trade', op: 'add', value: 3 },
{ path: 'standing', op: 'add', value: 3 },
{ path: 'relationships.ward.wary_of_you', op: 'add', value: 4 },
],
},
],
},
{
id: 'the_bounty_man',
stage: 'deputy',
weight: 40,
cooldown: 9,
description:
'A man with a paper from Bitter Wells and a rifle he keeps too clean ' +
'wants to know whether a teamster called Pike is in Cold Fork. There is ' +
'a teamster called Pike in Cold Fork, and he has been decent to you.',
options: [
{
id: 'tell_him',
label: 'Tell him where Pike is.',
result:
'He thanks you and leaves money on the desk, which you did not ask ' +
'for and do not give back. The teamsters hear about the money first.',
effects: [
{ path: 'money', op: 'add', value: 6 },
{ path: 'standing', op: 'add', value: -3 },
{ path: 'relationships.ward.trusts_you', op: 'add', value: 3 },
{ path: 'relationships.dutch.wary_of_you', op: 'add', value: 6 },
],
},
{
id: 'send_him_on',
label: 'Tell him Pike went west a month ago.',
result:
'He looks at you for long enough that you understand he knows. Then ' +
'he rides west, because the paper does not pay him to argue.',
effects: [
{ path: 'skills.nerve', op: 'add', value: 2 },
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 5 },
{ path: 'relationships.ward.wary_of_you', op: 'add', value: 5 },
],
},
{
id: 'check_the_paper',
label: 'Read his paper properly before you say anything at all.',
requires: [{ path: 'skills.trade', op: '>=', value: 30 }],
result:
'It names a Pike, a year and a county, and two of the three are ' +
'wrong. You tell him so, with the statute, and he goes home.',
effects: [
{ path: 'skills.trade', op: 'add', value: 3 },
{ path: 'standing', op: 'add', value: 4 },
{ path: 'relationships.ward.trusts_you', op: 'add', value: 4 },
{ path: 'notable', op: 'push', value: 'Read a bounty man’s paper closely enough to send him home.' },
],
},
],
},
{
id: 'fire_at_the_store',
stage: 'deputy',
weight: 35,
cooldown: 12,
description:
'A lamp goes over in the back room of the store an hour after midnight, ' +
'and by the time you are in your boots the whole street is standing in ' +
'it in its nightclothes.',
options: [
{
id: 'bucket_line',
label: 'Get a bucket line going from the trough.',
result:
'It is out by two. The back room is gone and the front is black to ' +
'the ceiling, and the storekeeper shakes every hand on the line.',
effects: [
{ path: 'money', op: 'add', value: -2 },
{ path: 'skills.nerve', op: 'add', value: 2 },
{ path: 'standing', op: 'add', value: 5 },
],
},
{
id: 'go_in_for_the_book',
label: 'Go in for the book on the counter.',
requires: [{ path: 'skills.nerve', op: '>=', value: 34 }],
result:
'You come out with it under your coat and no eyebrows. Half the town ' +
'is in that book, and every one of them knows who carried it out.',
effects: [
{ path: 'skills.nerve', op: 'add', value: 4 },
{ path: 'standing', op: 'add', value: 6 },
{ path: 'notable', op: 'push', value: 'Went into the burning store for the book.' },
],
},
{
id: 'hold_the_crowd',
label: 'Keep the crowd back and let it burn itself out.',
result:
'Nobody else is hurt, which Ward says is the only part of a fire that ' +
'is the office’s business. The storekeeper does not agree, loudly.',
effects: [
{ path: 'skills.trade', op: 'add', value: 2 },
{ path: 'standing', op: 'add', value: -2 },
{ path: 'relationships.ward.trusts_you', op: 'add', value: 3 },
],
},
],
},
{
id: 'dutch_wants_the_ridge',
stage: 'deputy',
weight: 40,
cooldown: 10,
description:
'Dutch comes to the office sober and early, which has not happened ' +
'before, and asks whether a deputy has any say in who drives the Ridge ' +
'road now that Vane is short a man.',
options: [
{
id: 'put_him_forward',
label: 'Say you will put his name in with Vane.',
result:
'He nods once and goes, and Vane hears about it from Dutch before he ' +
'hears about it from you, which is not how you meant it to go.',
effects: [
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 8 },
{ path: 'relationships.dutch.owes_you', op: 'add', value: 10 },
{ path: 'relationships.hollis.wary_of_you', op: 'add', value: 5 },
],
},
{
id: 'office_stays_out',
label: 'Say the office does not hire teamsters.',
result:
'He says that is true, and it is, and he takes his hat off the desk ' +
'and goes back down the street to wait for somebody else to decide.',
effects: [
{ path: 'standing', op: 'add', value: 2 },
{ path: 'relationships.dutch.trusts_you', op: 'add', value: -5 },
{ path: 'relationships.ward.trusts_you', op: 'add', value: 2 },
],
},
{
id: 'ask_vane_for_him',
label: 'Walk across the street and ask Vane yourself.',
requires: [{ path: 'relationships.hollis.trusts_you', op: '>=', value: 40 }],
result:
'Vane hears you out in the doorway and says he will think about it, ' +
'and Dutch is on the Ridge road on Monday.',
effects: [
{ path: 'standing', op: 'add', value: 3 },
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 10 },
{ path: 'relationships.hollis.trusts_you', op: 'add', value: -2 },
{ path: 'notable', op: 'push', value: 'Asked Vane to put Dutch on the Ridge road.' },
],
},
],
},
{
// Corporate Ladder's car lot, in Cold Fork's terms and at its scale. Same
// structure deliberately: an offer, never an obligation, with the weekly
// charge for falling short of it sitting in upkeep instead.
//
// The horse and the room used to be one event, whose description was
// always about the horse. A real session was offered the room under that
// text, and shown "Buy the bay" locked after buying it — a door that could
// never open. Now each is its own event, gated on the tier it offers, so
// neither can appear once it is bought. See DECISIONS #40.
id: 'the_sale_barn',
stage: 'deputy',
weight: 45,
cooldown: 6,
requires: [{ path: 'lifestyle', op: '==', value: 0 }],
description:
'The sale barn has a bay with four good years in him and a price on ' +
'him. Ward says nothing about it, except that the county pays mileage ' +
'on a horse a man owns and nothing at all on a hired one.',
options: [
{
id: 'buy_the_bay',
label: 'Buy the bay.',
requires: [{ path: 'money', op: '>=', value: 70 }],
result:
'He is a better animal than the livery has ever put under you, and ' +
'the whole street watches you ride him down it once.',
effects: [
{ path: 'money', op: 'add', value: -70 },
{ path: 'lifestyle', op: 'set', value: 1 },
{ path: 'standing', op: 'add', value: 3 },
{ path: 'notable', op: 'push', value: 'Bought the bay out of the sale barn.' },
],
},
{
id: 'ride_the_livery',
label: 'Ride what the livery gives you.',
result:
'A different animal every week, and you learn something about each ' +
'one of them in the first mile.',
effects: [{ path: 'skills.trade', op: 'add', value: 1 }],
},
],
},
{
id: 'the_ellis_room',
stage: 'deputy',
weight: 45,
cooldown: 6,
requires: [{ path: 'lifestyle', op: '==', value: 1 }],
description:
'The room over the Ellis place is empty again: a stove, a door that ' +
'locks, and a window over the street. The Cordwood charges a deputy the ' +
'same as a teamster, and says so to both of them.',
options: [
{
id: 'take_the_room',
label: 'Take the room over the Ellis place, with the stove.',
requires: [{ path: 'money', op: '>=', value: 150 }],
result:
'A door that locks, a stove, and a window over the street. The ' +
'Cordwood does not hold it against you; the Cordwood holds nothing ' +
'against anybody who pays up.',
effects: [
{ path: 'money', op: 'add', value: -150 },
{ path: 'lifestyle', op: 'set', value: 2 },
{ path: 'standing', op: 'add', value: 5 },
{ path: 'notable', op: 'push', value: 'Took the room with the stove over the Ellis place.' },
],
},
{
id: 'stay_at_the_cordwood',
label: 'Stay at the Cordwood.',
result:
'The walls are thin and the talk comes through them, and some of what ' +
'comes through is worth a deputy hearing.',
effects: [
{ path: 'skills.trade', op: 'add', value: 1 },
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 2 },
],
},
],
},
];
export default deputy;
+142
View File
@@ -0,0 +1,142 @@
// Running out of money.
//
// No game over and no failure mode in the engine — these are ordinary events
// whose conditions happen to be about money, weighted heavily enough to crowd
// out an ordinary day. Neither declares a stage: being broke follows you up
// the ladder rather than vanishing when you take the star.
//
// The arithmetic that matters, and the one Corporate Ladder got wrong once:
// `every_run` pays 30 against a room-and-board cycle of 20 and a fully-loaded
// store charge of 10, so there is always a way out that is worth more than the
// hole is deep. Change either number and re-derive the other.
export const hardTimes = [
{
id: 'store_cut_off',
// Heavy, but on a cooldown: being broke should colour a run, not become
// the run. Corporate Ladder learned this the expensive way at weight 5000
// with no cooldown.
weight: 900,
cooldown: 3,
requires: [{ path: 'money', op: '<', value: 0 }],
description:
'The storekeeper turns the book round on the counter so you can see ' +
'your own line in it, and does not say anything at all, which is worse ' +
'than the several things he could have said.',
options: [
{
id: 'on_the_book',
label: 'Ask him to carry you a while longer.',
// He stops eventually. Past that, the ways out cost standing and
// favours rather than interest, which is where the spiral gets real.
requires: [{ path: 'owed', op: '<', value: 120 }],
result:
'He turns the book back round and writes it. The figure at the ' +
'bottom of your line is now larger than a month of runs.',
effects: [
{ path: 'money', op: 'add', value: 30 },
{ path: 'owed', op: 'add', value: 32 },
{ path: 'flags.owes_the_store', op: 'set', value: true },
{ path: 'standing', op: 'add', value: -2 },
{ path: 'notable', op: 'push', value: 'Went further into the book at the store.' },
],
},
{
id: 'ask_dutch',
label: 'Ask Dutch to settle a line of his own.',
requires: [{ path: 'relationships.dutch.owes_you', op: '>=', value: 20 }],
result:
'He pays it without a word and crosses it out of the book in front ' +
'of you, and afterwards you are a man he has settled with rather ' +
'than a man he owes.',
effects: [
{ path: 'money', op: 'add', value: 18 },
{ path: 'relationships.dutch.owes_you', op: 'add', value: -20 },
{ path: 'relationships.dutch.trusts_you', op: 'add', value: -3 },
{ path: 'notable', op: 'push', value: 'Called in what Dutch owed.' },
],
},
{
id: 'ask_hollis',
label: 'Ask Vane for the smith’s money back.',
requires: [{ path: 'relationships.hollis.owes_you', op: '>=', value: 20 }],
result:
'He pays in the doorway, in full, and finds a reason not to look at ' +
'you for the rest of the week.',
effects: [
{ path: 'money', op: 'add', value: 22 },
{ path: 'relationships.hollis.owes_you', op: 'add', value: -20 },
{ path: 'relationships.hollis.trusts_you', op: 'add', value: -4 },
],
},
{
id: 'every_run',
label: 'Take every run going.',
// Has to be worth more than a cycle of room and board plus the fully
// stepped-up store charge, or it is not a way out — it is the hole
// with extra hours in it.
result:
'Eleven days without one off. The Ridge road stops being a road ' +
'after a while and becomes a sequence of turns you take.',
effects: [
{ path: 'money', op: 'add', value: 30 },
{ path: 'skills.nerve', op: 'add', value: 2 },
{ path: 'standing', op: 'add', value: -1 },
],
},
],
},
{
id: 'the_book_called',
// Often enough to matter. At weight 25 Corporate Ladder's equivalent
// appeared six times in 262 turns and debt became a one-way ratchet.
weight: 45,
cooldown: 4,
requires: [{ path: 'owed', op: '>', value: 0 }],
description:
'The storekeeper has written to the county about three lines in his ' +
'book and yours is one of them. He mentions this to you in the street, ' +
'in daylight, in front of two other people.',
options: [
// Both payments take what is owed up to their size, never more — money
// first, because both effects read `owed` before it moves. See #38.
{
id: 'pay_chunk',
label: 'Pay a real piece of it down, up to forty-five dollars.',
requires: [{ path: 'money', op: '>=', value: 45 }],
result: 'He rules a line through most of it and is disappointed about it.',
effects: [
{ path: 'money', op: 'add', value: { from: 'owed', times: -1, cap: 45 } },
{ path: 'owed', op: 'add', value: { from: 'owed', times: -1, cap: 45 } },
{ path: 'standing', op: 'add', value: 2 },
],
},
{
id: 'pay_minimum',
label: 'Pay what you have on you.',
requires: [{ path: 'money', op: '>=', value: 12 }],
result: 'Up to ten dollars against the line, and two dollars of coffee while you are in there.',
effects: [
{ path: 'money', op: 'add', value: -2 },
{ path: 'money', op: 'add', value: { from: 'owed', times: -1, cap: 10 } },
{ path: 'owed', op: 'add', value: { from: 'owed', times: -1, cap: 10 } },
],
},
{
id: 'stall',
label: 'Tell him after the next stage.',
result:
'He says after the next stage. The two other people in the street ' +
'say nothing, and repeat all of it by evening.',
effects: [
{ path: 'owed', op: 'add', value: 4 },
{ path: 'standing', op: 'add', value: -3 },
{ path: 'relationships.ward.wary_of_you', op: 'add', value: 4 },
],
},
],
},
];
export default hardTimes;
+8
View File
@@ -0,0 +1,8 @@
import { routine } from './routine.js';
import { people } from './people.js';
import { promotion } from './promotion.js';
import { deputy } from './deputy.js';
import { hardTimes } from './hard-times.js';
export const events = [...routine, ...people, ...promotion, ...deputy, ...hardTimes];
export default events;
+415
View File
@@ -0,0 +1,415 @@
// The three people in Cold Fork who have an opinion about you.
//
// Relationship-gated content, and the counterweight to the yard: the runs pay
// the room, but Dutch's book, Vane's ledger and Ward's slow appraisal are what
// decide whether the star is ever offered.
export const people = [
{
id: 'dutch_book',
stage: 'hand',
weight: 70,
cooldown: 5,
description:
'Dutch has his tally book open on the tailgate and is working through ' +
'who took which run this month. He has arrived at a number he does not ' +
'like and is now looking for somewhere to put it.',
options: [
{
id: 'let_him_talk',
label: 'Let him get all the way to the end of it.',
result:
'It takes twenty minutes. By the end he has talked himself into ' +
'thinking it was about the weather, which suits both of you.',
effects: [
{ path: 'skills.trade', op: 'add', value: 2 },
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 6 },
{ path: 'relationships.dutch.wary_of_you', op: 'add', value: -4 },
],
},
{
id: 'check_the_book',
label: 'Ask to see the book.',
result:
'The tally is right and his arithmetic is wrong by two runs, in his ' +
'own favour. You say so quietly. He does not thank you and does not ' +
'forget it either.',
effects: [
{ path: 'skills.trade', op: 'add', value: 3 },
{ path: 'relationships.dutch.wary_of_you', op: 'add', value: 7 },
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 3 },
],
},
{
id: 'trade_a_run',
label: 'Offer him the next Ridge run to square it.',
requires: [{ path: 'relationships.dutch.trusts_you', op: '>=', value: 45 }],
result:
'He takes it, and the book gets a line in it on your side for the ' +
'first time. Dutch settles his lines.',
effects: [
{ path: 'money', op: 'add', value: -6 },
{ path: 'skills.trade', op: 'add', value: 2 },
{ path: 'relationships.dutch.owes_you', op: 'add', value: 18 },
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 5 },
],
},
],
},
{
id: 'dutch_drunk',
stage: 'hand',
weight: 40,
cooldown: 9,
requires: [{ path: 'turn', op: '>=', value: 12 }],
description:
'Dutch did not come in for the morning run and the morning run is ' +
'loaded. He is at the Cordwood, upright, and in no condition to be ' +
'within thirty feet of a horse.',
options: [
{
id: 'cover',
label: 'Take his run and say nothing about it.',
result:
'Vane never learns. Dutch learns, in the afternoon, from the state ' +
'of the harness, and comes and stands near you for a while without ' +
'saying anything.',
effects: [
{ path: 'money', op: 'add', value: 7 },
{ path: 'skills.nerve', op: 'add', value: 2 },
{ path: 'flags.covered_for_dutch', op: 'set', value: true },
{ path: 'relationships.dutch.owes_you', op: 'add', value: 25 },
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 8 },
{ path: 'notable', op: 'push', value: 'Covered Dutch’s run and never mentioned it.' },
],
},
{
id: 'tell_vane',
label: 'Tell Vane the run needs a driver.',
result:
'Vane takes it out himself at fifty-one years of age and says one ' +
'sentence to Dutch on his return. Dutch works the rest of the month ' +
'like a man being watched, because he is.',
effects: [
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 7 },
{ path: 'relationships.dutch.trusts_you', op: 'add', value: -15 },
{ path: 'relationships.dutch.wary_of_you', op: 'add', value: 12 },
{ path: 'standing', op: 'add', value: -2 },
],
},
{
id: 'sober_him',
label: 'Walk him round the yard until he can stand up to it.',
requires: [{ path: 'skills.nerve', op: '>=', value: 12 }],
result:
'Four circuits and a bucket. He drives it badly but he drives it, ' +
'and that distinction is the entire point.',
effects: [
{ path: 'skills.nerve', op: 'add', value: 2 },
{ path: 'relationships.dutch.owes_you', op: 'add', value: 14 },
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 10 },
{ path: 'relationships.dutch.wary_of_you', op: 'add', value: -6 },
],
},
],
},
{
id: 'vane_ledger',
stage: 'hand',
weight: 50,
cooldown: 7,
description:
'The office door is open for once. Vane has the ledger out and the look ' +
'of a man who has added a column twice and got two answers.',
options: [
{
id: 'offer_help',
label: 'Offer to run the column.',
result:
'You find it in ten minutes: a freight bill entered twice in March. ' +
'He does not say thank you. He leaves the door open after that.',
effects: [
{ path: 'skills.trade', op: 'add', value: 4 },
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 9 },
],
},
{
id: 'ask_for_more',
label: 'Ask him what the good runs pay.',
requires: [{ path: 'skills.trade', op: '>=', value: 22 }],
result:
'Twenty-two, and he has been giving them to Dutch for two years ' +
'because Dutch asked in his first month. Nobody had told you that ' +
'asking was the mechanism.',
effects: [
{ path: 'money', op: 'add', value: 22 },
{ path: 'skills.trade', op: 'add', value: 3 },
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 3 },
{ path: 'relationships.dutch.wary_of_you', op: 'add', value: 6 },
{ path: 'notable', op: 'push', value: 'Asked Vane for the good runs.' },
],
},
{
id: 'call_in_the_debt',
label: 'Mention the smith’s bill you stood for.',
requires: [{ path: 'relationships.hollis.owes_you', op: '>=', value: 20 }],
result:
'He pays it back to the cent, in front of Dutch, and is short with ' +
'you for two days afterwards for having had to.',
effects: [
{ path: 'money', op: 'add', value: 24 },
{ path: 'relationships.hollis.owes_you', op: 'add', value: -20 },
{ path: 'relationships.hollis.trusts_you', op: 'add', value: -4 },
],
},
{
id: 'leave_him_to_it',
label: 'Leave him to it.',
result: 'The door is shut again within the hour.',
effects: [
{ path: 'skills.nerve', op: 'add', value: 1 },
],
},
],
},
{
id: 'ward_asks',
stage: 'hand',
weight: 45,
cooldown: 6,
requires: [{ path: 'turn', op: '>=', value: 6 }],
description:
'Marshal Ward is leaning on the yard gate with no particular business ' +
'there. He asks, eventually and as though it has just occurred to him, ' +
'what came in on the stage.',
options: [
{
id: 'tell_him',
label: 'Tell him what came in on the stage.',
result:
'Hinges, nails, and a crate consigned to a name nobody in Cold Fork ' +
'has heard of. He writes none of it down and remembers all of it.',
effects: [
{ path: 'relationships.ward.trusts_you', op: 'add', value: 8 },
{ path: 'relationships.ward.wary_of_you', op: 'add', value: -4 },
{ path: 'standing', op: 'add', value: -1 },
],
},
{
id: 'say_nothing',
label: 'Say that is Vane’s business and not yours.',
result:
'He agrees that it is, pleasantly, and stays leaning on the gate for ' +
'another twenty minutes.',
effects: [
{ path: 'skills.nerve', op: 'add', value: 2 },
{ path: 'relationships.ward.wary_of_you', op: 'add', value: 6 },
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 5 },
{ path: 'standing', op: 'add', value: 2 },
],
},
{
id: 'ask_him_back',
label: 'Ask him what he is actually looking for.',
requires: [{ path: 'skills.trade', op: '>=', value: 12 }],
result:
'He tells you, which surprises both of you. A crate a month, six ' +
'months running, and no consignee that exists.',
effects: [
{ path: 'skills.trade', op: 'add', value: 3 },
{ path: 'skills.nerve', op: 'add', value: 2 },
{ path: 'relationships.ward.trusts_you', op: 'add', value: 10 },
],
},
],
},
{
id: 'ward_deputizes',
stage: 'hand',
weight: 35,
cooldown: 10,
requires: [
{ path: 'turn', op: '>=', value: 18 },
{ path: 'relationships.ward.trusts_you', op: '>=', value: 45 },
],
description:
'There is a man in the one cell who needs moving to the county seat and ' +
'Ward cannot leave the town without somebody in it. He is not offering ' +
'you a job. He is offering you two days.',
options: [
{
id: 'ride_along',
label: 'Ride along.',
result:
'Two days out and two back, and the prisoner talks for all four. ' +
'Ward does not, which turns out to be the instruction.',
effects: [
{ path: 'money', op: 'add', value: 12 },
{ path: 'skills.nerve', op: 'add', value: 4 },
{ path: 'standing', op: 'add', value: 5 },
{ path: 'relationships.ward.trusts_you', op: 'add', value: 12 },
{ path: 'relationships.hollis.trusts_you', op: 'add', value: -4 },
{ path: 'notable', op: 'push', value: 'Rode to the county seat with Ward and a prisoner.' },
],
},
{
id: 'mind_the_town',
label: 'Offer to mind the town instead.',
requires: [{ path: 'skills.nerve', op: '>=', value: 22 }],
result:
'Two days with the keys and nothing whatever happens, which you ' +
'later understand was the test.',
effects: [
{ path: 'money', op: 'add', value: 12 },
{ path: 'skills.nerve', op: 'add', value: 3 },
{ path: 'standing', op: 'add', value: 8 },
{ path: 'relationships.ward.trusts_you', op: 'add', value: 15 },
{ path: 'notable', op: 'push', value: 'Held the town for two days while Ward was gone.' },
],
},
{
id: 'decline_ward',
label: 'Say the yard cannot spare you.',
result:
'It can. Both of you know it can. He takes the storekeeper instead ' +
'and the storekeeper tells the story for a year.',
effects: [
{ path: 'skills.trade', op: 'add', value: 2 },
{ path: 'relationships.ward.trusts_you', op: 'add', value: -8 },
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 6 },
],
},
],
},
{
id: 'saturday_night',
stage: 'hand',
weight: 55,
cooldown: 5,
description:
'The Cordwood on a Rest night: Dutch at the long table, half the yard ' +
'with him, and a game of cards that everybody present knows the ' +
'storekeeper’s nephew cannot afford to be in.',
options: [
{
id: 'sit_in',
label: 'Sit in.',
result:
'You come out four dollars ahead and the nephew comes out eleven ' +
'behind. Nobody says anything about it, then or afterwards.',
effects: [
{ path: 'money', op: 'add', value: 4 },
{ path: 'skills.trade', op: 'add', value: 2 },
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 5 },
{ path: 'standing', op: 'add', value: -2 },
],
},
{
id: 'buy_the_table',
label: 'Buy the table a round and stay out of the game.',
result:
'Six dollars, and for the rest of the month you are a man who bought ' +
'the table a round, which in Cold Fork is a kind of address.',
effects: [
{ path: 'money', op: 'add', value: -6 },
{ path: 'standing', op: 'add', value: 5 },
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 4 },
{ path: 'relationships.dutch.wary_of_you', op: 'add', value: -3 },
],
},
{
id: 'walk_the_nephew_out',
label: 'Get the nephew out of the game.',
requires: [{ path: 'skills.nerve', op: '>=', value: 12 }],
result:
'You do it badly and in front of everyone, and it works. The ' +
'storekeeper hears about it before you are back at the yard.',
effects: [
{ path: 'skills.nerve', op: 'add', value: 3 },
{ path: 'standing', op: 'add', value: 7 },
{ path: 'relationships.dutch.wary_of_you', op: 'add', value: 5 },
{ path: 'relationships.ward.trusts_you', op: 'add', value: 5 },
{ path: 'notable', op: 'push', value: 'Pulled the storekeeper’s nephew out of a card game.' },
],
},
{
id: 'early_night',
label: 'Go up early.',
result: 'The ceiling of a room at the Cordwood, at nine at night.',
effects: [
{ path: 'skills.trade', op: 'add', value: 1 },
{ path: 'relationships.dutch.trusts_you', op: 'add', value: -2 },
],
},
],
},
{
id: 'the_consignee',
stage: 'hand',
weight: 25,
cooldown: 12,
requires: [
{ path: 'turn', op: '>=', value: 24 },
{ path: 'relationships.ward.trusts_you', op: '>=', value: 50 },
],
description:
'The crate with no consignee is on the platform again, and this month ' +
'the manifest has Vane’s own hand on it. Ward is not at the gate today, ' +
'which is itself a decision somebody made.',
options: [
{
id: 'load_it',
label: 'Load it and drive it out to the Ridge like any other crate.',
result:
'It is heavier than hinges. Vane pays you eighteen dollars for a ' +
'nine dollar run and neither of you mentions the difference.',
effects: [
{ path: 'money', op: 'add', value: 18 },
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 10 },
{ path: 'relationships.ward.trusts_you', op: 'add', value: -12 },
{ path: 'standing', op: 'add', value: -3 },
{ path: 'notable', op: 'push', value: 'Ran the unmarked crate out to the Ridge for Vane.' },
],
},
{
id: 'tell_ward',
label: 'Go and find Ward.',
result:
'He looks at the manifest for a long time and then puts it back on ' +
'the platform exactly as he found it. Nothing happens for eleven ' +
'days, and then a great deal happens at once.',
effects: [
{ path: 'skills.nerve', op: 'add', value: 4 },
{ path: 'standing', op: 'add', value: 6 },
{ path: 'relationships.ward.trusts_you', op: 'add', value: 20 },
{ path: 'relationships.hollis.trusts_you', op: 'add', value: -14 },
{ path: 'notable', op: 'push', value: 'Took Vane’s manifest to Marshal Ward.' },
],
},
{
id: 'ask_vane_direct',
label: 'Ask Vane what is in it.',
requires: [{ path: 'skills.trade', op: '>=', value: 22 }],
result:
'He tells you, standing in the doorway of the office as always, and ' +
'then asks what you intend to do about it. It is the first real ' +
'question anybody in Cold Fork has asked you.',
effects: [
{ path: 'skills.trade', op: 'add', value: 4 },
{ path: 'skills.nerve', op: 'add', value: 3 },
{ path: 'standing', op: 'add', value: 2 },
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 6 },
{ path: 'notable', op: 'push', value: 'Asked Vane what was in the crate, and got an answer.' },
],
},
],
},
];
export default people;
+145
View File
@@ -0,0 +1,145 @@
// Taking the star.
//
// The same shape as Corporate Ladder's Dispatch offer, and for the same
// reason: the engine has no notion of a promotion, only of a stage that
// determines which events are in the pool. The offer is an ordinary event
// whose effects happen to include { path: 'stage', op: 'set', value: 'deputy' }.
//
// Declining is a real option. It comes back after a cooldown, and staying in
// the yard to build trade and a book full of favours is a way to play rather
// than a mistake.
export const promotion = [
{
// The first real player asked, thirty turns in, how they would know they
// were getting close. Nothing said. This does, in the town's own terms: it
// names what the offer is waiting for without printing a threshold. It
// comes once, somewhat before the offer can, and only to a hand already
// heading that way. See DECISIONS #40.
id: 'ward_takes_notice',
stage: 'hand',
weight: 300,
once: true,
requires: [
{ path: 'turn', op: '>=', value: 24 },
{ path: 'standing', op: '>=', value: 55 },
],
description:
'Ward stops at the yard gate on his way past and says, to nobody in ' +
'particular, that the county will want a second man by winter. The man ' +
'will need a name the street says for the right reasons, and he will ' +
'need to be one Ward can take at his word — or one who has already ' +
'walked into trouble on the Ridge road and walked back out of it.',
options: [
{
id: 'ask_what_it_takes',
label: 'Ask him how long a man would have to wait.',
result:
'He says not before the fortieth day of anybody’s employment, and ' +
'that he is not talking about anybody in particular, and goes on up ' +
'the street.',
effects: [
{ path: 'relationships.ward.trusts_you', op: 'add', value: 3 },
{ path: 'notable', op: 'push', value: 'Heard from Ward that the county would want a second man.' },
],
},
{
id: 'happy_in_the_yard',
label: 'Say you are happy in the yard.',
result: 'Vane, in the office doorway, hears you say it.',
effects: [
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 3 },
],
},
{
id: 'remember_it',
label: 'Say nothing, and remember it.',
result: 'He did not expect an answer, and seems to like not getting one.',
effects: [
{ path: 'skills.nerve', op: 'add', value: 1 },
],
},
],
},
{
id: 'deputy_offer',
stage: 'hand',
weight: 800,
cooldown: 12,
// A long way in, deliberately. The yard is where most of this pack lives,
// and the star at turn twenty would skip it.
requires: [
{ path: 'standing', op: '>=', value: 70 },
{ path: 'turn', op: '>=', value: 40 },
// Two ways to be offered it: the marshal has decided about you, or the
// town has, on the evidence of what you have walked into.
{
any: [
{ path: 'relationships.ward.trusts_you', op: '>=', value: 68 },
{ path: 'skills.nerve', op: '>=', value: 40 },
],
},
],
description:
'Ward has the county paper that makes a deputy and has already written a ' +
'name on it. He says the pay is thirty-eight a run and the work is mostly ' +
'walking about, and that the second half of that is a lie he tells ' +
'everybody once.',
options: [
{
id: 'accept',
label: 'Take the star.',
result:
'Vane says nothing at all, which is worse than anything he could ' +
'have said. Dutch carries your kit up the street and talks the ' +
'entire way about the Ridge road.',
effects: [
{ path: 'stage', op: 'set', value: 'deputy' },
{ path: 'flags.sworn_in', op: 'set', value: true },
{ path: 'standing', op: 'add', value: 6 },
{ path: 'relationships.ward.trusts_you', op: 'add', value: 5 },
{ path: 'relationships.hollis.trusts_you', op: 'add', value: -14 },
{ path: 'relationships.dutch.wary_of_you', op: 'add', value: 10 },
{ path: 'notable', op: 'push', value: 'Took the star.' },
],
},
{
id: 'accept_and_speak',
label: 'Take it, and put Dutch up for the good runs.',
requires: [{ path: 'relationships.dutch.trusts_you', op: '>=', value: 55 }],
result:
'Vane writes the name down in front of you, which is as close as he ' +
'comes to a farewell. Dutch is insufferable about it for a month and ' +
'then, quietly, is not.',
effects: [
{ path: 'stage', op: 'set', value: 'deputy' },
{ path: 'flags.sworn_in', op: 'set', value: true },
{ path: 'flags.spoke_for_dutch', op: 'set', value: true },
{ path: 'standing', op: 'add', value: 4 },
{ path: 'relationships.ward.trusts_you', op: 'add', value: 5 },
{ path: 'relationships.hollis.trusts_you', op: 'add', value: -8 },
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 15 },
{ path: 'relationships.dutch.owes_you', op: 'add', value: 20 },
{ path: 'notable', op: 'push', value: 'Took the star, and put Dutch up for the good runs.' },
],
},
{
id: 'decline',
label: 'Not yet.',
result:
'He folds the paper back into his coat. He has carried it around ' +
'unsigned for six years and is in no hurry now.',
effects: [
{ path: 'skills.nerve', op: 'add', value: 3 },
{ path: 'standing', op: 'add', value: -3 },
{ path: 'relationships.ward.trusts_you', op: 'add', value: -6 },
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 8 },
{ path: 'notable', op: 'push', value: 'Turned down the star.' },
],
},
],
},
];
export default promotion;
+432
View File
@@ -0,0 +1,432 @@
// Ordinary days in the yard.
//
// `stage_day`, `ridge_road` and `yard_work` carry no conditions, no cooldown
// and no `once`, which makes them this stage's floor: whatever else is
// excluded, there is always a turn to play.
//
// Note what the ungated options give. Corporate Ladder put four of its best
// negotiation gains behind a negotiation gate, and its logs show players
// bouncing off that wall for thirty turns. Every gate in this pack has ungated
// repeatable growth underneath it — see DECISIONS.md #32.
export const routine = [
{
id: 'first_morning',
stage: 'hand',
weight: 99999,
once: true,
description:
'Hollis Vane looks at your hands rather than your face, says the pay is ' +
'twenty-four dollars a run, the room at the Cordwood is eight and the ' +
'feed is what it is, and lets you do that arithmetic yourself. Dutch ' +
'Rennels is already harnessing.',
options: [
{
id: 'ask_the_rate',
label: 'Ask what a man makes on top of the twenty-four.',
result:
'He says that depends entirely on you, and goes inside. It is the ' +
'most useful thing anybody tells you all week.',
effects: [
{ path: 'skills.trade', op: 'add', value: 3 },
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 2 },
{ path: 'notable', op: 'push', value: 'Asked Vane what the work was really worth.' },
],
},
{
id: 'get_to_work',
label: 'Say nothing and pick up a harness.',
result:
'Dutch watches you do it wrong, then watches you do it right, and ' +
'does not offer a word either time.',
effects: [
{ path: 'skills.nerve', op: 'add', value: 2 },
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 4 },
{ path: 'relationships.dutch.wary_of_you', op: 'add', value: 3 },
],
},
{
id: 'ask_dutch',
label: 'Ask Dutch which of these animals bites.',
result:
'He tells you. He also tells you he has driven the Ridge road two ' +
'years, which is not an answer to the question you asked.',
effects: [
{ path: 'skills.trade', op: 'add', value: 1 },
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 5 },
{ path: 'relationships.dutch.wary_of_you', op: 'add', value: -5 },
],
},
],
},
{
id: 'stage_day',
stage: 'hand',
weight: 100,
description:
'The stage is in and there are nine crates on the platform for a wagon ' +
'that holds seven. The driver wants to be gone inside the hour and is ' +
'saying so at length.',
options: [
{
id: 'two_trips',
label: 'Run it twice. Nobody has to know it took all day.',
result: 'It takes all day. Everybody knows.',
effects: [
{ path: 'money', op: 'add', value: 4 },
{ path: 'skills.trade', op: 'add', value: 2 },
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 2 },
],
},
{
id: 'load_high',
label: 'Stack it high and rope it down.',
result:
'Seven crates and two more where a man would sit. It holds as far as ' +
'the creek crossing, which is farther than you expected.',
effects: [
{ path: 'money', op: 'add', value: 8 },
{ path: 'skills.nerve', op: 'add', value: 2 },
{ path: 'standing', op: 'add', value: -2 },
],
},
{
id: 'talk_the_driver',
label: 'Talk the driver into leaving two for the next stage.',
requires: [{ path: 'skills.trade', op: '>=', value: 12 }],
result:
'He leaves two. He also mentions your name at the far end of the ' +
'line, favourably, which you will not hear about for a month.',
effects: [
{ path: 'money', op: 'add', value: 6 },
{ path: 'skills.trade', op: 'add', value: 3 },
{ path: 'standing', op: 'add', value: 3 },
],
},
],
},
{
id: 'ridge_road',
stage: 'hand',
weight: 100,
description:
'A load for the Ridge camp, out and back in a day if the weather holds. ' +
'The last mile is the one with the bad turn on it, and everybody knows ' +
'which mile that is.',
options: [
{
id: 'drive_it',
label: 'Take it out yourself.',
result:
'The bad turn is exactly as advertised. You come back with the wagon ' +
'and the animals and most of your composure.',
effects: [
{ path: 'money', op: 'add', value: 9 },
{ path: 'skills.nerve', op: 'add', value: 3 },
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 2 },
],
},
{
id: 'go_with_dutch',
label: 'Ask Dutch to take the reins and ride along.',
result:
'He drives it in silence and points at the bad turn without slowing. ' +
'You learn the road. You do not learn much else.',
effects: [
{ path: 'money', op: 'add', value: 4 },
{ path: 'skills.trade', op: 'add', value: 2 },
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 4 },
{ path: 'relationships.dutch.owes_you', op: 'add', value: -2 },
],
},
{
id: 'take_the_gun',
label: 'Ask Hollis for the scattergun and go alone.',
requires: [{ path: 'skills.nerve', op: '>=', value: 12 }],
result:
'He hands it over without a word, which is the first time he has ' +
'agreed to anything you asked for.',
effects: [
{ path: 'money', op: 'add', value: 14 },
{ path: 'skills.nerve', op: 'add', value: 3 },
{ path: 'flags.carries_iron', op: 'set', value: true },
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 5 },
{ path: 'standing', op: 'add', value: 2 },
],
},
],
},
{
id: 'yard_work',
stage: 'hand',
weight: 100,
description:
'No stage, no run, and a yard full of things that are nearly broken. ' +
'Hollis is in the office with the ledger and the door shut.',
options: [
{
id: 'mend_harness',
label: 'Mend the harness that keeps parting.',
result:
'You find where it parts. It is not where anybody has been mending ' +
'it, which explains the last two years.',
effects: [
{ path: 'skills.trade', op: 'add', value: 2 },
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 3 },
{ path: 'relationships.dutch.wary_of_you', op: 'add', value: 2 },
],
},
{
id: 'shoe_the_team',
label: 'Walk the team down to the smith and stand for the bill.',
result:
'Eleven dollars of your own money on Vane’s animals. The smith ' +
'writes your name in his book, and so, eventually, does Vane.',
effects: [
{ path: 'money', op: 'add', value: -11 },
{ path: 'skills.trade', op: 'add', value: 2 },
{ path: 'relationships.hollis.owes_you', op: 'add', value: 12 },
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 4 },
],
},
{
id: 'idle',
label: 'Sit in the shade until something needs doing.',
result:
'Nothing needs doing. It is the first hour of the week that has been ' +
'yours, and you notice it going.',
effects: [
{ path: 'skills.nerve', op: 'add', value: 1 },
{ path: 'relationships.hollis.trusts_you', op: 'add', value: -3 },
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 2 },
],
},
],
},
{
id: 'broken_axle',
stage: 'hand',
weight: 55,
cooldown: 5,
description:
'The near wagon comes back with a cracked axle and a driver who says it ' +
'was cracked when he took it. Dutch says it was not.',
options: [
{
id: 'fix_it',
label: 'Get under it and put a splint on.',
result:
'It will hold for a season, which is longer than the last three ' +
'repairs on the same wagon.',
effects: [
{ path: 'money', op: 'add', value: 3 },
{ path: 'skills.trade', op: 'add', value: 3 },
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 3 },
],
},
{
id: 'back_dutch',
label: 'Say Dutch is right, because Dutch is right.',
result:
'The driver takes it badly and the yard takes note. Dutch says ' +
'nothing, and writes something in his book.',
effects: [
{ path: 'standing', op: 'add', value: -3 },
{ path: 'skills.nerve', op: 'add', value: 2 },
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 8 },
{ path: 'relationships.dutch.owes_you', op: 'add', value: 6 },
],
},
{
id: 'stay_out',
label: 'Stay out of it, and take neither side about the axle.',
result: 'Both of them decide, separately, that you are no use to them.',
effects: [
{ path: 'relationships.dutch.trusts_you', op: 'add', value: -4 },
{ path: 'relationships.hollis.trusts_you', op: 'add', value: -2 },
{ path: 'skills.trade', op: 'add', value: 1 },
],
},
],
},
{
id: 'the_store',
stage: 'hand',
weight: 60,
cooldown: 4,
description:
'Everything in Cold Fork that is not freight is in this one building, ' +
'and the book on the counter is where the town keeps its real accounts.',
options: [
{
id: 'buy_plain',
label: 'Buy what you need and pay for it.',
result: 'Coffee, a shirt, and a second blanket. The nights are turning.',
effects: [
{ path: 'money', op: 'add', value: -6 },
{ path: 'standing', op: 'add', value: 2 },
],
},
{
id: 'on_credit',
label: 'Put it on the book.',
result:
'He writes it without looking up. Being in the book is not a ' +
'disgrace here; being in it a long time is.',
effects: [
{ path: 'owed', op: 'add', value: 14 },
{ path: 'flags.owes_the_store', op: 'set', value: true },
{ path: 'standing', op: 'add', value: -1 },
],
},
{
id: 'haggle',
label: 'Argue the price of the coffee on principle.',
result:
'You win eleven cents and an hour of your life goes into it. He ' +
'enjoys it more than you do, and remembers you afterwards.',
effects: [
{ path: 'money', op: 'add', value: -4 },
{ path: 'skills.trade', op: 'add', value: 3 },
{ path: 'standing', op: 'add', value: 1 },
],
},
{
id: 'pay_down',
label: 'Pay the book down, up to twenty dollars.',
// Hidden rather than greyed out when there is nothing owed. A locked
// door is worth showing when it is a door the player could open later
// by playing differently; "pay down what you owe" shown to a man who
// owes nothing is not that. It was the most-locked option in a real
// 181-turn session, six times, and told the player nothing each time.
whenLocked: 'hide',
requires: [
{ path: 'owed', op: '>', value: 0 },
{ path: 'money', op: '>=', value: 20 },
],
result: 'He crosses a line through it and turns the page back over.',
// What is owed, up to twenty, and never more. A flat -20 against a
// four-dollar balance took sixteen dollars for nothing in a real
// session. Money comes first: both effects read `owed` before it moves.
// See DECISIONS #38.
effects: [
{ path: 'money', op: 'add', value: { from: 'owed', times: -1, cap: 20 } },
{ path: 'owed', op: 'add', value: { from: 'owed', times: -1, cap: 20 } },
{ path: 'standing', op: 'add', value: 2 },
],
},
],
},
{
id: 'weather',
stage: 'hand',
weight: 45,
cooldown: 6,
description:
'It has been raining since the small hours and the creek crossing is ' +
'four feet up. There is a load for the camp that was wanted yesterday.',
options: [
{
id: 'wait_it_out',
label: 'Nothing moves today.',
result:
'The creek is down by evening and the camp waits a day. It is the ' +
'right call, which is not the same as a popular one.',
effects: [
{ path: 'skills.trade', op: 'add', value: 2 },
{ path: 'relationships.hollis.trusts_you', op: 'add', value: -2 },
{ path: 'standing', op: 'add', value: 1 },
],
},
{
id: 'ford_it',
label: 'Ford it.',
result:
'The wagon floats for about six feet, which is six feet more than a ' +
'wagon should. The load gets there wet and on time.',
effects: [
{ path: 'money', op: 'add', value: 16 },
{ path: 'skills.nerve', op: 'add', value: 4 },
{ path: 'standing', op: 'add', value: 3 },
{ path: 'relationships.dutch.wary_of_you', op: 'add', value: 6 },
],
},
{
id: 'long_way',
label: 'Take the long road round the head of the creek.',
requires: [{ path: 'skills.trade', op: '>=', value: 12 }],
result:
'Eleven extra miles and a dry load. Dutch did not know that road was ' +
'passable, and now has to know that you did.',
effects: [
{ path: 'money', op: 'add', value: 11 },
{ path: 'skills.trade', op: 'add', value: 2 },
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 4 },
{ path: 'relationships.dutch.wary_of_you', op: 'add', value: 4 },
],
},
],
},
{
id: 'road_agents',
stage: 'hand',
weight: 30,
cooldown: 8,
requires: [{ path: 'turn', op: '>=', value: 8 }],
description:
'Two riders come out of the rocks a mile short of the camp and sit their ' +
'horses across the road. Neither of them looks like he has done this ' +
'often enough to be calm about it.',
options: [
{
id: 'hand_it_over',
label: 'Give them the box and remember the horses.',
result:
'They take it and go. You give Marshal Ward a description of two ' +
'horses that is better than anything he has had in a year.',
effects: [
{ path: 'money', op: 'add', value: -8 },
{ path: 'skills.nerve', op: 'add', value: 2 },
{ path: 'relationships.ward.trusts_you', op: 'add', value: 8 },
{ path: 'notable', op: 'push', value: 'Was stopped on the Ridge road, and gave Ward the horses.' },
],
},
{
id: 'talk_them_down',
label: 'Tell them what is actually in the box.',
result:
'It is a crate of hinges and forty pounds of nails. The older one ' +
'laughs. They let you by, which is worse for them than it is for you.',
effects: [
{ path: 'skills.trade', op: 'add', value: 4 },
{ path: 'standing', op: 'add', value: 4 },
{ path: 'notable', op: 'push', value: 'Talked two road agents out of a crate of hinges.' },
],
},
{
id: 'bring_it_up',
label: 'Bring the scattergun up off the floorboards.',
requires: [{ path: 'flags.carries_iron', op: '==', value: true }],
result:
'Nobody fires. They go back into the rocks and the story is around ' +
'Cold Fork before the wagon is.',
effects: [
{ path: 'skills.nerve', op: 'add', value: 5 },
{ path: 'standing', op: 'add', value: 8 },
{ path: 'relationships.ward.trusts_you', op: 'add', value: 6 },
{ path: 'relationships.dutch.wary_of_you', op: 'add', value: 8 },
{ path: 'notable', op: 'push', value: 'Brought the scattergun up on the Ridge road.' },
],
},
],
},
];
export default routine;
+285
View File
@@ -0,0 +1,285 @@
// The Frontier — the second content pack for The Ladder.
//
// Cold Fork, a freight town at the end of a stage line. You are a hired hand
// in Vane & Co.'s yard; the town has a marshal who is tired and a road that is
// not safe. Two rungs, the same as Corporate Ladder, and not one line of
// engine code between them.
//
// This pack exists to prove the split holds. Everything that makes it a
// different game from Corporate Ladder is in this directory: a four-day cycle
// instead of a week, money that arrives from work rather than from a wage,
// relationship stats named for a place where being owed a favour matters more
// than being liked, and a skill pair of nerve and trade.
import { characters } from './characters.js';
import { events } from './events/index.js';
export const frontier = {
id: 'frontier',
name: 'The Frontier',
blurb:
'Cold Fork, at the end of a stage line: a freight yard, a road that is ' +
'not safe, and a marshal who has been six years retiring.',
version: '0.1.1',
turnUnit: 'day',
startingStage: 'hand',
peopleHeading: 'Cold Fork',
stages: {
hand: { label: 'The Yard', title: 'Hired Hand' },
deputy: { label: 'The Office', title: 'Deputy Marshal' },
},
// Not a week. The stage comes every fourth day, and the whole town runs on
// that: the outfit settles up when the stage comes in, the boarding house
// collects on the rest day before it. A pack that wants a seven-day week
// says seven here; the engine has no opinion either way, and Corporate
// Ladder's calendar is the same field with different numbers in it.
calendar: {
cycleLength: 4,
cycleName: 'Run',
unitNames: ['Stage day', 'Haul', 'Yard', 'Rest'],
},
// Money is smaller here than in Corporate Ladder by a factor of about
// fifteen, and it behaves differently: the settlement covers board and room
// and very little else, so the difference between a good run and a bad one
// is what you took from the work. See `tuning` below for the arithmetic that
// is actually asserted.
state: {
'money': { initial: 40, min: -400, max: 100000 },
// The store ledger. Named for what it is rather than for a loan: nobody in
// Cold Fork lends money, they let you carry a balance.
'owed': { initial: 0, min: 0, max: 100000 },
'standing': { initial: 50, min: 0, max: 100 },
'skills.nerve': { initial: 5, min: 0, max: 100 },
'skills.trade': { initial: 5, min: 0, max: 100 },
// The same shape as Corporate Ladder's lifestyle, in this setting's own
// terms and at this setting's own scale: what the office expects a man to
// turn up on, and what keeping that up costs. See DECISIONS #36.
'lifestyle': { initial: 0, min: 0, max: 2 },
'flags.carries_iron': { initial: false },
'flags.owes_the_store': { initial: false },
'flags.covered_for_dutch': { initial: false },
'flags.sworn_in': { initial: false },
'flags.spoke_for_dutch': { initial: false },
'notable': { initial: [] },
},
// A different vocabulary from Corporate Ladder's likes/fears/helps, and the
// reason the vocabulary is content's to choose. Out here the useful question
// about a person is not whether they like you: it is whether they will take
// your word, and whether they are carrying a debt to you they mean to pay.
relationshipStats: {
trusts_you: { label: 'Takes your word', initial: 45, min: 0, max: 100 },
owes_you: { label: 'Owes you', initial: 10, min: 0, max: 100 },
wary_of_you: { label: 'Wary of you', initial: 15, min: 0, max: 100 },
},
// The economic shape, and the thing that makes this pack a real test of the
// engine rather than Corporate Ladder in a hat: there is no wage worth
// living on. Vane settles up when the stage comes in and it covers the room
// and the feed and four dollars besides. Everything else you earn, you earn
// on a turn, by choosing to — and what the choices cost is where the
// pressure comes from, not an unexplained drain.
//
// Keep this arithmetic honest when tuning. Per four-day run:
// hand: -12 board -8 room +24 settlement = +4
// deputy: -12 board -8 room +38 pay = +18
// Both are asserted directly by the conformance suite against `tuning`
// below, so a number changed here without re-deriving it fails the build
// rather than trapping a player forty turns in.
upkeep: [
{ label: 'Board and feed', effects: [{ path: 'money', op: 'add', value: -3 }] },
// Turn 1 is a Stage day, so offset 3 is the first Rest day and offset 4
// the next Stage day — the room is collected the night before the outfit
// settles up, which is the boarding house's whole business model.
{
label: 'Room at the Cordwood',
every: 4,
offset: 3,
effects: [{ path: 'money', op: 'add', value: -8 }],
},
// Labelled as wages, because they are. "Settlement" read as jargon to the
// first real player, who could not tell it was the twenty-four a run the
// first morning quotes; the ledger is written for a player (DECISIONS #27).
{
label: 'Wages from Vane',
every: 4,
offset: 4,
requires: [{ path: 'stage', op: '==', value: 'hand' }],
effects: [{ path: 'money', op: 'add', value: 24 }],
},
{
label: 'Wages from the county',
every: 4,
offset: 4,
// The town pays a real wage, and it is still not much of a living. What
// changes with the star is not the money; it is what the days contain.
requires: [{ path: 'stage', op: '==', value: 'deputy' }],
effects: [{ path: 'money', op: 'add', value: 38 }],
},
// What the star is expected to arrive on, and what that expectation costs
// to meet. The office nets +18 a run; a deputy on his own horse is +11,
// and one with the horse and the room is +4 and makes the rest on the
// days. Money is meant to keep mattering after the promotion — but the
// first pass charged 9 for the room and left a keeper's median balance at
// $8 after 200 turns, which is not pressure, it is the debt trap this pack
// was tuned twice to avoid.
{
label: 'Feed and shoeing',
every: 4,
offset: 4,
requires: [{ path: 'lifestyle', op: '>=', value: 1 }],
effects: [{ path: 'money', op: 'add', value: -7 }],
},
{
label: 'The room over the Ellis place',
every: 4,
offset: 4,
requires: [{ path: 'lifestyle', op: '>=', value: 2 }],
effects: [{ path: 'money', op: 'add', value: -7 }],
},
// Gated on the office for the same reason Corporate Ladder gates its
// equivalent on Dispatch: a hand cannot afford any of this, and being
// charged standing for that would be a trap rather than a choice.
{
label: 'What the star is expected to be',
every: 4,
offset: 4,
requires: [
{ path: 'stage', op: '==', value: 'deputy' },
{ path: 'lifestyle', op: '<', value: 1 },
],
effects: [{ path: 'standing', op: 'add', value: -2 }],
},
{
label: 'What the star is expected to be',
every: 4,
offset: 4,
requires: [
{ path: 'stage', op: '==', value: 'deputy' },
{ path: 'standing', op: '>=', value: 65 },
{ path: 'lifestyle', op: '<', value: 2 },
],
effects: [{ path: 'standing', op: 'add', value: -2 }],
},
{
// The top step, for the reason Corporate Ladder's has one: without it a
// man who plays for standing every single day holds a hundred of it on a
// livery horse, and the bay is money thrown away. With it he settles in
// the low eighties and the last stretch has to be bought.
label: 'What the star is expected to be',
every: 4,
offset: 4,
requires: [
{ path: 'stage', op: '==', value: 'deputy' },
{ path: 'standing', op: '>=', value: 80 },
{ path: 'lifestyle', op: '<', value: 2 },
],
effects: [{ path: 'standing', op: 'add', value: -14 }],
},
{
// The charge that never stops, for the reason Corporate Ladder has one:
// without it a man who has bought everything and stands well with the
// whole town has nothing left that money is for. Being somebody in Cold
// Fork costs, every run, and nothing you can buy ends it.
label: 'What the standing costs',
every: 4,
offset: 4,
requires: [
{ path: 'stage', op: '==', value: 'deputy' },
{ path: 'standing', op: '>=', value: 85 },
],
// Four rather than six: this pack's late game is already flat — measured,
// a deputy who has bought everything drifts 0 a run either way — so the
// charge is here to keep money live, not to arrest an accumulation that
// does not happen. Six left an ordinary run at 55; four leaves 105.
effects: [{ path: 'money', op: 'add', value: -4 }],
},
// Store credit costs more the deeper in you are, the same step function
// Corporate Ladder uses for its loan — a shape, not a setting.
{
label: 'Store credit',
every: 4,
offset: 4,
requires: [{ path: 'owed', op: '>', value: 0 }],
effects: [{ path: 'money', op: 'add', value: -3 }],
},
{
label: 'Store credit (carried)',
every: 4,
offset: 4,
requires: [{ path: 'owed', op: '>=', value: 60 }],
effects: [{ path: 'money', op: 'add', value: -3 }],
},
{
label: 'Store credit (posted)',
every: 4,
offset: 4,
requires: [{ path: 'owed', op: '>=', value: 120 }],
effects: [{ path: 'money', op: 'add', value: -4 }],
},
],
// What the conformance suite holds this pack to. Declaring it here rather
// than writing it into a test is the same principle as the rest of the pack:
// the numbers a setting must hit are the setting's business. See
// DECISIONS.md #31.
tuning: {
// Net movement of `money` from upkeep alone, per calendar cycle, at each
// stage. Upkeep is the floor a player plays against: barely above water in
// the yard, and a real if unremarkable living with the star.
baselinePerCycle: {
hand: [0, 12],
deputy: [10, 30],
},
// States a player opts into or earns, excluded from the baseline above:
// the floor is what a man faces owing nothing, keeping nothing, and being
// nobody in particular.
baselineExcludes: ['owed', 'lifestyle', 'standing'],
},
display: {
'money': { label: 'Money', format: 'money', order: 1 },
'owed': { label: 'Owed at the store', format: 'money', order: 2, hideWhenZero: true },
'standing': { label: 'Standing', format: 'meter', order: 3 },
'skills.nerve': { label: 'Nerve', format: 'meter', order: 4 },
'skills.trade': { label: 'Trade', format: 'meter', order: 5 },
'lifestyle': {
label: 'What you keep', format: 'tier', order: 6, hideWhenZero: true,
labels: ['The livery horse', 'Your own horse', 'Horse and a room'],
},
'notable': { hidden: true, inLedger: false },
'flags.carries_iron': {
label: 'The scattergun', hidden: true,
whenTrue: 'You are carrying Vane’s scattergun on the box.',
whenFalse: 'The scattergun is back under the office counter.',
},
'flags.owes_the_store': {
label: 'The store ledger', hidden: true,
whenTrue: 'Your name is in the book at the store.',
whenFalse: 'You are square with the store.',
},
'flags.covered_for_dutch': {
label: 'Dutch’s run', hidden: true,
whenTrue: 'Dutch owes you a run.',
whenFalse: 'You and Dutch are square.',
},
'flags.sworn_in': {
label: 'The star', hidden: true,
whenTrue: 'You are sworn in.',
whenFalse: 'You are back in the yard.',
},
'flags.spoke_for_dutch': {
label: 'Dutch’s name', hidden: true,
whenTrue: 'Dutch’s name is in for the good runs.',
whenFalse: 'Dutch’s name is not in for the good runs.',
},
},
characters,
events,
};
export default frontier;
+24
View File
@@ -0,0 +1,24 @@
// The packs this build ships with.
//
// A setting is a content pack, and this is the only list of them. Adding one
// is an import and an entry — no engine change, no interface change, and
// nothing anywhere else that names it. `packs[0]` is what a first-time player
// starts in.
//
// The bundler follows static imports from the entry module, so appearing in
// this file is also what gets a pack into `dist/theladder.html`.
import { corporateLadder } from './corporateladder/index.js';
import { frontier } from './frontier/index.js';
import { countingHouse } from './countinghouse/index.js';
export const packs = [corporateLadder, frontier, countingHouse];
/** A pack by id, or undefined. Used to resume into whatever was last played. */
export function packById(id) {
return packs.find((pack) => pack.id === id);
}
export const defaultPack = packs[0];
export default packs;
+856
View File
@@ -0,0 +1,856 @@
# 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.
## 31. A pack declares the numbers it must hit
Decision #29 added a test that asserts Corporate Ladder's economic baseline
directly, because a tuning change made without re-deriving what it meant had
trapped a real playtester. That test named the pack, named its stages and
hard-coded its ranges, which meant a second pack got none of it — the guard
that exists precisely because a number went unchecked would itself have gone
uncopied.
So the numbers moved into the pack, as `tuning.baselinePerCycle`:
```js
tuning: {
baselinePerCycle: { hand: [0, 12], deputy: [10, 30] },
},
```
The conformance suite reads that and holds each pack to its own declared range.
A new setting is checked the moment it is in the registry, and what it is
checked against is its designer's stated intent rather than another setting's
numbers. The pack is required to declare it: a missing `tuning` block fails,
because the silent failure mode here is a pack that is never economically
tested at all.
The figure is **derived, not simulated**. An upkeep entry worth `v` every `n`
turns is worth `v * cycleLength / n` per cycle, exactly. Simulating it over a
fixed window reads differently depending on where the last rent lands: Corporate
Ladder measures −19 a fortnight over 280 turns and −42 over 560, and only the
second is the steady state. The original test used the 280-turn window and was
reading a number 23 dollars off the truth — inside its own tolerance, so it
never said so.
## 32. A skill gate is never gated behind itself
Corporate Ladder gates four options on `skills.negotiation >= 15`, and several
of its largest negotiation gains sit behind that same gate — `+4` on
`marlene_confides.ask_how_to_leave`, `+3` on `trevor_favor.trade`. Its logs show
players meeting the wall repeatedly through the first thirty turns.
Measured, the gate is not unreachable: a player steering directly for the skill
is through it by turn 11. What the logs actually show is ordinary players not
prioritising it and having no incidental route, which is a pacing failure rather
than a reachability one — and the reachability test could never have caught it,
because reachability is exactly what holds.
The Frontier is built to a rule instead, asserted in `test/frontier.test.js`:
for every skill threshold in the pack, the growth available *without* passing
that threshold must be able to reach it, from repeatable events — a single
one-off award is not a route. A second test measures it, requiring a player who
steers for a skill to be through its lowest gate inside twenty turns.
The rule is deliberately not in the conformance suite, because Corporate Ladder
does not satisfy it and retuning that pack is a separate decision from adding
this one. It is written down here so the choice is visible rather than implied.
## 33. One save slot per pack, and the picker is the front door
`theladder.save.v1` was a single global slot. The app guarded it by comparing
the packId inside the save against the pack it was about to run, and started a
new game when they differed — so with two settings, the first switch would have
silently destroyed the other career.
Slots are now `theladder.save.v1.<packId>`, one per setting, and switching is
non-destructive in both directions: each run is written to its own slot every
turn, and coming back resumes rather than restarts. `theladder.pack.v1` holds
the last setting played, so a returning player lands back where they were.
The pre-packs slot is still read once, for Corporate Ladder only, and only when
it really holds a Corporate Ladder save. It is never written. Someone mid-career
when this shipped finds their career.
A first-time player — nothing saved, nothing remembered — gets the setting
picker rather than being dropped into whichever pack is first in the registry.
A build shipping one pack skips it entirely.
## 34. What a second setting proved, and what it cost
The engine/content split was a claim until there were two settings. The
Frontier is 29 events across two stages, a four-day cycle instead of a
seven-day one, a different relationship vocabulary (`trusts_you`, `owes_you`,
`wary_of_you`), a different skill pair, and money about fifteen times smaller
that arrives from work rather than from a wage. `src/engine/` was not touched.
Three things outside the engine did have to change, and none of them was about
the setting:
- **`src/io/save.js`** — the single save slot, above.
- **`src/ui/app.js`** — it named one pack; it now holds the current one. The
screens, the engine and the rest of the io layer still take the pack as an
argument and are no less pack-agnostic than before.
- **`src/ui/screens/turn.js:40`** — hardcoded `'The mailroom'` as the heading
over the people panel. That was the interface naming a setting, against
decision #14, and it had gone unnoticed while only one setting existed. It is
now `pack.peopleHeading`.
`tools/build.js` needed one fix, unrelated to packs but exposed by them: its
module scan used `.*?` where `transform` used a pattern that spans lines, so a
destructured import wrapped across several lines was rewritten into a
`__require` for a module the scan had never collected. The bundle built clean
and failed only when run. Both now use `[\s\S]*?`.
The honest summary is that adding a setting required no engine change and three
interface changes, two of which were latent defects that a second pack merely
made visible.
## 35. Three open questions, closed by the designer
Recorded 2026-09-11, alongside 0.3.1.
**Corporate Ladder's negotiation gate stays.** A gate that takes deliberate work
to get past is acceptable, and so is an option that needs groundwork first. #32
remains the rule the Frontier is built to and tested against; it is a choice a
pack may make, not a standard every pack must meet, and Corporate Ladder is not
being retuned toward it.
**The feedback widget stays exactly as it is.** Zero ratings across three
sessions says nothing about the widget: those playtests were rapid, and feedback
came back directly rather than through it. It is not evidence either way until a
playtest runs without that direct channel.
**Money has to matter late, through the cost of standing.** A careful player
ends around $8,300 over 200 turns, and money stops being a constraint. The
direction is that rising standing brings rising expenses — the car, the place to
live, the clothes a position expects. Upkeep entries are already gated
conditions over state, so this is content work, not an engine change.
## 37. A playtest is only as good as its instruments
The second session — 181 turns of the Frontier, the first time that pack has
been played by a human at all — ran against a **stale bundle**. The page had
been loaded before the last rebuild, and switching settings inside an open tab
does not re-fetch the script, so the run exercised everything except the change
it was meant to test. The charge that never stops fired zero times in ninety
turns where its conditions held.
Working that out took file timestamps and a simulation. It should have taken one
line of the log, so three things changed:
- **Every logged turn now records `app_version` and `build`.** The log says
which bundle produced it, and a stale session is visible immediately rather
than looking exactly like a feature that does not work.
- **`src/version.js` was lying.** It hardcoded `0.3.0` through the whole of the
0.3.1 and 0.3.2 work, while playtesters were told to check the status bar to
know what they were on. A version string that lies is worse than none, because
it is trusted. A test now holds it to `package.json`.
- **A locked door is only worth showing when it is a door.** `pay_down` — "put
money against the book" — was the most-locked option in the session, shown six
times to a player who owed nothing. Options gated on a state the player is not
in and cannot choose to enter now carry `whenLocked: 'hide'`. Gates worth
bouncing off, like a skill threshold, stay visible: that is the signal the log
exists to collect.
## 36. Standing costs money to keep
Built in both packs, with no engine change, exactly as #35 predicted.
Each pack declares a `lifestyle` tier — Corporate Ladder's car and address, the
Frontier's own horse and the room over the Ellis place — bought at a second-rung
event and charged for every cycle thereafter. Dispatch nets +49 a week; with the
car +19; with both, **-21**, so a coordinator keeping up has to earn the
difference out of the days themselves. The Frontier runs the same shape at its
own scale: +18, +11, +4 a run.
**The charge that makes it a choice is levied in standing, not money.** A player
whose lifestyle lags behind the job loses standing every cycle, in steps that
steepen as standing rises. Both are gated on the second stage: the first rung
pays too little for any of this, and charging a mailroom associate for a car
would be a trap rather than a decision.
Three things were measured rather than guessed, and all three changed the design:
- **At -2 a week the charge was invisible.** Dispatch hands out standing several
points at a time, so a coordinator who bought nothing still finished pinned at
100. The step had to reach -4/-6 before ordinary play settled in the seventies.
- **A player who chased standing every turn beat it anyway** — 99 standing while
buying nothing, and $2,245 richer than one who kept up. The lifestyle was a
pure loss to anybody paying attention, which is the opposite of the intent.
The top step (-14 above 80 standing) puts that player's ceiling at 82, so the
last fifteen points are bought rather than worked for.
- **The Frontier's first numbers left a keeper on $8 after 200 turns.** That is
not pressure, it is the debt trap this pack was tuned twice to avoid; the room
went from 9 a run to 7.
**Which upkeep counts as the baseline is now the pack's to declare.** The
conformance suite used to exclude debt-gated entries by matching
`/debt|owed|loan/i` against path names — the generic suite guessing at content's
vocabulary, which #31 exists to prevent, and which would have silently counted
every lifestyle tier at once into a baseline that is supposed to mean "the floor
a player faces having opted into nothing." Packs now name those paths in
`tuning.baselineExcludes`, and the suite fails if one is not declared state.
**The top of the ladder was a plateau, so one charge never stops.** The first
human play of the lifestyle system — 96 turns, Corporate Ladder — found both
purchases (the car at turn 52, the address at 75) and showed the standing
pressure working exactly as intended: "Looking the part" took 56 reputation, and
standing sagged into the low seventies on the car alone before recovering to 100
once the address was bought. But from turn 84 the run had nothing left to spend
on: every charge switches off at the top tier, and the balance simply climbed.
Money stopped mattering again, one rung higher than before.
Both packs now carry a charge levied by standing itself, which no purchase ends.
They are shaped differently, and the difference is measured rather than stylistic:
- **Corporate Ladder steps on money.** A flat charge cannot flatten a slope —
late income comes from choices and scales with how well the player plays, so
the flat charge needed to stop an optimiser accumulating (-360 a week) put
ordinary archetypes below zero for 44% of a run and ended them $1,385 in debt.
Stepping on money ($1,200/-60, $2,400/-120, $3,600/-240) lands it on whoever
has it: the balance settles around $4,250 and stops climbing (-3 a week), and
ordinary play comes out *better* than before the charge existed — 13% of a run
below zero against 17%, a tier-2 run ending +$175 rather than -$25.
- **The Frontier is flat, at -4 a run.** Its late game never accumulated in the
first place — a deputy who owns everything drifts 0 a run at every charge
strength tested — so there is no slope to flatten. The charge is there to keep
money live, and break-even at the top is the intent.
**A save written before a stat existed silently loses the content gated on it.**
Found while working out how to playtest this: `loadLocal` returns the saved
state verbatim, and `conditions.js` compares numbers only — so for a career
saved before `lifestyle` existed, both `lifestyle < 2` and `lifestyle >= 1` are
false. The car lot never fires, the purchase never unlocks, the charge never
lands, and nothing says so. Resuming a save would have shown a playtester none
of this feature while reporting nothing wrong. Loading now fills in any path the
pack has gained since (`hydrate`, in `state.js`), writes the repair back, and
does the same for an imported file. This is the price of "adding content is just
adding data", and it applies to every stat any pack adds from here on.
**And a test that had passed for two releases was measuring the wrong thing.**
"The promotion leaves a player better off" compared the end balances of promoted
and unpromoted players pooled across archetypes — but which archetypes get
promoted is not independent of how they spend, so the comparison mostly measured
the mix. It inverted the moment there was something to spend money on. It is now
derived from upkeep arithmetic, and the lifestyle's effect is tested with two
players who differ in exactly one habit.
## 38. A repayment pays what is owed, never more
Recorded 2026-09-15, for 0.3.4. Designer's call: a small engine addition rather
than a content workaround.
The first human session of the Frontier on a current build — 112 turns — owed the
store four dollars on turn 27 and chose "Put money against the book". It took
twenty dollars and cleared four: the option was a flat `money -20, owed -20`, and
`owed` clamped at zero while `money` did not. The ledger showed "−$4 at its
limit", which the player read, reasonably, as a debt still standing.
The same shape was in five options across both packs — the Frontier's store and
both of its hard-times repayments, and both of Corporate Ladder's loan
repayments — and none had been caught, because every archetype that pays debt
pays large debts.
**Content could not fix it.** Effects add or set a number the pack wrote, and a
balance built from +14, +32, +4, −45 and −10 can be anything, so no fixed payment
can equal it. The alternatives were to gate each instalment on the balance
covering it (leaving small balances to cost more than they are) or to retune
every credit amount to a common step (changing tuned numbers to suit a missing
primitive).
**So an `add` effect's value may now be read from state:**
`{ path: 'money', op: 'add', value: { from: 'owed', times: -1, cap: 20 } }` is
"take away whatever is owed, but no more than 20". The amount is resolved at the
moment the effect applies, so a repayment lists the money effect before the
balance effect — both read the balance before it moves. The change record keeps
the number actually applied, with the reference in `source`.
This is not the engine learning about debt. It reads a number from a path without
knowing what the path means, exactly as a condition does, and #1 and #2 still
hold: it is data, validated at load. The validator requires the referenced path to
be declared and `times` and `cap` to be numbers. The test archetypes score such an
effect at its cap, the most a player reading the label could expect.
A conformance test now holds every pack to it: an option that spends money and
reduces a balance — any money-formatted stat other than money — must read the
amount from the balance, or be gated on the balance covering the whole instalment.
## 39. A hidden option is not a locked door in the log either
`whenLocked: 'hide'` (#37) kept "Put money against the book" off the screen of a
player who owed nothing, and it worked. But the turn record listed offered options
without saying which were hidden, so `npm run analyze` reported the option as a
locked door shown three times to a player who never saw it. The instrument was
counting the very thing the fix removed.
The turn record now carries `hidden` for every offered option, and the analyser
skips hidden options when counting locked doors. Logs exported before 0.3.4 do not
have the field and still over-count.
The same session's exports also put a save file next to the feedback log in
`logs/`. The analyser read "the newest `.json`", so whichever of the two was
exported last would have been read as the log. It now reads only
`theladder-feedback-*.json` when no file is named.
## 40. What the first Frontier playtest changed
The session (112 turns, 0.3.3, promoted on turn 44) confirmed the systems #36 and
#37 were built for: both lifestyle purchases were found and taken, the standing
charge fired as designed, money never went below zero, and the run ended flat —
the break-even the Frontier intends at the top. Everything it found wrong was
legibility, plus the defect in #38. All eight of the player's comments were
questions about what a number meant, and both thumbs-down were on events that
confused them.
- **Wages are called wages.** "Settlement" and "Deputy pay" became "Wages from
Vane" and "Wages from the county". The player could not connect "Settlement"
to the twenty-four a run the first morning quotes, and read a run's own +$9 as
the pay going missing (#27: the ledger is written for a player).
- **A clamped change says where it stopped** — "stopped at $0", "stopped at 100" —
instead of "at its limit", which was read as a balance still outstanding.
- **Two option labels were rewritten** that the player could not parse: "Have no
view on the axle" and "take hold of the nearest one".
- **The horse and the room are separate events.** One event offered both, with a
description that was always about the horse, so the room was offered under
horse text and "Buy the bay" was shown locked to a player who had bought it — a
door that could never open. Each is now gated on the tier it sells, and a test
holds that neither is offered once bought.
- **The promotion gets a warning.** "How do I find out how close I am to
promotion?" had no answer anywhere. `ward_takes_notice` comes once, to a hand
past turn 24 with standing 55, and says in the town's terms what the offer waits
for — a name the street says for the right reasons, and a man Ward takes at his
word or one with the Ridge road behind him — without printing a threshold. Across
72 archetype runs it fired in 67, at a median of turn 29, always before the offer.
A numeric progress display was not built: it would need the interface to know
what a promotion is, which #22 keeps in content.
- **The office got five more days.** 69 deputy turns saw 13 distinct situations,
and `office_day` and `the_cells` were 17% of the stage each — the same thinness
#24 caught in Dispatch. Five events were added (the stage in short, the county's
fees, a bounty hunter, a fire at the store, Dutch wanting the Ridge road) and the
horse/room split adds a sixth. Measured across 72 archetype runs of 250 turns,
the three floor events fell from about 17.5% of deputy turns each to about
13.5%. Every new event has an ungated way through, and its skill gates (nerve 30
and 34, trade 28 and 30) sit under growth available without them (#32).
**Measured and not yet understood.** In the same simulation, promoted runs now end
with a median of $164 rather than $88, and a median standing of 89 rather than 98.
Two changes plausibly account for it — repayments no longer overcharge, and lower
standing triggers the $4 standing charge less often — but it has not been
isolated. The declared baselines and every tuning test still pass. The Frontier is
meant to be roughly level at the top, so the next Frontier session should watch
whether money stops mattering again, as it did in Corporate Ladder before #36.
"Owed at the store" was asked for as "a major field at the top". It already is,
second after Money whenever anything is owed. Whether it is prominent enough can
only be judged in a browser, which this machine cannot render (#18).
## 41. Chance, and a cast that changes between runs
Recorded 2026-09-23. Designer's call: a change to the base engine, generic, which
every pack may use and none has to. Corporate Ladder and the Frontier use none of
it and replay exactly as before.
Until now an option did exactly what it said. Skills only decided whether an
option was on the menu (#15, #32). The story-to-pack catalogue of recurring
situations (asking for a rise, the door to the powerful, a colleague's needling)
is made of moments whose outcome depends on who you are and who you are dealing
with, so three things were added. `content/countinghouse` is the pack that exercises them.
**Attributes.** A pack names them in `pack.attributes`. The player's live in
ordinary declared state (`attributes.<id>`), so they grow by effects like anything
else. The prototype's four, agreed with the designer, are Nerve, Wits, Charm and
Resolve. Another pack may rename them, as #14 lets it rename the interface.
**Checks.** An option may carry `check: { attribute, against?, opposedBy?,
relationship?, difficulty?, modifier? }` and then must carry `success` and
`failure`, each with its own `result` and `effects`. Its plain `effects` apply
either way. The chance is
base + perPoint × (yours − theirs) + relationshipPerPoint × (their regard − centre) + modifier,
clamped to [floor, ceiling]
where "theirs" is the opposing character's attribute, or the check's `difficulty`,
or the pack's `unopposed`. Every number is the pack's, in `pack.checks` (#9).
**The player never sees a number.** Options show the odds in words ("a long shot",
"likely"), from bands the pack may rename, and the outcome says whether it came
off. The roll is one draw from the run's own generator, so a run with checks still
replays exactly from its seed and choices (#5, #6). A pack that never checks draws
nothing extra.
**A cast drawn per run.** A character may be a *role* with `candidates`, several
authored people who could fill it, and one is drawn when a run starts. A role may
be a *group* with `count: [min, max]` (two to four clerks), which draws that many
different people into numbered slots (`clerk_1` .. `clerk_4`). Every candidate
carries a name, a description and the same attributes the player has, so a
replayed pack meets a different office with different strengths. Candidates are
authored rather than generated from random parts: a character written as sharp
has the wits to see through a bluff, which a random attribute roll pasted onto a
random description would not guarantee.
- **The draw is saved in engine-owned state** (`cast`, now a reserved root, #4)
and made on its own stream derived from the seed, so it neither moves the event
generator nor depends on it. `hydrate` (#36) draws one for a save that predates
it, and draws the same one every time.
- **Every slot up to a group's maximum is declared in the schema from the start.**
Relationship state for `clerk_3` exists whether or not anyone was drawn into it,
so it validates and saves like any other path. An empty slot is recorded as
`null` and is simply absent from the run.
- **Text names people by role.** `{partner}`, `{chief.first}` and `{clerk.title}`
are filled in from whoever was drawn. An event about "a clerk" says
`involves: ['clerk']`. When it is drawn, one present clerk is bound to it for
the turn, and `{clerk}` in its text, `relationships.{clerk}.trust` in its effects
and `against: '{clerk}'` in a check all mean that person. The binding is saved
with the run. An event cannot fire in a run with nobody to bind. Text kept in
state, such as a notable moment, has its placeholders pinned to the slot
(`{clerk_2}`), so the retrospective names the right person later.
- **The validator holds all of it:**
- attributes declared, and every drawn person carrying all of them;
- enough candidates for a group's maximum, and unique names;
- checks naming real attributes and people, with both branches;
- `{placeholders}` that name real characters, where a group is only named inside an event that binds it;
- no placeholder in an event's own `requires`, which is evaluated before anyone is bound.
**What the test harness had to learn.** The archetypes score an option by its
effects (#21). A checked option is now scored at its expected value: plain effects
in full, and each branch weighted by its chance, the way a player reading the odds
would. The conformance check that no repayment takes more than is owed (#38) now
reads both branches, since a check can pay or spend on either.
**The prototype pack was tuned by measurement, not by eye.** Its first draft
failed three conformance tests:
- the routine ledgers were a third of every player's turns;
- a careless player was broke 40% of a run;
- it had no balance a borrower could owe.
Two more catalogue situations (an errand for the partner's family, and a desk you
and a clerk both want), a lighter floor event, a loan that is refused but still
helps a little, and an "owed to the floor" balance repaid on Saturdays brought
those to 22% at worst, 12% at worst, and a debt that is repaid by reading the
balance (#38).
**Not yet known.** Whether chance makes the same situations worth replaying, and
whether the odds in words are legible, can only be judged by someone playing it
(#18). The pack has one stage on purpose, so that question can be answered before
a career is built on it.
+22
View File
@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>The Ladder</title>
<meta name="description" content="A turn-based career simulation. Start in the mailroom.">
<link rel="stylesheet" href="src/ui/styles.css">
</head>
<body>
<div id="app" class="app">
<p class="booting">Loading…</p>
</div>
<noscript>
<p class="booting">The Ladder needs JavaScript. It runs entirely in your browser — nothing is sent anywhere.</p>
</noscript>
<script type="module">
import { boot } from './src/ui/app.js';
boot(document.getElementById('app'));
</script>
</body>
</html>
+13
View File
@@ -0,0 +1,13 @@
# Exported playtest logs
Drop exported feedback logs here — the browser downloads them wherever it
normally does, and this is where they go to be read.
```sh
npm run analyze # the newest log in this folder
npm run analyze -- <file> # a specific one
```
The logs themselves are gitignored. They record what a real person did, turn by
turn, including anything they typed into the feedback box, so they are not
committed: keep them local, or share them deliberately.
+13
View File
@@ -0,0 +1,13 @@
{
"name": "theladder",
"version": "0.4.0",
"private": true,
"description": "A turn-based career simulation game. Engine is theme-agnostic; settings are content packs.",
"type": "module",
"scripts": {
"test": "node --test \"test/**/*.test.js\"",
"serve": "python3 -m http.server 8080",
"build": "node tools/build.js",
"analyze": "node tools/analyze-log.js"
}
}
+10
View File
@@ -0,0 +1,10 @@
// The build identity shown in the status bar.
//
// This file is never rewritten. Running from source is honestly "dev";
// tools/build.js substitutes the real commit into the bundled output only, so
// dist/theladder.html names the commit that produced it and the working tree
// never churns.
export const BUILD = {
commit: 'dev',
builtAt: null,
};
+139
View File
@@ -0,0 +1,139 @@
// Who is in this run.
//
// A pack's characters are fixed people by default: a name and a description,
// written once. A character may instead be a *role* with `candidates` — several
// authored people who could fill it — and one is drawn when a run starts. A role
// may also be a *group* with a `count` range, like two to four co-workers, in
// which case that many different candidates are drawn into numbered slots.
// Replaying a pack therefore meets a different cast, which is the point.
//
// Slots are what state is keyed by: a single role is its own id (`boss`), a
// group's members are `coworker_1` .. `coworker_<max>`. Every slot up to the
// group's maximum exists in the schema from the start, so relationship state
// is declared, validated and saved like any other; a slot nobody was drawn into
// is simply not present in the run. See DECISIONS #41.
//
// The draw uses its own stream derived from the run's seed, so it neither moves
// the event generator nor depends on it: a pack that gains a cast does not
// change which events any other pack draws, and a save that predates the cast
// can have one drawn for it on load, reproducibly.
import { createRng } from './rng.js';
import { getPath } from './paths.js';
/** The count range of a group role, or null for a single character. */
export function groupRange(character) {
if (character.count === undefined) return null;
return Array.isArray(character.count) ? character.count : [character.count, character.count];
}
/** Every state slot a character occupies: its own id, or one per possible group member. */
export function slotsOf(character) {
const range = groupRange(character);
if (!range) return [character.id];
return Array.from({ length: range[1] }, (_, i) => `${character.id}_${i + 1}`);
}
/** Whether a pack draws any part of its cast per run. */
export function hasDrawnCast(pack) {
return (pack.characters ?? []).some((c) => c.candidates?.length || groupRange(c));
}
/** The authored person behind a candidate entry, in the shape the state keeps. */
function personOf(character, candidate, index) {
return {
role: character.id,
candidate: index,
name: candidate.name,
title: candidate.title ?? character.title ?? null,
description: candidate.description ?? '',
attributes: { ...(candidate.attributes ?? {}) },
};
}
/**
* Draw the cast for a run. Returns `{ <slot>: person | null }` for every slot of
* every drawn role; a group slot left empty is `null`, which is how "not in this
* run" is recorded. Fixed characters are not in it: they need no drawing.
*/
export function drawCast(pack, seed) {
const rng = createRng(`cast:${seed}`);
const cast = {};
for (const character of pack.characters ?? []) {
const pool = character.candidates ?? [];
const range = groupRange(character);
if (!range && !pool.length) continue;
const slots = slotsOf(character);
const wanted = range ? range[0] + rng.int(range[1] - range[0] + 1) : 1;
const remaining = pool.map((candidate, index) => ({ candidate, index }));
for (let i = 0; i < slots.length; i++) {
if (i >= wanted || !remaining.length) {
cast[slots[i]] = null;
continue;
}
const [picked] = remaining.splice(rng.int(remaining.length), 1);
cast[slots[i]] = personOf(character, picked.candidate, picked.index);
}
}
return cast;
}
/** Starting relationship overrides a drawn person brings with them. */
export function drawnRelationships(pack, cast) {
const overrides = [];
for (const character of pack.characters ?? []) {
for (const slot of slotsOf(character)) {
const person = cast?.[slot];
if (!person) continue;
const candidate = character.candidates?.[person.candidate];
for (const [stat, value] of Object.entries(candidate?.startingRelationship ?? {})) {
overrides.push({ path: `relationships.${slot}.${stat}`, value });
}
}
}
return overrides;
}
/** The character definition that owns a slot. */
export function characterForSlot(pack, slot) {
for (const character of pack.characters ?? []) {
if (slotsOf(character).includes(slot)) return character;
}
return undefined;
}
/**
* The person in a slot as the interface and the checks see them: name, title,
* description and attributes, whether drawn for this run or written into the
* pack. Undefined if the slot is empty this run or unknown.
*/
export function personIn(pack, state, slot) {
const character = characterForSlot(pack, slot);
if (!character) return undefined;
const drawn = getPath(state, `cast.${slot}`);
if (drawn === null) return undefined;
if (drawn) return { id: slot, ...drawn };
if (character.candidates?.length || groupRange(character)) return undefined;
return {
id: slot,
role: character.id,
name: character.name,
title: character.title ?? null,
description: character.description ?? '',
attributes: { ...(character.attributes ?? {}) },
};
}
/** Everyone present in this run, in pack order. */
export function presentPeople(pack, state) {
return (pack.characters ?? []).flatMap((character) =>
slotsOf(character).map((slot) => personIn(pack, state, slot)).filter(Boolean));
}
/** The slots of a role that are filled in this run. */
export function presentSlots(pack, state, roleId) {
const character = (pack.characters ?? []).find((c) => c.id === roleId);
if (!character) return [];
return slotsOf(character).filter((slot) => personIn(pack, state, slot));
}
+87
View File
@@ -0,0 +1,87 @@
// Options whose outcome is not certain.
//
// An option may carry a `check`: which of the player's attributes it tests,
// optionally *against* a character who opposes it with their own attribute, and
// optionally leaning on how that character regards the player. It then has a
// `success` and a `failure`, each with its own result text and effects; the
// option's plain `effects` apply either way. The engine works out the chance,
// rolls on the run's own generator, and records which way it went.
//
// The engine knows the arithmetic and nothing about what any attribute means:
// the numbers are the pack's (`pack.checks`), the attribute names are the
// pack's, and so are the words the player sees in place of a percentage. A
// player is shown "a long shot" or "likely", never a number. See DECISIONS #41.
//
// chance = base + perPoint × (yours − theirs)
// + relationshipPerPoint × (their regard − relationshipCentre)
// + modifier, clamped to [floor, ceiling]
//
// where "theirs" is the opposing character's attribute when the check is
// `against` someone, and the check's `difficulty` (or the pack's `unopposed`)
// when it is not.
import { getPath } from './paths.js';
import { personIn } from './cast.js';
import { resolveSlot } from './text.js';
/** Odds words used when a pack does not name its own. Upper bounds, in percent. */
export const DEFAULT_ODDS = [
{ upTo: 20, words: 'a long shot' },
{ upTo: 40, words: 'unlikely' },
{ upTo: 60, words: 'an even chance' },
{ upTo: 80, words: 'likely' },
{ upTo: 100, words: 'a safe bet' },
];
/** The words for a chance, from the pack's bands. */
export function oddsWords(pack, chance) {
const bands = pack.checks?.odds ?? DEFAULT_ODDS;
return (bands.find((band) => chance <= band.upTo) ?? bands[bands.length - 1]).words;
}
/** The slot a check is made against this turn, if any. */
function opponentSlot(check, bindings) {
if (!check.against) return undefined;
const id = check.against.replace(/^\{|\}$/g, '');
return resolveSlot(id, bindings);
}
/**
* The chance of an option's check succeeding in this state, as a whole
* percentage, with the parts it was built from. Undefined for an option with
* no check.
*/
export function chanceOf(pack, state, option, bindings = state?.meta?.bindings ?? {}) {
const check = option.check;
if (!check) return undefined;
const rules = pack.checks ?? {};
const yours = getPath(state, `attributes.${check.attribute}`) ?? 0;
const slot = opponentSlot(check, bindings);
const opponent = slot ? personIn(pack, state, slot) : undefined;
const theirs = opponent
? opponent.attributes?.[check.opposedBy ?? check.attribute] ?? 0
: check.difficulty ?? rules.unopposed ?? 0;
let regard = 0;
if (check.relationship && slot) {
const value = getPath(state, `relationships.${slot}.${check.relationship}`);
if (typeof value === 'number') {
regard = (rules.relationshipPerPoint ?? 0) * (value - (rules.relationshipCentre ?? 50));
}
}
const raw = (rules.base ?? 50) + (rules.perPoint ?? 0) * (yours - theirs) + regard + (check.modifier ?? 0);
const chance = Math.round(Math.min(rules.ceiling ?? 95, Math.max(rules.floor ?? 5, raw)));
return { chance, words: oddsWords(pack, chance), yours, theirs, against: slot ?? null };
}
/**
* Roll a check. `rng` is the run's generator; exactly one number is drawn, so
* a replay from the same seed and choices rolls the same way.
*/
export function rollCheck(pack, state, option, rng, bindings) {
const odds = chanceOf(pack, state, option, bindings);
const roll = rng.next() * 100;
return { ...odds, succeeded: roll < odds.chance };
}
+68
View File
@@ -0,0 +1,68 @@
// Generic predicates over state paths.
//
// A condition is either a comparison — { path, op, value } — or a combinator:
// { all: [...] }, { any: [...] }, { not: {...} }. Content uses these for
// option gates, event triggers and anything else that needs to ask a question
// about state. The engine never learns what the paths mean.
import { getPath } from './paths.js';
const COMPARATORS = {
'==': (a, b) => a === b,
'!=': (a, b) => a !== b,
'>': (a, b) => typeof a === 'number' && typeof b === 'number' && a > b,
'>=': (a, b) => typeof a === 'number' && typeof b === 'number' && a >= b,
'<': (a, b) => typeof a === 'number' && typeof b === 'number' && a < b,
'<=': (a, b) => typeof a === 'number' && typeof b === 'number' && a <= b,
'in': (a, b) => Array.isArray(b) && b.includes(a),
'not-in': (a, b) => Array.isArray(b) && !b.includes(a),
'has': (a, b) => Array.isArray(a) && a.includes(b),
'not-has': (a, b) => Array.isArray(a) && !a.includes(b),
};
export const OPERATORS = Object.keys(COMPARATORS);
/** True if `condition` holds against `state`. */
export function evaluate(state, condition) {
if (condition === null || typeof condition !== 'object') {
throw new TypeError(`condition must be an object, got ${JSON.stringify(condition)}`);
}
if (Array.isArray(condition)) return condition.every((c) => evaluate(state, c));
if ('all' in condition) return condition.all.every((c) => evaluate(state, c));
if ('any' in condition) return condition.any.some((c) => evaluate(state, c));
if ('not' in condition) return !evaluate(state, condition.not);
const { path, op, value } = condition;
const compare = COMPARATORS[op];
if (!compare) throw new TypeError(`unknown condition operator ${JSON.stringify(op)}`);
return compare(getPath(state, path), value);
}
/**
* Evaluate a list of conditions, returning the ones that failed. An empty or
* absent list passes. The failures are what the UI greys out an option with
* and what the feedback log records as "the player could not pick this".
*/
export function check(state, conditions) {
if (!conditions) return { passed: true, failed: [] };
const list = Array.isArray(conditions) ? conditions : [conditions];
const failed = list.filter((condition) => !evaluate(state, condition));
return { passed: failed.length === 0, failed };
}
/** Every path a condition tree reads. Used by the validator. */
export function pathsUsed(condition, out = []) {
if (condition === null || typeof condition !== 'object') return out;
if (Array.isArray(condition)) {
for (const c of condition) pathsUsed(c, out);
} else if ('all' in condition) {
for (const c of condition.all) pathsUsed(c, out);
} else if ('any' in condition) {
for (const c of condition.any) pathsUsed(c, out);
} else if ('not' in condition) {
pathsUsed(condition.not, out);
} else if (typeof condition.path === 'string') {
out.push(condition.path);
}
return out;
}
+89
View File
@@ -0,0 +1,89 @@
// Generic mutations over state paths.
//
// An effect is { path, op, value }. Applying a list returns a new state plus
// a change record — the before/after of every path touched. That record is
// what the feedback log stores, so "what did this choice actually do" is
// answerable after the fact without replaying the run.
import { getPath, setPath } from './paths.js';
const OPS = {
add: (current, value) => (typeof current === 'number' ? current : 0) + value,
set: (_current, value) => value,
push: (current, value) => [...(Array.isArray(current) ? current : []), value],
remove: (current, value) => (Array.isArray(current) ? current.filter((v) => v !== value) : []),
};
export const OPERATIONS = Object.keys(OPS);
/** Constrain a value to the bounds its schema entry declares, if any. */
function clamp(value, rule) {
if (!rule || typeof value !== 'number') return value;
let out = value;
if (typeof rule.min === 'number') out = Math.max(rule.min, out);
if (typeof rule.max === 'number') out = Math.min(rule.max, out);
return out;
}
/** Whether an effect's value is a reference to state rather than a literal. */
export function isAmountFrom(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value)
&& typeof value.from === 'string';
}
/**
* The amount an `add` effect applies. Usually the number the pack wrote. It may
* instead be read from state at the moment the effect applies:
*
* { path: 'money', op: 'add', value: { from: 'owed', times: -1, cap: 20 } }
*
* is "take away whatever is owed, but no more than 20". A fixed instalment
* cannot pay off a balance that could be anything, and charging the whole
* instalment against a smaller balance took a playtester's money for a debt
* that did not exist (DECISIONS #38). It is still data, and still generic: the
* engine reads a number from a path without knowing what the path means.
*/
export function resolveAmount(state, value) {
if (!isAmountFrom(value)) return value;
const current = getPath(state, value.from);
let amount = typeof current === 'number' ? current : 0;
if (typeof value.cap === 'number') amount = Math.min(amount, value.cap);
return amount * (typeof value.times === 'number' ? value.times : 1);
}
/**
* Apply one effect. `schema` is the resolved state schema, consulted only for
* clamping — the engine still has no idea what any given path means.
*/
export function applyEffect(state, effect, schema = {}) {
const { path, op } = effect;
const operate = OPS[op];
if (!operate) throw new TypeError(`unknown effect operation ${JSON.stringify(op)}`);
const value = op === 'add' ? resolveAmount(state, effect.value) : effect.value;
const from = getPath(state, path);
const raw = operate(from, value);
const to = clamp(raw, schema[path]);
const change = { path, op, value, from, to, clamped: raw !== to };
// The log keeps the number actually applied, and says where it came from.
if (value !== effect.value) change.source = effect.value;
return { state: setPath(state, path, to), change };
}
/** Apply a list of effects in order. Returns the new state and every change. */
export function applyEffects(state, effects, schema = {}) {
let next = state;
const changes = [];
for (const effect of effects ?? []) {
const result = applyEffect(next, effect, schema);
next = result.state;
changes.push(result.change);
}
return { state: next, changes };
}
/** Every path a list of effects writes. Used by the validator. */
export function pathsUsed(effects) {
return (effects ?? []).map((effect) => effect.path).filter((p) => typeof p === 'string');
}
+191
View File
@@ -0,0 +1,191 @@
// The turn loop.
//
// Purely functional: a turn is (pack, state, optionId) -> new state. The RNG
// is rebuilt from the seed and position carried in state, so a saved game
// resumes into exactly the run it left, and a test can replay a whole career
// with no hidden state anywhere.
import { createRng } from './rng.js';
import { applyEffects } from './effects.js';
import { check } from './conditions.js';
import { resolveSchema, createState } from './state.js';
import { selectEvent, resolveOptions, findOption, bindRoles } from './select.js';
import { setPath, getPath } from './paths.js';
import { chanceOf, rollCheck } from './checks.js';
import { bindEffects, fillText } from './text.js';
export class ContentError extends Error {}
export class MoveError extends Error {}
/** Draw the next event and record where the generator ended up. */
function advance(pack, state) {
// 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 "${dated.stage}" on turn ${dated.turn}; ` +
'every stage needs at least one event with no conditions',
);
}
let next = setPath(dated, 'meta.currentEventId', event.id);
// Which co-worker this event is about, drawn after the event itself. A pack
// with no `involves` draws nothing here, so its runs are unchanged.
const bindings = bindRoles(pack, next, event, rng);
if (Object.keys(bindings).length || next.meta.bindings) next = setPath(next, 'meta.bindings', bindings);
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
* player's choice has landed, so upkeep is content's business and not a
* special case in the loop.
*/
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;
changes.push(...applied.changes.map((change) => ({ ...change, label: entry.label ?? null })));
}
return { state: next, changes };
}
/** Begin a run. `seed` is any string or number; it is stored in the save. */
export function startGame(pack, { seed = Date.now(), schema = resolveSchema(pack), now = null } = {}) {
let state = createState(pack, { seed, schema });
state = setPath(state, 'meta.startedAt', now);
return advance(pack, state);
}
/** The event the player is looking at. */
export function currentEvent(pack, state) {
const id = getPath(state, 'meta.currentEventId');
const event = (pack.events ?? []).find((e) => e.id === id);
if (!event) throw new ContentError(`current event "${id}" is not in pack "${pack.id}"`);
return event;
}
/** The current event together with each option's availability. */
export function currentTurn(pack, state) {
const event = currentEvent(pack, state);
const options = resolveOptions(state, event).map((resolved) => (resolved.option.check
? { ...resolved, odds: chanceOf(pack, state, resolved.option) }
: resolved));
return { event, options, bindings: state.meta.bindings ?? {} };
}
/**
* Play one option. Returns the new state and a record of what happened —
* the record is what the feedback log stores, and it deliberately includes the
* options that were locked, not just the one that was taken.
*/
export function takeTurn(pack, state, optionId, { schema = resolveSchema(pack), now = null } = {}) {
const event = currentEvent(pack, state);
const option = findOption(event, optionId);
if (!option) throw new MoveError(`event "${event.id}" has no option "${optionId}"`);
const resolved = resolveOptions(state, event);
const chosen = resolved.find((r) => r.option.id === optionId);
if (!chosen.available) {
throw new MoveError(`option "${optionId}" is locked: ${JSON.stringify(chosen.failed)}`);
}
const bindings = state.meta.bindings ?? {};
let effects = bindEffects(option.effects, bindings);
let check = null;
let rngState = state;
if (option.check) {
// One number from the run's own generator, so the roll replays with the run.
const rng = createRng(state.meta.seed, state.meta.rngPosition ?? undefined);
const rolled = rollCheck(pack, state, option, rng, bindings);
const outcome = rolled.succeeded ? option.success : option.failure;
effects = [...effects, ...bindEffects(outcome?.effects, bindings)];
check = {
attribute: option.check.attribute,
against: rolled.against,
chance: rolled.chance,
odds: rolled.words,
outcome: rolled.succeeded ? 'success' : 'failure',
};
rngState = setPath(state, 'meta.rngPosition', rng.getPosition());
}
const applied = applyEffects(rngState, effects, schema);
let next = applied.state;
const seenCount = getPath(next, `seen.${event.id}`) ?? 0;
next = setPath(next, `seen.${event.id}`, seenCount + 1);
next = setPath(next, `lastSeen.${event.id}`, next.turn);
const upkeep = applyUpkeep(pack, next, schema);
next = upkeep.state;
const record = {
turn: next.turn,
stage: next.stage,
eventId: event.id,
optionId: option.id,
// Names filled in, so the log and the retrospective read as the player saw it.
optionLabel: fillText(pack, state, option.label, bindings),
...(check ? { check } : {}),
...(Object.keys(bindings).length ? { bindings } : {}),
changes: applied.changes,
upkeep: upkeep.changes,
offered: resolved.map((r) => ({
id: r.option.id,
label: r.option.label,
available: r.available,
failed: r.failed,
// A hidden option was never on the player's screen. Without this the log
// cannot tell it from a locked door they saw, and the analyser counted
// one the first real Frontier player never met. See DECISIONS #39.
hidden: r.hidden,
})),
at: now,
};
next = setPath(next, 'history', [...next.history, record]);
next = setPath(next, 'turn', next.turn + 1);
next = advance(pack, next);
return { state: next, record };
}
+12
View File
@@ -0,0 +1,12 @@
// The engine's public surface. Nothing in src/engine may import from content/.
export { getPath, setPath, hasPath, parsePath, listPaths } from './paths.js';
export { createRng } from './rng.js';
export { evaluate, check, OPERATORS } from './conditions.js';
export { applyEffect, applyEffects, OPERATIONS } from './effects.js';
export { createState, resolveSchema, indexCharacters, RESERVED_ROOTS } from './state.js';
export { eligibleEvents, selectEvent, resolveOptions, findOption } from './select.js';
export { startGame, currentEvent, currentTurn, takeTurn, ContentError, MoveError } from './game.js';
export { validatePack, assertValidPack } from './validate.js';
export { drawCast, presentPeople, presentSlots, personIn, slotsOf, hasDrawnCast } from './cast.js';
export { fillText, placeholders, bindEffects, bindConditions } from './text.js';
export { chanceOf, rollCheck, oddsWords, DEFAULT_ODDS } from './checks.js';
+83
View File
@@ -0,0 +1,83 @@
// Path addressing for game state.
//
// Every piece of game state is reachable by a dotted path string: `money`,
// `skills.negotiation`, `relationships.boss.likes_you`, `flags.took_loan`.
// Conditions, effects, gates and event triggers are all expressed against
// these paths, which is what lets content reach any part of state without the
// engine knowing what that part means.
const SEGMENT = /^[A-Za-z0-9_]+$/;
const FORBIDDEN = new Set(['__proto__', 'prototype', 'constructor']);
/** Split a path into segments, throwing on anything malformed or unsafe. */
export function parsePath(path) {
if (typeof path !== 'string' || path.length === 0) {
throw new TypeError(`path must be a non-empty string, got ${JSON.stringify(path)}`);
}
const segments = path.split('.');
for (const segment of segments) {
if (!SEGMENT.test(segment)) {
throw new TypeError(`invalid path segment ${JSON.stringify(segment)} in "${path}"`);
}
// Content is data, and data can be authored by anyone; never let a path
// walk into the prototype chain.
if (FORBIDDEN.has(segment)) {
throw new TypeError(`forbidden path segment ${JSON.stringify(segment)} in "${path}"`);
}
}
return segments;
}
/** Read a path. Returns undefined if any link in the chain is missing. */
export function getPath(state, path) {
let cursor = state;
for (const segment of parsePath(path)) {
if (cursor === null || typeof cursor !== 'object') return undefined;
if (!Object.prototype.hasOwnProperty.call(cursor, segment)) return undefined;
cursor = cursor[segment];
}
return cursor;
}
/** True if the path exists (distinct from resolving to undefined). */
export function hasPath(state, path) {
let cursor = state;
for (const segment of parsePath(path)) {
if (cursor === null || typeof cursor !== 'object') return false;
if (!Object.prototype.hasOwnProperty.call(cursor, segment)) return false;
cursor = cursor[segment];
}
return true;
}
/**
* Write a path, returning a new state. Ancestors along the path are copied;
* everything else is shared, so this is cheap to call in a loop.
*/
export function setPath(state, path, value) {
const segments = parsePath(path);
const root = { ...state };
let cursor = root;
for (let i = 0; i < segments.length - 1; i++) {
const segment = segments[i];
const child = cursor[segment];
cursor[segment] = (child !== null && typeof child === 'object') ? { ...child } : {};
cursor = cursor[segment];
}
cursor[segments[segments.length - 1]] = value;
return root;
}
/** Every leaf path in an object, in depth-first order. Used by the validator. */
export function listPaths(object, prefix = '') {
const out = [];
for (const [key, value] of Object.entries(object)) {
const path = prefix ? `${prefix}.${key}` : key;
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
out.push(...listPaths(value, path));
} else {
out.push(path);
}
}
return out;
}
+64
View File
@@ -0,0 +1,64 @@
// Seeded, serializable random number generator.
//
// Every draw the game makes comes from here, so a run is fully reproducible
// from its seed plus the player's choice sequence. That makes tests
// deterministic and playtest reports replayable: the seed and the generator's
// position both live in the save file and the feedback log.
/** Hash an arbitrary string seed down to a uint32. */
function hashSeed(seed) {
let h = 1779033703 ^ String(seed).length;
for (let i = 0; i < String(seed).length; i++) {
h = Math.imul(h ^ String(seed).charCodeAt(i), 3432918353);
h = (h << 13) | (h >>> 19);
}
h = Math.imul(h ^ (h >>> 16), 2246822507);
h = Math.imul(h ^ (h >>> 13), 3266489909);
return (h ^ (h >>> 16)) >>> 0;
}
/**
* mulberry32: one uint32 of state, which is the whole point — the generator's
* position round-trips through JSON with no special handling.
*/
export function createRng(seed, position) {
let state = position === undefined ? hashSeed(seed) : position >>> 0;
const next = () => {
state = (state + 0x6d2b79f5) >>> 0;
let t = state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
return {
seed,
/** Float in [0, 1). */
next,
/** Integer in [0, n). */
int: (n) => Math.floor(next() * n),
/** Uniform pick from a non-empty array. */
pick(items) {
if (!items.length) throw new RangeError('cannot pick from an empty array');
return items[Math.floor(next() * items.length)];
},
/**
* Weighted pick. `weightOf` must return a non-negative number; entries
* weighing zero are never drawn. Returns undefined if nothing has weight.
*/
weighted(items, weightOf) {
let total = 0;
for (const item of items) total += Math.max(0, weightOf(item));
if (total <= 0) return undefined;
let roll = next() * total;
for (const item of items) {
roll -= Math.max(0, weightOf(item));
if (roll < 0) return item;
}
return items[items.length - 1];
},
/** Current position, for saving mid-run. */
getPosition: () => state,
};
}
+98
View File
@@ -0,0 +1,98 @@
// Choosing what happens this turn.
//
// There is deliberately no such thing as "a scripted turn" versus "a random
// event" in here. Every turn, the whole event pool is filtered by stage and
// conditions, then drawn from by weight. A routine turn is an event with a
// high weight and loose conditions; a rare interruption is one with a low
// weight and tight conditions; the "hard times" branch is one whose
// conditions include a money threshold. The engine branches on none of it.
import { check } from './conditions.js';
import { getPath } from './paths.js';
import { presentSlots } from './cast.js';
import { bindConditions } from './text.js';
/**
* Pick, for each role an event `involves`, one person present in this run. An
* event about "a co-worker" is about one particular co-worker for the turn.
*/
export function bindRoles(pack, state, event, rng) {
const bindings = {};
for (const role of event.involves ?? []) {
const slots = presentSlots(pack, state, role);
if (slots.length) bindings[role] = slots.length === 1 ? slots[0] : rng.pick(slots);
}
return bindings;
}
/** Events that could fire right now, with the reason excluded ones could not. */
export function eligibleEvents(pack, state) {
const eligible = [];
const excluded = [];
for (const event of pack.events ?? []) {
if (event.stage !== undefined && event.stage !== state.stage) {
excluded.push({ event, reason: 'stage' });
continue;
}
const seenCount = getPath(state, `seen.${event.id}`) ?? 0;
if (event.once && seenCount > 0) {
excluded.push({ event, reason: 'once' });
continue;
}
if (event.cooldown) {
const lastTurn = getPath(state, `lastSeen.${event.id}`);
if (lastTurn !== undefined && state.turn - lastTurn < event.cooldown) {
excluded.push({ event, reason: 'cooldown' });
continue;
}
}
const gate = check(state, event.requires);
if (!gate.passed) {
excluded.push({ event, reason: 'requires', failed: gate.failed });
continue;
}
// An event about a co-worker cannot fire in a run that drew none.
if ((event.involves ?? []).some((role) => !presentSlots(pack, state, role).length)) {
excluded.push({ event, reason: 'involves' });
continue;
}
eligible.push(event);
}
return { eligible, excluded };
}
/**
* Draw this turn's event. Returns undefined when nothing is eligible, which is
* a content bug rather than a game state — the validator checks that at least
* one unconditional event exists per stage so this cannot happen in practice.
*/
export function selectEvent(pack, state, rng) {
const { eligible } = eligibleEvents(pack, state);
return rng.weighted(eligible, (event) => event.weight ?? 1);
}
/**
* Every option on an event, in authored order, marked available or locked and
* carrying the conditions that locked it. The UI greys locked options out; the
* feedback log records them, because what a player *could not* choose is the
* strongest signal that the choice set is too narrow.
*/
export function resolveOptions(state, event) {
const bindings = state?.meta?.bindings ?? {};
return (event.options ?? []).map((option) => {
const gate = check(state, bindConditions(option.requires, bindings));
return {
option,
available: gate.passed,
failed: gate.failed,
hidden: !gate.passed && option.whenLocked === 'hide',
};
});
}
/** Look up one option on an event by id. */
export function findOption(event, optionId) {
return (event.options ?? []).find((option) => option.id === optionId);
}
+111
View File
@@ -0,0 +1,111 @@
// Turning a content pack into a running game state.
//
// The pack declares what state exists and what it starts at; the engine
// expands that into a flat schema and an initial state object. The engine
// names no stat of its own beyond its own bookkeeping.
import { getPath, setPath } from './paths.js';
import { slotsOf, drawCast, drawnRelationships, hasDrawnCast } from './cast.js';
/**
* Paths the engine owns. Content may *read* these in conditions — "only after
* 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', 'calendar', 'cast']);
/**
* Flatten a pack's state declarations into one path -> rule map, expanding the
* per-character relationship stats. `relationships.<characterId>.<stat>` is
* generated from `relationshipStats` x `characters`, so adding a character
* adds its relationship state automatically and naming a stat is the pack's
* business, never the engine's.
*/
export function resolveSchema(pack) {
const schema = {};
for (const [path, rule] of Object.entries(pack.state ?? {})) {
schema[path] = { ...rule };
}
for (const character of pack.characters ?? []) {
// A group role has one slot per possible member (coworker_1 .. coworker_4),
// all declared up front so every one is validated and saved. See cast.js.
for (const slot of slotsOf(character)) {
for (const [stat, rule] of Object.entries(pack.relationshipStats ?? {})) {
// A character may start somewhere other than the pack default — a rival
// who already resents you, a boss who has not formed an opinion.
const override = character.startingRelationship?.[stat];
schema[`relationships.${slot}.${stat}`] =
override === undefined ? { ...rule } : { ...rule, initial: override };
}
}
}
return schema;
}
/** Draw a run's cast into state, with the starting relationships the drawn people bring. */
function withCast(pack, state) {
if (!hasDrawnCast(pack)) return state;
const cast = drawCast(pack, state.meta.seed);
let next = setPath(state, 'cast', cast);
for (const { path, value } of drawnRelationships(pack, cast)) next = setPath(next, path, value);
return next;
}
/** A fresh game state for `pack`, seeded for reproducibility. */
export function createState(pack, { seed, schema = resolveSchema(pack) } = {}) {
let state = {
meta: {
packId: pack.id,
packVersion: pack.version ?? null,
seed: String(seed),
rngPosition: null,
startedAt: null,
},
turn: 1,
stage: pack.startingStage,
seen: {},
lastSeen: {},
calendar: {},
history: [],
};
for (const [path, rule] of Object.entries(schema)) {
state = setPath(state, path, structuredClone(rule.initial));
}
return withCast(pack, state);
}
/**
* Give a loaded save any state the pack has gained since it was written.
*
* Adding a stat to a pack is meant to be data, but a run saved before that stat
* existed has no path for it, and `undefined` fails every numeric comparison in
* conditions.js — both `x >= 1` and `x < 1` are false. So content gated on a new
* stat does not misbehave in a saved run, it *vanishes* from it: the event never
* fires, the option never unlocks, the upkeep never charges, and nothing
* anywhere says so. Filling the gaps in on load is what makes "adding content is
* just adding data" true for players already mid-career.
*
* Returns the paths it filled, so a caller can say what it repaired.
*/
export function hydrate(pack, state, schema = resolveSchema(pack)) {
let next = state;
const added = [];
for (const [path, rule] of Object.entries(schema)) {
if (getPath(next, path) !== undefined) continue;
next = setPath(next, path, structuredClone(rule.initial));
added.push(path);
}
// A run saved before its pack drew a cast gets one now, from its own seed, so
// the same save always wakes up with the same people.
if (hasDrawnCast(pack) && next.cast === undefined) {
next = withCast(pack, next);
added.push('cast');
}
return { state: next, added };
}
/** The characters a pack defines, keyed by id. */
export function indexCharacters(pack) {
return Object.fromEntries((pack.characters ?? []).map((c) => [c.id, c]));
}
+77
View File
@@ -0,0 +1,77 @@
// Names in text, and which person an event is about.
//
// Content that meets a different cast each run cannot write "Dutch" into its
// prose. It writes `{rival}`, and the name is filled in from whoever was drawn.
// `{rival.first}` is the first name, `{rival.title}` the title.
//
// A group role (two to four co-workers) cannot be named by its role alone —
// which one? An event says so with `involves: ['coworker']`, and when the event
// is drawn one present member is bound to it for the turn. `{coworker}` in its
// text, `relationships.{coworker}.trust` in its effects and `against:
// '{coworker}'` in a check all mean that person. The binding is saved with the
// run, so a replay binds the same person. See DECISIONS #41.
import { personIn } from './cast.js';
const PLACEHOLDER = /\{([A-Za-z0-9_]+)(?:\.(name|first|title))?\}/g;
/** Every `{id}` / `{id.part}` in a piece of text, as `{ id, part }`. */
export function placeholders(text) {
if (typeof text !== 'string') return [];
return [...text.matchAll(PLACEHOLDER)].map((m) => ({ id: m[1], part: m[2] ?? 'name' }));
}
/** The slot a placeholder id means this turn: a bound group member, or the id itself. */
export function resolveSlot(id, bindings = {}) {
return bindings[id] ?? id;
}
/** Replace `{id}` in a path or a string with the slot bound to it this turn. */
export function bindPath(path, bindings = {}) {
if (typeof path !== 'string') return path;
return path.replace(/\{([A-Za-z0-9_]+)\}/g, (whole, id) => resolveSlot(id, bindings));
}
/** Fill names into text. Anything it cannot resolve is left as written. */
export function fillText(pack, state, text, bindings = state?.meta?.bindings ?? {}) {
if (typeof text !== 'string') return text;
return text.replace(PLACEHOLDER, (whole, id, part = 'name') => {
const person = personIn(pack, state, resolveSlot(id, bindings));
if (!person) return whole;
if (part === 'first') return person.name.split(' ')[0];
if (part === 'title') return person.title ?? '';
return person.name;
});
}
/** Conditions with any bound placeholders in their paths replaced by the slot. */
export function bindConditions(conditions, bindings = {}) {
if (!conditions || !Object.keys(bindings).length) return conditions;
const walk = (c) => {
if (!c || typeof c !== 'object') return c;
if (Array.isArray(c)) return c.map(walk);
if ('all' in c) return { ...c, all: c.all.map(walk) };
if ('any' in c) return { ...c, any: c.any.map(walk) };
if ('not' in c) return { ...c, not: walk(c.not) };
return { ...c, path: bindPath(c.path, bindings) };
};
return walk(conditions);
}
/** Effects with any bound placeholders in their paths replaced by the slot. */
export function bindEffects(effects, bindings = {}) {
return (effects ?? []).map((effect) => {
const path = bindPath(effect.path, bindings);
let value = effect.value;
if (value && typeof value === 'object' && typeof value.from === 'string') {
value = { ...value, from: bindPath(value.from, bindings) };
} else if (typeof value === 'string') {
// Text kept in state (a notable moment) outlives the turn's bindings, so
// `{clerk}` is pinned to the slot it meant: `{clerk_2}` still names the
// same person when the retrospective reads it back.
value = value.replace(PLACEHOLDER, (whole, id, part) =>
(bindings[id] ? `{${bindings[id]}${part ? `.${part}` : ''}}` : whole));
}
return path === effect.path && value === effect.value ? effect : { ...effect, path, value };
});
}
+416
View File
@@ -0,0 +1,416 @@
// Load-time validation of a content pack.
//
// This is what makes "adding content is just adding data" safe. Without it, a
// typo in a path or a character id fails silently forty turns into a
// playthrough. Run it whenever a pack is loaded in development, and in tests.
import { RESERVED_ROOTS, resolveSchema, indexCharacters } from './state.js';
import { OPERATORS, pathsUsed as conditionPaths } from './conditions.js';
import { OPERATIONS, isAmountFrom } from './effects.js';
import { parsePath } from './paths.js';
import { groupRange } from './cast.js';
import { placeholders } from './text.js';
const ID = /^[A-Za-z0-9_]+$/;
/**
* Check a pack. Returns { errors, warnings, schema }; errors mean the pack
* cannot be played, warnings mean it probably does not do what was intended.
*/
export function validatePack(pack) {
const errors = [];
const warnings = [];
const schema = resolveSchema(pack);
const characters = indexCharacters(pack);
const stateRoots = new Set(Object.keys(schema).map((p) => p.split('.')[0]));
const fail = (where, message) => errors.push(`${where}: ${message}`);
const warn = (where, message) => warnings.push(`${where}: ${message}`);
if (!pack.id || !ID.test(pack.id)) fail('pack', `id ${JSON.stringify(pack.id)} must match ${ID}`);
if (!pack.startingStage) fail('pack', 'startingStage is required');
// Content may read engine bookkeeping but must never declare or write it.
for (const path of Object.keys(schema)) {
try {
const root = parsePath(path)[0];
if (RESERVED_ROOTS.has(root)) {
fail('state', `"${path}" declares reserved root "${root}", which the engine owns`);
}
} catch (error) {
fail('state', `"${path}" is not a usable path: ${error.message}`);
}
}
// Attributes are ordinary declared state (`attributes.<id>` for the player);
// pack.attributes names them, so checks and characters can be held to the list.
const attributeIds = new Set(Object.keys(pack.attributes ?? {}));
for (const id of attributeIds) {
if (!ID.test(id)) fail(`attribute "${id}"`, `id must match ${ID}`);
if (!(`attributes.${id}` in schema)) fail(`attribute "${id}"`, `the player needs "attributes.${id}" in pack.state`);
}
const checkAttributes = (where, attributes, { complete }) => {
for (const key of Object.keys(attributes ?? {})) {
if (!attributeIds.has(key)) fail(where, `attribute "${key}" is not in pack.attributes`);
else if (typeof attributes[key] !== 'number') fail(where, `attribute "${key}" must be a number`);
}
if (complete) {
for (const id of attributeIds) {
if (typeof attributes?.[id] !== 'number') fail(where, `has no "${id}", which every drawn person needs`);
}
}
};
const seenCharacters = new Set();
const names = new Map();
const nameOnce = (where, name) => {
if (names.has(name)) fail(where, `name "${name}" is already used by ${names.get(name)}`);
else names.set(name, where);
};
for (const character of pack.characters ?? []) {
const where = `character "${character.id}"`;
if (!character.id || !ID.test(character.id)) fail(where, `id must match ${ID}`);
if (seenCharacters.has(character.id)) fail(where, 'duplicate id');
seenCharacters.add(character.id);
if (!character.role_type) warn(where, 'no role_type tag; content cannot filter on it');
const range = groupRange(character);
const pool = character.candidates ?? [];
if (range) {
const [min, max] = range;
if (!Number.isInteger(min) || !Number.isInteger(max) || min < 0 || max < 1 || min > max) {
fail(where, `count ${JSON.stringify(character.count)} must be [min, max] whole numbers with 0 <= min <= max and max >= 1`);
} else if (pool.length < max) {
fail(where, `can hold up to ${max} people but has only ${pool.length} candidates`);
}
}
if (range || pool.length) {
if (character.name) warn(where, 'has a name as well as candidates; the drawn candidate\'s name is used');
pool.forEach((candidate, index) => {
const at = `${where} candidate ${index}`;
if (!candidate.name) fail(at, 'name is required');
else nameOnce(at, candidate.name);
if (!candidate.description) warn(at, 'no description');
checkAttributes(at, candidate.attributes, { complete: attributeIds.size > 0 });
for (const stat of Object.keys(candidate.startingRelationship ?? {})) {
if (!(stat in (pack.relationshipStats ?? {}))) fail(at, `startingRelationship names unknown stat "${stat}"`);
}
});
} else {
if (!character.name) fail(where, 'name is required');
else nameOnce(where, character.name);
checkAttributes(where, character.attributes, { complete: false });
}
}
const characterById = Object.fromEntries((pack.characters ?? []).map((c) => [c.id, c]));
const isGroup = (id) => Boolean(characterById[id] && groupRange(characterById[id]));
/**
* A path or text may name a character as `{id}`. A single character is always
* there to name; a group member only when the event binds one with `involves`.
* Returns the path with each placeholder replaced by a representative slot, so
* it can be checked against the schema like any written path.
*/
const bindForCheck = (where, text, involves) => {
for (const { id } of placeholders(text)) {
if (!characterById[id]) fail(where, `{${id}} names no character`);
else if (isGroup(id) && !involves.includes(id)) {
fail(where, `{${id}} is a group; the event must list "${id}" in involves to say which one`);
}
}
return typeof text === 'string'
? text.replace(/\{([A-Za-z0-9_]+)\}/g, (w, id) => (isGroup(id) ? `${id}_1` : id))
: text;
};
// Chances are the pack's numbers, like every other tuning value (#9).
const usesChecks = (pack.events ?? []).some((e) => (e.options ?? []).some((o) => o.check));
if (usesChecks) {
const rules = pack.checks;
if (!rules) fail('checks', 'options carry checks but the pack declares no checks block');
else {
for (const key of ['base', 'perPoint', 'floor', 'ceiling']) {
if (typeof rules[key] !== 'number') fail('checks', `${key} must be a number`);
}
if (rules.floor > rules.ceiling) fail('checks', 'floor is above ceiling');
if (rules.odds) {
let last = -Infinity;
for (const band of rules.odds) {
if (typeof band.upTo !== 'number' || band.upTo <= last) fail('checks', 'odds bands must rise by upTo');
if (!band.words) fail('checks', 'every odds band needs words');
last = band.upTo;
}
if (last < (rules.ceiling ?? 100)) fail('checks', `odds bands stop at ${last}, below the ceiling`);
}
}
}
const readable = (path) => {
let root;
try {
root = parsePath(path)[0];
} catch (error) {
return error.message;
}
if (RESERVED_ROOTS.has(root)) return null;
if (path in schema) return null;
if (stateRoots.has(root)) return `"${path}" is not declared in pack.state or relationshipStats`;
return `"${path}" is not declared anywhere in this pack`;
};
// Stages are the one piece of engine bookkeeping content may write: moving
// the player between them is a content decision (a promotion), and the
// engine only ever uses `stage` to filter events. The value is checked
// against the stages that actually have events, once those are known.
const stageWrites = [];
const writable = (path, effect, where) => {
let root;
try {
root = parsePath(path)[0];
} catch (error) {
return error.message;
}
if (path === 'stage') {
if (effect.op !== 'set') return `"stage" may only be written with set, not ${effect.op}`;
stageWrites.push({ where, value: effect.value });
return null;
}
if (RESERVED_ROOTS.has(root)) return `"${path}" writes engine-owned state`;
if (!(path in schema)) return `"${path}" is not declared in pack.state or relationshipStats`;
return null;
};
const checkConditions = (where, conditions) => {
if (!conditions) return;
const list = Array.isArray(conditions) ? conditions : [conditions];
for (const path of list.flatMap((c) => conditionPaths(c))) {
const problem = readable(path);
if (problem) fail(where, `condition reads ${problem}`);
}
const walk = (condition) => {
if (!condition || typeof condition !== 'object') return;
if (Array.isArray(condition)) return condition.forEach(walk);
if ('all' in condition) return condition.all.forEach(walk);
if ('any' in condition) return condition.any.forEach(walk);
if ('not' in condition) return walk(condition.not);
if (!OPERATORS.includes(condition.op)) {
fail(where, `unknown condition operator ${JSON.stringify(condition.op)}`);
}
};
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 ?? []) {
if (!OPERATIONS.includes(effect.op)) {
fail(where, `unknown effect operation ${JSON.stringify(effect.op)}`);
}
}
for (const effect of entry.effects ?? []) {
if (typeof effect.path !== 'string') continue;
const problem = writable(effect.path, effect, where);
if (problem) fail(where, `effect writes ${problem}`);
}
});
for (const character of pack.characters ?? []) {
for (const stat of Object.keys(character.startingRelationship ?? {})) {
if (!(stat in (pack.relationshipStats ?? {}))) {
fail(`character "${character.id}"`, `startingRelationship names unknown stat "${stat}"`);
}
}
}
const stages = new Set();
const unconditional = new Set();
const seenEvents = new Set();
for (const event of pack.events ?? []) {
const where = `event "${event.id}"`;
if (!event.id || !ID.test(event.id)) fail(where, `id must match ${ID} (it is used as a state path)`);
if (seenEvents.has(event.id)) fail(where, 'duplicate id');
seenEvents.add(event.id);
if (!event.description) warn(where, 'no description; the player will see an empty scenario');
if (event.stage !== undefined) stages.add(event.stage);
if (event.weight !== undefined && !(event.weight > 0)) {
fail(where, `weight ${event.weight} must be greater than zero, or the event can never fire`);
}
checkConditions(where, event.requires);
const involves = event.involves ?? [];
for (const role of involves) {
if (!characterById[role]) fail(where, `involves "${role}", which is no character`);
}
// Bindings are drawn after the event is chosen, so the event's own gate
// cannot depend on who they are.
const gatePaths = event.requires
? (Array.isArray(event.requires) ? event.requires : [event.requires]).flatMap((c) => conditionPaths(c))
: [];
if (gatePaths.some((p) => p.includes('{'))) {
fail(where, 'requires names a {character}; only options and effects can, once the event is drawn');
}
bindForCheck(where, event.description, involves);
const bound = (conditions) => {
if (!conditions) return conditions;
const walk = (c) => {
if (!c || typeof c !== 'object') return c;
if (Array.isArray(c)) return c.map(walk);
if ('all' in c) return { ...c, all: c.all.map(walk) };
if ('any' in c) return { ...c, any: c.any.map(walk) };
if ('not' in c) return { ...c, not: walk(c.not) };
return { ...c, path: bindForCheck(where, c.path, involves) };
};
return walk(conditions);
};
// A fallback must be one that *no* exclusion rule can remove: requires,
// once and cooldown are exactly the three reasons eligibleEvents drops an
// event, so a floor event may carry none of them.
if (!event.requires && !event.once && !event.cooldown && event.stage !== undefined) {
unconditional.add(event.stage);
}
const options = event.options ?? [];
if (options.length === 0) fail(where, 'has no options; the turn would be unplayable');
const seenOptions = new Set();
for (const option of options) {
const optionWhere = `${where} option "${option.id}"`;
if (!option.id || !ID.test(option.id)) fail(optionWhere, `id must match ${ID}`);
if (seenOptions.has(option.id)) fail(optionWhere, 'duplicate id');
seenOptions.add(option.id);
if (!option.label) fail(optionWhere, 'label is required');
bindForCheck(optionWhere, option.label, involves);
bindForCheck(optionWhere, option.result, involves);
checkConditions(optionWhere, bound(option.requires));
const checkEffects = (at, effects) => {
for (const effect of effects ?? []) {
if (!OPERATIONS.includes(effect.op)) {
fail(at, `unknown effect operation ${JSON.stringify(effect.op)}`);
}
if (effect.op === 'add' && typeof effect.value !== 'number') {
if (isAmountFrom(effect.value)) {
// An amount read from state must read something the pack declares,
// and scale and cap it by numbers. See DECISIONS #38.
const problem = readable(bindForCheck(at, effect.value.from, involves));
if (problem) fail(at, `effect "${effect.path}" reads its amount from ${problem}`);
for (const key of ['times', 'cap']) {
if (effect.value[key] !== undefined && typeof effect.value[key] !== 'number') {
fail(at, `effect "${effect.path}" has a non-numeric ${key}`);
}
}
} else {
fail(at, `effect "${effect.path}" uses ${effect.op} with a non-numeric value`);
}
}
}
for (const effect of effects ?? []) {
if (typeof effect.path !== 'string') continue;
const problem = writable(bindForCheck(at, effect.path, involves), effect, at);
if (problem) fail(at, `effect writes ${problem}`);
}
};
checkEffects(optionWhere, option.effects);
if (option.check) {
const c = option.check;
const at = `${optionWhere} check`;
if (!attributeIds.has(c.attribute)) fail(at, `attribute "${c.attribute}" is not in pack.attributes`);
if (c.opposedBy !== undefined && !attributeIds.has(c.opposedBy)) {
fail(at, `opposedBy "${c.opposedBy}" is not in pack.attributes`);
}
if (c.against !== undefined) {
const id = String(c.against).replace(/^\{|\}$/g, '');
const character = characterById[id];
if (!character) fail(at, `against "${c.against}" is no character`);
else {
if (isGroup(id) && !involves.includes(id)) {
fail(at, `against "${c.against}" is a group; the event must list "${id}" in involves`);
}
const attribute = c.opposedBy ?? c.attribute;
const people = character.candidates?.length ? character.candidates : [character];
if (people.some((p) => typeof p.attributes?.[attribute] !== 'number')) {
fail(at, `not everyone who can be "${id}" has "${attribute}" to oppose with`);
}
}
} else if (c.relationship !== undefined) {
fail(at, 'relationship needs against: whose regard is it?');
}
if (c.relationship !== undefined && !(c.relationship in (pack.relationshipStats ?? {}))) {
fail(at, `relationship "${c.relationship}" is not in relationshipStats`);
}
for (const key of ['difficulty', 'modifier']) {
if (c[key] !== undefined && typeof c[key] !== 'number') fail(at, `${key} must be a number`);
}
for (const branch of ['success', 'failure']) {
const outcome = option[branch];
if (!outcome) { fail(at, `has no ${branch}; a check needs both ways it can go`); continue; }
if (!outcome.result) warn(`${optionWhere} ${branch}`, 'no result text; the player will not be told');
bindForCheck(`${optionWhere} ${branch}`, outcome.result, involves);
checkEffects(`${optionWhere} ${branch}`, outcome.effects);
}
} else if (option.success || option.failure) {
fail(optionWhere, 'has success or failure but no check to decide between them');
}
}
}
// Every reachable stage needs a floor: one event with no conditions, or the
// pool can empty out mid-run and the turn loop has nothing to show.
if (pack.startingStage && !stages.has(pack.startingStage)) {
fail('pack', `startingStage "${pack.startingStage}" has no events`);
}
for (const { where, value } of stageWrites) {
if (typeof value !== 'string' || !stages.has(value)) {
fail(where, `moves the player to stage ${JSON.stringify(value)}, which has no events`);
}
}
for (const stage of stages) {
if (!unconditional.has(stage)) {
fail('events', `stage "${stage}" has no unconditional event; the turn pool can run dry`);
}
}
for (const [path, rule] of Object.entries(schema)) {
if (rule.initial === undefined) fail('state', `"${path}" has no initial value`);
if (typeof rule.initial === 'number' && typeof rule.min === 'number' && rule.initial < rule.min) {
warn('state', `"${path}" starts below its own minimum`);
}
}
if (Object.keys(characters).length === 0) warn('pack', 'no characters defined');
return { errors, warnings, schema, valid: errors.length === 0 };
}
/** Throw if the pack is unplayable. Call this at load time in development. */
export function assertValidPack(pack) {
const result = validatePack(pack);
if (!result.valid) {
throw new Error(`content pack "${pack.id}" is invalid:\n ${result.errors.join('\n ')}`);
}
return result;
}
+30
View File
@@ -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, '-');
}
+112
View File
@@ -0,0 +1,112 @@
// 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.
import { APP, BUILD } from '../version.js';
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(),
// Which build produced this turn. A session played on a page loaded before
// the last rebuild looks exactly like a feature that does not work: a real
// playtest ran 181 turns against a stale bundle and the charge it was meant
// to exercise never fired, which took file timestamps and a simulation to
// work out. The log should say so itself.
app_version: APP.version,
build: BUILD.commit,
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);
}
+115
View File
@@ -0,0 +1,115 @@
// 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.
//
// There is one slot per pack. A single global slot was fine while there was
// one setting; the moment a second existed, switching settings would have
// silently thrown away the other run — the guard in the app read the packId
// off whatever was in the slot and started a new game when it did not match.
const PREFIX = 'theladder.save.v1';
// The slot as it was before packs were selectable: one save, no pack in the
// key. Read once, for Corporate Ladder only, so a run in progress survives
// this change. Never written.
const LEGACY_KEY = PREFIX;
const LEGACY_PACK = 'corporateladder';
const LAST_PACK_KEY = 'theladder.pack.v1';
const FORMAT = 1;
const slotFor = (packId) => `${PREFIX}.${packId}`;
/** 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(packId, state) {
return safely(() => {
localStorage.setItem(
slotFor(packId),
JSON.stringify({ format: FORMAT, savedAt: new Date().toISOString(), state }),
);
return true;
}, false);
}
/**
* The saved run for one pack, or null. Falls back to the pre-packs slot for
* Corporate Ladder, and only when that slot really holds a Corporate Ladder
* save — a stale slot from some other pack is not a reason to load a run into
* a game whose events do not match it.
*/
export function loadLocal(packId) {
return safely(() => {
const raw = localStorage.getItem(slotFor(packId));
if (raw) return JSON.parse(raw)?.state ?? null;
if (packId !== LEGACY_PACK) return null;
const legacy = localStorage.getItem(LEGACY_KEY);
if (!legacy) return null;
const state = JSON.parse(legacy)?.state ?? null;
return state?.meta?.packId === LEGACY_PACK ? state : null;
});
}
export function clearLocal(packId) {
return safely(() => {
localStorage.removeItem(slotFor(packId));
if (packId === LEGACY_PACK) localStorage.removeItem(LEGACY_KEY);
return true;
}, false);
}
/** Which pack was last played, so a returning player lands back in it. */
export function lastPlayed() {
return safely(() => localStorage.getItem(LAST_PACK_KEY));
}
export function rememberPack(packId) {
return safely(() => { localStorage.setItem(LAST_PACK_KEY, packId); 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;
}
+335
View File
@@ -0,0 +1,335 @@
// The application: wiring the engine to the screens.
//
// This is the one module that knows there is more than one content pack. It
// holds the current pack and its schema, and everything below it still takes
// the pack as an argument — the screens, the engine and the io layer are all
// as pack-agnostic as they were when there was only one.
//
// Each pack has its own save slot, so switching settings never destroys a run.
//
// 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 { packs, packById, defaultPack } from '../../content/index.js';
import { validatePack } from '../engine/validate.js';
import { resolveSchema, hydrate } 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 { packsScreen } from './screens/packs.js';
import { fill, focusMarked } from './dom.js';
import {
saveLocal, loadLocal, clearLocal, serializeSave, parseSave, storageAvailable,
lastPlayed, rememberPack,
} 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 app = {
pack: null,
schema: null,
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(app.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 === 'packs'
? packsScreen(packs, packsView(), actions)
: app.view.screen === 'retrospective'
? retrospectiveScreen(app.pack, app.state, app.schema, actions)
: turnScreen(app.pack, app.state, app.schema, app.view, actions);
fill(app.root, screen);
// After the screen is in the document, put the caret where the player most
// likely wants it. This is what makes the game playable on Enter alone.
focusMarked(app.root);
}
/**
* The picker's view of the world: which pack is current, and what is saved in
* each of the others. Reading every slot is cheap and means the screen can say
* "day 41, Dispatch Coordinator" rather than just "saved".
*/
function packsView() {
const runs = {};
for (const candidate of packs) {
const saved = candidate.id === app.pack?.id
? app.state
: loadLocal(candidate.id);
if (saved) runs[candidate.id] = { turn: saved.turn, stage: saved.stage };
}
return { currentPackId: app.pack?.id ?? null, runs, canGoBack: app.state !== null };
}
const actions = {
onChoose(optionId) {
const option = findOptionById(app.view.turn.event, optionId);
const { state, record } = takeTurn(app.pack, app.state, optionId);
app.state = state;
saveLocal(app.pack.id, state);
const entry = logTurn(record, state, app.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 ${app.pack.turnUnit} ${app.state.turn}. ` +
'Export a save first if you want to keep it. Start a new game?',
)) return;
flushFeedback();
clearLocal(app.pack.id);
beginRun(app.pack);
render();
},
/** Open the setting picker. */
onPacks() {
flushFeedback();
app.view = { ...(app.view ?? {}), screen: 'packs' };
render();
},
/**
* Switch settings. The run you are leaving has already been written to its
* own slot every turn, so this is not destructive in either direction — and
* coming back to a pack resumes it rather than restarting it.
*/
onChoosePack(packId) {
const chosen = packById(packId);
if (!chosen) return;
flushFeedback();
if (chosen.id === app.pack?.id) {
app.view = choosingView();
render();
return;
}
if (!enterPack(chosen)) return;
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 {
// Same repair as a local load: an exported file is exactly as old as
// the run it came from.
const imported = parseSave(await readFile(file), app.pack.id);
const { state } = hydrate(app.pack, imported, app.schema);
app.state = state;
saveLocal(app.pack.id, 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,
});
}
/**
* Make `pack` the current setting: validate it, adopt its schema, and either
* resume its saved run or start one. Returns false when the pack will not
* load, having already put the error on screen.
*/
function enterPack(pack) {
const validation = validatePack(pack);
if (!validation.valid) {
fill(app.root, errorScreen(pack, validation.errors));
return false;
}
for (const warning of validation.warnings) console.warn(`[content ${pack.id}] ${warning}`);
app.pack = pack;
app.schema = resolveSchema(pack);
rememberPack(pack.id);
const restored = loadLocal(pack.id);
// The packId check is belt and braces now that slots are per pack, but an
// imported or hand-edited save can still carry the wrong name.
if (restored && restored.meta?.packId === pack.id) {
// A career saved before the pack gained a stat has no path for it, and
// every numeric comparison against `undefined` is false — so the events,
// options and upkeep gated on a new stat would quietly not exist for a
// player already mid-run. Fill them in, and write the repair back.
const repaired = hydrate(pack, restored, app.schema);
if (repaired.added.length > 0) {
console.info(`[save ${pack.id}] filled in new state: ${repaired.added.join(', ')}`);
saveLocal(pack.id, repaired.state);
}
app.state = repaired.state;
app.view = choosingView();
} else {
beginRun(pack);
}
return true;
}
/** A fresh run in `pack`, written to that pack's slot. */
function beginRun(pack) {
app.state = startGame(pack, {
seed: String(Date.now()),
schema: app.schema,
now: new Date().toISOString(),
});
saveLocal(pack.id, app.state);
app.view = choosingView();
}
export function boot(rootElement) {
app.root = rootElement;
if (!storageAvailable()) {
console.warn('[save] local storage is unavailable; progress will not persist. Use Export save.');
}
// Land a returning player back where they were. A first-time player with
// more than one setting to choose from gets the picker as the front door
// rather than being dropped into whichever pack happens to be first.
const remembered = packById(lastPlayed() ?? '');
const anySaved = packs.some((pack) => loadLocal(pack.id) !== null);
if (!remembered && !anySaved && packs.length > 1) {
app.view = { screen: 'packs' };
render();
} else if (!enterPack(remembered ?? defaultPack)) {
return;
} else {
render();
}
// Anything half-typed survives a tab close.
window.addEventListener('beforeunload', flushFeedback);
}
function errorScreen(pack, 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 = `"${pack.name ?? pack.id}" will not load.`;
wrapper.append(heading, list);
return wrapper;
}
+136
View File
@@ -0,0 +1,136 @@
// Pieces shared between the turn screen and the retrospective.
import { h } from './dom.js';
import { formatValue, labelFor, headerStats } from './format.js';
import { APP, versionLine } from '../version.js';
import { getPath } from '../engine/paths.js';
import { fillText } from '../engine/text.js';
/**
* What you are playing, which pack, and who you currently are — plus the build,
* so a playtester can say which version they were on.
*/
export function statusBar(pack, state) {
const title = pack.stages?.[state.stage]?.title ?? state.stage;
return h('div', { class: 'statusbar' },
h('div', { class: 'statusbar__what' },
h('span', { class: 'statusbar__game' }, APP.name),
h('span', { class: 'statusbar__sep' }, '·'),
h('span', { class: 'statusbar__pack' }, pack.name ?? pack.id),
h('span', { class: 'statusbar__sep' }, '·'),
h('span', { class: 'statusbar__title' }, title),
),
h('span', { class: 'statusbar__version', title: 'app version · build' }, versionLine()),
);
}
/** 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) {
// `character` is a person as presentPeople() returns them: drawn for this run
// or written into the pack, keyed by the slot their relationships live under.
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' }, fillText(pack, state, 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', state } = {}) {
const meaningful = changes.filter((change) => {
if (change.from === change.to) return false;
// Some state is bookkeeping the player has no use for — `notable` is the
// retrospective's own list, and reading "Notable — changed" in the ledger
// tells nobody anything.
return pack.display?.[change.path]?.inLedger !== false;
});
if (!meaningful.length) return h('p', { class: 'changes changes--none' }, 'Nothing measurable changed.');
return h('ul', { class: className },
meaningful.map((change) => {
// A flag flipping is a fact about the world, not a quantity. Say it as a
// sentence the pack supplies rather than as "now true".
if (typeof change.to === 'boolean') {
const rule = pack.display?.[change.path];
const phrase = (change.to ? rule?.whenTrue : rule?.whenFalse)
?? `${labelFor(pack, change.path, state)}: ${change.to ? 'yes' : 'no longer'}`;
return h('li', { class: `change change--note change--${change.to ? 'up' : 'down'}` },
h('span', { class: 'change__phrase' }, phrase),
);
}
const rising = change.to > change.from;
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, state)),
h('span', { class: 'change__delta' }, deltaText(pack, change)),
// Say where it stopped. "−$4 at its limit" was read by a real player as
// a debt of four still standing, when it meant the balance hit zero.
change.clamped && h('span', { class: 'change__note' },
`stopped at ${formatValue(pack, change.path, change.to)}`),
);
}),
);
}
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';
}
// A tier moved: "+1" says nothing, the name of the step says everything.
const rule = pack.display?.[change.path];
if (rule?.format === 'tier') return rule.labels?.[change.to] ?? String(change.to);
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}`;
}
+48
View File
@@ -0,0 +1,48 @@
// 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;
}
/**
* Focus whatever the screen marked with `dataset.autofocus`.
*
* Every screen names the one control a player most likely wants next — the
* first available option while choosing, "next day" once the result is in — so
* a whole run can be played on Enter alone, with Tab and Shift+Tab to pick a
* different option. Disabled buttons are skipped by the browser's own tab
* order, so locked options are visible without being in the way.
*/
export function focusMarked(root) {
const queue = [root];
while (queue.length) {
const node = queue.shift();
if (node?.dataset?.autofocus) {
node.focus?.();
return node;
}
queue.push(...(node?.childNodes ?? []));
}
return null;
}
+34
View File
@@ -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;
+161
View File
@@ -0,0 +1,161 @@
// 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';
import { personIn } from '../engine/cast.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.
* Given the run's state, a relationship path names whoever is in that slot this
* run — a drawn boss, the second of three co-workers — not the role.
*/
export function labelFor(pack, path, state) {
const declared = displayFor(pack, path)?.label;
if (declared) return declared;
const segments = path.split('.');
if (segments[0] === 'relationships' && segments.length === 3) {
const name = (state && personIn(pack, state, segments[1])?.name)
?? pack.characters?.find((c) => c.id === segments[1])?.name;
const stat = pack.relationshipStats?.[segments[2]]?.label ?? titleCase(segments[2]);
return `${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';
const rule = displayFor(pack, path);
if (rule?.format === 'money') return formatMoney(value);
// A tier is a number the player should never see as one: a pack declares
// what each step is called and the interface reads the name off the list.
if (rule?.format === 'tier') return rule.labels?.[value] ?? String(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, state) {
if (!condition || typeof condition !== 'object') return '';
if (Array.isArray(condition)) return condition.map((c) => describeCondition(pack, c, state)).join(', ');
if (condition.all) return condition.all.map((c) => describeCondition(pack, c, state)).join(', ');
if (condition.any) return condition.any.map((c) => describeCondition(pack, c, state)).join(' or ');
if (condition.not) return `not ${describeCondition(pack, condition.not, state)}`;
const { path, op, value } = condition;
const label = labelFor(pack, path, state);
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, state) {
const parts = failedConditions.map((c) => describeCondition(pack, c, state)).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);
+64
View File
@@ -0,0 +1,64 @@
// Choosing a setting.
//
// A setting is a content pack, and this screen is the whole of the interface's
// knowledge about there being more than one: it reads `name`, `blurb` and the
// stage titles off each pack and knows nothing else about any of them. Adding
// a third pack changes this file not at all.
//
// Each pack keeps its own save slot, so switching is not destructive and the
// screen says so — a run in progress is reported with the turn it is on.
import { h } from '../dom.js';
export function packsScreen(packs, { currentPackId, runs, canGoBack }, actions) {
return h('div', { class: 'screen screen--packs' },
h('header', { class: 'packs__head' },
h('p', { class: 'packs__eyebrow' }, 'The Ladder'),
h('h1', { class: 'packs__title' }, 'Choose a setting'),
h('p', { class: 'packs__subtitle' },
'The same game underneath. Each setting keeps its own run, so you can ' +
'move between them without losing either.'),
),
h('ul', { class: 'packs__list' },
packs.map((pack, index) => {
const run = runs[pack.id];
const current = pack.id === currentPackId;
return h('li', { class: `packs__item${current ? ' packs__item--current' : ''}` },
h('h2', { class: 'packs__name' }, pack.name),
pack.blurb && h('p', { class: 'packs__blurb' }, pack.blurb),
h('p', { class: 'packs__state' }, runLine(pack, run, current)),
h('button', {
class: current ? 'button' : 'button button--quiet',
// The setting the player is already in, or failing that the first
// one, is what Enter takes — the same keyboard contract as
// everywhere else.
dataset: (current || (!currentPackId && index === 0)) ? { autofocus: 'true' } : undefined,
onClick: () => actions.onChoosePack(pack.id),
}, buttonLabel(run, current)),
);
}),
),
canGoBack && h('footer', { class: 'controls' },
h('button', { class: 'button button--quiet', onClick: actions.onResume }, 'Back'),
),
);
}
function runLine(pack, run, current) {
const unit = pack.turnUnit ?? 'turn';
if (!run) return 'Not started.';
const title = pack.stages?.[run.stage]?.title ?? run.stage;
const where = `${capitalize(unit)} ${run.turn}, ${title}`;
return current ? `${where} — where you are now.` : `${where}, saved.`;
}
function buttonLabel(run, current) {
if (current) return 'Keep playing';
return run ? 'Go back to this run' : 'Start here';
}
const capitalize = (word) => word.charAt(0).toUpperCase() + word.slice(1);
export default packsScreen;
+114
View File
@@ -0,0 +1,114 @@
// 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, statusBar } from '../components.js';
import { presentPeople } from '../../engine/cast.js';
import { fillText } from '../../engine/text.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' },
statusBar(pack, state),
h('header', { class: 'retro__head' },
h('p', { class: 'retro__eyebrow' }, pack.name),
h('h1', { class: 'retro__title' }, 'Career retrospective'),
h('p', { class: 'retro__subtitle' }, careerLine(pack, summary, unit)),
// Said out loud because it did not read that way to a real player, who
// arrived here by keyboard at day 31 and took it for the end of the run.
h('p', { class: 'retro__note' },
`This is a summary, not an ending — "Keep going" picks up where you left `
+ `off, on ${unit} ${state.turn}.`),
),
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.eventsSeen > 0 && fact('Times you saw each, on average',
summary.averageTimesSeen.toFixed(1)),
summary.mostSeen && summary.mostSeen.count > 1 && fact('Most familiar of them',
`came up ${summary.mostSeen.count} times`),
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' },
presentPeople(pack, state).map((person) => characterCard(pack, state, schema, person)),
),
),
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' }, fillText(pack, state, 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',
dataset: { autofocus: 'true' },
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'),
),
);
}
/**
* One sentence for where the career went. A run that changed stage says so —
* that is the whole shape of it — and one that did not reads as it always did.
*/
function careerLine(pack, summary, unit) {
const label = (stage) => (pack.stages?.[stage]?.label ?? stage).toLowerCase();
const length = `${summary.turns} ${unit}${summary.turns === 1 ? '' : 's'}`;
if (summary.stagesVisited.length > 1) {
return `${length}, ${summary.stagesVisited.map(label).join(', then ')}.`;
}
return `${length} in the ${label(summary.stage)}.`;
}
/** 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);
+202
View File
@@ -0,0 +1,202 @@
// 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, statusBar } from '../components.js';
import { requirementText, turnHeading, upcomingUpkeep } from '../format.js';
import { presentPeople } from '../../engine/cast.js';
import { fillText } from '../../engine/text.js';
export function turnScreen(pack, state, schema, view, actions) {
const heading = turnHeading(pack, state);
return h('div', { class: 'screen screen--turn' },
statusBar(pack, state),
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, state, view, actions)
: choosingView(pack, state, view, actions),
),
h('aside', { class: 'people' },
h('h2', { class: 'people__heading' }, pack.peopleHeading ?? 'The people'),
presentPeople(pack, state).map((person) => characterCard(pack, state, schema, person)),
),
),
// Order here is tab order. "Wrap up my career" used to sit first, directly
// after the last option — so a player tabbing to reach an option, going one
// past it, and pressing Enter got the retrospective instead of a turn. That
// happened in a real session at day 31 and read as the game ending. It goes
// last now, behind buttons that do nothing frightening if pressed by
// accident, and Import save is next to Export save where it belongs.
h('footer', { class: 'controls' },
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.onPacks }, 'Change setting'),
h('button', { class: 'button button--quiet', onClick: actions.onNewGame }, 'New game'),
h('button', { class: 'button', onClick: actions.onRetrospective },
'Wrap up my career'),
),
);
}
/**
* 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, state, view, actions) {
const { event, options } = view.turn;
const text = (t) => fillText(pack, state, t, view.turn.bindings);
const visible = options.filter((o) => !o.hidden);
// The first option a player can actually take gets the focus, so Enter plays
// it and Tab walks to the others.
const firstAvailable = visible.find((o) => o.available)?.option.id;
return h('section', { class: 'scenario' },
h('p', { class: 'scenario__text' }, text(event.description)),
h('ul', { class: 'options' },
visible.map(({ option, available, failed, odds }) => h('li', { class: 'options__item' },
h('button', {
class: `option${available ? '' : ' option--locked'}`,
disabled: !available,
dataset: option.id === firstAvailable ? { autofocus: 'true' } : {},
onClick: available ? () => actions.onChoose(option.id) : null,
},
h('span', { class: 'option__label' }, text(option.label)),
// Odds are shown in words, never as a number: "a long shot", "likely".
available && odds && h('span', { class: 'option__odds' }, odds.words),
!available && h('span', { class: 'option__requirement' }, requirementText(pack, failed, state)),
),
)),
),
);
}
function outcomeView(pack, state, view, actions) {
const { record, option } = view;
// The state has already moved on to the next event, so the names in this
// outcome come from the bindings the turn was played with, not the new ones.
const text = (t) => fillText(pack, state, t, record.bindings ?? {});
const branch = record.check ? option[record.check.outcome] : null;
const result = branch ? branch.result : option.result;
const said = pack.checks?.said ?? { success: 'It came off.', failure: 'It did not come off.' };
return h('section', { class: 'outcome' },
h('p', { class: 'outcome__chose' }, record.optionLabel ?? text(option.label)),
record.check && h('p', { class: `outcome__check outcome__check--${record.check.outcome}` },
`${capitalise(record.check.odds)} — ${said[record.check.outcome]}`),
result && h('p', { class: 'outcome__text' }, text(result)),
h('div', { class: 'outcome__ledger' },
h('h3', { class: 'outcome__heading' }, 'What that changed'),
changeList(pack, record.changes, { state }),
record.upkeep.length > 0 && h('div', { class: 'outcome__upkeep' },
h('h3', { class: 'outcome__heading' }, 'And then the day happened'),
changeList(pack, record.upkeep, { state }),
),
),
feedbackWidget(view, actions),
h('button', {
class: 'button button--primary button--next',
dataset: { autofocus: 'true' },
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.'),
);
}
const capitalise = (words) => words.charAt(0).toUpperCase() + words.slice(1);
+347
View File
@@ -0,0 +1,347 @@
/* 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; }
/* ---------- status bar ---------- */
.statusbar {
display: flex; flex-wrap: wrap; gap: 8px 16px;
align-items: baseline; justify-content: space-between;
margin-bottom: 18px; padding-bottom: 10px;
border-bottom: 1px solid var(--rule);
font-size: 12px; letter-spacing: 0.04em;
}
.statusbar__what { display: flex; flex-wrap: wrap; gap: 8px; align-items: baseline; }
.statusbar__game { font-weight: 600; color: var(--ink); }
.statusbar__sep { color: var(--rule-strong); }
.statusbar__pack { color: var(--ink-soft); }
.statusbar__title { color: var(--accent); }
.statusbar__version {
font-variant-numeric: tabular-nums; color: var(--ink-faint); font-size: 11px;
}
.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;
}
/* The odds, in words: "a long shot", "likely". Never a number. */
.option__odds {
font-size: 12px; letter-spacing: 0.04em; color: var(--accent); font-style: italic;
}
.outcome__check { margin: 0 0 8px; font-size: 14px; font-style: italic; }
.outcome__check--success { color: var(--up); }
.outcome__check--failure { color: var(--down); }
/* ---------- 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; }
.change--note .change__phrase { font-style: italic; }
.change--note.change--up .change__phrase { color: var(--up); }
.change--note.change--down .change__phrase { color: var(--ink-soft); }
.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__note { margin: 8px 0 0; color: var(--ink-soft); font-size: 13px; }
.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); }
/* The setting picker. One card per content pack; the pack you are in is
marked rather than hidden, because "keep playing" is the common case. */
.screen--packs { padding: 40px 0; }
.packs__eyebrow {
margin: 0; font-size: 11px; letter-spacing: 0.14em;
text-transform: uppercase; color: var(--ink-faint);
}
.packs__title { margin: 6px 0 6px; font-size: 32px; font-weight: 600; letter-spacing: -0.02em; }
.packs__subtitle {
margin: 0 0 28px; max-width: 52ch;
color: var(--ink-soft); font-family: var(--serif); font-size: 17px;
}
.packs__list {
list-style: none; margin: 0; padding: 0;
display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px;
}
.packs__item {
padding: 18px 18px 16px; background: var(--card);
border: 1px solid var(--rule); border-radius: var(--radius);
display: flex; flex-direction: column; gap: 8px;
}
.packs__item--current { border-color: var(--accent); }
.packs__name { margin: 0; font-size: 19px; font-weight: 600; letter-spacing: -0.01em; }
.packs__blurb { margin: 0; color: var(--ink-soft); font-family: var(--serif); font-size: 16px; }
.packs__state {
margin: 0 0 4px; font-size: 12px; letter-spacing: 0.04em; color: var(--ink-faint);
}
.packs__item .button { margin-top: auto; align-self: flex-start; }
+85
View File
@@ -0,0 +1,85 @@
// 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,
);
// How much of the run was new ground and how much was familiar. The ratio of
// turns to distinct situations is the honest measure of whether a pack has
// enough content for the length of run it invites.
const eventCounts = new Map();
for (const record of history) {
eventCounts.set(record.eventId, (eventCounts.get(record.eventId) ?? 0) + 1);
}
const counts = [...eventCounts.values()];
const mostSeen = [...eventCounts.entries()].sort((a, b) => b[1] - a[1])[0] ?? null;
const stagesVisited = [];
for (const record of history) {
if (stagesVisited.at(-1) !== record.stage) stagesVisited.push(record.stage);
}
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: eventCounts.size,
eventsTotal: (pack.events ?? []).length,
eventCounts,
repeatedSituations: counts.filter((n) => n > 1).length,
averageTimesSeen: eventCounts.size ? history.length / eventCounts.size : 0,
mostSeen: mostSeen ? { eventId: mostSeen[0], count: mostSeen[1] } : null,
stagesVisited,
};
}
+19
View File
@@ -0,0 +1,19 @@
// What is running. Shown on the main screen so a playtester can say which
// build they were on, and so it is obvious when new work has landed.
import { BUILD } from './build-info.js';
export const APP = {
name: 'The Ladder',
// Kept in step with package.json by a test, because it silently was not: the
// status bar read 0.3.0 through the 0.3.1 and 0.3.2 work, which is exactly
// the thing a playtester is told to check to know what they are playing.
version: '0.4.0',
};
/** "0.3.0 · a1b2c3d" — or "0.3.0 · dev" when running from source unstamped. */
export function versionLine() {
return `${APP.version} · ${BUILD.commit}`;
}
export { BUILD };
+74
View File
@@ -0,0 +1,74 @@
// 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 version the app reports is the version in package.json', async () => {
// It silently was not. The status bar read 0.3.0 through the whole of the
// 0.3.1 and 0.3.2 work, while playtesters were being told to check it to know
// which build they were on — a version string that lies is worse than none,
// because it is trusted.
const { APP } = await import('../src/version.js');
const pkg = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8'));
assert.equal(APP.version, pkg.version,
'src/version.js and package.json disagree about what this is');
});
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, /<link\s+rel="stylesheet"/, 'the stylesheet is inlined');
assert.doesNotMatch(html, /type="module"/, 'no module scripts survive; file:// would block them');
assert.doesNotMatch(html, /^[ \t]*import\s/m, 'no bare import statements remain');
assert.match(html, /--paper:/, 'the CSS made it in');
// A distributed file has to be able to say which commit made it.
assert.match(html, /commit: '[^']+'/, 'the build stamp is present');
assert.doesNotMatch(html, /commit: 'dev'/, 'a built file is never stamped "dev"');
const script = html.match(/<script>([\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)();
// Nothing is saved and nothing is remembered, so a build shipping more
// than one setting opens on the picker. Click into the first one and carry
// on — this exercises the front door in the real bundle, which is the one
// place the pack registry, the picker screen and the save slots all meet.
assert.ok(firstByClass(dom.root, 'screen--packs'), 'the bundled game rendered the picker');
assert.match(dom.root.textContent, /Corporate Ladder/, 'the packs are named');
assert.match(dom.root.textContent, /The Frontier/);
const start = byClass(dom.root, 'button').find((b) => /Start here|Keep playing/.test(b.textContent));
assert.ok(start, 'the picker offers a way in');
start.click();
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/);
assert.match(dom.root.textContent, /The Ladder/, 'the status bar is in the bundle');
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();
}
});
+185
View File
@@ -0,0 +1,185 @@
// The cast drawn per run, names in text, and options whose outcome is a check.
// See DECISIONS #41. Runs against test/fixtures/cast-pack.js, never real content.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { startGame, currentTurn, takeTurn } from '../src/engine/game.js';
import { validatePack } from '../src/engine/validate.js';
import { hydrate, createState, resolveSchema } from '../src/engine/state.js';
import { presentPeople, presentSlots, personIn } from '../src/engine/cast.js';
import { fillText } from '../src/engine/text.js';
import { chanceOf, oddsWords } from '../src/engine/checks.js';
import { getPath, setPath } from '../src/engine/paths.js';
import { castPack } from './fixtures/cast-pack.js';
import { testPack } from './fixtures/test-pack.js';
const clone = (value) => structuredClone(value);
/** Advance until the current event is `eventId`, taking `work` otherwise. */
function until(state, eventId, pack = castPack) {
let next = state;
for (let i = 0; i < 200 && currentTurn(pack, next).event.id !== eventId; i++) {
next = takeTurn(pack, next, currentTurn(pack, next).options[0].option.id).state;
}
assert.equal(currentTurn(pack, next).event.id, eventId);
return next;
}
test('the cast pack is valid, with no warnings', () => {
const { errors, warnings } = validatePack(castPack);
assert.deepEqual(errors, []);
assert.deepEqual(warnings, []);
});
test('a run draws one boss and two to four different co-workers', () => {
const counts = new Set();
for (let seed = 0; seed < 40; seed++) {
const state = startGame(castPack, { seed });
const people = presentPeople(castPack, state);
assert.equal(people.filter((p) => p.role === 'boss').length, 1);
const coworkers = presentSlots(castPack, state, 'coworker');
assert.ok(coworkers.length >= 2 && coworkers.length <= 4, `drew ${coworkers.length}`);
counts.add(coworkers.length);
const names = people.map((p) => p.name);
assert.equal(new Set(names).size, names.length, 'nobody is drawn twice');
assert.ok(names.includes('Old Sam'), 'a fixed character is always present');
}
assert.deepEqual([...counts].sort(), [2, 3, 4], 'every head count turns up across seeds');
});
test('the cast is the seed\'s: the same seed draws the same people, others differ', () => {
const names = (seed) => presentPeople(castPack, startGame(castPack, { seed })).map((p) => p.name).join('|');
assert.equal(names('a'), names('a'));
const distinct = new Set(Array.from({ length: 20 }, (_, i) => names(`s${i}`)));
assert.ok(distinct.size > 10, `only ${distinct.size} different casts in 20 runs`);
});
test('an empty group slot is not in the run, and a drawn person brings their own starting regard', () => {
let seed = 0;
let state;
do state = startGame(castPack, { seed: seed++ });
while (presentSlots(castPack, state, 'coworker').length !== 2 || personIn(castPack, state, 'boss').name !== 'Walter Grose');
assert.equal(getPath(state, 'cast.coworker_3'), null);
assert.equal(personIn(castPack, state, 'coworker_3'), undefined);
assert.equal(getPath(state, 'relationships.boss.trust'), 30);
});
test('names are filled into text from whoever was drawn', () => {
const state = startGame(castPack, { seed: 'names' });
const boss = personIn(castPack, state, 'boss');
assert.equal(fillText(castPack, state, 'Ask {boss.first}, the {boss.title}.'),
`Ask ${boss.name.split(' ')[0]}, the Manager.`);
assert.equal(fillText(castPack, state, '{porter} nods.'), 'Old Sam nods.');
assert.equal(fillText(castPack, state, '{nobody} stays as written.'), '{nobody} stays as written.');
});
test('an event about a co-worker binds one who is present, and its effects land on them', () => {
const state = until(startGame(castPack, { seed: 'bind' }), 'needled');
const turn = currentTurn(castPack, state);
const slot = turn.bindings.coworker;
assert.ok(presentSlots(castPack, state, 'coworker').includes(slot));
assert.match(fillText(castPack, state, turn.event.description), new RegExp(personIn(castPack, state, slot).name));
const before = getPath(state, `relationships.${slot}.regard`);
const { state: after, record } = takeTurn(castPack, state, 'ignore');
assert.equal(getPath(after, `relationships.${slot}.regard`), before - 2);
assert.deepEqual(record.bindings, { coworker: slot });
});
test('the chance follows the formula, and the odds are words', () => {
let state = startGame(castPack, { seed: 'odds' });
const option = castPack.events[0].options[2]; // wits against difficulty 5
state = setPath(state, 'attributes.wits', 5);
assert.equal(chanceOf(castPack, state, option).chance, 50);
state = setPath(state, 'attributes.wits', 7);
assert.equal(chanceOf(castPack, state, option).chance, 70);
state = setPath(state, 'attributes.wits', 10);
assert.equal(chanceOf(castPack, state, option).chance, 100, 'clamped at the ceiling');
assert.equal(oddsWords(castPack, 15), 'a long shot');
assert.equal(oddsWords(castPack, 50), 'an even chance');
assert.equal(oddsWords(castPack, 90), 'a safe bet');
});
test('an opposed check reads the other person\'s attribute, and their regard moves it', () => {
let state = startGame(castPack, { seed: 'opposed' });
const option = castPack.events[0].options[1]; // charm against the boss's wits, leaning on trust
const boss = personIn(castPack, state, 'boss');
state = setPath(state, 'attributes.charm', 6);
state = setPath(state, 'relationships.boss.trust', 50);
const even = chanceOf(castPack, state, option);
assert.equal(even.theirs, boss.attributes.wits);
assert.equal(even.chance, Math.min(100, Math.max(0, 50 + 10 * (6 - boss.attributes.wits))));
state = setPath(state, 'relationships.boss.trust', 70);
assert.equal(chanceOf(castPack, state, option).chance, Math.min(100, even.chance + 10));
});
test('a check applies the branch it rolled, plus the option\'s own effects', () => {
let state = until(startGame(castPack, { seed: 'roll' }), 'routine');
const sure = setPath(setPath(state, 'attributes.charm', 10), 'relationships.boss.trust', 100);
const won = takeTurn(castPack, sure, 'ask_for_more');
assert.equal(won.record.check.outcome, 'success');
assert.equal(getPath(won.state, 'money'), getPath(sure, 'money') + 50);
assert.equal(getPath(won.state, 'relationships.boss.regard'), getPath(sure, 'relationships.boss.regard') - 1);
const hopeless = setPath(setPath(state, 'attributes.charm', 0), 'relationships.boss.trust', 0);
const lost = takeTurn(castPack, hopeless, 'ask_for_more');
assert.equal(lost.record.check.outcome, 'failure');
assert.equal(getPath(lost.state, 'money'), getPath(hopeless, 'money'));
assert.equal(getPath(lost.state, 'relationships.boss.trust'), 0, 'clamped');
assert.match(lost.record.check.odds, /long shot/);
});
test('a run with checks and bindings replays identically from its seed and choices', () => {
const play = () => {
let state = startGame(castPack, { seed: 'replay' });
const records = [];
for (let i = 0; i < 60; i++) {
const turn = currentTurn(castPack, state);
const pick = turn.options[i % turn.options.length].option.id;
const result = takeTurn(castPack, state, pick);
records.push(result.record);
state = i === 30 ? JSON.parse(JSON.stringify(result.state)) : result.state;
}
return { state, records };
};
const a = play();
const b = play();
assert.deepEqual(a.records.map((r) => r.check?.outcome ?? '-'), b.records.map((r) => r.check?.outcome ?? '-'));
assert.deepEqual(a.state, b.state);
assert.ok(a.records.some((r) => r.check?.outcome === 'success') && a.records.some((r) => r.check?.outcome === 'failure'));
});
test('a save from before the pack drew a cast gets one on load, the same one every time', () => {
const schema = resolveSchema(castPack);
const fresh = createState(castPack, { seed: 'old', schema });
const old = clone(fresh);
delete old.cast;
const once = hydrate(castPack, clone(old), schema);
const twice = hydrate(castPack, clone(old), schema);
assert.ok(once.added.includes('cast'));
assert.deepEqual(once.state.cast, twice.state.cast);
assert.deepEqual(once.state.cast, fresh.cast);
});
test('a pack with no drawn cast gets no cast and no bindings: its runs are unchanged', () => {
const state = startGame(testPack, { seed: 'plain' });
assert.equal(state.cast, undefined);
assert.equal(state.meta.bindings, undefined);
assert.equal(presentPeople(testPack, state).map((p) => p.name).join(), 'The Chief,Pat');
});
test('the validator catches cast and check mistakes', () => {
const broken = clone(castPack);
broken.events[0].options[1].check.attribute = 'strength';
broken.events[0].options[2].failure = undefined;
broken.events[0].description = 'Blame {coworker}.';
broken.events.push({ id: 'ghost', stage: 'desk', description: '{ghost} appears.', options: [{ id: 'x', label: 'x', effects: [] }] });
broken.characters[1].count = [2, 6];
const { errors } = validatePack(broken);
const has = (fragment) => assert.ok(errors.some((e) => e.includes(fragment)), `expected an error mentioning "${fragment}":\n${errors.join('\n')}`);
has('attribute "strength" is not in pack.attributes');
has('has no failure');
has('{coworker} is a group');
has('{ghost} names no character');
has('has only 5 candidates');
});
+79
View File
@@ -0,0 +1,79 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { evaluate, check, pathsUsed } from '../src/engine/conditions.js';
const state = {
money: 100,
turn: 12,
skills: { talking: 3 },
flags: { in_debt: false },
notable: ['promoted'],
seen: { first_day: 1 },
};
test('compares numbers', () => {
assert.equal(evaluate(state, { path: 'money', op: '>=', value: 100 }), true);
assert.equal(evaluate(state, { path: 'money', op: '>', value: 100 }), false);
assert.equal(evaluate(state, { path: 'skills.talking', op: '<', value: 5 }), true);
});
test('compares booleans and equality', () => {
assert.equal(evaluate(state, { path: 'flags.in_debt', op: '==', value: false }), true);
assert.equal(evaluate(state, { path: 'flags.in_debt', op: '!=', value: true }), true);
});
test('array membership works both directions', () => {
assert.equal(evaluate(state, { path: 'notable', op: 'has', value: 'promoted' }), true);
assert.equal(evaluate(state, { path: 'notable', op: 'not-has', value: 'fired' }), true);
assert.equal(evaluate(state, { path: 'skills.talking', op: 'in', value: [1, 2, 3] }), true);
assert.equal(evaluate(state, { path: 'skills.talking', op: 'not-in', value: [9] }), true);
});
test('ordering against a missing path is false, not a throw', () => {
assert.equal(evaluate(state, { path: 'skills.welding', op: '>=', value: 1 }), false);
assert.equal(evaluate(state, { path: 'skills.welding', op: '<', value: 1 }), false);
});
test('content can read engine bookkeeping', () => {
assert.equal(evaluate(state, { path: 'turn', op: '>=', value: 10 }), true);
assert.equal(evaluate(state, { path: 'seen.first_day', op: '>=', value: 1 }), true);
assert.equal(evaluate(state, { path: 'seen.never_fired', op: '>=', value: 1 }), false);
});
test('combinators nest', () => {
const condition = {
all: [
{ path: 'money', op: '>', value: 0 },
{ any: [{ path: 'skills.talking', op: '>=', value: 3 }, { path: 'flags.in_debt', op: '==', value: true }] },
{ not: { path: 'notable', op: 'has', value: 'fired' } },
],
};
assert.equal(evaluate(state, condition), true);
assert.equal(evaluate({ ...state, money: -1 }, condition), false);
});
test('check reports which conditions failed', () => {
const result = check(state, [
{ path: 'money', op: '>=', value: 100 },
{ path: 'skills.talking', op: '>=', value: 9 },
]);
assert.equal(result.passed, false);
assert.equal(result.failed.length, 1);
assert.equal(result.failed[0].path, 'skills.talking');
});
test('an absent gate passes', () => {
assert.equal(check(state, undefined).passed, true);
assert.equal(check(state, []).passed, true);
});
test('an unknown operator is a loud failure', () => {
assert.throws(() => evaluate(state, { path: 'money', op: '=~', value: 1 }), /unknown condition operator/);
});
test('pathsUsed walks the whole tree', () => {
const paths = pathsUsed({
all: [{ path: 'money', op: '>', value: 0 }, { not: { path: 'flags.in_debt', op: '==', value: true } }],
});
assert.deepEqual(paths.sort(), ['flags.in_debt', 'money']);
});
+314
View File
@@ -0,0 +1,314 @@
// What every content pack must do, whatever setting it is.
//
// This suite runs over `content/index.js` — every pack the build ships — and
// asserts only things that are true of a pack because it is a pack, never
// because it is about a mailroom or a freight yard. A new setting is therefore
// tested the moment it appears in the registry, rather than by copying this
// file and changing the nouns.
//
// Per-pack tuning lives with the pack: content.test.js and frontier.test.js
// hold each one to its own economy, its own promotion and its own characters.
// Anything a pack must declare for this suite to check it — the economic
// baseline, for one — is a field on the pack, not a constant in here.
//
// One trap, learned the hard way: the chooser's RNG must be seeded
// *differently* from the game's. Both draw one number per turn from the same
// generator, so sharing a seed correlates the choice index with the event draw
// and silently makes whole option combinations unreachable.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { packs } from '../content/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, strategies } from './fixtures/strategies.js';
import { summarize } from '../src/ui/summary.js';
/** Play `turns` turns of `pack` with independently-seeded random choices. */
function randomRun(pack, runSeed, turns) {
const rng = createRng(`chooser-${runSeed}`);
let state = startGame(pack, { seed: `run-${runSeed}` });
for (let t = 0; t < turns; t++) {
const turn = currentTurn(pack, state);
const available = turn.options.filter((o) => o.available);
assert.ok(available.length > 0,
`${pack.id}: no available option on event "${turn.event.id}"`);
state = takeTurn(pack, state, rng.pick(available).option.id).state;
}
return state;
}
/**
* Net movement of `money` from upkeep alone, per calendar cycle, at one stage.
*
* Derived rather than simulated: an entry worth `v` every `n` turns is worth
* `v * cycleLength / n` per cycle, exactly, with no window to fall off the end
* of. Simulating this over a fixed number of turns reads differently depending
* on where the last rent lands — Corporate Ladder measures -19 a fortnight over
* 280 turns and -42 over 560, and only one of those is the steady state.
*
* Entries gated on a path the pack calls elective are excluded: a debt it took
* on, a lifestyle it bought. The baseline is what a player faces having opted
* into none of them, which is the floor the pack is designed around.
*
* Which paths those are is the pack's to name, in `tuning.baselineExcludes`.
* This used to be a regex over path names — /debt|owed|loan/i — which is the
* generic suite guessing at content's vocabulary, exactly what DECISIONS #31
* says it must not do. It also silently missed anything named otherwise.
*/
function baselinePerCycle(pack, stage) {
const cycle = pack.calendar?.cycleLength ?? 1;
const elective = new Set(pack.tuning?.baselineExcludes ?? []);
return (pack.upkeep ?? [])
.filter((entry) => {
const gate = entry.requires ?? [];
if (gate.some((c) => elective.has(c.path))) return false;
return !gate.some((c) => c.path === 'stage' && c.value !== stage);
})
.reduce((total, entry) => {
const money = entry.effects
.filter((e) => e.path === 'money' && e.op === 'add')
.reduce((sum, e) => sum + e.value, 0);
return total + money * (cycle / (entry.every ?? 1));
}, 0);
}
for (const pack of packs) {
const where = `[${pack.id}]`;
test(`${where} the pack is valid, with no warnings`, () => {
const result = validatePack(pack);
assert.deepEqual(result.errors, []);
assert.deepEqual(result.warnings, []);
});
test(`${where} declares what the interface and this suite need from it`, () => {
// Everything the picker shows and everything asserted below. A pack that
// omits one of these is not a broken game, but it is a pack that will
// render badly or go unchecked, and both should fail loudly at build time.
assert.ok(pack.name, 'a display name');
assert.ok(pack.blurb, 'a one-line blurb for the setting picker');
assert.ok(pack.peopleHeading, 'a heading for the panel of characters');
assert.ok(pack.stages && Object.keys(pack.stages).length > 0, 'named stages');
assert.ok(pack.tuning?.baselinePerCycle, 'a declared economic baseline');
for (const stage of Object.keys(pack.stages)) {
assert.ok(pack.stages[stage].title, `a title for stage "${stage}"`);
assert.ok(pack.tuning.baselinePerCycle[stage], `a baseline for stage "${stage}"`);
}
// A typo here would not fail: the entry simply would not be excluded, and
// the baseline would quietly become a number nobody meant.
for (const path of pack.tuning.baselineExcludes ?? []) {
assert.ok(path in pack.state,
`baselineExcludes names "${path}", which the pack does not declare`);
}
});
test(`${where} no content is dead: every event fires and every option becomes available`, () => {
// Across archetypes, not just random play: several things in a pack exist
// for a player pursuing something, and "broke but well-liked" in
// particular is a state no single-axis strategy ever reaches.
//
// Options are checked on *availability*, not on being taken. Whether a
// particular bot picks an option is an artefact of how the bot scores;
// what matters is whether a player is ever offered it unlocked.
const { firedEvents, offeredOptions } = sweep(pack, { seeds: 12, turns: 250 });
const allEvents = pack.events.map((e) => e.id);
const allOptions = pack.events.flatMap((e) => e.options.map((o) => `${e.id}.${o.id}`));
assert.deepEqual(allEvents.filter((e) => !firedEvents.has(e)), [],
'events that can never fire');
assert.deepEqual(allOptions.filter((o) => !offeredOptions.has(o)), [],
'options no player can ever be offered');
});
test(`${where} every event leaves a player with nothing at least one way out`, () => {
// A player with no skills, no money and no friends must still be able to
// take a turn. The engine guarantees an event will be drawn; only content
// can guarantee it is playable when everything is gated.
const trapped = pack.events
.filter((event) => !(event.options ?? []).some((option) => !option.requires))
.map((event) => event.id);
assert.deepEqual(trapped, [], 'events whose every option is gated');
});
test(`${where} no repayment can take more than is owed`, () => {
// A fixed instalment against a smaller balance takes money for a debt that
// does not exist; the Frontier's store did it to a real player. A balance
// is any money-formatted stat other than money itself. An option that pays
// one down with money must read the amount from the balance, or be gated
// on the balance covering the whole instalment. See DECISIONS #38.
const balances = Object.entries(pack.display ?? {})
.filter(([path, rule]) => rule.format === 'money' && path !== 'money')
.map(([path]) => path);
const overcharging = [];
for (const event of pack.events) {
for (const option of event.options ?? []) {
// A check's branches pay and spend too (DECISIONS #41): each is judged
// together with the effects that apply either way.
const branches = option.check
? [option.success, option.failure].map((b) => [...(option.effects ?? []), ...(b?.effects ?? [])])
: [option.effects ?? []];
for (const effects of branches) {
const spends = effects.some((e) => e.path === 'money' && e.op === 'add'
&& (typeof e.value === 'number' ? e.value < 0 : (e.value?.times ?? 1) < 0));
if (!spends) continue;
for (const e of effects) {
if (!balances.includes(e.path) || e.op !== 'add') continue;
if (typeof e.value !== 'number' || e.value >= 0) continue;
const covered = (option.requires ?? [])
.some((r) => r.path === e.path && r.op === '>=' && r.value >= -e.value);
if (!covered) overcharging.push(`${event.id}.${option.id} (${e.path} ${e.value})`);
}
}
}
}
assert.ok(balances.length > 0 || pack.display === undefined, 'a pack with a balance should declare it as money');
assert.deepEqual(overcharging, [], 'options that can charge for more than is owed');
});
test(`${where} every turn always offers something to do, however badly it is going`, () => {
for (let seed = 100; seed < 130; seed++) randomRun(pack, seed, 150);
});
test(`${where} a played run replays identically from its seed`, () => {
assert.deepEqual(randomRun(pack, 42, 80), randomRun(pack, 42, 80));
});
test(`${where} how you play changes where you end up`, () => {
// Measured before promotions land, so this is about how a player handles
// money rather than how fast they left the first rung. Mixing the two
// hides the economy behind the career.
const { byStrategy } = sweep(pack, { seeds: 12, turns: 35 });
const finalMoney = (name) => median(byStrategy[name].map((s) => s.money));
assert.ok(byStrategy.thrifty.every((s) => s.stage === pack.startingStage),
'nobody should be promoted this early');
assert.ok(finalMoney('thrifty') > finalMoney('random'),
'a careful player should get ahead');
assert.ok(finalMoney('random') > finalMoney('spendthrift'),
'a careless player should struggle');
});
test(`${where} the upkeep baseline matches what the pack declares`, () => {
// The guard that was missing when Corporate Ladder's rent was raised from
// 385 to 455 without re-deriving what it meant: the baseline went from
// -$3/day to -$13/day and trapped a real playtester below zero for 30% of
// a 182-turn run. No archetype caught it — they all either optimised money
// or were promoted out of the problem before it bit.
for (const [stage, [low, high]] of Object.entries(pack.tuning.baselinePerCycle)) {
const net = baselinePerCycle(pack, stage);
assert.ok(net > low && net < high,
`"${stage}" nets ${net} a cycle; the pack declares (${low}, ${high})`);
}
});
test(`${where} nobody spends the run stuck below zero`, () => {
// Hard times is a state a player passes through, not one they live in.
// The failure this guards against is a debt whose service charge exceeds
// anything the escape options can pay — inescapable by arithmetic.
for (const strategy of Object.keys(strategies)) {
const shares = [];
for (let seed = 0; seed < 8; seed++) {
let red = 0;
let turns = 0;
play(pack, { strategy, seed, turns: 150,
visit: ({ state }) => { turns++; if (state.money < 0) red++; } });
shares.push(red / turns);
}
const worst = Math.max(...shares);
assert.ok(worst < 0.25,
`${strategy} spent ${(worst * 100).toFixed(0)}% of a run below zero`);
}
});
test(`${where} a run that goes broke can climb back out`, () => {
let sawNegative = false;
let recovered = false;
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(`${where} a long run does not collapse onto a handful of situations`, () => {
// Motivated by a real 183-turn session. Before Dispatch was fleshed out,
// its two floor events were 46% of every promoted run — one every three
// turns. Variety is a property of the pack, and this should fail loudly
// when a stage is too thin for the run it invites.
for (const strategy of ['sociable', 'thrifty', 'random']) {
const counts = new Map();
let turns = 0;
play(pack, { strategy, seed: 'variety', turns: 200,
visit: ({ turn }) => {
counts.set(turn.event.id, (counts.get(turn.event.id) ?? 0) + 1);
turns++;
} });
const [topEvent, topCount] = [...counts.entries()].sort((a, b) => b[1] - a[1])[0];
const share = topCount / turns;
assert.ok(share < 0.25,
`${strategy}: "${topEvent}" was ${(share * 100).toFixed(0)}% of turns`);
// Measured against what was actually reachable: a run that never leaves
// the first stage cannot see the second, and counting that as thin
// content would be measuring career progress rather than variety.
const visited = new Set([...counts.keys()]
.map((id) => pack.events.find((e) => e.id === id).stage)
.filter(Boolean));
const reachable = pack.events
.filter((e) => e.stage === undefined || visited.has(e.stage)).length;
assert.ok(counts.size / reachable > 0.6,
`${strategy}: saw ${counts.size} of ${reachable} reachable situations in ${turns} turns`);
}
});
test(`${where} the retrospective has something to read back`, () => {
const state = randomRun(pack, 3, 60);
assert.ok(state.notable.length > 0, 'notable moments should accumulate');
assert.equal(state.history.length, 60, 'every turn is recorded');
assert.ok(state.history.every((h) => Array.isArray(h.upkeep)), 'upkeep is recorded per turn');
});
test(`${where} the retrospective reports how repetitive the run was`, () => {
const state = play(pack, { strategy: 'sociable', seed: 'repeats', turns: 120 });
const summary = summarize(pack, state);
assert.equal(summary.turns, 120);
assert.ok(summary.eventsSeen > 0 && summary.eventsSeen <= summary.eventsTotal);
assert.ok(summary.averageTimesSeen > 1, 'a long run repeats situations');
assert.equal(
summary.averageTimesSeen.toFixed(2),
(summary.turns / summary.eventsSeen).toFixed(2),
'the average is turns over distinct situations',
);
assert.ok(summary.mostSeen.count >= summary.averageTimesSeen);
assert.ok(summary.repeatedSituations <= summary.eventsSeen);
assert.ok(summary.stagesVisited.includes(pack.startingStage));
});
if (pack.calendar) {
test(`${where} the calendar names the unit and counts the cycle`, () => {
const { cycleLength, unitNames } = pack.calendar;
let state = startGame(pack, { seed: 'calendar' });
assert.deepEqual(
[state.calendar.name, state.calendar.cycle, state.calendar.index],
[unitNames?.[0] ?? null, 1, 0],
'turn 1 is the first unit of the first cycle',
);
// One full cycle on, the name comes round and the cycle has ticked.
for (let t = 0; t < cycleLength; t++) {
const turn = currentTurn(pack, state);
state = takeTurn(pack, state, turn.options.find((o) => o.available).option.id).state;
}
assert.equal(state.turn, cycleLength + 1);
assert.deepEqual(
[state.calendar.name, state.calendar.cycle, state.calendar.index],
[unitNames?.[0] ?? null, 2, 0],
);
});
}
}
+228
View File
@@ -0,0 +1,228 @@
// Corporate Ladder's own tuning.
//
// What is true of this pack because it is *this* pack: the shape of its week,
// when wages and rent land, how the loan behaves, when Dispatch opens and how
// deep the Trevor chain is. Everything true of a pack because it is a pack —
// validity, dead content, the economic baseline, variety, replay — is in
// conformance.test.js, which runs over every pack in the registry.
//
// Engine behaviour is covered against the fixture pack instead, so tuning the
// game cannot break the engine suite and vice versa.
//
// One trap, learned the hard way: the chooser's RNG must be seeded
// *differently* from the game's. Both draw one number per turn from the same
// generator, so sharing a seed correlates the choice index with the event
// draw and silently makes whole option combinations unreachable — which looks
// exactly like a content bug.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { corporateLadder as pack } from '../content/corporateladder/index.js';
import { startGame, currentTurn, takeTurn } from '../src/engine/game.js';
import { check } from '../src/engine/conditions.js';
import { play, sweep, median } from './fixtures/strategies.js';
/**
* Net money from upkeep per week for a player in a given position: at a stage,
* at a lifestyle tier, owing nothing.
*
* Derived rather than simulated. Comparing simulated end-balances between
* promoted and unpromoted players looks like the obvious test and is
* confounded: which archetypes get promoted is not independent of how they
* spend, so the pooled medians mostly measure the archetype mix. It passed for
* two releases and then inverted the moment there was something to spend on.
*/
function netPerCycle(position) {
const state = { debt: 0, lifestyle: 0, reputation: 0, ...position };
const cycle = pack.calendar?.cycleLength ?? 1;
return (pack.upkeep ?? [])
.filter((entry) => check(state, entry.requires).passed)
.reduce((total, entry) => total + entry.effects
.filter((e) => e.path === 'money' && e.op === 'add')
.reduce((sum, e) => sum + e.value, 0) * (cycle / (entry.every ?? 1)), 0);
}
test('the promotion is worth taking, and reachable by a deliberate player', () => {
const { byStrategy } = sweep(pack, { seeds: 12, turns: 200 });
const promoted = Object.values(byStrategy).flat().filter((s) => s.stage === 'dispatch');
const stayed = Object.values(byStrategy).flat().filter((s) => s.stage === 'mailroom');
assert.ok(promoted.length > 0 && stayed.length > 0, 'both outcomes should occur');
const mailroom = netPerCycle({ stage: 'mailroom' });
const coordinator = netPerCycle({ stage: 'dispatch' });
assert.ok(coordinator > mailroom + 40,
`dispatch nets ${coordinator} a week against the mailroom's ${mailroom}`);
// A player actually pursuing it should get there mid-run, not at the very
// end — the mailroom is a career, not a tutorial.
const timings = [];
for (let seed = 0; seed < 12; seed++) {
let at = null;
play(pack, { strategy: 'sociable', seed, turns: 200,
visit: ({ state }) => { if (state.stage === 'dispatch' && at === null) at = state.turn; } });
if (at) timings.push(at);
}
assert.equal(timings.length, 12, 'a sociable player should always get the offer');
const typical = median(timings);
assert.ok(typical > 40 && typical < 90, `promotion landed at turn ${typical}`);
});
/**
* Play to `turns` chasing standing, buying the lifestyle or refusing to.
*
* Two players differing in exactly one habit. The archetype sweep cannot
* answer this question: which archetypes get promoted is not independent of
* how they spend, so its pooled medians compare populations, not policies.
*/
function chaseStanding({ seed, keepUp, turns = 200 }) {
const buys = (r) => (r.option.effects ?? []).some((e) => e.path === 'lifestyle');
const gain = (r) => (r.option.effects ?? [])
.filter((e) => e.path === 'reputation' && e.op === 'add')
.reduce((sum, e) => sum + e.value, 0);
let state = startGame(pack, { seed: `standing-${seed}` });
for (let t = 0; t < turns; t++) {
const turn = currentTurn(pack, state);
const available = turn.options.filter((o) => o.available);
const buy = available.find(buys);
const chosen = (keepUp && buy)
? buy
: [...available.filter((o) => keepUp || !buys(o))].sort((a, b) => gain(b) - gain(a))[0]
?? available[0];
state = takeTurn(pack, state, chosen.option.id).state;
}
return state;
}
test('standing costs money to keep, and the top of it cannot be worked for', () => {
// Derived: each tier is a real weekly charge, and a coordinator keeping up
// fully is running at a loss on upkeep alone. That is the answer to "money
// stops mattering late" — it stopped mattering because standing was free.
const plain = netPerCycle({ stage: 'dispatch' });
const car = netPerCycle({ stage: 'dispatch', lifestyle: 1 });
const address = netPerCycle({ stage: 'dispatch', lifestyle: 2 });
assert.ok(car < plain - 25, `the car costs ${plain - car} a week`);
assert.ok(address < car - 35, `the address costs ${car - address} a week`);
assert.ok(address < 0,
`a coordinator keeping up nets ${address} a week and has to earn the difference`);
// And the top of the ladder is not a plateau. A 96-turn session bought both
// tiers, reached 100 standing by turn 84, and from there had nothing left to
// spend on: every charge was switched off and the balance only climbed.
// Standing costs money to carry at every tier, with nothing to buy that ends
// it, so there is no position in this pack where money stops mattering.
const arrived = netPerCycle({ stage: 'dispatch', lifestyle: 2, reputation: 90, money: 2500 });
assert.ok(arrived < address,
`standing itself should cost something: ${arrived} against ${address} a week`);
// And it steepens with what there is to keep up, which is what stops the
// balance climbing forever. A flat charge could not: late income scales with
// how well the player plays, so flattening an optimiser's slope with one
// meant putting ordinary play 44% of a run below zero.
const wealthy = netPerCycle({ stage: 'dispatch', lifestyle: 2, reputation: 90, money: 6000 });
assert.ok(wealthy < arrived,
`the charge should steepen with money: ${wealthy} against ${arrived} a week`);
assert.ok(wealthy < 0, `and take a rich coordinator backwards: ${wealthy} a week`);
// At every tier, not only the top one: a coordinator who bought nothing is
// already paying for standing in standing, and pays in money besides.
const plainSomebody = netPerCycle({ stage: 'dispatch', lifestyle: 0, reputation: 90, money: 6000 });
assert.ok(plainSomebody < plain,
`standing should cost at tier 0 too: ${plainSomebody} against ${plain} a week`);
// Simulated, controlled: the ceiling. Without the top step a player who
// chased standing every turn held 99 while buying nothing, which made the
// whole system a tax on inattention rather than a choice.
const never = [];
const keeps = [];
for (let seed = 0; seed < 8; seed++) {
never.push(chaseStanding({ seed, keepUp: false }));
keeps.push(chaseStanding({ seed, keepUp: true }));
}
const standing = (runs) => median(runs.map((s) => s.reputation));
assert.ok(standing(never) <= 90,
`a coordinator who buys nothing reached ${standing(never)} standing`);
assert.ok(standing(keeps) >= 95,
`a coordinator who keeps up reached only ${standing(keeps)} standing`);
assert.ok(median(keeps.map((s) => s.money)) < median(never.map((s) => s.money)),
'and the difference is paid for out of the balance');
});
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('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;
}
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('debt cannot spiral without limit: lending stops and the charge escalates', () => {
let peakDebt = 0;
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.
assert.ok(peakDebt <= 3000, `debt reached ${peakDebt}; the borrowing ceiling is not holding`);
const charges = pack.upkeep.filter((u) => u.label?.startsWith('Loan service charge'));
assert.ok(charges.length >= 3, 'the service charge should step up as debt grows');
});
test('a deliberate player can reach relationship-gated content in a reasonable run', () => {
// The Trevor chain is the deepest gate in the pack. A player cultivating him
// should get there well inside a normal session, not at turn 100.
const regard = (o) => (o.option.effects ?? [])
.filter((e) => e.path === 'relationships.trevor.likes_you')
.reduce((sum, e) => sum + e.value, 0);
let state = startGame(pack, { seed: 'befriend-trevor' });
let reached = null;
for (let t = 0; t < 60 && reached === null; t++) {
const turn = currentTurn(pack, state);
if (turn.event.id === 'trevor_favor') { reached = state.turn; break; }
const best = turn.options.filter((o) => o.available).sort((a, b) => regard(b) - regard(a))[0];
state = takeTurn(pack, state, best.option.id).state;
}
assert.ok(reached && reached < 45, `Trevor's chain opened at turn ${reached ?? 'never'}`);
});
+105
View File
@@ -0,0 +1,105 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { applyEffect, applyEffects } from '../src/engine/effects.js';
const schema = {
'money': { initial: 0, min: -500, max: 1000 },
'skills.talking': { initial: 1, min: 0, max: 10 },
'flags.in_debt': { initial: false },
'notable': { initial: [] },
};
const state = { money: 100, skills: { talking: 1 }, flags: { in_debt: false }, notable: [] };
test('add and set produce a new state', () => {
const result = applyEffects(state, [
{ path: 'money', op: 'add', value: -30 },
{ path: 'flags.in_debt', op: 'set', value: true },
], schema);
assert.equal(result.state.money, 70);
assert.equal(result.state.flags.in_debt, true);
assert.equal(state.money, 100, 'the input state is never mutated');
});
test('push appends to a list', () => {
const result = applyEffects(state, [{ path: 'notable', op: 'push', value: 'first day' }], schema);
assert.deepEqual(result.state.notable, ['first day']);
assert.deepEqual(state.notable, [], 'the input list is never mutated');
});
test('remove drops matching entries', () => {
const seeded = { ...state, notable: ['a', 'b', 'a'] };
const result = applyEffects(seeded, [{ path: 'notable', op: 'remove', value: 'a' }], schema);
assert.deepEqual(result.state.notable, ['b']);
});
test('values clamp to their declared bounds', () => {
const high = applyEffect(state, { path: 'skills.talking', op: 'add', value: 99 }, schema);
assert.equal(high.state.skills.talking, 10);
assert.equal(high.change.clamped, true);
const low = applyEffect(state, { path: 'money', op: 'add', value: -9999 }, schema);
assert.equal(low.state.money, -500);
});
test('an unbounded path is not clamped', () => {
const result = applyEffect(state, { path: 'money', op: 'add', value: 10 }, {});
assert.equal(result.state.money, 110);
assert.equal(result.change.clamped, false);
});
test('the change record carries before and after for every path touched', () => {
const result = applyEffects(state, [
{ path: 'money', op: 'add', value: 25 },
{ path: 'skills.talking', op: 'add', value: 1 },
], schema);
assert.deepEqual(result.changes.map((c) => [c.path, c.from, c.to]), [
['money', 100, 125],
['skills.talking', 1, 2],
]);
});
test('adding to a path that has never been set treats it as zero', () => {
const result = applyEffect({}, { path: 'money', op: 'add', value: 5 }, {});
assert.equal(result.state.money, 5);
});
test('an unknown operation is a loud failure', () => {
assert.throws(() => applyEffect(state, { path: 'money', op: 'multiply', value: 2 }), /unknown effect operation/);
});
test('an amount can be read from state, capped and signed', () => {
// The Frontier's store, turn 27 of a real session: four owed, twenty taken.
const ledger = { owed: { initial: 0, min: 0, max: 1000 } };
const pay = [
{ path: 'money', op: 'add', value: { from: 'owed', times: -1, cap: 20 } },
{ path: 'owed', op: 'add', value: { from: 'owed', times: -1, cap: 20 } },
];
const small = applyEffects({ money: 110, owed: 4 }, pay, ledger);
assert.equal(small.state.money, 106, 'pays what is owed, not the instalment');
assert.equal(small.state.owed, 0);
assert.equal(small.changes[1].clamped, false, 'nothing is lost to a clamp');
assert.equal(small.changes[0].value, -4, 'the change records the amount applied');
assert.deepEqual(small.changes[0].source, { from: 'owed', times: -1, cap: 20 });
const large = applyEffects({ money: 110, owed: 50 }, pay, ledger);
assert.equal(large.state.money, 90, 'the cap holds when more is owed');
assert.equal(large.state.owed, 30);
});
test('an amount read from a path that is not a number is zero', () => {
const result = applyEffect({ money: 10 }, { path: 'money', op: 'add', value: { from: 'owed', times: -1 } }, {});
assert.equal(result.state.money, 10);
assert.equal(result.change.value === 0 || Object.is(result.change.value, -0), true);
});
test('a literal amount records no source', () => {
const result = applyEffect(state, { path: 'money', op: 'add', value: 5 }, schema);
assert.equal('source' in result.change, false);
});
test('an empty effect list is a no-op', () => {
const result = applyEffects(state, [], schema);
assert.deepEqual(result.changes, []);
assert.equal(result.state, state);
});
+114
View File
@@ -0,0 +1,114 @@
// A small pack for the cast and checks (DECISIONS #41): a drawn single role, a
// group role of two to four, a fixed character with attributes, opposed and
// unopposed checks, and an event about "one of the co-workers". Its checks run
// from 0 to 100 so tests can force an outcome; a real pack keeps a floor and a
// ceiling so nothing is ever certain.
export const castPack = {
id: 'castpack',
name: 'Cast Pack',
version: '1.0.0',
turnUnit: 'day',
startingStage: 'desk',
attributes: {
nerve: { label: 'Nerve' },
wits: { label: 'Wits' },
charm: { label: 'Charm' },
resolve: { label: 'Resolve' },
},
checks: {
base: 50, perPoint: 10, unopposed: 5,
relationshipPerPoint: 0.5, relationshipCentre: 50,
floor: 0, ceiling: 100,
},
state: {
'money': { initial: 100, min: -500, max: 10000 },
'attributes.nerve': { initial: 5, min: 0, max: 10 },
'attributes.wits': { initial: 5, min: 0, max: 10 },
'attributes.charm': { initial: 5, min: 0, max: 10 },
'attributes.resolve': { initial: 5, min: 0, max: 10 },
},
relationshipStats: {
trust: { label: 'Trusts you', initial: 50, min: 0, max: 100 },
regard: { label: 'Thinks of you', initial: 50, min: 0, max: 100 },
},
characters: [
{
id: 'boss',
role_type: 'boss',
title: 'Manager',
candidates: [
{ name: 'Agnes Pell', description: 'Reads every memo twice.', attributes: { nerve: 6, wits: 8, charm: 3, resolve: 7 } },
{ name: 'Walter Grose', description: 'Loud, and quicker than he looks.', attributes: { nerve: 7, wits: 6, charm: 6, resolve: 4 },
startingRelationship: { trust: 30 } },
{ name: 'Mina Hart', description: 'Has never raised her voice.', attributes: { nerve: 4, wits: 7, charm: 8, resolve: 6 } },
],
},
{
id: 'coworker',
role_type: 'peer',
title: 'Clerk',
count: [2, 4],
candidates: [
{ name: 'Ben Aske', description: 'Owes {boss} a favour.', attributes: { nerve: 3, wits: 4, charm: 6, resolve: 5 } },
{ name: 'Cora Dunn', description: 'First in, last out.', attributes: { nerve: 5, wits: 5, charm: 4, resolve: 8 } },
{ name: 'Del Fane', description: 'Knows everyone\'s business.', attributes: { nerve: 4, wits: 7, charm: 5, resolve: 3 } },
{ name: 'Edie Gow', description: 'Quiet until she is not.', attributes: { nerve: 7, wits: 5, charm: 3, resolve: 6 } },
{ name: 'Fitz Hale', description: 'Charming, unreliable.', attributes: { nerve: 5, wits: 3, charm: 8, resolve: 2 } },
],
},
{ id: 'porter', name: 'Old Sam', role_type: 'staff', description: 'Has seen everyone come and go.',
attributes: { nerve: 2, wits: 9, charm: 4, resolve: 9 } },
],
events: [
{
id: 'routine',
stage: 'desk',
weight: 100,
description: 'A day at the desk under {boss}.',
options: [
{ id: 'work', label: 'Work.', effects: [{ path: 'money', op: 'add', value: 10 }] },
{
id: 'ask_for_more',
label: 'Ask {boss.first} for more money.',
check: { attribute: 'charm', against: 'boss', opposedBy: 'wits', relationship: 'trust' },
effects: [{ path: 'relationships.boss.regard', op: 'add', value: -1 }],
success: { result: '{boss} agrees.', effects: [{ path: 'money', op: 'add', value: 50 }] },
failure: { result: '{boss} laughs.', effects: [{ path: 'relationships.boss.trust', op: 'add', value: -5 }] },
},
{
id: 'hard_sum',
label: 'Try the hard sum.',
check: { attribute: 'wits', difficulty: 5 },
success: { result: 'It adds up.', effects: [{ path: 'attributes.wits', op: 'add', value: 1 }] },
failure: { result: 'It does not.', effects: [] },
},
],
},
{
id: 'needled',
stage: 'desk',
weight: 100,
involves: ['coworker'],
description: '{coworker} makes a remark about your tie.',
options: [
{ id: 'ignore', label: 'Let it go.', effects: [{ path: 'relationships.{coworker}.regard', op: 'add', value: -2 }] },
{
id: 'retort',
label: 'Answer {coworker.first} back.',
check: { attribute: 'nerve', against: '{coworker}', relationship: 'regard' },
success: { result: '{coworker} laughs, and means it.', effects: [{ path: 'relationships.{coworker}.regard', op: 'add', value: 5 }] },
failure: { result: 'It lands badly.', effects: [{ path: 'relationships.{coworker}.trust', op: 'add', value: -5 }] },
},
],
},
],
};
export default castPack;
+167
View File
@@ -0,0 +1,167 @@
// 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'); }
focus() {
if (globalThis.document) globalThis.document.activeElement = this;
}
}
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'),
activeElement: null,
};
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 };
+135
View File
@@ -0,0 +1,135 @@
// 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';
/**
* What an effect is worth to a bot. An amount read from state (DECISIONS #38)
* is scored at its cap: the most it could be, which is what a player weighing
* the option sees on the label.
*/
const amountOf = (effect) => {
if (typeof effect.value === 'number') return effect.value;
if (typeof effect.value?.cap === 'number') return effect.value.cap * (effect.value.times ?? 1);
return 0;
};
/**
* Every effect an option can have, with the weight a player reading the odds
* would give it: plain effects count in full, a check's success and failure by
* their chance (DECISIONS #41). An option with no check is just its effects.
*/
export const weightedEffects = (resolved) => {
const { option, odds } = resolved;
const plain = (option.effects ?? []).map((effect) => ({ effect, weight: 1 }));
if (!option.check) return plain;
const p = (odds?.chance ?? 50) / 100;
return [
...plain,
...(option.success?.effects ?? []).map((effect) => ({ effect, weight: p })),
...(option.failure?.effects ?? []).map((effect) => ({ effect, weight: 1 - p })),
];
};
const moneyGain = (resolved) => weightedEffects(resolved)
.filter(({ effect }) => effect.path === 'money' && effect.op === 'add')
.reduce((sum, { effect, weight }) => sum + amountOf(effect) * weight, 0);
const relationshipGain = (resolved) => weightedEffects(resolved)
.filter(({ effect }) => effect.path.startsWith('relationships.'))
.reduce((sum, { effect, weight }) => sum + amountOf(effect) * weight, 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)),
/**
* Never leaves the first rung — refuses anything that would change stage,
* and otherwise plays for people. Added after a real 182-turn session that
* stayed in the mailroom the whole way and spent 30% of it below zero: every
* other archetype either optimised money or got promoted out of the problem,
* so none of them could see it.
*/
lifer: (options) => {
const stays = options.filter(
(o) => !weightedEffects(o).some(({ effect }) => effect.path === 'stage'),
);
return best(stays.length ? stays : options, relationshipGain);
},
};
/** 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();
// What a player *could* have picked. This, not what a bot happened to pick,
// is what "is this content reachable" means — a real player chooses freely,
// and an archetype's preferences are an artefact of the harness.
const offeredOptions = 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}`);
for (const resolved of turn.options) {
if (resolved.available) offeredOptions.add(`${turn.event.id}.${resolved.option.id}`);
}
},
});
finals.push(state);
}
byStrategy[strategy] = finals;
}
return { firedEvents, takenOptions, offeredOptions, byStrategy };
}
export const median = (values) =>
[...values].sort((a, b) => a - b)[Math.floor(values.length / 2)];
+112
View File
@@ -0,0 +1,112 @@
// A deliberately small pack that exercises every engine feature: gates,
// once-only events, cooldowns, weighted draws, a conditional branch and a
// clamped stat. Engine tests run against this, never against real content, so
// tuning the game cannot break the test suite.
export const testPack = {
id: 'testpack',
name: 'Test Pack',
version: '1.0.0',
turnUnit: 'day',
startingStage: 'floor',
state: {
'money': { initial: 100, min: -500, max: 10000 },
'skills.talking': { initial: 1, min: 0, max: 10 },
'flags.in_debt': { initial: false },
'notable': { initial: [] },
},
relationshipStats: {
likes_you: { initial: 50, min: 0, max: 100 },
fears_you: { initial: 50, min: 0, max: 100 },
},
characters: [
{ id: 'chief', name: 'The Chief', role_type: 'boss', description: 'Tall, tired.' },
{ id: 'pat', name: 'Pat', role_type: 'rival', description: 'Smiles too much.' },
],
events: [
{
id: 'routine',
stage: 'floor',
weight: 100,
description: 'Another ordinary shift.',
options: [
{
id: 'work',
label: 'Put your head down and work.',
effects: [
{ path: 'money', op: 'add', value: 10 },
{ path: 'relationships.chief.likes_you', op: 'add', value: 2 },
],
},
{
id: 'charm',
label: 'Talk your way into an easier job.',
requires: [{ path: 'skills.talking', op: '>=', value: 3 }],
effects: [{ path: 'skills.talking', op: 'add', value: 1 }],
},
{
id: 'practice',
label: 'Chat with Pat all afternoon.',
effects: [
{ path: 'skills.talking', op: 'add', value: 1 },
{ path: 'money', op: 'add', value: -5 },
{ path: 'relationships.pat.likes_you', op: 'add', value: 3 },
],
},
],
},
{
id: 'first_day',
stage: 'floor',
weight: 1000,
once: true,
description: 'Your first day.',
options: [
{
id: 'arrive',
label: 'Arrive on time.',
effects: [{ path: 'notable', op: 'push', value: 'Showed up on time, once.' }],
},
],
},
{
id: 'hard_times',
stage: 'floor',
weight: 1000,
requires: [{ path: 'money', op: '<', value: 0 }],
description: 'Rent is due and you do not have it.',
options: [
{
id: 'loan',
label: 'Take the loan.',
effects: [
{ path: 'money', op: 'add', value: 200 },
{ path: 'flags.in_debt', op: 'set', value: true },
],
},
{
id: 'beg',
label: 'Ask Pat for help.',
effects: [
{ path: 'money', op: 'add', value: 50 },
{ path: 'relationships.pat.fears_you', op: 'add', value: -10 },
],
},
],
},
{
id: 'audit',
stage: 'floor',
weight: 5,
cooldown: 10,
description: 'An auditor is wandering the floor.',
options: [{ id: 'nod', label: 'Nod politely.', effects: [] }],
},
],
};
export default testPack;
+299
View File
@@ -0,0 +1,299 @@
// The Frontier's own tuning.
//
// What is true of this pack because it is *this* pack: a four-day run instead
// of a week, a settlement that barely covers the room, a store ledger rather
// than a loan, and when the star is offered. Everything true of a pack because
// it is a pack is in conformance.test.js, which runs over every pack in the
// registry — including this one.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { frontier as pack } from '../content/frontier/index.js';
import { startGame, currentTurn, takeTurn } from '../src/engine/game.js';
import { check } from '../src/engine/conditions.js';
import { applyEffects } from '../src/engine/effects.js';
import { resolveSchema } from '../src/engine/state.js';
import { play, sweep, median } from './fixtures/strategies.js';
/**
* Net money from upkeep per run for a player in a given position: at a stage,
* at a lifestyle tier, owing the store nothing.
*
* Derived rather than simulated, and for a reason worth keeping: comparing the
* end-balances of promoted and unpromoted players pools archetypes that do not
* spend alike, so it measures the mix rather than the wage.
*/
function netPerCycle(position) {
const state = { owed: 0, lifestyle: 0, standing: 0, ...position };
const cycle = pack.calendar?.cycleLength ?? 1;
return (pack.upkeep ?? [])
.filter((entry) => check(state, entry.requires).passed)
.reduce((total, entry) => total + entry.effects
.filter((e) => e.path === 'money' && e.op === 'add')
.reduce((sum, e) => sum + e.value, 0) * (cycle / (entry.every ?? 1)), 0);
}
test('the calendar is a four-day run, not a week', () => {
// The point of this pack, structurally: the engine derives a cycle from
// whatever length the pack declares, and nothing anywhere assumes seven.
let state = startGame(pack, { seed: 'calendar' });
assert.deepEqual(
[state.calendar.name, state.calendar.cycle, state.calendar.index],
['Stage day', 1, 0],
);
const names = [];
for (let t = 0; t < 7; t++) {
names.push(state.calendar.name);
const turn = currentTurn(pack, state);
state = takeTurn(pack, state, turn.options.find((o) => o.available).option.id).state;
}
assert.deepEqual(names,
['Stage day', 'Haul', 'Yard', 'Rest', 'Stage day', 'Haul', 'Yard']);
assert.equal(state.calendar.cycle, 2, 'the second run has begun');
});
test('the settlement and the room land on the days the pack says', () => {
const settlement = [];
const room = [];
let state = startGame(pack, { seed: 'rhythm' });
for (let t = 0; t < 16; 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 from Vane') settlement.push(state.turn);
if (change.label === 'Room at the Cordwood') room.push(state.turn);
}
state = result.state;
}
// The boarding house collects on the Rest day, the night before the outfit
// settles up on the Stage day. That ordering is the whole reason a hand is
// never quite ahead.
assert.deepEqual(room, [4, 8, 12, 16], 'the room is collected every Rest day');
assert.deepEqual(settlement, [5, 9, 13], 'the outfit settles up every Stage day');
});
test('the star is worth taking, and reachable by a deliberate player', () => {
const { byStrategy } = sweep(pack, { seeds: 12, turns: 200 });
const sworn = Object.values(byStrategy).flat().filter((s) => s.stage === 'deputy');
const stayed = Object.values(byStrategy).flat().filter((s) => s.stage === 'hand');
assert.ok(sworn.length > 0 && stayed.length > 0, 'both outcomes should occur');
const yard = netPerCycle({ stage: 'hand' });
const office = netPerCycle({ stage: 'deputy' });
assert.ok(office > yard + 10,
`the office nets ${office} a run against the yard's ${yard}`);
// The yard is a career, not a tutorial: the offer is gated at turn 40 and
// should land somewhere in the middle of a run rather than at the very end.
const timings = [];
for (let seed = 0; seed < 12; seed++) {
let at = null;
play(pack, { strategy: 'sociable', seed, turns: 200,
visit: ({ state }) => { if (state.stage === 'deputy' && at === null) at = state.turn; } });
if (at) timings.push(at);
}
assert.equal(timings.length, 12, 'a sociable player should always get the offer');
const typical = median(timings);
assert.ok(typical >= 41 && typical < 90, `the star landed at turn ${typical}`);
});
/** Play to `turns` playing for standing, buying what the star expects or not. */
function chaseStanding({ seed, keepUp, turns = 200 }) {
const buys = (r) => (r.option.effects ?? []).some((e) => e.path === 'lifestyle');
const gain = (r) => (r.option.effects ?? [])
.filter((e) => e.path === 'standing' && e.op === 'add')
.reduce((sum, e) => sum + e.value, 0);
let state = startGame(pack, { seed: `standing-${seed}` });
for (let t = 0; t < turns; t++) {
const turn = currentTurn(pack, state);
const available = turn.options.filter((o) => o.available);
const buy = available.find(buys);
const chosen = (keepUp && buy)
? buy
: [...available.filter((o) => keepUp || !buys(o))].sort((a, b) => gain(b) - gain(a))[0]
?? available[0];
state = takeTurn(pack, state, chosen.option.id).state;
}
return state;
}
test('the star costs money to wear, and the top of standing cannot be worked for', () => {
// The same mechanism as Corporate Ladder's, at this pack's scale and in its
// own vocabulary — which is the whole claim the second setting exists to
// make. No engine code is involved in either.
const plain = netPerCycle({ stage: 'deputy' });
const horse = netPerCycle({ stage: 'deputy', lifestyle: 1 });
const room = netPerCycle({ stage: 'deputy', lifestyle: 2 });
assert.ok(horse < plain - 5, `the bay costs ${plain - horse} a run`);
assert.ok(room < horse - 5, `the room costs ${horse - room} a run`);
assert.ok(room > 0,
`a deputy keeping up nets ${room} a run; the yard's own floor is barely above water`);
// The top of the ladder is not a plateau here either: standing costs money
// to carry at every tier, and nothing you can buy switches it off.
// Flat here rather than stepped on money, as Corporate Ladder's is: this
// pack's late game does not accumulate in the first place — measured, a
// deputy who owns everything drifts 0 a run — so the charge exists to keep
// money live, and break-even at the top is the intent rather than a shortfall.
const somebody = netPerCycle({ stage: 'deputy', lifestyle: 2, standing: 90 });
assert.ok(somebody < room, `standing itself should cost: ${somebody} against ${room} a run`);
assert.ok(somebody <= 0,
`and leave a deputy who has everything no better than level: ${somebody} a run`);
const never = [];
const keeps = [];
for (let seed = 0; seed < 8; seed++) {
never.push(chaseStanding({ seed, keepUp: false }));
keeps.push(chaseStanding({ seed, keepUp: true }));
}
const standing = (runs) => median(runs.map((s) => s.standing));
assert.ok(standing(never) <= 90, `a deputy on a livery horse reached ${standing(never)} standing`);
assert.ok(standing(keeps) >= 95, `a deputy keeping up reached only ${standing(keeps)} standing`);
assert.ok(median(keeps.map((s) => s.money)) < median(never.map((s) => s.money)),
'and it is paid for in money');
});
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 === 'store_cut_off') days++; } });
return days;
};
assert.equal(countHardTimes('thrifty'), 0, 'minding the money should keep you out of the book');
assert.ok(countHardTimes('spendthrift') > 0, 'ignoring it should land you in it');
});
test('the store ledger cannot spiral without limit', () => {
let peak = 0;
for (const strategy of ['spendthrift', 'desperate', 'random']) {
for (let seed = 0; seed < 12; seed++) {
play(pack, { strategy, seed, turns: 200,
visit: ({ state }) => { peak = Math.max(peak, state.owed); } });
}
}
// He stops carrying you at 120 and the largest single line is 32, so 152
// plus a few stalled charges is the ceiling. Well above that means the gate
// has stopped working.
assert.ok(peak <= 220, `the ledger reached ${peak}; the credit ceiling is not holding`);
const charges = pack.upkeep.filter((u) => u.label?.startsWith('Store credit'));
assert.ok(charges.length >= 3, 'the charge should step up as the ledger grows');
});
test('a skill gate is never gated behind itself', () => {
// The lesson Corporate Ladder's logs taught, applied here as a rule rather
// than as a hope. Four of its options need negotiation 15 and several of its
// largest negotiation gains sit behind that same gate, so a player who is
// not deliberately chasing the skill bounces off the wall for thirty turns.
//
// The structural property that prevents it: for every skill threshold in the
// pack, the growth available *without* passing that threshold must be able
// to reach it, and repeatably — a single one-off award is not a route.
const gates = new Map();
for (const event of pack.events) {
for (const option of event.options ?? []) {
for (const requirement of option.requires ?? []) {
if (!requirement.path.startsWith('skills.')) continue;
const highest = gates.get(requirement.path) ?? 0;
gates.set(requirement.path, Math.max(highest, requirement.value));
}
}
}
assert.ok(gates.size > 0, 'the pack should gate something on a skill');
for (const [path, highest] of gates) {
const start = pack.state[path].initial;
let repeatable = 0;
for (const event of pack.events) {
if (event.once) continue;
for (const option of event.options ?? []) {
// Ungated on this skill: a route a player has open from turn one.
if ((option.requires ?? []).some((r) => r.path === path)) continue;
const gain = (option.effects ?? [])
.filter((e) => e.path === path && e.op === 'add')
.reduce((sum, e) => sum + e.value, 0);
if (gain > 0) repeatable += gain;
}
}
assert.ok(start + repeatable > highest,
`${path} tops out at ${highest}, but only ${repeatable} is reachable without it`);
}
});
test('a player who wants a skill does not wait long for the first door', () => {
// Measured rather than assumed: a player steering for a skill should be
// through its lowest gate inside the first two runs of the stage.
const firstGate = 12;
for (const path of ['skills.nerve', 'skills.trade']) {
const gain = (o) => (o.option.effects ?? [])
.filter((e) => e.path === path && e.op === 'add')
.reduce((sum, e) => sum + e.value, 0);
const times = [];
for (let seed = 0; seed < 12; seed++) {
let state = startGame(pack, { seed: `gate-${seed}` });
let at = null;
for (let t = 0; t < 120 && at === null; t++) {
const turn = currentTurn(pack, state);
const available = turn.options.filter((o) => o.available);
const best = [...available].sort((a, b) => gain(b) - gain(a))[0];
state = takeTurn(pack, state, best.option.id).state;
const value = path.split('.').reduce((o, k) => o?.[k], state);
if (value >= firstGate) at = state.turn;
}
times.push(at ?? Infinity);
}
const typical = median(times);
assert.ok(typical <= 20,
`${path} reached ${firstGate} at turn ${typical}; the first door is too far off`);
}
});
test('the horse and the room are each offered only while they can still be bought', () => {
// A real session was shown "Buy the bay" locked after buying it, and offered
// the room under a description about the horse. See DECISIONS #40.
const offered = (lifestyle) => pack.events
.filter((e) => ['the_sale_barn', 'the_ellis_room'].includes(e.id))
.filter((e) => check({ stage: 'deputy', lifestyle }, e.requires).passed)
.map((e) => e.id);
assert.deepEqual(offered(0), ['the_sale_barn']);
assert.deepEqual(offered(1), ['the_ellis_room']);
assert.deepEqual(offered(2), []);
});
test('paying the book down never takes more than is owed', () => {
// Turn 27 of that session: four dollars owed, twenty taken. See DECISIONS #38.
const schema = resolveSchema(pack);
const payDown = pack.events.find((e) => e.id === 'the_store')
.options.find((o) => o.id === 'pay_down');
const base = startGame(pack, { seed: 'the-book' });
const small = applyEffects({ ...base, money: 110, owed: 4 }, payDown.effects, schema).state;
assert.equal(small.money, 106, 'four owed costs four');
assert.equal(small.owed, 0);
const large = applyEffects({ ...base, money: 110, owed: 50 }, payDown.effects, schema).state;
assert.equal(large.money, 90, 'more than twenty owed costs twenty');
assert.equal(large.owed, 30);
});
test('a deliberate player can put Dutch in their debt', () => {
// The deepest relationship gate in the pack: `send_for_dutch` in the
// marshal's office needs a favour banked two rungs earlier.
const owed = (o) => (o.option.effects ?? [])
.filter((e) => e.path === 'relationships.dutch.owes_you')
.reduce((sum, e) => sum + e.value, 0);
let state = startGame(pack, { seed: 'dutch' });
let reached = null;
for (let t = 0; t < 60 && reached === null; t++) {
if (state.relationships.dutch.owes_you >= 25) { reached = state.turn; break; }
const turn = currentTurn(pack, state);
const best = turn.options.filter((o) => o.available).sort((a, b) => owed(b) - owed(a))[0];
state = takeTurn(pack, state, best.option.id).state;
}
assert.ok(reached && reached < 45, `Dutch's book opened at turn ${reached ?? 'never'}`);
});
+283
View File
@@ -0,0 +1,283 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { startGame, currentTurn, takeTurn, MoveError } from '../src/engine/game.js';
import { getPath, setPath } from '../src/engine/paths.js';
import { testPack } from './fixtures/test-pack.js';
/** Play `turns` turns, always taking the first available option. */
function playThrough(seed, turns, chooser = (options) => options.find((o) => o.available).option.id) {
let state = startGame(testPack, { seed });
const records = [];
for (let i = 0; i < turns; i++) {
const turn = currentTurn(testPack, state);
const result = takeTurn(testPack, state, chooser(turn.options, turn.event, state));
state = result.state;
records.push(result.record);
}
return { state, records };
}
test('a new game starts on turn one with the pack defaults', () => {
const state = startGame(testPack, { seed: 'start' });
assert.equal(state.turn, 1);
assert.equal(state.stage, 'floor');
assert.equal(state.money, 100);
assert.equal(state.skills.talking, 1);
assert.equal(state.relationships.chief.likes_you, 50);
assert.equal(state.relationships.pat.fears_you, 50);
assert.ok(state.meta.currentEventId, 'an event is waiting before the first turn');
});
test('relationship state is generated per character, so adding one adds its stats', () => {
const state = startGame(testPack, { seed: 'chars' });
assert.deepEqual(Object.keys(state.relationships).sort(), ['chief', 'pat']);
assert.deepEqual(Object.keys(state.relationships.chief).sort(), ['fears_you', 'likes_you']);
});
test('taking a turn applies effects and advances the clock', () => {
let state = startGame(testPack, { seed: 'turn' });
const before = state.money;
const result = takeTurn(testPack, state, currentTurn(testPack, state).options[0].option.id);
assert.equal(result.state.turn, 2);
assert.notEqual(result.state.meta.currentEventId, undefined);
assert.ok(result.state.money !== before || result.record.changes.length >= 0);
});
test('the same seed and the same choices replay identically', () => {
const a = playThrough('replay', 40);
const b = playThrough('replay', 40);
assert.deepEqual(a.state, b.state);
assert.deepEqual(a.records, b.records);
});
test('different seeds produce different runs', () => {
const a = playThrough('seed-a', 40);
const b = playThrough('seed-b', 40);
assert.notDeepEqual(a.records.map((r) => r.eventId), b.records.map((r) => r.eventId));
});
test('a saved game resumes into exactly the run it left', () => {
const uninterrupted = playThrough('save', 30);
let state = startGame(testPack, { seed: 'save' });
for (let i = 0; i < 15; i++) {
const turn = currentTurn(testPack, state);
state = takeTurn(testPack, state, turn.options.find((o) => o.available).option.id).state;
}
// The whole save is one JSON round trip; nothing lives outside it.
state = JSON.parse(JSON.stringify(state));
for (let i = 0; i < 15; i++) {
const turn = currentTurn(testPack, state);
state = takeTurn(testPack, state, turn.options.find((o) => o.available).option.id).state;
}
assert.deepEqual(state, uninterrupted.state);
});
test('a once-only event fires exactly once across a long run', () => {
const { records } = playThrough('once', 60);
assert.equal(records.filter((r) => r.eventId === 'first_day').length, 1);
assert.equal(records[0].eventId, 'first_day', 'its weight makes it the opener');
});
test('a locked option cannot be played', () => {
const state = startGame(testPack, { seed: 'locked' });
let probe = state;
// Walk to a turn that offers the gated option.
for (let i = 0; i < 10; i++) {
const turn = currentTurn(testPack, probe);
const charm = turn.options.find((o) => o.option.id === 'charm');
if (charm) {
assert.equal(charm.available, false);
assert.throws(() => takeTurn(testPack, probe, 'charm'), MoveError);
return;
}
probe = takeTurn(testPack, probe, turn.options.find((o) => o.available).option.id).state;
}
assert.fail('the gated option never came up');
});
test('an unknown option id is rejected', () => {
const state = startGame(testPack, { seed: 'bogus' });
assert.throws(() => takeTurn(testPack, state, 'no_such_option'), /has no option/);
});
test('a skill grows from choices and eventually unlocks its gate', () => {
const talkative = (options) => {
const practice = options.find((o) => o.option.id === 'practice' && o.available);
return (practice ?? options.find((o) => o.available)).option.id;
};
const { state } = playThrough('skill', 20, talkative);
assert.ok(state.skills.talking >= 3, `expected talking to grow, got ${state.skills.talking}`);
});
test('money going negative brings the hard-times branch into the pool', () => {
const spendy = (options) => {
const practice = options.find((o) => o.option.id === 'practice' && o.available);
return (practice ?? options.find((o) => o.available)).option.id;
};
let state = startGame(testPack, { seed: 'broke' });
let sawHardTimes = false;
for (let i = 0; i < 120; i++) {
const turn = currentTurn(testPack, state);
if (turn.event.id === 'hard_times') { sawHardTimes = true; break; }
state = takeTurn(testPack, state, spendy(turn.options, turn.event, state)).state;
}
assert.ok(state.money < 0 || sawHardTimes, 'the run should have gone broke');
assert.ok(sawHardTimes, 'hard times should surface once money is negative — the game never ends');
});
test('the turn record carries the diff and the options the player could not take', () => {
let state = startGame(testPack, { seed: 'record' });
state = takeTurn(testPack, state, currentTurn(testPack, state).options[0].option.id).state;
const turn = currentTurn(testPack, state);
const { record } = takeTurn(testPack, state, 'work');
assert.equal(record.eventId, turn.event.id);
assert.equal(record.optionId, 'work');
assert.deepEqual(record.changes.map((c) => c.path), ['money', 'relationships.chief.likes_you']);
assert.equal(record.changes[0].from + 10, record.changes[0].to);
const locked = record.offered.find((o) => o.id === 'charm');
assert.equal(locked.available, false);
assert.equal(locked.failed[0].path, 'skills.talking');
assert.equal(locked.hidden, false, 'the record says whether a locked option was on screen');
});
test('history accumulates every turn and is what the retrospective reads', () => {
const { state, records } = playThrough('history', 12);
assert.equal(state.history.length, 12);
assert.deepEqual(state.history, records);
});
test('a stat cannot be pushed past its declared bound', () => {
const { state } = playThrough('clamp', 80, (options) => {
const practice = options.find((o) => o.option.id === 'practice' && o.available);
return (practice ?? options.find((o) => o.available)).option.id;
});
assert.ok(state.skills.talking <= 10, 'talking is capped at 10');
assert.ok(state.relationships.pat.likes_you <= 100, 'relationship stats are capped at 100');
});
test('the run never ends on its own', () => {
const { state } = playThrough('endless', 200);
assert.equal(state.turn, 201);
assert.ok(currentTurn(testPack, state).options.some((o) => o.available), 'there is always something to do');
});
test('upkeep runs every turn and is recorded separately from the choice', () => {
const upkeepPack = structuredClone(testPack);
upkeepPack.upkeep = [
{ label: 'wage', effects: [{ path: 'money', op: 'add', value: 20 }] },
{
label: 'debt service',
requires: [{ path: 'flags.in_debt', op: '==', value: true }],
effects: [{ path: 'money', op: 'add', value: -5 }],
},
];
let state = startGame(upkeepPack, { seed: 'upkeep' });
const before = state.money;
const first = takeTurn(upkeepPack, state, currentTurn(upkeepPack, state).options[0].option.id);
assert.equal(first.record.upkeep.length, 1, 'the conditional entry is skipped while it does not apply');
assert.equal(first.record.upkeep[0].label, 'wage');
const fromChoice = first.record.changes
.filter((c) => c.path === 'money')
.reduce((sum, c) => sum + (c.to - c.from), 0);
assert.equal(first.state.money, before + fromChoice + 20);
// Once the flag is set, the conditional entry starts applying.
const nowIndebted = setPath(first.state, 'flags.in_debt', true);
const nextOption = currentTurn(upkeepPack, nowIndebted).options.find((o) => o.available).option.id;
const indebted = takeTurn(upkeepPack, nowIndebted, nextOption);
assert.deepEqual(indebted.record.upkeep.map((c) => c.label), ['wage', 'debt service']);
});
test('a character can start somewhere other than the pack default', () => {
const pack = structuredClone(testPack);
pack.characters.find((c) => c.id === 'pat').startingRelationship = { likes_you: 20 };
const state = startGame(pack, { seed: 'starts' });
assert.equal(state.relationships.pat.likes_you, 20, 'the override applies');
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');
});
test('moving stage changes which events are drawn', () => {
const twoStage = structuredClone(testPack);
twoStage.events.push({
id: 'upstairs_only',
stage: 'attic',
weight: 1,
description: 'It is quieter up here.',
options: [{ id: 'wait', label: 'Wait.', effects: [] }],
});
twoStage.events.find((e) => e.id === 'routine').options.push({
id: 'climb',
label: 'Go up to the attic.',
effects: [{ path: 'stage', op: 'set', value: 'attic' }],
});
let state = startGame(twoStage, { seed: 'stages' });
// Walk to a turn offering the climb, then take it.
for (let i = 0; i < 20; i++) {
const turn = currentTurn(twoStage, state);
if (turn.event.id === 'routine') {
state = takeTurn(twoStage, state, 'climb').state;
break;
}
state = takeTurn(twoStage, state, turn.options.find((o) => o.available).option.id).state;
}
assert.equal(state.stage, 'attic');
assert.equal(currentTurn(twoStage, state).event.id, 'upstairs_only',
'only the new stage\'s events are reachable now');
});
+47
View File
@@ -0,0 +1,47 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { getPath, setPath, hasPath, parsePath, listPaths } from '../src/engine/paths.js';
const state = { money: 100, skills: { talking: 2 }, relationships: { chief: { likes_you: 50 } } };
test('reads nested paths', () => {
assert.equal(getPath(state, 'money'), 100);
assert.equal(getPath(state, 'skills.talking'), 2);
assert.equal(getPath(state, 'relationships.chief.likes_you'), 50);
});
test('missing paths read as undefined, not as a throw', () => {
assert.equal(getPath(state, 'skills.welding'), undefined);
assert.equal(getPath(state, 'nothing.at.all'), undefined);
assert.equal(hasPath(state, 'skills.welding'), false);
assert.equal(hasPath(state, 'skills.talking'), true);
});
test('writes without mutating the original', () => {
const next = setPath(state, 'skills.talking', 5);
assert.equal(next.skills.talking, 5);
assert.equal(state.skills.talking, 2, 'original state must be untouched');
assert.equal(next.relationships, state.relationships, 'untouched branches are shared');
});
test('creates missing intermediate objects', () => {
const next = setPath({}, 'flags.deep.nested', true);
assert.equal(next.flags.deep.nested, true);
});
test('rejects prototype-polluting paths', () => {
assert.throws(() => parsePath('__proto__.polluted'), /forbidden/);
assert.throws(() => parsePath('constructor'), /forbidden/);
assert.throws(() => setPath({}, 'a.prototype.b', 1), /forbidden/);
});
test('rejects malformed paths', () => {
assert.throws(() => parsePath(''), /non-empty/);
assert.throws(() => parsePath('money.'), /invalid path segment/);
assert.throws(() => parsePath('a b'), /invalid path segment/);
assert.throws(() => parsePath(42), /non-empty string/);
});
test('lists leaf paths', () => {
assert.deepEqual(listPaths(state), ['money', 'skills.talking', 'relationships.chief.likes_you']);
});
+59
View File
@@ -0,0 +1,59 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createRng } from '../src/engine/rng.js';
test('the same seed produces the same sequence', () => {
const a = createRng('seed-one');
const b = createRng('seed-one');
const draws = Array.from({ length: 20 }, () => [a.next(), b.next()]);
for (const [x, y] of draws) assert.equal(x, y);
});
test('different seeds diverge', () => {
const a = Array.from({ length: 10 }, (_, i) => createRng('one').next());
const b = Array.from({ length: 10 }, (_, i) => createRng('two').next());
assert.notDeepEqual(a, b);
});
test('a generator resumes exactly from a saved position', () => {
const original = createRng('resume-me');
for (let i = 0; i < 7; i++) original.next();
const position = original.getPosition();
const resumed = createRng('resume-me', position);
const expected = Array.from({ length: 5 }, () => original.next());
const actual = Array.from({ length: 5 }, () => resumed.next());
assert.deepEqual(actual, expected);
});
test('a saved position survives a JSON round trip', () => {
const rng = createRng('json');
rng.next();
const revived = createRng('json', JSON.parse(JSON.stringify(rng.getPosition())));
assert.equal(revived.next(), createRng('json', rng.getPosition()).next());
});
test('draws stay in range', () => {
const rng = createRng('range');
for (let i = 0; i < 500; i++) {
const value = rng.next();
assert.ok(value >= 0 && value < 1);
assert.ok(rng.int(10) >= 0 && rng.int(10) < 10);
}
});
test('weighted picks respect weight and skip zero-weight entries', () => {
const rng = createRng('weighted');
const items = [{ id: 'common', w: 99 }, { id: 'rare', w: 1 }, { id: 'never', w: 0 }];
const counts = { common: 0, rare: 0, never: 0 };
for (let i = 0; i < 2000; i++) counts[rng.weighted(items, (i2) => i2.w).id]++;
assert.equal(counts.never, 0, 'zero weight must never be drawn');
assert.ok(counts.common > counts.rare * 10, `expected common to dominate, got ${JSON.stringify(counts)}`);
assert.ok(counts.rare > 0, 'a weight of 1 in 100 should still appear over 2000 draws');
});
test('weighted returns undefined when nothing has weight', () => {
const rng = createRng('empty');
assert.equal(rng.weighted([{ w: 0 }], (i) => i.w), undefined);
assert.equal(rng.weighted([], () => 1), undefined);
});
+80
View File
@@ -0,0 +1,80 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { eligibleEvents, selectEvent, resolveOptions } from '../src/engine/select.js';
import { createState, resolveSchema } from '../src/engine/state.js';
import { createRng } from '../src/engine/rng.js';
import { setPath } from '../src/engine/paths.js';
import { testPack } from './fixtures/test-pack.js';
const schema = resolveSchema(testPack);
const fresh = () => createState(testPack, { seed: 'select', schema });
test('a conditional event is out of the pool until its condition holds', () => {
const rich = fresh();
assert.ok(!eligibleEvents(testPack, rich).eligible.some((e) => e.id === 'hard_times'));
const broke = setPath(rich, 'money', -20);
assert.ok(eligibleEvents(testPack, broke).eligible.some((e) => e.id === 'hard_times'));
});
test('exclusions say why', () => {
const { excluded } = eligibleEvents(testPack, fresh());
const hardTimes = excluded.find((x) => x.event.id === 'hard_times');
assert.equal(hardTimes.reason, 'requires');
assert.equal(hardTimes.failed[0].path, 'money');
});
test('a once-only event leaves the pool after it fires', () => {
const state = setPath(fresh(), 'seen.first_day', 1);
const { eligible, excluded } = eligibleEvents(testPack, state);
assert.ok(!eligible.some((e) => e.id === 'first_day'));
assert.equal(excluded.find((x) => x.event.id === 'first_day').reason, 'once');
});
test('a cooldown holds an event back for the declared number of turns', () => {
let state = fresh();
state = setPath(state, 'lastSeen.audit', 5);
state = setPath(state, 'turn', 9);
assert.equal(eligibleEvents(testPack, state).excluded.find((x) => x.event.id === 'audit')?.reason, 'cooldown');
state = setPath(state, 'turn', 15);
assert.ok(eligibleEvents(testPack, state).eligible.some((e) => e.id === 'audit'));
});
test('events belonging to another stage are never drawn', () => {
const elsewhere = setPath(fresh(), 'stage', 'penthouse');
const { eligible, excluded } = eligibleEvents(testPack, elsewhere);
assert.equal(eligible.length, 0);
assert.ok(excluded.every((x) => x.reason === 'stage'));
});
test('selection is a weighted draw, so the heavy event dominates', () => {
const rng = createRng('draw');
const state = setPath(fresh(), 'seen.first_day', 1); // take the opener out of the way
const counts = {};
for (let i = 0; i < 1000; i++) {
const event = selectEvent(testPack, state, rng);
counts[event.id] = (counts[event.id] ?? 0) + 1;
}
assert.ok(counts.routine > 900, `routine (weight 100) should dominate audit (weight 5): ${JSON.stringify(counts)}`);
assert.ok(counts.audit > 0, 'a low weight is still reachable');
});
test('an option below its skill gate resolves as locked, with the reason', () => {
const event = testPack.events.find((e) => e.id === 'routine');
const resolved = resolveOptions(fresh(), event);
const charm = resolved.find((r) => r.option.id === 'charm');
assert.equal(charm.available, false);
assert.equal(charm.failed[0].path, 'skills.talking');
assert.equal(charm.hidden, false, 'locked options are shown greyed out unless the content says otherwise');
assert.equal(resolved.find((r) => r.option.id === 'work').available, true);
});
test('an option unlocks once the gate is met', () => {
const skilled = setPath(fresh(), 'skills.talking', 5);
const event = testPack.events.find((e) => e.id === 'routine');
assert.equal(resolveOptions(skilled, event).find((r) => r.option.id === 'charm').available, true);
});
+578
View File
@@ -0,0 +1,578 @@
// 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.
*
* `pack` is written to the remembered-pack slot before booting, which is how a
* returning player lands back in a setting. Pass null to boot with nothing
* remembered and nothing saved, which is the first-time player who gets the
* setting picker instead of a turn.
*/
async function boot({ pack = 'corporateladder' } = {}) {
const dom = installFakeDom();
if (pack) dom.storage.setItem('theladder.pack.v1', pack);
// 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);
const wrapUp = (root) => byTag(root, 'button').find((b) => /Wrap up/.test(b.textContent));
test('tabbing past the last option does not land on the button that ends the run', async () => {
// Document order is tab order. "Wrap up my career" sat immediately after the
// last option, so one Tab too many and Enter showed the retrospective — which
// a real player, on the keyboard, read as the game ending at day 31.
const dom = await boot();
try {
const buttons = byTag(dom.root, 'button');
const lastOption = buttons.map((b) => /option/.test(b.className ?? '')).lastIndexOf(true);
const next = buttons[lastOption + 1];
assert.ok(next, 'there is something after the options');
assert.doesNotMatch(next.textContent, /Wrap up/,
'the next thing Tab reaches should not be the one that leaves the turn');
assert.equal(wrapUp(dom.root), buttons[buttons.length - 1],
'it belongs last, behind everything harmless');
} finally {
dom.restore();
}
});
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.corporateladder'));
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.corporateladder'));
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();
}
wrapUp(dom.root).click();
assert.ok(firstByClass(dom.root, 'screen--retrospective'));
assert.match(text(dom.root), /not an ending/, 'it says so, rather than implying it');
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 {
wrapUp(dom.root).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();
}
});
test('the status bar says what you are playing, which pack, and who you are', async () => {
const dom = await boot();
try {
const status = text(firstByClass(dom.root, 'statusbar'));
assert.match(status, /The Ladder/, 'the game');
assert.match(status, /Corporate Ladder/, 'the pack');
assert.match(status, /Mail Associate I/, 'the current title');
assert.match(status, /\d+\.\d+\.\d+ · /, 'the version and build');
} finally {
dom.restore();
}
});
test('the status bar title follows the player up a tier', async () => {
const { statusBar } = await import('../src/ui/components.js');
const { corporateLadder } = await import('../content/corporateladder/index.js');
const dom = installFakeDom();
try {
const promoted = statusBar(corporateLadder, { stage: 'dispatch' });
assert.match(promoted.textContent, /Dispatch Coordinator/);
} finally {
dom.restore();
}
});
test('the keyboard alone can play a turn: focus lands where Enter should go', async () => {
const dom = await boot();
try {
// Choosing: the first option a player can actually take.
const firstPlayable = enabledOptions(dom.root)[0];
assert.equal(dom.document.activeElement, firstPlayable,
'the first available option is focused, so Enter takes it');
dom.document.activeElement.click();
// Outcome: the button that moves the run on.
const next = firstByClass(dom.root, 'button--next');
assert.equal(dom.document.activeElement, next,
'the next-turn button is focused, so Enter advances');
dom.document.activeElement.click();
assert.ok(firstByClass(dom.root, 'scenario'), 'and we are choosing again');
assert.equal(dom.document.activeElement, enabledOptions(dom.root)[0]);
} finally {
dom.restore();
}
});
test('locked options are not in the tab order', async () => {
const dom = await boot();
try {
// Disabled buttons are skipped by the browser's own tab handling; the
// guarantee this test protects is that locked options stay disabled.
for (let i = 0; i < 40; i++) {
const locked = optionButtons(dom.root).filter((b) => b.disabled);
if (locked.length) {
assert.ok(locked.every((b) => b.disabled === true));
return;
}
enabledOptions(dom.root)[0].click();
firstByClass(dom.root, 'button--next').click();
}
assert.fail('no locked option appeared in 40 turns');
} finally {
dom.restore();
}
});
test('the ledger reads as English, and leaves bookkeeping out of it', async () => {
const { changeList } = await import('../src/ui/components.js');
const { corporateLadder: pack } = await import('../content/corporateladder/index.js');
const dom = installFakeDom();
try {
const rendered = changeList(pack, [
{ path: 'money', op: 'add', value: -20, from: 520, to: 500, clamped: false },
{ path: 'flags.holds_the_envelope', op: 'set', value: true, from: false, to: true, clamped: false },
{ path: 'notable', op: 'push', value: 'Kept an envelope.', from: [], to: ['Kept an envelope.'], clamped: false },
]);
const body = rendered.textContent;
assert.match(body, /Money/);
assert.match(body, /−\$20/);
assert.match(body, /The envelope is in your locker\./, 'a flag reads as a sentence');
assert.doesNotMatch(body, /now true/, 'not as a boolean');
assert.doesNotMatch(body, /Notable/, 'the retrospective\'s own list stays out of the ledger');
assert.doesNotMatch(body, /Kept an envelope\./);
} finally {
dom.restore();
}
});
test('a first-time player is asked which setting to play', async () => {
// Nothing saved and nothing remembered: the front door is the picker rather
// than whichever pack happens to be first in the registry.
const dom = await boot({ pack: null });
try {
assert.ok(firstByClass(dom.root, 'screen--packs'), 'the picker rendered');
assert.match(text(dom.root), /Corporate Ladder/);
assert.match(text(dom.root), /The Frontier/);
assert.match(text(dom.root), /Not started/, 'a pack with no run says so');
const start = byClass(dom.root, 'button').find((b) => /Start here/.test(b.textContent));
assert.ok(start, 'the picker offers a way in');
start.click();
assert.ok(firstByClass(dom.root, 'screen--turn'), 'choosing a setting starts it');
} finally {
dom.restore();
}
});
test('switching settings keeps both runs', async () => {
// The defect this guards against: one global save slot, so the first switch
// silently threw the other career away.
const dom = await boot({ pack: 'corporateladder' });
try {
enabledOptions(dom.root)[0].click();
byClass(dom.root, 'button').find((b) => /Next/.test(b.textContent))?.click();
assert.match(text(dom.root), /Day 2/, 'a turn was played in the first setting');
byClass(dom.root, 'button').find((b) => /Change setting/.test(b.textContent)).click();
const toFrontier = byClass(dom.root, 'button')
.find((b) => /Start here|Go back to this run/.test(b.textContent));
toFrontier.click();
assert.match(text(dom.root), /Hollis Vane/, 'the second setting is running');
assert.match(text(dom.root), /Day 1/, 'and it started fresh');
// Both slots are on disk, independently.
const corporate = JSON.parse(dom.storage.getItem('theladder.save.v1.corporateladder'));
const frontier = JSON.parse(dom.storage.getItem('theladder.save.v1.frontier'));
assert.equal(corporate.state.turn, 2, 'the first run is still where it was');
assert.equal(frontier.state.turn, 1);
assert.equal(corporate.state.meta.packId, 'corporateladder');
assert.equal(frontier.state.meta.packId, 'frontier');
// And going back resumes rather than restarting.
byClass(dom.root, 'button').find((b) => /Change setting/.test(b.textContent)).click();
assert.match(text(dom.root), /Day 2, Mail Associate I, saved/,
'the picker reports where the other run is');
byClass(dom.root, 'button').find((b) => /Go back to this run/.test(b.textContent)).click();
assert.match(text(dom.root), /Day 2/, 'the first career resumed where it was left');
assert.match(text(dom.root), /Marlene Hobb/);
} finally {
dom.restore();
}
});
test('a run saved before settings were selectable is not lost', async () => {
// The old single slot, migrated on read. Someone mid-career when this
// shipped should find their career, not a new one.
const dom = installFakeDom();
try {
const { startGame } = await import('../src/engine/game.js');
const { corporateLadder } = await import('../content/corporateladder/index.js');
const legacy = startGame(corporateLadder, { seed: 'legacy' });
legacy.turn = 61;
dom.storage.setItem('theladder.save.v1',
JSON.stringify({ format: 1, savedAt: null, state: legacy }));
const { boot: bootApp } = await import(`../src/ui/app.js?t=${Math.random()}`);
bootApp(dom.root);
assert.ok(firstByClass(dom.root, 'screen--turn'), 'it booted into the run, not the picker');
assert.match(text(dom.root), /Day 61/, 'the old career came back');
} finally {
dom.restore();
}
});
test('a save written before a stat existed gains it, rather than losing the content', async () => {
// The failure this guards against is silent, which is what makes it worth a
// test: conditions.js compares numbers only, so with no `lifestyle` path both
// `lifestyle < 2` and `lifestyle >= 1` are false. Every event, option and
// upkeep entry gated on a stat added since the save was written simply does
// not exist for that career, and nothing anywhere says so.
const dom = installFakeDom();
try {
const { startGame } = await import('../src/engine/game.js');
const { corporateLadder } = await import('../content/corporateladder/index.js');
const before = startGame(corporateLadder, { seed: 'pre-lifestyle' });
before.turn = 40;
delete before.lifestyle;
dom.storage.setItem('theladder.save.v1.corporateladder',
JSON.stringify({ format: 1, savedAt: null, state: before }));
dom.storage.setItem('theladder.pack.v1', 'corporateladder');
const { boot: bootApp } = await import(`../src/ui/app.js?t=${Math.random()}`);
bootApp(dom.root);
assert.match(text(dom.root), /Day 40/, 'the career resumed where it was');
const saved = JSON.parse(dom.storage.getItem('theladder.save.v1.corporateladder'));
assert.equal(saved.state.lifestyle, 0,
'the stat the run predates was filled in, and the repair written back');
} finally {
dom.restore();
}
});
test('the header reads the second pack\'s display map, not the first\'s', async () => {
// `display` is a per-pack contract with the interface, and a typo in it is
// only visible at runtime. Corporate Ladder's version is covered above; this
// is the same assertion against a pack with entirely different stat names.
const dom = await boot({ pack: 'frontier' });
try {
const stats = text(firstByClass(dom.root, 'stats'));
assert.match(stats, /Money/);
assert.match(stats, /\$40/, 'money is formatted as money');
assert.match(stats, /Nerve/);
assert.match(stats, /Trade/);
assert.match(stats, /Standing/);
assert.doesNotMatch(stats, /Negotiation|Logistics/, 'no leakage from the other pack');
assert.doesNotMatch(stats, /Owed at the store/, 'the ledger is hidden while it is zero');
assert.match(text(dom.root), /Cold Fork/, 'the people panel is named by the pack');
assert.match(text(dom.root), /Hollis Vane/);
assert.match(text(dom.root), /Marshal Ward/);
assert.match(text(dom.root), /Hired Hand/, 'the stage title comes from the pack');
assert.match(text(dom.root), /Stage day/, 'and so does the name of the day');
} finally {
dom.restore();
}
});
// ---- a drawn cast, and checks (DECISIONS #41) ----------------------------------
test('a pack with a drawn cast shows whoever this run drew, and never a placeholder', async () => {
const dom = await boot({ pack: 'countinghouse' });
try {
const people = byClass(dom.root, 'person');
// One partner, one chief clerk, and two to four clerks.
assert.ok(people.length >= 4 && people.length <= 6, `${people.length} people in the panel`);
const titles = byClass(dom.root, 'person__title').map((t) => t.textContent);
assert.equal(titles.filter((t) => t === 'Senior Partner').length, 1);
assert.equal(titles.filter((t) => t === 'Chief Clerk').length, 1);
const clerks = titles.filter((t) => t === 'Clerk').length;
assert.ok(clerks >= 2 && clerks <= 4, `${clerks} clerks`);
for (let turn = 0; turn < 25; turn++) {
assert.doesNotMatch(text(dom.root), /\{[a-z_0-9.]+\}/, 'a {placeholder} reached the screen');
enabledOptions(dom.root)[0].click();
assert.doesNotMatch(text(dom.root), /\{[a-z_0-9.]+\}/, 'a {placeholder} reached the outcome');
firstByClass(dom.root, 'button--next').click();
}
} finally {
dom.restore();
}
});
test('a checked option shows its odds in words, and the outcome says whether it came off', async () => {
const dom = await boot({ pack: 'countinghouse' });
try {
let checked;
for (let turn = 0; turn < 40 && !checked; turn++) {
checked = optionButtons(dom.root).find((b) => !b.disabled && firstByClass(b, 'option__odds'));
if (checked) break;
enabledOptions(dom.root)[0].click();
firstByClass(dom.root, 'button--next').click();
}
assert.ok(checked, 'a checked option turned up within forty days');
const odds = firstByClass(checked, 'option__odds').textContent;
assert.match(odds, /^(a long shot|unlikely|an even chance|likely|a safe bet)$/);
assert.doesNotMatch(text(dom.root), /\d+%/, 'no percentage is ever shown');
checked.click();
const said = text(firstByClass(dom.root, 'outcome__check'));
assert.match(said, /and it (came|did not come) off\./);
assert.ok(said.toLowerCase().startsWith(odds), `the outcome repeats the odds: "${said}"`);
assert.ok(firstByClass(dom.root, 'outcome__text'), 'and says what happened');
} finally {
dom.restore();
}
});
+204
View File
@@ -0,0 +1,204 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { validatePack, assertValidPack } from '../src/engine/validate.js';
import { testPack } from './fixtures/test-pack.js';
/** A copy of the fixture with one thing broken. */
function broken(mutate) {
const pack = structuredClone(testPack);
mutate(pack);
return validatePack(pack);
}
const messages = (result) => result.errors.join('\n');
test('the fixture pack is valid and warning-free', () => {
const result = validatePack(testPack);
assert.deepEqual(result.errors, []);
assert.deepEqual(result.warnings, []);
});
test('an effect writing an undeclared path is caught', () => {
const result = broken((pack) => {
pack.events[0].options[0].effects.push({ path: 'skills.wleding', op: 'add', value: 1 });
});
assert.match(messages(result), /skills\.wleding.*not declared/s);
});
test('an amount read from state must read a declared path, with numeric scale and cap', () => {
const unknown = broken((pack) => {
pack.events[0].options[0].effects.push({ path: 'money', op: 'add', value: { from: 'skills.wleding', times: -1 } });
});
assert.match(messages(unknown), /reads its amount from "skills\.wleding".*not declared/s);
const badCap = broken((pack) => {
pack.events[0].options[0].effects.push({ path: 'money', op: 'add', value: { from: 'money', cap: 'lots' } });
});
assert.match(messages(badCap), /non-numeric cap/);
const fine = broken((pack) => {
pack.events[0].options[0].effects.push({ path: 'money', op: 'add', value: { from: 'money', times: -1, cap: 5 } });
});
assert.deepEqual(fine.errors, []);
});
test('a relationship effect naming a character who does not exist is caught', () => {
const result = broken((pack) => {
pack.events[0].options[0].effects.push({ path: 'relationships.dave.likes_you', op: 'add', value: 1 });
});
assert.match(messages(result), /relationships\.dave\.likes_you.*not declared/s);
});
test('a condition reading an undeclared path is caught', () => {
const result = broken((pack) => {
pack.events[0].options[1].requires = [{ path: 'skills.charisma', op: '>=', value: 1 }];
});
assert.match(messages(result), /skills\.charisma.*not declared/s);
});
test('content may read engine bookkeeping but not write it', () => {
const readsTurn = broken((pack) => {
// Not the floor event — see the fallback rule below.
pack.events.find((e) => e.id === 'audit').requires = [{ path: 'turn', op: '>=', value: 3 }];
});
assert.deepEqual(readsTurn.errors, []);
const writesTurn = broken((pack) => {
pack.events[0].options[0].effects.push({ path: 'turn', op: 'add', value: 5 });
});
assert.match(messages(writesTurn), /engine-owned state/);
});
test('declaring a reserved root as pack state is caught', () => {
const result = broken((pack) => { pack.state['history'] = { initial: [] }; });
assert.match(messages(result), /reserved root/);
});
test('unknown operators are caught before they reach the engine', () => {
const badCondition = broken((pack) => {
pack.events[0].options[0].requires = [{ path: 'money', op: 'exceeds', value: 1 }];
});
assert.match(messages(badCondition), /unknown condition operator/);
const badEffect = broken((pack) => {
pack.events[0].options[0].effects[0].op = 'multiply';
});
assert.match(messages(badEffect), /unknown effect operation/);
});
test('add with a non-numeric value is caught', () => {
const result = broken((pack) => { pack.events[0].options[0].effects[0].value = 'ten'; });
assert.match(messages(result), /non-numeric/);
});
test('duplicate ids are caught', () => {
assert.match(messages(broken((pack) => { pack.events[1].id = 'routine'; })), /duplicate id/);
assert.match(messages(broken((pack) => { pack.characters[1].id = 'chief'; })), /duplicate id/);
assert.match(messages(broken((pack) => { pack.events[0].options[1].id = 'work'; })), /duplicate id/);
});
test('an id that cannot be a path segment is caught, because ids become state paths', () => {
const result = broken((pack) => { pack.events[0].id = 'routine day'; });
assert.match(messages(result), /must match/);
});
test('an event with no options is caught', () => {
const result = broken((pack) => { pack.events[0].options = []; });
assert.match(messages(result), /no options/);
});
test('a zero weight is caught, since the event could never fire', () => {
const result = broken((pack) => { pack.events[0].weight = 0; });
assert.match(messages(result), /greater than zero/);
});
test('a stage with no unconditional event is caught, since the pool can run dry', () => {
const result = broken((pack) => {
pack.events[0].requires = [{ path: 'money', op: '>', value: 0 }];
});
assert.match(messages(result), /no unconditional event/);
});
test('a starting stage with no events at all is caught', () => {
const result = broken((pack) => { pack.startingStage = 'penthouse'; });
assert.match(messages(result), /startingStage "penthouse" has no events/);
});
test('a character with no role_type warns but still plays', () => {
const result = broken((pack) => { delete pack.characters[0].role_type; });
assert.deepEqual(result.errors, []);
assert.match(result.warnings.join('\n'), /role_type/);
});
test('assertValidPack throws with every error listed', () => {
const pack = structuredClone(testPack);
pack.events[0].options[0].effects.push({ path: 'nope', op: 'add', value: 1 });
assert.throws(() => assertValidPack(pack), /is invalid/);
assert.doesNotThrow(() => assertValidPack(testPack));
});
test('upkeep is validated like any other effect list', () => {
const badPath = broken((pack) => {
pack.upkeep = [{ label: 'wage', effects: [{ path: 'monie', op: 'add', value: 20 }] }];
});
assert.match(messages(badPath), /monie.*not declared/s);
const empty = broken((pack) => { pack.upkeep = [{ label: 'wage', effects: [] }]; });
assert.match(messages(empty), /has no effects/);
const writesReserved = broken((pack) => {
pack.upkeep = [{ effects: [{ path: 'turn', op: 'add', value: 1 }] }];
});
assert.match(messages(writesReserved), /engine-owned state/);
});
test('a starting relationship for a stat that does not exist is caught', () => {
const result = broken((pack) => {
pack.characters[0].startingRelationship = { respects_you: 10 };
});
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/);
});
test('content may move the player between stages, but only to a real one', () => {
const good = broken((pack) => {
pack.events.push({
id: 'upstairs', stage: 'attic', weight: 1, description: 'Up you go.',
options: [{ id: 'go', label: 'Go up.', effects: [] }],
});
pack.events[0].options[0].effects.push({ path: 'stage', op: 'set', value: 'attic' });
});
assert.deepEqual(good.errors, []);
const nowhere = broken((pack) => {
pack.events[0].options[0].effects.push({ path: 'stage', op: 'set', value: 'penthouse' });
});
assert.match(messages(nowhere), /stage "penthouse", which has no events/);
const wrongOp = broken((pack) => {
pack.events[0].options[0].effects.push({ path: 'stage', op: 'add', value: 1 });
});
assert.match(messages(wrongOp), /may only be written with set/);
});
+260
View File
@@ -0,0 +1,260 @@
// Read an exported feedback log and report what actually happened.
//
// node tools/analyze-log.js theladder-feedback-2026-09-10-11-04-22.json
//
// The question this exists to answer: how many turns were played against how
// many distinct situations, and how often each one came round again. A pack
// that invites a 180-turn run needs to be judged on what turn 180 looked like,
// not on what turn 5 did.
//
// It also surfaces the two things worth more than any rating — the gates
// players kept bouncing off, and anything they said they wanted to do.
import { readFile, readdir, stat } from 'node:fs/promises';
import { resolve, dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const logsDir = resolve(root, 'logs');
/**
* The most recently modified feedback log in logs/, when none was named.
*
* Feedback logs only. A save export is a .json too, and a playtester who
* exports both — as the first one to do so did — would otherwise have the save
* read as a log whenever it was the later of the two.
*/
async function newestLog() {
let names;
try {
names = (await readdir(logsDir))
.filter((name) => name.startsWith('theladder-feedback-') && name.endsWith('.json'));
} catch {
return null;
}
if (!names.length) return null;
const timed = await Promise.all(names.map(async (name) => {
const path = join(logsDir, name);
return { path, at: (await stat(path)).mtimeMs };
}));
return timed.sort((a, b) => b.at - a.at)[0].path;
}
const bar = (fraction, width = 24) =>
'█'.repeat(Math.max(1, Math.round(fraction * width))).padEnd(width, '·');
const pct = (n, total) => `${((n / total) * 100).toFixed(1)}%`;
function section(title) {
console.log(`\n${title}`);
console.log('─'.repeat(title.length));
}
/** Split the log into playthroughs. One export can hold several. */
function byRun(entries) {
const runs = new Map();
for (const entry of entries) {
if (!runs.has(entry.seed)) runs.set(entry.seed, []);
runs.get(entry.seed).push(entry);
}
return runs;
}
/**
* How often each situation came round.
*
* Gaps are pooled *within* runs and never across them. An export can hold
* several playthroughs, and turn numbers restart with each — measuring a gap
* across that seam produced "every ~-5 turns", which is how this was caught.
*/
function reportRepetition(runs) {
const counts = new Map();
const spans = new Map(); // summed (last - first) within each run
const pairs = new Map(); // summed (appearances - 1) within each run
const onlyTurn = new Map();
let turns = 0;
for (const run of runs.values()) {
const firstSeen = new Map();
const lastSeen = new Map();
const inRun = new Map();
for (const entry of run) {
turns++;
counts.set(entry.event_id, (counts.get(entry.event_id) ?? 0) + 1);
inRun.set(entry.event_id, (inRun.get(entry.event_id) ?? 0) + 1);
if (!firstSeen.has(entry.event_id)) firstSeen.set(entry.event_id, entry.turn);
lastSeen.set(entry.event_id, entry.turn);
}
for (const [eventId, appearances] of inRun) {
if (appearances > 1) {
spans.set(eventId, (spans.get(eventId) ?? 0) + (lastSeen.get(eventId) - firstSeen.get(eventId)));
pairs.set(eventId, (pairs.get(eventId) ?? 0) + (appearances - 1));
} else if (!onlyTurn.has(eventId)) {
onlyTurn.set(eventId, firstSeen.get(eventId));
}
}
}
const distinct = counts.size;
section('Repetition');
console.log(`${turns} turns across ${distinct} distinct situations ` +
`— each seen ${(turns / distinct).toFixed(1)} times on average.`);
const repeated = [...counts.values()].filter((n) => n > 1).length;
console.log(`${repeated} of ${distinct} came up more than once; ` +
`${distinct - repeated} were one-offs.`);
const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1]);
const most = ranked[0][1];
console.log('');
for (const [eventId, count] of ranked) {
const pairCount = pairs.get(eventId) ?? 0;
const gap = pairCount > 0
? ` every ~${Math.max(1, Math.round(spans.get(eventId) / pairCount))} turns`
: ` once${runs.size > 1 ? ' per run' : `, turn ${onlyTurn.get(eventId)}`}`;
console.log(` ${eventId.padEnd(24)} ${String(count).padStart(3)} ` +
`${bar(count / most)} ${pct(count, turns).padStart(6)}${gap}`);
}
}
function reportChoices(entries) {
const counts = new Map();
for (const entry of entries) {
const key = `${entry.event_id}.${entry.action_taken}`;
counts.set(key, (counts.get(key) ?? 0) + 1);
}
section('Choices');
const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1]);
console.log(`${counts.size} distinct options taken. Most used:`);
for (const [key, count] of ranked.slice(0, 8)) {
console.log(` ${String(count).padStart(3)}× ${key}`);
}
}
function reportGates(entries) {
// What players were shown but could not take. The strongest signal that a
// choice set is too narrow, and it arrives whether or not anyone types.
const blocked = new Map();
for (const entry of entries) {
for (const option of entry.offered ?? []) {
// A hidden option was never on screen, so it was never a door anybody
// met. Logs from before 0.3.4 do not record `hidden`, and still count
// such options here. See DECISIONS #39.
if (option.available || option.hidden) continue;
const reasons = (option.failed ?? [])
.map((c) => `${c.path} ${c.op} ${JSON.stringify(c.value)}`)
.join(', ') || 'unstated';
const key = `${entry.event_id}.${option.id} — needs ${reasons}`;
blocked.set(key, (blocked.get(key) ?? 0) + 1);
}
}
section('Doors that stayed shut');
if (blocked.size === 0) {
console.log('Nothing was ever locked.');
return;
}
const ranked = [...blocked.entries()].sort((a, b) => b[1] - a[1]);
console.log(`${ranked.reduce((s, [, n]) => s + n, 0)} locked options shown, ` +
`${blocked.size} distinct. Most often:`);
for (const [key, count] of ranked.slice(0, 10)) {
console.log(` ${String(count).padStart(3)}× ${key}`);
}
}
function reportFeedback(entries) {
section('What the player said');
const rated = entries.filter((e) => e.rating);
const tally = { up: 0, neutral: 0, down: 0 };
for (const entry of rated) tally[entry.rating] = (tally[entry.rating] ?? 0) + 1;
console.log(rated.length
? `${rated.length} of ${entries.length} turns rated — ` +
`👍 ${tally.up} 😐 ${tally.neutral} 👎 ${tally.down}`
: 'No turns were rated.');
const rateFor = (id) => {
const seen = entries.filter((e) => e.event_id === id && e.rating);
if (!seen.length) return null;
const score = seen.reduce((s, e) => s + (e.rating === 'up' ? 1 : e.rating === 'down' ? -1 : 0), 0);
return { id, score, n: seen.length };
};
const scored = [...new Set(entries.map((e) => e.event_id))]
.map(rateFor).filter(Boolean).sort((a, b) => b.score - a.score);
if (scored.length > 1) {
console.log(`\n best received: ${scored[0].id} (${scored[0].score >= 0 ? '+' : ''}${scored[0].score} over ${scored[0].n})`);
const worst = scored.at(-1);
console.log(` worst received: ${worst.id} (${worst.score >= 0 ? '+' : ''}${worst.score} over ${worst.n})`);
}
const comments = entries.filter((e) => e.comment);
if (comments.length) {
console.log('\n Comments:');
for (const entry of comments) {
console.log(` turn ${entry.turn} (${entry.event_id}): ${entry.comment}`);
}
}
const wanted = entries.filter((e) => e.desired_but_unavailable_action);
if (wanted.length) {
console.log('\n Wanted to do, but could not:');
for (const entry of wanted) {
console.log(` turn ${entry.turn} (${entry.event_id}): ${entry.desired_but_unavailable_action}`);
}
}
}
async function main() {
const path = process.argv[2] ?? await newestLog();
if (!path) {
console.error('usage: npm run analyze -- <exported-feedback-log.json>');
console.error(' or: drop the export into logs/ and run npm run analyze');
process.exitCode = 1;
return;
}
if (!process.argv[2]) console.log(`reading ${path.replace(`${root}/`, '')}\n`);
const log = JSON.parse(await readFile(path, 'utf8'));
const entries = log.entries ?? [];
if (!entries.length) {
console.log('The log has no entries.');
return;
}
// One log can hold turns from more than one setting, and pooling them would
// put a mailroom and a freight yard in the same repetition table. The same
// rule as the run boundary in DECISIONS #30, a level up: every statistic
// here is per pack, and a log is not one sequence.
const byPack = new Map();
for (const entry of entries) {
const id = entry.pack_id ?? 'unknown';
if (!byPack.has(id)) byPack.set(id, []);
byPack.get(id).push(entry);
}
if (byPack.size > 1) {
console.log(`${entries.length} turns logged across ${byPack.size} settings.`);
}
for (const [packId, packEntries] of byPack) {
const runs = byRun(packEntries);
console.log(
`${packEntries.length} turns logged across ${runs.size} run${runs.size === 1 ? '' : 's'} ` +
`(pack ${packId} ${packEntries[0].pack_version ?? ''})`.trimEnd(),
);
for (const [seed, run] of runs) {
const stages = [...new Set(run.map((e) => e.stage))];
console.log(` seed ${seed}: ${run.length} turns, ${stages.join(' → ')}`);
}
reportRepetition(runs);
reportChoices(packEntries);
reportGates(packEntries);
reportFeedback(packEntries);
console.log('');
}
}
main().catch((error) => {
console.error(`could not analyse that log: ${error.message}`);
process.exitCode = 1;
});
+175
View File
@@ -0,0 +1,175 @@
// 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 { execFileSync } from 'node:child_process';
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 <expression>
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
// `[\s\S]*?` rather than `.*?`: a destructured import may be wrapped across
// several lines, and `transform` below already rewrites those — a scan that
// missed them produced a bundle with a __require for a module that was never
// collected, which only failed when the bundled game was run.
const specifiers = [...source.matchAll(/^[ \t]*import\s[\s\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;
}
/**
* The commit this build came from, marked `+` when the tree had uncommitted
* changes. Substituted into the output only — src/build-info.js stays as it is
* in the repository, so building never dirties the working tree.
*/
function buildIdentity() {
try {
const sha = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: root, stdio: 'pipe' })
.toString().trim();
const dirty = execFileSync('git', ['status', '--porcelain'], { cwd: root, stdio: 'pipe' })
.toString().trim().length > 0;
return dirty ? `${sha}+` : sha;
} catch {
return 'unknown';
}
}
async function build() {
const html = await readFile(entryHtml, 'utf8');
const styleMatch = html.match(/<link\s+rel="stylesheet"\s+href="([^"]+)"\s*\/?>/);
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(/<script type="module">([\s\S]*?)<\/script>/);
if (!scriptMatch) throw new Error('index.html has no module script');
const bootstrapSource = scriptMatch[1];
const entrySpecifier = bootstrapSource.match(/from\s*['"]([^'"]+)['"]/)?.[1];
if (!entrySpecifier) throw new Error('could not find the entry module in index.html');
const entryPath = resolve(root, entrySpecifier);
const modules = await collect(entryPath);
const bootstrap = transform(bootstrapSource, entryHtml).replace(
/__define\("index\.html"/, '__define("__bootstrap__"',
);
const script = [
'(() => {',
' "use strict";',
' const __factories = new Map();',
' const __cache = new Map();',
' const __define = (id, factory) => __factories.set(id, factory);',
' const __require = (id) => {',
' if (__cache.has(id)) return __cache.get(id);',
' const factory = __factories.get(id);',
' if (!factory) throw new Error(`module not bundled: ${id}`);',
' const exports = {};',
' __cache.set(id, exports);',
' factory(exports, __require);',
' return exports;',
' };',
...[...modules.values()].map((code) => code.replace(/^/gm, ' ')),
bootstrap.replace(/^/gm, ' '),
' __require("__bootstrap__");',
'})();',
].join('\n');
const commit = buildIdentity();
const stamped = script
.replace("commit: 'dev'", `commit: '${commit}'`)
.replace('builtAt: null', `builtAt: '${new Date().toISOString().slice(0, 10)}'`);
if (stamped === script) throw new Error('could not stamp the build identity into the bundle');
const output = html
.replace(styleMatch[0], `<style>\n${css}\n </style>`)
.replace(scriptMatch[0], `<script>\n${stamped}\n </script>`);
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, commit ${commit}`);
}
build().catch((error) => {
console.error(`build failed: ${error.message}`);
process.exitCode = 1;
});