Compare commits

9 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
58 changed files with 8175 additions and 259 deletions
+5
View File
@@ -1,4 +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
+81 -13
View File
@@ -9,13 +9,14 @@ retrospective.
The engine knows nothing about offices, mailrooms or bosses. A **setting is a
content pack** — characters, events, choices, stat names and tuning, all data.
`corporateladder` is the default pack; a Wild West or lemonade-stand pack would
be new data and no new code.
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 and the first content pack are built and tested. No UI yet — the game is
currently playable only from a test harness.
Engine, interface and two content packs, all built and tested, and played in a
real browser.
| Piece | State |
| --- | --- |
@@ -24,12 +25,19 @@ currently playable only from a test harness.
| Event selection, option gating | done |
| Turn loop, per-turn upkeep | done |
| Content validator + reachability tests | done |
| Corporate Ladder pack — 17 events, 2 characters | 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 |
| Played in a real browser | done — first playtest 2026-09-09 |
| 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
@@ -47,6 +55,11 @@ it.**
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.
@@ -61,11 +74,63 @@ That is one self-contained file with the stylesheet and every module inlined.
It plays by double-clicking it — no server, no network, nothing installed.
```sh
npm test # 109 tests: engine, content, UI wiring, and the build
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 and the single-file build.
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
@@ -80,13 +145,16 @@ src/engine/ the game engine — imports nothing from content/, or from src/u
rng.js seeded, serializable RNG
validate.js load-time content checking
src/ui/ screens, formatting and the app controller
app.js the only module that names a content pack
screens/ the turn screen and the retrospective
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, file export/import, the feedback log
content/ content packs (settings)
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; content and UI tests
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
```
+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;
+25 -11
View File
@@ -11,8 +11,15 @@
export const hardTimes = [
{
id: 'rent_bounced',
stage: 'mailroom',
weight: 5000,
// 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 ' +
@@ -69,8 +76,10 @@ export const hardTimes = [
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: 120 },
{ path: 'money', op: 'add', value: 220 },
{ path: 'skills.logistics', op: 'add', value: 2 },
{ path: 'reputation', op: 'add', value: -1 },
],
@@ -80,22 +89,26 @@ export const hardTimes = [
{
id: 'debt_collector',
stage: 'mailroom',
weight: 25,
// 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.',
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: -350 },
{ path: 'debt', op: 'add', value: -350 },
{ 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 },
],
},
@@ -103,10 +116,11 @@ export const hardTimes = [
id: 'pay_minimum',
label: 'Pay what you can.',
requires: [{ path: 'money', op: '>=', value: 60 }],
result: 'Sixty dollars, fifty of which is the loan.',
result: 'Up to fifty against the loan, and ten for the privilege of the call.',
effects: [
{ path: 'money', op: 'add', value: -60 },
{ path: 'debt', op: 'add', value: -50 },
{ 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 } },
],
},
{
+3 -1
View File
@@ -1,6 +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, ...hardTimes];
export const events = [...routine, ...people, ...promotion, ...dispatch, ...hardTimes];
export default events;
@@ -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;
+213 -8
View File
@@ -10,12 +10,21 @@ import { events } from './events/index.js';
export const corporateLadder = {
id: 'corporateladder',
name: 'Corporate Ladder',
version: '0.1.0',
// 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,
@@ -36,9 +45,15 @@ export const corporateLadder = {
'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: [] },
@@ -53,14 +68,156 @@ export const corporateLadder = {
},
// Money moves on a schedule the player can feel: paid on alternate Fridays,
// rent out every Monday, and only small change in between. Over a fortnight
// the baseline is about forty dollars down — a drift, not a bleed.
// 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.
{ label: 'Wages', every: 14, offset: 4, effects: [{ path: 'money', op: 'add', value: 980 }] },
// 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.
@@ -87,6 +244,22 @@ export const corporateLadder = {
},
],
// 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: {
@@ -95,10 +268,42 @@ export const corporateLadder = {
'reputation': { label: 'Standing', format: 'meter', order: 3 },
'skills.negotiation': { label: 'Negotiation', format: 'meter', order: 4 },
'skills.logistics': { label: 'Logistics', format: 'meter', order: 5 },
'notable': { hidden: true },
'flags.took_loan': { label: 'a loan on the books', hidden: true },
'flags.holds_the_envelope': { label: 'the envelope', hidden: true },
'flags.covered_for_trevor': { label: "Trevor's Thursday", hidden: true },
// 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,
+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;
+593
View File
@@ -261,3 +261,596 @@ 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.
+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.
+3 -2
View File
@@ -1,12 +1,13 @@
{
"name": "theladder",
"version": "0.0.1",
"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"
"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 };
}
+32 -5
View File
@@ -25,23 +25,50 @@ function clamp(value, rule) {
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, value } = effect;
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]);
return {
state: setPath(state, path, to),
change: { path, op, value, from, to, clamped: raw !== to },
};
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. */
+40 -4
View File
@@ -9,8 +9,10 @@ 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 } from './select.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 {}
@@ -28,6 +30,10 @@ function advance(pack, state) {
);
}
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;
}
@@ -101,7 +107,10 @@ export function currentEvent(pack, state) {
/** The current event together with each option's availability. */
export function currentTurn(pack, state) {
const event = currentEvent(pack, state);
return { event, options: resolveOptions(state, event) };
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 ?? {} };
}
/**
@@ -120,7 +129,27 @@ export function takeTurn(pack, state, optionId, { schema = resolveSchema(pack),
throw new MoveError(`option "${optionId}" is locked: ${JSON.stringify(chosen.failed)}`);
}
const applied = applyEffects(state, option.effects, schema);
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;
@@ -135,7 +164,10 @@ export function takeTurn(pack, state, optionId, { schema = resolveSchema(pack),
stage: next.stage,
eventId: event.id,
optionId: option.id,
optionLabel: option.label,
// 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) => ({
@@ -143,6 +175,10 @@ export function takeTurn(pack, state, optionId, { schema = resolveSchema(pack),
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,
};
+3
View File
@@ -7,3 +7,6 @@ export { createState, resolveSchema, indexCharacters, RESERVED_ROOTS } from './s
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';
+22 -1
View File
@@ -9,6 +9,21 @@
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) {
@@ -37,6 +52,11 @@ export function eligibleEvents(pack, state) {
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);
}
@@ -60,8 +80,9 @@ export function selectEvent(pack, state, rng) {
* 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, option.requires);
const gate = check(state, bindConditions(option.requires, bindings));
return {
option,
available: gate.passed,
+53 -9
View File
@@ -4,7 +4,8 @@
// expands that into a flat schema and an initial state object. The engine
// names no stat of its own beyond its own bookkeeping.
import { setPath } from './paths.js';
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
@@ -12,7 +13,7 @@ import { setPath } from './paths.js';
* 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']);
new Set(['turn', 'stage', 'seen', 'lastSeen', 'history', 'meta', 'calendar', 'cast']);
/**
* Flatten a pack's state declarations into one path -> rule map, expanding the
@@ -27,17 +28,30 @@ export function resolveSchema(pack) {
schema[path] = { ...rule };
}
for (const character of pack.characters ?? []) {
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.${character.id}.${stat}`] =
override === undefined ? { ...rule } : { ...rule, initial: override };
// 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 = {
@@ -58,7 +72,37 @@ export function createState(pack, { seed, schema = resolveSchema(pack) } = {}) {
for (const [path, rule] of Object.entries(schema)) {
state = setPath(state, path, structuredClone(rule.initial));
}
return state;
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. */
+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 };
});
}
+213 -15
View File
@@ -6,8 +6,10 @@
import { RESERVED_ROOTS, resolveSchema, indexCharacters } from './state.js';
import { OPERATORS, pathsUsed as conditionPaths } from './conditions.js';
import { OPERATIONS, pathsUsed as effectPaths } from './effects.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_]+$/;
@@ -40,14 +42,108 @@ export function validatePack(pack) {
}
}
// 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.name) fail(where, 'name is required');
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) => {
@@ -63,13 +159,24 @@ export function validatePack(pack) {
return `"${path}" is not declared anywhere in this pack`;
};
const writable = (path) => {
// 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;
@@ -123,8 +230,9 @@ export function validatePack(pack) {
fail(where, `unknown effect operation ${JSON.stringify(effect.op)}`);
}
}
for (const path of effectPaths(entry.effects)) {
const problem = writable(path);
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}`);
}
});
@@ -152,6 +260,31 @@ export function validatePack(pack) {
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.
@@ -168,19 +301,78 @@ export function validatePack(pack) {
if (seenOptions.has(option.id)) fail(optionWhere, 'duplicate id');
seenOptions.add(option.id);
if (!option.label) fail(optionWhere, 'label is required');
checkConditions(optionWhere, option.requires);
bindForCheck(optionWhere, option.label, involves);
bindForCheck(optionWhere, option.result, involves);
checkConditions(optionWhere, bound(option.requires));
for (const effect of option.effects ?? []) {
if (!OPERATIONS.includes(effect.op)) {
fail(optionWhere, `unknown effect operation ${JSON.stringify(effect.op)}`);
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`);
}
}
}
if (['add'].includes(effect.op) && typeof effect.value !== 'number') {
fail(optionWhere, `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}`);
}
}
for (const path of effectPaths(option.effects)) {
const problem = writable(path);
if (problem) fail(optionWhere, `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');
}
}
}
@@ -190,6 +382,12 @@ export function validatePack(pack) {
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`);
+9
View File
@@ -11,6 +11,8 @@
// 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;
@@ -44,6 +46,13 @@ 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,
+48 -10
View File
@@ -4,10 +4,23 @@
// 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 KEY = 'theladder.save.v1';
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 {
@@ -17,24 +30,49 @@ function safely(action, fallback = null) {
}
}
export function saveLocal(state) {
export function saveLocal(packId, state) {
return safely(() => {
localStorage.setItem(KEY, JSON.stringify({ format: FORMAT, savedAt: new Date().toISOString(), state }));
localStorage.setItem(
slotFor(packId),
JSON.stringify({ format: FORMAT, savedAt: new Date().toISOString(), state }),
);
return true;
}, false);
}
export function loadLocal() {
/**
* 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(KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
return parsed?.state ?? null;
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() {
return safely(() => { localStorage.removeItem(KEY); return true; }, false);
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. */
+142 -37
View File
@@ -1,8 +1,11 @@
// The application: wiring the engine to the screens.
//
// This is the one module that names a content pack, which makes it the seam a
// future pack-picker slots into. Everything below it takes the pack as an
// argument.
// 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
@@ -10,21 +13,24 @@
// rebuilding the DOM under a cursor would throw the player out of the box they
// are typing in.
import { corporateLadder } from '../../content/corporateladder/index.js';
import { packs, packById, defaultPack } from '../../content/index.js';
import { validatePack } from '../engine/validate.js';
import { resolveSchema } from '../engine/state.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 { fill } from './dom.js';
import { saveLocal, loadLocal, clearLocal, serializeSave, parseSave, storageAvailable } from '../io/save.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 pack = corporateLadder;
const schema = resolveSchema(pack);
const app = {
pack: null,
schema: null,
state: null,
view: null,
root: null,
@@ -35,7 +41,7 @@ function choosingView() {
return {
screen: 'turn',
phase: 'choosing',
turn: currentTurn(pack, app.state),
turn: currentTurn(app.pack, app.state),
feedback: emptyFeedback(),
feedbackOpen: false,
};
@@ -45,20 +51,41 @@ const emptyFeedback = () =>
({ rating: null, comment: '', desiredAction: '', saved: false, touched: false, turnId: null });
function render() {
const screen = app.view.screen === 'retrospective'
? retrospectiveScreen(pack, app.state, schema, actions)
: turnScreen(pack, app.state, schema, app.view, actions);
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(pack, app.state, optionId);
const { state, record } = takeTurn(app.pack, app.state, optionId);
app.state = state;
saveLocal(state);
saveLocal(app.pack.id, state);
const entry = logTurn(record, state, pack);
const entry = logTurn(record, state, app.pack);
app.view = {
screen: 'turn',
phase: 'outcome',
@@ -117,14 +144,37 @@ const actions = {
onNewGame() {
const played = app.state?.history?.length ?? 0;
if (played > 0 && !confirm(
`This ends the current run at ${pack.turnUnit} ${app.state.turn}. ` +
`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();
app.state = startGame(pack, { seed: String(Date.now()), schema, now: new Date().toISOString() });
clearLocal();
saveLocal(app.state);
app.view = choosingView();
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();
},
@@ -140,9 +190,12 @@ const actions = {
const file = input.files?.[0];
if (!file) return;
try {
const state = parseSave(await readFile(file), pack.id);
// 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(state);
saveLocal(app.pack.id, state);
app.view = choosingView();
render();
} catch (error) {
@@ -191,30 +244,82 @@ function flushFeedback() {
});
}
export function boot(rootElement) {
/**
* 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(rootElement, errorScreen(validation.errors));
return;
fill(app.root, errorScreen(pack, validation.errors));
return false;
}
for (const warning of validation.warnings) console.warn(`[content] ${warning}`);
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.');
}
app.root = rootElement;
const restored = loadLocal();
app.state = restored && restored.meta?.packId === pack.id
? restored
: startGame(pack, { seed: String(Date.now()), schema, now: new Date().toISOString() });
app.view = choosingView();
render();
// 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(errors) {
function errorScreen(pack, errors) {
const list = document.createElement('ul');
for (const error of errors) {
const item = document.createElement('li');
@@ -224,7 +329,7 @@ function errorScreen(errors) {
const wrapper = document.createElement('div');
wrapper.className = 'screen screen--error';
const heading = document.createElement('h1');
heading.textContent = 'This content pack will not load.';
heading.textContent = `"${pack.name ?? pack.id}" will not load.`;
wrapper.append(heading, list);
return wrapper;
}
+52 -8
View File
@@ -2,7 +2,27 @@
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 } = {}) {
@@ -38,12 +58,14 @@ export function statPanel(pack, state, schema) {
/** 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' }, character.description),
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}`;
@@ -59,21 +81,39 @@ export function characterCard(pack, state, schema, character) {
}
/** The change list shown after a choice: what moved, and by how much. */
export function changeList(pack, changes, { className = 'changes' } = {}) {
const meaningful = changes.filter((c) => c.from !== c.to);
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) => {
const rising = typeof change.to === 'number' && typeof change.from === 'number'
? change.to > change.from
: Boolean(change.to);
// 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)),
h('span', { class: 'change__label' }, change.label ?? labelFor(pack, change.path, state)),
h('span', { class: 'change__delta' }, deltaText(pack, change)),
change.clamped && h('span', { class: 'change__note' }, 'at its limit'),
// 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)}`),
);
}),
);
@@ -83,6 +123,10 @@ 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
+22
View File
@@ -24,3 +24,25 @@ 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;
}
+23 -13
View File
@@ -8,6 +8,7 @@
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());
@@ -17,16 +18,21 @@ export function displayFor(pack, path) {
return pack.display?.[path] ?? null;
}
/** A human name for a path, derived from the path when the pack is silent. */
export function labelFor(pack, path) {
/**
* 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 character = pack.characters?.find((c) => c.id === segments[1]);
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 `${character?.name ?? titleCase(segments[1])} — ${stat.toLowerCase()}`;
return `${name ?? titleCase(segments[1])} — ${stat.toLowerCase()}`;
}
return titleCase(segments.at(-1));
}
@@ -34,7 +40,11 @@ export function labelFor(pack, path) {
/** Money is written as money; everything else is a plain number. */
export function formatValue(pack, path, value) {
if (typeof value === 'boolean') return value ? 'yes' : 'no';
if (displayFor(pack, path)?.format === 'money') return formatMoney(value);
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);
}
@@ -57,15 +67,15 @@ export function formatDelta(pack, path, from, to) {
* One condition as a requirement the player can read: "Negotiation 15+",
* "Trevor — likes you 48+", "not in debt".
*/
export function describeCondition(pack, condition) {
export function describeCondition(pack, condition, state) {
if (!condition || typeof condition !== 'object') return '';
if (Array.isArray(condition)) return condition.map((c) => describeCondition(pack, c)).join(', ');
if (condition.all) return condition.all.map((c) => describeCondition(pack, c)).join(', ');
if (condition.any) return condition.any.map((c) => describeCondition(pack, c)).join(' or ');
if (condition.not) return `not ${describeCondition(pack, condition.not)}`;
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);
const label = labelFor(pack, path, state);
if (typeof value === 'boolean') return value ? label.toLowerCase() : `no ${label.toLowerCase()}`;
@@ -82,8 +92,8 @@ export function describeCondition(pack, condition) {
}
/** What a locked option is waiting for. */
export function requirementText(pack, failedConditions) {
const parts = failedConditions.map((c) => describeCondition(pack, c)).filter(Boolean);
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';
}
+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;
+35 -8
View File
@@ -5,7 +5,9 @@
// not an ending: the player can close it and carry on from the same turn.
import { h } from '../dom.js';
import { statPanel, characterCard } from '../components.js';
import { 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';
@@ -14,12 +16,17 @@ export function retrospectiveScreen(pack, state, schema, actions) {
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' },
`${summary.turns} ${unit}${summary.turns === 1 ? '' : 's'} in the ` +
`${(pack.stages?.[summary.stage]?.label ?? summary.stage).toLowerCase()}.`),
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' },
@@ -41,6 +48,10 @@ export function retrospectiveScreen(pack, state, schema, actions) {
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)),
),
@@ -49,21 +60,24 @@ export function retrospectiveScreen(pack, state, schema, actions) {
h('section', { class: 'retro__section' },
h('h2', { class: 'retro__heading' }, 'Where you stand with people'),
h('div', { class: 'retro__people' },
(pack.characters ?? []).map((character) => characterCard(pack, state, schema, character)),
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' }, moment)))
? 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', onClick: actions.onResume },
'Keep going'),
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'),
@@ -72,6 +86,19 @@ export function retrospectiveScreen(pack, state, schema, actions) {
);
}
/**
* 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';
+50 -19
View File
@@ -10,13 +10,17 @@
// because this is the moment a player has an opinion.
import { h } from '../dom.js';
import { statPanel, characterCard, changeList } from '../components.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),
@@ -31,23 +35,30 @@ export function turnScreen(pack, state, schema, view, actions) {
h('div', { class: 'layout' },
h('main', { class: 'scene' },
view.phase === 'outcome'
? outcomeView(pack, view, actions)
: choosingView(pack, view, actions),
? outcomeView(pack, state, view, actions)
: choosingView(pack, state, view, actions),
),
h('aside', { class: 'people' },
h('h2', { class: 'people__heading' }, 'The mailroom'),
(pack.characters ?? []).map((character) => characterCard(pack, state, schema, character)),
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', onClick: actions.onRetrospective },
'Wrap up my career'),
h('button', { class: 'button button--quiet', onClick: actions.onExportSave }, 'Export save'),
h('button', { class: 'button button--quiet', onClick: actions.onImportSave }, 'Import save'),
h('button', { class: 'button button--quiet', onClick: actions.onExportFeedback },
'Export feedback log'),
h('button', { class: 'button button--quiet', onClick: actions.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'),
),
);
}
@@ -75,47 +86,65 @@ function diary(pack, state) {
);
}
function choosingView(pack, view, actions) {
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' }, event.description),
h('p', { class: 'scenario__text' }, text(event.description)),
h('ul', { class: 'options' },
visible.map(({ option, available, failed }) => h('li', { class: 'options__item' },
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' }, option.label),
!available && h('span', { class: 'option__requirement' }, requirementText(pack, failed)),
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, view, actions) {
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' }, option.label),
option.result && h('p', { class: 'outcome__text' }, option.result),
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),
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),
changeList(pack, record.upkeep, { state }),
),
),
feedbackWidget(view, actions),
h('button', { class: 'button button--primary button--next', onClick: actions.onNext },
`Next ${pack.turnUnit ?? 'turn'}`),
h('button', {
class: 'button button--primary button--next',
dataset: { autofocus: 'true' },
onClick: actions.onNext,
}, `Next ${pack.turnUnit ?? 'turn'} →`),
);
}
@@ -169,3 +198,5 @@ function feedbackWidget(view, actions) {
feedback.saved && h('p', { class: 'feedback__saved' }, 'Noted — thank you.'),
);
}
const capitalise = (words) => words.charAt(0).toUpperCase() + words.slice(1);
+58
View File
@@ -51,6 +51,24 @@ body {
}
.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 ---------- */
@@ -153,6 +171,13 @@ body {
.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 ---------- */
@@ -178,6 +203,9 @@ body {
.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; }
@@ -266,6 +294,7 @@ body {
}
.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; }
@@ -287,3 +316,32 @@ body {
.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; }
+20 -2
View File
@@ -47,7 +47,20 @@ export function summarize(pack, state) {
(sum, record) => sum + record.offered.filter((o) => !o.available).length, 0,
);
const eventsSeen = new Set(history.map((r) => r.eventId)).size;
// 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,
@@ -61,7 +74,12 @@ export function summarize(pack, state) {
bestDay: ranked[0]?.net > 0 ? ranked[0] : null,
worstDay: ranked.at(-1)?.net < 0 ? ranked.at(-1) : null,
lockedEncounters,
eventsSeen,
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 };
+27
View File
@@ -16,6 +16,17 @@ 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');
@@ -24,6 +35,9 @@ test('the bundle builds, and the bundled game boots and plays', () => {
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');
@@ -33,9 +47,22 @@ test('the bundle builds, and the bundled game boots and plays', () => {
// 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');
+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');
});
+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],
);
});
}
}
+124 -77
View File
@@ -1,9 +1,13 @@
// Tests for the shipped Corporate Ladder pack.
// Corporate Ladder's own tuning.
//
// These are content tests, not engine tests: they assert that the pack is
// playable, that none of it is dead, and that its economy behaves. Engine
// behaviour is covered against the fixture pack instead, so tuning the game
// cannot break the engine suite and vice versa.
// 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
@@ -14,57 +18,135 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { corporateLadder as pack } from '../content/corporateladder/index.js';
import { validatePack } from '../src/engine/validate.js';
import { startGame, currentTurn, takeTurn } from '../src/engine/game.js';
import { createRng } from '../src/engine/rng.js';
import { check } from '../src/engine/conditions.js';
import { play, sweep, median } from './fixtures/strategies.js';
/** Play `turns` turns with independently-seeded random choices. */
function randomRun(runSeed, turns, visit = () => {}) {
const rng = createRng(`chooser-${runSeed}`);
let state = startGame(pack, { seed: `run-${runSeed}` });
/**
* 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);
assert.ok(available.length > 0, `no available option on event "${turn.event.id}"`);
const choice = rng.pick(available).option.id;
visit(turn, state, choice);
state = takeTurn(pack, state, choice).state;
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 pack is valid, with no warnings', () => {
const result = validatePack(pack);
assert.deepEqual(result.errors, []);
assert.deepEqual(result.warnings, []);
});
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`);
test('no content is dead: every event and option is reachable by some player', () => {
// Across archetypes, not just random play: several things in the pack exist
// for a player pursuing something, and "broke but well-liked" in particular
// is a state no single-axis strategy ever reaches.
const { firedEvents, takenOptions } = sweep(pack, { seeds: 12, turns: 200 });
// 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`);
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) => !takenOptions.has(o)), [], 'options that can never be taken');
});
// 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`);
test('how you play changes where you end up', () => {
const { byStrategy } = sweep(pack, { seeds: 12, turns: 200 });
const finalMoney = (name) => median(byStrategy[name].map((s) => s.money));
// 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`);
// The shape that matters: minding the money climbs, ignoring it sinks, and
// playing without a plan roughly treads water. Exact figures will move with
// tuning; the ordering is the design.
assert.ok(finalMoney('thrifty') > finalMoney('random'),
'a careful player should get ahead');
assert.ok(finalMoney('random') > finalMoney('spendthrift'),
'a careless player should struggle');
assert.ok(finalMoney('thrifty') > 800,
`a careful player should be comfortably ahead, was ${finalMoney('thrifty')}`);
// 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', () => {
@@ -78,11 +160,6 @@ test('hard times is uncommon for a careful player and real for a careless one',
assert.ok(countHardTimes('spendthrift') > 0, 'ignoring it should land you in it');
});
test('every turn always offers something to do, however badly the run is going', () => {
// randomRun asserts this on every turn; 40 long runs is the coverage.
for (let seed = 100; seed < 140; seed++) randomRun(seed, 150);
});
test('money arrives and leaves on a rhythm the player can plan around', () => {
const wages = [];
const rent = [];
@@ -115,24 +192,6 @@ test('the calendar names the day and counts the week', () => {
assert.deepEqual([state.calendar.name, state.calendar.cycle], ['Friday', 2]);
});
test('hard times is survivable without any relationship or skill', () => {
const event = pack.events.find((e) => e.id === 'rent_bounced');
const ungated = event.options.filter((o) => !o.requires);
assert.ok(ungated.length > 0, 'a player with nothing must still have a way out');
});
test('the run recovers from hard times rather than sticking there', () => {
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('debt cannot spiral without limit: lending stops and the charge escalates', () => {
let peakDebt = 0;
for (const strategy of ['spendthrift', 'desperate', 'random']) {
@@ -167,15 +226,3 @@ test('a deliberate player can reach relationship-gated content in a reasonable r
assert.ok(reached && reached < 45, `Trevor's chain opened at turn ${reached ?? 'never'}`);
});
test('a played run replays identically from its seed', () => {
const a = randomRun(42, 80);
const b = randomRun(42, 80);
assert.deepEqual(a, b);
});
test('the retrospective has something to read back', () => {
const state = randomRun(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');
});
+31
View File
@@ -67,6 +67,37 @@ 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, []);
+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;
+5
View File
@@ -79,6 +79,10 @@ class FakeElement extends FakeNode {
}
click() { this.dispatch('click'); }
focus() {
if (globalThis.document) globalThis.document.activeElement = this;
}
}
function walk(node, out = []) {
@@ -122,6 +126,7 @@ export function installFakeDom() {
createTextNode: (text) => new FakeText(text),
getElementById: (id) => (id === 'app' ? root : null),
body: new FakeElement('body'),
activeElement: null,
};
const listeners = {};
const window = {
+55 -7
View File
@@ -13,13 +13,41 @@
import { startGame, currentTurn, takeTurn } from '../../src/engine/game.js';
import { createRng } from '../../src/engine/rng.js';
const moneyGain = (resolved) => (resolved.option.effects ?? [])
.filter((e) => e.path === 'money' && e.op === 'add')
.reduce((sum, e) => sum + e.value, 0);
/**
* 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;
};
const relationshipGain = (resolved) => (resolved.option.effects ?? [])
.filter((e) => e.path.startsWith('relationships.'))
.reduce((sum, e) => sum + e.value, 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];
@@ -39,6 +67,19 @@ export const strategies = {
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. */
@@ -62,6 +103,10 @@ export function play(pack, { strategy = 'random', seed = 'run', turns = 200, vis
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)) {
@@ -74,13 +119,16 @@ export function sweep(pack, { seeds = 12, turns = 200 } = {}) {
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, byStrategy };
return { firedEvents, takenOptions, offeredOptions, byStrategy };
}
export const median = (values) =>
+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'}`);
});
+32
View File
@@ -141,6 +141,7 @@ test('the turn record carries the diff and the options the player could not take
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', () => {
@@ -249,3 +250,34 @@ test('scheduled upkeep falls due on its own period, not every turn', () => {
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');
});
+307 -6
View File
@@ -10,9 +10,17 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import { installFakeDom, byClass, firstByClass, byTag } from './fixtures/fake-dom.js';
/** Boot the app fresh against a clean fake DOM. */
async function boot() {
/**
* 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);
@@ -22,6 +30,26 @@ async function boot() {
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();
@@ -112,7 +140,7 @@ test('progress is written to local storage and restored on the next boot', async
enabledOptions(dom.root)[0].click();
firstByClass(dom.root, 'button--next').click();
const saved = JSON.parse(dom.storage.getItem('theladder.save.v1'));
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.
@@ -142,7 +170,7 @@ test('every played turn is logged, separately from the save', async () => {
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'));
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 {
@@ -205,9 +233,10 @@ test('the retrospective summarises the run and is not an ending', async () => {
enabledOptions(dom.root)[0].click();
firstByClass(dom.root, 'button--next').click();
}
firstByClass(dom.root, 'button').click(); // "Wrap up my career"
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/);
@@ -226,7 +255,7 @@ test('the retrospective summarises the run and is not an ending', async () => {
test('a run with nothing notable still gets a retrospective', async () => {
const dom = await boot();
try {
firstByClass(dom.root, 'button').click(); // wrap up on turn one
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 {
@@ -275,3 +304,275 @@ test('wages and rent appear in the ledger on the days they fall due', async () =
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();
}
});
+38
View File
@@ -25,6 +25,23 @@ test('an effect writing an undeclared path is caught', () => {
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 });
@@ -164,3 +181,24 @@ test('a malformed upkeep schedule is caught', () => {
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;
});
+31 -3
View File
@@ -10,6 +10,7 @@
// 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';
@@ -78,7 +79,11 @@ async function collect(entryPath, seen = new Map()) {
const source = await readFile(entryPath, 'utf8');
seen.set(entryPath, null); // reserve the slot before recursing
const specifiers = [...source.matchAll(/^[ \t]*import\s.*?from\s*['"]([^'"]+)['"];?[ \t]*$/gm)]
// `[\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('.'));
@@ -89,6 +94,23 @@ async function collect(entryPath, seen = new Map()) {
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');
@@ -130,15 +152,21 @@ async function build() {
'})();',
].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${script}\n </script>`);
.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`);
console.log(`built ${id(outFile)} — ${modules.size} modules, ${kb} kB, commit ${commit}`);
}
build().catch((error) => {