Compare commits

..
13 Commits
Author SHA1 Message Date
JesseMarkowitzandClaude Opus 5.5 3a4758172d story-to-pack: add the corpora and the catalogue v2/v3
Fourteen corpora split and classified: aesop, bierce, chekhov, holmes,
keefe, lawson, lorimer, maupassant, nobody, plaintales, poe, torchy,
wallingford and winesburg, each with its splitter and the hand-written
groups and pages; catalogue v2 and v3; and the shared splitters
gutenberg_chunks.py, se_split.py and se_build.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rwKTmug58sEsJ72AuWsEi
2026-10-07 06:05:51 -04:00
JesseMarkowitzandClaude Opus 5 e5617b86ba Replace clustering with a catalogue, and hand-write the references to judge it against
The pipeline's embed-and-cluster step is dead, and this commit holds both the
evidence for that and the step proposed to replace it.

Predicaments. Scenes are re-described as "what the person is up against", with
no names, jobs or places, then embedded and clustered (redescribe.py,
topic_words.py, topic_share.py). The pilot chose qwen3:14b over 3b by reading
both side by side. Two defects the pilot exposed are fixed: split.py missed
titles in quotes and a contents subtitle after a dash, so three stories had been
merged into their neighbours, and strip_names.py read New York place names as
people. The corrected corpus is probe/v2 (97 stories, 839 scenes);
carry_summaries.py reuses the 829 unchanged v1 summaries. Topic share fell from
20% to 13% at k=60, short of the pre-registered 10%.

Hand references. Three corpora were read scene by scene and written up by hand,
under the same prompt rules the local models get, as a baseline to judge them
against: O. Henry (probe/v2/claude, 839 scenes, 20 situations), Wharton's
Descent of Man (probe/wharton, 262 scenes, 16 groups) and Jacobs's The Lady of
the Barge (probe/jacobs, 157 scenes, 19 groups). Each has its own README and a
readable page. No inference was used for any of them.

Catalogue. probe/catalogue maps every hand group in the three references onto 36
situation entries, with an answer key per corpus and one recurrence rule applied
to all three. classify.py assigns a scene one entry or none, leave-one-corpus-
out; score.py checks it against the key, with a self-test on random labels.

Why clustering is out: hand-written predicaments, embedded and clustered exactly
as the model's were, agree with the hand grouping at ARI 0.05 — no better than
the 14B text's 0.07. Better rewriting cannot rescue it. Embeddings cannot even
shortlist: the hand label is the nearest entry 13% of the time and in the top 8
half the time.

The classification runs are not here. The dev and test runs are pre-registered
in probe/catalogue/README.md with the bar set beforehand, and are blocked on the
inference host, whose GPU has fallen off the PCIe bus three times. The 30-scene
partial output in out/ is not a result.

Review page. The situation review is now a browser page rather than JSON edited
by hand (review_page.py, review_page_logic.cjs with Node tests, format schema
v2). It has never been rendered in a real browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014BygvsUXV9eU6oHkTCkKZ1
2026-09-20 17:19:42 -04:00
JesseMarkowitzandClaude Opus 5 fa3769d0fe Add story-to-pack research and a structured situation review
Research toward building a content pack from a story corpus, kept on its own
branch and independent of the game. Records the selection experiments against
blind labels, and settles selection as gate G2 followed by a human review:
review.py writes REVIEW.md and a review.json form, apply_review.py checks the
filled form and writes situations.json for the next stage.

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

Found in the first two human sessions of this work:

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxdJFbLq1rBKsUeticGV1g
2026-09-12 21:03:50 -04:00
JesseMarkowitzandClaude Opus 5 4957c24e31 Stamp the build into the output, not into a tracked file
The stamping scheme was wrong in a way worth recording. tools/stamp.js rewrote
src/build-info.js before every serve and build, so after each commit the
committed stamp named the *previous* commit — as it does right now, reading
76072ed+ while HEAD is 5c1784d — and the next serve rewrote it and dirtied the
tree again. A generated value does not belong in a tracked file if anything
routinely regenerates it.

src/build-info.js is now permanent and reads `commit: 'dev'`. tools/build.js
substitutes the real commit into the bundled output only, and fails loudly if
the substitution finds nothing to replace. So dist/theladder.html names the
commit that produced it, running from source honestly reads "0.3.0 · dev", and
the working tree never churns. tools/stamp.js is gone and `npm run serve` is a
plain static server again.

Also here, for picking this up later: a "Where things stand" section in the
README with the five open questions in the order they are likely to matter —
whether money stops mattering late, the negotiation gate that four early options
sit behind, the feedback widget nobody uses, how thin Dispatch is next to the
mailroom, and third-tier versus second-pack.

121 tests. docs/DECISIONS.md §25 corrected to describe what the code now does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VMSFHyVPitUoosW5wyEADj
2026-09-10 06:12:58 -04:00
JesseMarkowitzandClaude Opus 5 5c1784d990 Fix an inescapable debt trap the playtest logs exposed
A 182-turn session spent 30% of its turns below zero, went negative on turn 18
and never recovered, and chose the same hard-times option — "take every shift
going" — thirty-seven times.

The cause was a tuning change made without re-deriving what it meant. Rent went
from 385 to 455 while chasing a different problem, which took the mailroom
baseline from the designed -$3/day to -$13/day. Worse, at the debt that run
accumulated the weekly service charge came to $180 against an escape option
worth $120. The hole was inescapable by arithmetic, whatever the player did.

Nothing caught it. Every archetype either optimised money or got promoted out of
the problem before it bit, and the dominance test passed because rent_bounced
was only 17% of turns.

Fixes: mailroom wages 980 -> 1120, restoring the -$3/day baseline; the escape
option 120 -> 220, so it is worth more than a week's rent; rent_bounced from
weight 5000 with no cooldown to 900 on a cooldown of 3, because being broke
should colour a run rather than replace it; debt_collector from weight 25 to 45,
since at 25 it appeared six times in 262 turns and debt became a ratchet.

Three guards so this class of bug cannot recur quietly.

The baseline is now asserted directly: a test sums the pack's own upkeep over
twenty fortnights and requires the mailroom to net between -90 and +10, and
Dispatch to be better but not so much better that money stops mattering. That
would have failed the moment rent changed. Outcome tests over simulated play are
a slow and noisy way to detect a number that is simply wrong.

A `lifer` archetype refuses any option that would change stage — generically, by
looking for an effect on `stage` — and otherwise plays for people. It reproduces
the session that found this; no other archetype can.

And no archetype may spend more than a quarter of a run below zero. Hard times
is a state a player passes through; living in it is the failure mode.

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

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

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

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

The whole game is now playable on Enter. Every screen marks one control and the
app focuses it after each render: the first *available* option while choosing,
the next-turn button once the result is in, "keep going" on the retrospective.
Tab and Shift+Tab reach the other options. Locked options needed no special
handling — the browser's tab order already skips disabled controls, so they stay
visible without being in the way.

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

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

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

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

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

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

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

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

`npm run analyze <exported-log.json>` now reports turns against distinct
situations, how often each recurred, which options were most used, which locked
gates players kept meeting, and anything they typed. The retrospective shows the
player-facing version.

It found a regression in this very change on its first run. Dispatch started
with eight events against the mailroom's nineteen, so its two floor events were
46% of every promoted run — one every three turns. Promotion was moving the
player into *thinner* content. Three more events and a weight rebalance took the
top two to 24.5%, and the heaviest recurrence from every ~3 turns to every ~5.
A test now holds that line.

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VMSFHyVPitUoosW5wyEADj
2026-09-10 05:05:01 -04:00
JesseMarkowitzandClaude Opus 5 e482f07157 Add the interface, then rebuild the economy around the first playtest
The MVP is playable: a turn screen, a career retrospective, an optional
feedback widget, local-storage autosave with file export/import, and a build
that flattens everything into one self-contained HTML file.

A turn has two phases — the scenario and its options, then what the choice did
and, separately, what the day cost regardless. Numbers that move without the
player seeing why are most of what makes them meaningless. Locked options are
shown greyed with the requirement spelled out rather than hidden, so a player
can see the door they cannot open yet.

The interface is named entirely by the pack. `pack.display` maps a state path to
a label, a format and an order, which is why the header reads "Standing" for a
path called `reputation` and why debt disappears while it is zero.

The first playtest then found three things, all of them fair.

*"Wasn't clear what daily drain was for."* The ledger was labelling changes by
the path that moved — "Money −$18" — when the upkeep entry that caused them had
a perfectly good name. It now says "Coffee, transit, lunch". The labels were in
the data the whole time and never reached the screen.

*"Should probably pay rent weekly / get paid every other week… would be good to
have day of week and week # shown."* Packs can now declare a calendar, and the
engine derives the day name, cycle and index from the turn number before the
event is drawn — so content can require a Friday and the header can say
"Thursday · Week 2 · Day 12". Upkeep entries take `every` and `offset`, making
wages fall on alternate Fridays and rent every Monday. The turn screen carries
a diary line — "Wages tomorrow · Rent in 4 days" — computed generically from
whatever a pack schedules.

*"Always in debt — could never get ahead."* The old economy bled $25 a day
regardless of play. It is now roughly break-even at baseline, with a
`spare_shift` event that appears *because* you are broke: the way out of a hole
should be visible from inside it. Across archetypes a careful player ends around
$1,400, an unplanned one treads water, and a careless one sinks into real debt.

That retune broke the coverage test, usefully. Random play stopped reaching hard
times at all, which made the entire debt branch look dead — it was not, since
random play is not a plausible player. Coverage is now the union across five
archetypes, including one that is broke *and* well-liked, because the "ask a
friend for money" options sit in a state no single-axis strategy reaches.

Not covered by any of this: styling and dark mode, which only a human with a
browser can check. This machine has none that can render a page.

109 tests. Reasoning in docs/DECISIONS.md §14-21.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VMSFHyVPitUoosW5wyEADj
2026-09-10 04:44:49 -04:00
JesseMarkowitzandClaude Opus 5 1d4ac0fdbd Add the Corporate Ladder pack, and two engine features it needed
Seventeen events across the mailroom: ordinary days, Marlene Hobb and Trevor
Pike, a misrouted envelope that can be delivered or kept, and a hard-times
branch that fires on negative money and always leaves an ungated way out.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VMSFHyVPitUoosW5wyEADj
2026-09-09 21:58:20 -04:00
JesseMarkowitzandClaude Opus 5 cc59b81155 Add the engine core: path-addressed state, data-driven turns
The planning docs commit to one thing above all: adding content must never
require touching the engine. This lays the foundation that makes that true.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VMSFHyVPitUoosW5wyEADj
2026-09-09 21:43:46 -04:00
864 changed files with 474547 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
node_modules/
dist/
# Exported playtest logs are somebody's real session, including whatever
# they typed into the feedback box. Kept local; see logs/README.md.
logs/*
!logs/README.md
*.log
.DS_Store
+247
View File
@@ -0,0 +1,247 @@
# The Ladder
A turn-based career simulation game that runs entirely in the browser. You
start at the bottom of an organisation and play turns — a day at a time —
making choices that move your money, your skills and your standing with the
people around you. There is no win state and no game over: a bad run means
worse options, not an ending, and you can stop whenever you like and read the
retrospective.
The engine knows nothing about offices, mailrooms or bosses. A **setting is a
content pack** — characters, events, choices, stat names and tuning, all data.
Two ship: **Corporate Ladder**, a modern office and a mailroom, and **The
Frontier**, a freight yard at the end of a stage line. They share every line of
engine code and no content at all.
## Status
Engine, interface and two content packs, all built and tested, and played in a
real browser.
| Piece | State |
| --- | --- |
| Path-addressed state, conditions, effects | done |
| Seeded RNG, save/resume | done |
| Event selection, option gating | done |
| Turn loop, per-turn upkeep | done |
| Content validator + reachability tests | done |
| Corporate Ladder pack — 31 events, two tiers | done |
| Turn screen, retrospective, feedback widget | done |
| Local-storage save, file export/import | done |
| Single-file build | done |
| Calendar, scheduled wages and rent | done |
| Promotion from the mailroom to Dispatch | done |
| Repetition analytics (`npm run analyze`) | done |
| Version and build shown on screen | done |
| Keyboard play (Enter / Tab) | done |
| Second content pack — The Frontier, 29 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 yes, first playtest 2026-09-09; **The Frontier not yet** |
The first playtest confirmed the interface, saves and the exported log all work
in a real browser. Its findings — an unexplained daily drain, no sense of the
week, and an economy nobody could get ahead in — are what the calendar and the
retuned economy are for.
Note that the development machine has no browser that can render a page, so the
UI tests drive the real modules against a minimal fake DOM. That covers wiring,
not layout: **styling and dark mode are only ever verified by a human opening
it.**
## Playing it
```sh
npm run serve # then open http://localhost:8080
```
The game plays from the keyboard: Enter takes the focused option, Enter again
moves to the next day, and Tab / Shift+Tab pick a different option. The top of
every screen names the game, the pack, your current title, and the build —
`0.3.0 · dev` when running from source, and the commit it came from when built.
A server is needed during development because browsers refuse ES module imports
over `file://`. If port 8080 is taken, run `python3 -m http.server <port>`
instead.
To hand the game to someone else:
```sh
npm run build # writes dist/theladder.html
```
That is one self-contained file with the stylesheet and every module inlined.
It plays by double-clicking it — no server, no network, nothing installed.
```sh
npm test # 149 tests: engine, both packs, UI wiring, and the build
```
Tests are in three layers. `test/conformance.test.js` runs over **every pack in
the registry** and asserts only what is true of a pack because it is a pack —
validity, no dead content, the declared economic baseline, variety, replay from
seed, a way out of every event. `test/content.test.js` and
`test/frontier.test.js` hold each pack to its own tuning. The engine suite runs
against a fixture pack, so tuning a setting can never break it.
To see what a playtest actually did — turns against distinct situations, how
often each recurred, which locked gates players kept meeting, and anything they
typed into the feedback box:
```sh
npm run analyze # the newest export in logs/
npm run analyze -- <file> # a specific one
```
Drop exports into `logs/`. They are gitignored — a log is a record of what a
real person did, including anything they typed into the feedback box.
There are no runtime dependencies and no build step for development. Node is
used only to run the tests, the single-file build, and the log analyser.
## Where things stand
The MVP is complete and has survived three playtests, and a second setting now
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.
Open questions, in the order they are likely to matter:
- **The Frontier has never been played by a human.** Its economy is asserted by
the conformance suite and exercised by six archetypes, and that is exactly the
evidence that was not enough the last two times. It needs a session before its
numbers are believed.
- **Standing costs money to keep, and the Frontier's version is unplayed.**
Each setting has a `lifestyle` tier bought on the second rung and charged for
every cycle after, a standing penalty for lagging behind what the job expects,
and a charge levied by standing itself that no purchase ever ends. Corporate
Ladder's has had one 96-turn session: both purchases were found and taken, and
the standing pressure worked, but the run arrived at the top with nothing left
to spend on — which is where the never-stopping charge came from. The Frontier
carries the same system at its own scale and has never been in front of anyone.
See DECISIONS #36.
- **Dispatch is eleven events against the mailroom's nineteen.** It passes the
variety guard, but it has had far less play than the mailroom. Waits on more
playtesting.
- **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.
Waits until the Frontier has been played.
Settled, and not open: Corporate Ladder's negotiation gate stays as it is, and
the feedback widget stays as it is (DECISIONS #35). Money mattering late is
built rather than open (DECISIONS #36).
## Layout
```
src/engine/ the game engine — imports nothing from content/, or from src/ui/
paths.js dotted-path get/set over state
conditions.js generic predicates: { path, op, value }, all/any/not
effects.js generic mutations: add/set/push/remove, with clamping
state.js pack -> schema and initial state
select.js which event fires this turn, which options are available
game.js the turn loop
rng.js seeded, serializable RNG
validate.js load-time content checking
src/ui/ screens, formatting and the app controller
app.js the only module that knows there is more than one pack
screens/ the turn screen, the retrospective, the setting picker
format.js state paths and conditions rendered as English
src/io/ local-storage save (one slot per pack), export/import, feedback log
content/
index.js the pack registry — the only list of settings
corporateladder/ a modern office and a mailroom
frontier/ a freight yard at the end of a stage line
tools/build.js flattens everything into one self-contained HTML file
test/ engine tests against a fixture pack; conformance, per-pack, UI
docs/ DECISIONS.md — why things are the way they are
Planning/ the original design documents
```
**The rule that keeps this honest: nothing in `src/engine/` may import from
`content/`.** The engine names no stat, no character and no setting of its own.
## How content works
A pack declares what state exists and what it starts at, the relationship stats
every character carries, the characters, and the events. Everything else falls
out of that.
```js
export default {
id: 'corporateladder',
startingStage: 'mailroom',
state: {
'money': { initial: 1200, min: -5000, max: 1000000 },
'skills.negotiation': { initial: 1, min: 0, max: 100 },
'flags.took_loan': { initial: false },
},
// Expanded per character into relationships.<id>.<stat>, so adding a
// character adds its relationship state automatically.
relationshipStats: {
likes_you: { initial: 50, min: 0, max: 100 },
fears_you: { initial: 50, min: 0, max: 100 },
wants_to_help_you: { initial: 50, min: 0, max: 100 },
},
characters: [
{ id: 'boss', name: '...', role_type: 'boss', description: '...' },
],
events: [
{
id: 'mail_run',
stage: 'mailroom',
weight: 100, // relative likelihood among eligible events
description: 'A cart of mail and two hours to move it.',
options: [
{
id: 'hustle',
label: 'Get it done fast.',
requires: [{ path: 'skills.negotiation', op: '>=', value: 3 }],
effects: [
{ path: 'money', op: 'add', value: 40 },
{ path: 'relationships.boss.likes_you', op: 'add', value: 3 },
],
},
],
},
],
};
```
An event may also carry `once: true`, a `cooldown` in turns, and `requires`
conditions of its own. There is no separate notion of a random event: every
turn draws from the whole eligible pool by weight.
Conditions may read engine bookkeeping (`turn`, `stage`, `seen.<eventId>`,
`lastSeen.<eventId>`) as well as pack state. Effects may not write it.
A pack also declares `upkeep` — a list of `{ label, requires?, effects }`
applied at the end of a turn, after the choice. Adding `every` and `offset`
makes an entry periodic rather than daily: Corporate Ladder pays wages on
alternate Fridays (`{ every: 14, offset: 4 }`), charges rent every Monday, and
adds a loan service charge that steps up as the debt grows.
A pack may also declare a `calendar`:
```js
calendar: {
cycleLength: 7,
cycleName: 'Week',
unitNames: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'],
},
```
The engine derives `calendar.index`, `calendar.cycle` and `calendar.name` from
the turn number before each event is drawn, so content can require a Friday and
the interface can title the turn. Declare no calendar and there is no week.
Run `validatePack(pack)` when loading in development — it catches undeclared
paths, unknown characters, unusable ids, unreachable events and stages with no
fallback event, all of which otherwise fail silently mid-playthrough.
+30
View File
@@ -0,0 +1,30 @@
// The mailroom, as staffed.
//
// `description` is pure colour — the engine never reads it. What the engine
// does read is `startingRelationship`, where a character departs from the
// pack's defaults: nobody fears a new hire, and Trevor started two weeks
// earlier and has priced that in.
export const characters = [
{
id: 'marlene',
name: 'Marlene Hobb',
title: 'Mailroom Supervisor',
role_type: 'boss',
description:
'Has worked here nineteen years. Refers to the building as "the plant." ' +
'Keeps a laminated card of the org chart, updated in pen.',
},
{
id: 'trevor',
name: 'Trevor Pike',
title: 'Mail Associate II',
role_type: 'rival',
description:
'Arrived two weeks before you and has never once let that go. ' +
'Photographs his lunch.',
startingRelationship: { likes_you: 35, wants_to_help_you: 25 },
},
];
export default characters;
+549
View File
@@ -0,0 +1,549 @@
// Dispatch: the second tier.
//
// Same mechanics, different pressure. The mailroom's problem was money; up here
// the money is fine and the problem is that other people's days now depend on
// yours — including the mailroom's, which you used to be.
//
// The first two events carry no conditions, no cooldown and no `once`, so this
// stage has a floor of its own. Trevor's two chains are mutually exclusive
// branches on whether you put him up for your old job.
export const dispatch = [
{
id: 'dispatch_first_day',
stage: 'dispatch',
weight: 99999,
once: true,
description:
'The Dispatch desk has three phones and a laminated card taped to it, ' +
'listing what to do when each one rings. The handwriting is Marlene\'s.',
options: [
{
id: 'read_card',
label: 'Read the card.',
result: 'It is nineteen years of somebody else\'s hard-won afternoons, in eleven lines.',
effects: [
{ path: 'skills.logistics', op: 'add', value: 3 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: 2 },
{ path: 'notable', op: 'push', value: 'Found Marlene\'s handwriting taped to the Dispatch desk.' },
],
},
{
id: 'answer_everything',
label: 'Answer all three phones and work it out.',
result: 'By four o\'clock you have worked out what six of the eleven lines meant.',
effects: [
{ path: 'skills.logistics', op: 'add', value: 2 },
{ path: 'skills.negotiation', op: 'add', value: 2 },
{ path: 'reputation', op: 'add', value: -2 },
],
},
{
id: 'take_it_slow',
label: 'Let the phones ring and learn the board first.',
result: 'Two callers give up. The board, however, you now understand.',
effects: [
{ path: 'skills.logistics', op: 'add', value: 4 },
{ path: 'reputation', op: 'add', value: -3 },
],
},
],
},
{
id: 'routing_board',
stage: 'dispatch',
weight: 70,
description:
'Two runs are booked against the same van at the same hour. Both floors ' +
'have written "URGENT" on the docket, one of them twice.',
options: [
{
id: 'triage',
label: 'Work out which one is actually urgent.',
result: 'Neither. You send the van to a third job nobody had flagged at all.',
effects: [
{ path: 'skills.logistics', op: 'add', value: 3 },
{ path: 'reputation', op: 'add', value: 3 },
],
},
{
id: 'split_van',
label: 'Split the run and make both floors wait half as long.',
result: 'Both floors complain about the half they waited. Neither escalates it.',
effects: [
{ path: 'skills.logistics', op: 'add', value: 1 },
{ path: 'reputation', op: 'add', value: 1 },
],
},
{
id: 'first_come',
label: 'Whoever wrote URGENT twice can go second.',
result: 'They notice. They are the sort of floor that notices.',
effects: [
{ path: 'reputation', op: 'add', value: -2 },
{ path: 'skills.logistics', op: 'add', value: 1 },
],
},
],
},
{
id: 'late_freight',
stage: 'dispatch',
weight: 55,
description:
'A freight job has come in past the cutoff. Taking it means somebody stays, ' +
'and the somebody is either you or a person you now have the standing to ask.',
options: [
{
id: 'stay_yourself',
label: 'Stay and do it yourself.',
result: 'It takes ninety minutes. Nobody sees it, which is most of the job now.',
effects: [
{ path: 'money', op: 'add', value: 90 },
{ path: 'skills.logistics', op: 'add', value: 1 },
{ path: 'reputation', op: 'add', value: 2 },
],
},
{
id: 'ask_the_floor',
label: 'Ask the mailroom to cover it.',
requires: [{ path: 'skills.negotiation', op: '>=', value: 25 }],
result:
'They cover it. You remember precisely how this felt from the other end, ' +
'and ask anyway.',
effects: [
{ path: 'skills.negotiation', op: 'add', value: 2 },
{ path: 'reputation', op: 'add', value: -1 },
{ path: 'relationships.trevor.likes_you', op: 'add', value: -4 },
{ path: 'relationships.trevor.fears_you', op: 'add', value: 3 },
],
},
{
id: 'bounce_it',
label: 'Send it back as past cutoff.',
result: 'The cutoff exists. You have simply never seen anyone use it before.',
effects: [
{ path: 'reputation', op: 'add', value: -3 },
{ path: 'skills.negotiation', op: 'add', value: 1 },
],
},
],
},
{
id: 'mailroom_calls',
stage: 'dispatch',
weight: 60,
cooldown: 4,
description:
'The mailroom is short-handed and the bins are backing up. From up here it ' +
'is a number on a board. You know exactly what it is down there.',
options: [
{
id: 'go_down',
label: 'Go down and sort bins for an hour.',
result:
'You are a coordinator sorting bins. Two people find this funny and one ' +
'finds it reassuring.',
effects: [
{ path: 'skills.logistics', op: 'add', value: 2 },
{ path: 'reputation', op: 'add', value: 3 },
{ path: 'relationships.trevor.likes_you', op: 'add', value: 5 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: 4 },
],
},
{
id: 'send_help',
label: 'Pull someone off a run to help them.',
result: 'The run is late. The bins are not. You will hear about the run.',
effects: [
{ path: 'skills.logistics', op: 'add', value: 1 },
{ path: 'reputation', op: 'add', value: -1 },
{ path: 'relationships.trevor.wants_to_help_you', op: 'add', value: 4 },
],
},
{
id: 'leave_them',
label: 'It is a number on a board.',
result: 'The number goes down by itself, eventually, the way it always did.',
effects: [
{ path: 'relationships.trevor.likes_you', op: 'add', value: -5 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: -3 },
],
},
],
},
{
id: 'trevor_promoted',
stage: 'dispatch',
weight: 55,
cooldown: 5,
requires: [{ path: 'flags.recommended_trevor', op: '==', value: true }],
description:
'Trevor runs the mailroom now and has opinions about how it was run before. ' +
'Some of the opinions are about you. All of them are correct.',
options: [
{
id: 'concede',
label: 'Tell him he is right.',
result: 'He is so unprepared for this that he agrees with you about something else.',
effects: [
{ path: 'relationships.trevor.likes_you', op: 'add', value: 6 },
{ path: 'relationships.trevor.wants_to_help_you', op: 'add', value: 4 },
{ path: 'skills.negotiation', op: 'add', value: 1 },
],
},
{
id: 'back_him',
label: 'Back him upstairs when it matters.',
requires: [{ path: 'reputation', op: '>=', value: 55 }],
result: 'His changes go through. He knows exactly why they went through.',
effects: [
{ path: 'relationships.trevor.wants_to_help_you', op: 'add', value: 10 },
{ path: 'reputation', op: 'add', value: 2 },
{ path: 'notable', op: 'push', value: 'Backed Trevor\'s changes to the mailroom.' },
],
},
{
id: 'pull_rank',
label: 'Remind him whose desk the board is on.',
result: 'It works. It works the way a door works when you take it off its hinges.',
effects: [
{ path: 'relationships.trevor.fears_you', op: 'add', value: 8 },
{ path: 'relationships.trevor.likes_you', op: 'add', value: -8 },
{ path: 'reputation', op: 'add', value: -2 },
],
},
],
},
{
id: 'trevor_passed_over',
stage: 'dispatch',
weight: 55,
cooldown: 5,
requires: [{ path: 'flags.recommended_trevor', op: '==', value: false }],
description:
'Trevor is still in the mailroom and has started addressing you by your ' +
'full title. He does it in front of people. He does it kindly, which is worse.',
options: [
{
id: 'address_it',
label: 'Say the thing out loud.',
requires: [{ path: 'skills.negotiation', op: '>=', value: 25 }],
result:
'He denies it, then stops doing it. Both of you agree never to refer ' +
'to the conversation again.',
effects: [
{ path: 'skills.negotiation', op: 'add', value: 3 },
{ path: 'relationships.trevor.likes_you', op: 'add', value: 6 },
{ path: 'relationships.trevor.fears_you', op: 'add', value: 2 },
],
},
{
id: 'find_him_something',
label: 'Find him something worth doing.',
result:
'You give him the freight schedule, which is a real job. He is careful ' +
'with it, and the title stops.',
effects: [
{ path: 'relationships.trevor.likes_you', op: 'add', value: 8 },
{ path: 'relationships.trevor.wants_to_help_you', op: 'add', value: 6 },
{ path: 'reputation', op: 'add', value: 2 },
{ path: 'notable', op: 'push', value: 'Found Trevor something worth doing.' },
],
},
{
id: 'let_it_run',
label: 'Let him have it.',
result: 'He has it. It does not appear to be making him any happier.',
effects: [
{ path: 'relationships.trevor.likes_you', op: 'add', value: -3 },
{ path: 'reputation', op: 'add', value: -1 },
],
},
],
},
{
id: 'marlene_upstairs',
stage: 'dispatch',
weight: 45,
cooldown: 6,
description:
'Marlene comes up with a docket she could have sent. She looks at the three ' +
'phones for slightly too long before she says anything.',
options: [
{
id: 'ask_her',
label: 'Ask whether she ever wanted this desk.',
result:
'She says nobody offered, which is not the same as no. Then she asks ' +
'whether the third-floor bins have gone up.',
effects: [
{ path: 'relationships.marlene.likes_you', op: 'add', value: 6 },
{ path: 'relationships.marlene.wants_to_help_you', op: 'add', value: 4 },
{ path: 'notable', op: 'push', value: 'Asked Marlene whether she ever wanted the Dispatch desk.' },
],
},
{
id: 'take_the_docket',
label: 'Take the docket and get on with it.',
result: 'She goes back down. The card is still taped to the desk.',
effects: [{ path: 'skills.logistics', op: 'add', value: 1 }],
},
{
id: 'put_her_up',
label: 'Put her name up for the next post that opens.',
requires: [{ path: 'reputation', op: '>=', value: 60 }],
result:
'She does not thank you. Two days later there is a second card taped ' +
'to the desk, with the four lines she had left out.',
effects: [
{ path: 'relationships.marlene.likes_you', op: 'add', value: 10 },
{ path: 'relationships.marlene.wants_to_help_you', op: 'add', value: 10 },
{ path: 'reputation', op: 'add', value: 3 },
{ path: 'notable', op: 'push', value: 'Put Marlene up for the next post that opened.' },
],
},
],
},
{
id: 'dispatch_overtime',
stage: 'dispatch',
weight: 40,
cooldown: 6,
description:
'The quarterly stocktake needs a coordinator on site for the weekend. It ' +
'pays coordinator rates, which are new to you.',
options: [
{
id: 'take_it',
label: 'Take the weekend.',
result: 'Two days, and the rate is genuinely different. You check the slip twice.',
effects: [
{ path: 'money', op: 'add', value: 320 },
{ path: 'skills.logistics', op: 'add', value: 2 },
{ path: 'reputation', op: 'add', value: 2 },
],
},
{
id: 'send_trevor',
label: 'Offer it to the mailroom instead.',
result:
'Trevor takes it. He has never been paid that for a weekend and it ' +
'shows on Monday.',
effects: [
{ path: 'relationships.trevor.likes_you', op: 'add', value: 8 },
{ path: 'relationships.trevor.wants_to_help_you', op: 'add', value: 5 },
{ path: 'reputation', op: 'add', value: 2 },
],
},
{
id: 'pass',
label: 'Pass.',
result: 'Somebody from Finance does it and gets it wrong in an interesting way.',
effects: [],
},
],
},
{
id: 'van_breakdown',
stage: 'dispatch',
weight: 55,
cooldown: 4,
description:
'The long-wheelbase van is off the road until Thursday. Everything booked ' +
'against it is still booked against it, and the board does not know that yet.',
options: [
{
id: 'rebuild_board',
label: 'Rebuild the day around what you have.',
result:
'Four jobs move, one is dropped, and nobody upstairs ever learns the ' +
'van was off the road.',
effects: [
{ path: 'skills.logistics', op: 'add', value: 4 },
{ path: 'reputation', op: 'add', value: 3 },
],
},
{
id: 'hire_one',
label: 'Hire one in for the day.',
requires: [{ path: 'money', op: '>=', value: 200 }],
result: 'It costs what it costs. The day runs as though nothing happened.',
effects: [
{ path: 'money', op: 'add', value: -180 },
{ path: 'reputation', op: 'add', value: 2 },
{ path: 'skills.negotiation', op: 'add', value: 1 },
],
},
{
id: 'tell_them',
label: 'Tell every floor their job is late and why.',
result:
'Two floors are fine about it. The third writes an email with a ' +
'subject line that uses the word "again".',
effects: [
{ path: 'reputation', op: 'add', value: -2 },
{ path: 'skills.negotiation', op: 'add', value: 2 },
],
},
],
},
{
id: 'new_starter',
stage: 'dispatch',
weight: 50,
cooldown: 5,
description:
'There is somebody new in the mailroom. They have been handed a laminated ' +
'card with the org chart on it, and their name is not on it either.',
options: [
{
id: 'walk_them_through',
label: 'Go down and walk them through it.',
result:
'You explain the card. Then you explain it again, because that is how ' +
'it works and you know that now.',
effects: [
{ path: 'reputation', op: 'add', value: 3 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: 4 },
{ path: 'skills.negotiation', op: 'add', value: 1 },
{ path: 'notable', op: 'push', value: 'Walked a new starter through the org chart.' },
],
},
{
id: 'give_advice',
label: 'Tell them what you wish somebody had told you.',
requires: [{ path: 'skills.negotiation', op: '>=', value: 30 }],
result:
'They write it down, which nobody did for you. Whether it helps is ' +
'not something you will find out.',
effects: [
{ path: 'reputation', op: 'add', value: 2 },
{ path: 'skills.negotiation', op: 'add', value: 2 },
{ path: 'notable', op: 'push', value: 'Told a new starter what nobody had told you.' },
],
},
{
id: 'leave_them_to_it',
label: 'Leave them to work it out. Everyone else did.',
result: 'They work it out. It takes them eleven days.',
effects: [{ path: 'reputation', op: 'add', value: -1 }],
},
],
},
{
id: 'finance_query',
stage: 'dispatch',
weight: 45,
cooldown: 6,
description:
'Finance has queried the courier spend. The query is three lines long and ' +
'took somebody a whole morning to write.',
options: [
{
id: 'answer_properly',
label: 'Answer it properly, with the numbers.',
result:
'The numbers are fine. Finance says so in one line, which took them ' +
'a whole afternoon.',
effects: [
{ path: 'skills.logistics', op: 'add', value: 2 },
{ path: 'reputation', op: 'add', value: 4 },
],
},
{
id: 'push_back',
label: 'Ask them what they think the alternative is.',
requires: [{ path: 'skills.negotiation', op: '>=', value: 35 }],
result:
'There is no alternative. Establishing this in writing turns out to ' +
'be worth more than the money was.',
effects: [
{ path: 'skills.negotiation', op: 'add', value: 3 },
{ path: 'reputation', op: 'add', value: 5 },
{ path: 'notable', op: 'push', value: 'Won an argument with Finance in writing.' },
],
},
{
id: 'cut_the_spend',
label: 'Cut the spend and let the service slip.',
result: 'Finance is delighted. Three floors are not, and one of them notices why.',
effects: [
{ path: 'money', op: 'add', value: 60 },
{ path: 'reputation', op: 'add', value: -4 },
],
},
],
},
{
// The cost of standing, offered rather than imposed. Buying nothing is
// always playable and always cheaper; it is Dispatch's weekly "looking the
// part" charge that makes it a real decision instead of a shop.
id: 'the_car_lot',
stage: 'dispatch',
weight: 45,
cooldown: 6,
requires: [{ path: 'lifestyle', op: '<', value: 2 }],
description:
'Marlene mentions, not quite to you, that the coordinator before last ' +
'drove something with a roof rack, and that people noticed when he ' +
'stopped. There is a lot on Ninth with a sign about payments.',
options: [
{
id: 'buy_the_car',
label: 'Put money down on the car.',
requires: [
{ path: 'lifestyle', op: '==', value: 0 },
{ path: 'money', op: '>=', value: 450 },
],
result:
'It is four years old and the radio only gets one station, and on ' +
'Monday two people who have never spoken to you ask about it.',
effects: [
{ path: 'money', op: 'add', value: -450 },
{ path: 'lifestyle', op: 'set', value: 1 },
{ path: 'reputation', op: 'add', value: 3 },
{ path: 'notable', op: 'push', value: 'Bought a car with a roof rack.' },
],
},
{
id: 'trade_up',
label: 'Trade up, and take the lease on the better side of the park.',
requires: [
{ path: 'lifestyle', op: '==', value: 1 },
{ path: 'money', op: '>=', value: 900 },
],
result:
'The address is two stops closer and four hundred a month dearer. ' +
'Nobody says anything about it, which is the entire point of it.',
effects: [
{ path: 'money', op: 'add', value: -900 },
{ path: 'lifestyle', op: 'set', value: 2 },
{ path: 'reputation', op: 'add', value: 5 },
{ path: 'notable', op: 'push', value: 'Moved to the better side of the park.' },
],
},
{
id: 'keep_the_bus_pass',
label: 'Keep the bus pass. The bus is fine.',
result:
'The bus is fine. You read two chapters a day on it, which is two ' +
'more than anybody on Ninth manages.',
effects: [{ path: 'skills.logistics', op: 'add', value: 1 }],
},
],
},
];
export default dispatch;
@@ -0,0 +1,136 @@
// Running out of money.
//
// There is no game over here and no separate failure mode in the engine — this
// is an ordinary event whose conditions happen to be "your money is negative",
// carrying enough weight to crowd out an ordinary day. Every route out costs
// something other than money, and every one of them leaves you still playing.
//
// Both events always offer at least one ungated option, so a player with no
// skills and no friends is never stuck.
export const hardTimes = [
{
id: 'rent_bounced',
// No stage: rent is due wherever you work, and the branch has to follow
// the player up rather than vanishing on promotion.
//
// Weighted heavily but not overwhelmingly, and on a cooldown: being broke
// should colour a run, not replace it. At weight 5000 with no cooldown a
// player who could not climb out saw this and almost nothing else, 45
// times in one session.
weight: 900,
cooldown: 3,
requires: [{ path: 'money', op: '<', value: 0 }],
description:
'Rent came out and did not clear. The letter uses the word ' +
'"unfortunately" four times and encloses a form.',
options: [
{
id: 'take_loan',
label: 'Take the loan the letter suggests.',
// They stop lending eventually, which is when the spiral gets real:
// past this, the only ways out cost standing rather than interest.
requires: [{ path: 'debt', op: '<', value: 2000 }],
result:
'The money arrives the same afternoon, which tells you something about ' +
'the rate.',
effects: [
{ path: 'money', op: 'add', value: 500 },
{ path: 'debt', op: 'add', value: 500 },
{ path: 'flags.took_loan', op: 'set', value: true },
{ path: 'reputation', op: 'add', value: -2 },
{ path: 'notable', op: 'push', value: 'Took the loan.' },
],
},
{
id: 'ask_trevor',
label: 'Ask Trevor for a float.',
requires: [{ path: 'relationships.trevor.likes_you', op: '>=', value: 40 }],
result:
'He lends it without making a thing of it, and from then on he is a ' +
'man who lent you money.',
effects: [
{ path: 'money', op: 'add', value: 150 },
{ path: 'reputation', op: 'add', value: -3 },
{ path: 'relationships.trevor.fears_you', op: 'add', value: -8 },
{ path: 'relationships.trevor.wants_to_help_you', op: 'add', value: -5 },
{ path: 'notable', op: 'push', value: 'Borrowed money from Trevor.' },
],
},
{
id: 'ask_marlene',
label: 'Ask Marlene for an advance.',
requires: [{ path: 'relationships.marlene.wants_to_help_you', op: '>=', value: 55 }],
result:
'She processes it herself and does not put it through the system. ' +
'Neither of you mentions it again.',
effects: [
{ path: 'money', op: 'add', value: 200 },
{ path: 'relationships.marlene.wants_to_help_you', op: 'add', value: -10 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: -3 },
],
},
{
id: 'extra_shifts',
label: 'Take every shift going.',
result:
'You do eleven days without one off. The bins stop being individual ' +
'bins after a while.',
// Has to be worth more than a week of rent, or it is not a way out of
// the hole — it is the hole with extra steps.
effects: [
{ path: 'money', op: 'add', value: 220 },
{ path: 'skills.logistics', op: 'add', value: 2 },
{ path: 'reputation', op: 'add', value: -1 },
],
},
],
},
{
id: 'debt_collector',
// The chance to pay down has to come round often enough to matter; at
// weight 25 it appeared six times in 262 turns and debt became a ratchet.
weight: 45,
cooldown: 4,
requires: [{ path: 'debt', op: '>', value: 0 }],
description:
'A man from the lender calls the mailroom phone. Marlene answers it. She ' +
'hands it to you without expression and then, notably, without leaving.',
options: [
{
id: 'pay_chunk',
label: 'Pay a real chunk of it down.',
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: 'reputation', op: 'add', value: 1 },
],
},
{
id: 'pay_minimum',
label: 'Pay what you can.',
requires: [{ path: 'money', op: '>=', value: 60 }],
result: 'Sixty dollars, fifty of which is the loan.',
effects: [
{ path: 'money', op: 'add', value: -60 },
{ path: 'debt', op: 'add', value: -50 },
],
},
{
id: 'stall',
label: 'Tell him next month.',
result: 'He says next month. Marlene goes back to her desk.',
effects: [
{ path: 'debt', op: 'add', value: 25 },
{ path: 'reputation', op: 'add', value: -3 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: -4 },
],
},
],
},
];
export default hardTimes;
+8
View File
@@ -0,0 +1,8 @@
import { routine } from './routine.js';
import { people } from './people.js';
import { promotion } from './promotion.js';
import { dispatch } from './dispatch.js';
import { hardTimes } from './hard-times.js';
export const events = [...routine, ...people, ...promotion, ...dispatch, ...hardTimes];
export default events;
+313
View File
@@ -0,0 +1,313 @@
// Events that turn on where you stand with someone.
//
// These are gated on relationship state rather than skills, which is the point
// of tracking three separate numbers per character: a door can open because
// somebody likes you, or because they would rather you did not make a thing of
// it. The envelope chain is the pack's first taste of leverage — held, then
// spent or put back.
export const people = [
{
id: 'marlene_late',
stage: 'mailroom',
weight: 80,
cooldown: 3,
description:
'Marlene needs someone to stay until the six o\'clock courier. She is not ' +
'asking you specifically. She is looking at you specifically.',
options: [
{
id: 'stay',
label: 'Stay.',
result: 'The courier is twenty minutes late. Marlene waits with you.',
effects: [
{ path: 'money', op: 'add', value: 45 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: 5 },
{ path: 'relationships.marlene.wants_to_help_you', op: 'add', value: 4 },
],
},
{
id: 'decline',
label: 'Say you have somewhere to be.',
requires: [{ path: 'skills.negotiation', op: '>=', value: 15 }],
result: 'She says "of course" in a way that files it somewhere.',
effects: [
{ path: 'skills.negotiation', op: 'add', value: 2 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: -3 },
],
},
{
id: 'volunteer_trevor',
label: 'Suggest Trevor is free.',
result: 'Trevor is, as it turns out, free. He finds out the way you did.',
effects: [
{ path: 'relationships.trevor.likes_you', op: 'add', value: -6 },
{ path: 'relationships.trevor.fears_you', op: 'add', value: 4 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: 1 },
],
},
],
},
{
id: 'trevor_credit',
stage: 'mailroom',
weight: 60,
cooldown: 6,
description:
'The floor manager thanks Trevor for the reorganised outbound shelf. ' +
'Trevor accepts this. You built the shelf on Tuesday.',
options: [
{
id: 'let_it_go',
label: 'Let it go.',
result: 'Trevor is warmer to you all week, which is somehow worse.',
effects: [
{ path: 'reputation', op: 'add', value: -1 },
{ path: 'relationships.trevor.likes_you', op: 'add', value: 4 },
],
},
{
id: 'correct',
label: 'Correct the record, politely.',
requires: [{ path: 'skills.negotiation', op: '>=', value: 15 }],
result:
'You do it well enough that nobody is embarrassed except Trevor, who ' +
'is embarrassed thoroughly.',
effects: [
{ path: 'skills.negotiation', op: 'add', value: 2 },
{ path: 'reputation', op: 'add', value: 4 },
{ path: 'relationships.trevor.likes_you', op: 'add', value: -8 },
{ path: 'relationships.trevor.fears_you', op: 'add', value: 5 },
],
},
{
id: 'private',
label: 'Mention it to Trevor later, alone.',
result: 'He apologises twice, which is once more than he needed to.',
effects: [
{ path: 'skills.negotiation', op: 'add', value: 3 },
{ path: 'relationships.trevor.likes_you', op: 'add', value: -2 },
{ path: 'relationships.trevor.fears_you', op: 'add', value: 6 },
],
},
],
},
{
id: 'misrouted_envelope',
stage: 'mailroom',
weight: 35,
cooldown: 8,
requires: [{ path: 'flags.holds_the_envelope', op: '==', value: false }],
description:
'A sealed envelope, misrouted. Wrong floor, wrong name — and the name is ' +
'one you have seen on the quarterly. It is not sealed especially well.',
options: [
{
id: 'deliver',
label: 'Deliver it. Say nothing.',
result: 'It goes where it was going. You think about it on the way back.',
effects: [
{ path: 'reputation', op: 'add', value: 2 },
{ path: 'notable', op: 'push', value: 'Delivered the misrouted envelope unopened.' },
],
},
{
id: 'hold',
label: 'Put it in your locker for now.',
result: 'For now.',
effects: [
{ path: 'flags.holds_the_envelope', op: 'set', value: true },
{ path: 'notable', op: 'push', value: 'Kept an envelope that was not addressed to you.' },
],
},
{
id: 'to_marlene',
label: 'Take it to Marlene.',
result: 'She reroutes it without looking at it, which took her years.',
effects: [
{ path: 'relationships.marlene.likes_you', op: 'add', value: 4 },
{ path: 'relationships.marlene.wants_to_help_you', op: 'add', value: 3 },
],
},
],
},
{
id: 'envelope_pressure',
stage: 'mailroom',
weight: 45,
cooldown: 6,
requires: [{ path: 'flags.holds_the_envelope', op: '==', value: true }],
description:
'The envelope is still in your locker. It has begun to feel heavier than paper.',
options: [
{
id: 'return_it',
label: 'Put it back into the system, late.',
result: 'Late mail is nobody\'s scandal. It clears.',
effects: [
{ path: 'flags.holds_the_envelope', op: 'set', value: false },
{ path: 'reputation', op: 'add', value: -1 },
],
},
{
id: 'let_it_be_known',
label: 'Let it be known, quietly, that you have it.',
requires: [{ path: 'skills.negotiation', op: '>=', value: 30 }],
result:
'You never say what is in it. You never have to. The mailroom gets ' +
'noticeably more punctual.',
effects: [
{ path: 'relationships.marlene.fears_you', op: 'add', value: 10 },
{ path: 'relationships.trevor.fears_you', op: 'add', value: 10 },
{ path: 'reputation', op: 'add', value: -4 },
{ path: 'notable', op: 'push', value: 'Let it be known you had the envelope.' },
],
},
{
id: 'leave_it',
label: 'Leave it where it is.',
result: 'You check the locker twice on your way out.',
effects: [{ path: 'reputation', op: 'add', value: -1 }],
},
],
},
{
id: 'trevor_favor',
stage: 'mailroom',
weight: 55,
cooldown: 5,
requires: [
{ path: 'relationships.trevor.likes_you', op: '>=', value: 48 },
{ path: 'flags.covered_for_trevor', op: '==', value: false },
],
description:
'Trevor asks if you would cover the last hour on Thursday. He does not ' +
'say why. He does say it twice.',
options: [
{
id: 'cover',
label: 'Cover it.',
result: 'Thursday is quiet. Trevor is not there to see how quiet.',
effects: [
{ path: 'flags.covered_for_trevor', op: 'set', value: true },
{ path: 'relationships.trevor.likes_you', op: 'add', value: 6 },
{ path: 'relationships.trevor.wants_to_help_you', op: 'add', value: 8 },
],
},
{
id: 'trade',
label: 'Cover it, and say what you want in return.',
requires: [{ path: 'skills.negotiation', op: '>=', value: 15 }],
result: 'He agrees quickly, and then thinks about how quickly he agreed.',
effects: [
{ path: 'flags.covered_for_trevor', op: 'set', value: true },
{ path: 'skills.negotiation', op: 'add', value: 3 },
{ path: 'relationships.trevor.wants_to_help_you', op: 'add', value: 5 },
{ path: 'relationships.trevor.fears_you', op: 'add', value: 4 },
],
},
{
id: 'refuse',
label: 'Say no.',
result: 'He says it is fine. He asks Marlene, who says no as well.',
effects: [{ path: 'relationships.trevor.likes_you', op: 'add', value: -5 }],
},
],
},
{
id: 'call_in_favor',
stage: 'mailroom',
weight: 50,
cooldown: 4,
requires: [{ path: 'flags.covered_for_trevor', op: '==', value: true }],
description:
'Thursday was a while ago now. Trevor has not brought it up, which is its ' +
'own kind of answer.',
options: [
{
id: 'call_it_in',
label: 'Ask him to take the afternoon bins.',
result: 'He takes them. The ledger is closed and both of you know it.',
effects: [
{ path: 'flags.covered_for_trevor', op: 'set', value: false },
{ path: 'skills.logistics', op: 'add', value: 1 },
{ path: 'reputation', op: 'add', value: 1 },
{ path: 'relationships.trevor.wants_to_help_you', op: 'add', value: -4 },
],
},
{
id: 'bank_it',
label: 'Leave it. It keeps.',
result: 'It keeps. He knows it keeps.',
effects: [{ path: 'relationships.trevor.fears_you', op: 'add', value: 2 }],
},
{
id: 'forgive',
label: 'Tell him to forget it.',
result: 'He does not forget it, but he does stop bringing you coffee wrong.',
effects: [
{ path: 'flags.covered_for_trevor', op: 'set', value: false },
{ path: 'relationships.trevor.likes_you', op: 'add', value: 7 },
],
},
],
},
{
id: 'marlene_confides',
stage: 'mailroom',
weight: 50,
cooldown: 8,
requires: [{ path: 'relationships.marlene.likes_you', op: '>=', value: 65 }],
description:
'Marlene tells you, without preamble, that she was told nineteen years ago ' +
'that the mailroom was a two-year posting. She then asks whether the ' +
'third-floor bins have gone up.',
options: [
{
id: 'ask_about_it',
label: 'Ask her about it.',
result: 'She talks for a while. She has not told this to Trevor.',
effects: [
{ path: 'skills.negotiation', op: 'add', value: 2 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: 5 },
{ path: 'relationships.marlene.wants_to_help_you', op: 'add', value: 6 },
{
path: 'notable',
op: 'push',
value: 'Marlene told you about the two-year posting.',
},
],
},
{
id: 'answer_the_bins',
label: 'Tell her the bins have gone up.',
result: 'She writes it down. The subject is closed with visible relief.',
effects: [
{ path: 'skills.logistics', op: 'add', value: 1 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: 2 },
],
},
{
id: 'ask_how_to_leave',
label: 'Ask her how to get out of the mailroom.',
requires: [{ path: 'skills.negotiation', op: '>=', value: 15 }],
result:
'She gives you three names and a piece of advice she says she wishes ' +
'someone had given her, which is how you know it is good.',
effects: [
{ path: 'skills.negotiation', op: 'add', value: 4 },
{ path: 'reputation', op: 'add', value: 2 },
{ path: 'relationships.marlene.wants_to_help_you', op: 'add', value: 5 },
],
},
],
},
];
export default people;
@@ -0,0 +1,87 @@
// Leaving the mailroom.
//
// The offer is an ordinary event whose effects happen to include
// `{ path: 'stage', op: 'set', value: 'dispatch' }` — the engine has no notion
// of a promotion, only of a stage that determines which events are in the pool.
//
// Declining is a real option, not a soft no. It comes back after a cooldown,
// and the brainstorm is explicit that staying lower to build skills or
// relationships should be a strategy rather than a mistake.
export const promotion = [
{
id: 'dispatch_offer',
stage: 'mailroom',
weight: 800,
cooldown: 12,
// Deliberately a long way in. The mailroom is where most of this pack
// lives, and a promotion at turn 25 would skip it — the offer should read
// as the end of a career there, not an early exit from it.
requires: [
{ path: 'reputation', op: '>=', value: 72 },
{ path: 'turn', op: '>=', value: 40 },
// Two ways to be in the room: somebody upstairs wants to help you, or
// you have got good enough at asking.
{
any: [
{ path: 'relationships.marlene.wants_to_help_you', op: '>=', value: 68 },
{ path: 'skills.negotiation', op: '>=', value: 42 },
],
},
],
description:
'The Dispatch post is real, it is open, and Marlene has put your name on ' +
'a list of one. She says the coordinator job is mostly the same work with ' +
'a different phone. She does not say it is more money, because she assumes ' +
'you can read.',
options: [
{
id: 'accept',
label: 'Take it.',
result:
'You are given a desk with a window in it. Trevor helps you carry ' +
'your things up and does not say anything the whole way.',
effects: [
{ path: 'stage', op: 'set', value: 'dispatch' },
{ path: 'flags.promoted', op: 'set', value: true },
{ path: 'reputation', op: 'add', value: 5 },
{ path: 'relationships.trevor.likes_you', op: 'add', value: -12 },
{ path: 'relationships.trevor.fears_you', op: 'add', value: 6 },
{ path: 'notable', op: 'push', value: 'Took the Dispatch post.' },
],
},
{
id: 'accept_and_recommend',
label: 'Take it, and put Trevor up for the mailroom.',
requires: [{ path: 'relationships.trevor.likes_you', op: '>=', value: 50 }],
result:
'Marlene raises an eyebrow and writes his name down. Trevor finds out ' +
'from the notice board and is unbearable about it for a week.',
effects: [
{ path: 'stage', op: 'set', value: 'dispatch' },
{ path: 'flags.promoted', op: 'set', value: true },
{ path: 'flags.recommended_trevor', op: 'set', value: true },
{ path: 'reputation', op: 'add', value: 3 },
{ path: 'relationships.trevor.likes_you', op: 'add', value: 12 },
{ path: 'relationships.trevor.wants_to_help_you', op: 'add', value: 8 },
{ path: 'notable', op: 'push', value: 'Took Dispatch, and put Trevor up for the mailroom.' },
],
},
{
id: 'decline',
label: 'Not yet.',
result:
'Marlene takes the list back. She has been on a list of one before ' +
'and does not ask why.',
effects: [
{ path: 'skills.negotiation', op: 'add', value: 2 },
{ path: 'reputation', op: 'add', value: -2 },
{ path: 'relationships.marlene.wants_to_help_you', op: 'add', value: -6 },
{ path: 'notable', op: 'push', value: 'Turned down Dispatch.' },
],
},
],
},
];
export default promotion;
+436
View File
@@ -0,0 +1,436 @@
// Ordinary days in the mailroom.
//
// The first three carry no conditions, no cooldown and no `once`, which makes
// them the stage's floor: whatever else is excluded, there is always a turn to
// play. Everything else in the pack leans on that.
export const routine = [
{
id: 'first_day',
stage: 'mailroom',
weight: 99999,
once: true,
description:
'Marlene Hobb hands you a laminated card with the org chart on it. Your ' +
'name is not on the card. She says it will be, eventually, in pen.',
options: [
{
id: 'listen',
label: 'Ask her to walk you through it.',
result: 'She walks you through it. Then she walks you through it again.',
effects: [
{ path: 'skills.negotiation', op: 'add', value: 2 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: 4 },
{ path: 'notable', op: 'push', value: 'Let Marlene explain the org chart. Twice.' },
],
},
{
id: 'wing_it',
label: "Say you'll pick it up as you go.",
result: 'She puts the card back in her pocket without comment.',
effects: [
{ path: 'skills.logistics', op: 'add', value: 2 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: -2 },
],
},
{
id: 'ask_trevor',
label: 'Ask Trevor where you actually sit.',
result:
'He shows you a desk, then mentions he has been here two weeks longer ' +
'than you. This will not be the last time.',
effects: [
{ path: 'relationships.trevor.likes_you', op: 'add', value: 3 },
{ path: 'relationships.trevor.wants_to_help_you', op: 'add', value: 2 },
],
},
],
},
{
id: 'mail_cart',
stage: 'mailroom',
weight: 100,
description:
"The mail cart's left front wheel has developed a shriek. Facilities has " +
'classified it as "cosmetic."',
options: [
{
id: 'push',
label: "Push it anyway. Everyone will know you're coming.",
result: 'By Thursday, two floors can time their morning by you.',
effects: [
{ path: 'skills.logistics', op: 'add', value: 1 },
{ path: 'reputation', op: 'add', value: -2 },
],
},
{
id: 'requisition',
label: 'Spend the morning on a requisition form.',
result:
'The form requires the cart\'s asset number. The asset number is on the ' +
'wheel. You fill it in from underneath.',
effects: [
{ path: 'skills.negotiation', op: 'add', value: 2 },
{ path: 'skills.logistics', op: 'add', value: 1 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: 1 },
],
},
{
id: 'swap',
label: 'Swap it with the good cart from the third floor.',
result: 'The third floor has no way of proving anything. They try anyway.',
effects: [
{ path: 'skills.logistics', op: 'add', value: 3 },
{ path: 'reputation', op: 'add', value: -3 },
{ path: 'relationships.trevor.fears_you', op: 'add', value: 3 },
],
},
],
},
{
id: 'sorting_backlog',
stage: 'mailroom',
weight: 100,
description:
'Two bins came in overnight and neither is labelled. Trevor has decided ' +
'this is a good day to reorganise his desk.',
options: [
{
id: 'grind',
label: 'Sort both bins yourself.',
result: 'It takes until four. Marlene notices, which is the point.',
effects: [
{ path: 'skills.logistics', op: 'add', value: 3 },
{ path: 'reputation', op: 'add', value: 2 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: 3 },
// Trevor's bin got sorted too, and he is not too proud to notice.
{ path: 'relationships.trevor.likes_you', op: 'add', value: 2 },
],
},
{
id: 'split',
label: 'Ask Trevor to take one.',
requires: [{ path: 'skills.negotiation', op: '>=', value: 15 }],
result: 'He takes one. He takes the lighter one, but he takes one.',
effects: [
{ path: 'skills.negotiation', op: 'add', value: 2 },
{ path: 'skills.logistics', op: 'add', value: 1 },
{ path: 'relationships.trevor.likes_you', op: 'add', value: -2 },
],
},
{
id: 'halfway',
label: 'Sort what fits before lunch and leave the rest.',
result: 'The rest is still there on Monday, and so are you.',
effects: [
{ path: 'reputation', op: 'add', value: -2 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: -2 },
],
},
],
},
{
id: 'franking_machine',
stage: 'mailroom',
weight: 70,
description:
'The franking machine is making a noise it has not made before. The manual ' +
'is in a drawer that is locked, and the key is a subject nobody will be ' +
'drawn on.',
options: [
{
id: 'fix',
label: 'Take the panel off and have a look.',
requires: [{ path: 'skills.logistics', op: '>=', value: 20 }],
result: 'It was a spring. It is now a spring in the right place.',
effects: [
{ path: 'skills.logistics', op: 'add', value: 3 },
{ path: 'reputation', op: 'add', value: 4 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: 3 },
{
path: 'notable',
op: 'push',
value: 'Fixed the franking machine, which nobody had managed since 2019.',
},
],
},
{
id: 'cycle',
label: 'Turn it off and on.',
result: 'The noise stops. Nothing has been learned by anyone.',
effects: [{ path: 'skills.logistics', op: 'add', value: 1 }],
},
{
id: 'escalate',
label: 'Log it with Facilities and wait.',
result: 'Facilities classifies it as cosmetic.',
effects: [
{ path: 'skills.negotiation', op: 'add', value: 1 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: -1 },
],
},
],
},
{
id: 'birthday_card',
stage: 'mailroom',
weight: 45,
cooldown: 5,
description:
'A card is going round for someone on the fourth floor. You have never met ' +
'them. The envelope already has two twenties in it and your name is next ' +
'on the list.',
options: [
{
id: 'contribute',
label: 'Put something in.',
result: 'You are now a person who puts something in.',
effects: [
{ path: 'money', op: 'add', value: -20 },
{ path: 'reputation', op: 'add', value: 3 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: 2 },
{ path: 'relationships.trevor.likes_you', op: 'add', value: 2 },
],
},
{
id: 'sign_only',
label: 'Sign it and pass it on.',
result: 'You write "all the best" and immediately regret the phrasing.',
effects: [{ path: 'reputation', op: 'add', value: -1 }],
},
{
id: 'pass',
label: 'Pass it on unsigned.',
result: 'Trevor checks the list. Trevor notices the list.',
effects: [
{ path: 'reputation', op: 'add', value: -3 },
{ path: 'relationships.trevor.likes_you', op: 'add', value: -2 },
],
},
],
},
{
id: 'elevator',
stage: 'mailroom',
weight: 30,
cooldown: 10,
description:
'You are alone in the elevator with a man whose name is on the quarterly. ' +
'He looks at your cart. He asks how long the mail takes to get to eleven.',
options: [
{
id: 'honest',
label: 'Tell him the truth: forty minutes, on a good day.',
result: 'He says "forty" once, to himself, and gets out on nine.',
effects: [
{ path: 'reputation', op: 'add', value: 5 },
{ path: 'skills.negotiation', op: 'add', value: 2 },
{
path: 'notable',
op: 'push',
value: 'Told an executive the truth about the eleventh floor.',
},
],
},
{
id: 'optimistic',
label: 'Tell him twenty.',
result: 'He seems pleased. Somebody, eventually, will have to make it twenty.',
effects: [
{ path: 'reputation', op: 'add', value: -3 },
{ path: 'skills.negotiation', op: 'add', value: 1 },
],
},
{
id: 'unsure',
label: "Say you're not sure.",
result: 'The rest of the ride is four floors long.',
effects: [{ path: 'reputation', op: 'add', value: -1 }],
},
],
},
{
id: 'overtime_week',
stage: 'mailroom',
weight: 40,
cooldown: 7,
description:
'There is a weekend inventory. It pays. Marlene mentions it in the tone of ' +
'someone who has been told to mention it.',
options: [
{
id: 'take',
label: 'Take the shift.',
result: 'Two days of counting things that were already counted.',
effects: [
{ path: 'money', op: 'add', value: 180 },
{ path: 'skills.logistics', op: 'add', value: 2 },
{ path: 'reputation', op: 'add', value: 2 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: 2 },
],
},
{
id: 'negotiate',
label: 'Take it, for a bit more.',
requires: [{ path: 'skills.negotiation', op: '>=', value: 30 }],
result:
'Marlene does not enjoy the conversation, but she does have the ' +
'authority, and you both find that out at the same time.',
effects: [
{ path: 'money', op: 'add', value: 260 },
{ path: 'skills.negotiation', op: 'add', value: 3 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: -1 },
{
path: 'notable',
op: 'push',
value: 'Negotiated a better rate for the weekend inventory.',
},
],
},
{
id: 'decline',
label: 'Pass.',
result: 'You have a weekend. It is the only one anyone gets.',
effects: [],
},
],
},
{
id: 'promotion_rumor',
stage: 'mailroom',
weight: 25,
cooldown: 12,
requires: [{ path: 'turn', op: '>=', value: 30 }],
description:
'There is a post going in Dispatch. Trevor has heard about it. Trevor ' +
'mentions that he has heard about it.',
options: [
{
id: 'ask_marlene',
label: 'Ask Marlene to put your name forward.',
requires: [{ path: 'relationships.marlene.wants_to_help_you', op: '>=', value: 60 }],
result: 'She says she will see. From Marlene this is close to a contract.',
effects: [
{ path: 'reputation', op: 'add', value: 5 },
{ path: 'relationships.marlene.wants_to_help_you', op: 'add', value: -5 },
{
path: 'notable',
op: 'push',
value: 'Asked Marlene to put your name forward for Dispatch.',
},
],
},
{
id: 'ask_around',
label: 'Ask around on your own.',
result: 'You learn the post has been open for eleven months.',
effects: [
{ path: 'skills.negotiation', op: 'add', value: 3 },
{ path: 'reputation', op: 'add', value: 2 },
],
},
{
id: 'ignore',
label: 'Stay where you are for now.',
result: 'Trevor mentions it again on Thursday.',
effects: [],
},
],
},
{
id: 'late_run',
stage: 'mailroom',
weight: 35,
cooldown: 5,
description:
'The evening courier run needs covering. It is an hour, it is nobody\'s ' +
'favourite hour, and it pays.',
options: [
{
id: 'take_run',
label: 'Take the run.',
result: 'An hour in the loading bay. The city looks better from there than it has any right to.',
effects: [
{ path: 'money', op: 'add', value: 70 },
{ path: 'skills.logistics', op: 'add', value: 1 },
],
},
{
id: 'swap_it',
label: 'Take it, and trade it to Trevor for the Friday one.',
requires: [{ path: 'skills.negotiation', op: '>=', value: 15 }],
result: 'Friday pays the same and finishes earlier. Trevor works this out on Friday.',
effects: [
{ path: 'money', op: 'add', value: 70 },
{ path: 'skills.negotiation', op: 'add', value: 2 },
{ path: 'relationships.trevor.likes_you', op: 'add', value: -3 },
{ path: 'relationships.trevor.fears_you', op: 'add', value: 2 },
],
},
{
id: 'go_home',
label: 'Go home.',
result: 'You are home by six. It is a strange feeling and it does not last.',
effects: [{ path: 'relationships.marlene.likes_you', op: 'add', value: -1 }],
},
],
},
{
// Deliberately surfaced by being broke: when the money runs low the board
// starts offering work. The way out of a hole should be visible from
// inside it, not something you had to have planned for.
id: 'spare_shift',
stage: 'mailroom',
weight: 300,
cooldown: 2,
requires: [{ path: 'money', op: '<', value: 350 }],
description:
'There is a shift going spare on the board — someone else\'s weekend, ' +
'going cheap because nobody wants it. Marlene has not taken it down.',
options: [
{
id: 'take_shift',
label: 'Take it.',
result: 'Two days of somebody else\'s work. The money is real.',
effects: [
{ path: 'money', op: 'add', value: 140 },
{ path: 'skills.logistics', op: 'add', value: 1 },
{ path: 'relationships.marlene.likes_you', op: 'add', value: 1 },
],
},
{
id: 'take_two',
label: 'Take it, and ask what else is going.',
requires: [{ path: 'skills.negotiation', op: '>=', value: 15 }],
result:
'There is more going than the board admits to. Marlene finds you ' +
'another two days without writing either of them down.',
effects: [
{ path: 'money', op: 'add', value: 265 },
{ path: 'skills.negotiation', op: 'add', value: 2 },
{ path: 'reputation', op: 'add', value: -1 },
],
},
{
id: 'leave_it',
label: 'Leave it for someone who needs it.',
result: 'Trevor takes it. Trevor mentions taking it.',
effects: [
{ path: 'reputation', op: 'add', value: 2 },
{ path: 'relationships.trevor.likes_you', op: 'add', value: 2 },
],
},
],
},
];
export default routine;
+313
View File
@@ -0,0 +1,313 @@
// Corporate Ladder — the default content pack for The Ladder.
//
// A modern office, the mailroom, and two people who have been there longer
// than you. Everything the engine needs to run this setting is in this file
// and the ones it imports; the engine itself knows nothing about mailrooms.
import { characters } from './characters.js';
import { events } from './events/index.js';
export const corporateLadder = {
id: 'corporateladder',
name: 'Corporate Ladder',
// One sentence for the setting picker. The interface reads it and knows
// nothing else about what this pack is.
blurb:
'A modern office and a mailroom, two people who have been here longer ' +
'than you, and a laminated org chart with your name not on it.',
version: '0.1.0',
turnUnit: 'day',
startingStage: 'mailroom',
// What the interface calls the panel of people. The UI used to hardcode
// "The mailroom" here, which was the one place it named a setting.
peopleHeading: 'The mailroom',
stages: {
mailroom: { label: 'Mailroom', title: 'Mail Associate I' },
dispatch: { label: 'Dispatch', title: 'Dispatch Coordinator' },
},
// Turn 1 is a Monday. Content can gate on the day — and, more importantly,
// money arrives and leaves on a rhythm the player can plan around instead of
// draining by an unexplained amount every day.
calendar: {
cycleLength: 7,
cycleName: 'Week',
unitNames: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'],
},
// Money is tight but not hopeless. The baseline bleeds about three dollars a
// day, so a careful player creeps forward and a careless one does not — the
// pressure comes from what choices cost, not from an unexplained drain.
state: {
'money': { initial: 520, min: -5000, max: 1000000 },
'debt': { initial: 0, min: 0, max: 1000000 },
'reputation': { initial: 50, min: 0, max: 100 },
'skills.negotiation': { initial: 5, min: 0, max: 100 },
'skills.logistics': { initial: 5, min: 0, max: 100 },
// What standing costs to keep. Nothing forces a player to climb this, but
// Dispatch charges standing for staying at the bottom of it — see the
// "Looking the part" entries in upkeep, and DECISIONS #36.
'lifestyle': { initial: 0, min: 0, max: 2 },
'flags.took_loan': { initial: false },
'flags.holds_the_envelope': { initial: false },
'flags.covered_for_trevor': { initial: false },
'flags.promoted': { initial: false },
'flags.recommended_trevor': { initial: false },
// Moments the retrospective reads back. Content decides what is memorable;
// the full turn-by-turn record lives in history regardless.
'notable': { initial: [] },
},
// Nobody fears a new hire. Trevor's defaults are lower still — see
// characters.js, where he starts two weeks ahead of you and knows it.
relationshipStats: {
likes_you: { label: 'Likes you', initial: 50, min: 0, max: 100 },
fears_you: { label: 'Wary of you', initial: 10, min: 0, max: 100 },
wants_to_help_you: { label: 'Will help you', initial: 40, min: 0, max: 100 },
},
// Money moves on a schedule the player can feel: paid on alternate Fridays,
// rent out every Monday, and only small change in between.
//
// Keep this arithmetic honest when tuning: wages, minus two rents, minus
// fourteen days of small change. Per week that is 560 - 455 - 126 = -21, so
// about forty dollars down over a fortnight — a drift, not a bleed. Rent was
// once raised without re-deriving it, which quietly made the baseline four
// times steeper and trapped a real playtester below zero for 30% of a
// 182-turn run. The range is asserted from `tuning` below.
upkeep: [
{ label: 'Coffee, transit, lunch', effects: [{ path: 'money', op: 'add', value: -18 }] },
// Turn 1 is a Monday, so offset 4 is the first Friday and every 14 makes it
// alternate Fridays. Rent lands on Mondays, starting the second week.
// Wages depend on the job. Two entries rather than one, gated on stage:
// the engine has no idea what a promotion is, and does not need to.
{
label: 'Wages',
every: 14,
offset: 4,
requires: [{ path: 'stage', op: '==', value: 'mailroom' }],
effects: [{ path: 'money', op: 'add', value: 1120 }],
},
{
label: 'Wages',
every: 14,
offset: 4,
// A real raise, but not a different life — the coordinator job is, as
// Marlene puts it, mostly the same work with a different phone. Net of
// rent it comes to a few dollars a day ahead instead of behind.
requires: [{ path: 'stage', op: '==', value: 'dispatch' }],
effects: [{ path: 'money', op: 'add', value: 1260 }],
},
{ label: 'Rent', every: 7, offset: 7, effects: [{ path: 'money', op: 'add', value: -455 }] },
// What the job expects of you, and what meeting that expectation costs.
// Dispatch nets +49 a week; a coordinator with the car is +19, and one
// with the car and the address is -21 and has to earn the difference out
// of the days themselves. That is the answer to "money stops mattering
// late": it stops mattering only if standing is free to keep.
{
label: 'Car payment and parking',
every: 7,
offset: 7,
requires: [{ path: 'lifestyle', op: '>=', value: 1 }],
effects: [{ path: 'money', op: 'add', value: -30 }],
},
{
label: 'The better address',
every: 7,
offset: 7,
requires: [{ path: 'lifestyle', op: '>=', value: 2 }],
effects: [{ path: 'money', op: 'add', value: -40 }],
},
// The pressure that makes the spending a choice rather than a vanity. Both
// are gated on Dispatch: the mailroom pays too little for this to be
// anything but a trap, and "later in a career" is the point.
{
label: 'Looking the part',
every: 7,
offset: 7,
requires: [
{ path: 'stage', op: '==', value: 'dispatch' },
{ path: 'lifestyle', op: '<', value: 1 },
],
effects: [{ path: 'reputation', op: 'add', value: -4 }],
},
{
// Standing above sixty-five is a thing you are seen to be, and being seen
// to be it runs on a better address than a coordinator's first one.
//
// Sized by measurement, not by feel. At -2 a week this was invisible:
// Dispatch hands out standing several points at a time, so a coordinator
// who bought nothing still finished pinned at 100 and the charge may as
// well not have existed. At -6, with the entry above, a non-buyer settles
// in the low seventies while somebody keeping up finishes at 100 — the
// ceiling is real and money is what lifts it.
label: 'Looking the part',
every: 7,
offset: 7,
requires: [
{ path: 'stage', op: '==', value: 'dispatch' },
{ path: 'reputation', op: '>=', value: 65 },
{ path: 'lifestyle', op: '<', value: 2 },
],
effects: [{ path: 'reputation', op: 'add', value: -6 }],
},
{
// The top step, and the one that makes the whole thing a choice rather
// than a tax on carelessness. Without it, a player who spends every turn
// chasing standing simply out-earns the charge: measured, they held 99
// while never buying anything, and finished $2,245 richer than a player
// who kept up — so the lifestyle was a pure loss to anybody paying
// attention. With it they plateau in the low eighties. What money buys
// up here is the last fifteen points, which cannot be worked for.
label: 'Looking the part',
every: 7,
offset: 7,
requires: [
{ path: 'stage', op: '==', value: 'dispatch' },
{ path: 'reputation', op: '>=', value: 80 },
{ path: 'lifestyle', op: '<', value: 2 },
],
effects: [{ path: 'reputation', op: 'add', value: -14 }],
},
// The charge that never stops, and the only one with no purchase that ends
// it. Everything above is a ladder with a top: buy both tiers, reach 100
// standing, and every charge switches off. A real 96-turn session arrived
// there at turn 84 with nothing left to spend on and a balance that only
// climbed.
//
// It steps on money rather than being flat, and that is the whole design.
// A flat charge cannot flatten a slope: late income comes from choices and
// scales with how well the player is playing, so a charge big enough to
// stop an optimiser accumulating (-360 a week, measured) put ordinary
// archetypes below zero for 44% of a run and ended them $1,385 in debt.
// Stepping on money lands it on whoever has it. Measured: the balance
// settles around $4,250 and stops climbing (-3 a week), while ordinary play
// gets *better* than before it existed — 13% of a run below zero against
// 17%, and a tier-2 run ending +$175 rather than -$25.
{
label: 'What the standing costs',
every: 7,
offset: 7,
requires: [
{ path: 'stage', op: '==', value: 'dispatch' },
{ path: 'reputation', op: '>=', value: 85 },
{ path: 'money', op: '>=', value: 1200 },
],
effects: [{ path: 'money', op: 'add', value: -60 }],
},
{
label: 'What the standing costs',
every: 7,
offset: 7,
requires: [
{ path: 'stage', op: '==', value: 'dispatch' },
{ path: 'reputation', op: '>=', value: 85 },
{ path: 'money', op: '>=', value: 2400 },
],
effects: [{ path: 'money', op: 'add', value: -120 }],
},
{
label: 'What the standing costs',
every: 7,
offset: 7,
requires: [
{ path: 'stage', op: '==', value: 'dispatch' },
{ path: 'reputation', op: '>=', value: 85 },
{ path: 'money', op: '>=', value: 3600 },
],
effects: [{ path: 'money', op: 'add', value: -240 }],
},
// The service charge steps up with the size of the debt, so the spiral
// tightens instead of flattening out. A step function in data costs the
// engine nothing; a percentage would have needed a multiply operation.
{
label: 'Loan service charge',
every: 7,
offset: 7,
requires: [{ path: 'debt', op: '>', value: 0 }],
effects: [{ path: 'money', op: 'add', value: -70 }],
},
{
label: 'Loan service charge (arrears)',
every: 7,
offset: 7,
requires: [{ path: 'debt', op: '>=', value: 1000 }],
effects: [{ path: 'money', op: 'add', value: -40 }],
},
{
label: 'Loan service charge (collections)',
every: 7,
offset: 7,
requires: [{ path: 'debt', op: '>=', value: 2000 }],
effects: [{ path: 'money', op: 'add', value: -70 }],
},
],
// What the conformance suite holds this pack to. The numbers a setting must
// hit are the setting's business, so they are declared here rather than
// written into a test that names this pack. See DECISIONS.md #31.
tuning: {
// Net movement of `money` from upkeep alone, per calendar cycle — here a
// week — at each stage, with no debt outstanding.
baselinePerCycle: {
mailroom: [-45, 5],
dispatch: [5, 125],
},
// Upkeep gated on these is excluded from that baseline: a debt taken on, a
// lifestyle bought, a standing earned. The baseline is the floor underneath
// everybody — what a player faces having none of the three.
baselineExcludes: ['debt', 'lifestyle', 'reputation'],
},
// How the interface names and formats this pack's state. The UI reads only
// this map, so renaming a stat or reordering the header is content's job.
display: {
'money': { label: 'Money', format: 'money', order: 1 },
'debt': { label: 'Debt', format: 'money', order: 2, hideWhenZero: true },
'reputation': { label: 'Standing', format: 'meter', order: 3 },
'skills.negotiation': { label: 'Negotiation', format: 'meter', order: 4 },
'skills.logistics': { label: 'Logistics', format: 'meter', order: 5 },
// Hidden until it is bought: a mailroom associate has no lifestyle to
// speak of, and a row reading "The bus" from day one is noise.
'lifestyle': {
label: 'Lifestyle', format: 'tier', order: 6, hideWhenZero: true,
labels: ['The bus', 'A car', 'The address'],
},
// Kept out of the header, and out of the ledger too: `notable` is the
// retrospective's own list, and "Notable — changed" tells nobody anything.
'notable': { hidden: true, inLedger: false },
// Flags do belong in the ledger — a flag flipping is a fact about the
// world — but as a sentence rather than as "now true".
'flags.took_loan': {
label: 'The loan', hidden: true,
whenTrue: 'You are carrying a loan now.',
whenFalse: 'The loan is settled.',
},
'flags.holds_the_envelope': {
label: 'The envelope', hidden: true,
whenTrue: 'The envelope is in your locker.',
whenFalse: 'The envelope is back in the system.',
},
'flags.covered_for_trevor': {
label: "Trevor's Thursday", hidden: true,
whenTrue: 'Trevor owes you a Thursday.',
whenFalse: 'You and Trevor are square.',
},
'flags.promoted': {
label: 'Promotion', hidden: true,
whenTrue: 'You are out of the mailroom.',
whenFalse: 'Back in the mailroom.',
},
'flags.recommended_trevor': {
label: "Trevor's recommendation", hidden: true,
whenTrue: "Trevor's name is in for the mailroom.",
whenFalse: "Trevor's name is not in for the mailroom.",
},
},
characters,
events,
};
export default corporateLadder;
+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;
+644
View File
@@ -0,0 +1,644 @@
// 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 one.',
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 },
],
},
],
},
{
// 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.
id: 'the_sale_barn',
stage: 'deputy',
weight: 45,
cooldown: 6,
requires: [{ path: 'lifestyle', op: '<', value: 2 }],
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: 'lifestyle', op: '==', value: 0 },
{ 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: 'take_the_room',
label: 'Take the room over the Ellis place, with the stove.',
requires: [
{ path: 'lifestyle', op: '==', value: 1 },
{ 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: '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 }],
},
],
},
];
export default deputy;
+139
View File
@@ -0,0 +1,139 @@
// 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: [
{
id: 'pay_chunk',
label: 'Pay a real piece of it down.',
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: -45 },
{ path: 'owed', op: 'add', value: -45 },
{ path: 'standing', op: 'add', value: 2 },
],
},
{
id: 'pay_minimum',
label: 'Pay what you have on you.',
requires: [{ path: 'money', op: '>=', value: 12 }],
result: 'Twelve dollars, ten of which is the line and two of which is coffee.',
effects: [
{ path: 'money', op: 'add', value: -12 },
{ path: 'owed', op: 'add', value: -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;
+93
View File
@@ -0,0 +1,93 @@
// 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 = [
{
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;
+428
View File
@@ -0,0 +1,428 @@
// 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: 'Have no view on 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: 'Put money against the book.',
// 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.',
effects: [
{ path: 'money', op: 'add', value: -20 },
{ path: 'owed', op: 'add', value: -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;
+282
View File
@@ -0,0 +1,282 @@
// 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.0',
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 }],
},
{
label: 'Settlement',
every: 4,
offset: 4,
requires: [{ path: 'stage', op: '==', value: 'hand' }],
effects: [{ path: 'money', op: 'add', value: 24 }],
},
{
label: 'Deputy pay',
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;
+23
View File
@@ -0,0 +1,23 @@
// 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';
export const packs = [corporateLadder, frontier];
/** 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;
+658
View File
@@ -0,0 +1,658 @@
# Architecture decisions
Why the code is shaped the way it is. Append to this file as decisions are
made; the reasoning is worth more later than the conclusion.
## 1. All game state is addressable by path
Every value in a run — `money`, `skills.negotiation`,
`relationships.boss.likes_you`, `flags.took_loan` — is reachable by a dotted
path string, and conditions and effects are expressed against those paths.
The planning docs' schema had `requires: { skill, min }` and
`effects: { money, skills, relationships }`. Both hardcode the state shape into
the engine: the first gate that wants a relationship threshold, a money floor
or a flag, and the first effect that touches a fourth kind of state, means
editing engine code. Path addressing costs one `getPath`/`setPath` pair and
removes the whole class of change.
Everything else in the engine is built on this primitive: option gates, event
triggers, the soft-failure branch, and — later — promotion tiers.
## 2. Conditions and effects are data, evaluated generically
A condition is `{ path, op, value }` or a combinator (`all` / `any` / `not`).
An effect is `{ path, op, value }` with `add` / `set` / `push` / `remove`.
The engine knows the operators; it never knows what a path *means*.
Bounds live in the pack's state declaration (`min` / `max`) and are applied by
the effect layer, so clamping is content's decision, not the engine's.
## 3. There is no such thing as a "random event"
Every turn, the engine filters the entire event pool by stage and conditions,
then draws by weight. A routine turn is a high-weight event with loose
conditions; a rare interruption is a low-weight one; the hard-times branch is
an event whose conditions include a money threshold. The engine branches on
none of it, which is what the planning docs asked for in
`03-claude-code-build-prompt.md` §3 — but achieved by deleting the category
rather than by carefully not special-casing it.
Consequence: every stage needs at least one event that no exclusion rule can
remove (no `requires`, no `once`, no `cooldown`), or the pool can run dry
mid-run. The validator enforces this.
## 4. The engine owns some state; content may read it but not write it
`turn`, `stage`, `seen`, `lastSeen`, `history` and `meta` are engine
bookkeeping. Content can read them in conditions — "only after day 10", "only
if this event has never fired" — which is useful and safe. Writing them is a
validation error, so data cannot corrupt the loop.
`seen.<eventId>` is a count and `lastSeen.<eventId>` a turn number, which is
why event ids must be valid path segments.
## 5. Seeded, serializable RNG
Not in the planning docs; added because it is nearly free and pays three ways:
tests are deterministic, playtest reports are reproducible from seed plus
choice sequence, and the feedback log becomes replayable rather than merely
descriptive. mulberry32 was chosen because its entire state is one uint32, so
the generator's position round-trips through JSON with no special handling.
The seed and position live in `meta` inside the save file. Nothing about a run
exists outside its state object.
## 6. The turn loop is a pure function
`takeTurn(pack, state, optionId) -> { state, record }`. No mutable game object,
no hidden state. A saved game resumes into exactly the run it left — there is a
test that plays 30 turns straight, plays the same 30 turns with a JSON round
trip in the middle, and deep-equals the results.
## 7. Content is validated at load, not discovered at play
`validatePack` checks that every path referenced by a condition or effect is
declared, every character id resolves, ids are usable as path segments, no
event is unreachable, and every stage has a fallback. Without it, a typo in
content fails silently forty turns into a playthrough — the exact failure mode
that makes "just add data" unsafe for a non-programmer.
## 8. Distribution: ES modules in dev, one inlined file to ship
Chrome and Firefox block ES module imports and `fetch` over `file://`, so
"open the HTML file" and "modular JS with JSON content" are in direct conflict.
Resolved by serving during development (`npm run serve`) and inlining
everything into a single self-contained HTML file for distribution
(`npm run build`).
Related: content is authored as `.js` modules exporting plain objects rather
than `.json`. Same data, but comments and multiline strings are available —
which matters a great deal for narrative text — and it sidesteps `fetch`
entirely.
## 9. Tuning values live in the content pack
Relationship stats run 0–100 starting at 50, clamped; typical choice effects
are a few points, so one choice matters but several turns of consistent
behaviour are needed to move someone. Money is dollars. Turn is a day.
None of these are engine constants. They are declared in the pack manifest, so
retuning the game — or a pack that runs on weeks instead of days — touches no
code.
## 10. Naming
The product is **The Ladder**; the default content pack is **Corporate
Ladder**. The planning docs use "Corporate Ladder" as the working title for
both, which would have baked one setting into the product name, against the
explicit goal that corporate is one pack among many.
## 11. Per-turn upkeep is declared by the pack
The MVP spec wants "basic income and basic recurring costs", but effects only
run when a player chooses something. Baking wages and rent into every option
would be tedious and easy to get wrong.
`pack.upkeep` is a list of `{ label, requires?, effects }` applied at the end of
each turn, after the choice lands. It is still data — the engine reads it and
knows nothing about wages — and the conditions make it expressive enough to be
useful: Corporate Ladder's loan service charge is three conditional entries
forming a step function that escalates with the size of the debt, which needed
no multiply operation and no engine change.
Upkeep changes are recorded separately from choice changes in the turn record,
so the UI can show "you chose this" and "and then the month happened" as
different things.
## 12. Characters may start somewhere other than the pack default
`relationshipStats` sets the defaults; a character's `startingRelationship`
overrides individual stats. Trevor starts below the default on `likes_you`
because he arrived two weeks before you and has priced that in — which is
characterisation expressed as data rather than as an opening cutscene.
Nobody starts feared: `fears_you` defaults to 10 rather than 50, because a
midpoint default would mean every new hire arrives already intimidating.
## 13. Content is tested for reachability, not just validity
`validatePack` proves a pack *can* run. It does not prove any of it is ever
seen. The content suite plays 60 seeded runs and asserts that every event fires
and every option is taken at least once — dead content is otherwise invisible,
and two whole events plus a hard-times escape route were in fact unreachable on
the first pass because Trevor's gate sat above anything a player could
plausibly reach.
**A trap that cost a cycle:** the chooser's RNG must be seeded differently from
the game's. Both draw exactly one number per turn from the same generator, so a
shared seed correlates the choice index with the event draw and makes whole
option combinations structurally unreachable. It presents as a content bug and
is not one. Any headless playtest harness written later has to know this.
## 14. The interface is named by the pack, not by the UI
`pack.display` maps a state path to `{ label, format, order, hidden,
hideWhenZero }`. The header reads it to decide what to show, in what order, and
whether a value is money or a bar. Anything the pack does not describe falls
back to a name derived from the path, so content is never *required* to supply
a label just to be playable.
This is why the header says "Standing" for a path called `reputation`, and why
`debt` disappears while it is zero — both are content's decisions. A Wild West
pack renames the whole interface without touching a UI file.
## 15. A turn has two phases
Choosing, then outcome. The outcome phase shows what the choice did, as a list
of deltas, and separately what the day cost regardless — wages, rent, the loan.
Collapsing these into one screen would have made the numbers move without the
player seeing why, which is most of what makes them mean anything.
Locked options are rendered greyed with the requirement spelled out ("Needs
Negotiation 15+") rather than hidden. A player should be able to see the door
they cannot open yet; content can still opt into hiding one with
`whenLocked: 'hide'`.
The feedback widget lives in the outcome phase, because that is the moment a
player has an opinion — they have now seen both the options and the result.
## 16. Full re-render, with one deliberate exception
The root element is rebuilt from state on every change. At this size it is
instant and it removes a whole class of stale-view bug.
The exception is the feedback text fields, which update the model without
re-rendering: rebuilding the DOM under a cursor throws the player out of the
box they are typing in. Their contents are flushed to the log on a timer, when
the turn advances, and on `beforeunload`.
A related bug worth remembering: the flush originally skipped writing when the
feedback was empty, which meant *clearing* a rating did not clear it from the
log. Withdrawing feedback is itself feedback. The guard is now "has the player
touched this", not "is there content".
## 17. The single-file build is verified by running it
`tools/build.js` inlines the stylesheet and flattens the module graph into one
script. It is a deliberately small bundler that understands only the module
syntax this project uses and **throws on anything it does not recognise** — a
silent mis-bundle is far worse than a failed build.
The build is covered by a test that executes the bundled script against the
fake DOM and plays a turn through it. A build that merely produces a file
proves nothing; a broken bundle is otherwise discovered by whoever was handed
the file.
## 18. What the tests do not cover
There is no browser on this machine that can render the page — headless Firefox
cannot get a framebuffer, and no Chromium is installed. The UI tests drive the
real app modules against a minimal fake DOM (`test/fixtures/fake-dom.js`), which
covers wiring: imports resolve, handlers fire, screens render the right text,
choices reach the engine, saves and the log are written.
It does not cover layout, CSS, the dark-mode palette, file downloads or file
imports. Those need a human with a browser, and should be treated as unverified
until someone has clicked them.
## 19. The calendar is the pack's, derived by the engine
First playtest: *"wasn't clear what daily drain was for… should probably pay
rent weekly / get paid every other week… would be good to have day of week
shown and week # instead of just day #"*.
A pack may declare `calendar: { cycleLength, cycleName, unitNames }`. The
engine derives `calendar.index`, `calendar.cycle` and `calendar.name` from the
turn number and writes them into engine-owned state *before* the turn's event
is drawn — so content can require a Friday, and upkeep can charge rent on a
Monday. A pack that declares no calendar has none; the engine imposes no week
of its own, and `turnUnit` remains whatever the pack says.
## 20. Upkeep runs on a schedule
`every` and `offset` on an upkeep entry make it periodic: wages are
`{ every: 14, offset: 4 }` — alternate Fridays, given that turn 1 is a Monday —
and rent is `{ every: 7, offset: 7 }`. What was a flat $25/day of unexplained
drain is now $18 of small change, a fortnightly paycheque and a weekly rent
cheque, which is both easier to plan around and easier to feel.
Two display consequences, both of which were the real cause of the "unclear
drain" complaint. The ledger now labels a change with the upkeep entry that
caused it ("Rent −$455") rather than the path it moved ("Money −$455"). And
the turn screen carries a diary line — "Wages tomorrow · Rent in 4 days" —
computed generically from any pack's scheduled entries, so the player is told
what is coming before it happens rather than after.
## 21. Content coverage is measured across player archetypes
Playtest: *"always in debt — could never get ahead"*. The economy was retuned so
that a careful player climbs, a careless one sinks, and an unplanned one treads
water, with a `spare_shift` event that surfaces *because* you are broke — the
way out of a hole should be visible from inside it.
That retune broke the old coverage test, and usefully. Random play stopped
reaching hard times at all, which made the whole debt branch look dead. It was
not dead; random play is simply not a plausible player.
Coverage is now the union across five archetypes (`test/fixtures/strategies.js`)
— random, thrifty, spendthrift, sociable, and "desperate", which is broke *and*
well-liked. That last one exists because the "ask a friend for money" options
sit in a state no single-axis strategy ever reaches. The economy's shape is
asserted as an ordering between archetypes rather than as absolute figures, so
tuning does not churn the tests.
## 22. Stage is the one piece of engine state content may write
A promotion is `{ path: 'stage', op: 'set', value: 'dispatch' }` on an option's
effects. Nothing else about it exists in the engine — `stage` was already how
events are filtered, and moving the player between stages is a content
decision, so the reserved-write rule now allows it.
It allows it narrowly. Only `set`, and only to a stage that actually has events;
both are checked by the validator. A typo cannot strand the player somewhere
with an empty event pool.
Wages differ by rank through two upkeep entries gated on `stage`, not through
any notion of rank in the engine. The hard-times events dropped their `stage`
entirely and so apply everywhere — rent is due wherever you work, and that
branch has to follow the player up rather than vanishing on promotion.
## 23. What "reachable" means for an option
The coverage test used to ask whether an archetype ever *took* an option. That
is the wrong question: whether a bot picks something is an artefact of how the
bot scores, not a property of the pack. `rent_bounced.ask_trevor` was offered
and unlocked 419 times across a sweep and chosen once, and the test called it
dead content.
Events are still checked on firing, since the engine draws those. Options are
checked on *availability*: every option must, at some point, be offered to a
player unlocked. What they then do with it is theirs.
## 24. Variety is a measured property of the pack
Playtest: a 183-turn session, *"mailroom stayed mostly interesting despite the
number of repeats… what might be of interest is seeing how many turns are
played versus how many unique situations."*
So it is now measured, in three places.
`tools/analyze-log.js` reads an exported feedback log and reports turns against
distinct situations, how often each recurred, which options were most used,
which locked gates players kept meeting, and anything they typed. The
retrospective shows the player-facing version of the same thing.
And a test asserts no single event exceeds 25% of a 200-turn run, and that a run
sees more than 60% of what was reachable in the stages it visited — measured
against reachable content, since a run that never leaves the mailroom cannot see
Dispatch and counting that as thin would be measuring career progress instead.
That guardrail exists because the first version of Dispatch failed it badly:
eight events against the mailroom's nineteen meant its two floor events were 46%
of every promoted run, one every three turns. Promotion was moving the player
into *thinner* content. Three more events and a weight rebalance took the top
two to 24.5%, and the heaviest recurrence from every ~3 turns to every ~5.
## 25. The screen says which build it is
Playtest: *"need entry on the main screen showing what version we're running…
so it's easy to tell when new stuff is."*
A status line at the top of every screen names the game, the pack, the player's
current title, and the build.
`src/build-info.js` is tracked and permanently reads `commit: 'dev'`; it is
never rewritten. `tools/build.js` substitutes the real commit into the *bundled
output* only, so `dist/theladder.html` states exactly which commit produced it
while the working tree never churns. A build from a dirty tree gets a `+`
suffix — "5c1784d+" is honest in a way a bare SHA would not be, and the build
fails loudly if the substitution finds nothing to replace.
Running from source therefore reads "0.3.0 · dev", which is what it is.
The first attempt stamped the tracked file in place before every serve and
build. It was wrong in a way worth recording: after each commit the committed
stamp named the *previous* commit, and the next serve rewrote it and dirtied the
tree. A generated value does not belong in a tracked file if anything routinely
regenerates it.
## 26. The whole game is playable from the keyboard
Playtest: *"when I get the results, the focus should automatically be on the
next day, so I can just hit Enter."*
Every screen marks one control with `dataset.autofocus` and the app focuses it
after each render: the first *available* option while choosing, the next-turn
button once the result is in, "keep going" on the retrospective. A run is
therefore playable on Enter alone, with Tab and Shift+Tab to reach a different
option.
Locked options need no special handling — the browser's own tab order skips
disabled controls, so they stay visible without being in the way, which is
exactly the behaviour wanted.
## 27. The ledger is written for a player, not for the engine
Two fixes from the same playtest note, *"what is notable? under what changed?"*.
`notable` is the retrospective's own list of moments. Reading "Notable —
changed" in the turn ledger tells nobody anything, so a display rule may now
say `inLedger: false` and it is left out.
Flags are the opposite: a flag flipping is a fact about the world and worth
seeing. But "the envelope: now true" is engine talk. A display rule can supply
`whenTrue` and `whenFalse` sentences — "The envelope is in your locker." — and
the ledger prints those instead of a delta.
## 28. Exported logs live in logs/, and are not committed
`logs/` holds exported feedback logs and `npm run analyze` reads the newest one
there when given no argument. The logs are gitignored: they record what a real
person did turn by turn, including anything they typed into the feedback box.
That is not repository content, and making it so once would put it in the
history for good.
## 29. The economy needs a test on its arithmetic, not just on its outcomes
A real 182-turn session spent 30% of its turns below zero, went negative on turn
18 and never recovered, and chose the same hard-times option — "take every shift
going" — thirty-seven times.
The cause was a tuning change made without re-deriving what it meant. Rent went
from 385 to 455 while chasing a different problem, which took the mailroom
baseline from the designed −$3/day to −$13/day. Worse, at the debt that run
accumulated, the weekly service charge came to $180 against an escape option
worth $120: the hole was inescapable *by arithmetic*, whatever the player did.
Nothing caught it. Every archetype either optimised money or was promoted out of
the problem before it bit, and the 25%-dominance test passed because
`rent_bounced` was only 17% of turns.
Three things came out of it.
The baseline is now asserted directly — a test sums the pack's own upkeep over
twenty fortnights and requires the mailroom to net between −90 and +10, and
Dispatch to be better but under +250. That test would have failed the moment
rent changed. Outcome tests over simulated play are worth having, but they are a
slow and noisy way to detect a number that is simply wrong.
A `lifer` archetype was added: it refuses any option that would change stage —
generically, by looking for an effect on `stage` — and otherwise plays for
people. It reproduces the session that found this, and no other archetype can.
And a test now holds that no archetype spends more than a quarter of a run below
zero. Hard times is a state a player passes through. Living in it is the
failure mode.
Fixes: mailroom wages 980 → 1120, the escape option 120 → 220 so it is worth
more than a week's rent, `rent_bounced` from weight 5000 with no cooldown to 900
on a cooldown of 3 — being broke should colour a run, not replace it — and
`debt_collector` from weight 25 to 45, since at 25 it appeared six times in 262
turns and debt became a one-way ratchet.
## 30. Gaps are measured within a run, never across two
`tools/analyze-log.js` reported "every ~-5 turns". One export can hold several
playthroughs and turn numbers restart with each, so a first-seen in run two
compared against a last-seen in run one produces nonsense.
Spans and pair-counts are now accumulated per run and pooled, so the figure is a
proper weighted average of within-run gaps. It is worth stating the general
form: any statistic over a log has to respect the run boundary, because a log is
not one sequence.
## 31. A pack declares the numbers it must hit
Decision #29 added a test that asserts Corporate Ladder's economic baseline
directly, because a tuning change made without re-deriving what it meant had
trapped a real playtester. That test named the pack, named its stages and
hard-coded its ranges, which meant a second pack got none of it — the guard
that exists precisely because a number went unchecked would itself have gone
uncopied.
So the numbers moved into the pack, as `tuning.baselinePerCycle`:
```js
tuning: {
baselinePerCycle: { hand: [0, 12], deputy: [10, 30] },
},
```
The conformance suite reads that and holds each pack to its own declared range.
A new setting is checked the moment it is in the registry, and what it is
checked against is its designer's stated intent rather than another setting's
numbers. The pack is required to declare it: a missing `tuning` block fails,
because the silent failure mode here is a pack that is never economically
tested at all.
The figure is **derived, not simulated**. An upkeep entry worth `v` every `n`
turns is worth `v * cycleLength / n` per cycle, exactly. Simulating it over a
fixed window reads differently depending on where the last rent lands: Corporate
Ladder measures −19 a fortnight over 280 turns and −42 over 560, and only the
second is the steady state. The original test used the 280-turn window and was
reading a number 23 dollars off the truth — inside its own tolerance, so it
never said so.
## 32. A skill gate is never gated behind itself
Corporate Ladder gates four options on `skills.negotiation >= 15`, and several
of its largest negotiation gains sit behind that same gate — `+4` on
`marlene_confides.ask_how_to_leave`, `+3` on `trevor_favor.trade`. Its logs show
players meeting the wall repeatedly through the first thirty turns.
Measured, the gate is not unreachable: a player steering directly for the skill
is through it by turn 11. What the logs actually show is ordinary players not
prioritising it and having no incidental route, which is a pacing failure rather
than a reachability one — and the reachability test could never have caught it,
because reachability is exactly what holds.
The Frontier is built to a rule instead, asserted in `test/frontier.test.js`:
for every skill threshold in the pack, the growth available *without* passing
that threshold must be able to reach it, from repeatable events — a single
one-off award is not a route. A second test measures it, requiring a player who
steers for a skill to be through its lowest gate inside twenty turns.
The rule is deliberately not in the conformance suite, because Corporate Ladder
does not satisfy it and retuning that pack is a separate decision from adding
this one. It is written down here so the choice is visible rather than implied.
## 33. One save slot per pack, and the picker is the front door
`theladder.save.v1` was a single global slot. The app guarded it by comparing
the packId inside the save against the pack it was about to run, and started a
new game when they differed — so with two settings, the first switch would have
silently destroyed the other career.
Slots are now `theladder.save.v1.<packId>`, one per setting, and switching is
non-destructive in both directions: each run is written to its own slot every
turn, and coming back resumes rather than restarts. `theladder.pack.v1` holds
the last setting played, so a returning player lands back where they were.
The pre-packs slot is still read once, for Corporate Ladder only, and only when
it really holds a Corporate Ladder save. It is never written. Someone mid-career
when this shipped finds their career.
A first-time player — nothing saved, nothing remembered — gets the setting
picker rather than being dropped into whichever pack is first in the registry.
A build shipping one pack skips it entirely.
## 34. What a second setting proved, and what it cost
The engine/content split was a claim until there were two settings. The
Frontier is 29 events across two stages, a four-day cycle instead of a
seven-day one, a different relationship vocabulary (`trusts_you`, `owes_you`,
`wary_of_you`), a different skill pair, and money about fifteen times smaller
that arrives from work rather than from a wage. `src/engine/` was not touched.
Three things outside the engine did have to change, and none of them was about
the setting:
- **`src/io/save.js`** — the single save slot, above.
- **`src/ui/app.js`** — it named one pack; it now holds the current one. The
screens, the engine and the rest of the io layer still take the pack as an
argument and are no less pack-agnostic than before.
- **`src/ui/screens/turn.js:40`** — hardcoded `'The mailroom'` as the heading
over the people panel. That was the interface naming a setting, against
decision #14, and it had gone unnoticed while only one setting existed. It is
now `pack.peopleHeading`.
`tools/build.js` needed one fix, unrelated to packs but exposed by them: its
module scan used `.*?` where `transform` used a pattern that spans lines, so a
destructured import wrapped across several lines was rewritten into a
`__require` for a module the scan had never collected. The bundle built clean
and failed only when run. Both now use `[\s\S]*?`.
The honest summary is that adding a setting required no engine change and three
interface changes, two of which were latent defects that a second pack merely
made visible.
## 35. Three open questions, closed by the designer
Recorded 2026-09-11, alongside 0.3.1.
**Corporate Ladder's negotiation gate stays.** A gate that takes deliberate work
to get past is acceptable, and so is an option that needs groundwork first. #32
remains the rule the Frontier is built to and tested against; it is a choice a
pack may make, not a standard every pack must meet, and Corporate Ladder is not
being retuned toward it.
**The feedback widget stays exactly as it is.** Zero ratings across three
sessions says nothing about the widget: those playtests were rapid, and feedback
came back directly rather than through it. It is not evidence either way until a
playtest runs without that direct channel.
**Money has to matter late, through the cost of standing.** A careful player
ends around $8,300 over 200 turns, and money stops being a constraint. The
direction is that rising standing brings rising expenses — the car, the place to
live, the clothes a position expects. Upkeep entries are already gated
conditions over state, so this is content work, not an engine change.
## 37. A playtest is only as good as its instruments
The second session — 181 turns of the Frontier, the first time that pack has
been played by a human at all — ran against a **stale bundle**. The page had
been loaded before the last rebuild, and switching settings inside an open tab
does not re-fetch the script, so the run exercised everything except the change
it was meant to test. The charge that never stops fired zero times in ninety
turns where its conditions held.
Working that out took file timestamps and a simulation. It should have taken one
line of the log, so three things changed:
- **Every logged turn now records `app_version` and `build`.** The log says
which bundle produced it, and a stale session is visible immediately rather
than looking exactly like a feature that does not work.
- **`src/version.js` was lying.** It hardcoded `0.3.0` through the whole of the
0.3.1 and 0.3.2 work, while playtesters were told to check the status bar to
know what they were on. A version string that lies is worse than none, because
it is trusted. A test now holds it to `package.json`.
- **A locked door is only worth showing when it is a door.** `pay_down` — "put
money against the book" — was the most-locked option in the session, shown six
times to a player who owed nothing. Options gated on a state the player is not
in and cannot choose to enter now carry `whenLocked: 'hide'`. Gates worth
bouncing off, like a skill threshold, stay visible: that is the signal the log
exists to collect.
## 36. Standing costs money to keep
Built in both packs, with no engine change, exactly as #35 predicted.
Each pack declares a `lifestyle` tier — Corporate Ladder's car and address, the
Frontier's own horse and the room over the Ellis place — bought at a second-rung
event and charged for every cycle thereafter. Dispatch nets +49 a week; with the
car +19; with both, **-21**, so a coordinator keeping up has to earn the
difference out of the days themselves. The Frontier runs the same shape at its
own scale: +18, +11, +4 a run.
**The charge that makes it a choice is levied in standing, not money.** A player
whose lifestyle lags behind the job loses standing every cycle, in steps that
steepen as standing rises. Both are gated on the second stage: the first rung
pays too little for any of this, and charging a mailroom associate for a car
would be a trap rather than a decision.
Three things were measured rather than guessed, and all three changed the design:
- **At -2 a week the charge was invisible.** Dispatch hands out standing several
points at a time, so a coordinator who bought nothing still finished pinned at
100. The step had to reach -4/-6 before ordinary play settled in the seventies.
- **A player who chased standing every turn beat it anyway** — 99 standing while
buying nothing, and $2,245 richer than one who kept up. The lifestyle was a
pure loss to anybody paying attention, which is the opposite of the intent.
The top step (-14 above 80 standing) puts that player's ceiling at 82, so the
last fifteen points are bought rather than worked for.
- **The Frontier's first numbers left a keeper on $8 after 200 turns.** That is
not pressure, it is the debt trap this pack was tuned twice to avoid; the room
went from 9 a run to 7.
**Which upkeep counts as the baseline is now the pack's to declare.** The
conformance suite used to exclude debt-gated entries by matching
`/debt|owed|loan/i` against path names — the generic suite guessing at content's
vocabulary, which #31 exists to prevent, and which would have silently counted
every lifestyle tier at once into a baseline that is supposed to mean "the floor
a player faces having opted into nothing." Packs now name those paths in
`tuning.baselineExcludes`, and the suite fails if one is not declared state.
**The top of the ladder was a plateau, so one charge never stops.** The first
human play of the lifestyle system — 96 turns, Corporate Ladder — found both
purchases (the car at turn 52, the address at 75) and showed the standing
pressure working exactly as intended: "Looking the part" took 56 reputation, and
standing sagged into the low seventies on the car alone before recovering to 100
once the address was bought. But from turn 84 the run had nothing left to spend
on: every charge switches off at the top tier, and the balance simply climbed.
Money stopped mattering again, one rung higher than before.
Both packs now carry a charge levied by standing itself, which no purchase ends.
They are shaped differently, and the difference is measured rather than stylistic:
- **Corporate Ladder steps on money.** A flat charge cannot flatten a slope —
late income comes from choices and scales with how well the player plays, so
the flat charge needed to stop an optimiser accumulating (-360 a week) put
ordinary archetypes below zero for 44% of a run and ended them $1,385 in debt.
Stepping on money ($1,200/-60, $2,400/-120, $3,600/-240) lands it on whoever
has it: the balance settles around $4,250 and stops climbing (-3 a week), and
ordinary play comes out *better* than before the charge existed — 13% of a run
below zero against 17%, a tier-2 run ending +$175 rather than -$25.
- **The Frontier is flat, at -4 a run.** Its late game never accumulated in the
first place — a deputy who owns everything drifts 0 a run at every charge
strength tested — so there is no slope to flatten. The charge is there to keep
money live, and break-even at the top is the intent.
**A save written before a stat existed silently loses the content gated on it.**
Found while working out how to playtest this: `loadLocal` returns the saved
state verbatim, and `conditions.js` compares numbers only — so for a career
saved before `lifestyle` existed, both `lifestyle < 2` and `lifestyle >= 1` are
false. The car lot never fires, the purchase never unlocks, the charge never
lands, and nothing says so. Resuming a save would have shown a playtester none
of this feature while reporting nothing wrong. Loading now fills in any path the
pack has gained since (`hydrate`, in `state.js`), writes the repair back, and
does the same for an imported file. This is the price of "adding content is just
adding data", and it applies to every stat any pack adds from here on.
**And a test that had passed for two releases was measuring the wrong thing.**
"The promotion leaves a player better off" compared the end balances of promoted
and unpromoted players pooled across archetypes — but which archetypes get
promoted is not independent of how they spend, so the comparison mostly measured
the mix. It inverted the moment there was something to spend money on. It is now
derived from upkeep arithmetic, and the lifestyle's effect is tested with two
players who differ in exactly one habit.
+22
View File
@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>The Ladder</title>
<meta name="description" content="A turn-based career simulation. Start in the mailroom.">
<link rel="stylesheet" href="src/ui/styles.css">
</head>
<body>
<div id="app" class="app">
<p class="booting">Loading…</p>
</div>
<noscript>
<p class="booting">The Ladder needs JavaScript. It runs entirely in your browser — nothing is sent anywhere.</p>
</noscript>
<script type="module">
import { boot } from './src/ui/app.js';
boot(document.getElementById('app'));
</script>
</body>
</html>
+13
View File
@@ -0,0 +1,13 @@
# Exported playtest logs
Drop exported feedback logs here — the browser downloads them wherever it
normally does, and this is where they go to be read.
```sh
npm run analyze # the newest log in this folder
npm run analyze -- <file> # a specific one
```
The logs themselves are gitignored. They record what a real person did, turn by
turn, including anything they typed into the feedback box, so they are not
committed: keep them local, or share them deliberately.
+13
View File
@@ -0,0 +1,13 @@
{
"name": "theladder",
"version": "0.3.3",
"private": true,
"description": "A turn-based career simulation game. Engine is theme-agnostic; settings are content packs.",
"type": "module",
"scripts": {
"test": "node --test \"test/**/*.test.js\"",
"serve": "python3 -m http.server 8080",
"build": "node tools/build.js",
"analyze": "node tools/analyze-log.js"
}
}
+10
View File
@@ -0,0 +1,10 @@
// The build identity shown in the status bar.
//
// This file is never rewritten. Running from source is honestly "dev";
// tools/build.js substitutes the real commit into the bundled output only, so
// dist/theladder.html names the commit that produced it and the working tree
// never churns.
export const BUILD = {
commit: 'dev',
builtAt: null,
};
+68
View File
@@ -0,0 +1,68 @@
// Generic predicates over state paths.
//
// A condition is either a comparison — { path, op, value } — or a combinator:
// { all: [...] }, { any: [...] }, { not: {...} }. Content uses these for
// option gates, event triggers and anything else that needs to ask a question
// about state. The engine never learns what the paths mean.
import { getPath } from './paths.js';
const COMPARATORS = {
'==': (a, b) => a === b,
'!=': (a, b) => a !== b,
'>': (a, b) => typeof a === 'number' && typeof b === 'number' && a > b,
'>=': (a, b) => typeof a === 'number' && typeof b === 'number' && a >= b,
'<': (a, b) => typeof a === 'number' && typeof b === 'number' && a < b,
'<=': (a, b) => typeof a === 'number' && typeof b === 'number' && a <= b,
'in': (a, b) => Array.isArray(b) && b.includes(a),
'not-in': (a, b) => Array.isArray(b) && !b.includes(a),
'has': (a, b) => Array.isArray(a) && a.includes(b),
'not-has': (a, b) => Array.isArray(a) && !a.includes(b),
};
export const OPERATORS = Object.keys(COMPARATORS);
/** True if `condition` holds against `state`. */
export function evaluate(state, condition) {
if (condition === null || typeof condition !== 'object') {
throw new TypeError(`condition must be an object, got ${JSON.stringify(condition)}`);
}
if (Array.isArray(condition)) return condition.every((c) => evaluate(state, c));
if ('all' in condition) return condition.all.every((c) => evaluate(state, c));
if ('any' in condition) return condition.any.some((c) => evaluate(state, c));
if ('not' in condition) return !evaluate(state, condition.not);
const { path, op, value } = condition;
const compare = COMPARATORS[op];
if (!compare) throw new TypeError(`unknown condition operator ${JSON.stringify(op)}`);
return compare(getPath(state, path), value);
}
/**
* Evaluate a list of conditions, returning the ones that failed. An empty or
* absent list passes. The failures are what the UI greys out an option with
* and what the feedback log records as "the player could not pick this".
*/
export function check(state, conditions) {
if (!conditions) return { passed: true, failed: [] };
const list = Array.isArray(conditions) ? conditions : [conditions];
const failed = list.filter((condition) => !evaluate(state, condition));
return { passed: failed.length === 0, failed };
}
/** Every path a condition tree reads. Used by the validator. */
export function pathsUsed(condition, out = []) {
if (condition === null || typeof condition !== 'object') return out;
if (Array.isArray(condition)) {
for (const c of condition) pathsUsed(c, out);
} else if ('all' in condition) {
for (const c of condition.all) pathsUsed(c, out);
} else if ('any' in condition) {
for (const c of condition.any) pathsUsed(c, out);
} else if ('not' in condition) {
pathsUsed(condition.not, out);
} else if (typeof condition.path === 'string') {
out.push(condition.path);
}
return out;
}
+62
View File
@@ -0,0 +1,62 @@
// Generic mutations over state paths.
//
// An effect is { path, op, value }. Applying a list returns a new state plus
// a change record — the before/after of every path touched. That record is
// what the feedback log stores, so "what did this choice actually do" is
// answerable after the fact without replaying the run.
import { getPath, setPath } from './paths.js';
const OPS = {
add: (current, value) => (typeof current === 'number' ? current : 0) + value,
set: (_current, value) => value,
push: (current, value) => [...(Array.isArray(current) ? current : []), value],
remove: (current, value) => (Array.isArray(current) ? current.filter((v) => v !== value) : []),
};
export const OPERATIONS = Object.keys(OPS);
/** Constrain a value to the bounds its schema entry declares, if any. */
function clamp(value, rule) {
if (!rule || typeof value !== 'number') return value;
let out = value;
if (typeof rule.min === 'number') out = Math.max(rule.min, out);
if (typeof rule.max === 'number') out = Math.min(rule.max, out);
return out;
}
/**
* 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 operate = OPS[op];
if (!operate) throw new TypeError(`unknown effect operation ${JSON.stringify(op)}`);
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 },
};
}
/** Apply a list of effects in order. Returns the new state and every change. */
export function applyEffects(state, effects, schema = {}) {
let next = state;
const changes = [];
for (const effect of effects ?? []) {
const result = applyEffect(next, effect, schema);
next = result.state;
changes.push(result.change);
}
return { state: next, changes };
}
/** Every path a list of effects writes. Used by the validator. */
export function pathsUsed(effects) {
return (effects ?? []).map((effect) => effect.path).filter((p) => typeof p === 'string');
}
+155
View File
@@ -0,0 +1,155 @@
// The turn loop.
//
// Purely functional: a turn is (pack, state, optionId) -> new state. The RNG
// is rebuilt from the seed and position carried in state, so a saved game
// resumes into exactly the run it left, and a test can replay a whole career
// with no hidden state anywhere.
import { createRng } from './rng.js';
import { applyEffects } from './effects.js';
import { check } from './conditions.js';
import { resolveSchema, createState } from './state.js';
import { selectEvent, resolveOptions, findOption } from './select.js';
import { setPath, getPath } from './paths.js';
export class ContentError extends Error {}
export class MoveError extends Error {}
/** Draw the next event and record where the generator ended up. */
function advance(pack, state) {
// The calendar is settled before the draw, so an event can require a Friday.
const dated = applyCalendar(pack, state);
const rng = createRng(dated.meta.seed, dated.meta.rngPosition ?? undefined);
const event = selectEvent(pack, dated, rng);
if (!event) {
throw new ContentError(
`no event is eligible at stage "${dated.stage}" on turn ${dated.turn}; ` +
'every stage needs at least one event with no conditions',
);
}
let next = setPath(dated, 'meta.currentEventId', event.id);
next = setPath(next, 'meta.rngPosition', rng.getPosition());
return next;
}
/**
* Derive the calendar from the turn number.
*
* A pack that declares `calendar` gets `calendar.index` (position in the
* cycle), `calendar.cycle` (which cycle, 1-based) and `calendar.name` written
* into engine-owned state before the turn's event is chosen — so content can
* gate on the day of the week, and upkeep can charge rent on one of them. A
* pack that declares no calendar simply has none; the engine imposes no week.
*/
function applyCalendar(pack, state) {
const calendar = pack.calendar;
if (!calendar?.cycleLength) return state;
const elapsed = state.turn - 1;
const index = ((elapsed % calendar.cycleLength) + calendar.cycleLength) % calendar.cycleLength;
let next = setPath(state, 'calendar.index', index);
next = setPath(next, 'calendar.cycle', Math.floor(elapsed / calendar.cycleLength) + 1);
return setPath(next, 'calendar.name', calendar.unitNames?.[index] ?? null);
}
/**
* Whether a scheduled upkeep entry falls due this turn. `every` is a period in
* turns and `offset` the first turn it lands on (zero-based), so wages every
* fortnight starting on the seventh day are `{ every: 14, offset: 6 }`. An
* entry with no `every` runs every turn.
*/
function isDue(entry, state) {
if (!entry.every) return true;
const elapsed = state.turn - 1 - (entry.offset ?? 0);
return elapsed >= 0 && elapsed % entry.every === 0;
}
/**
* Apply the pack's per-turn upkeep — wages in, rent out, whatever a loan
* costs to carry. Each entry may carry its own conditions, evaluated after the
* player's choice has landed, so upkeep is content's business and not a
* special case in the loop.
*/
function applyUpkeep(pack, state, schema) {
let next = state;
const changes = [];
for (const entry of pack.upkeep ?? []) {
if (!isDue(entry, next)) continue;
if (!check(next, entry.requires).passed) continue;
const applied = applyEffects(next, entry.effects, schema);
next = applied.state;
changes.push(...applied.changes.map((change) => ({ ...change, label: entry.label ?? null })));
}
return { state: next, changes };
}
/** Begin a run. `seed` is any string or number; it is stored in the save. */
export function startGame(pack, { seed = Date.now(), schema = resolveSchema(pack), now = null } = {}) {
let state = createState(pack, { seed, schema });
state = setPath(state, 'meta.startedAt', now);
return advance(pack, state);
}
/** The event the player is looking at. */
export function currentEvent(pack, state) {
const id = getPath(state, 'meta.currentEventId');
const event = (pack.events ?? []).find((e) => e.id === id);
if (!event) throw new ContentError(`current event "${id}" is not in pack "${pack.id}"`);
return event;
}
/** The current event together with each option's availability. */
export function currentTurn(pack, state) {
const event = currentEvent(pack, state);
return { event, options: resolveOptions(state, event) };
}
/**
* Play one option. Returns the new state and a record of what happened —
* the record is what the feedback log stores, and it deliberately includes the
* options that were locked, not just the one that was taken.
*/
export function takeTurn(pack, state, optionId, { schema = resolveSchema(pack), now = null } = {}) {
const event = currentEvent(pack, state);
const option = findOption(event, optionId);
if (!option) throw new MoveError(`event "${event.id}" has no option "${optionId}"`);
const resolved = resolveOptions(state, event);
const chosen = resolved.find((r) => r.option.id === optionId);
if (!chosen.available) {
throw new MoveError(`option "${optionId}" is locked: ${JSON.stringify(chosen.failed)}`);
}
const applied = applyEffects(state, option.effects, schema);
let next = applied.state;
const seenCount = getPath(next, `seen.${event.id}`) ?? 0;
next = setPath(next, `seen.${event.id}`, seenCount + 1);
next = setPath(next, `lastSeen.${event.id}`, next.turn);
const upkeep = applyUpkeep(pack, next, schema);
next = upkeep.state;
const record = {
turn: next.turn,
stage: next.stage,
eventId: event.id,
optionId: option.id,
optionLabel: option.label,
changes: applied.changes,
upkeep: upkeep.changes,
offered: resolved.map((r) => ({
id: r.option.id,
label: r.option.label,
available: r.available,
failed: r.failed,
})),
at: now,
};
next = setPath(next, 'history', [...next.history, record]);
next = setPath(next, 'turn', next.turn + 1);
next = advance(pack, next);
return { state: next, record };
}
+9
View File
@@ -0,0 +1,9 @@
// The engine's public surface. Nothing in src/engine may import from content/.
export { getPath, setPath, hasPath, parsePath, listPaths } from './paths.js';
export { createRng } from './rng.js';
export { evaluate, check, OPERATORS } from './conditions.js';
export { applyEffect, applyEffects, OPERATIONS } from './effects.js';
export { createState, resolveSchema, indexCharacters, RESERVED_ROOTS } from './state.js';
export { eligibleEvents, selectEvent, resolveOptions, findOption } from './select.js';
export { startGame, currentEvent, currentTurn, takeTurn, ContentError, MoveError } from './game.js';
export { validatePack, assertValidPack } from './validate.js';
+83
View File
@@ -0,0 +1,83 @@
// Path addressing for game state.
//
// Every piece of game state is reachable by a dotted path string: `money`,
// `skills.negotiation`, `relationships.boss.likes_you`, `flags.took_loan`.
// Conditions, effects, gates and event triggers are all expressed against
// these paths, which is what lets content reach any part of state without the
// engine knowing what that part means.
const SEGMENT = /^[A-Za-z0-9_]+$/;
const FORBIDDEN = new Set(['__proto__', 'prototype', 'constructor']);
/** Split a path into segments, throwing on anything malformed or unsafe. */
export function parsePath(path) {
if (typeof path !== 'string' || path.length === 0) {
throw new TypeError(`path must be a non-empty string, got ${JSON.stringify(path)}`);
}
const segments = path.split('.');
for (const segment of segments) {
if (!SEGMENT.test(segment)) {
throw new TypeError(`invalid path segment ${JSON.stringify(segment)} in "${path}"`);
}
// Content is data, and data can be authored by anyone; never let a path
// walk into the prototype chain.
if (FORBIDDEN.has(segment)) {
throw new TypeError(`forbidden path segment ${JSON.stringify(segment)} in "${path}"`);
}
}
return segments;
}
/** Read a path. Returns undefined if any link in the chain is missing. */
export function getPath(state, path) {
let cursor = state;
for (const segment of parsePath(path)) {
if (cursor === null || typeof cursor !== 'object') return undefined;
if (!Object.prototype.hasOwnProperty.call(cursor, segment)) return undefined;
cursor = cursor[segment];
}
return cursor;
}
/** True if the path exists (distinct from resolving to undefined). */
export function hasPath(state, path) {
let cursor = state;
for (const segment of parsePath(path)) {
if (cursor === null || typeof cursor !== 'object') return false;
if (!Object.prototype.hasOwnProperty.call(cursor, segment)) return false;
cursor = cursor[segment];
}
return true;
}
/**
* Write a path, returning a new state. Ancestors along the path are copied;
* everything else is shared, so this is cheap to call in a loop.
*/
export function setPath(state, path, value) {
const segments = parsePath(path);
const root = { ...state };
let cursor = root;
for (let i = 0; i < segments.length - 1; i++) {
const segment = segments[i];
const child = cursor[segment];
cursor[segment] = (child !== null && typeof child === 'object') ? { ...child } : {};
cursor = cursor[segment];
}
cursor[segments[segments.length - 1]] = value;
return root;
}
/** Every leaf path in an object, in depth-first order. Used by the validator. */
export function listPaths(object, prefix = '') {
const out = [];
for (const [key, value] of Object.entries(object)) {
const path = prefix ? `${prefix}.${key}` : key;
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
out.push(...listPaths(value, path));
} else {
out.push(path);
}
}
return out;
}
+64
View File
@@ -0,0 +1,64 @@
// Seeded, serializable random number generator.
//
// Every draw the game makes comes from here, so a run is fully reproducible
// from its seed plus the player's choice sequence. That makes tests
// deterministic and playtest reports replayable: the seed and the generator's
// position both live in the save file and the feedback log.
/** Hash an arbitrary string seed down to a uint32. */
function hashSeed(seed) {
let h = 1779033703 ^ String(seed).length;
for (let i = 0; i < String(seed).length; i++) {
h = Math.imul(h ^ String(seed).charCodeAt(i), 3432918353);
h = (h << 13) | (h >>> 19);
}
h = Math.imul(h ^ (h >>> 16), 2246822507);
h = Math.imul(h ^ (h >>> 13), 3266489909);
return (h ^ (h >>> 16)) >>> 0;
}
/**
* mulberry32: one uint32 of state, which is the whole point — the generator's
* position round-trips through JSON with no special handling.
*/
export function createRng(seed, position) {
let state = position === undefined ? hashSeed(seed) : position >>> 0;
const next = () => {
state = (state + 0x6d2b79f5) >>> 0;
let t = state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
return {
seed,
/** Float in [0, 1). */
next,
/** Integer in [0, n). */
int: (n) => Math.floor(next() * n),
/** Uniform pick from a non-empty array. */
pick(items) {
if (!items.length) throw new RangeError('cannot pick from an empty array');
return items[Math.floor(next() * items.length)];
},
/**
* Weighted pick. `weightOf` must return a non-negative number; entries
* weighing zero are never drawn. Returns undefined if nothing has weight.
*/
weighted(items, weightOf) {
let total = 0;
for (const item of items) total += Math.max(0, weightOf(item));
if (total <= 0) return undefined;
let roll = next() * total;
for (const item of items) {
roll -= Math.max(0, weightOf(item));
if (roll < 0) return item;
}
return items[items.length - 1];
},
/** Current position, for saving mid-run. */
getPosition: () => state,
};
}
+77
View File
@@ -0,0 +1,77 @@
// Choosing what happens this turn.
//
// There is deliberately no such thing as "a scripted turn" versus "a random
// event" in here. Every turn, the whole event pool is filtered by stage and
// conditions, then drawn from by weight. A routine turn is an event with a
// high weight and loose conditions; a rare interruption is one with a low
// weight and tight conditions; the "hard times" branch is one whose
// conditions include a money threshold. The engine branches on none of it.
import { check } from './conditions.js';
import { getPath } from './paths.js';
/** Events that could fire right now, with the reason excluded ones could not. */
export function eligibleEvents(pack, state) {
const eligible = [];
const excluded = [];
for (const event of pack.events ?? []) {
if (event.stage !== undefined && event.stage !== state.stage) {
excluded.push({ event, reason: 'stage' });
continue;
}
const seenCount = getPath(state, `seen.${event.id}`) ?? 0;
if (event.once && seenCount > 0) {
excluded.push({ event, reason: 'once' });
continue;
}
if (event.cooldown) {
const lastTurn = getPath(state, `lastSeen.${event.id}`);
if (lastTurn !== undefined && state.turn - lastTurn < event.cooldown) {
excluded.push({ event, reason: 'cooldown' });
continue;
}
}
const gate = check(state, event.requires);
if (!gate.passed) {
excluded.push({ event, reason: 'requires', failed: gate.failed });
continue;
}
eligible.push(event);
}
return { eligible, excluded };
}
/**
* Draw this turn's event. Returns undefined when nothing is eligible, which is
* a content bug rather than a game state — the validator checks that at least
* one unconditional event exists per stage so this cannot happen in practice.
*/
export function selectEvent(pack, state, rng) {
const { eligible } = eligibleEvents(pack, state);
return rng.weighted(eligible, (event) => event.weight ?? 1);
}
/**
* Every option on an event, in authored order, marked available or locked and
* carrying the conditions that locked it. The UI greys locked options out; the
* feedback log records them, because what a player *could not* choose is the
* strongest signal that the choice set is too narrow.
*/
export function resolveOptions(state, event) {
return (event.options ?? []).map((option) => {
const gate = check(state, option.requires);
return {
option,
available: gate.passed,
failed: gate.failed,
hidden: !gate.passed && option.whenLocked === 'hide',
};
});
}
/** Look up one option on an event by id. */
export function findOption(event, optionId) {
return (event.options ?? []).find((option) => option.id === optionId);
}
+91
View File
@@ -0,0 +1,91 @@
// Turning a content pack into a running game state.
//
// The pack declares what state exists and what it starts at; the engine
// expands that into a flat schema and an initial state object. The engine
// names no stat of its own beyond its own bookkeeping.
import { getPath, setPath } from './paths.js';
/**
* Paths the engine owns. Content may *read* these in conditions — "only after
* day 10", "only if this event hasn't fired" — but may not declare or write
* them, which is what keeps engine bookkeeping from being corrupted by data.
*/
export const RESERVED_ROOTS =
new Set(['turn', 'stage', 'seen', 'lastSeen', 'history', 'meta', 'calendar']);
/**
* Flatten a pack's state declarations into one path -> rule map, expanding the
* per-character relationship stats. `relationships.<characterId>.<stat>` is
* generated from `relationshipStats` x `characters`, so adding a character
* adds its relationship state automatically and naming a stat is the pack's
* business, never the engine's.
*/
export function resolveSchema(pack) {
const schema = {};
for (const [path, rule] of Object.entries(pack.state ?? {})) {
schema[path] = { ...rule };
}
for (const character of pack.characters ?? []) {
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 };
}
}
return schema;
}
/** A fresh game state for `pack`, seeded for reproducibility. */
export function createState(pack, { seed, schema = resolveSchema(pack) } = {}) {
let state = {
meta: {
packId: pack.id,
packVersion: pack.version ?? null,
seed: String(seed),
rngPosition: null,
startedAt: null,
},
turn: 1,
stage: pack.startingStage,
seen: {},
lastSeen: {},
calendar: {},
history: [],
};
for (const [path, rule] of Object.entries(schema)) {
state = setPath(state, path, structuredClone(rule.initial));
}
return 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);
}
return { state: next, added };
}
/** The characters a pack defines, keyed by id. */
export function indexCharacters(pack) {
return Object.fromEntries((pack.characters ?? []).map((c) => [c.id, c]));
}
+237
View File
@@ -0,0 +1,237 @@
// Load-time validation of a content pack.
//
// This is what makes "adding content is just adding data" safe. Without it, a
// typo in a path or a character id fails silently forty turns into a
// playthrough. Run it whenever a pack is loaded in development, and in tests.
import { RESERVED_ROOTS, resolveSchema, indexCharacters } from './state.js';
import { OPERATORS, pathsUsed as conditionPaths } from './conditions.js';
import { OPERATIONS } from './effects.js';
import { parsePath } from './paths.js';
const ID = /^[A-Za-z0-9_]+$/;
/**
* Check a pack. Returns { errors, warnings, schema }; errors mean the pack
* cannot be played, warnings mean it probably does not do what was intended.
*/
export function validatePack(pack) {
const errors = [];
const warnings = [];
const schema = resolveSchema(pack);
const characters = indexCharacters(pack);
const stateRoots = new Set(Object.keys(schema).map((p) => p.split('.')[0]));
const fail = (where, message) => errors.push(`${where}: ${message}`);
const warn = (where, message) => warnings.push(`${where}: ${message}`);
if (!pack.id || !ID.test(pack.id)) fail('pack', `id ${JSON.stringify(pack.id)} must match ${ID}`);
if (!pack.startingStage) fail('pack', 'startingStage is required');
// Content may read engine bookkeeping but must never declare or write it.
for (const path of Object.keys(schema)) {
try {
const root = parsePath(path)[0];
if (RESERVED_ROOTS.has(root)) {
fail('state', `"${path}" declares reserved root "${root}", which the engine owns`);
}
} catch (error) {
fail('state', `"${path}" is not a usable path: ${error.message}`);
}
}
const seenCharacters = new Set();
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 readable = (path) => {
let root;
try {
root = parsePath(path)[0];
} catch (error) {
return error.message;
}
if (RESERVED_ROOTS.has(root)) return null;
if (path in schema) return null;
if (stateRoots.has(root)) return `"${path}" is not declared in pack.state or relationshipStats`;
return `"${path}" is not declared anywhere in this pack`;
};
// Stages are the one piece of engine bookkeeping content may write: moving
// the player between them is a content decision (a promotion), and the
// engine only ever uses `stage` to filter events. The value is checked
// against the stages that actually have events, once those are known.
const stageWrites = [];
const writable = (path, effect, where) => {
let root;
try {
root = parsePath(path)[0];
} catch (error) {
return error.message;
}
if (path === 'stage') {
if (effect.op !== 'set') return `"stage" may only be written with set, not ${effect.op}`;
stageWrites.push({ where, value: effect.value });
return null;
}
if (RESERVED_ROOTS.has(root)) return `"${path}" writes engine-owned state`;
if (!(path in schema)) return `"${path}" is not declared in pack.state or relationshipStats`;
return null;
};
const checkConditions = (where, conditions) => {
if (!conditions) return;
const list = Array.isArray(conditions) ? conditions : [conditions];
for (const path of list.flatMap((c) => conditionPaths(c))) {
const problem = readable(path);
if (problem) fail(where, `condition reads ${problem}`);
}
const walk = (condition) => {
if (!condition || typeof condition !== 'object') return;
if (Array.isArray(condition)) return condition.forEach(walk);
if ('all' in condition) return condition.all.forEach(walk);
if ('any' in condition) return condition.any.forEach(walk);
if ('not' in condition) return walk(condition.not);
if (!OPERATORS.includes(condition.op)) {
fail(where, `unknown condition operator ${JSON.stringify(condition.op)}`);
}
};
list.forEach(walk);
};
if (pack.calendar) {
const { cycleLength, unitNames } = pack.calendar;
if (!Number.isInteger(cycleLength) || cycleLength < 1) {
fail('calendar', `cycleLength ${cycleLength} must be a positive whole number`);
}
if (unitNames && unitNames.length !== cycleLength) {
fail('calendar', `unitNames has ${unitNames.length} entries but cycleLength is ${cycleLength}`);
}
}
(pack.upkeep ?? []).forEach((entry, index) => {
const where = `upkeep[${index}]${entry.label ? ` "${entry.label}"` : ''}`;
if (entry.every !== undefined && (!Number.isInteger(entry.every) || entry.every < 1)) {
fail(where, `every ${entry.every} must be a positive whole number of turns`);
}
if (entry.offset !== undefined && (!Number.isInteger(entry.offset) || entry.offset < 0)) {
fail(where, `offset ${entry.offset} must be zero or a positive whole number`);
}
if (entry.offset !== undefined && entry.every === undefined) {
warn(where, 'offset has no effect without every');
}
checkConditions(where, entry.requires);
if (!entry.effects?.length) fail(where, 'has no effects');
for (const effect of entry.effects ?? []) {
if (!OPERATIONS.includes(effect.op)) {
fail(where, `unknown effect operation ${JSON.stringify(effect.op)}`);
}
}
for (const effect of entry.effects ?? []) {
if (typeof effect.path !== 'string') continue;
const problem = writable(effect.path, effect, where);
if (problem) fail(where, `effect writes ${problem}`);
}
});
for (const character of pack.characters ?? []) {
for (const stat of Object.keys(character.startingRelationship ?? {})) {
if (!(stat in (pack.relationshipStats ?? {}))) {
fail(`character "${character.id}"`, `startingRelationship names unknown stat "${stat}"`);
}
}
}
const stages = new Set();
const unconditional = new Set();
const seenEvents = new Set();
for (const event of pack.events ?? []) {
const where = `event "${event.id}"`;
if (!event.id || !ID.test(event.id)) fail(where, `id must match ${ID} (it is used as a state path)`);
if (seenEvents.has(event.id)) fail(where, 'duplicate id');
seenEvents.add(event.id);
if (!event.description) warn(where, 'no description; the player will see an empty scenario');
if (event.stage !== undefined) stages.add(event.stage);
if (event.weight !== undefined && !(event.weight > 0)) {
fail(where, `weight ${event.weight} must be greater than zero, or the event can never fire`);
}
checkConditions(where, event.requires);
// A fallback must be one that *no* exclusion rule can remove: requires,
// once and cooldown are exactly the three reasons eligibleEvents drops an
// event, so a floor event may carry none of them.
if (!event.requires && !event.once && !event.cooldown && event.stage !== undefined) {
unconditional.add(event.stage);
}
const options = event.options ?? [];
if (options.length === 0) fail(where, 'has no options; the turn would be unplayable');
const seenOptions = new Set();
for (const option of options) {
const optionWhere = `${where} option "${option.id}"`;
if (!option.id || !ID.test(option.id)) fail(optionWhere, `id must match ${ID}`);
if (seenOptions.has(option.id)) fail(optionWhere, 'duplicate id');
seenOptions.add(option.id);
if (!option.label) fail(optionWhere, 'label is required');
checkConditions(optionWhere, option.requires);
for (const effect of option.effects ?? []) {
if (!OPERATIONS.includes(effect.op)) {
fail(optionWhere, `unknown effect operation ${JSON.stringify(effect.op)}`);
}
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 option.effects ?? []) {
if (typeof effect.path !== 'string') continue;
const problem = writable(effect.path, effect, optionWhere);
if (problem) fail(optionWhere, `effect writes ${problem}`);
}
}
}
// Every reachable stage needs a floor: one event with no conditions, or the
// pool can empty out mid-run and the turn loop has nothing to show.
if (pack.startingStage && !stages.has(pack.startingStage)) {
fail('pack', `startingStage "${pack.startingStage}" has no events`);
}
for (const { where, value } of stageWrites) {
if (typeof value !== 'string' || !stages.has(value)) {
fail(where, `moves the player to stage ${JSON.stringify(value)}, which has no events`);
}
}
for (const stage of stages) {
if (!unconditional.has(stage)) {
fail('events', `stage "${stage}" has no unconditional event; the turn pool can run dry`);
}
}
for (const [path, rule] of Object.entries(schema)) {
if (rule.initial === undefined) fail('state', `"${path}" has no initial value`);
if (typeof rule.initial === 'number' && typeof rule.min === 'number' && rule.initial < rule.min) {
warn('state', `"${path}" starts below its own minimum`);
}
}
if (Object.keys(characters).length === 0) warn('pack', 'no characters defined');
return { errors, warnings, schema, valid: errors.length === 0 };
}
/** Throw if the pack is unplayable. Call this at load time in development. */
export function assertValidPack(pack) {
const result = validatePack(pack);
if (!result.valid) {
throw new Error(`content pack "${pack.id}" is invalid:\n ${result.errors.join('\n ')}`);
}
return result;
}
+30
View File
@@ -0,0 +1,30 @@
// Handing the player a file.
//
// Object URLs are revoked on the next frame rather than immediately: Safari
// has historically cancelled the download if the URL goes away too soon.
export function downloadText(filename, text, type = 'application/json') {
const url = URL.createObjectURL(new Blob([text], { type }));
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.append(link);
link.click();
link.remove();
requestAnimationFrame(() => URL.revokeObjectURL(url));
}
/** Read a File the player picked, as text. */
export function readFile(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(new Error('Could not read that file.'));
reader.readAsText(file);
});
}
/** A timestamp suffix that sorts, for filenames. */
export function stamp(date = new Date()) {
return date.toISOString().slice(0, 19).replace(/[:T]/g, '-');
}
+112
View File
@@ -0,0 +1,112 @@
// The feedback and instrumentation log.
//
// Deliberately separate from the save: this is research data for the designer,
// not part of anyone's career. It is exported on its own and never merged into
// a save file.
//
// Each entry records more than the spec's minimum, for two reasons. The full
// before/after diff makes "what did that choice actually do" answerable months
// later without replaying anything. The list of options that were *offered but
// locked* is the strongest available signal that a choice set is too narrow —
// players rarely type into a comment box, but the gates they keep bouncing off
// are recorded whether they say anything or not.
import { APP, BUILD } from '../version.js';
const KEY = 'theladder.feedback.v1';
const FORMAT = 1;
function safely(action, fallback = null) {
try {
return action();
} catch {
return fallback;
}
}
function readRaw() {
return safely(() => {
const raw = localStorage.getItem(KEY);
return raw ? JSON.parse(raw) : [];
}, []) ?? [];
}
function writeRaw(entries) {
return safely(() => {
localStorage.setItem(KEY, JSON.stringify(entries));
return true;
}, false);
}
/**
* Record a turn. `record` is what takeTurn returned; `state` is the state it
* produced, carried for the seed and the running totals at that moment.
*/
export function logTurn(record, state, pack) {
const entries = readRaw();
entries.push({
logged_at: new Date().toISOString(),
// Which build produced this turn. A session played on a page loaded before
// the last rebuild looks exactly like a feature that does not work: a real
// playtest ran 181 turns against a stale bundle and the charge it was meant
// to exercise never fired, which took file timestamps and a simulation to
// work out. The log should say so itself.
app_version: APP.version,
build: BUILD.commit,
pack_id: pack.id,
pack_version: pack.version ?? null,
seed: state.meta.seed,
turn_id: `${state.meta.seed}:${record.turn}`,
turn: record.turn,
stage: record.stage,
event_id: record.eventId,
action_taken: record.optionId,
action_label: record.optionLabel,
changes: record.changes,
upkeep: record.upkeep,
offered: record.offered,
rating: null,
comment: null,
desired_but_unavailable_action: null,
});
writeRaw(entries);
return entries.at(-1);
}
/**
* Attach the player's feedback to a turn already logged. Feedback arrives
* after the choice — often several seconds after — so it is a second write
* rather than part of the first.
*/
export function attachFeedback(turnId, { rating, comment, desiredAction }) {
const entries = readRaw();
const entry = entries.findLast((e) => e.turn_id === turnId);
if (!entry) return false;
if (rating !== undefined) entry.rating = rating;
if (comment !== undefined) entry.comment = comment || null;
if (desiredAction !== undefined) entry.desired_but_unavailable_action = desiredAction || null;
return writeRaw(entries);
}
export function readLog() {
return readRaw();
}
export function clearLog() {
return safely(() => { localStorage.removeItem(KEY); return true; }, false);
}
/** How many entries carry actual player feedback, for the export summary. */
export function feedbackCount(entries = readRaw()) {
return entries.filter((e) => e.rating || e.comment || e.desired_but_unavailable_action).length;
}
export function serializeLog(entries = readRaw()) {
return JSON.stringify({
format: FORMAT,
exported_at: new Date().toISOString(),
turns_logged: entries.length,
turns_with_feedback: feedbackCount(entries),
entries,
}, null, 2);
}
+115
View File
@@ -0,0 +1,115 @@
// Saving and loading.
//
// Local storage is the live save; the file export exists so a cleared cache or
// a crashed browser does not take a career with it. A save is the whole game
// state and nothing else — the engine keeps no state outside it, so this is a
// complete record by construction.
//
// There is one slot per pack. A single global slot was fine while there was
// one setting; the moment a second existed, switching settings would have
// silently thrown away the other run — the guard in the app read the packId
// off whatever was in the slot and started a new game when it did not match.
const PREFIX = 'theladder.save.v1';
// The slot as it was before packs were selectable: one save, no pack in the
// key. Read once, for Corporate Ladder only, so a run in progress survives
// this change. Never written.
const LEGACY_KEY = PREFIX;
const LEGACY_PACK = 'corporateladder';
const LAST_PACK_KEY = 'theladder.pack.v1';
const FORMAT = 1;
const slotFor = (packId) => `${PREFIX}.${packId}`;
/** Local storage throws in private windows and when site data is blocked. */
function safely(action, fallback = null) {
try {
return action();
} catch {
return fallback;
}
}
export function saveLocal(packId, state) {
return safely(() => {
localStorage.setItem(
slotFor(packId),
JSON.stringify({ format: FORMAT, savedAt: new Date().toISOString(), state }),
);
return true;
}, false);
}
/**
* The saved run for one pack, or null. Falls back to the pre-packs slot for
* Corporate Ladder, and only when that slot really holds a Corporate Ladder
* save — a stale slot from some other pack is not a reason to load a run into
* a game whose events do not match it.
*/
export function loadLocal(packId) {
return safely(() => {
const raw = localStorage.getItem(slotFor(packId));
if (raw) return JSON.parse(raw)?.state ?? null;
if (packId !== LEGACY_PACK) return null;
const legacy = localStorage.getItem(LEGACY_KEY);
if (!legacy) return null;
const state = JSON.parse(legacy)?.state ?? null;
return state?.meta?.packId === LEGACY_PACK ? state : null;
});
}
export function clearLocal(packId) {
return safely(() => {
localStorage.removeItem(slotFor(packId));
if (packId === LEGACY_PACK) localStorage.removeItem(LEGACY_KEY);
return true;
}, false);
}
/** Which pack was last played, so a returning player lands back in it. */
export function lastPlayed() {
return safely(() => localStorage.getItem(LAST_PACK_KEY));
}
export function rememberPack(packId) {
return safely(() => { localStorage.setItem(LAST_PACK_KEY, packId); return true; }, false);
}
/** True if the browser will actually remember anything. */
export function storageAvailable() {
return safely(() => {
const probe = '__theladder_probe__';
localStorage.setItem(probe, '1');
localStorage.removeItem(probe);
return true;
}, false);
}
/** A save file's contents, as a string ready to be downloaded. */
export function serializeSave(state) {
return JSON.stringify({ format: FORMAT, savedAt: new Date().toISOString(), state }, null, 2);
}
/**
* Parse an imported save, refusing anything that is not one. A file from a
* different pack is rejected by name rather than loaded into a game whose
* events and characters do not match it.
*/
export function parseSave(text, expectedPackId) {
let parsed;
try {
parsed = JSON.parse(text);
} catch {
throw new Error('That file is not valid JSON.');
}
const state = parsed?.state;
if (!state || typeof state !== 'object' || !state.meta || typeof state.turn !== 'number') {
throw new Error('That does not look like a saved game.');
}
if (expectedPackId && state.meta.packId !== expectedPackId) {
throw new Error(
`That save is from "${state.meta.packId}", but this is "${expectedPackId}".`,
);
}
return state;
}
+335
View File
@@ -0,0 +1,335 @@
// The application: wiring the engine to the screens.
//
// This is the one module that knows there is more than one content pack. It
// holds the current pack and its schema, and everything below it still takes
// the pack as an argument — the screens, the engine and the io layer are all
// as pack-agnostic as they were when there was only one.
//
// Each pack has its own save slot, so switching settings never destroys a run.
//
// Rendering is a full rebuild of the root element. At this size it is fast and
// removes a whole class of stale-view bug — with one deliberate exception: the
// feedback text fields update the model without re-rendering, because
// rebuilding the DOM under a cursor would throw the player out of the box they
// are typing in.
import { packs, packById, defaultPack } from '../../content/index.js';
import { validatePack } from '../engine/validate.js';
import { resolveSchema, hydrate } from '../engine/state.js';
import { startGame, currentTurn, takeTurn, findOptionById } from './engine-bridge.js';
import { turnScreen } from './screens/turn.js';
import { retrospectiveScreen } from './screens/retrospective.js';
import { packsScreen } from './screens/packs.js';
import { fill, focusMarked } from './dom.js';
import {
saveLocal, loadLocal, clearLocal, serializeSave, parseSave, storageAvailable,
lastPlayed, rememberPack,
} from '../io/save.js';
import { logTurn, attachFeedback, readLog, serializeLog, clearLog, feedbackCount } from '../io/feedback-log.js';
import { downloadText, readFile, stamp } from '../io/download.js';
const app = {
pack: null,
schema: null,
state: null,
view: null,
root: null,
};
/** Reset the per-turn view, reading the current turn out of the game state. */
function choosingView() {
return {
screen: 'turn',
phase: 'choosing',
turn: currentTurn(app.pack, app.state),
feedback: emptyFeedback(),
feedbackOpen: false,
};
}
const emptyFeedback = () =>
({ rating: null, comment: '', desiredAction: '', saved: false, touched: false, turnId: null });
function render() {
const screen = app.view.screen === 'packs'
? packsScreen(packs, packsView(), actions)
: app.view.screen === 'retrospective'
? retrospectiveScreen(app.pack, app.state, app.schema, actions)
: turnScreen(app.pack, app.state, app.schema, app.view, actions);
fill(app.root, screen);
// After the screen is in the document, put the caret where the player most
// likely wants it. This is what makes the game playable on Enter alone.
focusMarked(app.root);
}
/**
* The picker's view of the world: which pack is current, and what is saved in
* each of the others. Reading every slot is cheap and means the screen can say
* "day 41, Dispatch Coordinator" rather than just "saved".
*/
function packsView() {
const runs = {};
for (const candidate of packs) {
const saved = candidate.id === app.pack?.id
? app.state
: loadLocal(candidate.id);
if (saved) runs[candidate.id] = { turn: saved.turn, stage: saved.stage };
}
return { currentPackId: app.pack?.id ?? null, runs, canGoBack: app.state !== null };
}
const actions = {
onChoose(optionId) {
const option = findOptionById(app.view.turn.event, optionId);
const { state, record } = takeTurn(app.pack, app.state, optionId);
app.state = state;
saveLocal(app.pack.id, state);
const entry = logTurn(record, state, app.pack);
app.view = {
screen: 'turn',
phase: 'outcome',
turn: app.view.turn,
record,
option,
feedback: { ...emptyFeedback(), turnId: entry?.turn_id ?? null },
feedbackOpen: false,
};
render();
},
onNext() {
flushFeedback();
app.view = choosingView();
render();
},
onRate(rating) {
app.view.feedback.rating = app.view.feedback.rating === rating ? null : rating;
app.view.feedback.saved = app.view.feedback.rating !== null;
app.view.feedback.touched = true;
flushFeedback();
render();
},
// No render: rebuilding the DOM would move the cursor out of the field.
onComment(text) {
app.view.feedback.comment = text;
app.view.feedback.touched = true;
scheduleFlush();
},
onDesiredAction(text) {
app.view.feedback.desiredAction = text;
app.view.feedback.touched = true;
scheduleFlush();
},
onToggleFeedbackDetail() {
app.view.feedbackOpen = !app.view.feedbackOpen;
render();
},
onRetrospective() {
flushFeedback();
app.view = { ...app.view, screen: 'retrospective' };
render();
},
onResume() {
app.view = choosingView();
render();
},
onNewGame() {
const played = app.state?.history?.length ?? 0;
if (played > 0 && !confirm(
`This ends the current run at ${app.pack.turnUnit} ${app.state.turn}. ` +
'Export a save first if you want to keep it. Start a new game?',
)) return;
flushFeedback();
clearLocal(app.pack.id);
beginRun(app.pack);
render();
},
/** Open the setting picker. */
onPacks() {
flushFeedback();
app.view = { ...(app.view ?? {}), screen: 'packs' };
render();
},
/**
* Switch settings. The run you are leaving has already been written to its
* own slot every turn, so this is not destructive in either direction — and
* coming back to a pack resumes it rather than restarting it.
*/
onChoosePack(packId) {
const chosen = packById(packId);
if (!chosen) return;
flushFeedback();
if (chosen.id === app.pack?.id) {
app.view = choosingView();
render();
return;
}
if (!enterPack(chosen)) return;
render();
},
onExportSave() {
downloadText(`theladder-save-${stamp()}.json`, serializeSave(app.state));
},
onImportSave() {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'application/json,.json';
input.addEventListener('change', async () => {
const file = input.files?.[0];
if (!file) return;
try {
// Same repair as a local load: an exported file is exactly as old as
// the run it came from.
const imported = parseSave(await readFile(file), app.pack.id);
const { state } = hydrate(app.pack, imported, app.schema);
app.state = state;
saveLocal(app.pack.id, state);
app.view = choosingView();
render();
} catch (error) {
alert(`That save could not be loaded.\n\n${error.message}`);
}
});
input.click();
},
onExportFeedback() {
flushFeedback();
const entries = readLog();
if (entries.length === 0) {
alert('Nothing logged yet — play a turn or two first.');
return;
}
downloadText(`theladder-feedback-${stamp()}.json`, serializeLog(entries));
const withFeedback = feedbackCount(entries);
if (confirm(
`Exported ${entries.length} logged turns (${withFeedback} carry feedback).\n\n` +
'Clear the log now that it is saved?',
)) clearLog();
},
};
// Text fields write through on a delay so a long comment is not one storage
// write per keystroke, and always flush before anything that could lose it.
let flushTimer = null;
function scheduleFlush() {
clearTimeout(flushTimer);
flushTimer = setTimeout(flushFeedback, 500);
}
function flushFeedback() {
clearTimeout(flushTimer);
const feedback = app.view?.feedback;
if (!feedback?.turnId) return;
// `touched` rather than "has content": a player who clears a rating or
// deletes a comment is withdrawing it, and that has to be written through
// too. Turns the player simply ignored are never written at all.
if (!feedback.touched) return;
attachFeedback(feedback.turnId, {
rating: feedback.rating,
comment: feedback.comment,
desiredAction: feedback.desiredAction,
});
}
/**
* Make `pack` the current setting: validate it, adopt its schema, and either
* resume its saved run or start one. Returns false when the pack will not
* load, having already put the error on screen.
*/
function enterPack(pack) {
const validation = validatePack(pack);
if (!validation.valid) {
fill(app.root, errorScreen(pack, validation.errors));
return false;
}
for (const warning of validation.warnings) console.warn(`[content ${pack.id}] ${warning}`);
app.pack = pack;
app.schema = resolveSchema(pack);
rememberPack(pack.id);
const restored = loadLocal(pack.id);
// The packId check is belt and braces now that slots are per pack, but an
// imported or hand-edited save can still carry the wrong name.
if (restored && restored.meta?.packId === pack.id) {
// A career saved before the pack gained a stat has no path for it, and
// every numeric comparison against `undefined` is false — so the events,
// options and upkeep gated on a new stat would quietly not exist for a
// player already mid-run. Fill them in, and write the repair back.
const repaired = hydrate(pack, restored, app.schema);
if (repaired.added.length > 0) {
console.info(`[save ${pack.id}] filled in new state: ${repaired.added.join(', ')}`);
saveLocal(pack.id, repaired.state);
}
app.state = repaired.state;
app.view = choosingView();
} else {
beginRun(pack);
}
return true;
}
/** A fresh run in `pack`, written to that pack's slot. */
function beginRun(pack) {
app.state = startGame(pack, {
seed: String(Date.now()),
schema: app.schema,
now: new Date().toISOString(),
});
saveLocal(pack.id, app.state);
app.view = choosingView();
}
export function boot(rootElement) {
app.root = rootElement;
if (!storageAvailable()) {
console.warn('[save] local storage is unavailable; progress will not persist. Use Export save.');
}
// Land a returning player back where they were. A first-time player with
// more than one setting to choose from gets the picker as the front door
// rather than being dropped into whichever pack happens to be first.
const remembered = packById(lastPlayed() ?? '');
const anySaved = packs.some((pack) => loadLocal(pack.id) !== null);
if (!remembered && !anySaved && packs.length > 1) {
app.view = { screen: 'packs' };
render();
} else if (!enterPack(remembered ?? defaultPack)) {
return;
} else {
render();
}
// Anything half-typed survives a tab close.
window.addEventListener('beforeunload', flushFeedback);
}
function errorScreen(pack, errors) {
const list = document.createElement('ul');
for (const error of errors) {
const item = document.createElement('li');
item.textContent = error;
list.append(item);
}
const wrapper = document.createElement('div');
wrapper.className = 'screen screen--error';
const heading = document.createElement('h1');
heading.textContent = `"${pack.name ?? pack.id}" will not load.`;
wrapper.append(heading, list);
return wrapper;
}
+130
View File
@@ -0,0 +1,130 @@
// Pieces shared between the turn screen and the retrospective.
import { h } from './dom.js';
import { formatValue, labelFor, headerStats } from './format.js';
import { APP, versionLine } from '../version.js';
import { getPath } from '../engine/paths.js';
/**
* What you are playing, which pack, and who you currently are — plus the build,
* so a playtester can say which version they were on.
*/
export function statusBar(pack, state) {
const title = pack.stages?.[state.stage]?.title ?? state.stage;
return h('div', { class: 'statusbar' },
h('div', { class: 'statusbar__what' },
h('span', { class: 'statusbar__game' }, APP.name),
h('span', { class: 'statusbar__sep' }, '·'),
h('span', { class: 'statusbar__pack' }, pack.name ?? pack.id),
h('span', { class: 'statusbar__sep' }, '·'),
h('span', { class: 'statusbar__title' }, title),
),
h('span', { class: 'statusbar__version', title: 'app version · build' }, versionLine()),
);
}
/** A labelled bar for a bounded stat. */
export function meter(label, value, bounds, { compact = false } = {}) {
const min = bounds?.min ?? 0;
const max = bounds?.max ?? 100;
const fraction = max === min ? 0 : Math.max(0, Math.min(1, (value - min) / (max - min)));
return h('div', { class: compact ? 'meter meter--compact' : 'meter' },
h('div', { class: 'meter__head' },
h('span', { class: 'meter__label' }, label),
h('span', { class: 'meter__value' }, String(value)),
),
h('div', { class: 'meter__track', role: 'img', 'aria-label': `${label}: ${value} of ${max}` },
h('div', { class: 'meter__fill', style: `width: ${(fraction * 100).toFixed(1)}%` }),
),
);
}
/** The stat strip: money and debt as figures, bounded stats as bars. */
export function statPanel(pack, state, schema) {
return h('div', { class: 'stats' },
headerStats(pack, state, schema).map((stat) => (
stat.rule.format === 'meter'
? meter(stat.label, stat.value, stat.bounds, { compact: true })
: h('div', { class: `figure figure--${stat.path.replace(/\./g, '-')}` },
h('span', { class: 'figure__label' }, stat.label),
h('span', {
class: `figure__value${stat.value < 0 ? ' figure__value--negative' : ''}`,
}, formatValue(pack, stat.path, stat.value)),
)
)),
);
}
/** One character, with how they currently regard the player. */
export function characterCard(pack, state, schema, character) {
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('div', { class: 'person__meters' },
Object.keys(pack.relationshipStats ?? {}).map((stat) => {
const path = `relationships.${character.id}.${stat}`;
return meter(
pack.relationshipStats[stat].label ?? stat,
getPath(state, path),
schema[path],
{ compact: true },
);
}),
),
);
}
/** The change list shown after a choice: what moved, and by how much. */
export function changeList(pack, changes, { className = 'changes' } = {}) {
const meaningful = changes.filter((change) => {
if (change.from === change.to) return false;
// Some state is bookkeeping the player has no use for — `notable` is the
// retrospective's own list, and reading "Notable — changed" in the ledger
// tells nobody anything.
return pack.display?.[change.path]?.inLedger !== false;
});
if (!meaningful.length) return h('p', { class: 'changes changes--none' }, 'Nothing measurable changed.');
return h('ul', { class: className },
meaningful.map((change) => {
// A flag flipping is a fact about the world, not a quantity. Say it as a
// sentence the pack supplies rather than as "now true".
if (typeof change.to === 'boolean') {
const rule = pack.display?.[change.path];
const phrase = (change.to ? rule?.whenTrue : rule?.whenFalse)
?? `${labelFor(pack, change.path)}: ${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__delta' }, deltaText(pack, change)),
change.clamped && h('span', { class: 'change__note' }, 'at its limit'),
);
}),
);
}
function deltaText(pack, change) {
if (typeof change.to !== 'number' || typeof change.from !== 'number') {
return typeof change.to === 'boolean' ? (change.to ? 'now true' : 'no longer true') : 'changed';
}
// A tier moved: "+1" says nothing, the name of the step says everything.
const rule = pack.display?.[change.path];
if (rule?.format === 'tier') return rule.labels?.[change.to] ?? String(change.to);
const delta = change.to - change.from;
const money = pack.display?.[change.path]?.format === 'money';
const magnitude = money
? `$${Math.abs(Math.round(delta)).toLocaleString('en-US')}`
: String(Math.abs(delta));
return `${delta > 0 ? '+' : '−'}${magnitude}`;
}
+48
View File
@@ -0,0 +1,48 @@
// A very small DOM helper. Not a framework — just enough to build elements
// without a wall of createElement calls.
export function h(tag, props = {}, ...children) {
const el = document.createElement(tag);
for (const [key, value] of Object.entries(props ?? {})) {
if (value === null || value === undefined || value === false) continue;
if (key === 'class') el.className = value;
else if (key === 'dataset') Object.assign(el.dataset, value);
else if (key.startsWith('on') && typeof value === 'function') {
el.addEventListener(key.slice(2).toLowerCase(), value);
} else if (key in el && key !== 'list') el[key] = value;
else el.setAttribute(key, value);
}
for (const child of children.flat(Infinity)) {
if (child === null || child === undefined || child === false) continue;
el.append(child instanceof Node ? child : document.createTextNode(String(child)));
}
return el;
}
/** Replace an element's contents. */
export function fill(container, ...children) {
container.replaceChildren(...children.flat(Infinity).filter((c) => c !== null && c !== undefined && c !== false));
return container;
}
/**
* Focus whatever the screen marked with `dataset.autofocus`.
*
* Every screen names the one control a player most likely wants next — the
* first available option while choosing, "next day" once the result is in — so
* a whole run can be played on Enter alone, with Tab and Shift+Tab to pick a
* different option. Disabled buttons are skipped by the browser's own tab
* order, so locked options are visible without being in the way.
*/
export function focusMarked(root) {
const queue = [root];
while (queue.length) {
const node = queue.shift();
if (node?.dataset?.autofocus) {
node.focus?.();
return node;
}
queue.push(...(node?.childNodes ?? []));
}
return null;
}
+34
View File
@@ -0,0 +1,34 @@
// The UI's view of the engine, with the pack's schema bound in.
//
// Re-exported here rather than imported directly so the screens never have to
// thread a schema argument through, and so there is one obvious place to look
// for everything the interface is allowed to do to a game.
import { resolveSchema } from '../engine/state.js';
import * as game from '../engine/game.js';
import { findOption } from '../engine/select.js';
const schemaCache = new WeakMap();
function schemaFor(pack) {
if (!schemaCache.has(pack)) schemaCache.set(pack, resolveSchema(pack));
return schemaCache.get(pack);
}
export function startGame(pack, options = {}) {
return game.startGame(pack, { schema: schemaFor(pack), ...options });
}
export function currentTurn(pack, state) {
return game.currentTurn(pack, state);
}
export function takeTurn(pack, state, optionId, options = {}) {
return game.takeTurn(pack, state, optionId, {
schema: schemaFor(pack),
now: new Date().toISOString(),
...options,
});
}
export const findOptionById = findOption;
+155
View File
@@ -0,0 +1,155 @@
// Turning state paths and conditions into English.
//
// Display names, number formats and which stats appear in the header all come
// from the pack's `display` map, so a new setting renames and re-orders the
// interface without touching the UI. Anything the pack does not describe falls
// back to a derivation from the path itself, so content is never *required* to
// supply a label just to be playable.
import { getPath } from '../engine/paths.js';
import { check } from '../engine/conditions.js';
const titleCase = (text) =>
text.replace(/_/g, ' ').replace(/^\w/, (c) => c.toUpperCase());
/** The pack's display rules for a path, if it has any. */
export function displayFor(pack, path) {
return pack.display?.[path] ?? null;
}
/** A human name for a path, derived from the path when the pack is silent. */
export function labelFor(pack, path) {
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 stat = pack.relationshipStats?.[segments[2]]?.label ?? titleCase(segments[2]);
return `${character?.name ?? titleCase(segments[1])} — ${stat.toLowerCase()}`;
}
return titleCase(segments.at(-1));
}
/** Money is written as money; everything else is a plain number. */
export function formatValue(pack, path, value) {
if (typeof value === 'boolean') return value ? 'yes' : 'no';
const rule = displayFor(pack, path);
if (rule?.format === 'money') return formatMoney(value);
// A tier is a number the player should never see as one: a pack declares
// what each step is called and the interface reads the name off the list.
if (rule?.format === 'tier') return rule.labels?.[value] ?? String(value);
return String(value);
}
export function formatMoney(amount) {
const rounded = Math.round(amount);
const body = `$${Math.abs(rounded).toLocaleString('en-US')}`;
return rounded < 0 ? `−${body}` : body;
}
/** A signed change, for the outcome list: "+$45", "−3". */
export function formatDelta(pack, path, from, to) {
const delta = to - from;
if (displayFor(pack, path)?.format === 'money') {
return `${delta > 0 ? '+' : '−'}$${Math.abs(Math.round(delta)).toLocaleString('en-US')}`;
}
return `${delta > 0 ? '+' : '−'}${Math.abs(delta)}`;
}
/**
* One condition as a requirement the player can read: "Negotiation 15+",
* "Trevor — likes you 48+", "not in debt".
*/
export function describeCondition(pack, condition) {
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)}`;
const { path, op, value } = condition;
const label = labelFor(pack, path);
if (typeof value === 'boolean') return value ? label.toLowerCase() : `no ${label.toLowerCase()}`;
const amount = formatValue(pack, path, value);
switch (op) {
case '>=': return `${label} ${amount}+`;
case '>': return `${label} over ${amount}`;
case '<=': return `${label} ${amount} or less`;
case '<': return `${label} under ${amount}`;
case '==': return `${label} exactly ${amount}`;
case '!=': return `${label} other than ${amount}`;
default: return `${label} ${op} ${amount}`;
}
}
/** What a locked option is waiting for. */
export function requirementText(pack, failedConditions) {
const parts = failedConditions.map((c) => describeCondition(pack, c)).filter(Boolean);
return parts.length ? `Needs ${parts.join(', ')}` : 'Not available yet';
}
/**
* The stats the header shows, in the pack's declared order, skipping anything
* the pack marks hidden or asks to hide while it is zero.
*/
export function headerStats(pack, state, schema) {
return Object.entries(pack.display ?? {})
.filter(([, rule]) => !rule.hidden)
.map(([path, rule]) => ({
path,
rule,
label: labelFor(pack, path),
value: getPath(state, path),
bounds: schema[path],
}))
.filter((stat) => !(stat.rule.hideWhenZero && !stat.value))
.sort((a, b) => (a.rule.order ?? 99) - (b.rule.order ?? 99));
}
/**
* How the current turn is titled. A pack with a calendar gets its day name and
* cycle ("Thursday", "Week 2 · Day 12"); one without falls back to the turn
* number alone, which is what the engine guarantees.
*/
export function turnHeading(pack, state) {
const unit = capitalize(pack.turnUnit ?? 'turn');
const name = state.calendar?.name;
if (!name) return { title: `${unit} ${state.turn}`, detail: null };
const cycleName = pack.calendar?.cycleName ?? 'Cycle';
return {
title: name,
detail: `${cycleName} ${state.calendar.cycle} · ${unit} ${state.turn}`,
};
}
/**
* The pack's scheduled upkeep, as "what is coming and when". Answers the
* question a player actually has — when am I next paid, when is rent due —
* without them having to reverse-engineer it from the ledger.
*/
export function upcomingUpkeep(pack, state, { limit = 3 } = {}) {
const upcoming = [];
for (const entry of pack.upkeep ?? []) {
if (!entry.every || !entry.label) continue;
if (!check(state, entry.requires).passed) continue;
const offset = entry.offset ?? 0;
const elapsed = state.turn - 1 - offset;
const turnsAway = elapsed < 0 ? -elapsed : (entry.every - (elapsed % entry.every)) % entry.every;
const amount = (entry.effects ?? [])
.filter((e) => e.op === 'add' && typeof e.value === 'number')
.reduce((sum, e) => sum + e.value, 0);
upcoming.push({ label: entry.label, turnsAway, incoming: amount > 0 });
}
return upcoming
.sort((a, b) => a.turnsAway - b.turnsAway)
.filter((entry, index, all) => all.findIndex((e) => e.label === entry.label) === index)
.slice(0, limit);
}
const capitalize = (text) => text.charAt(0).toUpperCase() + text.slice(1);
+64
View File
@@ -0,0 +1,64 @@
// Choosing a setting.
//
// A setting is a content pack, and this screen is the whole of the interface's
// knowledge about there being more than one: it reads `name`, `blurb` and the
// stage titles off each pack and knows nothing else about any of them. Adding
// a third pack changes this file not at all.
//
// Each pack keeps its own save slot, so switching is not destructive and the
// screen says so — a run in progress is reported with the turn it is on.
import { h } from '../dom.js';
export function packsScreen(packs, { currentPackId, runs, canGoBack }, actions) {
return h('div', { class: 'screen screen--packs' },
h('header', { class: 'packs__head' },
h('p', { class: 'packs__eyebrow' }, 'The Ladder'),
h('h1', { class: 'packs__title' }, 'Choose a setting'),
h('p', { class: 'packs__subtitle' },
'The same game underneath. Each setting keeps its own run, so you can ' +
'move between them without losing either.'),
),
h('ul', { class: 'packs__list' },
packs.map((pack, index) => {
const run = runs[pack.id];
const current = pack.id === currentPackId;
return h('li', { class: `packs__item${current ? ' packs__item--current' : ''}` },
h('h2', { class: 'packs__name' }, pack.name),
pack.blurb && h('p', { class: 'packs__blurb' }, pack.blurb),
h('p', { class: 'packs__state' }, runLine(pack, run, current)),
h('button', {
class: current ? 'button' : 'button button--quiet',
// The setting the player is already in, or failing that the first
// one, is what Enter takes — the same keyboard contract as
// everywhere else.
dataset: (current || (!currentPackId && index === 0)) ? { autofocus: 'true' } : undefined,
onClick: () => actions.onChoosePack(pack.id),
}, buttonLabel(run, current)),
);
}),
),
canGoBack && h('footer', { class: 'controls' },
h('button', { class: 'button button--quiet', onClick: actions.onResume }, 'Back'),
),
);
}
function runLine(pack, run, current) {
const unit = pack.turnUnit ?? 'turn';
if (!run) return 'Not started.';
const title = pack.stages?.[run.stage]?.title ?? run.stage;
const where = `${capitalize(unit)} ${run.turn}, ${title}`;
return current ? `${where} — where you are now.` : `${where}, saved.`;
}
function buttonLabel(run, current) {
if (current) return 'Keep playing';
return run ? 'Go back to this run' : 'Start here';
}
const capitalize = (word) => word.charAt(0).toUpperCase() + word.slice(1);
export default packsScreen;
+112
View File
@@ -0,0 +1,112 @@
// The career retrospective.
//
// Structurally identical however the run went — this is a summary, not a
// scoreboard, and there is no win state to congratulate anyone on. It is also
// not an ending: the player can close it and carry on from the same turn.
import { h } from '../dom.js';
import { statPanel, characterCard, statusBar } from '../components.js';
import { formatMoney, labelFor } from '../format.js';
import { summarize } from '../summary.js';
export function retrospectiveScreen(pack, state, schema, actions) {
const summary = summarize(pack, state);
const unit = pack.turnUnit ?? 'turn';
return h('div', { class: 'screen screen--retrospective' },
statusBar(pack, state),
h('header', { class: 'retro__head' },
h('p', { class: 'retro__eyebrow' }, pack.name),
h('h1', { class: 'retro__title' }, 'Career retrospective'),
h('p', { class: 'retro__subtitle' }, careerLine(pack, summary, unit)),
// Said out loud because it did not read that way to a real player, who
// arrived here by keyboard at day 31 and took it for the end of the run.
h('p', { class: 'retro__note' },
`This is a summary, not an ending — "Keep going" picks up where you left `
+ `off, on ${unit} ${state.turn}.`),
),
h('section', { class: 'retro__section' },
h('h2', { class: 'retro__heading' }, 'Where you ended up'),
statPanel(pack, state, schema),
),
h('section', { class: 'retro__section' },
h('h2', { class: 'retro__heading' }, 'How it went'),
h('dl', { class: 'facts' },
fact(`Highest your ${primaryLabel(pack, summary)} got`,
summary.peak === null ? '—' : formatMoney(summary.peak)),
fact('Lowest it got', summary.trough === null ? '—' : formatMoney(summary.trough)),
summary.daysInTheRed > 0 && fact(`${capitalize(unit)}s in the red`, String(summary.daysInTheRed)),
summary.bestDay && fact(`Best ${unit}`,
`${formatMoney(summary.bestDay.net)} — ${summary.bestDay.label}`),
summary.worstDay && fact(`Worst ${unit}`,
`${formatMoney(summary.worstDay.net)} — ${summary.worstDay.label}`),
summary.favouriteAction && fact('What you did most',
`${summary.favouriteAction.label} (${summary.favouriteAction.count}×)`),
fact('Situations you saw', `${summary.eventsSeen} of ${summary.eventsTotal}`),
summary.eventsSeen > 0 && fact('Times you saw each, on average',
summary.averageTimesSeen.toFixed(1)),
summary.mostSeen && summary.mostSeen.count > 1 && fact('Most familiar of them',
`came up ${summary.mostSeen.count} times`),
summary.lockedEncounters > 0 && fact('Doors you could not open yet',
String(summary.lockedEncounters)),
),
),
h('section', { class: 'retro__section' },
h('h2', { class: 'retro__heading' }, 'Where you stand with people'),
h('div', { class: 'retro__people' },
(pack.characters ?? []).map((character) => characterCard(pack, state, schema, character)),
),
),
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('p', { class: 'moments moments--none' },
'Nothing that anybody would write down. That happens.'),
),
h('footer', { class: 'controls' },
h('button', {
class: 'button button--primary',
dataset: { autofocus: 'true' },
onClick: actions.onResume,
}, 'Keep going'),
h('button', { class: 'button button--quiet', onClick: actions.onExportSave }, 'Export save'),
h('button', { class: 'button button--quiet', onClick: actions.onExportFeedback },
'Export feedback log'),
h('button', { class: 'button button--quiet', onClick: actions.onNewGame }, 'Start again'),
),
);
}
/**
* One sentence for where the career went. A run that changed stage says so —
* that is the whole shape of it — and one that did not reads as it always did.
*/
function careerLine(pack, summary, unit) {
const label = (stage) => (pack.stages?.[stage]?.label ?? stage).toLowerCase();
const length = `${summary.turns} ${unit}${summary.turns === 1 ? '' : 's'}`;
if (summary.stagesVisited.length > 1) {
return `${length}, ${summary.stagesVisited.map(label).join(', then ')}.`;
}
return `${length} in the ${label(summary.stage)}.`;
}
/** The pack's own word for its main resource, lowercased for mid-sentence use. */
function primaryLabel(pack, summary) {
return summary.primaryPath ? labelFor(pack, summary.primaryPath).toLowerCase() : 'balance';
}
function fact(label, value) {
return h('div', { class: 'facts__item' },
h('dt', { class: 'facts__label' }, label),
h('dd', { class: 'facts__value' }, value),
);
}
const capitalize = (text) => text.charAt(0).toUpperCase() + text.slice(1);
+187
View File
@@ -0,0 +1,187 @@
// The turn screen, in two phases.
//
// Choosing: the scenario and its options, with locked ones shown greyed and
// labelled with what they are waiting for — a player should be able to see the
// door they cannot open yet, because that is most of what makes the numbers
// mean anything.
//
// Outcome: what happened, what moved, and what the day cost regardless of the
// choice. The feedback widget lives here rather than in the choosing phase,
// because this is the moment a player has an opinion.
import { h } from '../dom.js';
import { statPanel, characterCard, changeList, statusBar } from '../components.js';
import { requirementText, turnHeading, upcomingUpkeep } from '../format.js';
export function turnScreen(pack, state, schema, view, actions) {
const heading = turnHeading(pack, state);
return h('div', { class: 'screen screen--turn' },
statusBar(pack, state),
h('header', { class: 'hud' },
h('div', { class: 'hud__where' },
h('p', { class: 'hud__stage' }, pack.stages?.[state.stage]?.title ?? state.stage),
h('h1', { class: 'hud__day' }, heading.title),
heading.detail && h('p', { class: 'hud__detail' }, heading.detail),
),
statPanel(pack, state, schema),
),
diary(pack, state),
h('div', { class: 'layout' },
h('main', { class: 'scene' },
view.phase === 'outcome'
? outcomeView(pack, view, actions)
: choosingView(pack, view, actions),
),
h('aside', { class: 'people' },
h('h2', { class: 'people__heading' }, pack.peopleHeading ?? 'The people'),
(pack.characters ?? []).map((character) => characterCard(pack, state, schema, character)),
),
),
// Order here is tab order. "Wrap up my career" used to sit first, directly
// after the last option — so a player tabbing to reach an option, going one
// past it, and pressing Enter got the retrospective instead of a turn. That
// happened in a real session at day 31 and read as the game ending. It goes
// last now, behind buttons that do nothing frightening if pressed by
// accident, and Import save is next to Export save where it belongs.
h('footer', { class: 'controls' },
h('button', { class: 'button button--quiet', onClick: actions.onExportSave }, 'Export save'),
h('button', { class: 'button button--quiet', onClick: actions.onImportSave }, 'Import save'),
h('button', { class: 'button button--quiet', onClick: actions.onExportFeedback },
'Export feedback log'),
h('button', { class: 'button button--quiet', onClick: actions.onPacks }, 'Change setting'),
h('button', { class: 'button button--quiet', onClick: actions.onNewGame }, 'New game'),
h('button', { class: 'button', onClick: actions.onRetrospective },
'Wrap up my career'),
),
);
}
/**
* What is coming and when. A player asked what the daily drain was for; the
* answer is that most of it is not daily, and this says so before the money
* moves rather than after.
*/
function diary(pack, state) {
const upcoming = upcomingUpkeep(pack, state);
if (!upcoming.length) return null;
const unit = pack.turnUnit ?? 'turn';
return h('p', { class: 'diary' },
upcoming.map((entry, index) => h('span', {
class: `diary__entry diary__entry--${entry.incoming ? 'in' : 'out'}`,
},
index > 0 ? ' · ' : '',
entry.label,
entry.turnsAway === 0
? ' today'
: entry.turnsAway === 1 ? ` tomorrow` : ` in ${entry.turnsAway} ${unit}s`,
)),
);
}
function choosingView(pack, view, actions) {
const { event, options } = view.turn;
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('ul', { class: 'options' },
visible.map(({ option, available, failed }) => 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)),
),
)),
),
);
}
function outcomeView(pack, view, actions) {
const { record, option } = view;
return h('section', { class: 'outcome' },
h('p', { class: 'outcome__chose' }, option.label),
option.result && h('p', { class: 'outcome__text' }, option.result),
h('div', { class: 'outcome__ledger' },
h('h3', { class: 'outcome__heading' }, 'What that changed'),
changeList(pack, record.changes),
record.upkeep.length > 0 && h('div', { class: 'outcome__upkeep' },
h('h3', { class: 'outcome__heading' }, 'And then the day happened'),
changeList(pack, record.upkeep),
),
),
feedbackWidget(view, actions),
h('button', {
class: 'button button--primary button--next',
dataset: { autofocus: 'true' },
onClick: actions.onNext,
}, `Next ${pack.turnUnit ?? 'turn'} →`),
);
}
// Entirely optional: ignoring it does nothing at all, which is the point.
function feedbackWidget(view, actions) {
const { feedback } = view;
const ratingButton = (value, label) => h('button', {
class: `rating${feedback.rating === value ? ' rating--on' : ''}`,
type: 'button',
'aria-pressed': String(feedback.rating === value),
onClick: () => actions.onRate(value),
}, label);
return h('section', { class: 'feedback' },
h('div', { class: 'feedback__row' },
h('span', { class: 'feedback__prompt' }, 'How was that?'),
h('div', { class: 'feedback__ratings' },
ratingButton('up', '👍'),
ratingButton('neutral', '😐'),
ratingButton('down', '👎'),
),
h('button', {
class: 'feedback__toggle',
type: 'button',
onClick: actions.onToggleFeedbackDetail,
}, view.feedbackOpen ? 'Fewer options' : 'Say more'),
),
view.feedbackOpen && h('div', { class: 'feedback__detail' },
h('label', { class: 'field' },
h('span', { class: 'field__label' }, 'Anything you want to tell the designer?'),
h('textarea', {
class: 'field__input',
rows: 2,
value: feedback.comment ?? '',
onInput: (e) => actions.onComment(e.target.value),
}),
),
h('label', { class: 'field' },
h('span', { class: 'field__label' },
"Something you wanted to do that wasn't offered?"),
h('textarea', {
class: 'field__input',
rows: 2,
value: feedback.desiredAction ?? '',
onInput: (e) => actions.onDesiredAction(e.target.value),
}),
),
),
feedback.saved && h('p', { class: 'feedback__saved' }, 'Noted — thank you.'),
);
}
+340
View File
@@ -0,0 +1,340 @@
/* The Ladder — one stylesheet, no framework.
The look is office stationery: manila, rules, stamped figures. Muted enough
that seventeen scenarios in a row do not tire the eye. */
:root {
--paper: #f2eee4;
--card: #fffdf7;
--ink: #26231d;
--ink-soft: #6b6659;
--ink-faint: #938c7c;
--rule: #ded6c4;
--rule-strong: #c9bfa8;
--accent: #7c5a2c;
--accent-soft: #efe3cd;
--up: #4a6b3d;
--down: #a4402f;
--focus: #2f6f9e;
--radius: 3px;
--sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--serif: Georgia, "Iowan Old Style", "Palatino Linotype", Palatino, serif;
}
@media (prefers-color-scheme: dark) {
:root {
--paper: #1c1a17;
--card: #24211d;
--ink: #e8e3d8;
--ink-soft: #a8a294;
--ink-faint: #7d776a;
--rule: #38342d;
--rule-strong: #4a453b;
--accent: #c9a05e;
--accent-soft: #3a3227;
--up: #85ad72;
--down: #d17a68;
--focus: #6fa8d4;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--paper);
color: var(--ink);
font-family: var(--sans);
font-size: 16px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
.app { max-width: 1080px; margin: 0 auto; padding: 24px 20px 48px; }
/* ---------- status bar ---------- */
.statusbar {
display: flex; flex-wrap: wrap; gap: 8px 16px;
align-items: baseline; justify-content: space-between;
margin-bottom: 18px; padding-bottom: 10px;
border-bottom: 1px solid var(--rule);
font-size: 12px; letter-spacing: 0.04em;
}
.statusbar__what { display: flex; flex-wrap: wrap; gap: 8px; align-items: baseline; }
.statusbar__game { font-weight: 600; color: var(--ink); }
.statusbar__sep { color: var(--rule-strong); }
.statusbar__pack { color: var(--ink-soft); }
.statusbar__title { color: var(--accent); }
.statusbar__version {
font-variant-numeric: tabular-nums; color: var(--ink-faint); font-size: 11px;
}
.booting { color: var(--ink-faint); padding: 48px 20px; text-align: center; }
/* ---------- head-up display ---------- */
.hud {
display: flex;
flex-wrap: wrap;
gap: 20px 32px;
align-items: flex-end;
justify-content: space-between;
padding-bottom: 16px;
border-bottom: 2px solid var(--rule-strong);
}
.hud__stage {
margin: 0;
font-size: 12px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--ink-faint);
}
.hud__day { margin: 2px 0 0; font-size: 26px; font-weight: 600; letter-spacing: -0.01em; }
.hud__detail { margin: 1px 0 0; font-size: 13px; color: var(--ink-soft); }
.diary {
margin: 14px 0 0; padding: 8px 12px;
font-size: 12.5px; color: var(--ink-soft);
background: var(--card); border: 1px solid var(--rule); border-radius: var(--radius);
}
.diary__entry--in { color: var(--up); }
.diary__entry--out { color: var(--ink-soft); }
.stats { display: flex; flex-wrap: wrap; gap: 10px 26px; align-items: flex-end; }
.figure { display: flex; flex-direction: column; gap: 1px; min-width: 92px; }
.figure__label {
font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--ink-faint);
}
.figure__value { font-family: var(--serif); font-size: 21px; font-variant-numeric: tabular-nums; }
.figure__value--negative { color: var(--down); }
/* ---------- meters ---------- */
.meter { min-width: 150px; }
.meter--compact { min-width: 132px; }
.meter__head {
display: flex; justify-content: space-between; gap: 10px; align-items: baseline;
font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-faint);
}
.meter__value { font-variant-numeric: tabular-nums; color: var(--ink-soft); }
.meter__track {
margin-top: 4px; height: 5px; background: var(--rule); border-radius: 99px; overflow: hidden;
}
.meter__fill {
height: 100%; background: var(--accent); border-radius: 99px;
transition: width 280ms ease;
}
/* ---------- layout ---------- */
.layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 280px;
gap: 36px;
margin-top: 28px;
align-items: start;
}
@media (max-width: 860px) {
.layout { grid-template-columns: minmax(0, 1fr); gap: 28px; }
}
/* ---------- the scenario ---------- */
.scenario__text, .outcome__text {
font-family: var(--serif);
font-size: 20px;
line-height: 1.55;
margin: 0 0 24px;
}
.options { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
.option {
display: flex; flex-direction: column; gap: 3px; width: 100%;
padding: 14px 16px; text-align: left; cursor: pointer;
background: var(--card); color: inherit; font: inherit;
border: 1px solid var(--rule-strong); border-radius: var(--radius);
transition: border-color 120ms ease, transform 120ms ease, background 120ms ease;
}
.option:hover:not(:disabled) { border-color: var(--accent); transform: translateX(2px); }
.option:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
.option__label { font-size: 16px; }
.option--locked {
cursor: not-allowed; background: transparent; border-style: dashed;
border-color: var(--rule); color: var(--ink-faint);
}
.option__requirement {
font-size: 12px; letter-spacing: 0.04em; color: var(--ink-faint); font-style: italic;
}
/* ---------- outcome ---------- */
.outcome__chose {
margin: 0 0 10px; font-size: 12px; letter-spacing: 0.12em;
text-transform: uppercase; color: var(--accent);
}
.outcome__ledger {
margin: 26px 0; padding: 18px 20px;
background: var(--card); border: 1px solid var(--rule); border-radius: var(--radius);
}
.outcome__heading {
margin: 0 0 10px; font-size: 11px; letter-spacing: 0.12em;
text-transform: uppercase; color: var(--ink-faint); font-weight: 600;
}
.outcome__upkeep { margin-top: 18px; padding-top: 16px; border-top: 1px dashed var(--rule); }
.changes { list-style: none; margin: 0; padding: 0; display: grid; gap: 5px; }
.changes--none { color: var(--ink-faint); font-style: italic; margin: 0; }
.change { display: flex; gap: 10px; align-items: baseline; font-size: 14px; }
.change__label { color: var(--ink-soft); }
.change__delta { font-variant-numeric: tabular-nums; font-weight: 600; margin-left: auto; }
.change--up .change__delta { color: var(--up); }
.change--down .change__delta { color: var(--down); }
.change__note { font-size: 11px; color: var(--ink-faint); font-style: italic; }
.change--note .change__phrase { font-style: italic; }
.change--note.change--up .change__phrase { color: var(--up); }
.change--note.change--down .change__phrase { color: var(--ink-soft); }
.button--next { margin-top: 8px; }
/* ---------- people ---------- */
.people__heading, .retro__heading {
margin: 0 0 12px; font-size: 11px; letter-spacing: 0.12em;
text-transform: uppercase; color: var(--ink-faint); font-weight: 600;
}
.person {
padding: 16px; margin-bottom: 14px;
background: var(--card); border: 1px solid var(--rule); border-radius: var(--radius);
}
.person__name { margin: 0; font-size: 16px; font-weight: 600; }
.person__title {
margin: 1px 0 0; font-size: 11px; letter-spacing: 0.08em;
text-transform: uppercase; color: var(--ink-faint);
}
.person__description {
margin: 10px 0 14px; font-size: 13px; line-height: 1.5; color: var(--ink-soft);
font-family: var(--serif);
}
.person__meters { display: grid; gap: 8px; }
/* ---------- feedback ---------- */
.feedback {
margin: 24px 0; padding: 14px 16px;
border: 1px dashed var(--rule-strong); border-radius: var(--radius);
}
.feedback__row { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }
.feedback__prompt { font-size: 13px; color: var(--ink-soft); }
.feedback__ratings { display: flex; gap: 6px; }
.rating {
padding: 4px 10px; font-size: 16px; line-height: 1.2; cursor: pointer;
background: transparent; border: 1px solid var(--rule); border-radius: var(--radius);
filter: grayscale(1); opacity: 0.65; transition: all 120ms ease;
}
.rating:hover { opacity: 1; filter: none; }
.rating--on { filter: none; opacity: 1; border-color: var(--accent); background: var(--accent-soft); }
.rating:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
.feedback__toggle {
margin-left: auto; padding: 0; font: inherit; font-size: 12px; cursor: pointer;
background: none; border: none; color: var(--ink-faint); text-decoration: underline;
}
.feedback__detail { display: grid; gap: 12px; margin-top: 14px; }
.field { display: grid; gap: 4px; }
.field__label { font-size: 12px; color: var(--ink-faint); }
.field__input {
width: 100%; padding: 8px 10px; font: inherit; font-size: 14px; resize: vertical;
background: var(--card); color: inherit;
border: 1px solid var(--rule); border-radius: var(--radius);
}
.field__input:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
.feedback__saved { margin: 10px 0 0; font-size: 12px; color: var(--up); }
/* ---------- buttons and controls ---------- */
.controls {
display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
margin-top: 36px; padding-top: 18px; border-top: 1px solid var(--rule);
}
.button {
padding: 9px 15px; font: inherit; font-size: 14px; cursor: pointer;
background: var(--card); color: inherit;
border: 1px solid var(--rule-strong); border-radius: var(--radius);
transition: border-color 120ms ease, background 120ms ease;
}
.button:hover { border-color: var(--accent); }
.button:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
.button--primary { background: var(--accent); border-color: var(--accent); color: #fffdf7; }
.button--primary:hover { filter: brightness(1.08); }
.button--quiet { border-color: transparent; background: transparent; color: var(--ink-faint); }
.button--quiet:hover { color: var(--ink); border-color: var(--rule); }
/* ---------- retrospective ---------- */
.retro__head { padding-bottom: 20px; border-bottom: 2px solid var(--rule-strong); }
.retro__eyebrow {
margin: 0; font-size: 11px; letter-spacing: 0.14em;
text-transform: uppercase; color: var(--ink-faint);
}
.retro__title { margin: 6px 0 4px; font-size: 32px; font-weight: 600; letter-spacing: -0.02em; }
.retro__subtitle { margin: 0; color: var(--ink-soft); font-family: var(--serif); font-size: 17px; }
.retro__note { margin: 8px 0 0; color: var(--ink-soft); font-size: 13px; }
.retro__section { margin-top: 34px; }
.retro__people { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px; }
.retro__people .person { margin-bottom: 0; }
.facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 14px; margin: 0; }
.facts__item {
padding: 12px 14px; background: var(--card);
border: 1px solid var(--rule); border-radius: var(--radius);
}
.facts__label {
font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-faint);
}
.facts__value { margin: 3px 0 0; font-family: var(--serif); font-size: 16px; }
.moments { margin: 0; padding-left: 22px; display: grid; gap: 7px; }
.moment { font-family: var(--serif); font-size: 16px; }
.moments--none { padding: 0; color: var(--ink-faint); font-style: italic; }
.screen--error { padding: 40px 0; }
.screen--error h1 { font-size: 20px; color: var(--down); }
/* The setting picker. One card per content pack; the pack you are in is
marked rather than hidden, because "keep playing" is the common case. */
.screen--packs { padding: 40px 0; }
.packs__eyebrow {
margin: 0; font-size: 11px; letter-spacing: 0.14em;
text-transform: uppercase; color: var(--ink-faint);
}
.packs__title { margin: 6px 0 6px; font-size: 32px; font-weight: 600; letter-spacing: -0.02em; }
.packs__subtitle {
margin: 0 0 28px; max-width: 52ch;
color: var(--ink-soft); font-family: var(--serif); font-size: 17px;
}
.packs__list {
list-style: none; margin: 0; padding: 0;
display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px;
}
.packs__item {
padding: 18px 18px 16px; background: var(--card);
border: 1px solid var(--rule); border-radius: var(--radius);
display: flex; flex-direction: column; gap: 8px;
}
.packs__item--current { border-color: var(--accent); }
.packs__name { margin: 0; font-size: 19px; font-weight: 600; letter-spacing: -0.01em; }
.packs__blurb { margin: 0; color: var(--ink-soft); font-family: var(--serif); font-size: 16px; }
.packs__state {
margin: 0 0 4px; font-size: 12px; letter-spacing: 0.04em; color: var(--ink-faint);
}
.packs__item .button { margin-top: auto; align-self: flex-start; }
+85
View File
@@ -0,0 +1,85 @@
// Reading a career back out of its history.
//
// Everything here is derived generically from the turn record and the pack's
// display map — no path is named. A pack with different stats, or none of them
// called money, gets the same retrospective for free.
/** Paths the pack formats as money, in display order. */
function moneyPaths(pack) {
return Object.entries(pack.display ?? {})
.filter(([, rule]) => rule.format === 'money')
.sort((a, b) => (a[1].order ?? 99) - (b[1].order ?? 99))
.map(([path]) => path);
}
/** Every value a path passed through, turn by turn. */
function trace(history, path) {
const values = [];
for (const record of history) {
for (const change of [...record.changes, ...record.upkeep]) {
if (change.path === path && typeof change.to === 'number') values.push(change.to);
}
}
return values;
}
export function summarize(pack, state) {
const history = state.history ?? [];
const primary = moneyPaths(pack)[0] ?? null;
const primaryTrace = primary ? trace(history, primary) : [];
const byLabel = new Map();
for (const record of history) {
byLabel.set(record.optionLabel, (byLabel.get(record.optionLabel) ?? 0) + 1);
}
const favourite = [...byLabel.entries()].sort((a, b) => b[1] - a[1])[0] ?? null;
const netByTurn = history.map((record) => ({
turn: record.turn,
label: record.optionLabel,
net: [...record.changes, ...record.upkeep]
.filter((c) => c.path === primary && typeof c.to === 'number')
.reduce((sum, c) => sum + (c.to - c.from), 0),
}));
const ranked = [...netByTurn].sort((a, b) => b.net - a.net);
const lockedEncounters = history.reduce(
(sum, record) => sum + record.offered.filter((o) => !o.available).length, 0,
);
// How much of the run was new ground and how much was familiar. The ratio of
// turns to distinct situations is the honest measure of whether a pack has
// enough content for the length of run it invites.
const eventCounts = new Map();
for (const record of history) {
eventCounts.set(record.eventId, (eventCounts.get(record.eventId) ?? 0) + 1);
}
const counts = [...eventCounts.values()];
const mostSeen = [...eventCounts.entries()].sort((a, b) => b[1] - a[1])[0] ?? null;
const stagesVisited = [];
for (const record of history) {
if (stagesVisited.at(-1) !== record.stage) stagesVisited.push(record.stage);
}
return {
turns: history.length,
stage: state.stage,
notable: state.notable ?? [],
primaryPath: primary,
peak: primaryTrace.length ? Math.max(...primaryTrace) : null,
trough: primaryTrace.length ? Math.min(...primaryTrace) : null,
daysInTheRed: primaryTrace.filter((value) => value < 0).length,
favouriteAction: favourite ? { label: favourite[0], count: favourite[1] } : null,
bestDay: ranked[0]?.net > 0 ? ranked[0] : null,
worstDay: ranked.at(-1)?.net < 0 ? ranked.at(-1) : null,
lockedEncounters,
eventsSeen: eventCounts.size,
eventsTotal: (pack.events ?? []).length,
eventCounts,
repeatedSituations: counts.filter((n) => n > 1).length,
averageTimesSeen: eventCounts.size ? history.length / eventCounts.size : 0,
mostSeen: mostSeen ? { eventId: mostSeen[0], count: mostSeen[1] } : null,
stagesVisited,
};
}
+19
View File
@@ -0,0 +1,19 @@
// What is running. Shown on the main screen so a playtester can say which
// build they were on, and so it is obvious when new work has landed.
import { BUILD } from './build-info.js';
export const APP = {
name: 'The Ladder',
// Kept in step with package.json by a test, because it silently was not: the
// status bar read 0.3.0 through the 0.3.1 and 0.3.2 work, which is exactly
// the thing a playtester is told to check to know what they are playing.
version: '0.3.3',
};
/** "0.3.0 · a1b2c3d" — or "0.3.0 · dev" when running from source unstamped. */
export function versionLine() {
return `${APP.version} · ${BUILD.commit}`;
}
export { BUILD };
+74
View File
@@ -0,0 +1,74 @@
// The single-file build.
//
// A build that produces a file proves nothing; this one runs the bundled
// script against the fake DOM and plays a turn through it. That is the closest
// thing to opening dist/theladder.html that is available without a browser —
// it catches a broken bundle, which is otherwise only discovered by whoever
// was handed the file.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { installFakeDom, byClass, firstByClass } from './fixtures/fake-dom.js';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
test('the version the app reports is the version in package.json', async () => {
// It silently was not. The status bar read 0.3.0 through the whole of the
// 0.3.1 and 0.3.2 work, while playtesters were being told to check it to know
// which build they were on — a version string that lies is worse than none,
// because it is trusted.
const { APP } = await import('../src/version.js');
const pkg = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8'));
assert.equal(APP.version, pkg.version,
'src/version.js and package.json disagree about what this is');
});
test('the bundle builds, and the bundled game boots and plays', () => {
execFileSync('node', ['tools/build.js'], { cwd: root, stdio: 'pipe' });
const html = readFileSync(resolve(root, 'dist/theladder.html'), 'utf8');
assert.doesNotMatch(html, /<link\s+rel="stylesheet"/, 'the stylesheet is inlined');
assert.doesNotMatch(html, /type="module"/, 'no module scripts survive; file:// would block them');
assert.doesNotMatch(html, /^[ \t]*import\s/m, 'no bare import statements remain');
assert.match(html, /--paper:/, 'the CSS made it in');
// A distributed file has to be able to say which commit made it.
assert.match(html, /commit: '[^']+'/, 'the build stamp is present');
assert.doesNotMatch(html, /commit: 'dev'/, 'a built file is never stamped "dev"');
const script = html.match(/<script>([\s\S]*?)<\/script>/)?.[1];
assert.ok(script, 'the bundled script is present');
const dom = installFakeDom();
try {
// eslint-disable-next-line no-new-func -- running the build output is the point
new Function(script)();
// Nothing is saved and nothing is remembered, so a build shipping more
// than one setting opens on the picker. Click into the first one and carry
// on — this exercises the front door in the real bundle, which is the one
// place the pack registry, the picker screen and the save slots all meet.
assert.ok(firstByClass(dom.root, 'screen--packs'), 'the bundled game rendered the picker');
assert.match(dom.root.textContent, /Corporate Ladder/, 'the packs are named');
assert.match(dom.root.textContent, /The Frontier/);
const start = byClass(dom.root, 'button').find((b) => /Start here|Keep playing/.test(b.textContent));
assert.ok(start, 'the picker offers a way in');
start.click();
assert.ok(firstByClass(dom.root, 'screen--turn'), 'the bundled game rendered a turn');
assert.match(dom.root.textContent, /Day 1/);
assert.match(dom.root.textContent, /Marlene Hobb/);
assert.match(dom.root.textContent, /The Ladder/, 'the status bar is in the bundle');
const playable = byClass(dom.root, 'option').filter((b) => !b.disabled);
assert.ok(playable.length > 0, 'the bundled game offers choices');
playable[0].click();
assert.ok(firstByClass(dom.root, 'outcome'), 'a turn can be played in the bundle');
} finally {
dom.restore();
}
});
+79
View File
@@ -0,0 +1,79 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { evaluate, check, pathsUsed } from '../src/engine/conditions.js';
const state = {
money: 100,
turn: 12,
skills: { talking: 3 },
flags: { in_debt: false },
notable: ['promoted'],
seen: { first_day: 1 },
};
test('compares numbers', () => {
assert.equal(evaluate(state, { path: 'money', op: '>=', value: 100 }), true);
assert.equal(evaluate(state, { path: 'money', op: '>', value: 100 }), false);
assert.equal(evaluate(state, { path: 'skills.talking', op: '<', value: 5 }), true);
});
test('compares booleans and equality', () => {
assert.equal(evaluate(state, { path: 'flags.in_debt', op: '==', value: false }), true);
assert.equal(evaluate(state, { path: 'flags.in_debt', op: '!=', value: true }), true);
});
test('array membership works both directions', () => {
assert.equal(evaluate(state, { path: 'notable', op: 'has', value: 'promoted' }), true);
assert.equal(evaluate(state, { path: 'notable', op: 'not-has', value: 'fired' }), true);
assert.equal(evaluate(state, { path: 'skills.talking', op: 'in', value: [1, 2, 3] }), true);
assert.equal(evaluate(state, { path: 'skills.talking', op: 'not-in', value: [9] }), true);
});
test('ordering against a missing path is false, not a throw', () => {
assert.equal(evaluate(state, { path: 'skills.welding', op: '>=', value: 1 }), false);
assert.equal(evaluate(state, { path: 'skills.welding', op: '<', value: 1 }), false);
});
test('content can read engine bookkeeping', () => {
assert.equal(evaluate(state, { path: 'turn', op: '>=', value: 10 }), true);
assert.equal(evaluate(state, { path: 'seen.first_day', op: '>=', value: 1 }), true);
assert.equal(evaluate(state, { path: 'seen.never_fired', op: '>=', value: 1 }), false);
});
test('combinators nest', () => {
const condition = {
all: [
{ path: 'money', op: '>', value: 0 },
{ any: [{ path: 'skills.talking', op: '>=', value: 3 }, { path: 'flags.in_debt', op: '==', value: true }] },
{ not: { path: 'notable', op: 'has', value: 'fired' } },
],
};
assert.equal(evaluate(state, condition), true);
assert.equal(evaluate({ ...state, money: -1 }, condition), false);
});
test('check reports which conditions failed', () => {
const result = check(state, [
{ path: 'money', op: '>=', value: 100 },
{ path: 'skills.talking', op: '>=', value: 9 },
]);
assert.equal(result.passed, false);
assert.equal(result.failed.length, 1);
assert.equal(result.failed[0].path, 'skills.talking');
});
test('an absent gate passes', () => {
assert.equal(check(state, undefined).passed, true);
assert.equal(check(state, []).passed, true);
});
test('an unknown operator is a loud failure', () => {
assert.throws(() => evaluate(state, { path: 'money', op: '=~', value: 1 }), /unknown condition operator/);
});
test('pathsUsed walks the whole tree', () => {
const paths = pathsUsed({
all: [{ path: 'money', op: '>', value: 0 }, { not: { path: 'flags.in_debt', op: '==', value: true } }],
});
assert.deepEqual(paths.sort(), ['flags.in_debt', 'money']);
});
+279
View File
@@ -0,0 +1,279 @@
// 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} every turn always offers something to do, however badly it is going`, () => {
for (let seed = 100; seed < 130; seed++) randomRun(pack, seed, 150);
});
test(`${where} a played run replays identically from its seed`, () => {
assert.deepEqual(randomRun(pack, 42, 80), randomRun(pack, 42, 80));
});
test(`${where} how you play changes where you end up`, () => {
// Measured before promotions land, so this is about how a player handles
// money rather than how fast they left the first rung. Mixing the two
// hides the economy behind the career.
const { byStrategy } = sweep(pack, { seeds: 12, turns: 35 });
const finalMoney = (name) => median(byStrategy[name].map((s) => s.money));
assert.ok(byStrategy.thrifty.every((s) => s.stage === pack.startingStage),
'nobody should be promoted this early');
assert.ok(finalMoney('thrifty') > finalMoney('random'),
'a careful player should get ahead');
assert.ok(finalMoney('random') > finalMoney('spendthrift'),
'a careless player should struggle');
});
test(`${where} the upkeep baseline matches what the pack declares`, () => {
// The guard that was missing when Corporate Ladder's rent was raised from
// 385 to 455 without re-deriving what it meant: the baseline went from
// -$3/day to -$13/day and trapped a real playtester below zero for 30% of
// a 182-turn run. No archetype caught it — they all either optimised money
// or were promoted out of the problem before it bit.
for (const [stage, [low, high]] of Object.entries(pack.tuning.baselinePerCycle)) {
const net = baselinePerCycle(pack, stage);
assert.ok(net > low && net < high,
`"${stage}" nets ${net} a cycle; the pack declares (${low}, ${high})`);
}
});
test(`${where} nobody spends the run stuck below zero`, () => {
// Hard times is a state a player passes through, not one they live in.
// The failure this guards against is a debt whose service charge exceeds
// anything the escape options can pay — inescapable by arithmetic.
for (const strategy of Object.keys(strategies)) {
const shares = [];
for (let seed = 0; seed < 8; seed++) {
let red = 0;
let turns = 0;
play(pack, { strategy, seed, turns: 150,
visit: ({ state }) => { turns++; if (state.money < 0) red++; } });
shares.push(red / turns);
}
const worst = Math.max(...shares);
assert.ok(worst < 0.25,
`${strategy} spent ${(worst * 100).toFixed(0)}% of a run below zero`);
}
});
test(`${where} a run that goes broke can climb back out`, () => {
let sawNegative = false;
let recovered = false;
play(pack, { strategy: 'spendthrift', seed: 'recovery', turns: 200,
visit: ({ state }) => {
if (state.money < 0) sawNegative = true;
else if (sawNegative) recovered = true;
} });
assert.ok(sawNegative, 'a careless run should go broke');
assert.ok(recovered, 'and should be able to climb back out');
});
test(`${where} a long run does not collapse onto a handful of situations`, () => {
// Motivated by a real 183-turn session. Before Dispatch was fleshed out,
// its two floor events were 46% of every promoted run — one every three
// turns. Variety is a property of the pack, and this should fail loudly
// when a stage is too thin for the run it invites.
for (const strategy of ['sociable', 'thrifty', 'random']) {
const counts = new Map();
let turns = 0;
play(pack, { strategy, seed: 'variety', turns: 200,
visit: ({ turn }) => {
counts.set(turn.event.id, (counts.get(turn.event.id) ?? 0) + 1);
turns++;
} });
const [topEvent, topCount] = [...counts.entries()].sort((a, b) => b[1] - a[1])[0];
const share = topCount / turns;
assert.ok(share < 0.25,
`${strategy}: "${topEvent}" was ${(share * 100).toFixed(0)}% of turns`);
// Measured against what was actually reachable: a run that never leaves
// the first stage cannot see the second, and counting that as thin
// content would be measuring career progress rather than variety.
const visited = new Set([...counts.keys()]
.map((id) => pack.events.find((e) => e.id === id).stage)
.filter(Boolean));
const reachable = pack.events
.filter((e) => e.stage === undefined || visited.has(e.stage)).length;
assert.ok(counts.size / reachable > 0.6,
`${strategy}: saw ${counts.size} of ${reachable} reachable situations in ${turns} turns`);
}
});
test(`${where} the retrospective has something to read back`, () => {
const state = randomRun(pack, 3, 60);
assert.ok(state.notable.length > 0, 'notable moments should accumulate');
assert.equal(state.history.length, 60, 'every turn is recorded');
assert.ok(state.history.every((h) => Array.isArray(h.upkeep)), 'upkeep is recorded per turn');
});
test(`${where} the retrospective reports how repetitive the run was`, () => {
const state = play(pack, { strategy: 'sociable', seed: 'repeats', turns: 120 });
const summary = summarize(pack, state);
assert.equal(summary.turns, 120);
assert.ok(summary.eventsSeen > 0 && summary.eventsSeen <= summary.eventsTotal);
assert.ok(summary.averageTimesSeen > 1, 'a long run repeats situations');
assert.equal(
summary.averageTimesSeen.toFixed(2),
(summary.turns / summary.eventsSeen).toFixed(2),
'the average is turns over distinct situations',
);
assert.ok(summary.mostSeen.count >= summary.averageTimesSeen);
assert.ok(summary.repeatedSituations <= summary.eventsSeen);
assert.ok(summary.stagesVisited.includes(pack.startingStage));
});
if (pack.calendar) {
test(`${where} the calendar names the unit and counts the cycle`, () => {
const { cycleLength, unitNames } = pack.calendar;
let state = startGame(pack, { seed: 'calendar' });
assert.deepEqual(
[state.calendar.name, state.calendar.cycle, state.calendar.index],
[unitNames?.[0] ?? null, 1, 0],
'turn 1 is the first unit of the first cycle',
);
// One full cycle on, the name comes round and the cycle has ticked.
for (let t = 0; t < cycleLength; t++) {
const turn = currentTurn(pack, state);
state = takeTurn(pack, state, turn.options.find((o) => o.available).option.id).state;
}
assert.equal(state.turn, cycleLength + 1);
assert.deepEqual(
[state.calendar.name, state.calendar.cycle, state.calendar.index],
[unitNames?.[0] ?? null, 2, 0],
);
});
}
}
+228
View File
@@ -0,0 +1,228 @@
// Corporate Ladder's own tuning.
//
// What is true of this pack because it is *this* pack: the shape of its week,
// when wages and rent land, how the loan behaves, when Dispatch opens and how
// deep the Trevor chain is. Everything true of a pack because it is a pack —
// validity, dead content, the economic baseline, variety, replay — is in
// conformance.test.js, which runs over every pack in the registry.
//
// Engine behaviour is covered against the fixture pack instead, so tuning the
// game cannot break the engine suite and vice versa.
//
// One trap, learned the hard way: the chooser's RNG must be seeded
// *differently* from the game's. Both draw one number per turn from the same
// generator, so sharing a seed correlates the choice index with the event
// draw and silently makes whole option combinations unreachable — which looks
// exactly like a content bug.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { corporateLadder as pack } from '../content/corporateladder/index.js';
import { startGame, currentTurn, takeTurn } from '../src/engine/game.js';
import { check } from '../src/engine/conditions.js';
import { play, sweep, median } from './fixtures/strategies.js';
/**
* Net money from upkeep per week for a player in a given position: at a stage,
* at a lifestyle tier, owing nothing.
*
* Derived rather than simulated. Comparing simulated end-balances between
* promoted and unpromoted players looks like the obvious test and is
* confounded: which archetypes get promoted is not independent of how they
* spend, so the pooled medians mostly measure the archetype mix. It passed for
* two releases and then inverted the moment there was something to spend on.
*/
function netPerCycle(position) {
const state = { debt: 0, lifestyle: 0, reputation: 0, ...position };
const cycle = pack.calendar?.cycleLength ?? 1;
return (pack.upkeep ?? [])
.filter((entry) => check(state, entry.requires).passed)
.reduce((total, entry) => total + entry.effects
.filter((e) => e.path === 'money' && e.op === 'add')
.reduce((sum, e) => sum + e.value, 0) * (cycle / (entry.every ?? 1)), 0);
}
test('the promotion is worth taking, and reachable by a deliberate player', () => {
const { byStrategy } = sweep(pack, { seeds: 12, turns: 200 });
const promoted = Object.values(byStrategy).flat().filter((s) => s.stage === 'dispatch');
const stayed = Object.values(byStrategy).flat().filter((s) => s.stage === 'mailroom');
assert.ok(promoted.length > 0 && stayed.length > 0, 'both outcomes should occur');
const mailroom = netPerCycle({ stage: 'mailroom' });
const coordinator = netPerCycle({ stage: 'dispatch' });
assert.ok(coordinator > mailroom + 40,
`dispatch nets ${coordinator} a week against the mailroom's ${mailroom}`);
// A player actually pursuing it should get there mid-run, not at the very
// end — the mailroom is a career, not a tutorial.
const timings = [];
for (let seed = 0; seed < 12; seed++) {
let at = null;
play(pack, { strategy: 'sociable', seed, turns: 200,
visit: ({ state }) => { if (state.stage === 'dispatch' && at === null) at = state.turn; } });
if (at) timings.push(at);
}
assert.equal(timings.length, 12, 'a sociable player should always get the offer');
const typical = median(timings);
assert.ok(typical > 40 && typical < 90, `promotion landed at turn ${typical}`);
});
/**
* Play to `turns` chasing standing, buying the lifestyle or refusing to.
*
* Two players differing in exactly one habit. The archetype sweep cannot
* answer this question: which archetypes get promoted is not independent of
* how they spend, so its pooled medians compare populations, not policies.
*/
function chaseStanding({ seed, keepUp, turns = 200 }) {
const buys = (r) => (r.option.effects ?? []).some((e) => e.path === 'lifestyle');
const gain = (r) => (r.option.effects ?? [])
.filter((e) => e.path === 'reputation' && e.op === 'add')
.reduce((sum, e) => sum + e.value, 0);
let state = startGame(pack, { seed: `standing-${seed}` });
for (let t = 0; t < turns; t++) {
const turn = currentTurn(pack, state);
const available = turn.options.filter((o) => o.available);
const buy = available.find(buys);
const chosen = (keepUp && buy)
? buy
: [...available.filter((o) => keepUp || !buys(o))].sort((a, b) => gain(b) - gain(a))[0]
?? available[0];
state = takeTurn(pack, state, chosen.option.id).state;
}
return state;
}
test('standing costs money to keep, and the top of it cannot be worked for', () => {
// Derived: each tier is a real weekly charge, and a coordinator keeping up
// fully is running at a loss on upkeep alone. That is the answer to "money
// stops mattering late" — it stopped mattering because standing was free.
const plain = netPerCycle({ stage: 'dispatch' });
const car = netPerCycle({ stage: 'dispatch', lifestyle: 1 });
const address = netPerCycle({ stage: 'dispatch', lifestyle: 2 });
assert.ok(car < plain - 25, `the car costs ${plain - car} a week`);
assert.ok(address < car - 35, `the address costs ${car - address} a week`);
assert.ok(address < 0,
`a coordinator keeping up nets ${address} a week and has to earn the difference`);
// And the top of the ladder is not a plateau. A 96-turn session bought both
// tiers, reached 100 standing by turn 84, and from there had nothing left to
// spend on: every charge was switched off and the balance only climbed.
// Standing costs money to carry at every tier, with nothing to buy that ends
// it, so there is no position in this pack where money stops mattering.
const arrived = netPerCycle({ stage: 'dispatch', lifestyle: 2, reputation: 90, money: 2500 });
assert.ok(arrived < address,
`standing itself should cost something: ${arrived} against ${address} a week`);
// And it steepens with what there is to keep up, which is what stops the
// balance climbing forever. A flat charge could not: late income scales with
// how well the player plays, so flattening an optimiser's slope with one
// meant putting ordinary play 44% of a run below zero.
const wealthy = netPerCycle({ stage: 'dispatch', lifestyle: 2, reputation: 90, money: 6000 });
assert.ok(wealthy < arrived,
`the charge should steepen with money: ${wealthy} against ${arrived} a week`);
assert.ok(wealthy < 0, `and take a rich coordinator backwards: ${wealthy} a week`);
// At every tier, not only the top one: a coordinator who bought nothing is
// already paying for standing in standing, and pays in money besides.
const plainSomebody = netPerCycle({ stage: 'dispatch', lifestyle: 0, reputation: 90, money: 6000 });
assert.ok(plainSomebody < plain,
`standing should cost at tier 0 too: ${plainSomebody} against ${plain} a week`);
// Simulated, controlled: the ceiling. Without the top step a player who
// chased standing every turn held 99 while buying nothing, which made the
// whole system a tax on inattention rather than a choice.
const never = [];
const keeps = [];
for (let seed = 0; seed < 8; seed++) {
never.push(chaseStanding({ seed, keepUp: false }));
keeps.push(chaseStanding({ seed, keepUp: true }));
}
const standing = (runs) => median(runs.map((s) => s.reputation));
assert.ok(standing(never) <= 90,
`a coordinator who buys nothing reached ${standing(never)} standing`);
assert.ok(standing(keeps) >= 95,
`a coordinator who keeps up reached only ${standing(keeps)} standing`);
assert.ok(median(keeps.map((s) => s.money)) < median(never.map((s) => s.money)),
'and the difference is paid for out of the balance');
});
test('hard times is uncommon for a careful player and real for a careless one', () => {
const countHardTimes = (strategy) => {
let days = 0;
play(pack, { strategy, seed: 'ht', turns: 200,
visit: ({ turn }) => { if (turn.event.id === 'rent_bounced') days++; } });
return days;
};
assert.equal(countHardTimes('thrifty'), 0, 'minding the money should keep you out of trouble');
assert.ok(countHardTimes('spendthrift') > 0, 'ignoring it should land you in it');
});
test('money arrives and leaves on a rhythm the player can plan around', () => {
const wages = [];
const rent = [];
let state = startGame(pack, { seed: 'rhythm' });
for (let t = 0; t < 30; t++) {
const turn = currentTurn(pack, state);
const result = takeTurn(pack, state, turn.options.find((o) => o.available).option.id);
for (const change of result.record.upkeep) {
if (change.label === 'Wages') wages.push(state.turn);
if (change.label === 'Rent') rent.push(state.turn);
}
state = result.state;
}
assert.deepEqual(wages, [5, 19], 'paid on alternate Fridays');
assert.deepEqual(rent, [8, 15, 22, 29], 'rent every Monday from the second week');
});
test('the calendar names the day and counts the week', () => {
let state = startGame(pack, { seed: 'calendar' });
assert.deepEqual(
[state.calendar.name, state.calendar.cycle, state.calendar.index],
['Monday', 1, 0],
);
for (let t = 0; t < 11; t++) {
const turn = currentTurn(pack, state);
state = takeTurn(pack, state, turn.options.find((o) => o.available).option.id).state;
}
assert.equal(state.turn, 12);
assert.deepEqual([state.calendar.name, state.calendar.cycle], ['Friday', 2]);
});
test('debt cannot spiral without limit: lending stops and the charge escalates', () => {
let peakDebt = 0;
for (const strategy of ['spendthrift', 'desperate', 'random']) {
for (let seed = 0; seed < 12; seed++) {
play(pack, { strategy, seed, turns: 200,
visit: ({ state }) => { peakDebt = Math.max(peakDebt, state.debt); } });
}
}
// The loan is gated at debt < 2000 and lends 500, so 2500 plus stalled
// interest is the ceiling. Well above that means the gate has stopped working.
assert.ok(peakDebt <= 3000, `debt reached ${peakDebt}; the borrowing ceiling is not holding`);
const charges = pack.upkeep.filter((u) => u.label?.startsWith('Loan service charge'));
assert.ok(charges.length >= 3, 'the service charge should step up as debt grows');
});
test('a deliberate player can reach relationship-gated content in a reasonable run', () => {
// The Trevor chain is the deepest gate in the pack. A player cultivating him
// should get there well inside a normal session, not at turn 100.
const regard = (o) => (o.option.effects ?? [])
.filter((e) => e.path === 'relationships.trevor.likes_you')
.reduce((sum, e) => sum + e.value, 0);
let state = startGame(pack, { seed: 'befriend-trevor' });
let reached = null;
for (let t = 0; t < 60 && reached === null; t++) {
const turn = currentTurn(pack, state);
if (turn.event.id === 'trevor_favor') { reached = state.turn; break; }
const best = turn.options.filter((o) => o.available).sort((a, b) => regard(b) - regard(a))[0];
state = takeTurn(pack, state, best.option.id).state;
}
assert.ok(reached && reached < 45, `Trevor's chain opened at turn ${reached ?? 'never'}`);
});
+74
View File
@@ -0,0 +1,74 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { applyEffect, applyEffects } from '../src/engine/effects.js';
const schema = {
'money': { initial: 0, min: -500, max: 1000 },
'skills.talking': { initial: 1, min: 0, max: 10 },
'flags.in_debt': { initial: false },
'notable': { initial: [] },
};
const state = { money: 100, skills: { talking: 1 }, flags: { in_debt: false }, notable: [] };
test('add and set produce a new state', () => {
const result = applyEffects(state, [
{ path: 'money', op: 'add', value: -30 },
{ path: 'flags.in_debt', op: 'set', value: true },
], schema);
assert.equal(result.state.money, 70);
assert.equal(result.state.flags.in_debt, true);
assert.equal(state.money, 100, 'the input state is never mutated');
});
test('push appends to a list', () => {
const result = applyEffects(state, [{ path: 'notable', op: 'push', value: 'first day' }], schema);
assert.deepEqual(result.state.notable, ['first day']);
assert.deepEqual(state.notable, [], 'the input list is never mutated');
});
test('remove drops matching entries', () => {
const seeded = { ...state, notable: ['a', 'b', 'a'] };
const result = applyEffects(seeded, [{ path: 'notable', op: 'remove', value: 'a' }], schema);
assert.deepEqual(result.state.notable, ['b']);
});
test('values clamp to their declared bounds', () => {
const high = applyEffect(state, { path: 'skills.talking', op: 'add', value: 99 }, schema);
assert.equal(high.state.skills.talking, 10);
assert.equal(high.change.clamped, true);
const low = applyEffect(state, { path: 'money', op: 'add', value: -9999 }, schema);
assert.equal(low.state.money, -500);
});
test('an unbounded path is not clamped', () => {
const result = applyEffect(state, { path: 'money', op: 'add', value: 10 }, {});
assert.equal(result.state.money, 110);
assert.equal(result.change.clamped, false);
});
test('the change record carries before and after for every path touched', () => {
const result = applyEffects(state, [
{ path: 'money', op: 'add', value: 25 },
{ path: 'skills.talking', op: 'add', value: 1 },
], schema);
assert.deepEqual(result.changes.map((c) => [c.path, c.from, c.to]), [
['money', 100, 125],
['skills.talking', 1, 2],
]);
});
test('adding to a path that has never been set treats it as zero', () => {
const result = applyEffect({}, { path: 'money', op: 'add', value: 5 }, {});
assert.equal(result.state.money, 5);
});
test('an unknown operation is a loud failure', () => {
assert.throws(() => applyEffect(state, { path: 'money', op: 'multiply', value: 2 }), /unknown effect operation/);
});
test('an empty effect list is a no-op', () => {
const result = applyEffects(state, [], schema);
assert.deepEqual(result.changes, []);
assert.equal(result.state, state);
});
+167
View File
@@ -0,0 +1,167 @@
// A minimal DOM, enough to drive the UI headlessly.
//
// Not a browser and not a substitute for clicking the real thing — it exists
// so that the wiring (imports, handlers, the render/choose/log path) is
// exercised by the test suite rather than discovered by a player. Anything
// involving real files or real downloads is out of its scope and has to be
// checked by hand.
class FakeNode {
constructor() {
this.childNodes = [];
this.parentNode = null;
}
get textContent() {
return this.childNodes.map((child) => child.textContent).join('');
}
}
class FakeText extends FakeNode {
constructor(text) {
super();
this.nodeValue = String(text);
}
get textContent() {
return this.nodeValue;
}
}
class FakeElement extends FakeNode {
constructor(tagName) {
super();
this.tagName = tagName.toUpperCase();
this.className = '';
this.id = '';
this.attributes = {};
this.listeners = {};
this.dataset = {};
this.style = '';
// Predefined so `key in el` behaves the way it does in a real element:
// these are set as properties, everything else falls through to attributes.
this.disabled = false;
this.value = '';
this.rows = 0;
this.type = '';
this.accept = '';
this.href = '';
this.download = '';
this.files = null;
}
append(...nodes) {
for (const node of nodes) {
node.parentNode = this;
this.childNodes.push(node);
}
}
replaceChildren(...nodes) {
this.childNodes = [];
this.append(...nodes);
}
remove() {
const siblings = this.parentNode?.childNodes;
if (siblings) siblings.splice(siblings.indexOf(this), 1);
}
setAttribute(name, value) { this.attributes[name] = String(value); }
getAttribute(name) { return this.attributes[name] ?? null; }
addEventListener(type, handler) {
(this.listeners[type] ??= []).push(handler);
}
dispatch(type, event = {}) {
for (const handler of this.listeners[type] ?? []) handler({ target: this, ...event });
}
click() { this.dispatch('click'); }
focus() {
if (globalThis.document) globalThis.document.activeElement = this;
}
}
function walk(node, out = []) {
out.push(node);
for (const child of node.childNodes) if (child instanceof FakeElement) walk(child, out);
return out;
}
/** Every element under `root` whose class list contains `className`. */
export function byClass(root, className) {
return walk(root).filter((el) => el.className.split(/\s+/).includes(className));
}
/** The first such element, or undefined. */
export function firstByClass(root, className) {
return byClass(root, className)[0];
}
/** Every element of a tag. */
export function byTag(root, tagName) {
return walk(root).filter((el) => el.tagName === tagName.toUpperCase());
}
class FakeStorage {
constructor() { this.map = new Map(); }
getItem(key) { return this.map.has(key) ? this.map.get(key) : null; }
setItem(key, value) { this.map.set(key, String(value)); }
removeItem(key) { this.map.delete(key); }
clear() { this.map.clear(); }
}
/**
* Install the fake DOM as globals and return a handle for the test. Call the
* returned `restore` afterwards so suites do not leak into each other.
*/
export function installFakeDom() {
const root = new FakeElement('div');
root.id = 'app';
const document = {
createElement: (tag) => new FakeElement(tag),
createTextNode: (text) => new FakeText(text),
getElementById: (id) => (id === 'app' ? root : null),
body: new FakeElement('body'),
activeElement: null,
};
const listeners = {};
const window = {
addEventListener: (type, handler) => { (listeners[type] ??= []).push(handler); },
};
const previous = {};
const globals = {
document,
window,
Node: FakeNode,
localStorage: new FakeStorage(),
requestAnimationFrame: (fn) => setTimeout(fn, 0),
confirm: () => true,
alert: () => {},
};
for (const [key, value] of Object.entries(globals)) {
previous[key] = globalThis[key];
globalThis[key] = value;
}
return {
document,
window,
root,
storage: globals.localStorage,
/** Fire a window event, e.g. beforeunload. */
fireWindow: (type) => { for (const handler of listeners[type] ?? []) handler({}); },
restore() {
for (const [key, value] of Object.entries(previous)) {
if (value === undefined) delete globalThis[key];
else globalThis[key] = value;
}
},
};
}
export { FakeElement, FakeText, FakeNode };
+107
View File
@@ -0,0 +1,107 @@
// Player archetypes for content probing.
//
// "Is this content reachable?" is not the same question as "does random play
// reach it?" — a random player is not a plausible player, and several things
// in a pack exist precisely for a player pursuing something. Coverage is
// therefore the union across archetypes, and each one is a rough caricature of
// a way somebody actually plays.
//
// NOTE: seed the chooser differently from the game. Both draw one number per
// turn from the same generator, so a shared seed correlates the choice with
// the event draw and silently makes combinations unreachable.
import { startGame, currentTurn, takeTurn } from '../../src/engine/game.js';
import { createRng } from '../../src/engine/rng.js';
const moneyGain = (resolved) => (resolved.option.effects ?? [])
.filter((e) => e.path === 'money' && e.op === 'add')
.reduce((sum, e) => sum + e.value, 0);
const relationshipGain = (resolved) => (resolved.option.effects ?? [])
.filter((e) => e.path.startsWith('relationships.'))
.reduce((sum, e) => sum + e.value, 0);
const best = (options, score) => [...options].sort((a, b) => score(b) - score(a))[0];
export const strategies = {
/** No plan at all. */
random: (options, rng) => rng.pick(options),
/** Takes the money every time. */
thrifty: (options) => best(options, moneyGain),
/** Spends it every time. */
spendthrift: (options) => best(options, (o) => -moneyGain(o)),
/** Chases standing with people. */
sociable: (options) => best(options, relationshipGain),
/**
* Broke but well-liked — the state the "ask a friend for money" options were
* written for, and one no single-axis strategy ever reaches.
*/
desperate: (options, rng) => (rng.next() < 0.25
? rng.pick(options)
: best(options, (o) => relationshipGain(o) - moneyGain(o) / 40)),
/**
* Never leaves the first rung — refuses anything that would change stage,
* and otherwise plays for people. Added after a real 182-turn session that
* stayed in the mailroom the whole way and spent 30% of it below zero: every
* other archetype either optimised money or got promoted out of the problem,
* so none of them could see it.
*/
lifer: (options) => {
const stays = options.filter(
(o) => !(o.option.effects ?? []).some((e) => e.path === 'stage'),
);
return best(stays.length ? stays : options, relationshipGain);
},
};
/** Play one run to `turns`, calling `visit` before each choice. */
export function play(pack, { strategy = 'random', seed = 'run', turns = 200, visit } = {}) {
const choose = strategies[strategy];
const rng = createRng(`chooser-${strategy}-${seed}`);
let state = startGame(pack, { seed: `game-${strategy}-${seed}` });
for (let t = 0; t < turns; t++) {
const turn = currentTurn(pack, state);
const available = turn.options.filter((o) => o.available);
if (available.length === 0) throw new Error(`no available option on "${turn.event.id}"`);
const chosen = choose(available, rng);
visit?.({ turn, state, chosen });
state = takeTurn(pack, state, chosen.option.id).state;
}
return state;
}
/** Play every archetype across several seeds, reporting what was reached. */
export function sweep(pack, { seeds = 12, turns = 200 } = {}) {
const firedEvents = new Set();
const takenOptions = new Set();
// What a player *could* have picked. This, not what a bot happened to pick,
// is what "is this content reachable" means — a real player chooses freely,
// and an archetype's preferences are an artefact of the harness.
const offeredOptions = new Set();
const byStrategy = {};
for (const strategy of Object.keys(strategies)) {
const finals = [];
for (let seed = 0; seed < seeds; seed++) {
const state = play(pack, {
strategy,
seed,
turns,
visit: ({ turn, chosen }) => {
firedEvents.add(turn.event.id);
takenOptions.add(`${turn.event.id}.${chosen.option.id}`);
for (const resolved of turn.options) {
if (resolved.available) offeredOptions.add(`${turn.event.id}.${resolved.option.id}`);
}
},
});
finals.push(state);
}
byStrategy[strategy] = finals;
}
return { firedEvents, takenOptions, offeredOptions, byStrategy };
}
export const median = (values) =>
[...values].sort((a, b) => a - b)[Math.floor(values.length / 2)];
+112
View File
@@ -0,0 +1,112 @@
// A deliberately small pack that exercises every engine feature: gates,
// once-only events, cooldowns, weighted draws, a conditional branch and a
// clamped stat. Engine tests run against this, never against real content, so
// tuning the game cannot break the test suite.
export const testPack = {
id: 'testpack',
name: 'Test Pack',
version: '1.0.0',
turnUnit: 'day',
startingStage: 'floor',
state: {
'money': { initial: 100, min: -500, max: 10000 },
'skills.talking': { initial: 1, min: 0, max: 10 },
'flags.in_debt': { initial: false },
'notable': { initial: [] },
},
relationshipStats: {
likes_you: { initial: 50, min: 0, max: 100 },
fears_you: { initial: 50, min: 0, max: 100 },
},
characters: [
{ id: 'chief', name: 'The Chief', role_type: 'boss', description: 'Tall, tired.' },
{ id: 'pat', name: 'Pat', role_type: 'rival', description: 'Smiles too much.' },
],
events: [
{
id: 'routine',
stage: 'floor',
weight: 100,
description: 'Another ordinary shift.',
options: [
{
id: 'work',
label: 'Put your head down and work.',
effects: [
{ path: 'money', op: 'add', value: 10 },
{ path: 'relationships.chief.likes_you', op: 'add', value: 2 },
],
},
{
id: 'charm',
label: 'Talk your way into an easier job.',
requires: [{ path: 'skills.talking', op: '>=', value: 3 }],
effects: [{ path: 'skills.talking', op: 'add', value: 1 }],
},
{
id: 'practice',
label: 'Chat with Pat all afternoon.',
effects: [
{ path: 'skills.talking', op: 'add', value: 1 },
{ path: 'money', op: 'add', value: -5 },
{ path: 'relationships.pat.likes_you', op: 'add', value: 3 },
],
},
],
},
{
id: 'first_day',
stage: 'floor',
weight: 1000,
once: true,
description: 'Your first day.',
options: [
{
id: 'arrive',
label: 'Arrive on time.',
effects: [{ path: 'notable', op: 'push', value: 'Showed up on time, once.' }],
},
],
},
{
id: 'hard_times',
stage: 'floor',
weight: 1000,
requires: [{ path: 'money', op: '<', value: 0 }],
description: 'Rent is due and you do not have it.',
options: [
{
id: 'loan',
label: 'Take the loan.',
effects: [
{ path: 'money', op: 'add', value: 200 },
{ path: 'flags.in_debt', op: 'set', value: true },
],
},
{
id: 'beg',
label: 'Ask Pat for help.',
effects: [
{ path: 'money', op: 'add', value: 50 },
{ path: 'relationships.pat.fears_you', op: 'add', value: -10 },
],
},
],
},
{
id: 'audit',
stage: 'floor',
weight: 5,
cooldown: 10,
description: 'An auditor is wandering the floor.',
options: [{ id: 'nod', label: 'Nod politely.', effects: [] }],
},
],
};
export default testPack;
+269
View File
@@ -0,0 +1,269 @@
// 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 { 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 === 'Settlement') 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('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'}`);
});
+282
View File
@@ -0,0 +1,282 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { startGame, currentTurn, takeTurn, MoveError } from '../src/engine/game.js';
import { getPath, setPath } from '../src/engine/paths.js';
import { testPack } from './fixtures/test-pack.js';
/** Play `turns` turns, always taking the first available option. */
function playThrough(seed, turns, chooser = (options) => options.find((o) => o.available).option.id) {
let state = startGame(testPack, { seed });
const records = [];
for (let i = 0; i < turns; i++) {
const turn = currentTurn(testPack, state);
const result = takeTurn(testPack, state, chooser(turn.options, turn.event, state));
state = result.state;
records.push(result.record);
}
return { state, records };
}
test('a new game starts on turn one with the pack defaults', () => {
const state = startGame(testPack, { seed: 'start' });
assert.equal(state.turn, 1);
assert.equal(state.stage, 'floor');
assert.equal(state.money, 100);
assert.equal(state.skills.talking, 1);
assert.equal(state.relationships.chief.likes_you, 50);
assert.equal(state.relationships.pat.fears_you, 50);
assert.ok(state.meta.currentEventId, 'an event is waiting before the first turn');
});
test('relationship state is generated per character, so adding one adds its stats', () => {
const state = startGame(testPack, { seed: 'chars' });
assert.deepEqual(Object.keys(state.relationships).sort(), ['chief', 'pat']);
assert.deepEqual(Object.keys(state.relationships.chief).sort(), ['fears_you', 'likes_you']);
});
test('taking a turn applies effects and advances the clock', () => {
let state = startGame(testPack, { seed: 'turn' });
const before = state.money;
const result = takeTurn(testPack, state, currentTurn(testPack, state).options[0].option.id);
assert.equal(result.state.turn, 2);
assert.notEqual(result.state.meta.currentEventId, undefined);
assert.ok(result.state.money !== before || result.record.changes.length >= 0);
});
test('the same seed and the same choices replay identically', () => {
const a = playThrough('replay', 40);
const b = playThrough('replay', 40);
assert.deepEqual(a.state, b.state);
assert.deepEqual(a.records, b.records);
});
test('different seeds produce different runs', () => {
const a = playThrough('seed-a', 40);
const b = playThrough('seed-b', 40);
assert.notDeepEqual(a.records.map((r) => r.eventId), b.records.map((r) => r.eventId));
});
test('a saved game resumes into exactly the run it left', () => {
const uninterrupted = playThrough('save', 30);
let state = startGame(testPack, { seed: 'save' });
for (let i = 0; i < 15; i++) {
const turn = currentTurn(testPack, state);
state = takeTurn(testPack, state, turn.options.find((o) => o.available).option.id).state;
}
// The whole save is one JSON round trip; nothing lives outside it.
state = JSON.parse(JSON.stringify(state));
for (let i = 0; i < 15; i++) {
const turn = currentTurn(testPack, state);
state = takeTurn(testPack, state, turn.options.find((o) => o.available).option.id).state;
}
assert.deepEqual(state, uninterrupted.state);
});
test('a once-only event fires exactly once across a long run', () => {
const { records } = playThrough('once', 60);
assert.equal(records.filter((r) => r.eventId === 'first_day').length, 1);
assert.equal(records[0].eventId, 'first_day', 'its weight makes it the opener');
});
test('a locked option cannot be played', () => {
const state = startGame(testPack, { seed: 'locked' });
let probe = state;
// Walk to a turn that offers the gated option.
for (let i = 0; i < 10; i++) {
const turn = currentTurn(testPack, probe);
const charm = turn.options.find((o) => o.option.id === 'charm');
if (charm) {
assert.equal(charm.available, false);
assert.throws(() => takeTurn(testPack, probe, 'charm'), MoveError);
return;
}
probe = takeTurn(testPack, probe, turn.options.find((o) => o.available).option.id).state;
}
assert.fail('the gated option never came up');
});
test('an unknown option id is rejected', () => {
const state = startGame(testPack, { seed: 'bogus' });
assert.throws(() => takeTurn(testPack, state, 'no_such_option'), /has no option/);
});
test('a skill grows from choices and eventually unlocks its gate', () => {
const talkative = (options) => {
const practice = options.find((o) => o.option.id === 'practice' && o.available);
return (practice ?? options.find((o) => o.available)).option.id;
};
const { state } = playThrough('skill', 20, talkative);
assert.ok(state.skills.talking >= 3, `expected talking to grow, got ${state.skills.talking}`);
});
test('money going negative brings the hard-times branch into the pool', () => {
const spendy = (options) => {
const practice = options.find((o) => o.option.id === 'practice' && o.available);
return (practice ?? options.find((o) => o.available)).option.id;
};
let state = startGame(testPack, { seed: 'broke' });
let sawHardTimes = false;
for (let i = 0; i < 120; i++) {
const turn = currentTurn(testPack, state);
if (turn.event.id === 'hard_times') { sawHardTimes = true; break; }
state = takeTurn(testPack, state, spendy(turn.options, turn.event, state)).state;
}
assert.ok(state.money < 0 || sawHardTimes, 'the run should have gone broke');
assert.ok(sawHardTimes, 'hard times should surface once money is negative — the game never ends');
});
test('the turn record carries the diff and the options the player could not take', () => {
let state = startGame(testPack, { seed: 'record' });
state = takeTurn(testPack, state, currentTurn(testPack, state).options[0].option.id).state;
const turn = currentTurn(testPack, state);
const { record } = takeTurn(testPack, state, 'work');
assert.equal(record.eventId, turn.event.id);
assert.equal(record.optionId, 'work');
assert.deepEqual(record.changes.map((c) => c.path), ['money', 'relationships.chief.likes_you']);
assert.equal(record.changes[0].from + 10, record.changes[0].to);
const locked = record.offered.find((o) => o.id === 'charm');
assert.equal(locked.available, false);
assert.equal(locked.failed[0].path, 'skills.talking');
});
test('history accumulates every turn and is what the retrospective reads', () => {
const { state, records } = playThrough('history', 12);
assert.equal(state.history.length, 12);
assert.deepEqual(state.history, records);
});
test('a stat cannot be pushed past its declared bound', () => {
const { state } = playThrough('clamp', 80, (options) => {
const practice = options.find((o) => o.option.id === 'practice' && o.available);
return (practice ?? options.find((o) => o.available)).option.id;
});
assert.ok(state.skills.talking <= 10, 'talking is capped at 10');
assert.ok(state.relationships.pat.likes_you <= 100, 'relationship stats are capped at 100');
});
test('the run never ends on its own', () => {
const { state } = playThrough('endless', 200);
assert.equal(state.turn, 201);
assert.ok(currentTurn(testPack, state).options.some((o) => o.available), 'there is always something to do');
});
test('upkeep runs every turn and is recorded separately from the choice', () => {
const upkeepPack = structuredClone(testPack);
upkeepPack.upkeep = [
{ label: 'wage', effects: [{ path: 'money', op: 'add', value: 20 }] },
{
label: 'debt service',
requires: [{ path: 'flags.in_debt', op: '==', value: true }],
effects: [{ path: 'money', op: 'add', value: -5 }],
},
];
let state = startGame(upkeepPack, { seed: 'upkeep' });
const before = state.money;
const first = takeTurn(upkeepPack, state, currentTurn(upkeepPack, state).options[0].option.id);
assert.equal(first.record.upkeep.length, 1, 'the conditional entry is skipped while it does not apply');
assert.equal(first.record.upkeep[0].label, 'wage');
const fromChoice = first.record.changes
.filter((c) => c.path === 'money')
.reduce((sum, c) => sum + (c.to - c.from), 0);
assert.equal(first.state.money, before + fromChoice + 20);
// Once the flag is set, the conditional entry starts applying.
const nowIndebted = setPath(first.state, 'flags.in_debt', true);
const nextOption = currentTurn(upkeepPack, nowIndebted).options.find((o) => o.available).option.id;
const indebted = takeTurn(upkeepPack, nowIndebted, nextOption);
assert.deepEqual(indebted.record.upkeep.map((c) => c.label), ['wage', 'debt service']);
});
test('a character can start somewhere other than the pack default', () => {
const pack = structuredClone(testPack);
pack.characters.find((c) => c.id === 'pat').startingRelationship = { likes_you: 20 };
const state = startGame(pack, { seed: 'starts' });
assert.equal(state.relationships.pat.likes_you, 20, 'the override applies');
assert.equal(state.relationships.pat.fears_you, 50, 'unnamed stats keep the pack default');
assert.equal(state.relationships.chief.likes_you, 50, 'other characters are unaffected');
});
test('a pack with a calendar gets day-of-cycle state it can gate on', () => {
const dated = structuredClone(testPack);
dated.calendar = { cycleLength: 7, cycleName: 'Week', unitNames: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] };
let state = startGame(dated, { seed: 'calendar' });
assert.deepEqual(
{ index: state.calendar.index, cycle: state.calendar.cycle, name: state.calendar.name },
{ index: 0, cycle: 1, name: 'Mon' },
);
for (let i = 0; i < 7; i++) {
state = takeTurn(dated, state, currentTurn(dated, state).options.find((o) => o.available).option.id).state;
}
assert.equal(state.turn, 8);
assert.deepEqual(
{ index: state.calendar.index, cycle: state.calendar.cycle, name: state.calendar.name },
{ index: 0, cycle: 2, name: 'Mon' },
);
});
test('a pack with no calendar has none: the engine imposes no week', () => {
const state = startGame(testPack, { seed: 'no-calendar' });
assert.deepEqual(state.calendar, {});
});
test('scheduled upkeep falls due on its own period, not every turn', () => {
const scheduled = structuredClone(testPack);
scheduled.upkeep = [
{ label: 'daily', effects: [{ path: 'money', op: 'add', value: -1 }] },
{ label: 'weekly rent', every: 7, offset: 6, effects: [{ path: 'money', op: 'add', value: -50 }] },
{ label: 'fortnightly pay', every: 14, offset: 6, effects: [{ path: 'money', op: 'add', value: 200 }] },
];
let state = startGame(scheduled, { seed: 'schedule' });
const dueOn = [];
for (let turn = 1; turn <= 22; turn++) {
const result = takeTurn(scheduled, state, currentTurn(scheduled, state).options.find((o) => o.available).option.id);
dueOn.push(result.record.upkeep.map((c) => c.label));
state = result.state;
}
assert.deepEqual(dueOn[0], ['daily'], 'turn 1 is an ordinary day');
assert.deepEqual(dueOn[6], ['daily', 'weekly rent', 'fortnightly pay'], 'turn 7 is rent and payday');
assert.deepEqual(dueOn[13], ['daily', 'weekly rent'], 'turn 14 is rent only');
assert.deepEqual(dueOn[20], ['daily', 'weekly rent', 'fortnightly pay'], 'turn 21 is both again');
});
test('moving stage changes which events are drawn', () => {
const twoStage = structuredClone(testPack);
twoStage.events.push({
id: 'upstairs_only',
stage: 'attic',
weight: 1,
description: 'It is quieter up here.',
options: [{ id: 'wait', label: 'Wait.', effects: [] }],
});
twoStage.events.find((e) => e.id === 'routine').options.push({
id: 'climb',
label: 'Go up to the attic.',
effects: [{ path: 'stage', op: 'set', value: 'attic' }],
});
let state = startGame(twoStage, { seed: 'stages' });
// Walk to a turn offering the climb, then take it.
for (let i = 0; i < 20; i++) {
const turn = currentTurn(twoStage, state);
if (turn.event.id === 'routine') {
state = takeTurn(twoStage, state, 'climb').state;
break;
}
state = takeTurn(twoStage, state, turn.options.find((o) => o.available).option.id).state;
}
assert.equal(state.stage, 'attic');
assert.equal(currentTurn(twoStage, state).event.id, 'upstairs_only',
'only the new stage\'s events are reachable now');
});
+47
View File
@@ -0,0 +1,47 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { getPath, setPath, hasPath, parsePath, listPaths } from '../src/engine/paths.js';
const state = { money: 100, skills: { talking: 2 }, relationships: { chief: { likes_you: 50 } } };
test('reads nested paths', () => {
assert.equal(getPath(state, 'money'), 100);
assert.equal(getPath(state, 'skills.talking'), 2);
assert.equal(getPath(state, 'relationships.chief.likes_you'), 50);
});
test('missing paths read as undefined, not as a throw', () => {
assert.equal(getPath(state, 'skills.welding'), undefined);
assert.equal(getPath(state, 'nothing.at.all'), undefined);
assert.equal(hasPath(state, 'skills.welding'), false);
assert.equal(hasPath(state, 'skills.talking'), true);
});
test('writes without mutating the original', () => {
const next = setPath(state, 'skills.talking', 5);
assert.equal(next.skills.talking, 5);
assert.equal(state.skills.talking, 2, 'original state must be untouched');
assert.equal(next.relationships, state.relationships, 'untouched branches are shared');
});
test('creates missing intermediate objects', () => {
const next = setPath({}, 'flags.deep.nested', true);
assert.equal(next.flags.deep.nested, true);
});
test('rejects prototype-polluting paths', () => {
assert.throws(() => parsePath('__proto__.polluted'), /forbidden/);
assert.throws(() => parsePath('constructor'), /forbidden/);
assert.throws(() => setPath({}, 'a.prototype.b', 1), /forbidden/);
});
test('rejects malformed paths', () => {
assert.throws(() => parsePath(''), /non-empty/);
assert.throws(() => parsePath('money.'), /invalid path segment/);
assert.throws(() => parsePath('a b'), /invalid path segment/);
assert.throws(() => parsePath(42), /non-empty string/);
});
test('lists leaf paths', () => {
assert.deepEqual(listPaths(state), ['money', 'skills.talking', 'relationships.chief.likes_you']);
});
+59
View File
@@ -0,0 +1,59 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createRng } from '../src/engine/rng.js';
test('the same seed produces the same sequence', () => {
const a = createRng('seed-one');
const b = createRng('seed-one');
const draws = Array.from({ length: 20 }, () => [a.next(), b.next()]);
for (const [x, y] of draws) assert.equal(x, y);
});
test('different seeds diverge', () => {
const a = Array.from({ length: 10 }, (_, i) => createRng('one').next());
const b = Array.from({ length: 10 }, (_, i) => createRng('two').next());
assert.notDeepEqual(a, b);
});
test('a generator resumes exactly from a saved position', () => {
const original = createRng('resume-me');
for (let i = 0; i < 7; i++) original.next();
const position = original.getPosition();
const resumed = createRng('resume-me', position);
const expected = Array.from({ length: 5 }, () => original.next());
const actual = Array.from({ length: 5 }, () => resumed.next());
assert.deepEqual(actual, expected);
});
test('a saved position survives a JSON round trip', () => {
const rng = createRng('json');
rng.next();
const revived = createRng('json', JSON.parse(JSON.stringify(rng.getPosition())));
assert.equal(revived.next(), createRng('json', rng.getPosition()).next());
});
test('draws stay in range', () => {
const rng = createRng('range');
for (let i = 0; i < 500; i++) {
const value = rng.next();
assert.ok(value >= 0 && value < 1);
assert.ok(rng.int(10) >= 0 && rng.int(10) < 10);
}
});
test('weighted picks respect weight and skip zero-weight entries', () => {
const rng = createRng('weighted');
const items = [{ id: 'common', w: 99 }, { id: 'rare', w: 1 }, { id: 'never', w: 0 }];
const counts = { common: 0, rare: 0, never: 0 };
for (let i = 0; i < 2000; i++) counts[rng.weighted(items, (i2) => i2.w).id]++;
assert.equal(counts.never, 0, 'zero weight must never be drawn');
assert.ok(counts.common > counts.rare * 10, `expected common to dominate, got ${JSON.stringify(counts)}`);
assert.ok(counts.rare > 0, 'a weight of 1 in 100 should still appear over 2000 draws');
});
test('weighted returns undefined when nothing has weight', () => {
const rng = createRng('empty');
assert.equal(rng.weighted([{ w: 0 }], (i) => i.w), undefined);
assert.equal(rng.weighted([], () => 1), undefined);
});
+80
View File
@@ -0,0 +1,80 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { eligibleEvents, selectEvent, resolveOptions } from '../src/engine/select.js';
import { createState, resolveSchema } from '../src/engine/state.js';
import { createRng } from '../src/engine/rng.js';
import { setPath } from '../src/engine/paths.js';
import { testPack } from './fixtures/test-pack.js';
const schema = resolveSchema(testPack);
const fresh = () => createState(testPack, { seed: 'select', schema });
test('a conditional event is out of the pool until its condition holds', () => {
const rich = fresh();
assert.ok(!eligibleEvents(testPack, rich).eligible.some((e) => e.id === 'hard_times'));
const broke = setPath(rich, 'money', -20);
assert.ok(eligibleEvents(testPack, broke).eligible.some((e) => e.id === 'hard_times'));
});
test('exclusions say why', () => {
const { excluded } = eligibleEvents(testPack, fresh());
const hardTimes = excluded.find((x) => x.event.id === 'hard_times');
assert.equal(hardTimes.reason, 'requires');
assert.equal(hardTimes.failed[0].path, 'money');
});
test('a once-only event leaves the pool after it fires', () => {
const state = setPath(fresh(), 'seen.first_day', 1);
const { eligible, excluded } = eligibleEvents(testPack, state);
assert.ok(!eligible.some((e) => e.id === 'first_day'));
assert.equal(excluded.find((x) => x.event.id === 'first_day').reason, 'once');
});
test('a cooldown holds an event back for the declared number of turns', () => {
let state = fresh();
state = setPath(state, 'lastSeen.audit', 5);
state = setPath(state, 'turn', 9);
assert.equal(eligibleEvents(testPack, state).excluded.find((x) => x.event.id === 'audit')?.reason, 'cooldown');
state = setPath(state, 'turn', 15);
assert.ok(eligibleEvents(testPack, state).eligible.some((e) => e.id === 'audit'));
});
test('events belonging to another stage are never drawn', () => {
const elsewhere = setPath(fresh(), 'stage', 'penthouse');
const { eligible, excluded } = eligibleEvents(testPack, elsewhere);
assert.equal(eligible.length, 0);
assert.ok(excluded.every((x) => x.reason === 'stage'));
});
test('selection is a weighted draw, so the heavy event dominates', () => {
const rng = createRng('draw');
const state = setPath(fresh(), 'seen.first_day', 1); // take the opener out of the way
const counts = {};
for (let i = 0; i < 1000; i++) {
const event = selectEvent(testPack, state, rng);
counts[event.id] = (counts[event.id] ?? 0) + 1;
}
assert.ok(counts.routine > 900, `routine (weight 100) should dominate audit (weight 5): ${JSON.stringify(counts)}`);
assert.ok(counts.audit > 0, 'a low weight is still reachable');
});
test('an option below its skill gate resolves as locked, with the reason', () => {
const event = testPack.events.find((e) => e.id === 'routine');
const resolved = resolveOptions(fresh(), event);
const charm = resolved.find((r) => r.option.id === 'charm');
assert.equal(charm.available, false);
assert.equal(charm.failed[0].path, 'skills.talking');
assert.equal(charm.hidden, false, 'locked options are shown greyed out unless the content says otherwise');
assert.equal(resolved.find((r) => r.option.id === 'work').available, true);
});
test('an option unlocks once the gate is met', () => {
const skilled = setPath(fresh(), 'skills.talking', 5);
const event = testPack.events.find((e) => e.id === 'routine');
assert.equal(resolveOptions(skilled, event).find((r) => r.option.id === 'charm').available, true);
});
+529
View File
@@ -0,0 +1,529 @@
// UI wiring tests.
//
// These drive the real app module against a fake DOM (test/fixtures/fake-dom.js).
// They prove the wiring runs — imports resolve, handlers fire, the screens
// render the right text, choices reach the engine, saves and the feedback log
// are written. They are not a substitute for playing it in a browser: layout,
// styling, file downloads and file imports are outside their reach.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { installFakeDom, byClass, firstByClass, byTag } from './fixtures/fake-dom.js';
/**
* Boot the app fresh against a clean fake DOM.
*
* `pack` is written to the remembered-pack slot before booting, which is how a
* returning player lands back in a setting. Pass null to boot with nothing
* remembered and nothing saved, which is the first-time player who gets the
* setting picker instead of a turn.
*/
async function boot({ pack = 'corporateladder' } = {}) {
const dom = installFakeDom();
if (pack) dom.storage.setItem('theladder.pack.v1', pack);
// A fresh module instance per test: the app holds module-level state.
const { boot: bootApp } = await import(`../src/ui/app.js?t=${Math.random()}`);
bootApp(dom.root);
return dom;
}
const text = (root) => root.textContent;
const optionButtons = (root) => byClass(root, 'option');
const enabledOptions = (root) => optionButtons(root).filter((b) => !b.disabled);
const wrapUp = (root) => byTag(root, 'button').find((b) => /Wrap up/.test(b.textContent));
test('tabbing past the last option does not land on the button that ends the run', async () => {
// Document order is tab order. "Wrap up my career" sat immediately after the
// last option, so one Tab too many and Enter showed the retrospective — which
// a real player, on the keyboard, read as the game ending at day 31.
const dom = await boot();
try {
const buttons = byTag(dom.root, 'button');
const lastOption = buttons.map((b) => /option/.test(b.className ?? '')).lastIndexOf(true);
const next = buttons[lastOption + 1];
assert.ok(next, 'there is something after the options');
assert.doesNotMatch(next.textContent, /Wrap up/,
'the next thing Tab reaches should not be the one that leaves the turn');
assert.equal(wrapUp(dom.root), buttons[buttons.length - 1],
'it belongs last, behind everything harmless');
} finally {
dom.restore();
}
});
test('the app boots to a playable turn screen', async () => {
const dom = await boot();
try {
assert.ok(firstByClass(dom.root, 'screen--turn'), 'the turn screen rendered');
assert.match(text(dom.root), /Day 1/, 'the day counter is showing');
assert.match(text(dom.root), /Marlene Hobb/, 'the characters are listed');
assert.match(text(dom.root), /Trevor Pike/);
assert.ok(enabledOptions(dom.root).length > 0, 'there is something to click');
} finally {
dom.restore();
}
});
test('the header shows the pack\'s declared stats, and hides what it asks to hide', async () => {
const dom = await boot();
try {
const stats = text(firstByClass(dom.root, 'stats'));
assert.match(stats, /Money/);
assert.match(stats, /\$520/, 'money is formatted as money');
assert.match(stats, /Negotiation/);
assert.match(stats, /Standing/, 'reputation is renamed by the pack');
assert.doesNotMatch(stats, /Debt/, 'debt is hidden while it is zero');
assert.doesNotMatch(stats, /Notable/, 'hidden state stays out of the header');
} finally {
dom.restore();
}
});
test('choosing an option shows the outcome, the deltas and the upkeep', async () => {
const dom = await boot();
try {
enabledOptions(dom.root)[0].click();
assert.ok(firstByClass(dom.root, 'outcome'), 'the outcome view rendered');
assert.match(text(dom.root), /What that changed/);
assert.match(text(dom.root), /And then the day happened/, 'upkeep is shown separately');
const upkeep = text(firstByClass(dom.root, 'outcome__upkeep'));
// Labelled by what caused it, not by the path it moved — this is the
// answer to "what was that daily drain for?".
assert.match(upkeep, /Coffee, transit, lunch/);
assert.doesNotMatch(upkeep, /Money/, 'the bare path name is not useful here');
assert.ok(byClass(dom.root, 'change').length > 0, 'individual changes are listed');
} finally {
dom.restore();
}
});
test('advancing moves to the next turn', async () => {
const dom = await boot();
try {
enabledOptions(dom.root)[0].click();
firstByClass(dom.root, 'button--next').click();
assert.ok(firstByClass(dom.root, 'scenario'), 'back to choosing');
assert.match(text(dom.root), /Day 2/);
} finally {
dom.restore();
}
});
test('a locked option is shown, disabled, and says what it needs', async () => {
const dom = await boot();
try {
// Play until a locked option appears — the pack gates several.
let locked = null;
for (let i = 0; i < 40 && !locked; i++) {
locked = optionButtons(dom.root).find((b) => b.disabled);
if (locked) break;
enabledOptions(dom.root)[0].click();
firstByClass(dom.root, 'button--next').click();
}
assert.ok(locked, 'a gated option should turn up within 40 turns');
assert.match(locked.textContent, /Needs /, 'the requirement is spelled out');
locked.click(); // must be inert
assert.ok(firstByClass(dom.root, 'scenario'), 'clicking a locked option does nothing');
} finally {
dom.restore();
}
});
test('progress is written to local storage and restored on the next boot', async () => {
const dom = await boot();
try {
enabledOptions(dom.root)[0].click();
firstByClass(dom.root, 'button--next').click();
enabledOptions(dom.root)[0].click();
firstByClass(dom.root, 'button--next').click();
const saved = JSON.parse(dom.storage.getItem('theladder.save.v1.corporateladder'));
assert.equal(saved.state.turn, 3);
// Boot again against the same storage: the run should resume, not restart.
const { boot: bootAgain } = await import(`../src/ui/app.js?t=${Math.random()}`);
const secondRoot = dom.document.createElement('div');
bootAgain(secondRoot);
assert.match(text(secondRoot), /Day 3/, 'the saved run resumed');
} finally {
dom.restore();
}
});
test('every played turn is logged, separately from the save', async () => {
const dom = await boot();
try {
for (let i = 0; i < 3; i++) {
enabledOptions(dom.root)[0].click();
firstByClass(dom.root, 'button--next').click();
}
const log = JSON.parse(dom.storage.getItem('theladder.feedback.v1'));
assert.equal(log.length, 3);
const entry = log[0];
assert.ok(entry.event_id && entry.action_taken, 'the choice is recorded');
assert.ok(Array.isArray(entry.changes) && entry.changes.length > 0, 'the diff is recorded');
assert.ok(Array.isArray(entry.offered), 'the options offered are recorded');
assert.equal(entry.rating, null, 'feedback starts empty');
const save = JSON.parse(dom.storage.getItem('theladder.save.v1.corporateladder'));
assert.equal(save.state.history.length, 3);
assert.ok(!JSON.stringify(save).includes('rating'), 'feedback is not mixed into the save');
} finally {
dom.restore();
}
});
test('the feedback widget is optional, and records a rating when used', async () => {
const dom = await boot();
try {
enabledOptions(dom.root)[0].click();
const ratings = byClass(dom.root, 'rating');
assert.equal(ratings.length, 3, 'up, neutral and down');
ratings[0].click();
const log = JSON.parse(dom.storage.getItem('theladder.feedback.v1'));
assert.equal(log[0].rating, 'up');
// Clicking the same rating again clears it.
byClass(dom.root, 'rating')[0].click();
assert.equal(JSON.parse(dom.storage.getItem('theladder.feedback.v1'))[0].rating, null);
} finally {
dom.restore();
}
});
test('the comment fields write through without re-rendering under the cursor', async () => {
const dom = await boot();
try {
enabledOptions(dom.root)[0].click();
firstByClass(dom.root, 'feedback__toggle').click();
const fields = byTag(firstByClass(dom.root, 'feedback__detail'), 'textarea');
assert.equal(fields.length, 2, 'a comment box and a "what did you want to do" box');
fields[0].value = 'the wheel bit made me laugh';
fields[0].dispatch('input');
fields[1].value = 'I wanted to just hide in the stairwell';
fields[1].dispatch('input');
// The same element objects must still be live — a re-render would have
// replaced them and thrown the player out of the box they are typing in.
assert.equal(byTag(firstByClass(dom.root, 'feedback__detail'), 'textarea')[0], fields[0]);
// Written through on the way to the next turn.
firstByClass(dom.root, 'button--next').click();
const entry = JSON.parse(dom.storage.getItem('theladder.feedback.v1'))[0];
assert.equal(entry.comment, 'the wheel bit made me laugh');
assert.equal(entry.desired_but_unavailable_action, 'I wanted to just hide in the stairwell');
} finally {
dom.restore();
}
});
test('the retrospective summarises the run and is not an ending', async () => {
const dom = await boot();
try {
for (let i = 0; i < 6; i++) {
enabledOptions(dom.root)[0].click();
firstByClass(dom.root, 'button--next').click();
}
wrapUp(dom.root).click();
assert.ok(firstByClass(dom.root, 'screen--retrospective'));
assert.match(text(dom.root), /not an ending/, 'it says so, rather than implying it');
const body = text(dom.root);
assert.match(body, /Career retrospective/);
assert.match(body, /6 days in the mailroom/);
assert.match(body, /Where you stand with people/);
assert.match(body, /Marlene Hobb/);
assert.match(body, /Situations you saw/);
firstByClass(dom.root, 'button--primary').click(); // "Keep going"
assert.ok(firstByClass(dom.root, 'screen--turn'), 'the player can carry on');
assert.match(text(dom.root), /Day 7/);
} finally {
dom.restore();
}
});
test('a run with nothing notable still gets a retrospective', async () => {
const dom = await boot();
try {
wrapUp(dom.root).click(); // wrap up on turn one
assert.ok(firstByClass(dom.root, 'screen--retrospective'));
assert.match(text(dom.root), /Nothing that anybody would write down/);
} finally {
dom.restore();
}
});
test('the header names the day and counts the week', async () => {
const dom = await boot();
try {
const hud = text(firstByClass(dom.root, 'hud'));
assert.match(hud, /Monday/, 'the day is named');
assert.match(hud, /Week 1 · Day 1/, 'the week and the day number are both shown');
} finally {
dom.restore();
}
});
test('the diary says what is coming, so the drain is not a mystery', async () => {
const dom = await boot();
try {
const diary = text(firstByClass(dom.root, 'diary'));
assert.match(diary, /Wages in 4 days/, 'payday is announced before it lands');
assert.match(diary, /Rent in 7 days/);
assert.doesNotMatch(diary, /Loan service/, 'charges that do not apply are not listed');
} finally {
dom.restore();
}
});
test('wages and rent appear in the ledger on the days they fall due', async () => {
const dom = await boot();
try {
const ledgerOn = [];
for (let day = 1; day <= 8; day++) {
enabledOptions(dom.root)[0].click();
const upkeep = firstByClass(dom.root, 'outcome__upkeep');
ledgerOn.push(upkeep ? upkeep.textContent : '');
firstByClass(dom.root, 'button--next').click();
}
assert.match(ledgerOn[0], /Coffee, transit, lunch/, 'small change every day');
assert.doesNotMatch(ledgerOn[0], /Wages/, 'no wages on day 1');
assert.match(ledgerOn[4], /Wages/, 'paid on day 5, the first Friday');
assert.match(ledgerOn[7], /Rent/, 'rent on day 8, the second Monday');
} finally {
dom.restore();
}
});
test('the status bar says what you are playing, which pack, and who you are', async () => {
const dom = await boot();
try {
const status = text(firstByClass(dom.root, 'statusbar'));
assert.match(status, /The Ladder/, 'the game');
assert.match(status, /Corporate Ladder/, 'the pack');
assert.match(status, /Mail Associate I/, 'the current title');
assert.match(status, /\d+\.\d+\.\d+ · /, 'the version and build');
} finally {
dom.restore();
}
});
test('the status bar title follows the player up a tier', async () => {
const { statusBar } = await import('../src/ui/components.js');
const { corporateLadder } = await import('../content/corporateladder/index.js');
const dom = installFakeDom();
try {
const promoted = statusBar(corporateLadder, { stage: 'dispatch' });
assert.match(promoted.textContent, /Dispatch Coordinator/);
} finally {
dom.restore();
}
});
test('the keyboard alone can play a turn: focus lands where Enter should go', async () => {
const dom = await boot();
try {
// Choosing: the first option a player can actually take.
const firstPlayable = enabledOptions(dom.root)[0];
assert.equal(dom.document.activeElement, firstPlayable,
'the first available option is focused, so Enter takes it');
dom.document.activeElement.click();
// Outcome: the button that moves the run on.
const next = firstByClass(dom.root, 'button--next');
assert.equal(dom.document.activeElement, next,
'the next-turn button is focused, so Enter advances');
dom.document.activeElement.click();
assert.ok(firstByClass(dom.root, 'scenario'), 'and we are choosing again');
assert.equal(dom.document.activeElement, enabledOptions(dom.root)[0]);
} finally {
dom.restore();
}
});
test('locked options are not in the tab order', async () => {
const dom = await boot();
try {
// Disabled buttons are skipped by the browser's own tab handling; the
// guarantee this test protects is that locked options stay disabled.
for (let i = 0; i < 40; i++) {
const locked = optionButtons(dom.root).filter((b) => b.disabled);
if (locked.length) {
assert.ok(locked.every((b) => b.disabled === true));
return;
}
enabledOptions(dom.root)[0].click();
firstByClass(dom.root, 'button--next').click();
}
assert.fail('no locked option appeared in 40 turns');
} finally {
dom.restore();
}
});
test('the ledger reads as English, and leaves bookkeeping out of it', async () => {
const { changeList } = await import('../src/ui/components.js');
const { corporateLadder: pack } = await import('../content/corporateladder/index.js');
const dom = installFakeDom();
try {
const rendered = changeList(pack, [
{ path: 'money', op: 'add', value: -20, from: 520, to: 500, clamped: false },
{ path: 'flags.holds_the_envelope', op: 'set', value: true, from: false, to: true, clamped: false },
{ path: 'notable', op: 'push', value: 'Kept an envelope.', from: [], to: ['Kept an envelope.'], clamped: false },
]);
const body = rendered.textContent;
assert.match(body, /Money/);
assert.match(body, /−\$20/);
assert.match(body, /The envelope is in your locker\./, 'a flag reads as a sentence');
assert.doesNotMatch(body, /now true/, 'not as a boolean');
assert.doesNotMatch(body, /Notable/, 'the retrospective\'s own list stays out of the ledger');
assert.doesNotMatch(body, /Kept an envelope\./);
} finally {
dom.restore();
}
});
test('a first-time player is asked which setting to play', async () => {
// Nothing saved and nothing remembered: the front door is the picker rather
// than whichever pack happens to be first in the registry.
const dom = await boot({ pack: null });
try {
assert.ok(firstByClass(dom.root, 'screen--packs'), 'the picker rendered');
assert.match(text(dom.root), /Corporate Ladder/);
assert.match(text(dom.root), /The Frontier/);
assert.match(text(dom.root), /Not started/, 'a pack with no run says so');
const start = byClass(dom.root, 'button').find((b) => /Start here/.test(b.textContent));
assert.ok(start, 'the picker offers a way in');
start.click();
assert.ok(firstByClass(dom.root, 'screen--turn'), 'choosing a setting starts it');
} finally {
dom.restore();
}
});
test('switching settings keeps both runs', async () => {
// The defect this guards against: one global save slot, so the first switch
// silently threw the other career away.
const dom = await boot({ pack: 'corporateladder' });
try {
enabledOptions(dom.root)[0].click();
byClass(dom.root, 'button').find((b) => /Next/.test(b.textContent))?.click();
assert.match(text(dom.root), /Day 2/, 'a turn was played in the first setting');
byClass(dom.root, 'button').find((b) => /Change setting/.test(b.textContent)).click();
const toFrontier = byClass(dom.root, 'button')
.find((b) => /Start here|Go back to this run/.test(b.textContent));
toFrontier.click();
assert.match(text(dom.root), /Hollis Vane/, 'the second setting is running');
assert.match(text(dom.root), /Day 1/, 'and it started fresh');
// Both slots are on disk, independently.
const corporate = JSON.parse(dom.storage.getItem('theladder.save.v1.corporateladder'));
const frontier = JSON.parse(dom.storage.getItem('theladder.save.v1.frontier'));
assert.equal(corporate.state.turn, 2, 'the first run is still where it was');
assert.equal(frontier.state.turn, 1);
assert.equal(corporate.state.meta.packId, 'corporateladder');
assert.equal(frontier.state.meta.packId, 'frontier');
// And going back resumes rather than restarting.
byClass(dom.root, 'button').find((b) => /Change setting/.test(b.textContent)).click();
assert.match(text(dom.root), /Day 2, Mail Associate I, saved/,
'the picker reports where the other run is');
byClass(dom.root, 'button').find((b) => /Go back to this run/.test(b.textContent)).click();
assert.match(text(dom.root), /Day 2/, 'the first career resumed where it was left');
assert.match(text(dom.root), /Marlene Hobb/);
} finally {
dom.restore();
}
});
test('a run saved before settings were selectable is not lost', async () => {
// The old single slot, migrated on read. Someone mid-career when this
// shipped should find their career, not a new one.
const dom = installFakeDom();
try {
const { startGame } = await import('../src/engine/game.js');
const { corporateLadder } = await import('../content/corporateladder/index.js');
const legacy = startGame(corporateLadder, { seed: 'legacy' });
legacy.turn = 61;
dom.storage.setItem('theladder.save.v1',
JSON.stringify({ format: 1, savedAt: null, state: legacy }));
const { boot: bootApp } = await import(`../src/ui/app.js?t=${Math.random()}`);
bootApp(dom.root);
assert.ok(firstByClass(dom.root, 'screen--turn'), 'it booted into the run, not the picker');
assert.match(text(dom.root), /Day 61/, 'the old career came back');
} finally {
dom.restore();
}
});
test('a save written before a stat existed gains it, rather than losing the content', async () => {
// The failure this guards against is silent, which is what makes it worth a
// test: conditions.js compares numbers only, so with no `lifestyle` path both
// `lifestyle < 2` and `lifestyle >= 1` are false. Every event, option and
// upkeep entry gated on a stat added since the save was written simply does
// not exist for that career, and nothing anywhere says so.
const dom = installFakeDom();
try {
const { startGame } = await import('../src/engine/game.js');
const { corporateLadder } = await import('../content/corporateladder/index.js');
const before = startGame(corporateLadder, { seed: 'pre-lifestyle' });
before.turn = 40;
delete before.lifestyle;
dom.storage.setItem('theladder.save.v1.corporateladder',
JSON.stringify({ format: 1, savedAt: null, state: before }));
dom.storage.setItem('theladder.pack.v1', 'corporateladder');
const { boot: bootApp } = await import(`../src/ui/app.js?t=${Math.random()}`);
bootApp(dom.root);
assert.match(text(dom.root), /Day 40/, 'the career resumed where it was');
const saved = JSON.parse(dom.storage.getItem('theladder.save.v1.corporateladder'));
assert.equal(saved.state.lifestyle, 0,
'the stat the run predates was filled in, and the repair written back');
} finally {
dom.restore();
}
});
test('the header reads the second pack\'s display map, not the first\'s', async () => {
// `display` is a per-pack contract with the interface, and a typo in it is
// only visible at runtime. Corporate Ladder's version is covered above; this
// is the same assertion against a pack with entirely different stat names.
const dom = await boot({ pack: 'frontier' });
try {
const stats = text(firstByClass(dom.root, 'stats'));
assert.match(stats, /Money/);
assert.match(stats, /\$40/, 'money is formatted as money');
assert.match(stats, /Nerve/);
assert.match(stats, /Trade/);
assert.match(stats, /Standing/);
assert.doesNotMatch(stats, /Negotiation|Logistics/, 'no leakage from the other pack');
assert.doesNotMatch(stats, /Owed at the store/, 'the ledger is hidden while it is zero');
assert.match(text(dom.root), /Cold Fork/, 'the people panel is named by the pack');
assert.match(text(dom.root), /Hollis Vane/);
assert.match(text(dom.root), /Marshal Ward/);
assert.match(text(dom.root), /Hired Hand/, 'the stage title comes from the pack');
assert.match(text(dom.root), /Stage day/, 'and so does the name of the day');
} finally {
dom.restore();
}
});
+187
View File
@@ -0,0 +1,187 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { validatePack, assertValidPack } from '../src/engine/validate.js';
import { testPack } from './fixtures/test-pack.js';
/** A copy of the fixture with one thing broken. */
function broken(mutate) {
const pack = structuredClone(testPack);
mutate(pack);
return validatePack(pack);
}
const messages = (result) => result.errors.join('\n');
test('the fixture pack is valid and warning-free', () => {
const result = validatePack(testPack);
assert.deepEqual(result.errors, []);
assert.deepEqual(result.warnings, []);
});
test('an effect writing an undeclared path is caught', () => {
const result = broken((pack) => {
pack.events[0].options[0].effects.push({ path: 'skills.wleding', op: 'add', value: 1 });
});
assert.match(messages(result), /skills\.wleding.*not declared/s);
});
test('a relationship effect naming a character who does not exist is caught', () => {
const result = broken((pack) => {
pack.events[0].options[0].effects.push({ path: 'relationships.dave.likes_you', op: 'add', value: 1 });
});
assert.match(messages(result), /relationships\.dave\.likes_you.*not declared/s);
});
test('a condition reading an undeclared path is caught', () => {
const result = broken((pack) => {
pack.events[0].options[1].requires = [{ path: 'skills.charisma', op: '>=', value: 1 }];
});
assert.match(messages(result), /skills\.charisma.*not declared/s);
});
test('content may read engine bookkeeping but not write it', () => {
const readsTurn = broken((pack) => {
// Not the floor event — see the fallback rule below.
pack.events.find((e) => e.id === 'audit').requires = [{ path: 'turn', op: '>=', value: 3 }];
});
assert.deepEqual(readsTurn.errors, []);
const writesTurn = broken((pack) => {
pack.events[0].options[0].effects.push({ path: 'turn', op: 'add', value: 5 });
});
assert.match(messages(writesTurn), /engine-owned state/);
});
test('declaring a reserved root as pack state is caught', () => {
const result = broken((pack) => { pack.state['history'] = { initial: [] }; });
assert.match(messages(result), /reserved root/);
});
test('unknown operators are caught before they reach the engine', () => {
const badCondition = broken((pack) => {
pack.events[0].options[0].requires = [{ path: 'money', op: 'exceeds', value: 1 }];
});
assert.match(messages(badCondition), /unknown condition operator/);
const badEffect = broken((pack) => {
pack.events[0].options[0].effects[0].op = 'multiply';
});
assert.match(messages(badEffect), /unknown effect operation/);
});
test('add with a non-numeric value is caught', () => {
const result = broken((pack) => { pack.events[0].options[0].effects[0].value = 'ten'; });
assert.match(messages(result), /non-numeric/);
});
test('duplicate ids are caught', () => {
assert.match(messages(broken((pack) => { pack.events[1].id = 'routine'; })), /duplicate id/);
assert.match(messages(broken((pack) => { pack.characters[1].id = 'chief'; })), /duplicate id/);
assert.match(messages(broken((pack) => { pack.events[0].options[1].id = 'work'; })), /duplicate id/);
});
test('an id that cannot be a path segment is caught, because ids become state paths', () => {
const result = broken((pack) => { pack.events[0].id = 'routine day'; });
assert.match(messages(result), /must match/);
});
test('an event with no options is caught', () => {
const result = broken((pack) => { pack.events[0].options = []; });
assert.match(messages(result), /no options/);
});
test('a zero weight is caught, since the event could never fire', () => {
const result = broken((pack) => { pack.events[0].weight = 0; });
assert.match(messages(result), /greater than zero/);
});
test('a stage with no unconditional event is caught, since the pool can run dry', () => {
const result = broken((pack) => {
pack.events[0].requires = [{ path: 'money', op: '>', value: 0 }];
});
assert.match(messages(result), /no unconditional event/);
});
test('a starting stage with no events at all is caught', () => {
const result = broken((pack) => { pack.startingStage = 'penthouse'; });
assert.match(messages(result), /startingStage "penthouse" has no events/);
});
test('a character with no role_type warns but still plays', () => {
const result = broken((pack) => { delete pack.characters[0].role_type; });
assert.deepEqual(result.errors, []);
assert.match(result.warnings.join('\n'), /role_type/);
});
test('assertValidPack throws with every error listed', () => {
const pack = structuredClone(testPack);
pack.events[0].options[0].effects.push({ path: 'nope', op: 'add', value: 1 });
assert.throws(() => assertValidPack(pack), /is invalid/);
assert.doesNotThrow(() => assertValidPack(testPack));
});
test('upkeep is validated like any other effect list', () => {
const badPath = broken((pack) => {
pack.upkeep = [{ label: 'wage', effects: [{ path: 'monie', op: 'add', value: 20 }] }];
});
assert.match(messages(badPath), /monie.*not declared/s);
const empty = broken((pack) => { pack.upkeep = [{ label: 'wage', effects: [] }]; });
assert.match(messages(empty), /has no effects/);
const writesReserved = broken((pack) => {
pack.upkeep = [{ effects: [{ path: 'turn', op: 'add', value: 1 }] }];
});
assert.match(messages(writesReserved), /engine-owned state/);
});
test('a starting relationship for a stat that does not exist is caught', () => {
const result = broken((pack) => {
pack.characters[0].startingRelationship = { respects_you: 10 };
});
assert.match(messages(result), /unknown stat "respects_you"/);
});
test('a malformed calendar is caught', () => {
const badLength = broken((pack) => { pack.calendar = { cycleLength: 0 }; });
assert.match(messages(badLength), /cycleLength 0 must be a positive/);
const mismatched = broken((pack) => {
pack.calendar = { cycleLength: 7, unitNames: ['Mon', 'Tue'] };
});
assert.match(messages(mismatched), /unitNames has 2 entries but cycleLength is 7/);
});
test('a malformed upkeep schedule is caught', () => {
const badEvery = broken((pack) => {
pack.upkeep = [{ label: 'rent', every: 0, effects: [{ path: 'money', op: 'add', value: -1 }] }];
});
assert.match(messages(badEvery), /every 0 must be a positive/);
const orphanOffset = broken((pack) => {
pack.upkeep = [{ label: 'rent', offset: 3, effects: [{ path: 'money', op: 'add', value: -1 }] }];
});
assert.deepEqual(orphanOffset.errors, []);
assert.match(orphanOffset.warnings.join('\n'), /offset has no effect without every/);
});
test('content may move the player between stages, but only to a real one', () => {
const good = broken((pack) => {
pack.events.push({
id: 'upstairs', stage: 'attic', weight: 1, description: 'Up you go.',
options: [{ id: 'go', label: 'Go up.', effects: [] }],
});
pack.events[0].options[0].effects.push({ path: 'stage', op: 'set', value: 'attic' });
});
assert.deepEqual(good.errors, []);
const nowhere = broken((pack) => {
pack.events[0].options[0].effects.push({ path: 'stage', op: 'set', value: 'penthouse' });
});
assert.match(messages(nowhere), /stage "penthouse", which has no events/);
const wrongOp = broken((pack) => {
pack.events[0].options[0].effects.push({ path: 'stage', op: 'add', value: 1 });
});
assert.match(messages(wrongOp), /may only be written with set/);
});
+250
View File
@@ -0,0 +1,250 @@
// 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 log in logs/, when none was named. */
async function newestLog() {
let names;
try {
names = (await readdir(logsDir)).filter((name) => 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 ?? []) {
if (option.available) continue;
const reasons = (option.failed ?? [])
.map((c) => `${c.path} ${c.op} ${JSON.stringify(c.value)}`)
.join(', ') || 'unstated';
const key = `${entry.event_id}.${option.id} — needs ${reasons}`;
blocked.set(key, (blocked.get(key) ?? 0) + 1);
}
}
section('Doors that stayed shut');
if (blocked.size === 0) {
console.log('Nothing was ever locked.');
return;
}
const ranked = [...blocked.entries()].sort((a, b) => b[1] - a[1]);
console.log(`${ranked.reduce((s, [, n]) => s + n, 0)} locked options shown, ` +
`${blocked.size} distinct. Most often:`);
for (const [key, count] of ranked.slice(0, 10)) {
console.log(` ${String(count).padStart(3)}× ${key}`);
}
}
function reportFeedback(entries) {
section('What the player said');
const rated = entries.filter((e) => e.rating);
const tally = { up: 0, neutral: 0, down: 0 };
for (const entry of rated) tally[entry.rating] = (tally[entry.rating] ?? 0) + 1;
console.log(rated.length
? `${rated.length} of ${entries.length} turns rated — ` +
`👍 ${tally.up} 😐 ${tally.neutral} 👎 ${tally.down}`
: 'No turns were rated.');
const rateFor = (id) => {
const seen = entries.filter((e) => e.event_id === id && e.rating);
if (!seen.length) return null;
const score = seen.reduce((s, e) => s + (e.rating === 'up' ? 1 : e.rating === 'down' ? -1 : 0), 0);
return { id, score, n: seen.length };
};
const scored = [...new Set(entries.map((e) => e.event_id))]
.map(rateFor).filter(Boolean).sort((a, b) => b.score - a.score);
if (scored.length > 1) {
console.log(`\n best received: ${scored[0].id} (${scored[0].score >= 0 ? '+' : ''}${scored[0].score} over ${scored[0].n})`);
const worst = scored.at(-1);
console.log(` worst received: ${worst.id} (${worst.score >= 0 ? '+' : ''}${worst.score} over ${worst.n})`);
}
const comments = entries.filter((e) => e.comment);
if (comments.length) {
console.log('\n Comments:');
for (const entry of comments) {
console.log(` turn ${entry.turn} (${entry.event_id}): ${entry.comment}`);
}
}
const wanted = entries.filter((e) => e.desired_but_unavailable_action);
if (wanted.length) {
console.log('\n Wanted to do, but could not:');
for (const entry of wanted) {
console.log(` turn ${entry.turn} (${entry.event_id}): ${entry.desired_but_unavailable_action}`);
}
}
}
async function main() {
const path = process.argv[2] ?? await newestLog();
if (!path) {
console.error('usage: npm run analyze -- <exported-feedback-log.json>');
console.error(' or: drop the export into logs/ and run npm run analyze');
process.exitCode = 1;
return;
}
if (!process.argv[2]) console.log(`reading ${path.replace(`${root}/`, '')}\n`);
const log = JSON.parse(await readFile(path, 'utf8'));
const entries = log.entries ?? [];
if (!entries.length) {
console.log('The log has no entries.');
return;
}
// One log can hold turns from more than one setting, and pooling them would
// put a mailroom and a freight yard in the same repetition table. The same
// rule as the run boundary in DECISIONS #30, a level up: every statistic
// here is per pack, and a log is not one sequence.
const byPack = new Map();
for (const entry of entries) {
const id = entry.pack_id ?? 'unknown';
if (!byPack.has(id)) byPack.set(id, []);
byPack.get(id).push(entry);
}
if (byPack.size > 1) {
console.log(`${entries.length} turns logged across ${byPack.size} settings.`);
}
for (const [packId, packEntries] of byPack) {
const runs = byRun(packEntries);
console.log(
`${packEntries.length} turns logged across ${runs.size} run${runs.size === 1 ? '' : 's'} ` +
`(pack ${packId} ${packEntries[0].pack_version ?? ''})`.trimEnd(),
);
for (const [seed, run] of runs) {
const stages = [...new Set(run.map((e) => e.stage))];
console.log(` seed ${seed}: ${run.length} turns, ${stages.join(' → ')}`);
}
reportRepetition(runs);
reportChoices(packEntries);
reportGates(packEntries);
reportFeedback(packEntries);
console.log('');
}
}
main().catch((error) => {
console.error(`could not analyse that log: ${error.message}`);
process.exitCode = 1;
});
+175
View File
@@ -0,0 +1,175 @@
// Build a single self-contained HTML file.
//
// Chrome and Firefox refuse ES module imports over file://, so the modular
// source that is pleasant to develop cannot be the thing you hand someone.
// This inlines the stylesheet and flattens the module graph into one script,
// producing a file that plays by double-clicking it.
//
// It is a deliberately small bundler that understands only the module syntax
// this project actually uses, and throws on anything it does not recognise —
// a silent mis-bundle would be far worse than a failed build.
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { execFileSync } from 'node:child_process';
import { dirname, resolve, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const entryHtml = resolve(root, 'index.html');
const outDir = resolve(root, 'dist');
const outFile = resolve(outDir, 'theladder.html');
const id = (path) => relative(root, path).replaceAll('\\', '/');
/** Rewrite one module's source into a registry factory. */
function transform(source, path) {
const exports = [];
let body = source;
// import { a, b as c } from './x.js'
body = body.replace(
/^[ \t]*import\s*\{([^}]*)\}\s*from\s*['"]([^'"]+)['"];?[ \t]*$/gm,
(_m, names, from) => `const {${names.replaceAll(' as ', ': ')}} = __require(${JSON.stringify(id(resolve(dirname(path), from)))});`,
);
// import * as ns from './x.js'
body = body.replace(
/^[ \t]*import\s*\*\s*as\s+(\w+)\s+from\s*['"]([^'"]+)['"];?[ \t]*$/gm,
(_m, name, from) => `const ${name} = __require(${JSON.stringify(id(resolve(dirname(path), from)))});`,
);
// import name from './x.js'
body = body.replace(
/^[ \t]*import\s+(\w+)\s+from\s*['"]([^'"]+)['"];?[ \t]*$/gm,
(_m, name, from) => `const ${name} = __require(${JSON.stringify(id(resolve(dirname(path), from)))}).default;`,
);
// export { a, b } (no `from` — re-exports are not used in this project)
body = body.replace(/^[ \t]*export\s*\{([^}]*)\};?[ \t]*$/gm, (_m, names) => {
for (const entry of names.split(',').map((n) => n.trim()).filter(Boolean)) {
const [local, exported = local] = entry.split(/\s+as\s+/).map((n) => n.trim());
exports.push([exported, local]);
}
return '';
});
// export function / const / let / class
body = body.replace(/^[ \t]*export\s+(async\s+)?(function|const|let|class)\s+(\w+)/gm, (_m, async_, kind, name) => {
exports.push([name, name]);
return `${async_ ?? ''}${kind} ${name}`;
});
// export default <expression>
body = body.replace(/^[ \t]*export\s+default\s+/gm, () => {
exports.push(['default', '__default']);
return 'const __default = ';
});
const leftover = body.match(/^\s*(import|export)\s/m);
if (leftover) {
throw new Error(`${id(path)}: unsupported module syntax near "${leftover[0].trim()}"`);
}
const assignments = exports.map(([exported, local]) =>
` __exports[${JSON.stringify(exported)}] = ${local};`).join('\n');
return `__define(${JSON.stringify(id(path))}, (__exports, __require) => {\n${body}\n${assignments}\n});`;
}
async function collect(entryPath, seen = new Map()) {
if (seen.has(entryPath)) return seen;
const source = await readFile(entryPath, 'utf8');
seen.set(entryPath, null); // reserve the slot before recursing
// `[\s\S]*?` rather than `.*?`: a destructured import may be wrapped across
// several lines, and `transform` below already rewrites those — a scan that
// missed them produced a bundle with a __require for a module that was never
// collected, which only failed when the bundled game was run.
const specifiers = [...source.matchAll(/^[ \t]*import\s[\s\S]*?from\s*['"]([^'"]+)['"];?[ \t]*$/gm)]
.map((match) => match[1])
.filter((specifier) => specifier.startsWith('.'));
for (const specifier of specifiers) {
await collect(resolve(dirname(entryPath), specifier), seen);
}
seen.set(entryPath, transform(source, entryPath));
return seen;
}
/**
* The commit this build came from, marked `+` when the tree had uncommitted
* changes. Substituted into the output only — src/build-info.js stays as it is
* in the repository, so building never dirties the working tree.
*/
function buildIdentity() {
try {
const sha = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: root, stdio: 'pipe' })
.toString().trim();
const dirty = execFileSync('git', ['status', '--porcelain'], { cwd: root, stdio: 'pipe' })
.toString().trim().length > 0;
return dirty ? `${sha}+` : sha;
} catch {
return 'unknown';
}
}
async function build() {
const html = await readFile(entryHtml, 'utf8');
const styleMatch = html.match(/<link\s+rel="stylesheet"\s+href="([^"]+)"\s*\/?>/);
if (!styleMatch) throw new Error('index.html has no stylesheet link to inline');
const css = await readFile(resolve(root, styleMatch[1]), 'utf8');
const scriptMatch = html.match(/<script type="module">([\s\S]*?)<\/script>/);
if (!scriptMatch) throw new Error('index.html has no module script');
const bootstrapSource = scriptMatch[1];
const entrySpecifier = bootstrapSource.match(/from\s*['"]([^'"]+)['"]/)?.[1];
if (!entrySpecifier) throw new Error('could not find the entry module in index.html');
const entryPath = resolve(root, entrySpecifier);
const modules = await collect(entryPath);
const bootstrap = transform(bootstrapSource, entryHtml).replace(
/__define\("index\.html"/, '__define("__bootstrap__"',
);
const script = [
'(() => {',
' "use strict";',
' const __factories = new Map();',
' const __cache = new Map();',
' const __define = (id, factory) => __factories.set(id, factory);',
' const __require = (id) => {',
' if (__cache.has(id)) return __cache.get(id);',
' const factory = __factories.get(id);',
' if (!factory) throw new Error(`module not bundled: ${id}`);',
' const exports = {};',
' __cache.set(id, exports);',
' factory(exports, __require);',
' return exports;',
' };',
...[...modules.values()].map((code) => code.replace(/^/gm, ' ')),
bootstrap.replace(/^/gm, ' '),
' __require("__bootstrap__");',
'})();',
].join('\n');
const commit = buildIdentity();
const stamped = script
.replace("commit: 'dev'", `commit: '${commit}'`)
.replace('builtAt: null', `builtAt: '${new Date().toISOString().slice(0, 10)}'`);
if (stamped === script) throw new Error('could not stamp the build identity into the bundle');
const output = html
.replace(styleMatch[0], `<style>\n${css}\n </style>`)
.replace(scriptMatch[0], `<script>\n${stamped}\n </script>`);
await mkdir(outDir, { recursive: true });
await writeFile(outFile, output, 'utf8');
const kb = (Buffer.byteLength(output) / 1024).toFixed(1);
console.log(`built ${id(outFile)} — ${modules.size} modules, ${kb} kB, commit ${commit}`);
}
build().catch((error) => {
console.error(`build failed: ${error.message}`);
process.exitCode = 1;
});
+14
View File
@@ -0,0 +1,14 @@
# story-to-pack lives on its own branch and is never imported by the game.
# The repo root ignores *.log. Here the run logs are the record of results, so keep them.
!*.log
# Inference-host telemetry: large, and it names the host.
probe/gpu-logs/
# Rebuildable caches — gate2.py, gate3.py and signals.py recreate them on first use.
probe/sim.pkl
probe/null.pkl
probe/null-samples.pkl
probe/coassoc.json
__pycache__/
+275
View File
@@ -0,0 +1,275 @@
# story-to-pack — design note
A tool that builds a playable The Ladder content pack from a corpus of stories.
**Status (2026-09-14): direction agreed, nothing built.** The decisions below were made in
conversation on 2026-09-10 and had until now existed only in that session's transcript. The
architecture rests on one assumption that has so far **failed** its first test (see
[Assumptions](#assumptions-under-test)); nothing past the probes should be built until it passes.
This is a design note, not a specification. It records what was decided and why, so that the next
step is argued from it rather than re-derived.
---
## What it is for
Feed it a body of fiction set in one world — say 20–30 short stories — plus a handful of structural
numbers, and get back a content pack the game can load and that its own conformance checks judge
viable. Then play it, hand-edit it, and retune it from play logs.
The Ladder's founding commitment (Planning/01 §9) is that a new setting is purely data. This tool
tests how far that goes: whether "data" can come from a corpus rather than a designer.
## Decisions
| Area | Decision |
| --- | --- |
| **Inputs** | A thematically coherent corpus (one world), 20–30 stories of 2–15 pages. Plus: number of stages, turns per stage, number of promotions, number of characters, and a one-line player role written by the designer. Early development uses much less text. |
| **Corpus** | Saved permanently beside the pack. Read once, at fixed depth. **Provenance recorded:** which chunks fed which event, even though nothing reads it yet. |
| **Artifact** | **One.** The generated pack is the source of truth from the moment it is written. The designer and the tool both edit that same file in place. Seed text is a seed, not a source: there is no regenerate-and-merge. |
| **Pipeline** | Chunk → embed → cluster → select recurring situations → extract prose → assign state paths and directions → search numbers → verify → repair. |
| **The model's job** | Selection and classification. **It never chooses a number.** Its generative tasks are entity substitution and, only where needed, filling out options. |
| **Inference** | Once, up front, checkpointed and cached. The numeric search and every repair iteration are pure computation and never call a model. This is what makes a small local model viable. |
| **Stat vocabulary** | A fixed mechanical spine (currency, debt, standing, two skills, flags, notable, per-character relationship stats). The corpus supplies the *labels*, not the structure. |
| **Cast** | Recurring **roles** harvested across the corpus (the authority, the rival, the peer), not people from any one story. Characters are minted for the roles and entity names are substituted in extracted prose. |
| **Stages** | Assigned automatically by the protagonist's standing in each scene, binned and balanced across stages. Overridable by hand. |
| **Event count** | Derived from turns per stage, with a sufficiency report *before* inference is spent: "this corpus yields N situations, the pack needs M". |
| **Options** | 3–5 per event: 1–3 fixed archetypes ("say nothing"), 1–3 drawn from other scenes in the cluster, and 1–3 generated only to fill gaps. Deduplicated **mechanically** — two options with the same effects are one option. |
| **Description variants** | An event can carry several descriptions, sharing one general option set. This needs an engine change (see Open questions). |
| **Viability** | Fully automatic — a human does not have to play a pack for it to count as viable. A **graded score**, not pass/fail, so extra time improves it. The search **always keeps the best-scoring candidate**, never the most recent. When it cannot reach viable, it reports precisely which checks it could not satisfy. |
| **Time and quality** | Start fast and iterate; later, overnight runs for higher quality. Time buys **numeric search depth and prose fidelity** — not corpus reach. |
| **Retuning** | Deterministic, driven by the play log `npm run analyze` already reads. Not model-mediated. |
| **Extension** | Pulling more corpus into an existing pack is **out of scope** for now. Provenance is recorded so it stays possible. |
| **Home** | `theladder/tools/`, never imported by `src/`, `content/` or `test/`. Its own dependencies stay out of the game. |
| **Hardware** | Must be able to run on a low-powered local inference host, taking longer if it has to. |
## Why the hard part is numbers, not prose
Counted from the two hand-written packs:
| | Corporate Ladder | The Frontier |
| --- | --- | --- |
| Prose strings | 219 | 211 |
| Numeric decisions | 347 | 427 |
| Stable ids | 125 | 120 |
A pack is roughly 35% prose and 65% numbers, and every defect the project has hit — the debt trap,
the rent retune, the negotiation cliff — lived in the numbers. The corpus supplies the prose; the
tool has to invent the rest, and has to be able to show it is not a trap.
Randomising The Frontier's numbers while keeping its prose and structure: random magnitudes pass the
conformance checks 3% of the time, random directions 0%, and a crude four-rule repair loop 42% (5 of
12 runs converged). The search space is not hopeless, but it is not a broad basin either — hence a
real search against a graded score.
## Why recurrence is the central problem
Hand-written packs are almost entirely **recurring** situations: 29 of 31 events in Corporate Ladder
and 28 of 29 in The Frontier can fire more than once, and a 41-turn playtest saw 12 distinct
situations an average of 3.4 times each. Stories are the opposite: every scene happens once.
So selection is not "pick 30 good scenes". It is **find the situations this corpus keeps returning
to**. Twenty stories in one world should contain eight variations on "a superior asks you to cover
something up"; clustering those is what yields one recurring event with several descriptions. One
long novel is the hard case and is not the target.
## Boundaries
- **Planning/01 §10 is not violated by a hosted model here.** §10 says the *game* must never require
a hosted inference service. The generator is a build-time tool whose output is plain data with no
runtime dependency; a shipped `.html` still plays offline. Record this in theladder's DECISIONS
when the tool lands there.
- **There are two provider slots, not one.** Embeddings and generation are configured separately.
Anthropic offers generation but no embeddings API, so any mix must keep embeddings local.
- **The inference host is shared.** Other work sends requests to the same server. Clients run one
request at a time with a pause between requests, set a short `keep_alive`, and read the host from
the environment (`STP_OLLAMA`) — never from a tracked file. Every run is agreed with its owner
before it starts.
## Assumptions under test
The architecture rests on three assumptions. All three are cheap to test, and the first could
invalidate everything else.
### 1. A coherent corpus clusters into recurring situations — FAILED in naive form
**Test (2026-09-10):** O. Henry's *Four Million* cycle — four Gutenberg volumes, 94 stories, 244,690
words — cut into 838 scenes of ~300 words, embedded with `nomic-embed-text`, clustered with k-means.
**Result:** at the best k, 18 clusters were both tight and drawn from four or more stories. But
reading them showed most were 60–79% **one** story plus stragglers. Measured by concentration, only
**3 of 18** were genuinely recurring, covering **2% of the corpus**. At scene length, embedding
similarity tracks *which story a scene is from* — names, setting, narrator voice — not *what kind of
situation it is*.
**Fix under test:** summarise each scene into one generic sentence with no proper names, embed the
summary, and cluster those instead. This probe is in `probe/`.
**Baseline re-measured (2026-09-14)** on the rebuilt corpus — prose embeddings made on a GPU host,
same model, same 838 chunks. Seed 11 at k = 100 reproduces the 2026-09-10 figures exactly (18 sharp
clusters spanning ≥ 4 stories, 3 genuinely recurring, 2% coverage). Seeds 12 and 13 give 7 and 7,
covering 8% and 10%: the old 2% was one unlucky initialisation, and the true baseline is poor rather
than hopeless.
**Pass mark, fixed before any summary existed.** Judged at **k = 60 and k = 100**, medians over
three seeds, summary embeddings against prose embeddings as reported by `probe/measure.py`:
1. Median dominant-story share of clusters of size ≥ 5 falls to **40% or below** at both k.
2. **Tight-recurring coverage** — recurring clusters (≥ 4 stories, dominant ≤ 40%) whose coherence is
at or above that file's median cluster — reaches **at least 10% of the corpus** at both k. (A
relative bar was written first and dropped once the baseline came in at 2% and 0%, where "double"
means nothing. 10% of 838 scenes is ~84 — enough raw material for a stage's worth of situations.)
3. Reading three sampled tight-recurring clusters, each is recognisably **one kind of situation**,
not a prose register ("men walking through the city") or a vocabulary cluster.
The prose baseline numbers the first two are judged against are in `probe/measure-prose.log`.
**Result (2026-09-14): criteria 1 and 2 pass decisively; criterion 3 passes only in part.**
| k | Median dominant share, prose → summaries | Tight-recurring coverage, prose → summaries |
| --- | --- | --- |
| 60 | 57% → **22%** | 2% → **26%** |
| 100 | 67% → **28%** | 0% → **19%** |
Pipeline as run: `qwen2.5:3b-instruct` summaries with the v2 prompt (no example roles), names removed
by `strip_names.py`, embedded with `nomic-embed-text`. Logs: `probe/measure-summary.log`,
`probe/sample-k60.log`, `probe/sample-k100.log`.
Reading the seeded samples (three at each k; one cluster appeared at both, so five distinct):
- **Clearly one situation (2):** a spouse scheming against or confronting a spouse over infidelity
(15 scenes, 10 stories); a woman made the centre of attention while men compete for her (5 scenes,
5 stories).
- **Broadly one situation (2):** a secret identity or past revealed in a romantic encounter, often over
dinner (15 scenes, 13 stories); hosts and guests jockeying for social rank at a dinner (6, 6).
- **Not a situation (1):** fortune-tellers, adventurers, a found tyre, a graft inquiry — held together
by "seeks", "fortune" and "mysterious" and dominated by three stories. It is the same cluster at
both k.
The rule as written requires every sample to read as one situation, so **strictly this is not a full
pass**, and it is recorded that way rather than rounded up.
**Follow-up: the selection step is designed and tested in [`SELECTION.md`](SELECTION.md).** A
two-signal gate (cross-story cohesion, and no single word dominating) accepted 10 clusters across
development and test, all of them situations; it rejects the vocabulary clusters this result found.
But it keeps only 5 of ~40 candidates — far short of what a pack needs — so recall is the open problem.
Three recall-recovery methods were then tested on a fresh partition and **none met its pre-registered
bar**: re-clustering found nothing new, consensus clustering found only sharp situations (3 of 3) but
too few, and prune-then-grow found 10 distinct situations with too much noise. The finding that
matters is that the gate is size-dependent in both directions, so it has to be made size-invariant
before recall can be recovered.
A second gate, G2, built for that (cohesion against a same-size random baseline, a significance test
for shared words), **failed validation on fresh data**: it accepts three to four times as many
clusters as the first gate at about the same precision (0.79), but still rejects every cluster under
10 members and still lets growth run to the cap. A fixed threshold on a z-score reverses the size bias
rather than removing it.
A third gate, G3 (a percentile against same-size random sets, plus a mandatory per-member floor),
**failed all four validation criteria and did worse than G2**: 17 accepted across fresh partitions,
only 8 of them situations. The random-set baseline turned out to be the wrong comparison — any
k-means cluster beats random scenes — and the per-member floor is noisiest, and so most lenient,
exactly where clusters are smallest.
A fourth gate, G4, had the inference model read each cluster and say which summaries share one
situation. It **failed with both `qwen2.5:3b-instruct` and `qwen3:14b`** (primary precision 0.48 and
0.52 against G2's 0.73): the models answer with abstractions ("power dynamics", "someone wants
something from someone") that most summaries fit. Observed but not pre-registered: the 14B model AND
G2 accepted 10 clusters, all situations — precise, narrow, and untested on fresh data. The substantive conclusion: summarising
before embedding **does** break the story-identity effect that sank the naive design, and most of
what clusters is recognisably situational — the recurrence premise is alive, not proven. The design
needs a **selection step that rejects vocabulary clusters**, not only a clustering step.
Known defects in this run, none large enough to change the verdict:
- `first_sentence` cut 7 of 838 summaries that begin with an initial ("E. Rushmore Coglan…") to
"E." — single-letter initials and "Gen." are not handled.
- Heavy use of "someone" is itself a shared token across summaries with many characters. Whether it
pulls clusters together has not been measured; a replacement by role or a neutral mix would test it.
- A few names that are also common words survive stripping ("Silver", "Cherry", "Grace", "the Kid").
**Why k ≥ 60, and why a tightness filter.** The first version of this pass mark said "median dominant
≤ 40% at k ≥ 40" — and the prose baseline, the approach known to fail, passed it at k = 40 (36%).
Two things were wrong. Small k forces clusters to merge stories whatever the embeddings encode: on
synthetic vectors carrying nothing but story identity, `measure.py` reports 36% at k = 25, 53% at k
= 40 and 100% at k ≥ 60, while vectors with planted cross-story situations report 11–17% at every k
≥ 40. And without a tightness filter, the loose clusters the 2026-09-10 reading identified as prose
register count as recurring. The tightness cutoff is relative to each file's own median because an
absolute cosine threshold does not transfer between 300-word scenes and one-sentence summaries.
If it fails, the recurrence premise needs rethinking before any design goes further.
### 2. A small model can classify against a closed path vocabulary
Roughly 100+ calls per pack deciding which state paths an option moves and in which direction.
Malformed or wrong output at a meaningful rate changes the design (batching, retries, a bigger
model). **Untested.**
### 3. A small model can substitute entity names without wrecking prose
The main generative task, run on every description. **Untested.**
The summarisation probe in assumption 1 incidentally gives the first evidence on 2 and 3: it asks a
small model for constrained, name-free output and the results can be read.
**First evidence (2026-09-14, `qwen2.5:3b-instruct`, two 40-scene pilots):**
- **It copies examples from the prompt into the output.** A prompt that illustrated roles with "a
clerk", "a landlady", "a rich man", "a city park" got those phrases back 18 times — in scenes
containing **none** of them, every one invented. For this probe that is worse than noise: shared
invented phrases make unrelated scenes cluster together and fake exactly the recurrence being
measured. Removing the examples brought it to 1. **Rule: prompts for a small model name no example
of the output vocabulary.**
- **It does not follow "never use a name."** 25 of 40 summaries kept names with the first prompt,
and 27 of 40 with a stricter one plus an automatic retry — the retry fired 35 times, nearly doubled
the run, and fixed almost nothing. Names are therefore removed **deterministically** afterwards
(`probe/strip_names.py`): a lexicon built from the corpus, of words seen capitalised mid-sentence
and almost never in lowercase. It misses names that are also common words ("Bill", "the Kid",
"New" in "New York"). That bears directly on assumption 3: **entity substitution should be done by
code over a lexicon, with the model's output treated as untrusted.**
- **Throughput is not the constraint.** On a GTX 1080 Ti, a scene summary takes ~0.6 s of model time
(about 1.1 s including a deliberate pause between requests), and embedding runs at ~16 texts/s.
A 838-scene corpus summarises in roughly a quarter of an hour.
## Open questions
- **Description variants need an engine change.** Events carry one description today. Scope it
before building, and record it in theladder's DECISIONS — it is the first engine change content
generation would force.
- **The test strategies hardcode the stat spine.** `theladder/test/fixtures/strategies.js` reads the
literal path `money` and the prefix `relationships.`. A pack that named its currency `coin` would
make four of six archetypes stop discriminating while the sweep still ran and reported. The fixed
spine satisfies this by construction; it is also a coupling that should be made explicit.
- **Options versus variants.** A cluster-derived option is tied to the scene it came from and may
read wrongly under the event's other descriptions. The more variants an event has, the more its
options must come from the fixed archetypes.
- **More options, harder search.** Moving from ~3.1 to ~4 options per event adds roughly 90 effect
magnitudes to a search that already converged only 5 times in 12 with crude repair.
- **Prompts may not transfer between models.** Prompts developed against a capable model are
typically under-specified for a 3B one. The small-model question stays open until it is tested on
a small model, however well a larger one does.
- **Story splitting is per-volume fiddly.** Segmentation needs no model, but each source's format
needs its own pattern. A production tool needs a report when a volume does not split cleanly.
The probe's `split.py` yields 94 stories from 98 contents entries: it silently drops titles
containing quotation marks or an em dash ("LITTLE SPECK IN GARNERED FRUIT", "THE THING'S THE PLAY",
"WHAT YOU WANT", "THE GUILTY PARTY — AN EAST SIDE TRAGEDY"). The 2026-09-10 run dropped the same
ones, so it is left alone to keep the before/after comparison exact.
## Layout
```
story-to-pack/
DESIGN.md this note
recovered/ the 2026-09-10 scripts, as recovered from the transcript; not re-run
probe/ the working probe for assumption 1
corpus/ Gutenberg source texts (rebuildable; see recovered/README.md)
split.py volumes -> stories.json
chunk.py stories -> chunks.json (~300-word scenes)
ollama.py the only module that talks to an inference host
embed.py texts -> vectors, checkpointed
summarise.py scenes -> one generic sentence each, checkpointed
strip_names.py summaries -> summaries with names replaced, from a corpus-built lexicon
measure.py clustering concentration report for any embedding file
sample_clusters.py seeded sample of tight-recurring clusters, for reading (criterion 3)
summaries-v1-pilot.json, summaries-v2-pilot-retry.json the two 40-scene pilots, kept as evidence
*.log run and measurement output
gpu-logs/ the inference host's power/link/kernel logs for the 2026-09-14 run
```
+103
View File
@@ -0,0 +1,103 @@
# story-to-pack
Research toward a tool that builds a The Ladder content pack from a corpus of stories.
**Independent of the game.** Nothing in `src/`, `content/` or `test/` imports anything here, and
nothing here is part of `npm test` or `npm run build`. It lives on the `story-to-pack` branch so it is
versioned alongside the pack format it targets, without touching `main`.
- **What was decided and why:** [`DESIGN.md`](DESIGN.md)
- **How situations are selected, and every test of it:** [`SELECTION.md`](SELECTION.md)
- **The 2026-09-10 scripts, recovered from a transcript:** [`recovered/`](recovered/README.md)
## Status
Selection was settled as **gate G2 followed by a structured human review** (see below). Every automated
alternative tried — three more geometric gates and two model-judged gates — was worse on fresh,
blind-labelled data. The first real review then found G2's groups were **topics** (artists, police,
hotels), not situations. **Now under test:** re-describe every scene as its main person's
**predicament** with qwen3:14b, regroup, and read the groups (SELECTION.md, "Predicaments"). Nothing
downstream of selection (paths and directions, numbers, pack output) is built yet.
## Layout
`probe/` holds the v1 data every G1–G4 result was measured on. `probe/v2/` is the corrected corpus
(97 stories, 839 scenes: v1's splitter merged three quoted-title stories into their neighbours), built by
running the same scripts from inside `v2/` (`python3 ../split.py`, and so on).
```
probe/
corpus/ O. Henry's Four Million cycle, Project Gutenberg (public domain)
split.py, chunk.py volumes -> stories.json -> chunks.json (838 scenes in v1, 839 in v2)
carry_summaries.py v1 summaries onto v2 scenes; only new scene text goes to the model
redescribe.py scene -> its main person's predicament, via the model -> predicaments.json
topic_words.py, topic_share.py how much groups are held together by a job, relationship or place word
pilot_page.py side-by-side page for the 3B / 14B predicament pilot -> pilot-compare.html
groups_page.py reading page for regrouped predicaments -> v2/groups-*.html
ollama.py the only module that talks to an inference host (host from STP_OLLAMA)
summarise.py scene -> one sentence, via the model -> summaries.json
strip_names.py names removed deterministically -> summaries_clean.json
embed.py texts -> vectors -> embeddings.json, summary_embeddings.json
measure.py, signals.py, gate2.py, gate3.py, judge.py … the selection experiments
labels-*.json, sheet-*.txt, candidates-*.json, *-map.json blind labels and what they labelled
review.py G2 on a partition -> REVIEW.html and candidates.json
review_page.py renders the page; picks out role words for each group
review_page.html the page template
review_page_logic.cjs the rules the page applies (also run by the Node tests)
review_format.py the saved review's format, checks and conversion
apply_review.py saved review -> situations.json
test_review.py, test_review_page.mjs tests
review/<run>/ REVIEW.html, candidates.json, and situations.json once applied
```
Not committed (see `.gitignore`): the inference host's power and kernel logs, and caches that the
scripts rebuild on first use (`sim.pkl`, `null*.pkl`, `coassoc.json`). The embeddings are committed
(about 16 MB) because recreating them needs the inference host.
## Running
Everything runs from `probe/` with Python 3 and no third-party packages.
Anything that calls a model reads the host from the environment and never from a file:
```sh
export STP_OLLAMA=http://<inference-host>:11434
```
## Reviewing situations
The review happens in a web page. Nobody edits JSON.
```sh
cd probe
python3 review.py # k = 60, seed 24 -> review/k60-s24/REVIEW.html
```
1. **Open `review/k60-s24/REVIEW.html` in a browser** (double-click it; nothing to install). The first
screen explains the job with examples.
2. **One group per screen.** For each: keep it, drop it, or mark it the same as a group already kept;
untick scenes that don't fit; and fill in "A ___ wants ___ from ___", with the people named in its
scenes offered as click-to-fill words. The page shows as you go whether a group still has enough
scenes, and colours each group in the strip across the top. Keys: K keep, D drop, ← →.
3. **Progress saves in the browser** after every change, so you can close it and come back.
4. **"Finish & save"** lists anything unfinished and saves `review-k60-s24.json`, usually to Downloads.
5. **Then:**
```sh
python3 apply_review.py review/k60-s24
```
It finds the saved review in Downloads by itself, checks everything again, and names any problem the
way the page does ("Group 4: kept, but its name is not finished"). Nothing is written until the
review passes; then it writes **`situations.json`**: each situation's sentence and roles, the scenes
that show it with their stories, and which groups it came from.
`candidates.json` in the run directory is the program's record for the checker; leave it alone.
```sh
python3 -m unittest test_review # the saved review's checks, the page renderer, apply_review.py
node --test test_review_page.mjs # the rules the page applies as you review
```
The page's drawing code is only checked for syntax and for starting up; how it looks and behaves in a
real browser has to be tried by a person.
+891
View File
@@ -0,0 +1,891 @@
# story-to-pack — selecting situations
> **Decision (2026-09-15): selection is gate G2 followed by a structured human review.** `probe/review.py`
> writes `REVIEW.html`, a page where the reviewer keeps, drops or merges each group, unticks scenes and
> names the situation; the page saves a review file, and `probe/apply_review.py` checks it and writes
> `situations.json` for the next stage. See the README.
>
> The first version asked the reviewer to read a long Markdown file and edit JSON by hand. The first
> real attempt stopped before a single entry: what to decide was unclear, there was too much to read at
> once, hand-edited JSON broke easily, and the wording was hard to write. The page answers each of those.
> The record below is how the G2 decision was reached: four gate designs and two model-judged gates,
> each tested against blind labels.
>
> **Superseded in part (2026-09-15), see "Predicaments" at the end:** reading the page, the reviewer
> found the groups were topics, not situations.
The step after clustering: from the clusters of summarised scenes, choose the ones that are genuine
**recurring situations** — the raw material of a recurring event — and reject the rest. Companion to
`DESIGN.md`; this is the step its assumption-1 result called for.
**Status (2026-09-14): designed and pre-registered below, before any feature was computed or any
label was read against a feature.** Results are appended at the end, and nothing above the Results
heading is edited after the test runs, except to fix a typo.
---
## The problem, from the assumption-1 result
Summarise-then-embed broke the story-identity effect: clusters now draw from many stories. But of five
sampled tight-recurring clusters, one was a **vocabulary cluster** — fortune-tellers, adventurers, a
found tyre, a graft inquiry — held together by "seeks", "fortune", "mysterious", and mostly from three
stories. The concentration and tightness filters in `measure.py` let it through. A selector has to
reject clusters like that one and keep clusters like "a spouse schemes against or confronts a spouse
over infidelity".
## Constraints
- **No inference.** Selection uses only what is already on disk: the summary embeddings, the prose
embeddings, the summary text, and which story each scene came from. It is pure computation, like the
numeric search, so it can be re-run and tuned for free. (It also keeps the inference host free for
other work.)
- **Deterministic.** Seeded clustering; the same inputs give the same selection.
- **Pure Python**, like the rest of the probe.
## What a selected situation is
A set of scenes, from **at least 4 different stories**, with no story contributing more than 40%,
that a reader can name as **one social transaction**: who wants what from whom. It becomes one
recurring event; its member scenes become that event's description variants and option material.
Rejected: story-dominated clusters (already mechanical), **vocabulary clusters** (shared words, no
shared transaction), **register clusters** (shared tone or setting, e.g. "men walking through the
city"), and **near-duplicates** of an already-selected situation.
## Candidate signals
All computed per cluster, from existing vectors and text. Each carries a pre-registered prediction of
which way it moves for a genuine situation.
| # | Signal | Definition | Prediction for a situation |
| --- | --- | --- | --- |
| S1 | **Cross-story cohesion** | Mean summary-embedding cosine over member pairs from *different* stories | Higher |
| S2 | **Cross/within ratio** | S1 divided by the mean cosine over member pairs from the *same* story (1.0 if there are none) | Closer to 1 — a situation is as similar across stories as within one; a cluster held together by a few stories' scenes is not |
| S3 | **Effective stories** | Inverse Simpson index of the member story counts, divided by cluster size | Higher — members spread evenly rather than a few stories plus stragglers |
| S4 | **Prose agreement** | Mean *prose*-embedding cosine over cross-story member pairs, minus the corpus-wide mean cross-story prose cosine | Higher — a second, independent view: scenes of the same situation should read a little alike even in the original text |
| S5 | **Stability** | Mean pairwise co-association of members across 12 k-means runs (k from 50 to 100, several seeds): the share of runs in which two members land together | Higher |
| S6 | **Word dependence** | Share of members containing the cluster's single most over-represented content word (log-odds against the whole summary set, stopwords and "someone" excluded) | Higher for a **vocabulary** cluster — one word doing the work |
S6 is predicted to indicate the failure, the others to indicate success.
## Protocol
1. **Candidates.** Cluster the summary embeddings with k = 60. A candidate is a cluster with ≥ 5
members, ≥ 4 distinct stories and dominant share ≤ 40% (`measure.py`'s "recurring"; the relative
tightness filter is *not* applied — the selector replaces it).
2. **Two candidate sets from two partitions.** Development: seed 11. Test: seed 12.
3. **Blind labels.** Each set is dumped to a sheet showing, per cluster, only its member summaries in
shuffled order — no story titles, no scores, no sizes beyond the lines themselves. Each cluster is
labelled by reading:
- **2 — one situation:** one nameable social transaction that most members (roughly 70%+)
instantiate. The label records its name.
- **1 — broad:** a shared setting or theme, but the transaction varies across members.
- **0 — not a situation:** vocabulary, register, or nothing nameable.
The test sheet is labelled before any signal is computed for the test set.
4. **Choose the rule on development only.** Compute S1–S6 for the development set. Candidate rules
are single thresholds on one signal, or an AND of two. The chosen rule is the one with the highest
**precision** (share of accepted clusters labelled 2), subject to accepting at least half of the
development set's label-2 clusters; ties go to the simpler rule. Freeze it here, in writing, before
step 5.
*Detail fixed after the labels were written and before any signal was computed:* thresholds are
placed midway between adjacent observed development values, never on one. Ties beyond simplicity
break on more label-2 clusters accepted, then on precision for label ≥ 1. Two reference points
are reported but cannot be chosen: accepting every candidate, and `measure.py`'s tightness filter
(coherence at or above the partition's median sized cluster), called B0.
5. **Test once.** Apply the frozen rule to the test set. Report precision (label 2, and label ≥ 1),
recall of label-2 clusters, and the number accepted. No re-tuning after seeing test results; any
later change is a new rule and needs new data to be tested on.
6. **Near-duplicates.** Among accepted clusters, merge any pair whose centroids have cosine ≥ a
threshold chosen on development by reading the labels' names. Report how many merges happened on
the test set and whether each merged pair's names agree.
## Known limits of this test
- **The labels are one reader's judgement** — the model running the probe. They are written to a
file so the designer can spot-check or overrule them, and the verdict should be read with that in
mind.
- **Development and test come from the same corpus.** Two partitions of the same 838 scenes share
situations, so the test measures stability across clustering, not generalisation to a new corpus.
A genuinely held-out test needs a second corpus, which needs embedding, which needs the inference
host.
- **k is fixed at 60** for the test. The selector's behaviour at other k is not tested.
## Frozen rule (written before any test signal was computed)
Chosen on development (40 candidates, 5 labelled 2; must accept ≥ 3), by `probe/select_rule.py choose`:
> **Accept a candidate when S1 ≥ 0.6119 AND S6 ≤ 0.1603.**
> Cross-story cohesion at least 0.6119, and no single over-represented word in more than 16% of members.
| On development | Accepted | Precision (2) | Precision (≥ 1) | Recall (2) |
| --- | --- | --- | --- | --- |
| Accept every candidate | 40 | 0.12 | 0.62 | 1.00 |
| B0, tightness filter | 17 | 0.24 | 0.76 | 0.80 |
| S1 alone (best single) | 9 | 0.44 | 0.89 | 0.80 |
| **Frozen rule** | **5** | **0.60** | **1.00** | **0.60** |
Accepted on development: D04 (2), D09 (1), D13 (2), D16 (2), D33 (1). Rejected label-2: D15 (S1 0.603),
D30 (S6 0.20). Full leaderboard: `probe/choose-dev.log`; signals: `probe/signals-dev.json`.
Observations recorded now, so they cannot be shaped by the test:
- **This is very likely overfit.** Five positives, fine-grained thresholds, and an AND of two signals
is exactly the setting in which a rule memorises its development set. The test is what decides.
- **S1 does most of the work.** It is the best single signal by a wide margin, and it alone rejects
the vocabulary cluster that motivated this step (D18, S1 0.605).
- **S6 did not behave as predicted.** Its most over-represented word is usually a *story's* word
("juggler", "furs", "eviction"), not generic vocabulary, and D18's is low (0.11). It catches D39
("young", 1.00) — a vocabulary cluster of a different kind. Its contribution to the frozen rule is
a single cut, and may not carry over.
- **S2, S3 and S4 were weak on development**, and S5 middling. None of the predicted directions was
strongly wrong, but only S1 separated well.
**Near-duplicates (step 6): centroid cosine cannot do it, so no automatic merge is applied.** Among
the five clusters the rule accepts on development, no two share a situation, so there was nothing
there to calibrate on. Across all 26 development candidates labelled ≥ 1, the one clear duplicate —
D13 and D30, both "a run-in with the police" — has centroid cosine **0.9026**, while five pairs of
plainly different situations score higher, up to **0.9326** ("money and marriage" against "schemes,
cons and theft"). D05/D23 (artists) sit at 0.9005. Any threshold that merges the duplicate merges
different situations first. Summary embeddings place situations about money, romance and conflict
in one dense region, so closeness between centroids is not identity of situation.
Decision, fixed before the test: the test reports, **by label name**, whether any two accepted
clusters are the same situation, as a measurement of how often duplicates survive selection. A
merge method is an open problem; member overlap across partitions (co-association between two
clusters' members) is the candidate to try next, and is untested.
## Results
**Test run once, 2026-09-14,** on the seed-12 partition (41 candidates, 5 labelled 2), frozen rule
unchanged. Logs: `probe/signals-test.json`, `probe/apply-test.log`.
| On test | Accepted | Precision (2) | Precision (≥ 1) | Recall (2) |
| --- | --- | --- | --- | --- |
| Accept every candidate | 41 | 0.12 | 0.51 | 1.00 |
| B0, tightness filter | 18 | 0.17 | 0.44 | 0.60 |
| **Frozen rule** | **5** | **0.40** | **1.00** | **0.40** |
Accepted: T35 (2) coercion over money; T39 (2) spouse against spouse over infidelity; T05 (1) schemes
and cons over money; T07 (1) courtship; T25 (1) trying to impress or win someone's favour.
Rejected label-2: T16 newcomer seeks acceptance (S1 0.598), T17 proposal or engagement (S1 0.596),
T30 run-in with the police (S1 0.604, S6 0.23).
### Verdict
**The selector is precise and narrow.** Across both sets it accepted 10 clusters, and **all 10 are
situations or broad situational themes; none is a vocabulary, register or grab-bag cluster.**
Accepting everything gives about half that. The tightness filter used in the assumption-1 test is
*worse* than accepting everything on the test set (0.44 against 0.51) — it keeps vocabulary clusters,
which are tight.
**It does not find most of the situations.** Recall of label-2 clusters fell from 0.60 to 0.40, and
precision for label 2 from 0.60 to 0.40 — the expected overfitting. The rejected label-2 clusters sit
just under the S1 cut (0.596–0.604), so the threshold is doing real work at a place where situations
and non-situations overlap. **Five accepted clusters out of 41 is far short of the ~15 situations
per stage a pack needs**, so as it stands selection would make the sufficiency report fail on this
corpus.
**S6 earned its place on the test set, in the way it failed to on development.** Two test clusters are
held together by a single word — T10 "caliph" (S6 0.70) and T18 "young" (1.00) — and both have high
cross-story cohesion (S1 0.628, 0.626). S1 alone would accept them; S6 rejects them. (Observation made
after the test, not a re-tuning: S1 ≥ 0.6119 alone would accept 10 test clusters, 2 labelled 2 and 6 labelled ≥ 1.)
Its original motivation, D18, was caught by S1 instead. Both failure modes exist, and each signal
catches one.
**Duplicates among accepted clusters:** no two carry the same label name. T07 (courtship) and T25
(trying to impress or win someone's favour) overlap in substance; a reader could reasonably call them
one situation. Automatic merging remains unsolved (see step 6).
### What this means for the design
1. **Two-signal selection is a sound first gate, not a complete step.** It should run as a *filter
that is allowed to reject* — its precision is what matters for pack quality — followed by a
second stage that recovers recall.
2. **Recall is the next problem, and clustering is the likely cause, not selection.** The rejected
situations (police run-ins, proposals, social climbing) are recognisable but diluted by
off-situation members at k = 60. Candidates to test, all without inference: re-cluster the members
of rejected clusters at finer k and re-apply the gate; select from the union of several partitions,
using co-association to keep only members that consistently travel together; or grow an accepted
cluster's core by adding nearest cross-story members while S1 stays above the cut.
3. **Merging duplicates needs member overlap, not centroid distance.**
4. **These labels are one reader's.** Every number above rests on them. They are in
`probe/labels-dev.json` and `probe/labels-test.json`, with the sheets they were read from, and are
worth a designer's spot-check — especially the 1-versus-0 boundary, which is the least certain.
5. **The test is within one corpus.** Generalisation to a different corpus is untested and needs
embeddings, which needs the inference host.
---
## Recovering recall (pre-registered 2026-09-14, before any recovery method was run)
The gate is precise and keeps too few. Three ways to find more situations, compared on **fresh data**,
because the test set above is spent. Still no inference.
### Fixed before running
- **The gate does not change.** `S1 ≥ 0.6119 AND S6 ≤ 0.1603`, plus the candidate conditions (≥ 5
members, ≥ 4 stories, dominant ≤ 40%). "Passes the gate" below means all of these.
- **A property of the gate, noted in advance:** S6 counts a word only if at least two members share
it, so a cluster of fewer than 13 members fails whenever any content word repeats (2/12 = 0.167). In
effect the gate requires ~13 members. The methods are built to produce clusters of that size;
anything smaller is expected to fail, and that is the gate's behaviour, not a method's.
- **Fresh partition:** k = 60, **seed 13** — neither development (11) nor test (12).
- **Baseline (B):** the gate applied to seed 13's candidates, as before.
- **M1 — re-cluster the rejected.** Every scene not in a B-accepted cluster is pooled and re-clustered
with k-means, k = round(pool size / 20), seed 13. Result: B's accepted clusters plus pool clusters
that pass the gate.
- **M2 — consensus clustering.** Each scene is represented by its co-association row over the 12 cached
runs (k 50–100, seeds 21 and 22 — no overlap with 11, 12 or 13): how often it landed with every
other scene. Those rows are clustered with k-means, k = 60, seed 13. Result: clusters that pass the gate.
- **M3 — prune, then grow.** For each seed-13 candidate: if it fails the gate, repeatedly remove the
member with the lowest mean similarity to members from *other* stories, until it passes or falls to
13 members (then it is dropped). Then grow every passing cluster: add the non-member scene nearest
its centroid while the result still passes; stop at the first addition that would fail, or at 30
members. Scenes may end up in more than one M3 cluster; the overlap is reported.
### Evaluation
- Every cluster accepted by any method is pooled (identical member sets once), given a shuffled id, and
dumped to a blind sheet showing only member summaries. Which method produced a cluster is written
to a separate map file that is not read until labelling is finished.
- Labels use the same 2 / 1 / 0 scale. Names are reused verbatim from the development and test label
files wherever the situation is the same, so duplicate situations can be counted by name.
- Per method: clusters accepted; precision for label ≥ 1 and for label 2; **distinct situations**
(distinct names among label ≥ 1); scene coverage; overlap between accepted clusters.
- **Success, per method:** at least **twice** B's accepted clusters, **and** precision (≥ 1) of at least
**0.80**, **and** at least twice B's distinct situations. For scale: a two-stage pack wants ~30.
### Known limits
The reader has now read every summary several times; labels are blind to method and signals, not to
content. The methods and the gate are evaluated on one corpus.
### Results (2026-09-14)
Seed 13 gave 42 candidates. 21 distinct accepted clusters were labelled blind (`probe/labels-recover.json`,
3 labelled 2, 13 labelled 1, 5 labelled 0), then scored by `probe/evaluate_recover.py`:
| Method | Accepted | Precision (≥ 1) | Precision (2) | Distinct situations | Scene coverage | Success |
| --- | --- | --- | --- | --- | --- | --- |
| B, gate on candidates | 4 | 1.00 | 0.00 | 4 | 13% | — |
| M1, re-cluster the rejected | 4 | 1.00 | 0.00 | 4 | 13% | **fail** — 0 new |
| M2, consensus clustering | 3 | 1.00 | **1.00** | 3 | 5% | **fail** — too few |
| M3, prune then grow | 16 | 0.69 | 0.00 | 10 | 45% | **fail** — precision |
**No method meets the pre-registered bar.**
- **M1 adds nothing.** Re-clustering the 729 scenes outside B's clusters at k = 36 produced no cluster
that passes the gate. The gate's selectivity does not come from dilution by one bad partition.
- **M2 is the only method that produced sharp situations.** All three of its clusters are labelled 2
— a wealthy suitor weighed against love, a spouse against a spouse over infidelity, coercion by
threat — at 14–17 members, against B's 13–38. Consensus over many partitions isolates the scenes
that genuinely travel together. It produces too few clusters to be the selection step on its own.
- **M3 finds the most, and the gate cannot stop it diluting.** Pruning recovered 12 rejected
candidates and M3 reached 10 distinct situations (B: 4), but 5 of its 16 clusters are not
situations. **Growth was stopped by the 30-member cap in 10 of 16 clusters, not by the gate.**
That exposes the gate's second size effect: S6 is a share, so it gets *easier* to satisfy as a
cluster grows, and S1 moves slowly. The gate calibrated on k = 60 clusters does not constrain a
cluster that is being built up one scene at a time.
- **Duplicates survive.** Across methods, "a spouse against a spouse" appears three times (R006, R015)
and courtship twice within M3 (R008, R010); 68 scenes sit in more than one M3 cluster.
- **The seed-13 baseline itself accepted no label-2 cluster** (0 of 4), where development and test
each had 2–3. The baseline's precision for label 2 is unstable across partitions.
### What this means
The gate, as frozen, is **size-dependent in both directions**: it structurally rejects clusters under
~13 members and barely constrains clusters above ~25. Any method that changes cluster size — pruning,
growing, finer re-clustering — changes what the gate measures. Every recovery method was, in effect,
testing the gate at sizes it was not calibrated for.
Observation only, not pre-registered and not a result: B and M2 together would give 7 clusters,
6 distinct situations, all labelled ≥ 1, and 3 labelled 2.
Directions this points to, none tested:
1. **Make the gate size-invariant before anything else.** Replace S6's share with a count or a
significance test against the corpus, and judge S1 relative to what a random cluster of the same
size scores. Then re-validate — which needs fresh labels on fresh partitions.
2. **Use consensus (M2) to form clusters, not k-means.** It was the only source of sharp situations.
Running more co-association partitions (more seeds, more k) and extracting cores at several
agreement levels may raise its yield without losing precision.
3. **Grow only with a size-invariant stopping rule.** M3's recall is real; its stopping rule is not.
---
## A size-invariant gate, G2 (pre-registered 2026-09-14, before any G2 signal was computed)
### Why each old signal depended on size
- **S6** is a share with a floor of 2 members: 2/size. Under ~13 members any repeated word fails the
cut; above ~25 almost nothing does. A share is not evidence unless the count is improbable.
- **S1**'s *expectation* does not depend on size — it is a mean over pairs — but its *noise* does:
small clusters reach a high S1 by chance, which is part of why k-means' small clusters look tight.
- **Nothing looked at individual members.** Adding one off-situation scene to 30 moves S1 by
roughly a thirtieth of the difference, so growth was never stopped.
### G2 signals
| Signal | Definition | Why it is size-invariant |
| --- | --- | --- |
| **S1** | Unchanged: mean summary cosine over cross-story member pairs | Unbiased at any size |
| **Z1** | (S1 − μₘ) / σₘ, where μₘ and σₘ come from random member sets of the same size m (≥ 4 stories, dominant ≤ 40%): 300 samples for m ≤ 40, 120 above, sizes 5–150, seeded | Requires more evidence where chance does more |
| **MMIN** | The lowest, over members, of a member's mean cosine to members of *other* stories | A per-member quantity; one diluting member fails it at any size |
| **W** | Among words shared by ≥ 2 members, those whose count is significant by a hypergeometric upper-tail test at Bonferroni 0.01 over the corpus vocabulary; W is the largest share among them (0 if none) | Counts only improbable sharing, then measures how dominant it is |
Word extraction, stopwords and the corpus are identical to `signals.py`. Pairwise similarities are
precomputed once. Candidate conditions (≥ 5 members, ≥ 4 stories, dominant ≤ 40%) still apply.
### Choosing thresholds
- **Tuning set:** every labelled cluster so far — development (40), test (41) and the recovery pool
(21): 102 clusters, 63 labelled ≥ 1, sizes 5–39. All have been seen; none is used for validation.
- **Rule form:** S1 ≥ a AND Z1 ≥ b AND MMIN ≥ c AND W < d, where each term may be switched off. Grid:
a and c at the deciles of the tuning set's values or off; b ∈ {off, 1, 2, 3, 4, 5}; d ∈ {off, 0.25,
0.30, 0.35, 0.40, 0.50, 0.60}.
- **Objective:** the most tuning clusters labelled ≥ 1 accepted, subject to precision (≥ 1) of at least
**0.85**; ties on more label-2 accepted, then fewer active terms. The objective changes from the
first gate (which maximised label-2 precision) because the failure now is recall, and label 1 —
a situational theme — is usable material for a pack.
- The tuning report also gives, per size band (≤ 12, 13–25, ≥ 26), how many clusters the old and new
gates accept and at what precision.
### Validation on fresh data (nothing below has been computed)
- **V1, small clusters:** k = 100, seed 15; 30 candidates sampled at random (seed 15).
- **V2, large clusters:** k = 40, seed 14; 30 candidates sampled at random (seed 14).
- **V3, growth:** k = 60, seed 16; every candidate G2 accepts is grown by adding the non-member nearest
its centroid while the result still passes G2, up to 45 members. The stop reason — gate or cap — is
recorded.
- All V1 and V2 samples and all V3 grown clusters are pooled, deduplicated, shuffled and labelled blind,
with the set and method in a separate map file.
- **Success, all three required:**
1. G2's precision (≥ 1) is at least **0.80 on V1 and on V2 separately**, and G2 accepts at least 3
clusters in each.
2. In V1, G2 accepts **more** small clusters than the old gate.
3. In V3, **most growths are stopped by the gate, not the cap**, and grown clusters have precision
(≥ 1) of at least 0.80.
The old gate is scored on V1 and V2 alongside, for comparison.
### Frozen G2 (written after tuning, before any validation cluster was built)
`probe/tune_gate2.py`, log `probe/tune-gate2.log`. (Correction: the tuning set has 62 clusters
labelled ≥ 1, not 63.)
> **Accept a candidate when S1 ≥ 0.6037 AND Z1 ≥ 5 AND W < 0.60.** MMIN is off.
| Tuning set, 102 clusters | Accepted | Label ≥ 1 | Precision (≥ 1) | Label 2 |
| --- | --- | --- | --- | --- |
| Accept all | 102 | 62 | 0.61 | 13 |
| Old gate | 31 | 26 | 0.84 | 8 |
| **G2** | **40** | **34** | **0.85** | **9** |
| Size band | Clusters (≥ 1) | Old gate: accepted / P1+ | G2: accepted / P1+ |
| --- | --- | --- | --- |
| ≤ 12 | 20 (7) | 0 / — | 1 / 1.00 |
| 13–25 | 52 (34) | 13 / 0.92 | 18 / 0.89 |
| ≥ 26 | 30 (21) | 18 / 0.78 | 21 / 0.81 |
**Observations and predictions, recorded before validation:**
- **G2 is not size-invariant at the small end, and the objective chose that.** Z1 ≥ 5 is a fixed bar
on a statistic whose value, for a fixed true cohesion, *grows* with size (σₘ shrinks as m grows).
It replaces S6's structural rejection of small clusters with a milder one: 1 small cluster accepted
instead of 0. **Prediction: criterion 2 passes narrowly or fails, and V1 yields fewer than 3
accepted clusters, failing criterion 1.**
- **MMIN, the signal designed to stop dilution, was not selected**, and W's cut (0.60) is loose enough
to act only on near-total single-word clusters. Growing a cluster raises Z1 and barely moves S1.
**Prediction: most V3 growths run to the cap, failing criterion 3.**
- The gain over the old gate is real but modest, and it is in the mid and large bands: 8 more clusters
labelled ≥ 1 accepted at unchanged precision.
- The thresholds are not changed in light of these predictions. The validation tests the rule the
pre-registered procedure produced.
### Validation results (2026-09-14)
73 fresh clusters labelled blind (`probe/labels-validate.json`: 6 labelled 2, 33 labelled 1, 34
labelled 0), scored by `probe/validate_gate2.py score` (log `probe/validate-score.log`).
| Set | Sampled (≥ 1) | Old gate: accepted / ≥ 1 / P1+ | G2: accepted / ≥ 1 / P1+ |
| --- | --- | --- | --- |
| V1, k = 100 | 30 (13) | 3 / 2 / 0.67 | **10 / 8 / 0.80** (2 labelled 2) |
| V2, k = 40 | 30 (16) | 1 / 1 / 1.00 | 4 / 3 / 0.75 |
| V3, grown | 13 | — | 13 grown: **11 stopped by the cap**, 2 by the gate; 10 ≥ 1, P1+ 0.77 |
| Criterion | Result |
| --- | --- |
| 1. P1+ ≥ 0.80 and ≥ 3 accepted, on V1 and V2 | **fail** — V1 passes (0.80, 10), V2 does not (0.75, 4) |
| 2. G2 accepts more V1 clusters than the old gate | **pass** — 10 against 3 |
| 3. V3 mostly stopped by the gate, P1+ ≥ 0.80 | **fail** — 2 of 13, 0.77 |
| **Overall** | **fail** |
By size, across V1 and V2 together:
| Size | Old gate: accepted (≥ 1) | G2: accepted (≥ 1) |
| --- | --- | --- |
| ≤ 9 | 0 | **0** — including two clusters labelled 2 (5 and 6 members) |
| 10–12 | 0 | 3 (3) |
| 13–25 | 3 (2) | 7 (5) |
| ≥ 26 | 1 (1) | 4 (3) |
| All | 4 (3), P1+ 0.75 | 14 (11), P1+ 0.79 |
### Verdict on G2
**G2 is a real improvement on the old gate, and it is not size-invariant.** It accepts three to four
times as many clusters at slightly better precision, and it opens the 10–25 member range the old gate
all but closed. Both pre-registered predictions of failure held:
- **The small end is still closed below 10 members.** Z1 ≥ 5 is unreachable for a cluster of 5–9
however cohesive it is. The two sharpest small clusters on the sheet — a fare dispute (5 members,
label 2) and a spouse against a spouse (6 members, label 2) — were both rejected.
- **Growth is still unconstrained.** Growing a cluster raises Z1 and barely moves S1, so both gates
accept a 45-member cluster as readily as a 15-member one. The one per-member signal built to stop
this, MMIN, was not selected by the tuning objective.
- **Large clusters are still mostly rejected** (4 of 30 in V2), including clear themes such as the
saloon, dining rooms and illness.
**What was learned about size-invariance itself:** a fixed threshold on a z-score is not size-invariant;
it reverses the old bias rather than removing it. A size-invariant cohesion test needs a threshold
that scales with the null — for example a fixed *percentile* of the same-size random distribution
combined with an effect-size floor on S1 — and a stopping rule for growth has to be a per-member
test that the tuning cannot switch off. Both are untested proposals, and testing them needs another
fresh partition and another round of blind labels, because every cluster labelled so far has now
informed a design decision.
---
## G3: a percentile of the same-size null, and a mandatory member floor (pre-registered 2026-09-14, before any G3 signal was computed)
### Changes from G2
- **Z1 is replaced by PCT**: the fraction of same-size random member sets (≥ 4 stories, dominant ≤ 40%)
whose S1 is below the cluster's. 1,000 samples per size for 5–60 members, 200 for 61–150, seeded.
A percentile threshold asks "is this cluster as unusual as the top x% of random sets *of its own
size*", which a z-score with a fixed bar does not.
- **S1 floor is mandatory.** At large sizes the null is narrow and PCT saturates at 1.0 for any
cluster slightly above average, so an effect-size floor has to carry the weight there.
- **MMIN is mandatory**: every member's mean cosine to members of other stories must reach the floor.
It is the growth stop; tuning chooses its value but cannot switch it off.
- **W** is unchanged from G2 and remains optional.
### Tuning
- **Tuning set: all 175 labelled clusters** — development 40, test 41, recovery 21, G2 validation 73.
- **Rule form:** S1 ≥ a AND PCT ≥ p AND MMIN ≥ c AND W < d. a and c at the tuning set's deciles (no "off");
p ∈ {0.90, 0.95, 0.975, 0.99, 0.999}; d ∈ {off, 0.30, 0.40, 0.50, 0.60}.
- **Objective unchanged from G2:** most clusters labelled ≥ 1 accepted, subject to precision (≥ 1) of at
least 0.85; ties on more label-2 accepted, then fewer active terms.
**Risk named in advance:** MMIN is a minimum over per-member means. Small clusters have noisier
member means but fewer of them; large clusters have steadier means but more chances to contain one
low member. Its net size effect is not predictable, and it could re-introduce a size bias of its own.
### Validation on fresh partitions (seeds never used before)
- **V0, very small:** k = 150, seed 20; 25 candidates sampled (seed 20).
- **V1, small:** k = 100, seed 17; 20 sampled (seed 17).
- **V2, large:** k = 40, seed 18; 20 sampled (seed 18).
- **V3, growth:** k = 60, seed 19; every G3-accepted candidate grown toward its nearest scene while it
passes G3, cap 45, stop reason recorded.
- Pooled, deduplicated, shuffled and labelled blind as before. The old gate and G2 are scored on the
same clusters.
**Success, all four required:**
1. G3 precision (≥ 1) ≥ **0.80** and ≥ 3 accepted, on V1 and on V2 separately.
2. **Small clusters:** among V0 and V1 clusters of ≤ 9 members, G3 accepts **at least 2**, with precision
(≥ 1) of at least **0.67**, and more than G2 accepts.
3. **Growth:** most V3 growths stopped by the gate, and grown clusters have precision (≥ 1) ≥ 0.80.
4. G3 accepts at least as many clusters labelled ≥ 1 across V0–V2 as G2, at precision no lower than G2's.
### Frozen G3 (written after tuning, before any validation cluster was built)
`probe/tune_gate3.py`, log `probe/tune-gate3.log`.
> **Accept a candidate when S1 ≥ 0.6175 AND PCT ≥ 0.90 AND MMIN ≥ 0.5296 AND W < 0.60.**
| Tuning set, 175 clusters (101 ≥ 1, 19 label 2) | Accepted | Label ≥ 1 | Precision (≥ 1) | Label 2 |
| --- | --- | --- | --- | --- |
| Old gate | 47 | 38 | 0.81 | 9 |
| G2 | 67 | 55 | 0.82 | 12 |
| **G3** | **47** | **40** | **0.85** | **13** |
| Size band | Clusters (≥ 1) | Old: accepted/≥ 1 | G2 | G3 |
| --- | --- | --- | --- | --- |
| ≤ 9 | 26 (5) | 0/0 | 0/0 | **4/3** |
| 10–12 | 21 (11) | 0/0 | 4/4 | 4/4 |
| 13–25 | 75 (47) | 17/15 | 26/22 | 17/17 |
| ≥ 26 | 53 (38) | 30/23 | 37/29 | 22/16 |
**Observations and predictions, recorded before validation:**
- **G3 is the first gate to accept any cluster under 10 members** (4 on tuning, 3 of them ≥ 1).
**Prediction: criterion 2 passes.**
- **The percentile threshold settled at its loosest value, 0.90, and the S1 floor rose** from G2's 0.6037
to 0.6175. The floor, not the percentile, is doing most of the work at mid and large sizes, and G3
accepts 15 fewer large clusters than G2 on tuning. **Prediction: criterion 4 fails** — G3 finds fewer
clusters labelled ≥ 1 than G2 (40 against 55 on tuning), though at higher precision. Criterion 1 is
at risk on V2 for the same reason.
- **MMIN at 0.53 is the growth stop.** Whether a nearest-to-centroid scene falls below it is not
predictable from tuning, which contains no grown-under-G3 clusters. No prediction for criterion 3.
- The trade is visible already: G3 exchanges recall at the large end for precision everywhere and for
admission at the small end. Whether that is the right trade for a pack is a design question the
validation cannot answer.
### Validation results (2026-09-14)
75 fresh clusters labelled blind (`probe/labels-validate3.json`: 4 labelled 2, 24 labelled 1, 47
labelled 0), scored by `probe/validate_gate3.py score` (log `probe/validate3-score.log`).
| Set | Sampled (≥ 1) | Old: acc / ≥ 1 / P1+ | G2: acc / ≥ 1 / P1+ | G3: acc / ≥ 1 / P1+ |
| --- | --- | --- | --- | --- |
| V0, k = 150 | 25 (5) | 2 / 1 / 0.50 | 8 / 4 / 0.50 | 11 / 4 / 0.36 |
| V1, k = 100 | 20 (5) | 1 / 0 / 0.00 | 1 / 1 / 1.00 | 4 / 2 / 0.50 |
| V2, k = 40 | 20 (12) | 0 / 0 / — | 4 / 4 / 1.00 | 2 / 2 / 1.00 |
| ≤ 9 members, V0 + V1 | 27 (4) | 1 / 0 / 0.00 | 3 / 1 / 0.33 | 11 / 3 / 0.27 |
| V0–V2 pooled | 65 (22) | 3 / 1 / 0.33 | **13 / 9 / 0.69** | 17 / 8 / 0.47 |
| V3, grown under G3 | 10 | — | — | 7 stopped by the cap, 3 by the gate; P1+ 0.60 |
**All four criteria fail**, and G3 is worse than G2 on fresh data: more clusters accepted, fewer of
them situations. G3's gain on the tuning set (P1+ 0.85, 13 label-2) did not survive a new partition.
### Why G3 failed — measured, not inferred
- **PCT carries no information.** Across all 175 tuning clusters, 173 reach PCT ≥ 0.90 and 141 reach
1.0 — including 72 of the 74 clusters labelled 0. **The random-set null is the wrong null.** k-means
groups each scene with its nearest neighbours, so every cluster it produces is far tighter than a
random set of scenes of the same size, whether or not it is a situation. A cohesion test has to ask
whether a cluster is tighter than *other clusters of its size*, not than random scenes.
- **MMIN barely separates labels and favours small clusters.** Median MMIN is 0.536 for label 0,
0.543 for label 1 and 0.564 for label 2; by size it is 0.547 for ≤ 9 members, 0.537 for 10–25 and
0.552 for ≥ 26. A minimum over few, noisy per-member means is not reliably lower for small clusters,
so the floor admitted 11 small clusters of which 8 are not situations. This is the risk recorded
before tuning.
- **Growth is still not stopped by the gate** (7 of 10 to the cap). A nearest-to-centroid scene
typically clears a member floor calibrated on whole clusters.
### Where this leaves selection
Across three gates and five blind-labelled evaluations, **G2 remains the best**: pooled precision
(≥ 1) of 0.79 on its own validation and 0.69 here, with the fewest non-situations admitted.
None of the three is size-invariant, and none stops growth.
Two conclusions look robust enough to build on:
1. **Cohesion alone does not identify a situation.** Clusters labelled 0, 1 and 2 overlap heavily on
every cohesion statistic tried (S1, Z1, PCT, MMIN). What separates the best-labelled clusters is
visible to a reader — one transaction — and is not a geometric property of summary embeddings at
the resolution tried.
2. **The labelling budget is the binding constraint.** Each gate needs fresh partitions and 70–80 new
blind labels to test honestly, and every labelled cluster so far has informed a design. Continuing
to iterate gate designs on this corpus without a different kind of evidence is unlikely to converge.
Candidate directions, none tested: a null built from **k-means clusters of the same size on
shuffled-story data** rather than random scenes; a selection step that uses the **inference model to
name each cluster's transaction** and checks agreement across members (needs the GPU); or accepting
G2 as the gate and moving the problem downstream to event construction, where a human pass over
~40 candidate clusters is cheap compared with the labelling this probe has already required.
---
## G4: the model reads the cluster (pre-registered 2026-09-14, before any model call)
The finding above is that what makes a cluster a situation is visible to a reader and not to the
geometry. So let a reader decide — the inference model — and measure it against the blind labels.
### Method
- For each cluster, up to **15 member summaries** (a seeded sample when larger), shuffled and numbered,
go to the model in one call with the prompt below. It returns JSON: the single social transaction
most of them clearly show, or NONE, and the numbers of the summaries that clearly show it.
- **FIT** = (number of valid summary numbers listed) / (number shown); 0 when the answer is NONE or
the reply cannot be parsed. Parse failures are counted and reported.
- **Gate: accept when FIT ≥ θ**, plus the usual candidate conditions (≥ 5 members, ≥ 4 stories,
dominant ≤ 40%), which every labelled cluster already meets.
- Deterministic: temperature 0, 2,048-token context, `format: json`. One request at a time, as before.
- **The prompt contains no example of a situation** — the 3B model copied the examples it was given
in the summarisation pilot.
Prompt (system), fixed:
> You read short summaries of scenes from different stories and decide whether they share one kind
> of social situation.
>
> A social situation is a specific transaction between people: who wants what from whom, and what
> is at stake. Describe it in your own words.
>
> Rules:
> - Find the single situation that the largest number of summaries clearly show.
> - It is normal for many summaries not to fit. Include a summary only if it clearly shows that
> situation.
> - A shared place, mood, profession or word is not a situation.
> - If no situation is clearly shown by at least three summaries, the situation is NONE.
>
> Reply with JSON only: {"situation": "<at most 10 words, or NONE>", "fits": [<numbers of the
> summaries that clearly show it>]}
The user message is the numbered list. For `qwen3:14b` it ends with `/no_think`, and any think block
is stripped before parsing.
### Models
- **Primary: `qwen2.5:3b-instruct`** — the design requires a small local model; the verdict rests on it.
- **Secondary: `qwen3:14b`** — reported, to show whether capability is what limits the result.
### Data — no new labels
The model is never fitted to labels; the only fitted quantity is θ.
- **θ is chosen on development (40 clusters)** only, with the objective used for G2 and G3: most
clusters labelled ≥ 1 accepted, subject to precision (≥ 1) ≥ 0.85; ties on more label-2 accepted,
then the higher θ. Grid: θ ∈ {0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0}.
- **Primary evaluation: the G2 and G3 validation sets, 148 clusters**, which tuned neither G2 nor G4,
so G2 can be compared fairly on them. **Secondary:** test and recovery, 62 clusters.
- Identical member sets appearing in more than one file are judged once.
- **Pilot, allowed once:** the first 5 development clusters, to check the JSON parses. If more than 1
of 5 fails to parse, one prompt revision is allowed and recorded here before any other call.
### Success, on the 148-cluster primary set, with the 3B model
1. Precision (≥ 1) at least **0.80**.
2. **More** clusters labelled ≥ 1 accepted than G2 accepts on the same set.
3. Among accepted clusters of ≤ 9 members, precision (≥ 1) at least 0.67, if there are at least 3.
Also reported: G4 AND G2; FIT distribution by label; the situations named for accepted clusters
against the label names; and the 14B results against the same bar.
**Pilot (21:23):** 5 of 5 replies parsed with the 3B model, so the prompt is unchanged. About 1.6 s
per cluster. Noted without drawing a conclusion from five: the situations named are often generic
("Confrontation and hidden motives" for D04, a label-2 spouse-against-spouse cluster, FIT 0.47), and
a label-1 argument cluster scored FIT 0.87.
### Results, `qwen2.5:3b-instruct` (21:24–21:29, 245 distinct clusters in 286 s, 1 parse failure)
Log `probe/evaluate-judge-3b.log`. θ chosen on development: **0.6** (9 accepted, P1+ 0.89).
| Primary set, 148 clusters (67 ≥ 1) | Accepted | ≥ 1 | Label 2 | P1+ | Recall |
| --- | --- | --- | --- | --- | --- |
| Accept all | 148 | 67 | 10 | 0.45 | 1.00 |
| **G2** | **49** | **36** | 5 | **0.73** | **0.54** |
| G4, FIT ≥ 0.6 | 50 | 24 | 4 | 0.48 | 0.36 |
| G4 AND G2 | 18 | 11 | 1 | 0.61 | 0.16 |
On the secondary set (62) G4 reaches P1+ 0.71 against G2's 0.82. **All three criteria fail**
(P1+ 0.48; 24 ≥ 1 against G2's 36; 13 accepted small clusters at P1+ 0.23).
**FIT does not separate the labels:** mean 0.39 for label 0, 0.49 for label 1 — and **0.42 for label
2**, lower than for label 1. Precision at the primary set (0.48) is barely above accepting everything
(0.45).
**Why, visible in the replies:** the 3B model names situations at a level of abstraction where almost
anything fits — "confrontation", "power dynamics in social hierarchies", "tension between parties",
"coercion and manipulation" — and then lists most summaries as fitting it. A 5-member cluster labelled
0 scored FIT 1.00 as "power struggle/conflict". When it is specific it is often right ("marital
conflict" for a spouse-against-spouse cluster, "con or scam", "argue over payment"), but specificity
is not what FIT rewards. The rule against shared place, mood and profession was followed; a rule
against *abstraction* was never written, and the model supplies abstraction freely.
### 14B pilot and the one allowed revision
The `qwen3:14b` pilot failed to parse **5 of 5**: every reply's content was empty. The model spent its
whole 200-token budget in a separate thinking channel (Ollama returns it outside `content`), and the
`/no_think` suffix did not stop it. **Revision, the one allowed by the pilot rule, affecting only the
14B run:** requests set Ollama's `think: false` and allow 300 output tokens. The prompt text,
sampling, θ procedure and success criteria are unchanged. The five empty results were deleted before
re-running the pilot.
Re-run pilot: **5 of 5 parsed**, about 2.4 s per cluster. The situations named are more specific than
the 3B model's ("Wealthy person pressures someone into marriage", "Struggling artist facing societal or
personal challenges"), though an abstract one still scored FIT 0.80 ("conflict between individuals with
differing perspectives").
### Results, `qwen3:14b` (21:31–21:45, 245 clusters in 832 s, 0 parse failures)
Log `probe/evaluate-judge-14b.log`. θ chosen on development: **0.6** (8 accepted, P1+ 0.88).
| Primary set, 148 clusters (67 ≥ 1) | Accepted | ≥ 1 | Label 2 | P1+ | Recall |
| --- | --- | --- | --- | --- | --- |
| Accept all | 148 | 67 | 10 | 0.45 | 1.00 |
| **G2** | **49** | **36** | 5 | **0.73** | **0.54** |
| G4 (14B), FIT ≥ 0.6 | 42 | 22 | 4 | 0.52 | 0.33 |
| G4 (14B) AND G2 | 10 | 10 | 1 | 1.00 | 0.15 |
By size, G4 (14B) on the primary set: ≤ 9 members 17 accepted, P1+ 0.29; 10–25, 14 accepted, P1+ 0.50;
≥ 26, 11 accepted, **P1+ 0.91**. Secondary set (62): G4 P1+ 0.60 against G2's 0.82.
**All three criteria fail with the 14B model as well** (P1+ 0.52; 22 ≥ 1 against G2's 36; 17 small
clusters accepted at P1+ 0.29).
FIT now rises with the label — mean 0.40, 0.47, 0.52 for labels 0, 1, 2 — where the 3B model's did
not, so capability helps. But the separation is small next to the spread, and the 14B model still
names abstractions that fit almost anything ("Someone wants something from someone else", "Power
dynamics between individuals", "Someone holds power over someone else"), most often for small
clusters.
GPU host during both runs: no faults logged; peak 284.9 W (1 s), mean 163 W under load, 65 °C, PCIe
Gen 3 x4 throughout. Logs in `probe/gpu-logs/*2122*`.
### Verdict on G4
**Neither model's judgement is a better gate than G2**, and the pre-registered verdict (on the 3B
model) is a fail. The failure is specific and repeatable: asked for "the single situation most
summaries share", a model answers at whatever level of abstraction makes the most summaries fit, and
FIT rewards exactly that. Small clusters make it worse, because with 5–9 summaries an abstraction that
covers four or five of them is always available.
Two observations, recorded as observations because neither was a pre-registered criterion:
- **G4 (14B) AND G2 accepted 10 clusters on the primary set, and all 10 are labelled ≥ 1.** The two
gates fail in different ways — G2 admits cohesive clusters that are not situations, the model admits
abstract "situations" that are not cohesive — and their intersection is precise. It is also
narrow: recall 0.15.
- **For clusters of 26 or more, the 14B model's precision is 0.91.** The abstraction problem is a
small-cluster problem: 15 shown summaries rarely all fit one vague phrase *and* get listed.
What would test the obvious next step, none of it done: a prompt that asks for the situation **in
terms of roles and a concrete want** and rejects answers without both; scoring the *specificity* of
the named situation (for example, requiring the named situation to be closer to the member summaries
than to the corpus average in embedding space); and G2 AND G4 evaluated as a pre-registered gate on a
fresh partition, since every labelled cluster now informs this observation.
---
## Predicaments (recorded 2026-09-15, before any model call)
### What the first real review found
The designer opened `REVIEW.html` for run k60-s24 and read four groups closely:
- **Group 1 is artists.** It holds at least three different situations: an artist in a new place using
the art to get on, being paid for the work against what the work is worth, and plain money trouble.
- **Group 2 is police and court officers.** Inside it: being coerced, with the police watching or
arriving; and a crime that is stopped or found out.
- **Group 6 is flirting and infatuation.**
- **Extra group 1 is room and board**: hotels, dinners and the like.
None of these is "one person wanting something specific from another". Checked across all 20 groups,
most are held together by who is in the scenes or where they happen: *artist* in 44% of group 1's
scenes, *policeman* in 54% of group 3's, *uncle* and *lawyer* in group 4, *marriage* and *wedding* in
group 8, *restaurant* and *customers* in group 12, *hotel* in 41% of extra group 1.
**Why every earlier test missed it.** A topic group is as cohesive and as spread across stories as a
situation group, so cohesion, story spread, word dominance and the model's own judgement all passed it.
Summaries keep jobs and places, and the embeddings group on them. It took a person reading the groups.
### Decisions (designer, 2026-09-15)
1. **The unit is a predicament**, not a two-person transaction: what the main person is up against and
what they want out of it, as The Ladder's existing events are (the rent bouncing, the mail cart).
The sub-themes the designer found inside groups 1 and 2 are this kind of thing.
2. **Re-describe every scene as a predicament, then regroup**, rather than splitting topic groups by hand.
### Method, fixed before any call
- `probe/redescribe.py` gives the model each scene's own prose (not its summary) and the prompt in that
script: one sentence, at most 20 words, no names, jobs, places, businesses or story-specific objects,
saying what the person is up against and what they want. No example of the output vocabulary.
- Names are then removed deterministically (`strip_names.py`), the sentences embedded (`embed.py`), and
the embeddings clustered at k = 60.
- **Pilot first:** the first 40 scenes, with both `qwen2.5:3b-instruct` and `qwen3:14b`, read by the
designer side by side before any full run. The full run uses the model the designer prefers.
### How it is judged
- **Topic share** (`probe/topic_share.py`): for each candidate group, the share of its scenes whose
original summary contains the group's most common job, relationship or place word; median over groups
and seeds 11–13 at k = 60. The baseline on the summary embeddings is measured before any call and
recorded below. **Pass: the median falls to half the baseline or less**, with at least 30 candidate
groups remaining at k = 60 (enough material for a two-stage pack).
- **Leakage:** how many predicament sentences still contain a job, relationship or place word. Reported,
not gated.
- **The real test is the designer reading the regrouped top groups** in the review page: most should
read as one predicament. Numbers alone passed topic groups before.
### Baseline (measured 2026-09-15, before any model call)
A first measurement counted words for a person with no role at all ("man", "woman", "girl"), and scored
one group 90% topic-bound for saying "man". Those words (`GENERIC_PEOPLE` in `probe/topic_words.py`)
were taken out of the measure, and the baseline re-measured, still before any predicament was generated.
`python3 topic_share.py summary_embeddings.json` (`probe/topic-share-baseline.log`):
| Seed | Candidate groups | Median top topic-word share |
| --- | --- | --- |
| 11 | 40 | 16% |
| 12 | 41 | 20% |
| 13 | 42 | 22% |
| **Median** | **41** | **20%** |
Most topic-bound at seed 11: *wife* 47% of 15, *dinner* 47% of 15, *policeman* 40% of 15 and 38% of 16,
*brother* 38% of 8, *artist* 36% of 11. **Pass mark for the predicament groups: median 10% or less, with
at least 30 candidate groups.**
### Pilot (2026-09-15)
First 40 scenes, one request at a time on the GPU host (3B 39 s, 14B 111 s; no GPU faults; logs in
`probe/gpu-logs/*-2026-09-15-1803*`). Read side by side in `probe/pilot-compare.html` (`pilot_page.py`).
| | qwen2.5:3b-instruct | qwen3:14b |
| --- | --- | --- |
| Has a job, relationship or place word | 15 of 40 | 13 of 40 (mostly *wife*, *husband*) |
| Mean words (limit 20) | 14 | 25; 34 of 40 over |
| Most common opening | "The Kid seeks…" ×3 | "A man struggles…" ×6 |
3B mostly retells the plot, keeping names, objects and places. 14B writes something close to a
predicament but ignores the length limit and repeats a few openings. **Designer's read: scenes 1–10,
14B dramatically better in every one; stopped there. The full run uses qwen3:14b.**
Found while reading: `split.py` skips a title written in quotation marks, so three stories were
merged into the story before them. "Little Speck in Garnered Fruit" is inside "Dougherty's
Eye-Opener", "The Guilty Party" inside "A Harlem Tragedy", and "What You Want" inside "The Duel". The
corpus has 97 stories, not 94. Separately, `strip_names.py` treats *York*, *Manhattan* and *Broadway*
as personal names, which gives "New someone" in 29 of 838 summaries.
### Corrections before the full run (2026-09-15, designer's go-ahead)
The v1 data and every G1–G4 record stay as they were, in `probe/`. The corrected corpus is built in
`probe/v2/`, running the same scripts from that directory.
- **Split.** `split.py` accepts a quoted title and matches a contents entry by the part before a dash
(“THE GUILTY PARTY”—AN EAST SIDE TRAGEDY). Result: **97 stories, 839 scenes**. Exactly the three
missing stories were added, nothing was lost, and only their three host stories changed.
- **Summaries carried over.** 829 of the 839 scenes have text identical to a v1 scene and keep that
v1 summary verbatim (`carry_summaries.py`). The other 10 are summarised with the v1 model and prompt.
- **Name stripper.** New York places (New York, Manhattan, Broadway, Harlem, the Bowery, and a few
others) become "the city" before names are looked for, and days and holidays are never names. Months
stay ambiguous ("May" is a person in scene 5) and are left alone.
- **The baseline is re-measured on v2** (the v2 summaries, re-embedded), because both fixes change
what is embedded. It is recorded below before any predicament is read.
- **No trimming to 20 words.** Tried on the 14B pilot: cutting at the last clause break within 20
words removed what the person is up against in most cut sentences ("…with a promise of future money"
without "but his dishonesty sparks a violent confrontation"), and 10 of 40 had no clean break.
The full 14B sentence is embedded.
### Result (2026-09-15)
Full run: 839 scenes re-described by `qwen3:14b`, one request at a time, 37 minutes on the GPU host
(peak 270 W, mean 136 W, 65 °C, no faults; logs in `probe/gpu-logs/*-2026-09-15-1831*`). Sentences
average 25 words. 219 of 839 still contain a job, relationship or place word, most often a relationship
(*wife*, *husband*) — reported, not gated.
`topic_share.py`, k = 60, seeds 11–13, both measured on the v2 corpus:
| | Candidate groups | Median top topic-word share |
| --- | --- | --- |
| v2 summaries (baseline, `topic-share-baseline-v2.log`) | 45 | **20%** |
| Predicaments (`topic-share-predicaments.log`) | 42 | **13%** |
**The pre-registered pass mark is not met.** It asked for half the baseline or less (10%); the measure
fell by about a third, from 20% to 13%. The group-count mark is met (42 ≥ 30). The most topic-bound
groups are also much weaker than the baseline's: the worst predicament group is *room* at 40% of 5
scenes, against the baseline's *bride* at 62% of 8 and *policeman* at 60% of 20.
Not a pass and not a wash, so the decision goes to the reading test that this whole step exists for:
`groups_page.py` builds `v2/groups-predicaments-k60-s24.html`, which lists each group's predicament
sentences with each scene's original summary beneath, most cohesive group first, and asks the designer
to mark each group **one predicament**, **two or three mixed**, or **not a predicament**. The openings
repeat ("A man must" 45 times, "A man struggles" 37), so the risk to watch for is a group held together
by that phrasing rather than by a predicament.
+220
View File
@@ -0,0 +1,220 @@
# Aesop reference set: *Three Hundred Aesop's Fables* (tr. George Fyler Townsend, 1867)
A hand-made comparison baseline for the story-to-pack pipeline, a sibling of the Jacobs set in
`../jacobs/` and the Lawson set in `../lawson/`. Claude read all 313 fables and wrote every summary,
predicament, group, option and measurement row. **No inference of any kind was used.** Do not treat
it as ground truth: it is one careful reading.
Aesop has two jobs here. It is a normal reference set. It is also a test of a different claim: a story
supplies predicaments well but rarely says what a move leads to, while a fable puts situation, move
and outcome in one paragraph. The second job is measured in
[Fables as an option source](#fables-as-an-option-source).
| File | What it is |
| --- | --- |
| `corpus/three-hundred-aesops-fables.txt` | Project Gutenberg eBook #21 (public domain) |
| `split_gutenberg.py` | → `stories.json` and `chunks.json`: **313 fables, one story and one scene per fable** (see below) |
| `claude/batches/sNNN.json` | `{chunk, story, summary, predicament, moral}`, one file per run of fables, named by the first fable in it so that the `s*.json` glob sorts in fable order |
| `claude/groups_src.py` | the groups, their options, and the protagonist's standing in every fable |
| `claude/options_src.py` | the options measurement, with the definitions of its `new:` entries and the rules for `stated` |
| `claude/build.py` | checks everything, then writes `claude/records.json`, `claude/groups.json` and `claude/options_by_entry.json` |
| `claude/page.py` | → `claude/REFERENCE.html`, the readable page, with the measurement at the end |
Rebuild: `python3 split_gutenberg.py` from this directory, then
`cd claude && python3 build.py && python3 page.py`.
**One scene per fable** departs from the usual 80-word scene rule on purpose. The median fable is 97
words, and many are under 80, so the rule would discard them, and a fable split in two would lose its
turn. A group's fable count is therefore also its story count.
## What is in it
- **313 predicaments**: one sentence each (median 21 words, min 15, max 24), transplanted to a
workplace. They contain no animal, plant or god names and no story-only objects. Human role words
stay (someone powerful, a partner, a newcomer, the boss). `build.py` enforces this with a list of
every creature, plant, god and prop in the book, not with `topic_words`. The list caught nine
predicaments that used an animal word as a verb or an idiom ("crow about it", "swallow it whole",
"the lion's share"), and all nine were reworded.
- **115 stated morals**, recorded verbatim. A moral here is the fable's unquoted, generalising
sentence ("Look before you leap."). Where the lesson is only spoken by a character inside quotation
marks, as in *The Lion and the Mouse*, `moral` is `null`. `build.py` checks every moral is in the
text.
- **16 groups of 13 or more fables**, the recurrence rule scaled to this corpus (at least
max(3, 4% of 313) = 13). They range from 13 to 18 fables. **4 small groups of 5 to 12** are marked
`small` and reported separately: G17 letting a known predator inside (6), G18 the lion's share (6),
G19 a bad record when you need trust (5), G20 a warning before the trouble (6). Each fable sits in at
most one group: **253 are assigned and 60 fit nothing that recurs**.
- **Options**: 3 to 5 per group, 94 in all. 87 come from what someone in a member fable did or
refused to do (`scene:N`), 7 fill gaps, and none are archetypes. No option cites a fable outside
its own group, unlike Jacobs (8) and Lawson (14). A fable is a whole turn, so the move and the fable
are always the same scene.
- **Moves**: spine paths with a direction only (`+`, `-`, `risk`, `set`). There are no magnitudes.
**Skills** proposed for this corpus: `wit`, for seeing through a trick and turning it, and
`prudence`, for looking before you leap, knowing your limits and keeping what you have. Between them
they are most of Aesop's morals.
- **Stage** comes from protagonist standing, judged **per fable** in `STANDING` in `groups_src.py`:
157 fables rank low, 119 middle and 37 high. **No group comes out stage `high`** (11 low, 9 middle).
Aesop's "you" is usually the lamb, not the wolf, and the high-standing fables (a father, a master, a
captor) scatter across groups rather than recurring together.
The groups:
| | Group | Fables | Nearest catalogue entry |
| --- | --- | --- | --- |
| G01 | A friendly approach from someone who would gain by your fall | 16 | new (near `sure_thing`) |
| G02 | At the mercy of someone who has already decided | 14 | `at_their_mercy` |
| G03 | Trading one hardship for another | 14 | new |
| G04 | Envying another's gift | 17 | new (near `overlooked`) |
| G05 | Passing for more than you are | 14 | `pretence` |
| G06 | Riding high | 13 | new |
| G07 | Sneered at by someone showier | 13 | new (near `power_over_you`) |
| G08 | Help asked in need | 14 | `plea_for_help` |
| G09 | Wanting more than you hold | 13 | new |
| G10 | Your side divided | 15 | new |
| G11 | A partner you may not be able to count on | 18 | new (near `used`) |
| G12 | Rushing in without looking | 13 | new |
| G13 | Pointing at others' faults | 14 | new (near `own_words_back`) |
| G14 | Liberties between the weak and the strong | 15 | new (near `revenge`) |
| G15 | A hard lot | 14 | new (near `dead_end`) |
| G16 | Worked hard, badly repaid | 13 | new (near `overlooked`) |
Only three large groups match an existing entry. The rest are situations about **self-regard and
prudence** (envy, pride, greed, heedlessness, pointing at faults), which no story corpus isolated.
The story corpora found them only as the texture of longer plots.
## How this corpus differs
**Recurrence is the corpus's nature, not something to be found.** Every earlier corpus needed a
search for the handful of situations its stories kept returning to. Aesop is built from a small
stock of situations told over and over with different animals: the predator's invitation alone
appears sixteen times. The problem flips. Nearly every fable recurs, and the work is deciding which
of several overlapping situations a fable belongs to. *The Fox and the Goat* is a trap, a heedless
leap, and a partner who uses you. It sits in G01.
**The predicaments are the cleanest yet.** There are no names to strip, and the whole cast is
already a set of roles: the strong, the weak, the flatterer, the partner, the master. Transplanting
to a workplace needs only the animal removed. The cost is abstraction: a pack built on these
predicaments alone would have no setting at all.
**Two groups hold both sides of one situation.** G13 holds the critic who shares the fault and the
one criticised. G14 holds the weak who strike at the fallen and the strong being needled. In Jacobs
and Lawson the actor was always on one side. Here the fable often follows both parties to their fate,
and splitting them would leave neither side at 13. The measurement does the same for `sneered_at`:
G07's predicaments are all from the side of the one sneered at, but four of its fables (the heifer, the
horse, the olive, the hare) also supply the sneerer's move as an option.
**Power runs through nearly everything.** Five of the sixteen groups turn on an imbalance of power
(G01, G02, G03, G14, G16), and two of the small ones as well. Of the options measured below, 110 of
242 are about the actor's standing going down. It is the most power-heavy corpus so far, and the
least domestic: there is almost no home, love, grief or dread.
## Fables as an option source
`claude/options_by_entry.json` maps a catalogue entry (a v2 id, or `new:<id>` defined under
`_new_entries`) to the options the fables supply for it. Each option is
`{text, moves, source: "chunk:N", evidence, stated}`. `evidence` is the verbatim moral, or an
excerpt of at most 30 words, and `build.py` checks both. **`stated` is `true` only if the fable's own
outcome or moral shows the direction of every path in `moves`.** The rules, applied the same way to
every row, are in the docstring of `options_src.py`. The main points:
- An outcome is read as a direction by a fixed table. Killed, caught, beaten, driven out or publicly
rebuked counts as `standing -`. Goods, food or pay lost counts as `money -`. A counterpart who
repays, deserts or stops trusting you counts as `relationships`. Reading "eaten" as `standing -` is
a convention. The fable does not say it.
- A moral that only calls a move wise or foolish ("Look before you leap.") states no direction. One
that names the effect ("Slow but steady wins the race.") does.
- An outcome the move did not cause is not counted, however pointed. The sparrow is taken by a hawk
while mocking the hare, but mocking did not bring the hawk.
- `risk` is stated only when one fable shows the same move ending both ways. That happens once, in
*The Birdcatcher, the Partridge, and the Cock*: one bird's plea spares it, and the other's does not.
- **Measurement moves use only `money`, `standing` and `relationships.<role>`.** A skill label or a
flag is a decision about the option, not an outcome any text can show. Leaving them out means every
row could in principle be stated. The group options in `groups_src.py` keep them.
**Results:**
| | |
| --- | --- |
| Catalogue v2 entries with at least one sourced option | **32 of 82** (26 with at least one stated option) |
| `new:` entries | **19**, all with at least one stated option |
| Options | **242**, from 229 fables (84 for v2 entries, 158 for new ones) |
| `stated: true` | **165 (68%)**: 62 of 84 for v2 entries (74%), 103 of 158 for new ones (65%) |
| Entries with three or more stated options | 21 |
| Entries with a stated `+` option and a stated `-` option | 15 |
The v2 entries the fables speak to are mostly about power, deception and help. `at_their_mercy`
gets 14 options (12 stated), `plea_for_help` 11 (9), `pretence` 8 (6) and `warning` 5 (2). Another 28
entries get one to four options each. The 50 entries with nothing are almost all of the catalogue's
home, love, loss, fear and duty. Examples are `home_unseen`, `grief`, `losing_them`, `dread`,
`duty_against_love`, `past_comes_back` and `promised_elsewhere`. Aesop has no inner life and almost
no family.
The 19 new entries are `friendly_predator`, `better_the_devil`, `ape_your_betters`, `riding_high`,
`sneered_at`, `grasping`, `divided`, `doubtful_ally`, `heedless`, `pot_and_kettle`,
`needling_the_strong`, `hard_lot`, `used_up`, `predator_inside`, `lions_share`, `bad_record`,
`asking_above`, `please_everyone` and `make_them_move`. Each definition says which v2 entry it sits
nearest to and how the decision differs.
**Against the story packs.** Every option in a story pack has an inferred direction: the author
judged where the move leads, because the story does not say.
| Pack | Options | `scene:N` | `archetype` | `filled` | Directions stated by the text |
| --- | --- | --- | --- | --- | --- |
| Lawson (`../lawson/claude/groups.json`) | 142 | 118 | 12 | 12 | 0 (all inferred) |
| Bierce (`../bierce/claude/groups.json`) | 128 | 96 | 15 | 17 | 0 (all inferred) |
| Aesop groups (`claude/groups.json`) | 94 | 87 | 0 | 7 | not separately judged |
| Aesop measurement (`claude/options_by_entry.json`) | 242 | 242 (all from a fable) | 0 | 0 | 165 (68%) |
Even a `scene:N` option in a story pack is sourced only for its *move*: the scene shows someone doing
it, and its effect is the author's call. In the measurement, two thirds of the options are sourced for
the effect as well.
The 68% needs three qualifications before anyone relies on it.
1. **The stated options are thin.** 226 of the 242 options carry a single path, and 15 of the 16
two-path options are stated. A fable follows one actor to one fate, and that fate is nearly always
their standing. Story-pack options carry more: Lawson's average 1.67 spine paths each, Bierce's
1.18 and Aesop's own group options 1.40, before skills and flags. What a fable usually leaves
unsaid is the second path, how the move changes a relationship.
2. **They are almost all downside.** Of the 165 stated options, **136 move only in the `-`
direction**. By path, 90 stated `standing -` against 12 stated `standing +`. A fable states the
price of the tempting mistake: the goat in the well, the crow who sang, the dog who dropped his
meat. The wise move's reward is usually just that the harm does not happen, and that gives no
direction. The fox who stays outside the sick lion's cave is right, and nothing moves for him. That
is why the options marked `false` are so often the prudent ones. Only 15 entries get a stated
option in each direction.
3. **The coverage is narrow.** 32 of 82 entries, with most of the weight on a handful. The fables
tell you a great deal about predators, pretenders and the greedy, and nothing about a spouse, a
dead friend or a night watch.
**Verdict.** Fables are a real source of effect directions, and a far better one than stories, but
only for one kind of option. For a tempting mistake in a situation of power, greed, pride or trust,
the fable states where it leads, with evidence, seven times in ten (136 of the 190 options that only
move down). For any other option it is about half (29 of 52). That is enough to anchor
the `-` direction of an event's tempting option, and to check an author's inferred direction against
a text. Fables do not supply the prudent option's payoff, the relationship side of a move, or skills,
and they say almost nothing about the catalogue's domestic and inward half. The same applies to them as
a source of predicaments. Use them to calibrate the downside of options in power and trust events,
not to build whole events.
## Known defects
- **Drop capitals.** The transcription lost each fable's printed drop capital, so many fables open
with the first word's initial missing: "WOLF, meeting with a Lamb" (fable 1), "TEE KITES" (180).
`chunks.json` is left as split. Morals and evidence are quoted from later in the text, so none is
affected.
- **Other transcription slips**, left as split: "an a woman" (194), "use I gave credence" (280),
"all bag in front" (260).
- **Fable 89, *The Æthiop***, rests on a racist premise, that a black servant's colour is dirt to be
scrubbed off. Its predicament keeps only the transplantable turn: trying to scrub away someone's
nature, and hurting them. It is unassigned.
- **Etiologies are not situations.** *The Goods and the Ills* (250), *The Lark Burying Her Father*
(263), *The Man, the Horse, the Ox, and the Dog* (203) and *The Two Bags* (260) explain how
something came to be. Their predicaments are strained, and three of the four are unassigned.
- **G11 is the loosest large group** (18 fables). It holds every doubtful partner, from the companion
who flees a bear to the boastful suitor, joined only by the question "can I count on them?".
- **Standing is judged per fable** from the predicament's "you", not from the animal. So the lion in
*The Lion and the Mouse* ranks high because the predicament puts the player in the lion's place.
The Lorimer advice-book pilot running alongside this one was not read. The two results are
independent.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,47 @@
[
{"chunk": 0, "story": 0, "summary": "A lion spares a mouse who begs for its life, and later the mouse gnaws through the hunters' ropes that bind the lion.", "predicament": "Someone far beneath you whom you could crush begs to be let off and swears they will repay you one day.", "moral": null},
{"chunk": 1, "story": 1, "summary": "A wolf invents one charge after another against a lamb, and when each is refuted eats him anyway.", "predicament": "Someone powerful who has already decided to ruin you keeps inventing charges, and each answer you give changes nothing.", "moral": "The tyrant will always find a pretext for his tyranny."},
{"chunk": 2, "story": 2, "summary": "An ass envies the grasshoppers' song, tries to live on dew as they claim to, and starves.", "predicament": "You admire a colleague's talent so much that you copy their habits, though what feeds them will not feed you.", "moral": null},
{"chunk": 3, "story": 3, "summary": "A crane pulls a bone from a wolf's throat and, asking for her fee, is told that keeping her head is payment enough.", "predicament": "You did risky work for someone ruthless on a promise of payment, and now they say escaping unharmed is your fee.", "moral": "In serving the wicked, expect no reward, and be thankful if you escape injury for your pains."},
{"chunk": 4, "story": 4, "summary": "A father shows his quarrelling sons that a bundle of sticks cannot be broken though each stick alone snaps easily.", "predicament": "The people under you quarrel constantly among themselves, and you must make them see that divided they are easy prey.", "moral": null},
{"chunk": 5, "story": 5, "summary": "A bat caught by two weasels claims to be a mouse to one and a bird to the other, and escapes both.", "predicament": "Two hostile powers each catch you in turn, and you could save yourself by claiming to be whatever each one tolerates.", "moral": "It is wise to turn circumstances to good account."},
{"chunk": 6, "story": 6, "summary": "A cock scratching for food finds a jewel and says one grain of barley would be worth more to him.", "predicament": "Something others would prize highly has fallen into your hands, but it is of no use to you in your present need.", "moral": null},
{"chunk": 7, "story": 7, "summary": "A swallow and a crow argue over plumage, and the crow says his feathers at least last through the winter.", "predicament": "A showy colleague belittles you, and you must decide whether to argue looks or point to what lasts when times turn hard.", "moral": "Fair weather friends are not worth much."},
{"chunk": 8, "story": 8, "summary": "A just lion king proclaims peace between strong and weak beasts, and the hare, praising it, runs for his life.", "predicament": "A new leader declares that the weak will now stand safely beside the strong, and you are one of the weak.", "moral": null},
{"chunk": 9, "story": 9, "summary": "A traveller scolds his dog for dawdling, and the dog replies that he is ready and waiting on his master.", "predicament": "Someone above you blames you for the delay, when in fact you have long been ready and are waiting on them.", "moral": "The loiterer often blames delay on his more active friend."},
{"chunk": 10, "story": 10, "summary": "A starving grasshopper who sang all summer begs food of the ants, who mock him for not saving.", "predicament": "Someone who idled while you worked and saved now comes to you hungry, asking you to share what you stored.", "moral": null},
{"chunk": 11, "story": 11, "summary": "A hare mocks a tortoise's pace, sleeps during their race, and loses to her steady plodding.", "predicament": "Someone gifted mocks your slowness and accepts your challenge, sure they can win without really trying.", "moral": "Slow but steady wins the race."},
{"chunk": 12, "story": 12, "summary": "A charcoal-burner invites a fuller to share his house to save costs, and the fuller refuses because he would blacken all his work.", "predicament": "A friend proposes sharing premises to save money, but the way they work would undo everything you do.", "moral": "Like will draw like."},
{"chunk": 13, "story": 13, "summary": "A boy catching locusts reaches for a scorpion, which warns him he would have lost everything had he touched it.", "predicament": "You have been collecting easy wins, and the next thing you are about to grab looks like the others but is dangerous.", "moral": null},
{"chunk": 14, "story": 14, "summary": "A fisherman pipes to the fish in vain, then casts his net and scolds them for dancing only now.", "predicament": "You have waited in vain for people to come round of their own accord, and could stop coaxing and use the tool you have.", "moral": null},
{"chunk": 15, "story": 15, "summary": "A dog carrying meat snaps at his reflection's larger piece and loses his own.", "predicament": "You already hold something good, and a bigger prize that may not be real tempts you to let go of it.", "moral": null},
{"chunk": 16, "story": 16, "summary": "A carter whose wagon is stuck calls on Hercules, who tells him to put his shoulder to the wheel first.", "predicament": "Your work is stuck, and you are waiting for someone powerful to rescue you instead of putting your own effort into it.", "moral": "Self-help is the best help."},
{"chunk": 17, "story": 17, "summary": "A blind young mole insists he can see, and his mother's test shows he cannot even smell.", "predicament": "A young person insists they have a skill they plainly lack, and you must decide how to show them without crushing them.", "moral": null},
{"chunk": 18, "story": 18, "summary": "A herdsman vows an offering to find his calf's thief, then finds a lion eating it and vows more to escape.", "predicament": "You swore to track down whoever took from you, and have just found out the culprit is far more dangerous than you.", "moral": null},
{"chunk": 19, "story": 19, "summary": "A fawn asks why his mother, bigger and armed, flees from hounds, and she admits she simply cannot help it.", "predicament": "You have every advantage over the one who frightens you, and still you cannot bring yourself to stand your ground.", "moral": "No arguments will give courage to the coward."},
{"chunk": 20, "story": 20, "summary": "A fox betrays his partner the ass to a lion in return for his own safety, and the lion takes them both.", "predicament": "Danger threatens you and a partner, and someone powerful offers to spare you if you hand your partner over.", "moral": null},
{"chunk": 21, "story": 21, "summary": "Flies gorge on spilt honey, stick fast in it and die lamenting their greed.", "predicament": "An easy pleasure is spread out in front of you, and the longer you indulge the harder it will be to get free.", "moral": "Pleasure bought with pains, hurts."},
{"chunk": 22, "story": 22, "summary": "The beasts ask the lioness how many young she bears, and she says only one, but that one a lion.", "predicament": "Others boast of how much they produce and demand to know your numbers, when you produce little but of real quality.", "moral": "The value is in the worth, not in the number."},
{"chunk": 23, "story": 23, "summary": "A farmer warms a frozen snake in his bosom, and it revives and bites him fatally.", "predicament": "Someone known for treachery is down and helpless, and you could take them in and nurse them back to strength.", "moral": "The greatest kindness will not bind the ungrateful."},
{"chunk": 24, "story": 24, "summary": "A man points to a statue of a man strangling a lion, and the lion says a lion would have carved it the other way.", "predicament": "In a dispute over who is stronger, your rival points to evidence that was produced entirely by their own side.", "moral": "One story is good, till another is told."},
{"chunk": 25, "story": 25, "summary": "A bramble tells a pomegranate and an apple tree to stop disputing which is more beautiful in its presence.", "predicament": "Two respected colleagues argue over who is the best, and someone who counts for nothing loudly sets themselves up as judge.", "moral": null},
{"chunk": 26, "story": 26, "summary": "A stork caught in a net with thieving cranes pleads his good character, but the farmer kills him with them.", "predicament": "You are caught alongside people who were doing wrong, and your good character counts for nothing because of the company you kept.", "moral": "Birds of a feather flock together."},
{"chunk": 27, "story": 27, "summary": "A mountain groans as if in labour, crowds gather in dread, and out comes a mouse.", "predicament": "Someone has made a huge commotion about what they are about to produce, and everyone is gathered waiting for the result.", "moral": "Don’t make much ado about nothing."},
{"chunk": 28, "story": 28, "summary": "A bear boasts he loves men so much he never touches their dead, and a fox wishes he spared the living instead.", "predicament": "Someone boasts of their kindness in a way that costs them nothing, while they keep harming those who are still around.", "moral": null},
{"chunk": 29, "story": 29, "summary": "A tortoise begs an eagle to teach her to fly, promising riches, and is dropped and dashed to pieces.", "predicament": "You long for a role quite beyond your nature, and someone offers to lift you into it if you pay well.", "moral": "If men had all they wished, they would be often ruined."},
{"chunk": 30, "story": 30, "summary": "A fox trapped in a well praises the water to a goat, climbs out on the goat's back, and leaves him there.", "predicament": "Someone already in trouble urges you to join them in a venture, praising it highly, and you have not checked the way out.", "moral": "Look before you leap."},
{"chunk": 31, "story": 31, "summary": "A raven envies a swan's whiteness, moves to the lakes to wash, and starves without changing colour.", "predicament": "You envy a colleague's standing and give up the work that feeds you to copy their habits, hoping to become like them.", "moral": "Change of habit cannot alter Nature."},
{"chunk": 32, "story": 32, "summary": "A thirsty pigeon flies at a painted goblet on a signboard and breaks her wings.", "predicament": "You badly need something, and what looks like it has just appeared, so you rush at it without checking that it is real.", "moral": "Zeal should not outrun discretion."},
{"chunk": 33, "story": 33, "summary": "A dog lying in a manger snaps at the oxen to stop them eating hay he cannot eat himself.", "predicament": "Something you cannot use yourself sits under your control, and the people who need it are waiting for you to let them have it.", "moral": null},
{"chunk": 34, "story": 34, "summary": "Oxen hauling a wagon rebuke the creaking axles for complaining when it is the oxen doing the work.", "predicament": "You carry the heaviest share of the work in silence, while someone who does far less complains loudly about the strain.", "moral": "Those who suffer most cry out the least."},
{"chunk": 35, "story": 35, "summary": "Cranes learn a farmer's sling is only for show and ignore it, until he loads it with stones and kills many.", "predicament": "Someone in charge has threatened you many times without following through, and you have started to ignore the threats.", "moral": "If words suffice not, blows must follow."},
{"chunk": 36, "story": 36, "summary": "An old lion feigns sickness and eats his visitors, until the fox notices tracks going in and none coming out.", "predicament": "A powerful person asks you to come in close to talk, and you notice that others who went in never came back.", "moral": "He is wise who is warned by the misfortunes of others."},
{"chunk": 37, "story": 37, "summary": "One traveller climbs a tree and leaves his friend to face a bear, and the friend later says the bear warned him against such companions.", "predicament": "When trouble struck, your companion saved themselves and left you to face it alone, and now they joke about it.", "moral": "Misfortune tests the sincerity of friends."},
{"chunk": 38, "story": 38, "summary": "A fox who lost his tail urges all the other foxes to cut theirs off, and one sees through him.", "predicament": "Someone who has suffered a loss urges everyone to give up the same thing, claiming it is an improvement for all.", "moral": null},
{"chunk": 39, "story": 39, "summary": "A cat accuses a cock of crowing at night to justify eating him, and eats him despite his defence.", "predicament": "Someone with power over you has already decided to be rid of you and is only looking for a respectable excuse.", "moral": null},
{"chunk": 40, "story": 40, "summary": "A wolf disguised in a sheep's skin is shut in the fold and killed by the shepherd who takes him for mutton.", "predicament": "You have disguised yourself as one of the harmless crowd to get what you want, and now you are treated exactly like them.", "moral": "Harm seek, harm find."},
{"chunk": 41, "story": 41, "summary": "A goatherd breaks a stray goat's horn with a stone and begs it not to tell his master, and the goat says the horn will speak.", "predicament": "You have damaged something in your care and ask a witness to keep quiet, but the damage itself will be plain to see.", "moral": "Do not attempt to hide things which cannot be hid."},
{"chunk": 42, "story": 42, "summary": "A returned traveller boasts of a great leap made abroad, and a bystander tells him to leap it here.", "predicament": "You have been boasting of a feat done far away with witnesses, and someone asks you to do it again right here.", "moral": null},
{"chunk": 43, "story": 43, "summary": "A woodcutter agrees to let a lion court his daughter if he gives up his teeth and claws, then drives him off.", "predicament": "Someone you fear demands something you cannot refuse outright, so you set a condition that will leave them harmless.", "moral": null},
{"chunk": 44, "story": 44, "summary": "A miser's buried gold is stolen, and a neighbour tells him a stone in the hole would serve him just as well.", "predicament": "Wealth you hoarded and never used has been taken from you, and someone points out you were no poorer before.", "moral": null}
]
@@ -0,0 +1,49 @@
[
{"chunk": 45, "story": 45, "summary": "A pig squeals when the shepherd seizes him, and tells the complaining sheep and goat that they are handled for wool and milk but he for his life.", "predicament": "Colleagues who are only ever asked for small things scold you for resisting, when what is demanded of you is everything.", "moral": null},
{"chunk": 46, "story": 46, "summary": "A boy grasps so many filberts that he cannot pull his hand out of the pitcher, and is told to take half.", "predicament": "You have grabbed so much at once that you cannot get any of it out, and letting go of half feels like losing.", "moral": "Do not attempt too much at once."},
{"chunk": 47, "story": 47, "summary": "Frogs ask Jupiter for a king, scorn the log and the eel he sends, and are given a heron who eats them.", "predicament": "Your group keeps complaining that its mild leader is too weak and demanding a stronger one from above.", "moral": null},
{"chunk": 48, "story": 48, "summary": "A father who maimed the snake that killed his son offers it peace, and the snake says neither can ever forget.", "predicament": "You and someone you hurt, and who hurt you far worse, both have reason to hate, and you now offer to make peace.", "moral": "No one truly forgets injuries in the presence of him who caused the injury."},
{"chunk": 49, "story": 49, "summary": "A lion woken by a mouse searches his den in fury, and tells a mocking fox it is the mouse's cheek, not the mouse, he resents.", "predicament": "Someone far beneath you has taken a small liberty, and a bystander mocks you for making so much of it.", "moral": "Little liberties are great offenses."},
{"chunk": 50, "story": 50, "summary": "A groom curries his horse all day but sells its oats, and the horse asks to be groomed less and fed more.", "predicament": "Someone responsible for you makes a great show of looking after you while quietly skimming off what you actually need.", "moral": null},
{"chunk": 51, "story": 51, "summary": "A mule refuses to take a little of the struggling ass's load, the ass dies, and the mule must carry all of it and the ass's hide.", "predicament": "A partner struggling under their share asks you to take a little of it, and you could say it is not your job.", "moral": null},
{"chunk": 52, "story": 52, "summary": "An ass envious of the petted lapdog bursts into the house to fawn on his master and is beaten back to the stable.", "predicament": "You work hard while a charming favourite is pampered for doing little, and you are tempted to try the favourite's tricks.", "moral": null},
{"chunk": 53, "story": 53, "summary": "Oxen plot to kill the butchers, but an old ox warns that clumsier ones would replace them.", "predicament": "Your group is set on getting rid of the people who harm it, and you suspect whoever replaces them will be worse.", "moral": "Do not be in a hurry to change one evil for another."},
{"chunk": 54, "story": 54, "summary": "A shepherd boy raises false alarms of a wolf for fun, and when the wolf truly comes nobody answers his cry.", "predicament": "You have raised false alarms before to amuse yourself, and now there is a real crisis and you need everyone to believe you.", "moral": "There is no believing a liar, even when he speaks the truth."},
{"chunk": 55, "story": 55, "summary": "Boys pelting frogs for fun are told by one frog that their sport is death to the frogs.", "predicament": "People above you are amusing themselves at your expense, and what is a game to them is ruin to you.", "moral": null},
{"chunk": 56, "story": 56, "summary": "An ass who lightened his salt load by falling in a stream tries the trick with sponges and doubles his burden.", "predicament": "A shortcut that once lightened your load has been noticed, and you are tempted to try it again.", "moral": null},
{"chunk": 57, "story": 57, "summary": "A biting dog made to wear a warning bell struts proudly with it until an old hound tells him it is a mark of disgrace.", "predicament": "You have been singled out with a mark you take for an honour, and an old hand tells you it is a public warning.", "moral": "Notoriety is often mistaken for fame."},
{"chunk": 58, "story": 58, "summary": "A goatherd feeds stray wild goats better than his own to win them, and they leave, reasoning he would do the same to them.", "predicament": "Someone courting you treats you better than the people who have served them for years, and you wonder what that means for you.", "moral": "Old friends cannot with impunity be sacrificed for new ones."},
{"chunk": 59, "story": 59, "summary": "A greying man courts a young and an old woman, who between them pluck out all his hair.", "predicament": "You are courting two parties who want opposite things from you, and each is quietly remaking you to suit themselves.", "moral": "Those who seek to please everybody please nobody."},
{"chunk": 60, "story": 60, "summary": "A sick stag's many visitors each eat some of his food, so he dies of want rather than illness.", "predicament": "While you are laid low, a stream of well-wishers visits, and each helps themselves to a little of what you need.", "moral": "Evil companions bring more hurt than profit."},
{"chunk": 61, "story": 61, "summary": "A boy stung after touching a nettle gently is told by his mother to grasp it boldly next time.", "predicament": "You approached a prickly problem timidly and got hurt, and someone tells you only a bold, firm grip will work.", "moral": "Whatever you do, do with all your might."},
{"chunk": 62, "story": 62, "summary": "An astronomer gazing at the stars falls into a well, and a neighbour asks why he cannot see what is on earth.", "predicament": "You have been so absorbed in lofty plans that you have fallen into trouble right in front of you, and must call for help.", "moral": null},
{"chunk": 63, "story": 63, "summary": "Wolves persuade the sheep to send away their guard dogs for peace, and then devour the unguarded flock.", "predicament": "Your enemies offer peace if only you dismiss the people who protect you, claiming those protectors are the cause of all trouble.", "moral": null},
{"chunk": 64, "story": 64, "summary": "A cat disguised as a doctor offers to treat ailing birds, who say they will stay well if he goes away.", "predicament": "Someone known to prey on people like you arrives dressed as a helper, offering to fix your troubles if you let them in.", "moral": null},
{"chunk": 65, "story": 65, "summary": "A jackdaw decks himself in others' fallen feathers to be chosen king, and the birds pluck them back.", "predicament": "You are presenting yourself for a top post decked out in achievements borrowed from colleagues, who are about to see it.", "moral": null},
{"chunk": 66, "story": 66, "summary": "A kid on a rooftop taunts a passing wolf, who says it is the roof that mocks him.", "predicament": "You are safely out of reach of someone powerful for the moment, and it is tempting to taunt them while you can.", "moral": "Time and place often give the advantage to the weak over the strong."},
{"chunk": 67, "story": 67, "summary": "A doctor robs a blind old woman while treating her, and when he sues for his fee she says she still cannot see her goods.", "predicament": "Someone you hired has been quietly robbing you while doing the job, and now demands full payment under your agreement.", "moral": null},
{"chunk": 68, "story": 68, "summary": "A mother frog puffs herself up to match the ox that killed her son, and is told she would burst first.", "predicament": "Someone far bigger has crushed one of yours, and you are tempted to puff yourself up to look their equal.", "moral": null},
{"chunk": 69, "story": 69, "summary": "A dying farmer tells his sons treasure lies in the vineyard, and their digging brings a great harvest.", "predicament": "You want those who will take over from you to work as hard as you did, and plain advice will not move them.", "moral": null},
{"chunk": 70, "story": 70, "summary": "A heifer mocks a working ox for his labour and is then led off to be sacrificed while the ox is freed.", "predicament": "An idle favourite mocks you for how hard you work, not seeing why they have been kept free of work.", "moral": null},
{"chunk": 71, "story": 71, "summary": "A victorious cock crows on a wall and is carried off by an eagle, leaving the beaten cock to rule.", "predicament": "You have just beaten your rival, and it is tempting to boast about it from the most exposed place you can find.", "moral": "Pride goes before destruction."},
{"chunk": 72, "story": 72, "summary": "An old warhorse sent to work a mill laments his former glory, and the miller tells him fortune rises and falls.", "predicament": "Age has moved you from a proud post to drudgery, and you keep bemoaning what you had to whoever will listen.", "moral": null},
{"chunk": 73, "story": 73, "summary": "A jealous fox leads the newly elected monkey king into a trap, then mocks him as unfit to rule.", "predicament": "You have just been raised to a high post, and a jealous colleague brings you a tempting windfall they say they kept for you.", "moral": null},
{"chunk": 74, "story": 74, "summary": "A soldier pampers his horse in war, drudges it in peace, and finds it cannot carry him when war returns.", "predicament": "Someone valued you highly in a crisis, worked you into the ground when it passed, and now needs you at your best again.", "moral": null},
{"chunk": 75, "story": 75, "summary": "The limbs rebel against the idle-seeming belly and stop feeding it, and the whole body weakens.", "predicament": "You and your fellow workers resent a part of the organisation that seems to do nothing, and are tempted to stop supplying it.", "moral": null},
{"chunk": 76, "story": 76, "summary": "Maids kill the cock that wakes their mistress early, and she wakes them even earlier.", "predicament": "Your boss works you too early and too long, and you could get rid of the thing that sets the pace.", "moral": null},
{"chunk": 77, "story": 77, "summary": "A vine cropped by a goat says she will provide the wine poured over him at his sacrifice.", "predicament": "Someone is stripping you bare without cause, and you know that in time you will have a hand in their downfall.", "moral": null},
{"chunk": 78, "story": 78, "summary": "A monkey presents her ugly baby for the beauty prize and, laughed at, says he is the most beautiful to her.", "predicament": "You have put forward work of your own that everyone laughs at, and you are sure it is the best there is.", "moral": null},
{"chunk": 79, "story": 79, "summary": "Pigeons invite a hawk to protect them from a kite, and he kills more of them in a day than the kite did in a year.", "predicament": "Threatened by one predator, your group is ready to invite a powerful protector inside who may prove far worse.", "moral": "Avoid a remedy that is worse than the disease."},
{"chunk": 80, "story": 80, "summary": "A sprat offers to reconcile warring dolphins and whales, who say they would rather be destroyed than accept him.", "predicament": "Two powerful factions are at war, and you, a nobody, offer to step in and settle their quarrel.", "moral": null},
{"chunk": 81, "story": 81, "summary": "A swallow nests in the wall of a court of justice, and her young are eaten by a serpent there.", "predicament": "You trusted the one place meant to protect everyone's rights, and it is exactly there that you have been wronged.", "moral": null},
{"chunk": 82, "story": 82, "summary": "An earthen pot floating downstream begs a brass pot to keep away, since any touch would break her.", "predicament": "Someone much stronger than you wants to travel alongside you, and even friendly contact could break you.", "moral": "Equals make the best friends."},
{"chunk": 83, "story": 83, "summary": "A shepherd raises a wolf cub to steal lambs from neighbours, and it warns him to watch his own flock.", "predicament": "You have trained someone to take advantage of others for you, and they have just hinted they could do the same to you.", "moral": null},
{"chunk": 84, "story": 84, "summary": "A crab tells her son to walk straight, and he asks her to show him how.", "predicament": "You are correcting a junior for a fault you plainly share, and they ask you to demonstrate the right way.", "moral": "Example is more powerful than precept."},
{"chunk": 85, "story": 85, "summary": "A father's two daughters, married to a gardener and a tile-maker, wish for rain and for sun, and he cannot back both.", "predicament": "Two people you care for equally need opposite outcomes, and whichever you support you will be working against the other.", "moral": null},
{"chunk": 86, "story": 86, "summary": "A mother praises her son's first small thefts, and on his way to execution he bites her ear for not stopping him.", "predicament": "Someone in your charge has brought you a small ill-gotten gain, and you could praise it or stop it now.", "moral": null},
{"chunk": 87, "story": 87, "summary": "A weary old woodcutter calls for Death, and when Death comes asks only for help lifting his load.", "predicament": "Worn out, you have loudly wished to be done with it all, and now the chance to quit is actually offered.", "moral": null},
{"chunk": 88, "story": 88, "summary": "A fir scorns a useless bramble, which reminds it of the axes waiting to fell the useful.", "predicament": "Someone important sneers that you are of no use, and you know their usefulness is exactly what marks them to be cut down.", "moral": "Better poverty without care, than riches with."},
{"chunk": 89, "story": 89, "summary": "A buyer of a black servant, thinking his colour is dirt, scrubs him incessantly and only gives him a cold.", "predicament": "You have taken someone on convinced that their nature is a fault you can scrub away, and your efforts are only hurting them.", "moral": "What’s bred in the bone will stick to the flesh."},
{"chunk": 90, "story": 90, "summary": "A frog ties a mouse's foot to his own and drowns him, and a hawk carries off both.", "predicament": "A new acquaintance proposes binding your fortunes tightly together, and you would be on their ground, not yours.", "moral": "Harm hatch, harm catch."},
{"chunk": 91, "story": 91, "summary": "A fisherman's net holds the large fish, but the small ones slip back through the meshes.", "predicament": "A sweep that catches the big players lets the small ones slip away, and you are either doing the sweeping or being swept.", "moral": null}
]
@@ -0,0 +1,48 @@
[
{"chunk": 92, "story": 92, "summary": "A wounded wolf asks a passing sheep to fetch him water, and the sheep sees he would make her the meat as well.", "predicament": "Someone known to prey on people like you is laid up and asks you for a small favour that would bring you within reach.", "moral": "Hypocritical speeches are easily seen through."},
{"chunk": 93, "story": 93, "summary": "An old woman sniffs an empty wine jar and praises how fine the wine must have been.", "predicament": "Only the faint trace of someone's good work is left behind, and you find yourself still admiring what it must have been.", "moral": "The memory of a good deed lives."},
{"chunk": 94, "story": 94, "summary": "A man bitten by a dog is advised to feed the dog bread dipped in his blood, and says that would invite every dog to bite him.", "predicament": "Someone who hurt you could be soothed with a reward, and you are advised that appeasing them is the cure.", "moral": "Benefits bestowed upon the evil-disposed increase their means of injuring you."},
{"chunk": 95, "story": 95, "summary": "A huntsman and a fisherman swap their catches daily until a neighbour warns that habit will spoil the pleasure.", "predicament": "An exchange with a partner has pleased you both so much that you repeat it daily, and someone warns it will soon go stale.", "moral": "Abstain and enjoy."},
{"chunk": 96, "story": 96, "summary": "A fox flatters a crow's beauty and doubts her voice, so she caws and drops her meat to him.", "predicament": "Someone who wants what you hold praises you lavishly, then hints at one doubt you would have to prove wrong by speaking up.", "moral": null},
{"chunk": 97, "story": 97, "summary": "A poor widow shears her sheep herself to save the fee and cuts his flesh with the wool.", "predicament": "To save an expert's fee you are doing a skilled job yourself, and you are hurting the thing you meant to profit from.", "moral": "The least outlay is not always the greatest gain."},
{"chunk": 98, "story": 98, "summary": "An ass dances on the roof and is beaten, protesting that everyone laughed when the monkey did it.", "predicament": "You copied a favourite's antics that everyone applauded, and are being punished for exactly what they were praised for.", "moral": null},
{"chunk": 99, "story": 99, "summary": "A hunted stag hides in an ox-stall, escapes the servants' notice, and is found by the sharp-eyed master.", "predicament": "You are hiding from trouble in a place you should not be, the careless have passed you by, and the sharp-eyed boss is coming.", "moral": null},
{"chunk": 100, "story": 100, "summary": "A hound complains that the housedog feasts on his catch, and the housedog tells him to blame the master who taught him so.", "predicament": "You do the hard work while a colleague enjoys its rewards, and they say the fault lies with the boss who arranged it.", "moral": "Children are not to be blamed for the faults of their parents."},
{"chunk": 101, "story": 101, "summary": "A lion and a wild ass hunt together, and the lion takes all three shares by rank, partnership and threat.", "predicament": "You went into a venture with a far more powerful partner, and now they are dividing the proceeds entirely their own way.", "moral": "Might makes right."},
{"chunk": 102, "story": 102, "summary": "A lion allies with a dolphin, and when the dolphin cannot come ashore to help him fight, calls him a traitor.", "predicament": "An impressive ally from another field swore friendship with you, and now you need help they have no means to give.", "moral": null},
{"chunk": 103, "story": 103, "summary": "An eagle is mortally shot with an arrow fledged with his own feathers.", "predicament": "You are being brought down by a rival who is using the very tools, ideas or people you supplied them.", "moral": null},
{"chunk": 104, "story": 104, "summary": "A dying kite asks his mother to pray for him, and she asks which god he has not robbed.", "predicament": "In your hour of need you look for someone to speak up for you, and realise you have cheated everyone who could.", "moral": "We must make friends in prosperity if we would have their help in adversity."},
{"chunk": 105, "story": 105, "summary": "A lion and a boar fighting over a well see vultures waiting and make peace.", "predicament": "You and a rival are fighting over something small, and you notice others waiting to pick over whoever loses.", "moral": null},
{"chunk": 106, "story": 106, "summary": "The mice agree to bell the cat, but none will volunteer to do it.", "predicament": "Everyone agrees a clever plan to deal with the danger, and the only question left is who will risk carrying it out.", "moral": null},
{"chunk": 107, "story": 107, "summary": "A one-eyed doe watches the land and is shot from the sea she thought safe.", "predicament": "You have guarded carefully against the threat you expect, and turned your back entirely on the side you assume is safe.", "moral": null},
{"chunk": 108, "story": 108, "summary": "Mice appoint generals who wear conspicuous plumes, and in the rout the generals cannot fit into their holes and are eaten.", "predicament": "You have been promoted and given marks of rank, and when things go wrong those marks may be exactly what trap you.", "moral": "The more honour the more danger."},
{"chunk": 109, "story": 109, "summary": "A shepherd sells his flock to trade dates by sea, loses all in a storm, and later sneers that the calm sea wants more dates.", "predicament": "A calm opening tempts you to sell what you have and stake it all on a trade you do not know.", "moral": null},
{"chunk": 110, "story": 110, "summary": "An ass sees a lion flee at a cock's crow, chases the lion himself, and is torn apart.", "predicament": "You saw someone powerful back down before another person, and are tempted to think they will back down before you too.", "moral": "False confidence often leads into danger."},
{"chunk": 111, "story": 111, "summary": "The rivers complain the sea makes them salty, and the sea tells them to stop flowing into it.", "predicament": "People who keep choosing to join you complain that you change them, and are trying to lay the blame on you.", "moral": null},
{"chunk": 112, "story": 112, "summary": "A boar whets his tusks with no enemy in sight, saying there is no time to sharpen them once attacked.", "predicament": "Things are quiet with no threat in sight, and someone asks why you are spending effort preparing for trouble.", "moral": "To be well prepared for war is the best guarantee of peace."},
{"chunk": 113, "story": 113, "summary": "A milkmaid counts the future profits of her milk, tosses her head at imagined suitors, and spills the pail.", "predicament": "You are carrying your only real asset while already spending, in your head, everything you hope it will turn into.", "moral": null},
{"chunk": 114, "story": 114, "summary": "A bee asks Jupiter for a deadly sting, and gets one that kills her if she uses it.", "predicament": "A powerful patron has promised you any favour, and you are tempted to ask for the power to punish anyone touching what is yours.", "moral": "Evil wishes, like chickens, come home to roost."},
{"chunk": 115, "story": 115, "summary": "A lean wolf admires a well-fed dog until he sees the heavy collar and log, and wants none of it.", "predicament": "A well-fed insider shows you how comfortably their employer keeps them, and you notice the chain they wear for it.", "moral": null},
{"chunk": 116, "story": 116, "summary": "A besieged city asks how to defend itself, and the bricklayer, carpenter and currier each propose their own trade.", "predicament": "In a crisis everyone is asked for the best solution, and each expert recommends exactly what they happen to sell.", "moral": "Every man for himself."},
{"chunk": 117, "story": 117, "summary": "An ass carrying a sacred image thinks the crowd bows to him and balks, until the driver's whip corrects him.", "predicament": "People bow as you pass, and you are tempted to believe it is for you rather than for what you happen to carry.", "moral": "They are not wise who give to themselves the credit due to others."},
{"chunk": 118, "story": 118, "summary": "A storm-bound man kills his sheep, goats and then his oxen, and his dogs flee reasoning they are next.", "predicament": "In hard times your employer has sacrificed even the people who earn for them, and you wonder how long before it is you.", "moral": "He is not to be trusted as a friend who mistreats his own family."},
{"chunk": 119, "story": 119, "summary": "An old hound loses his grip on a boar and, abused by his master, asks to be praised for what he was.", "predicament": "Age has cost you the ability you were known for, and your boss is berating you for failing at it.", "moral": null},
{"chunk": 120, "story": 120, "summary": "A traveller says 'I' found the axe but 'we' are undone when the owner comes, and his companion holds him to 'I'.", "predicament": "A companion kept a find to themselves, and now that it has brought trouble they want you to share the blame.", "moral": "He who shares the danger ought to share the prize."},
{"chunk": 121, "story": 121, "summary": "A dying old lion is struck by the boar and the bull, and then kicked by the ass.", "predicament": "Once powerful, you are now weak, and even the least of those you once ruled are lining up to strike at you.", "moral": null},
{"chunk": 122, "story": 122, "summary": "A wolf sees shepherds eating mutton and remarks on the outcry there would be if he did the same.", "predicament": "The people who condemn you for something are openly doing the very same thing themselves.", "moral": null},
{"chunk": 123, "story": 123, "summary": "Travellers wait for what they think is a ship, and it turns out to be a bundle of sticks.", "predicament": "You have waited a long while for something that looked grand from far away, and it is shrinking as it comes closer.", "moral": "Our mere anticipations of life outrun its realities."},
{"chunk": 124, "story": 124, "summary": "A traveller and the ass's owner fight over who hired the ass's shadow, and the ass runs off.", "predicament": "You and a client are locked in a bitter dispute over a technicality while the thing you both needed slips away.", "moral": "In quarreling about the shadow we often lose the substance."},
{"chunk": 125, "story": 125, "summary": "An overworked ass keeps asking for a new master and each is worse, ending with a tanner.", "predicament": "You are unhappy with your boss and could ask for a transfer, though you have asked before and each move was worse.", "moral": null},
{"chunk": 126, "story": 126, "summary": "Mercury in disguise asks a sculptor the price of his own statue, expecting it to be highest, and is offered it free.", "predicament": "You go incognito to find out how highly people rate you, expecting to hear you are the most valued of all.", "moral": null},
{"chunk": 127, "story": 127, "summary": "A woodcutter hides a fox but points to it while denying it, and the fox leaves without thanks.", "predicament": "Someone claims you owe them for protecting you, when their words protected you and their gestures quietly gave you away.", "moral": null},
{"chunk": 128, "story": 128, "summary": "An uprooted oak asks the reeds how they survive the wind, and they say they bend rather than fight.", "predicament": "A force far stronger than you is sweeping through, and you could stand rigid against it or bend until it passes.", "moral": "Stoop to conquer."},
{"chunk": 129, "story": 129, "summary": "A farmer shuts a lion in his farmyard, loses his flocks, and has to let it go.", "predicament": "You have a chance to trap someone far more dangerous than you, but you would be locked in with them.", "moral": null},
{"chunk": 130, "story": 130, "summary": "A lion takes a stolen lamb from a wolf, who protests the injustice, and the lion jeers at his claim.", "predicament": "Someone stronger has taken from you something you had taken dishonestly yourself, and you want to protest.", "moral": null},
{"chunk": 131, "story": 131, "summary": "A birdcatcher spares his decoy partridge and his cock after each pleads usefulness, but still must feed his guest.", "predicament": "Your boss needs to cut someone and you plead how useful you are, but the need is pressing and must be met somehow.", "moral": "Necessity knows no law."},
{"chunk": 132, "story": 132, "summary": "A dove saves a drowning ant with a leaf, and the ant later stings a birdcatcher to save the dove.", "predicament": "Someone you once helped for nothing sees a threat to you coming that you cannot see, and has a chance to repay you.", "moral": null},
{"chunk": 133, "story": 133, "summary": "Despairing hares rush to drown themselves, see frogs flee from them, and take heart.", "predicament": "You feel so weak and harried that you are ready to give up entirely, and then notice others who fear even you.", "moral": null},
{"chunk": 134, "story": 134, "summary": "A monkey copies the fishermen's netting, gets tangled and drowns.", "predicament": "You have watched experts do a job and are about to try it yourself with their tools, having never learned how.", "moral": null},
{"chunk": 135, "story": 135, "summary": "A swan seized by mistake for the goose sings and is known and spared.", "predicament": "In the dark you have been mistaken for someone marked to be cut, and only showing your one real talent will set it right.", "moral": null},
{"chunk": 136, "story": 136, "summary": "A doe escaping hunters takes refuge in a lion's cave and is killed there.", "predicament": "Fleeing one danger, you are about to take shelter with someone who may be far more dangerous than what chases you.", "moral": "In avoiding one evil, care must be taken not to fall into another."},
{"chunk": 137, "story": 137, "summary": "A fisherman refuses to release a small fish that promises to be worth more when grown.", "predicament": "Someone you have caught asks you to let them go now, promising you a much bigger return later.", "moral": null}
]
@@ -0,0 +1,49 @@
[
{"chunk": 138, "story": 138, "summary": "A timid hunter asks a woodman for the lion's tracks and, offered the lion itself, says he only wanted the tracks.", "predicament": "You have talked boldly about tackling a formidable opponent, and someone offers to take you to face them right now.", "moral": "The hero is brave in deeds as well as words."},
{"chunk": 139, "story": 139, "summary": "A fox gorges on food left in a hollow tree and is too swollen to get out.", "predicament": "You helped yourself so freely to what was lying unguarded that you can no longer get out of the position it put you in.", "moral": null},
{"chunk": 140, "story": 140, "summary": "Two frogs seeking a new home find a deep well, and one asks how they would get out if it dried up.", "predicament": "You and a partner must move, and an attractive opening has appeared that would be very hard to leave if it failed.", "moral": "Do nothing without a regard to the consequences."},
{"chunk": 141, "story": 141, "summary": "A flaring lamp boasts it outshines the sun and is blown out by a gust.", "predicament": "You are shining brightly just now and are tempted to claim you outshine the people who truly lead.", "moral": null},
{"chunk": 142, "story": 142, "summary": "A camel-driver asks his camel whether it would rather go uphill or down, and the camel asks whether the level road is closed.", "predicament": "Your boss offers you a choice between two hard assignments, as if the easy option you both know exists were not there.", "moral": null},
{"chunk": 143, "story": 143, "summary": "A miller and his son change how they travel with their ass at every passer-by's criticism until they lose the ass in the river.", "predicament": "Every passer-by criticises how you are doing the job, and each time you change it to please them, a new critic appears.", "moral": null},
{"chunk": 144, "story": 144, "summary": "A cat pretends to be dead on a peg to lure the mice, and one says it will not come near her even if she turns into a sack.", "predicament": "Someone who has already done you harm now appears harmless, even lifeless, and is plainly waiting for you to come out.", "moral": null},
{"chunk": 145, "story": 145, "summary": "A mouse bites a bull, hides in its hole, and bites him again while he sleeps.", "predicament": "Someone powerful is furious with you but cannot reach you where you are safe, and you could sting them again.", "moral": null},
{"chunk": 146, "story": 146, "summary": "A dog invited by a friend to his master's feast is thrown out by the cook and pretends he was too drunk to remember.", "predicament": "An insider invited you to help yourself at their employer's expense, and you were thrown out and must explain it to your peers.", "moral": null},
{"chunk": 147, "story": 147, "summary": "Thieves steal a cock who pleads he wakes men to work, and they say that is why he must die.", "predicament": "You plead your usefulness to people you have fallen among, not seeing that your usefulness is exactly what they cannot stand.", "moral": "The safeguards of virtue are hateful to those with evil intentions."},
{"chunk": 148, "story": 148, "summary": "Trained monkeys dance like courtiers until someone throws nuts and they brawl for them.", "predicament": "Your team has learned to perform with polish, and a rival is about to toss something in front of them to expose them.", "moral": null},
{"chunk": 149, "story": 149, "summary": "A farmer takes revenge on a fox by setting its tail alight, and it runs through his own wheat.", "predicament": "You finally have the person who has been robbing you at your mercy, and your revenge could easily burn your own harvest.", "moral": null},
{"chunk": 150, "story": 150, "summary": "Fortune wakes a traveller asleep on a well's brink, lest she be blamed for his fall.", "predicament": "You are careless in a dangerous spot, and if you come to grief you will blame bad luck rather than yourself.", "moral": "Everyone is more or less master of his own fate."},
{"chunk": 151, "story": 151, "summary": "A seagull bursts its gullet on too large a fish, and a kite says a bird of the air should not feed from the sea.", "predicament": "You have taken on far more than you can manage in a field that is not yours, and it is tearing you apart.", "moral": "Every man should be content to mind his own business."},
{"chunk": 152, "story": 152, "summary": "A lion and a bear fight over a kid until both are exhausted, and a fox runs off with it.", "predicament": "You and a powerful rival are exhausting each other over one prize, while someone else circles waiting to walk off with it.", "moral": "It sometimes happens that one man has all the toil, and another all the profit."},
{"chunk": 153, "story": 153, "summary": "A philosopher decrying Providence for drowning the innocent with the guilty tramples an anthill for one sting.", "predicament": "You are loudly condemning those above for punishing many for one person's fault, and you have just done the same to those below.", "moral": null},
{"chunk": 154, "story": 154, "summary": "A peasant frees an eagle, which later snatches his bundle to lure him from under a wall that then falls.", "predicament": "Someone you once freed from a trap suddenly snatches something of yours, and you are angry without knowing why they did it.", "moral": null},
{"chunk": 155, "story": 155, "summary": "A leopard and a fox dispute their beauty, and the fox says his is in the mind.", "predicament": "A colleague shows off their visible credentials one by one to prove they are the better, and you have only your wits.", "moral": null},
{"chunk": 156, "story": 156, "summary": "A lion leaves a sleeping hare to chase a hart, loses the hart, and returns to find the hare gone.", "predicament": "You have an easy win within reach, and a bigger, faster prize has just run past that you could chase instead.", "moral": null},
{"chunk": 157, "story": 157, "summary": "A poor carpenter smashes the idol that never helped him, and gold pours out of its head.", "predicament": "You have courted a patron respectfully for years with nothing to show, and anger tempts you to smash the relationship.", "moral": null},
{"chunk": 158, "story": 158, "summary": "A lion eats the ass who divided their spoil equally, and the fox, having learned, leaves himself a morsel.", "predicament": "A powerful partner asks you to divide the spoils, and you have just seen what happened to the last one who divided them fairly.", "moral": "Happy is the man who learns from the misfortunes of others."},
{"chunk": 159, "story": 159, "summary": "A bull hiding from a lion in a cave is butted by a goat, and says he will answer once the lion is gone.", "predicament": "While you are sheltering from a bigger threat, someone lesser takes the chance to attack you, knowing you cannot answer yet.", "moral": "It shows an evil disposition to take advantage of a friend in distress."},
{"chunk": 160, "story": 160, "summary": "A bald knight's wig blows off during a hunt, and he joins the laughter with a joke.", "predicament": "Something you were hiding about yourself has just been exposed in front of your companions, and everyone is laughing.", "moral": null},
{"chunk": 161, "story": 161, "summary": "The oaks complain they are always felled, and Jupiter says it is because they are so useful.", "predicament": "You complain that you are always picked for the hardest jobs, and are told it is because you are so good at them.", "moral": null},
{"chunk": 162, "story": 162, "summary": "A monkey mother smothers her favourite twin with love while the neglected one thrives.", "predicament": "You lavish all your care on one favourite protege and neglect another, and your attention may be what harms the favourite.", "moral": "The best intentions will not always ensure success."},
{"chunk": 163, "story": 163, "summary": "A hound gives up chasing a hare and, mocked, says he ran for a dinner but the hare for his life.", "predicament": "Someone mocks you for losing to a weaker rival, but they were fighting for everything and you only for a little.", "moral": null},
{"chunk": 164, "story": 164, "summary": "A shepherd is about to shut a wolf in the fold, and his dog asks how the sheep can then be safe.", "predicament": "Your boss is about to bring a known predator into the team, and you are the one placed to say so.", "moral": null},
{"chunk": 165, "story": 165, "summary": "An oak split by wedges cut from its own branches grieves more at them than at the axe.", "predicament": "You are being broken apart by people who came from your own side, and it hurts more than any outside attack.", "moral": "Misfortunes springing from ourselves are the hardest to bear."},
{"chunk": 166, "story": 166, "summary": "A snake tormented by a wasp puts its head under a wagon wheel so they die together.", "predicament": "Someone is tormenting you without pause and you cannot shake them, and you could take them down with you.", "moral": null},
{"chunk": 167, "story": 167, "summary": "A peacock mocks a plain crane's plumage, and the crane says he flies to the heavens while the peacock walks the dunghill.", "predicament": "A showy colleague mocks your plain appearance in front of others, and you could answer with what you can actually do.", "moral": "Fine feathers don’t make fine birds."},
{"chunk": 168, "story": 168, "summary": "A couple kill their hen that lays golden eggs to get the gold inside and find nothing.", "predicament": "Something steadily brings you a small, sure return, and you are tempted to break it open to get everything at once.", "moral": null},
{"chunk": 169, "story": 169, "summary": "An ass who falls in a pond groans, and the frogs ask what he would do if he lived there always.", "predicament": "You are making a great fuss about a small setback in front of people who live with far worse every day.", "moral": "Men often bear little grievances with less courage than they do large misfortunes."},
{"chunk": 170, "story": 170, "summary": "A crow jealous of the raven's standing as an omen caws at travellers, who dismiss her cry.", "predicament": "Envying a colleague whose word carries weight, you are tempted to speak up as if yours carried the same.", "moral": "Those who assume a character which does not belong to them, only make themselves ridiculous."},
{"chunk": 171, "story": 171, "summary": "The trees give a man a young ash for his axe handle, and he fells the great trees with it.", "predicament": "Someone asks your group to give up just one small, junior member, and that concession is what they need to cut down the rest.", "moral": null},
{"chunk": 172, "story": 172, "summary": "Wolves urge the sheepdogs to betray the flock and live as brothers, and then tear the dogs apart.", "predicament": "Rivals flatter you as one of their own and urge you to hand over what you guard and share the spoils with them.", "moral": null},
{"chunk": 173, "story": 173, "summary": "A lioness mourns her cub killed by a bull, and a hunter reminds her how many she has made mourn.", "predicament": "You are loudly grieving a loss, and someone reminds you how many people have suffered the same at your hands.", "moral": null},
{"chunk": 174, "story": 174, "summary": "A lion wounded by a bowman's arrow flees, saying he dare not face the man if that is his messenger.", "predicament": "An opponent's first warning shot has already hurt you badly, and an adviser urges you not to give up at the first blow.", "moral": "Be on guard against men who can strike from a distance."},
{"chunk": 175, "story": 175, "summary": "Men first flee the huge camel, then, finding it meek, bridle it and let a child drive it.", "predicament": "People who once feared you have noticed you never push back, and are growing bolder by the day.", "moral": "Use serves to overcome dread."},
{"chunk": 176, "story": 176, "summary": "A crab that moved inland to feed is eaten by a fox and blames himself for leaving the sea.", "predicament": "You left the field you are made for to chase better prospects elsewhere, and now you are exposed and out of your depth.", "moral": "Contentment with our lot is an element of happiness."},
{"chunk": 177, "story": 177, "summary": "An ass refuses to flee the enemy with his master, since any master will load him the same.", "predicament": "Your boss urges you to fight off a takeover, and you suspect any new owner would work you exactly the same.", "moral": "In a change of government the poor change nothing beyond the name of their master."},
{"chunk": 178, "story": 178, "summary": "A wounded fox refuses to have the sated flies driven off, lest hungrier ones replace them.", "predicament": "Someone offers to rid you of the people draining you, but those ones are already well fed and hungrier ones would follow.", "moral": null},
{"chunk": 179, "story": 179, "summary": "A woman doubles her hen's feed to get two eggs a day, and it grows fat and stops laying.", "predicament": "A reliable source gives you a steady return, and you are thinking of pushing extra into it to double what it produces.", "moral": null},
{"chunk": 180, "story": 180, "summary": "Kites that could once sing try to neigh like horses and lose their song.", "predicament": "You are so taken with a colleague's style that you are trying to copy it, and your own gift is starting to fade.", "moral": "The desire for imaginary benefits often involves the loss of present blessings."},
{"chunk": 181, "story": 181, "summary": "A hound alternately bites and fawns on a hare, who asks him to show his true colours.", "predicament": "Someone alternately attacks you and flatters you, and you cannot tell whether to treat them as friend or enemy.", "moral": "No one can be a friend if you know not whether to trust or distrust him."},
{"chunk": 182, "story": 182, "summary": "Hares at war with eagles ask the foxes for help, who decline knowing both sides.", "predicament": "Someone weak who has picked a fight with the powerful asks you to join their side.", "moral": "Count the cost before you commit yourselves."},
{"chunk": 183, "story": 183, "summary": "A calf offers to show a bull the way through a narrow passage, and the bull says he knew it before the calf was born.", "predicament": "A newcomer offers to show you how to do something you have been doing since before they arrived.", "moral": null},
{"chunk": 184, "story": 184, "summary": "A stag asks a sheep for a loan with the wolf as surety, and the sheep declines, doubting she could ever collect.", "predicament": "Someone asks you for a loan and offers as guarantor another person well known for taking what they want and vanishing.", "moral": "Two blacks do not make one white."}
]
@@ -0,0 +1,45 @@
[
{"chunk": 185, "story": 185, "summary": "A cat tells an eagle and a sow each that the other plans to kill her young, and both starve in fear while the cat feeds.", "predicament": "A colleague privately warns you that your neighbour is plotting against you, and you suspect they said the same to your neighbour.", "moral": null},
{"chunk": 186, "story": 186, "summary": "A huge wolf nicknamed 'Lion' leaves the wolves to live among lions, and a fox mocks his conceit.", "predicament": "Your peers jokingly call you the equal of those at the top, and you are tempted to leave them and move among the top.", "moral": null},
{"chunk": 187, "story": 187, "summary": "An idle, overfed mule boasts he is a racer's son, and after a long hard journey decides his father was an ass.", "predicament": "After an easy spell you have been boasting of your pedigree and speed, and tomorrow brings a long, hard assignment.", "moral": null},
{"chunk": 188, "story": 188, "summary": "A fortune-teller runs home on hearing his house is being robbed, and a neighbour asks why he did not foresee it.", "predicament": "You make your living advising others on what is coming, and a disaster has just struck you that you failed to foresee.", "moral": null},
{"chunk": 189, "story": 189, "summary": "A frog refuses a friend's invitation to leave his exposed gully for a safe pond and is crushed by a wagon.", "predicament": "A friend urges you to leave your familiar but exposed position for a safer one, and you cannot bring yourself to move.", "moral": "A willful man will have his way to his own hurt."},
{"chunk": 190, "story": 190, "summary": "A countryman frees an eagle from a serpent, and the eagle later knocks away the drink the serpent poisoned.", "predicament": "You stepped in to rescue someone from a dangerous enemy, and that enemy has quietly set a trap for you in return.", "moral": null},
{"chunk": 191, "story": 191, "summary": "A thirsty crow raises the water in a pitcher by dropping in stones one by one.", "predicament": "What you urgently need is just out of reach, and none of the obvious approaches has worked.", "moral": "Necessity is the mother of invention."},
{"chunk": 192, "story": 192, "summary": "A thief frightens an innkeeper with a tale that he turns into a wolf, and makes off with his coat.", "predicament": "A stranger tells you an alarming story and urges you to act at once, and the action would leave your goods in their hands.", "moral": "Every tale is not to be believed."},
{"chunk": 193, "story": 193, "summary": "A hart hidden under a vine eats its leaves once the hunters pass, and is spotted and shot.", "predicament": "Someone sheltered you when you were in danger, and now that the danger seems past you are tempted to help yourself to their things.", "moral": null},
{"chunk": 194, "story": 194, "summary": "A gnat stings a lion into tearing his own face, then is caught and eaten by a spider.", "predicament": "You have just beaten a powerful opponent and are celebrating loudly, not watching for small, patient dangers.", "moral": null},
{"chunk": 195, "story": 195, "summary": "A fox that cannot reach the grapes walks away saying they are sour.", "predicament": "You tried everything to get something you wanted and failed, and people are watching to see how you take it.", "moral": null},
{"chunk": 196, "story": 196, "summary": "A roadside walnut tree is beaten with sticks by passers-by who want its nuts.", "predicament": "The more you give, the more roughly people treat you to get more, and those you feed are the ones doing the damage.", "moral": null},
{"chunk": 197, "story": 197, "summary": "A kid caught by a wolf asks him to pipe a last dance, and the music brings hounds that chase off the wolf.", "predicament": "Someone who has you cornered is enjoying their power, and you could ask for one small favour that might draw help.", "moral": null},
{"chunk": 198, "story": 198, "summary": "A dolphin carrying a monkey from a wreck drowns him when he lies about his noble birth and 'knowing' the Piraeus.", "predicament": "Someone is helping you because they think you are one of their own, and asks questions you can only answer by lying.", "moral": null},
{"chunk": 199, "story": 199, "summary": "A horse accepts a man's bit and saddle to get revenge on a stag, and is enslaved for good.", "predicament": "To get back at a newcomer who shares your territory, someone powerful offers help if you accept their harness.", "moral": null},
{"chunk": 200, "story": 200, "summary": "A jackdaw painted white lives among doves until he chatters, and is then rejected by both doves and jackdaws.", "predicament": "You have disguised yourself as one of a better-fed group to share their benefits, and must keep silent to pass.", "moral": null},
{"chunk": 201, "story": 201, "summary": "A monkey claims a cemetery's monuments honour his ancestors, and a fox notes no ancestor can contradict him.", "predicament": "You are inflating your background to a companion, and they have noticed that nobody who could contradict you is around.", "moral": "A false tale often betrays itself."},
{"chunk": 202, "story": 202, "summary": "A man sends his disliked wife to her father's house, and her report of the herdsmen's looks confirms her effect on everyone.", "predicament": "Someone you are responsible for is disliked by everyone around them, and you want to learn whether it is them or the place.", "moral": "Straws show how the wind blows."},
{"chunk": 203, "story": 203, "summary": "A horse, ox and dog repay a man's shelter by giving the stages of his life their own tempers.", "predicament": "People you took in during hard times want to repay you by shaping you in their own likeness.", "moral": null},
{"chunk": 204, "story": 204, "summary": "A thief throws meat to a housedog, who says the sudden kindness only makes him more watchful.", "predicament": "A stranger showers you with sudden favours just as you are guarding something for your employer.", "moral": null},
{"chunk": 205, "story": 205, "summary": "An ape king rewards a liar's flattery and has his court maul a truthful traveller.", "predicament": "A vain leader asks your honest opinion of them and their court, and has just richly rewarded someone who flattered them.", "moral": null},
{"chunk": 206, "story": 206, "summary": "A fox terrified at first sight of a lion grows bolder each meeting until he chats with him.", "predicament": "You were terrified the first time you met someone very powerful, and each meeting since, you have been growing bolder.", "moral": "Acquaintance softens prejudices."},
{"chunk": 207, "story": 207, "summary": "An old weasel rolls in flour to look like food, and an old mouse sees through it.", "predicament": "Something tempting lies in a dark corner, and you notice that everyone who went for it has vanished.", "moral": null},
{"chunk": 208, "story": 208, "summary": "A drowning boy asks a traveller who scolds him to help first and scold afterwards.", "predicament": "You are in serious trouble and the person who could help is busy lecturing you on how you got there.", "moral": "Counsel without help is useless."},
{"chunk": 209, "story": 209, "summary": "A peacock complains to Juno that he cannot sing, and she tells him each has his own gift.", "predicament": "You are admired for one gift but resent being laughed at for lacking another that a colleague has.", "moral": null},
{"chunk": 210, "story": 210, "summary": "A wolf follows a flock harmlessly for so long that the shepherd leaves him in charge, and he kills the flock.", "predicament": "Someone you once guarded against has behaved so well for so long that you are about to leave them in charge.", "moral": null},
{"chunk": 211, "story": 211, "summary": "Hares argue that all beasts should be equal, and lions say the words lack claws and teeth.", "predicament": "You are arguing before the powerful that everyone should be treated as equals, and you have nothing to back it with.", "moral": null},
{"chunk": 212, "story": 212, "summary": "A man selling an image of Mercury claims it brings wealth, and says he sells because its gifts come too slowly.", "predicament": "You are selling something by promising it makes its owner rich, and a customer asks why you would part with it.", "moral": null},
{"chunk": 213, "story": 213, "summary": "A hawk refuses a nightingale's plea to let him go for larger birds not yet in sight.", "predicament": "You have someone in your power who argues you should let them go and chase a bigger target instead.", "moral": null},
{"chunk": 214, "story": 214, "summary": "A lark stays put while the farmer relies on friends to reap, and moves when he says he will reap himself.", "predicament": "Your boss keeps announcing a shake-up, and you must judge whether they mean it this time or will leave it to others again.", "moral": "Self-help is the best help."},
{"chunk": 215, "story": 215, "summary": "A fox flatters a cock in a tree, who invites him to wake the 'porter' below, and the dog kills the fox.", "predicament": "A stranger known to prey on people like you is praising you warmly and asking to be let in to meet you properly.", "moral": null},
{"chunk": 216, "story": 216, "summary": "Cranes fly off at a birdcatcher's approach, while the heavier geese are caught.", "predicament": "Danger is coming for everyone in your position, and the nimble are already slipping away while you are weighed down.", "moral": null},
{"chunk": 217, "story": 217, "summary": "An ass feigns a thorn in his hoof, and when the wolf goes to pull it he kicks his teeth in.", "predicament": "Someone stronger has you cornered, and you could ask them for a small service that would put them off their guard.", "moral": null},
{"chunk": 218, "story": 218, "summary": "A goat envious of the ass advises him to feign illness, and the goat's lungs are then prescribed to heal him.", "predicament": "Out of envy you are urging a colleague to fake an illness to escape work, and the fallout could land on you.", "moral": null},
{"chunk": 219, "story": 219, "summary": "A lion invites a bull to dine on a sheep, and the bull sees only spits and cauldrons big enough for a bull.", "predicament": "Someone who would gain by your downfall invites you to share a feast, and the preparations look suspiciously large.", "moral": null},
{"chunk": 220, "story": 220, "summary": "A fox finds a lifelike mask in an actor's house and says it is beautiful but has no brains.", "predicament": "An impressive-looking candidate is presented to you, and you notice there seems to be nothing behind the face.", "moral": null},
{"chunk": 221, "story": 221, "summary": "An owl kept awake by a grasshopper flatters her voice and invites her to drink, then kills her.", "predicament": "Someone you have been annoying suddenly praises you and invites you in for a treat, after asking you nicely to stop.", "moral": null},
{"chunk": 222, "story": 222, "summary": "A fowler staring up at a thrush treads on a viper and is fatally stung.", "predicament": "You are so intent on a target you are stalking that you have stopped watching where you are putting your own feet.", "moral": null},
{"chunk": 223, "story": 223, "summary": "A proud horse threatens a laden ass, then broken-winded is put to a dungcart and mocked by the ass.", "predicament": "Someone high in favour sneers at you and your heavy load, and you could answer back or hold your peace.", "moral": null},
{"chunk": 224, "story": 224, "summary": "A lion afraid of three bulls together separates them with sly talk and eats them one by one.", "predicament": "Someone who fears you and your colleagues together is quietly talking each of you into going your own way.", "moral": "Union is strength."},
{"chunk": 225, "story": 225, "summary": "A wolf urges a goat on a cliff to come down to the tender grass, and she knows he wants to eat her.", "predicament": "You hold a secure position, and someone who would profit from your fall urges you down to where the rewards are richer.", "moral": null},
{"chunk": 226, "story": 226, "summary": "A fly on the axle threatens to sting the mule, who says he heeds only the driver.", "predicament": "Someone with no authority over you is loudly telling you to hurry up and threatening you if you do not.", "moral": null},
{"chunk": 227, "story": 227, "summary": "Fishermen rejoice at heavy nets that turn out full of stones, and an old man says sorrow is twin to joy.", "predicament": "You celebrated early over what looked like a huge haul, and it has turned out to be mostly worthless.", "moral": null}
]
@@ -0,0 +1,47 @@
[
{"chunk": 228, "story": 228, "summary": "A country mouse visits his friend's rich town house, is chased from every meal, and goes back to his safe plain fare.", "predicament": "A well-off friend urges you to leave your plain, safe post for their lavish one, where every meal comes with a fright.", "moral": null},
{"chunk": 229, "story": 229, "summary": "An ape judging a wolf's theft charge against a fox rules that the wolf lost nothing and the fox stole anyway.", "predicament": "You must settle a dispute between two people with bad reputations, and you believe neither of their accounts.", "moral": "The dishonest, if they act honestly, get no credit."},
{"chunk": 230, "story": 230, "summary": "Wasps and partridges promise a farmer service for water, and he says his oxen already do it without promises.", "predicament": "Newcomers promise you great service in return for a favour now, while your steady workers already do it without promising.", "moral": null},
{"chunk": 231, "story": 231, "summary": "A father tells his vain son and plain, spiteful daughter both to look in the mirror daily, to mind their conduct.", "predicament": "Two people in your care are at odds, one vain of their gifts and the other spiteful for lacking them, and both appeal.", "moral": null},
{"chunk": 232, "story": 232, "summary": "Dogs tear at a lion's skin, and a fox says they would learn a lion's claws if it were alive.", "predicament": "Someone who once ruled over you is gone for good, and everyone is now bravely tearing into their reputation.", "moral": "It is easy to kick a man that is down."},
{"chunk": 233, "story": 233, "summary": "A blind man feeling a wolf cub cannot say if it is fox or wolf, but knows it must not go in the sheepfold.", "predicament": "You are asked to vouch for a young newcomer whose record is unclear, but whose instincts are plainly those of a predator.", "moral": "Evil tendencies are shown in early life."},
{"chunk": 234, "story": 234, "summary": "A failed cobbler poses as a doctor with a cure-all, and confesses when the governor tests him with 'poison'.", "predicament": "Your reputation as an expert was built on noise rather than skill, and someone powerful is about to test it on you.", "moral": null},
{"chunk": 235, "story": 235, "summary": "A wolf says he left a field of oats untouched for the horse, who says he would not if wolves ate oats.", "predicament": "Someone with a bad reputation makes a show of having saved something for you that they could never have used themselves.", "moral": "Men of evil reputation, when they perform a good deed, fail to get credit for it."},
{"chunk": 236, "story": 236, "summary": "Two enemies on a sinking ship sit at opposite ends, and one is content if his enemy's end sinks first.", "predicament": "You and a bitter enemy are going down together, and you care more about seeing them fall first than about saving yourself.", "moral": null},
{"chunk": 237, "story": 237, "summary": "A partridge pecked by gamecocks thinks it is because he is a stranger, until he sees them fight each other.", "predicament": "You are new and the old hands keep attacking you, and you assume it is because you are an outsider.", "moral": null},
{"chunk": 238, "story": 238, "summary": "A fox reviles a caged lion, who says it is his mischance, not the fox, that insults him.", "predicament": "Someone powerful is trapped by circumstances for now, and it is tempting to insult them while they cannot answer.", "moral": null},
{"chunk": 239, "story": 239, "summary": "A frog proclaims himself a physician, and a fox asks why he cannot cure his own gait and skin.", "predicament": "Someone offers to fix everyone else's problems, while their own obvious failings are there for all to see.", "moral": null},
{"chunk": 240, "story": 240, "summary": "A wolf accuses the absent fox to the sick lion, and the fox prescribes the wolf's warm skin as the cure.", "predicament": "A rival is denouncing you to the boss just as you walk in, and you could turn the boss's anger back on them.", "moral": null},
{"chunk": 241, "story": 241, "summary": "A dog curled against winter cold plans a house, but stretched out in summer thinks one too big a job.", "predicament": "In hard times you resolved to build yourself some security, and now that times are easy it seems too big a job.", "moral": null},
{"chunk": 242, "story": 242, "summary": "The north wind blows in vain to strip a traveller's cloak, and the sun's warmth makes him take it off.", "predicament": "You need someone to give something up, and the harder you push the tighter they cling to it.", "moral": "Persuasion is better than Force."},
{"chunk": 243, "story": 243, "summary": "A crow breaks his vow to Apollo after being freed, and when caught again Mercury will not believe his new vow.", "predicament": "You broke your promise to the last person who got you out of trouble, and now you need a new patron to trust you.", "moral": null},
{"chunk": 244, "story": 244, "summary": "A fox serves a crane soup in a flat dish, and the crane repays him with a narrow-necked jar.", "predicament": "Someone humiliated you with a show of hospitality you could not use, and now it is your turn to host them.", "moral": null},
{"chunk": 245, "story": 245, "summary": "A wolf seeing his long evening shadow thinks he should be king, and a lion kills him.", "predicament": "Things are going so well that you feel bigger than the people above you and start to think you deserve their place.", "moral": null},
{"chunk": 246, "story": 246, "summary": "A bat fights for whichever side, birds or beasts, is winning, and after peace both condemn him.", "predicament": "Two factions are at war, and you could keep switching to whichever side is winning at the moment.", "moral": null},
{"chunk": 247, "story": 247, "summary": "A spendthrift sells his last cloak on seeing an early swallow, and the frost returns.", "predicament": "Having run through almost everything, you see an early sign of good times and are tempted to sell your last safeguard.", "moral": null},
{"chunk": 248, "story": 248, "summary": "A captured trumpeter pleads he carried no weapon, and his captors say his trumpet roused all the others.", "predicament": "You are caught on the losing side and plead that you never did harm yourself, only urged others on.", "moral": null},
{"chunk": 249, "story": 249, "summary": "The birds scorn the owl's warnings about acorns, flax and archers until they come true.", "predicament": "You warned your group early about dangers others could not yet see, and they called you mad.", "moral": null},
{"chunk": 250, "story": 250, "summary": "Driven out by the ills, the goods ask Jupiter for protection, and he sends them to men one at a time.", "predicament": "Troubles keep arriving together while good things come one at a time, and you must learn to spot the good when it comes.", "moral": null},
{"chunk": 251, "story": 251, "summary": "An ass in a lion's skin frightens the foolish beasts until a fox hears him bray.", "predicament": "You have borrowed the trappings of real authority and are enjoying frightening people, and now you meet someone who listens.", "moral": null},
{"chunk": 252, "story": 252, "summary": "A sparrow mocks a hare seized by an eagle and is himself taken by a hawk.", "predicament": "A colleague has just been brought down, and it is tempting to mock them for failing, as if it could not happen to you.", "moral": null},
{"chunk": 253, "story": 253, "summary": "A flea asks why the ox slaves for men, and the ox says he is loved and patted, while a pat means the flea's death.", "predicament": "A freeloader asks why you work so hard for your employer when they themselves take freely and give nothing.", "moral": null},
{"chunk": 254, "story": 254, "summary": "A buyer returns an ass on trial because it at once joined the laziest ass in the yard.", "predicament": "You are new and on trial, and the first thing you do is choose whom to fall in with.", "moral": "A man is known by the company he keeps."},
{"chunk": 255, "story": 255, "summary": "A caged dove boasts of her many young, and a crow says that only means more of them in prison.", "predicament": "You boast of how many people you have brought into the organisation, when all of them are stuck there with you.", "moral": null},
{"chunk": 256, "story": 256, "summary": "A satyr breaks his friendship with a man who blows on his hands to warm them and on his food to cool it.", "predicament": "Your new ally has watched you argue both sides of the same question within a day, and is doubting you.", "moral": null},
{"chunk": 257, "story": 257, "summary": "Momus, made judge of the gods' works, envies and faults all three, and is expelled.", "predicament": "You have been asked to judge between colleagues' work, and your envy is tempting you to find fault with all of it.", "moral": null},
{"chunk": 258, "story": 258, "summary": "A jackdaw envious of an eagle tries to carry off a ram, gets caught in the fleece, and is clipped and given to children.", "predicament": "You watched a star performer pull off a feat and are about to try an even bigger one yourself, without their strength.", "moral": null},
{"chunk": 259, "story": 259, "summary": "An eagle steals her neighbour fox's cub, and later her own young burn in a nest fired by a stolen cinder.", "predicament": "A powerful neighbour who swore friendship has taken something precious from you, and you cannot reach them to answer it.", "moral": null},
{"chunk": 260, "story": 260, "summary": "Every man carries a bag of his neighbours' faults in front and a bigger bag of his own behind.", "predicament": "You see a colleague's faults very clearly and are ready to point them out, without looking at your own.", "moral": "Hence it is that men are quick to see the faults of others, and yet are often blind to their own failings."},
{"chunk": 261, "story": 261, "summary": "A bitch granted a place to whelp later uses her grown pups to bar the shepherd from it.", "predicament": "Someone you let use your space for a while has built up a following and now claims it as their own.", "moral": null},
{"chunk": 262, "story": 262, "summary": "A stag admires his antlers and despises his thin legs, which save him until the antlers catch in a thicket.", "predicament": "You prize your showy strengths and are ashamed of your plain ones, and trouble is coming that will test which matters.", "moral": "What is most truly valuable is often underrated."},
{"chunk": 263, "story": 263, "summary": "A lark born before the earth, with nowhere to bury her father, buries him in her own head.", "predicament": "You owe a duty of respect to someone who has gone, and there is no proper place to pay it but within yourself.", "moral": "Youth’s first duty is reverence to parents."},
{"chunk": 264, "story": 264, "summary": "A gnat asks a bull if he should leave, and the bull says he never noticed him come.", "predicament": "You are making a show of whether to leave, sure your departure will be felt, and you may not have been noticed at all.", "moral": "Some men are of more consequence in their own eyes than in the eyes of their neighbors."},
{"chunk": 265, "story": 265, "summary": "A camel envious of the monkey's applauded dance tries to dance and is driven out.", "predicament": "A colleague's performance has just been loudly applauded, and you are tempted to get up and try to outdo them.", "moral": "It is absurd to ape our betters."},
{"chunk": 266, "story": 266, "summary": "Hungry dogs try to drink a river dry to reach hides in it and burst.", "predicament": "Your group has agreed on a plan to reach something it wants that would require doing the impossible.", "moral": "Attempt not impossibilities."},
{"chunk": 267, "story": 267, "summary": "A hungry jackdaw waits on a fig tree for out-of-season figs to ripen.", "predicament": "You are waiting, hungry, for a promise that is out of season to come good, and someone tells you it never will.", "moral": null},
{"chunk": 268, "story": 268, "summary": "An honest woodman is given gold and silver axes, and a neighbour who lies to get the same loses his own axe.", "predicament": "A colleague was rewarded for honesty in a lucky accident, and you could stage the same accident and claim the reward.", "moral": null},
{"chunk": 269, "story": 269, "summary": "A peasant ignores the pleas of birds to spare his barren tree, but spares it on finding honey inside.", "predicament": "You are about to cut something that has not paid its way, and those who depend on it beg you to spare it.", "moral": "Self-interest alone moves some men."},
{"chunk": 270, "story": 270, "summary": "A soldier who fled a robber returns boasting after his comrade has killed him, and is rebuked.", "predicament": "You ran when your partner was in danger, and now that it is over you are tempted to boast of your courage.", "moral": null},
{"chunk": 271, "story": 271, "summary": "A shepherd shakes down acorns onto his cloak, and the sheep eating them tear it.", "predicament": "The people you work to provide for have carelessly damaged something of yours while taking what you gave them.", "moral": null},
{"chunk": 272, "story": 272, "summary": "The gods choose fruitless trees so as not to seem covetous, and Minerva prefers the olive for its fruit.", "predicament": "Your peers choose honours that look disinterested, and you would rather be known for work that is plainly useful.", "moral": null}
]
@@ -0,0 +1,42 @@
[
{"chunk": 273, "story": 273, "summary": "A wrestler bitten by a flea calls on Hercules, and wonders what help to expect against greater foes if none comes for a flea.", "predicament": "You keep calling on your powerful backer over every small irritation, and wonder whether they will come when it truly matters.", "moral": null},
{"chunk": 274, "story": 274, "summary": "A fox who finds prey for a lion grows jealous of the lion's share, hunts alone, and is caught.", "predicament": "You do the scouting while a stronger partner takes the biggest share, and you are tempted to go it alone.", "moral": null},
{"chunk": 275, "story": 275, "summary": "A traveller meets Truth alone in the desert, who says she left the city because falsehood is now with everyone.", "predicament": "Everyone around you now lies as a matter of course, and speaking plainly has left you entirely on your own.", "moral": null},
{"chunk": 276, "story": 276, "summary": "A murderer fleeing his victim's kin climbs from a lion into a serpent's tree and jumps into a crocodile's river.", "predicament": "Pursued for a real wrong you did, you find every place you run to is more dangerous than the last.", "moral": null},
{"chunk": 277, "story": 277, "summary": "An eagle proposes an alliance, and the lion asks for a surety since the eagle could fly off whenever he likes.", "predicament": "Someone proposes a partnership who could walk away from it at any moment, while you could not.", "moral": "Try before you trust."},
{"chunk": 278, "story": 278, "summary": "An ass bolts for a precipice, and his owner, unable to hold him by the tail, lets go.", "predicament": "Someone you are responsible for is determined to throw themselves at a ruinous course, and holding them back is hurting you both.", "moral": null},
{"chunk": 279, "story": 279, "summary": "A thrush lingers on a myrtle for its berries and is caught by a fowler.", "predicament": "You have been lingering in one pleasant spot because it is so rewarding, and someone has noticed exactly where to find you.", "moral": null},
{"chunk": 280, "story": 280, "summary": "A wolf waits all day at a cottage door after hearing a mother threaten to throw her child out to him.", "predicament": "Someone made an angry threat in your hearing that seemed to promise you a windfall, and you are waiting for them to follow through.", "moral": null},
{"chunk": 281, "story": 281, "summary": "A hen hatches viper eggs, and a swallow warns they will harm everyone, starting with her.", "predicament": "You are carefully nurturing a group of newcomers who, once grown, are likely to turn on everyone, starting with you.", "moral": null},
{"chunk": 282, "story": 282, "summary": "An amaranth envies the rose, who replies that her beauty is brief while the amaranth never fades.", "predicament": "You envy a colleague's dazzling reputation, and they tell you it will not last while your quieter one will.", "moral": null},
{"chunk": 283, "story": 283, "summary": "Travellers resting in a plane tree's shade call it useless, and the tree rebukes them.", "predicament": "People who depend on you every day are, in your hearing, calling you useless because you do not produce anything showy.", "moral": "Some men underrate their best blessings."},
{"chunk": 284, "story": 284, "summary": "A horse refuses the ass a little feed now but promises a sackful later, and the ass does not believe it.", "predicament": "Someone refuses you a small favour now but promises a much larger one later, and you are asked to be grateful.", "moral": null},
{"chunk": 285, "story": 285, "summary": "A crow rides a sheep's back and says she bullies the weak and flatters the strong.", "predicament": "A colleague bullies you freely, and openly admits they would never try it with someone who could bite back.", "moral": null},
{"chunk": 286, "story": 286, "summary": "A slipping fox grabs a bramble for help, is torn by it, and is told he should have known its nature.", "predicament": "In a crisis you grabbed for help from someone known to take advantage of everyone, and they have hurt you.", "moral": null},
{"chunk": 287, "story": 287, "summary": "An ass envies the pampered warhorse until the horse is killed in battle.", "predicament": "You envy how well a favoured colleague is kept, not asking what they are being kept for.", "moral": null},
{"chunk": 288, "story": 288, "summary": "A lion ashamed of fearing a cock finds an elephant terrified of a gnat, and is consoled.", "predicament": "You are strong in every way but one small fear, and it shames you so much that you are ready to give up.", "moral": null},
{"chunk": 289, "story": 289, "summary": "A dog used to eggs swallows an oyster whole and suffers for thinking everything round is an egg.", "predicament": "Something new looks just like what you are used to handling, and you are about to take it on without a second look.", "moral": "They who act without sufficient thought, will often fall into unsuspected danger."},
{"chunk": 290, "story": 290, "summary": "Robbers wound the proud mule carrying treasure and ignore the one carrying grain.", "predicament": "You are proud to carry the most valuable load and make sure everyone hears it, while a colleague carries theirs quietly.", "moral": null},
{"chunk": 291, "story": 291, "summary": "A lamb fleeing a wolf into a temple says being sacrificed there is better than being eaten by him.", "predicament": "Someone chasing you warns that the refuge you are running to will be no kinder to you than they would be.", "moral": null},
{"chunk": 292, "story": 292, "summary": "A partridge offers to lure other partridges if spared, and the fowler kills him for it.", "predicament": "You have been caught, and you could save yourself by offering to deliver your colleagues to the one who caught you.", "moral": null},
{"chunk": 293, "story": 293, "summary": "A man catches a flea who pleads it does little harm, and kills it because no evil should be tolerated.", "predicament": "You have caught someone in a petty offence, and they plead that what they did was too small to matter.", "moral": null},
{"chunk": 294, "story": 294, "summary": "A rich man presses his tanner neighbour to move, the tanner stalls, and the rich man grows used to the smell.", "predicament": "A powerful neighbour wants you gone and keeps pressing, and you could keep promising to leave soon without actually going.", "moral": null},
{"chunk": 295, "story": 295, "summary": "A viper asks a file for a meal, and the file says he takes from everyone and gives nothing.", "predicament": "You have gone to ask for help from someone well known for taking from everyone and never giving.", "moral": "The covetous are poor givers."},
{"chunk": 296, "story": 296, "summary": "A shepherd pulls a thorn from a lion's paw, and the lion later spares him in the arena.", "predicament": "Someone dangerous and in pain comes to you for help, and helping them means getting close to their claws.", "moral": null},
{"chunk": 297, "story": 297, "summary": "A camel envying the bull's horns asks Jupiter for some and loses part of his ears.", "predicament": "Not content with your strengths, you are asking the boss for a colleague's distinctions too.", "moral": null},
{"chunk": 298, "story": 298, "summary": "A panther in a pit, stoned by some shepherds and fed by others, escapes and takes revenge only on his attackers.", "predicament": "Someone dangerous has fallen, and others are pelting them while a few offer help, and you must choose which you will be.", "moral": null},
{"chunk": 299, "story": 299, "summary": "A kite boasts he can catch ostriches to win an eagle's hand, and brings her a stale mouse.", "predicament": "Someone courting you for a partnership boasts of what they can deliver, and you have no way yet to check.", "moral": null},
{"chunk": 300, "story": 300, "summary": "An eagle freed by a new owner brings him a hare, and a fox advises courting the old owner who clipped him.", "predicament": "A new employer has restored what your old one took from you, and you must decide whom to cultivate.", "moral": null},
{"chunk": 301, "story": 301, "summary": "A prince confined because of his father's dream of a lion strikes at a painted lion, is pricked by a thorn and dies.", "predicament": "You have been confined to protect you from a danger someone only dreamed of, and you are raging against it.", "moral": "We had better bear our troubles bravely than try to escape them."},
{"chunk": 302, "story": 302, "summary": "A cat turned into a woman for love leaps from the bridal couch after a mouse.", "predicament": "You have been given a new, polished role, and an old temptation is about to be dropped in front of you.", "moral": "Nature exceeds nurture."},
{"chunk": 303, "story": 303, "summary": "A beetle whose young an eagle ate pursues her eggs even into Jupiter's lap until they are broken.", "predicament": "Someone far more powerful destroyed what you cared about, and you could pursue them relentlessly even into their protector's presence.", "moral": "The weak often revenge themselves on those who use them ill, even though they be more powerful."},
{"chunk": 304, "story": 304, "summary": "The he-goats complain the she-goats got beards, and Jupiter says an empty badge makes them no equals.", "predicament": "Juniors have been given the same badge of rank as you, and you are tempted to complain to those above.", "moral": "It matters little if those who are inferior to us in merit should be like us in outside appearances."},
{"chunk": 305, "story": 305, "summary": "A bald man slaps his own head at a fly, and says he forgives himself but would kill the fly at any cost.", "predicament": "Someone small keeps needling you, and your efforts to hit back are hurting you more than them.", "moral": null},
{"chunk": 306, "story": 306, "summary": "A shipwrecked man blames the sea, who says the winds are to blame for her fury.", "predicament": "You were ruined in a venture and blame the institution, which says it was only stirred up by others.", "moral": null},
{"chunk": 307, "story": 307, "summary": "A crowd prefers a buffoon's fake pig squeal to a countryman's real pig, until he shows it.", "predicament": "The crowd has already decided their favourite is better than you, however genuine your work, and you must show them.", "moral": null},
{"chunk": 308, "story": 308, "summary": "A hungry crow seizes a sleeping serpent as a windfall and is bitten to death.", "predicament": "You are desperate, and an easy prize seems to have been left lying unguarded right in front of you.", "moral": null},
{"chunk": 309, "story": 309, "summary": "A horseman takes a hunter's hare under pretence of buying it and gallops off, and the hunter calls it a gift.", "predicament": "Someone has made off with what you earned, pretending to buy it, and you cannot catch them.", "moral": null},
{"chunk": 310, "story": 310, "summary": "An evergreen olive mocks the fig for shedding leaves, and snow breaks the olive but passes through the fig.", "predicament": "A colleague mocks you for scaling back in lean seasons while they keep up appearances all year round.", "moral": null},
{"chunk": 311, "story": 311, "summary": "The frogs protest the sun's marriage, fearing that more suns would parch them worse.", "predicament": "Someone who already makes your life hard is about to gain more power, and you could protest before it happens.", "moral": null},
{"chunk": 312, "story": 312, "summary": "A brazier scolds his dog for sleeping while he works and waking only to beg at dinner.", "predicament": "Someone who dozes through all the work always wakes up just in time to ask for a share of the rewards.", "moral": null}
]
@@ -0,0 +1,120 @@
#!/usr/bin/env python3
"""Assemble records.json, groups.json and options_by_entry.json from batches/, groups_src.py and
options_src.py, and check them. Jacobs' build.py plus the checks this corpus needs: one scene per
fable, predicament length and name leaks, verbatim morals and evidence, the 13-fable recurrence rule,
and the catalogue ids the options measurement uses. No inference."""
import collections, glob, json, pathlib, re, sys
sys.dont_write_bytecode = True # the catalogue import below must not leave caches outside probe/aesop/
from groups_src import G, STANDING
from options_src import O, NEW_ENTRIES
here = pathlib.Path(__file__).parent
sys.path.insert(0, str(here.parent.parent / 'catalogue' / 'v2'))
from catalogue_v2_src import CATALOGUE
chunks = json.loads((here.parent / 'chunks.json').read_text())
stories = json.loads((here.parent / 'stories.json').read_text())
recs = [r for f in sorted(glob.glob(str(here / 'batches/s*.json'))) for r in json.loads(open(f).read())]
assert [r['chunk'] for r in recs] == list(range(len(chunks))), 'every fable, once, in order'
assert all(chunks[r['chunk']]['story'] == r['story'] for r in recs)
assert all(set(r) == {'chunk', 'story', 'summary', 'predicament', 'moral'} for r in recs)
# ---- records -------------------------------------------------------------------------------------
NAMES = ('amaranth ant ape apple ass bat bear bee beetle bird boar bramble bull calf camel cat cock crab crane '
'crow dog doe dolphin dove eagle elephant fawn fig filbert fir fish flea fly flies frog gnat goat goose '
'geese grape grasshopper hare hart hawk hedgehog heifer hen horse hound jackdaw kid kite lamb lark '
'leopard lion lioness locust mole monkey mouse mice mule nettle nightingale oak olive ox oxen oyster '
'panther partridge peacock pig pigeon pomegranate porker raven reed rose satyr seagull serpent sheep '
'snake sow sparrow sprat stag stork swallow swan thrush tortoise vine viper walnut wasp weasel whale '
'whelp wolf wolves jupiter juno venus mercury minerva neptune momus hercules apollo pallas hermes '
'axe net sling fleece wool manger pitcher honey acorn').split()
NOPLURAL = {'doe'} # 'does' is a verb
leak = re.compile(r'\b(' + '|'.join(n + ('' if n in NOPLURAL else 's?') for n in NAMES) + r')\b', re.I)
def norm(s): return ' '.join(s.split())
problems = []
for r in recs:
p, n = r['predicament'], len(r['predicament'].split())
if not 15 <= n <= 24: problems.append(f"chunk {r['chunk']}: predicament has {n} words")
if len(re.findall(r'[.!?](?:\s|$)', p)) != 1 or not p.endswith('.'): problems.append(f"chunk {r['chunk']}: not one sentence")
if leak.search(p): problems.append(f"chunk {r['chunk']}: name or story object in predicament: {leak.search(p).group(0)}")
if r['moral'] is not None and norm(r['moral']) not in norm(chunks[r['chunk']]['text']):
problems.append(f"chunk {r['chunk']}: moral is not verbatim")
# ---- groups --------------------------------------------------------------------------------------
MIN_MEMBERS = max(3, round(0.04 * len(stories))) # 13 for 313 fables
SKILLS = {'wit', 'prudence'}
seen = collections.Counter(m for g in G for m in g['members'])
dup = [m for m, k in seen.items() if k > 1]
if dup: problems.append(f'fables in two groups: {dup}')
assert set(STANDING) == set(range(len(recs))), 'STANDING covers every fable'
by = {r['chunk']: r for r in recs}
out = []
for g in G:
small = g.get('small', False)
k = len(g['members'])
if small and not 5 <= k <= 12: problems.append(f"{g['id']}: marked small but has {k} fables")
if not small and k < MIN_MEMBERS: problems.append(f"{g['id']}: {k} fables, below the {MIN_MEMBERS}-fable rule")
if not 3 <= len(g['options']) <= 5: problems.append(f"{g['id']}: {len(g['options'])} options")
used = {p for _, _, mv in g['options'] for p in mv if '.' not in p and p not in ('money', 'standing')}
if not used <= SKILLS: problems.append(f"{g['id']}: skill labels outside {SKILLS}: {used - SKILLS}")
for _, s, mv in g['options']:
if not all(v in ('+', '-', 'risk', 'set') for v in mv.values()): problems.append(f"{g['id']}: bad direction in {mv}")
if s.startswith('scene:') and not 0 <= int(s.split(':')[1]) < len(recs): problems.append(f"{g['id']}: bad source {s}")
st = sorted({by[m]['story'] for m in g['members']})
stand = collections.Counter(STANDING[by[m]['story']] for m in g['members'])
out.append({
'id': g['id'], 'name': g['name'], 'predicament': g['predicament'], 'small': small,
'sentence': f"A {g['actor']} wants {g['wants']} from {g['counterpart']}.",
'actor': g['actor'], 'wants': g['wants'], 'counterpart': g['counterpart'],
'stories': st, 'story_titles': [stories[s]['title'] for s in st],
'standing': dict(stand), 'stage': stand.most_common(1)[0][0],
'members': [{'chunk': m, 'story': by[m]['story'], 'predicament': by[m]['predicament'],
'moral': by[m]['moral']} for m in g['members']],
'options': [{'text': t, 'source': s, 'moves': mv,
'in_group': (int(s.split(':')[1]) in g['members']) if s.startswith('scene:') else None}
for t, s, mv in g['options']],
})
unassigned = [r['chunk'] for r in recs if r['chunk'] not in seen]
# ---- options measurement -------------------------------------------------------------------------
v2 = {e['id'] for e in CATALOGUE}
obe, new_used = collections.defaultdict(list), set()
for entry, c, text, mv, ev, stated in O:
if entry.startswith('new:'):
if entry[4:] not in NEW_ENTRIES: problems.append(f'{entry}: not defined in NEW_ENTRIES')
if entry[4:] in v2: problems.append(f'{entry}: already a v2 id')
new_used.add(entry[4:])
elif entry not in v2: problems.append(f'{entry}: not a v2 catalogue id')
ev = by[c]['moral'] if ev == 'M' else ev
if ev is None: problems.append(f'chunk {c}: evidence M but the fable has no moral')
elif norm(ev) not in norm(chunks[c]['text']): problems.append(f'chunk {c}: evidence not verbatim: {ev[:50]}')
elif len(ev.split()) > 30: problems.append(f'chunk {c}: evidence over 30 words')
bad = [p for p in mv if not (p in ('money', 'standing') or p.startswith('relationships.'))]
if bad: problems.append(f'chunk {c}: measurement moves use {bad}; money, standing, relationships only')
if not all(v in ('+', '-', 'risk') for v in mv.values()): problems.append(f'chunk {c}: bad direction in {mv}')
obe[entry].append({'text': text, 'moves': mv, 'source': f'chunk:{c}', 'evidence': ev, 'stated': stated})
unused = sorted(set(NEW_ENTRIES) - new_used)
if unused: problems.append(f'NEW_ENTRIES never used: {unused}')
if problems:
print('\n'.join(problems)); sys.exit(1)
obe_out = {k: obe[k] for k in sorted(obe, key=lambda k: (k.startswith('new:'), k))}
obe_out['_new_entries'] = {f'new:{k}': {'name': n, 'definition': d, 'differs_from': x}
for k, (n, d, x) in NEW_ENTRIES.items()}
(here / 'records.json').write_text(json.dumps(recs, indent=1, ensure_ascii=False))
(here / 'groups.json').write_text(json.dumps({'groups': out, 'unassigned': unassigned}, indent=1, ensure_ascii=False))
(here / 'options_by_entry.json').write_text(json.dumps(obe_out, indent=1, ensure_ascii=False))
# ---- report --------------------------------------------------------------------------------------
big = [g for g in out if not g['small']]
print(f'{len(recs)} fables, {sum(r["moral"] is not None for r in recs)} with a stated moral; '
f'{len(big)} groups of {MIN_MEMBERS}+ fables, {len(out) - len(big)} small groups (5-12); '
f'{sum(seen.values())} assigned, {len(unassigned)} unassigned')
for g in out:
print(f"{g['id']} {len(g['members']):2} fables stage {g['stage']:6} {'SMALL ' if g['small'] else ''}{g['name']}")
src = collections.Counter(o['source'].split(':')[0] for g in out for o in g['options'])
print('group option sources:', dict(src), '| scene options citing a fable outside their group:',
sum(o['in_group'] is False for g in out for o in g['options']))
opts = [o for k, v in obe.items() for o in v]
cov_v2 = sorted(k for k in obe if not k.startswith('new:'))
print(f'options measurement: {len(opts)} options, {sum(o["stated"] for o in opts)} stated '
f'({sum(o["stated"] for o in opts) / len(opts):.0%}); {len(cov_v2)} of {len(v2)} v2 entries covered, '
f'{len(new_used)} new entries; from {len({o["source"] for o in opts})} distinct fables')
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,228 @@
"""Hand-built reference grouping of the Aesop predicaments (Claude-written, no inference of any kind).
One fable is one story and one scene, so a group's member count is also its story count.
Recurrence rule, scaled to the corpus: with 313 fables a group needs at least 13 members
(max(3, 4% of 313) = 13). Groups of 5-12 are allowed but are marked SMALL below and reported
separately. Each fable is in at most one group; fables that fit no recurring situation stay
unassigned.
Options follow DESIGN.md: 3-5 per group, each marked by where it came from --
'archetype' a fixed, setting-free option
'scene:N' what someone in fable N actually did (or refused to do)
'filled' written to fill a gap the fables leave
`moves` names spine paths and a direction only (+ / - / risk / set). No magnitudes: the model never
chooses a number. Skill labels proposed for this corpus: wit (seeing through a trick, and turning it),
prudence (looking before you leap, knowing your limits, keeping what you have).
"""
G = [
# ---- the powerful and the weak ---------------------------------------------------------------------
dict(id='G01', name='A friendly approach from someone who would gain by your fall',
predicament='Someone whose nature is to prey on people like you comes friendly, praising, inviting or offering peace, and what they propose would put you in reach.',
actor='person with something to lose', wants='to keep it', counterpart='a known predator acting friendly',
members=[30, 36, 63, 64, 73, 92, 96, 144, 172, 192, 204, 207, 215, 219, 221, 225],
options=[
('Keep your distance and decline politely.', 'scene:36', {'prudence':'+', 'relationships.predator':'-'}),
('Accept the invitation and go in.', 'scene:221', {'relationships.predator':'+', 'standing':'risk'}),
('Say out loud what they are really after.', 'scene:225', {'wit':'+', 'relationships.predator':'-'}),
('Send them round to someone who can deal with them.', 'scene:215', {'wit':'+', 'relationships.predator':'-', 'flags.turned_the_tables':'set'}),
('Rise to the praise and show them what you can do.', 'scene:96', {'money':'-', 'standing':'-'})]),
dict(id='G02', name='At the mercy of someone who has already decided',
predicament='Someone who holds your fate has caught you and is looking for a reason, and you have one chance to talk your way out.',
actor='person caught', wants='to be let go', counterpart='the one who has them',
members=[1, 5, 26, 39, 43, 55, 131, 135, 147, 197, 211, 217, 248, 292],
options=[
('Answer every charge with the facts.', 'scene:1', {'standing':'risk', 'relationships.captor':'-'}),
('Show them the one thing you do that nobody else can.', 'scene:135', {'standing':'+', 'relationships.captor':'+'}),
('Tell them you belong to the side they spare.', 'scene:5', {'wit':'+', 'flags.two_faced':'set'}),
('Ask for one small favour that puts them off guard.', 'scene:217', {'wit':'+', 'relationships.captor':'-'}),
('Offer to hand over your own people instead.', 'scene:292', {'relationships.colleagues':'-', 'standing':'-', 'flags.informer':'set'})]),
dict(id='G03', name='Trading one hardship for another',
predicament='Your present lot is hard, and you could change master, protector or post, though the new one may be worse.',
actor='person with a hard lot', wants='something better', counterpart='the master or post on offer',
members=[47, 53, 76, 79, 109, 115, 125, 136, 177, 178, 189, 199, 228, 291],
options=[
('Ask for a new master.', 'scene:125', {'relationships.boss':'-', 'standing':'risk'}),
('Bring in a strong protector.', 'scene:79', {'relationships.protector':'+', 'standing':'risk'}),
('Keep the harm you know.', 'scene:53', {'prudence':'+'}),
('Go back to your plain, safe post.', 'scene:228', {'prudence':'+', 'money':'-'}),
('Sell up and stake it all on the change.', 'scene:109', {'money':'risk', 'flags.staked_all':'set'})]),
dict(id='G04', name='Envying another\'s gift',
predicament='You envy a colleague\'s gift or standing so much that you are tempted to copy them, or ask for what they have.',
actor='person with plainer gifts', wants='what the admired one has', counterpart='the admired colleague',
members=[2, 29, 31, 52, 68, 98, 134, 170, 180, 186, 209, 258, 265, 268, 282, 287, 297],
options=[
('Copy what they do.', 'scene:265', {'standing':'risk', 'relationships.admired':'-'}),
('Ask those above for what they have.', 'scene:297', {'relationships.boss':'-', 'standing':'-'}),
('Stick to what you are good at.', 'scene:209', {'prudence':'+'}),
('Look at what their position costs them.', 'scene:287', {'prudence':'+', 'relationships.admired':'+'}),
('Learn the craft properly before you try it.', 'filled', {'money':'-', 'wit':'+'})]),
dict(id='G05', name='Passing for more than you are',
predicament='You are passing for braver, grander or more skilled than you are, and the moment that would test it has come.',
actor='pretender', wants='to keep the credit', counterpart='whoever might test them',
members=[40, 42, 65, 138, 148, 160, 187, 198, 200, 201, 234, 251, 270, 302],
options=[
('Keep up the pretence and say nothing.', 'scene:200', {'standing':'risk'}),
('Confess before you are tested.', 'scene:234', {'standing':'-', 'relationships.examiner':'+'}),
('Laugh with them when it slips.', 'scene:160', {'standing':'+', 'relationships.companions':'+'}),
('Double the claim.', 'scene:198', {'standing':'risk', 'flags.liar':'set'}),
('Back out quietly.', 'scene:138', {'standing':'-'})]),
dict(id='G06', name='Riding high',
predicament='Things are going your way, and you are tempted to show it, crow about it, or take on someone bigger.',
actor='person on top for now', wants='to be seen on top', counterpart='rivals and onlookers',
members=[27, 57, 71, 108, 110, 117, 126, 141, 194, 245, 255, 264, 290],
options=[
('Crow about it where everyone can see.', 'scene:71', {'standing':'risk', 'relationships.rival':'-'}),
('Press on and take on someone bigger.', 'scene:110', {'standing':'risk', 'flags.overreached':'set'}),
('Keep quiet and go on shining.', 'scene:141', {'prudence':'+', 'standing':'+'}),
('Ask an old hand what your mark really means.', 'scene:57', {'relationships.mentor':'+', 'prudence':'+'})]),
dict(id='G07', name='Sneered at by someone showier',
predicament='A showier or better-placed colleague sneers at your plainness or your load, and you must decide how to answer.',
actor='plain worker', wants='respect', counterpart='a showier colleague',
members=[7, 11, 22, 24, 70, 88, 155, 163, 167, 223, 226, 253, 310],
options=[
('Answer with what you can do that they cannot.', 'scene:167', {'wit':'+', 'standing':'+', 'relationships.rival':'-'}),
('Hold your peace and wait.', 'scene:223', {'prudence':'+', 'relationships.rival':'-'}),
('Challenge them to a test.', 'scene:11', {'standing':'risk', 'relationships.rival':'-'}),
('Ignore them and heed only the one in charge.', 'scene:226', {'relationships.boss':'+'}),
('Try to outshine them on their terms.', 'filled', {'money':'-', 'standing':'risk'})]),
# ---- help, greed, partners -------------------------------------------------------------------------
dict(id='G08', name='Help asked in need',
predicament='Someone in trouble asks for help, or you need it yourself, and nobody can tell how the help will be repaid.',
actor='person asked for help', wants='to do right without being bitten', counterpart='someone in need',
members=[0, 10, 23, 51, 132, 154, 190, 193, 208, 230, 261, 286, 295, 296],
options=[
('Help, though they can give nothing back now.', 'scene:0', {'relationships.helped':'+', 'money':'-'}),
('Refuse: they should have looked ahead.', 'scene:10', {'relationships.asker':'-', 'money':'+'}),
('Take them in and nurse them.', 'scene:23', {'relationships.helped':'+', 'standing':'risk'}),
('Refuse to lift any of their load.', 'scene:51', {'relationships.partner':'-', 'money':'risk'}),
('Stick with those who already serve you without promises.', 'scene:230', {'prudence':'+', 'relationships.staff':'+'})]),
dict(id='G09', name='Wanting more than you hold',
predicament='You already have something good, and a chance to grab more, squeeze more or stay longer is tempting you.',
actor='person with something in hand', wants='more', counterpart='the bigger prize',
members=[15, 21, 46, 95, 113, 137, 139, 151, 156, 168, 179, 213, 279],
options=[
('Keep what you hold.', 'scene:137', {'money':'+', 'prudence':'+'}),
('Drop it and chase the bigger prize.', 'scene:156', {'money':'risk'}),
('Squeeze the steady source for more.', 'scene:168', {'money':'risk', 'flags.killed_the_goose':'set'}),
('Take half and get free.', 'scene:46', {'prudence':'+', 'money':'-'}),
('Stay and enjoy it a while longer.', 'scene:279', {'money':'+', 'standing':'risk'})]),
dict(id='G10', name='Your side divided',
predicament='You and a rival, or the people on your own side, are quarrelling, and someone else stands to profit from it.',
actor='member of a divided side', wants='to win the quarrel, or end it', counterpart='the rival, and whoever is waiting',
members=[4, 25, 48, 75, 80, 105, 124, 152, 165, 166, 171, 185, 224, 236, 246],
options=[
('Make peace with the rival before others profit.', 'scene:105', {'relationships.rival':'+'}),
('Show them together they cannot be broken.', 'scene:4', {'relationships.team':'+', 'standing':'+'}),
('Fight it out to the end.', 'scene:152', {'relationships.rival':'-', 'money':'-'}),
('Stop supplying the part you resent.', 'scene:75', {'relationships.team':'-', 'money':'-'}),
('Keep switching to whichever side is winning.', 'scene:246', {'wit':'+', 'relationships.team':'-', 'flags.turncoat':'set'})]),
dict(id='G11', name='A partner you may not be able to count on',
predicament='You are joined, or about to be joined, to a partner or ally, and you cannot be sure they will stand by you.',
actor='person choosing a partner', wants='someone to rely on', counterpart='a doubtful partner',
members=[12, 20, 37, 58, 60, 82, 90, 102, 120, 127, 181, 182, 184, 254, 256, 277, 284, 299],
options=[
('Ask for a surety before you join.', 'scene:277', {'prudence':'+', 'relationships.partner':'-'}),
('Decline: their ways would undo yours.', 'scene:12', {'prudence':'+', 'relationships.partner':'-'}),
('Save yourself and leave the partner to it.', 'scene:37', {'relationships.partner':'-', 'flags.deserted':'set'}),
('Bind yourself to them.', 'scene:90', {'relationships.partner':'+', 'standing':'risk'}),
('Hold them to their own words.', 'scene:120', {'wit':'+', 'relationships.partner':'-'})]),
dict(id='G12', name='Rushing in without looking',
predicament='Something you want is right in front of you, or you are intent on a target, and you have not looked at the danger.',
actor='eager person', wants='what is in front of them', counterpart='the danger they have not seen',
members=[13, 18, 32, 62, 99, 107, 140, 150, 216, 222, 247, 289, 308],
options=[
('Go for it now before it is gone.', 'scene:32', {'money':'risk', 'standing':'risk'}),
('Ask how you get out before going in.', 'scene:140', {'prudence':'+'}),
('Keep your eyes on your target.', 'scene:222', {'money':'+', 'standing':'risk'}),
('Wait for a surer sign before you act.', 'filled', {'prudence':'+', 'money':'-'})]),
dict(id='G13', name='Pointing at others\' faults',
predicament='Faults are being pointed out, by you or at you, and the one pointing shares the fault or gains by the blame.',
actor='critic or criticised', wants='to be in the right', counterpart='the other party',
members=[9, 28, 38, 84, 111, 116, 122, 153, 173, 188, 239, 257, 260, 306],
options=[
('Ask them to show you how.', 'scene:84', {'wit':'+', 'relationships.critic':'-'}),
('Point out that they do the same.', 'scene:122', {'wit':'+', 'relationships.critic':'-', 'standing':'+'}),
('Ask what they gain by the advice.', 'scene:38', {'wit':'+', 'relationships.adviser':'-'}),
('Find fault with everyone.', 'scene:257', {'standing':'-', 'relationships.colleagues':'-'}),
('Look at your own faults first.', 'filled', {'prudence':'+', 'relationships.colleagues':'+'})]),
dict(id='G14', name='Liberties between the weak and the strong',
predicament='Someone powerful is down, caged or out of reach, or someone small is needling you, and a blow could be struck.',
actor='weak or strong party', wants='to strike, or to be left alone', counterpart='the other party',
members=[49, 66, 77, 121, 145, 159, 175, 206, 232, 238, 252, 285, 298, 303, 305],
options=[
('Taunt them while they cannot reach you.', 'scene:66', {'standing':'+', 'relationships.powerful':'-'}),
('Pursue them however long it takes.', 'scene:303', {'relationships.powerful':'-', 'flags.revenge':'set', 'standing':'risk'}),
('Help them while they are down.', 'scene:298', {'relationships.powerful':'+', 'money':'-'}),
('Kick them now that they are down.', 'scene:232', {'relationships.powerful':'-', 'standing':'-'}),
('Let a small insult pass.', 'filled', {'prudence':'+'})]),
dict(id='G15', name='A hard lot',
predicament='You are stuck, worn down or beaten, and it is tempting to despair, complain, or wait for someone to rescue you.',
actor='person in hard times', wants='a way through', counterpart='their own circumstances',
members=[16, 61, 72, 87, 133, 157, 169, 191, 195, 227, 267, 273, 288, 301],
options=[
('Put your own shoulder to it.', 'scene:16', {'prudence':'+', 'money':'+'}),
('Try something nobody has tried.', 'scene:191', {'wit':'+', 'money':'+'}),
('Say you never wanted it anyway.', 'scene:195', {'standing':'+'}),
('Call on your backer.', 'scene:273', {'relationships.patron':'risk'}),
('Give it up entirely.', 'scene:87', {'standing':'-', 'flags.gave_up':'set'})]),
dict(id='G16', name='Worked hard, badly repaid',
predicament='You carry the load for the one you serve, and they take you for granted, work you into the ground or skim what you need.',
actor='worker', wants='fair treatment', counterpart='the one they serve',
members=[34, 45, 50, 74, 100, 118, 119, 142, 161, 196, 271, 283, 312],
options=[
('Carry on in silence.', 'scene:34', {'relationships.boss':'+', 'standing':'-'}),
('Tell them plainly what they are doing to you.', 'scene:50', {'relationships.boss':'risk', 'standing':'+'}),
('Remind them what you were.', 'scene:119', {'relationships.boss':'risk'}),
('Leave before you are next.', 'scene:118', {'prudence':'+', 'money':'-', 'relationships.boss':'-'}),
('Stop being so useful.', 'filled', {'money':'-', 'relationships.boss':'-'})]),
# ---- SMALL groups (5-12 fables): below the 13-fable recurrence rule, reported separately ----------
dict(id='G17', name='Letting a known predator inside', small=True, # SMALL: 6 fables
predicament='Someone with a predator\'s instincts is about to be let inside, or trained, or left in charge, and you know what they are.',
actor='keeper', wants='to use them safely', counterpart='a known predator',
members=[83, 129, 164, 210, 233, 281],
options=[
('Speak up before they are let in.', 'scene:164', {'relationships.boss':'risk', 'standing':'+'}),
('Trust them after long good behaviour.', 'scene:210', {'relationships.newcomer':'+', 'money':'risk'}),
('Let them go and cut your losses.', 'scene:129', {'money':'-', 'prudence':'+'}),
('Train them to your uses.', 'scene:83', {'money':'+', 'relationships.newcomer':'risk'})]),
dict(id='G18', name='The lion\'s share', small=True, # SMALL: 6 fables
predicament='You did the work alongside someone far stronger, and now the proceeds are being divided on their terms.',
actor='junior partner', wants='a fair share', counterpart='a far stronger partner',
members=[3, 101, 130, 158, 274, 309],
options=[
('Divide it fairly.', 'scene:158', {'relationships.partner':'-', 'standing':'risk'}),
('Leave yourself almost nothing.', 'scene:158', {'money':'-', 'relationships.partner':'+'}),
('Go it alone next time.', 'scene:274', {'money':'risk', 'relationships.partner':'-'}),
('Ask for what you were promised.', 'scene:3', {'relationships.partner':'-', 'standing':'risk'})]),
dict(id='G19', name='A bad record when you need trust', small=True, # SMALL: 5 fables
predicament='You need people to believe you or help you now, and your record gives them every reason not to.',
actor='person with a past', wants='to be believed', counterpart='those who remember',
members=[54, 104, 229, 235, 243],
options=[
('Raise the alarm anyway.', 'scene:54', {'standing':'-'}),
('Make a new promise to a new patron.', 'scene:243', {'relationships.patron':'-'}),
('Do a good turn to mend your name.', 'scene:235', {'relationships.neighbour':'risk'}),
('Earn trust in good times, before you need it.', 'filled', {'relationships.allies':'+'})]),
dict(id='G20', name='A warning before the trouble', small=True, # SMALL: 6 fables
predicament='Trouble is coming but not yet here, and you must decide whether to prepare, warn others, or take the threat seriously.',
actor='person with warning', wants='to be ready', counterpart='the trouble coming',
members=[35, 112, 174, 214, 241, 249],
options=[
('Prepare now while it is quiet.', 'scene:112', {'prudence':'+', 'money':'-'}),
('Warn the others though they laugh.', 'scene:249', {'standing':'-', 'relationships.team':'-'}),
('Wait until they mean it.', 'scene:214', {'prudence':'+'}),
('Put it off until next time.', 'scene:241', {'money':'+', 'prudence':'-'})]),
]
# Protagonist's power in the situation each predicament describes, for stage assignment (DESIGN.md
# "Stages"). One fable is one scene, so this is per fable. 'high' means the "you" of the predicament
# holds power over the others in it, 'middle' that they deal as a peer, 'low' that they are at
# someone's mercy or under their orders.
HIGH = {0, 4, 17, 33, 49, 69, 73, 84, 85, 86, 89, 126, 137, 152, 153, 156, 162, 173, 175, 183, 202,
203, 213, 220, 229, 230, 231, 257, 261, 269, 271, 277, 278, 288, 293, 305, 312}
MIDDLE = {2, 6, 7, 10, 12, 14, 15, 21, 22, 23, 24, 25, 27, 28, 30, 31, 37, 38, 40, 42, 44, 46, 48, 51,
58, 59, 60, 61, 62, 65, 67, 71, 78, 83, 91, 93, 95, 96, 97, 102, 103, 105, 108, 111, 112, 114,
116, 120, 123, 124, 129, 132, 138, 139, 140, 141, 143, 148, 149, 150, 151, 154, 155, 159,
160, 163, 165, 167, 168, 171, 174, 179, 180, 182, 184, 185, 186, 188, 190, 192, 194, 199,
201, 209, 210, 212, 219, 222, 224, 225, 227, 233, 235, 236, 239, 242, 244, 245, 249, 250,
251, 252, 256, 260, 262, 263, 270, 272, 281, 282, 289, 290, 296, 297, 298, 299, 302, 304, 310}
STANDING = {i: 'high' if i in HIGH else 'middle' if i in MIDDLE else 'low' for i in range(313)}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,342 @@
"""The options measurement: which catalogue situations the fables supply options for, and whether the
fable itself shows where each option's moves go. Hand-written by Claude reading every fable; no inference.
Each row: (entry, chunk, text, moves, evidence, stated)
entry a catalogue v2 id, or 'new:<id>' defined in NEW_ENTRIES
chunk the fable the option comes from (written out as source 'chunk:N')
text the move, transplanted to a workplace
moves spine paths with a direction. Only money, standing and relationships.<role>: a skill label
or a flag is a design decision about the option, not an outcome a story can show, so
none is used here (the group options in groups_src.py carry them)
evidence a verbatim excerpt of the fable, at most 30 words, or 'M' for the fable's stated moral
stated True only if the fable's own outcome or moral shows the direction of EVERY path in moves.
How an outcome is read as a direction (the same reading for every row):
money goods, food, pay, property or a prize gained (+) or lost (-); a heavier load or harder
work for the same keep counts as (-)
standing the actor's safety, position or name: made king, spared, pardoned, praised, winning
the contest (+); killed, caught, beaten, driven out, exposed or publicly rebuked (-)
relationships.<role> how that party treats the actor afterwards: repays, rescues, makes peace (+);
turns on them, deserts them, refuses them, stops trusting them (-)
'risk' stated only when the same fable shows the same move ending both ways
A moral that only calls a move wise or foolish does not state a direction; one that names the
effect ("wins the race", "lost both") does. An outcome that befalls the actor but is not caused by
the move (the sparrow taken by a hawk while mocking the hare) is not counted as stated.
"""
NEW_ENTRIES = {
'friendly_predator': ('A friendly approach from someone who would gain by your fall',
'Someone whose nature is to prey on people like you comes friendly, praising, inviting, offering peace or a small favour to ask, and what they propose would put you in their reach.',
'near sure_thing (an easy fortune that is a trap) and not_who_they_seem (a false face among you); here the danger is known, and the bait is friendship, not fortune'),
'better_the_devil': ('Trading one hardship for another',
'Your present master, protector or post is hard on you, and you could change it, though the new one may be worse.',
'no v2 entry; the decision is whether to change your lot, not how to bear it'),
'ape_your_betters': ('Envying another\'s gift',
'You envy a colleague\'s gift, looks or standing so much that you are tempted to copy them, ask for what they have, or leave your own kind to join theirs.',
'near overlooked (wanting, once, to be noticed beside the admired one); here the move is imitation'),
'riding_high': ('Riding high',
'Things are going your way, and you are tempted to show it: crow, parade your honours, or take on someone bigger.',
'no v2 entry; the fables\' most common turn, pride before a fall'),
'sneered_at': ('Sneered at by someone showier',
'A showier or better-placed colleague sneers at your plainness, slowness or load; you are the one sneered at, or the one sneering.',
'near power_over_you and despised, but the sneerer has no power over you'),
'grasping': ('Wanting more than you hold',
'You already have something good, and the chance to grab more, squeeze more out of it, or stay longer is tempting you.',
'no v2 entry'),
'divided': ('Your side divided',
'You and a rival, or the people on your own side, are quarrelling, and someone else stands to profit from it.',
'no v2 entry'),
'doubtful_ally': ('A partner you may not be able to count on',
'You are joined, or about to be joined, to a partner or ally, and cannot be sure they will stand by you.',
'near used; here nothing is hidden yet, the question is whether to rely on them'),
'heedless': ('Rushing in without looking',
'Something you want is right in front of you, or you are intent on a target, and you have not looked at the danger.',
'no v2 entry'),
'pot_and_kettle': ('Pointing at others\' faults',
'Faults are being pointed out, by you or at you, and the one pointing shares the fault or gains by the blame.',
'near own_words_back'),
'needling_the_strong': ('Liberties between the weak and the strong',
'Someone powerful is down, caged or out of reach, or someone small is needling you, and a blow could be struck.',
'near revenge and at_their_mercy'),
'hard_lot': ('A hard lot',
'You are stuck, worn down or beaten, and it is tempting to despair, complain, or wait for someone to rescue you.',
'near dead_end, which is years of thankless work; this is any hardship'),
'used_up': ('Worked hard, badly repaid',
'You carry the load for the one you serve, and they take you for granted, work you into the ground or skim what you need.',
'near overlooked and bow_for_your_living'),
'predator_inside': ('Letting a known predator inside',
'Someone with a predator\'s instincts is about to be let in, trained or left in charge, and you know what they are.',
'near warning'),
'lions_share': ('The lion\'s share',
'You did the work alongside someone far stronger, and the proceeds are being divided on their terms.',
'near used and power_over_you'),
'bad_record': ('A bad record when you need trust',
'You need people to believe or help you now, and your record gives them every reason not to.',
'near unbelievable_truth, but here the listeners are right to doubt you'),
'asking_above': ('Asking the power above for a favour',
'You can ask someone far above you for a favour, a change or a rescue, and what you ask for may not serve you.',
'near patron and benefactor; the fables\' petitions to Jupiter'),
'please_everyone': ('Trying to please everyone',
'Several people want different things of you, and changing to suit each one in turn is tempting.',
'near peace_at_home'),
'make_them_move': ('Getting someone to move',
'You need someone to do or give up something, and must choose between coaxing, tricking and forcing them.',
'no v2 entry'),
}
M = 'M'
O = [
# ---- at the mercy of the powerful ----------------------------------------------------------------
('at_their_mercy', 1, 'Answer every charge with the facts.', {'standing':'-'}, M, True),
('at_their_mercy', 5, 'Tell each captor you belong to the side they spare.', {'standing':'+'}, 'The Bat assured him that he was not a mouse, but a bat, and thus a second time escaped.', True),
('at_their_mercy', 26, 'Plead your good character, though you were caught with the wrongdoers.', {'standing':'-'}, 'I have taken you with these robbers, the Cranes, and you must die in their company.', True),
('at_their_mercy', 39, 'Defend yourself against the excuse they have found.', {'standing':'-'}, 'Although you abound in specious apologies, I shall not remain supperless;', True),
('at_their_mercy', 55, 'Ask them to stop, since their sport is your ruin.', {'relationships.powerful':'+'}, 'Pray stop, my boys: what is sport to you, is death to us.', False),
('at_their_mercy', 131, 'Plead how useful you are when they must cut someone.', {'standing':'risk'}, 'The Birdcatcher spared his life, and determined to pick out a fine young Cock just attaining to his comb.', True),
('at_their_mercy', 135, 'Show the one real talent that sets you apart.', {'standing':'+'}, 'made himself known by his voice, and preserved his life by his melody.', True),
('at_their_mercy', 147, 'Plead your usefulness to people your usefulness threatens.', {'standing':'-'}, 'That is the very reason why we must the more kill you,', True),
('at_their_mercy', 197, 'Ask the one who has you for a small favour that may draw help.', {'standing':'+'}, 'some hounds hearing the sound ran up and began chasing the Wolf.', True),
('at_their_mercy', 211, 'Argue that everyone should be equal, with nothing to back it.', {'standing':'-'}, 'Your words, O Hares! are good; but they lack both claws and teeth such as we have.', False),
('at_their_mercy', 217, 'Ask for a small service that puts them off guard.', {'standing':'+', 'relationships.captor':'-'}, 'the Ass, with his heels, kicked his teeth into his mouth and galloped away.', True),
('at_their_mercy', 248, 'Plead that you never did harm yourself, you only urged others on.', {'standing':'-'}, 'That is the very reason for which you should be put to death,', True),
('at_their_mercy', 292, 'Offer to deliver your colleagues in return for your own escape.', {'standing':'-'}, 'I shall now with less scruple take your life, because you are willing to save it at the cost of betraying your friends and relations.', True),
('at_their_mercy', 293, 'Plead that your offence was too small to matter.', {'standing':'-'}, 'Now you shall certainly die by mine own hands, for no evil, whether it be small or large, ought to be tolerated.', True),
('station', 43, 'Agree to the powerful suitor, on a condition that leaves them harmless.', {'relationships.powerful':'-'}, 'the Woodman, no longer afraid, set upon him with his club, and drove him away into the forest.', True),
('power_over_you', 205, 'Give a vain leader your honest opinion.', {'standing':'-'}, 'The King of the Apes, enraged at hearing these truths, gave him over to the teeth and claws of his companions.', True),
('power_over_you', 205, 'Flatter a vain leader.', {'money':'+'}, 'The Ape and all his court, gratified with the lie, commanded that a handsome present be given to the flatterer.', True),
# ---- a friendly approach from a predator ----------------------------------------------------------
('new:friendly_predator', 36, 'Stay outside and ask after them from a safe distance.', {'relationships.predator':'-'}, 'I notice that there are many prints of feet entering your cave, but I see no trace of any returning.', False),
('new:friendly_predator', 36, 'Go in when they call you close.', {'standing':'-'}, 'The beasts expressed their sorrow, and came one by one to his den, where the Lion devoured them.', True),
('new:friendly_predator', 63, 'Dismiss your protectors to make peace with your enemies.', {'standing':'-'}, 'The Sheep, poor silly creatures, were easily beguiled and dismissed the Dogs, whereupon the Wolves destroyed the unguarded flock at their own pleasure.', True),
('new:friendly_predator', 64, 'Tell the helper you will stay well if they go away.', {'relationships.predator':'-'}, 'We are all very well, and shall continue so, if you will only be good enough to go away, and leave us as we are.', False),
('new:friendly_predator', 92, 'Refuse the small favour that would bring you in reach.', {'relationships.predator':'-'}, M, False),
('new:friendly_predator', 96, 'Speak up to prove the flatterer\'s one doubt wrong.', {'money':'-', 'standing':'-'}, 'My good Crow, your voice is right enough, but your wit is wanting.', True),
('new:friendly_predator', 144, 'Stay put, however harmless they now look.', {'relationships.predator':'-'}, 'even though you should turn into a meal-bag, we will not come near you.', False),
('new:friendly_predator', 192, 'Act at once on a stranger\'s alarming story.', {'money':'-'}, 'The Thief made off with the coat and did not return again to the inn.', True),
('new:friendly_predator', 207, 'Go for the tempting thing lying in the dark corner.', {'standing':'-'}, 'A Mouse, supposing him to be food, leaped upon him, and was instantly caught and squeezed to death.', True),
('new:friendly_predator', 215, 'Send the flatterer round to the one who guards you.', {'relationships.predator':'-'}, 'When the Fox approached the tree, the Dog sprang out and caught him, and tore him to pieces.', True),
('new:friendly_predator', 219, 'Look at the preparations and leave without a word.', {'relationships.host':'-'}, 'The Lion inquired why he went off so abruptly without a word of salutation to his host', True),
('new:friendly_predator', 221, 'Accept the sudden praise and invitation of someone you annoyed.', {'standing':'-'}, 'The Owl came forth from her hollow, seized her, and put her to death.', True),
('new:friendly_predator', 225, 'Stay on your secure ledge and name what they are after.', {'relationships.predator':'-'}, 'it is not for the pasture that you invite me, but for yourself, who are in want of food.', False),
('in_your_keeping', 204, 'Refuse a stranger\'s sudden favours and keep watch.', {'relationships.stranger':'-'}, 'This sudden kindness at your hands will only make me more watchful', False),
('sure_thing', 73, 'Take up the windfall a jealous colleague says they kept for you.', {'standing':'-'}, 'The Monkey approached carelessly and was caught in the trap;', True),
('used', 30, 'Let the one in trouble climb out on your back, on a promise to pull you after.', {'relationships.partner':'-', 'standing':'-'}, 'he safely reached the mouth of the well and made off as fast as he could.', True),
('used', 309, 'Hand your catch to someone who says they will pay for it.', {'money':'-'}, 'However, when the Horseman got the hare, he rode off as fast as he could.', True),
('your_scheme', 185, 'Warn each neighbour privately that the other plots against them.', {'money':'+'}, 'afforded ample provision for the Cat and her kittens.', True),
('your_scheme', 56, 'Repeat the shortcut that lightened your load once.', {'money':'-'}, 'And thus his trick recoiled on him, for he now carried on his back a double burden.', True),
('your_scheme', 218, 'Out of envy, urge a colleague to fake an illness to escape work.', {'standing':'-'}, 'They at once killed the Goat, and so healed the Ass.', True),
('compromise_for_money', 172, 'Hand over what you guard, for a share with the rivals.', {'standing':'-'}, 'entering the den of the Wolves, they were set upon and torn to pieces.', True),
('compromise_for_money', 268, 'Tell the truth about what you lost, though a richer answer is offered.', {'money':'+'}, 'Mercury, pleased with his honesty, gave him the golden and silver axes in addition to his own.', True),
('compromise_for_money', 268, 'Claim the richer thing is yours.', {'money':'-'}, 'not only took away the golden axe, but refused to recover for him the axe he had thrown into the pool.', True),
('pressed_to_wrong', 20, 'Hand your partner over to the powerful in return for your own safety.', {'standing':'-', 'relationships.partner':'-'}, 'The Lion, seeing that the Ass was secured, immediately clutched the Fox, and attacked the Ass at his leisure.', True),
('strings', 199, 'Accept a powerful helper\'s harness to get your revenge.', {'standing':'-'}, 'instead of obtaining revenge on the Stag, he had enslaved himself to the service of man.', True),
('strings', 115, 'Turn down a comfortable post that comes with a chain.', {'money':'-'}, 'May no friend of mine ever be in such a plight; for the weight of this chain is enough to spoil the appetite.', False),
# ---- trading hardships, and asking the power above ------------------------------------------------
('new:better_the_devil', 47, 'Keep demanding a stronger leader from above.', {'standing':'-'}, 'sent a Heron, who preyed upon the Frogs day by day till there were none left to croak upon the lake.', True),
('new:better_the_devil', 53, 'Keep the harm you know rather than risk a worse replacement.', {'standing':'+'}, M, False),
('new:better_the_devil', 76, 'Get rid of what sets the harsh pace.', {'money':'-'}, 'woke them up to their work in the middle of the night.', True),
('new:better_the_devil', 79, 'Invite a powerful protector inside.', {'standing':'-'}, 'he made more havoc and slew a larger number of them in one day than the Kite could pounce upon in a whole year.', True),
('new:better_the_devil', 109, 'Sell what you have and stake it all on a calm-looking trade.', {'money':'-'}, 'he threw all his merchandise overboard, and barely escaped with his life in the empty ship.', True),
('new:better_the_devil', 125, 'Ask for a new master each time you are unhappy.', {'money':'-'}, 'The Ass found that he had fallen into worse hands', True),
('new:better_the_devil', 136, 'Flee into the shelter of someone more dangerous.', {'standing':'-'}, 'when she was safe within the cave, sprang upon her and tore her to pieces.', True),
('new:better_the_devil', 176, 'Leave the field you are made for to chase better prospects.', {'standing':'-'}, 'A Fox came across him, and being very hungry ate him up.', True),
('new:better_the_devil', 177, 'Refuse to fight a takeover, since any master will work you the same.', {'relationships.boss':'-'}, M, False),
('new:better_the_devil', 178, 'Keep the sated drainers rather than invite hungrier ones.', {'money':'+'}, 'others more hungry will come in their place, and will drink up all the blood I have left.', False),
('new:better_the_devil', 189, 'Stay in your familiar, exposed spot.', {'standing':'-'}, 'A few days afterwards a heavy wagon passed through the gully and crushed him to death under its wheels.', True),
('new:better_the_devil', 228, 'Go back to plain fare where you can live without fear.', {'money':'-'}, 'I prefer my bare plowlands and roots from the hedgerow, where I can live in safety, and without fear.', True),
('new:better_the_devil', 291, 'Take refuge with those who may also harm you, rather than with the one chasing you.', {'standing':'+'}, 'It would be better for me to be sacrificed in the Temple than to be eaten by you.', False),
('new:asking_above', 16, 'Wait for someone powerful to rescue you before you try.', {'relationships.patron':'-'}, 'never more pray to me for help, until you have done your best to help yourself', True),
('new:asking_above', 114, 'Ask your patron for the power to punish anyone who touches what is yours.', {'standing':'risk'}, 'if you use your sting, it shall remain in the wound you make, and then you will die from the loss of it.', False),
('new:asking_above', 157, 'Smash your ties with the patron who never helped you.', {'money':'+'}, 'When its head was knocked off, out came a stream of gold, which the Carpenter quickly picked up', True),
('new:asking_above', 297, 'Ask the boss for a colleague\'s distinctions as well as your own.', {'standing':'-'}, 'not only refused to give him horns, but even deprived him of a portion of his ears.', True),
# ---- envy and pretence ----------------------------------------------------------------------------
('new:ape_your_betters', 2, 'Copy the habits of the one you admire.', {'standing':'-'}, 'The Ass resolved that he would live only upon dew, and in a short time died of hunger.', True),
('new:ape_your_betters', 29, 'Pay someone powerful to lift you into a role beyond your nature.', {'standing':'-'}, 'suddenly he let her go, and she fell on a lofty mountain, dashing her shell to pieces.', True),
('new:ape_your_betters', 31, 'Give up the work that feeds you to copy their habits.', {'money':'-'}, 'he could not change their color, while through want of food he perished.', True),
('new:ape_your_betters', 52, 'Try the favourite\'s tricks on the boss.', {'standing':'-'}, 'drove out the Ass to his stable with kicks and clubs and cuffs.', True),
('new:ape_your_betters', 68, 'Puff yourself up to match the one who crushed yours.', {'standing':'-'}, 'you would, I assure you, sooner burst than successfully imitate the hugeness of that monster.', False),
('new:ape_your_betters', 98, 'Copy the antics everyone applauded in a favourite.', {'standing':'-'}, 'The owner went up after him and quickly drove him down, beating him severely with a thick wooden cudgel.', True),
('new:ape_your_betters', 134, 'Try the experts\' job with their tools, untaught.', {'standing':'-'}, 'became tangled in the meshes and drowned.', True),
('new:ape_your_betters', 170, 'Speak up as if your word carried a respected colleague\'s weight.', {'standing':'-'}, M, True),
('new:ape_your_betters', 180, 'Copy an admired style until you lose your own.', {'standing':'-'}, 'in trying to neigh, they forgot how to sing.', False),
('new:ape_your_betters', 186, 'Leave your peers to live among those at the top.', {'standing':'-'}, 'in a herd of lions you are definitely a wolf.', False),
('new:ape_your_betters', 258, 'Try a bigger version of the star performer\'s feat.', {'standing':'-'}, 'He at once clipped the Jackdaw’s wings, and taking him home at night, gave him to his children.', True),
('new:ape_your_betters', 265, 'Get up and try to outdo the colleague everyone applauded.', {'standing':'-'}, 'the Beasts, in a fit of indignation, set upon him with clubs and drove him out of the assembly.', True),
('pretence', 40, 'Disguise yourself as one of the harmless to get what you want.', {'standing':'-'}, 'mistakenly caught up the Wolf instead of a sheep, and killed him instantly.', True),
('pretence', 42, 'Boast of a feat done far away, with witnesses.', {'standing':'-'}, 'Suppose this to be Rhodes, and leap for us.', False),
('pretence', 65, 'Present yourself decked in achievements borrowed from colleagues.', {'standing':'-', 'relationships.colleagues':'-'}, 'each plucked from him his own feathers, leaving the Jackdaw nothing but a Jackdaw.', True),
('pretence', 198, 'Lie about your background to someone helping you as one of their own.', {'standing':'-'}, 'The Dolphin, indignant at these falsehoods, dipped the Monkey under the water and drowned him.', True),
('pretence', 200, 'Disguise yourself to share a better-fed group\'s benefits.', {'standing':'-', 'relationships.own_kind':'-'}, 'They too, not recognizing him on account of his color, expelled him from living with them.', True),
('pretence', 201, 'Inflate your background where nobody can contradict you.', {'standing':'-'}, M, False),
('pretence', 234, 'Confess when your fake expertise is about to be tested.', {'standing':'-'}, 'confessed that he had no knowledge of medicine, and was only made famous by the stupid clamors of the crowd.', True),
('pretence', 251, 'Borrow the trappings of authority and frighten people with them.', {'standing':'-'}, 'I might possibly have been frightened myself, if I had not heard your bray.', True),
('courage_doubted', 138, 'Talk big, then back out when offered the real thing.', {'standing':'-'}, 'it is his track only I am in search of, not the Lion himself.', False),
('courage_doubted', 270, 'Run when your partner is in danger, then boast once it is over.', {'relationships.partner':'-'}, 'no dependence can be placed on your valor.', True),
('habit', 148, 'Let an old appetite break your polished act.', {'standing':'-'}, 'The dancing spectacle thus came to an end amidst the laughter and ridicule of the audience.', True),
('habit', 302, 'Let an old temptation pull you out of your new role.', {'standing':'-'}, 'Venus was much disappointed and again caused her to return to her former shape.', True),
('caught_out', 99, 'Relax once the careless have passed you by.', {'standing':'-'}, 'he ordered that the Stag should be seized and killed.', True),
('caught_out', 160, 'Laugh along when your secret is exposed.', {'relationships.companions':'+'}, 'with great glee joined in the joke', False),
('cover_up_spiral', 41, 'Ask the witness to keep quiet about the damage.', {'relationships.boss':'-'}, 'the horn will speak though I be silent.', False),
# ---- riding high, and the sneer -------------------------------------------------------------------
('new:riding_high', 27, 'Make a great commotion about what you are about to produce.', {'standing':'-'}, 'out came a Mouse.', False),
('new:riding_high', 57, 'Parade the mark you take for an honour.', {'standing':'-'}, 'a public notice to all men to avoid you as an ill mannered dog.', True),
('new:riding_high', 71, 'Crow over your beaten rival from the most exposed place.', {'standing':'-'}, 'An Eagle sailing through the air pounced upon him and carried him off in his talons.', True),
('new:riding_high', 108, 'Wear your marks of rank into the fight.', {'standing':'-'}, 'The generals, not being able to get in on account of the ornaments on their heads, were all captured and eaten by the Weasels.', True),
('new:riding_high', 110, 'Chase someone powerful who has just backed off from another.', {'standing':'-'}, 'the Lion, turning about, seized him and tore him to pieces.', True),
('new:riding_high', 117, 'Take the bows meant for what you carry as your own.', {'standing':'-'}, 'laid his whip lustily about his shoulders', True),
('new:riding_high', 126, 'Ask, incognito, how highly you are rated.', {'standing':'-'}, 'Well, if you will buy these, I’ll fling you that into the bargain.', True),
('new:riding_high', 141, 'Claim to outshine the people who truly lead.', {'standing':'-'}, 'Boast no more, but henceforth be content to give thy light in silence.', True),
('new:riding_high', 194, 'Celebrate loudly after beating a powerful opponent.', {'standing':'-'}, 'he became entangled in the meshes of a cobweb and was eaten by a spider.', False),
('new:riding_high', 245, 'Think yourself bigger than those above you.', {'standing':'-'}, 'this overestimation of myself is the cause of my destruction.', True),
('new:riding_high', 264, 'Make a show of whether to leave, sure you will be missed.', {'standing':'-'}, 'I did not know you had come, and I shall not miss you when you go away.', True),
('new:riding_high', 290, 'Carry your valuable load with your bells ringing.', {'money':'-', 'standing':'-'}, 'wounded with a sword the Mule carrying the treasure, which they greedily seized while taking no notice of the grain.', False),
('new:sneered_at', 7, 'Answer that your plainness lasts when times turn hard.', {'standing':'+'}, 'The Crow put an end to the dispute by saying,', True),
('new:sneered_at', 11, 'Challenge the one who mocks you, and keep a steady pace.', {'standing':'+'}, M, True),
('new:sneered_at', 11, 'Take the contest for granted and rest.', {'standing':'-'}, 'he saw the Tortoise had reached the goal, and was comfortably dozing after her fatigue.', True),
('new:sneered_at', 22, 'Answer that yours are few but of real quality.', {'standing':'+'}, M, False),
('new:sneered_at', 70, 'Mock the hard worker from your idle favour.', {'standing':'-'}, 'For this you were allowed to live in idleness, because you were presently to be sacrificed.', False),
('new:sneered_at', 155, 'Answer the display of credentials with your wits.', {'standing':'+'}, 'And how much more beautiful than you am I, who am decorated, not in body, but in mind.', False),
('new:sneered_at', 167, 'Answer the show-off with what you can actually do.', {'standing':'+'}, M, False),
('new:sneered_at', 223, 'Hold your peace and wait for their fall.', {'relationships.rival':'-'}, 'The Ass held his peace, and made only a silent appeal to the justice of the gods.', False),
('new:sneered_at', 223, 'Sneer at a laden colleague from your height of favour.', {'standing':'-'}, 'the Horse, having become broken-winded, was sent by his owner to the farm.', False),
('new:sneered_at', 226, 'Heed only the one in charge, not the loud one.', {'relationships.boss':'+'}, 'I only care for him who sits above you', False),
('new:sneered_at', 310, 'Keep up appearances in every season, and mock those who scale back.', {'standing':'-'}, 'at once despoiling it of its beauty and killing the tree.', True),
('new:sneered_at', 310, 'Scale back in lean seasons, whatever the mockers say.', {'standing':'+'}, 'the snow fell through to the ground, and did not injure it at all.', False),
# ---- help asked, and who repays -------------------------------------------------------------------
('plea_for_help', 0, 'Spare someone beneath you who begs to be let off.', {'relationships.spared':'+'}, 'now you know that it is possible for even a Mouse to confer benefits on a Lion.', True),
('plea_for_help', 10, 'Refuse someone who idled while you saved.', {'relationships.asker':'-'}, 'If you were foolish enough to sing all the summer, you must dance supperless to bed in the winter.', False),
('plea_for_help', 10, 'Ask those who saved to share with you, having idled yourself.', {'standing':'-'}, 'If you were foolish enough to sing all the summer, you must dance supperless to bed in the winter.', True),
('plea_for_help', 23, 'Take in and nurse someone known for treachery.', {'relationships.helped':'-', 'standing':'-'}, 'bit its benefactor, inflicting on him a mortal wound.', True),
('plea_for_help', 51, 'Refuse to take any of a struggling partner\'s load.', {'relationships.partner':'-', 'money':'-'}, 'I should not now be bearing, together with his burden, himself as well.', True),
('plea_for_help', 132, 'Help someone in trouble who can give nothing now.', {'relationships.helped':'+'}, 'The Ant, perceiving his design, stung him in the foot.', True),
('plea_for_help', 154, 'Free someone caught in a trap.', {'relationships.freed':'+'}, 'he marveled at the service rendered him by the Eagle.', True),
('plea_for_help', 190, 'Rescue someone from a dangerous enemy.', {'relationships.rescued':'+', 'relationships.enemy':'-'}, 'The Serpent, irritated at the escape of his prey, injected his poison into the drinking horn of the countryman.', True),
('plea_for_help', 230, 'Give to those who already serve you, not to those who promise.', {'relationships.staff':'+'}, 'It is surely better for me to give the water to them than to you.', False),
('plea_for_help', 286, 'Grab for help from someone known to take from everyone.', {'standing':'-'}, 'Having pricked and grievously torn the soles of his feet', True),
('plea_for_help', 296, 'Pull the thorn from someone dangerous who is in pain.', {'relationships.helped':'+', 'standing':'+'}, 'the Shepherd to be pardoned and restored to his friends.', True),
('protecting_hunted', 127, 'Protect the hunted in words while your gestures give them away.', {'relationships.hunted':'-'}, 'the Fox departed without taking any notice of the Woodcutter', True),
('protecting_hunted', 193, 'Help yourself to your protector\'s things once the danger seems past.', {'standing':'-'}, 'seeing the Hart, shot an arrow from his bow and struck it.', True),
('despised', 0, 'Offer your help to someone powerful who laughs at the idea.', {'relationships.powerful':'+'}, 'The Lion laughed and let him go.', True),
('unwelcome_in_home', 261, 'Let someone use your space a little longer.', {'money':'-'}, 'asserted her exclusive right to the place and would not permit the shepherd to approach.', True),
('unwelcome_in_home', 294, 'Keep promising the neighbour who wants you gone that you will leave soon.', {'relationships.neighbour':'+'}, 'the rich man became accustomed to the smell, and feeling no manner of inconvenience, made no further complaints.', False),
('after_your_money', 60, 'Let every well-wisher help themselves while you are down.', {'money':'-'}, 'he died, not from his sickness, but from the failure of the means of living.', True),
# ---- wanting more ---------------------------------------------------------------------------------
('new:grasping', 15, 'Let go of what you hold to snatch at a bigger prize.', {'money':'-'}, 'He thus lost both:', True),
('new:grasping', 21, 'Keep indulging in the easy pleasure.', {'standing':'-'}, 'for the sake of a little pleasure we have destroyed ourselves.', True),
('new:grasping', 46, 'Take half and get your hand free.', {'money':'-'}, 'Be satisfied with half the quantity, and you will readily draw out your hand.', False),
('new:grasping', 95, 'Repeat the pleasing exchange every day.', {'relationships.partner':'-'}, 'If you go on in this way, you will soon destroy by frequent use the pleasure of your exchange', False),
('new:grasping', 113, 'Spend, in your head, what your one asset will become.', {'money':'-'}, 'down fell the milk pail to the ground, and all her imaginary schemes perished in a moment.', True),
('new:grasping', 137, 'Keep the small sure catch rather than wait for a bigger one.', {'money':'+'}, 'I were to forego my present certain gain.', True),
('new:grasping', 139, 'Help yourself freely to what is left unguarded.', {'standing':'-'}, 'he was so full that he was not able to get out', False),
('new:grasping', 151, 'Bite off more than you can swallow in someone else\'s field.', {'standing':'-'}, 'burst its deep gullet-bag and lay down on the shore to die.', True),
('new:grasping', 156, 'Leave the sure win to chase the bigger prize.', {'money':'-'}, 'I am rightly served, for having let go of the food that I had in my hand for the chance of obtaining more.', True),
('new:grasping', 168, 'Break open the steady source to get everything at once.', {'money':'-'}, 'deprived themselves of the gain of which they were assured day by day.', True),
('new:grasping', 179, 'Feed the steady source extra to double what it yields.', {'money':'-'}, 'the Hen became fat and sleek, and never once laid another egg.', True),
('new:grasping', 213, 'Keep the one in hand rather than chase bigger ones not yet in sight.', {'money':'+'}, 'I should indeed have lost my senses if I should let go food ready in my hand', True),
('new:grasping', 266, 'Agree on a plan that needs the impossible to reach what you want.', {'standing':'-'}, 'they burst themselves with drinking long before they reached the hides.', True),
('new:grasping', 279, 'Linger where it is most rewarding.', {'standing':'-'}, 'For the sake of a little pleasant food I have deprived myself of my life.', True),
('mistook_them', 44, 'Hoard what you never use.', {'money':'-'}, 'digging down, came to the lump of gold, and stole it.', True),
('mistook_them', 262, 'Prize your showy strengths over your plain ones.', {'standing':'-'}, 'These feet which would have saved me I despised, and I gloried in these antlers which have proved my destruction.', True),
# ---- divided sides and doubtful partners ----------------------------------------------------------
('new:divided', 4, 'Show your quarrelling people that together they cannot be broken.', {'relationships.team':'+'}, 'if you are divided among yourselves, you will be broken as easily as these sticks.', False),
('new:divided', 75, 'Stop supplying the part you resent.', {'standing':'-'}, 'The whole Body quickly became debilitated', True),
('new:divided', 80, 'Offer to settle a quarrel between powerful factions.', {'relationships.factions':'-'}, 'We would far rather be destroyed in our battle with each other than admit any interference from you in our affairs.', True),
('new:divided', 105, 'Make peace with your rival once you see others waiting to profit.', {'relationships.rival':'+'}, 'They at once made up their quarrel', True),
('new:divided', 124, 'Fight a client over a technicality.', {'money':'-'}, 'The quarrel proceeded from words to blows, and while the men fought, the Ass galloped off.', True),
('new:divided', 152, 'Fight a rival to exhaustion over one prize.', {'money':'-'}, 'He ran in between them, and seizing the Kid scampered off as fast as he could.', True),
('new:divided', 171, 'Give up one junior member when you are asked.', {'standing':'-'}, 'The first step has lost us all.', True),
('new:divided', 185, 'Believe a colleague\'s whisper that your neighbour means you harm.', {'relationships.neighbour':'-', 'standing':'-'}, 'the Eagle, full of fear of the Sow, sat still on the branches, and the Sow, terrified by the Eagle, did not dare to go out from her cave.', True),
('new:divided', 224, 'Go your own way when a smooth talker suggests it.', {'standing':'-'}, 'he attacked them without fear as they fed alone, and feasted on them one by one at his own leisure.', True),
('new:divided', 246, 'Keep switching to whichever side is winning.', {'relationships.both_sides':'-', 'standing':'-'}, 'being condemned by each for his treachery, he was driven forth from the light of day', True),
('new:doubtful_ally', 12, 'Decline to share premises with someone whose work undoes yours.', {'money':'-'}, 'whatever I should whiten, you would immediately blacken again with your charcoal.', False),
('new:doubtful_ally', 37, 'Save yourself and leave your companion to face it.', {'relationships.companion':'-'}, 'Never travel with a friend who deserts you at the approach of danger.', True),
('new:doubtful_ally', 58, 'Treat new allies better than old ones to win them.', {'relationships.new_allies':'-'}, 'the Wild Goats scampered away as fast as they could to the mountains.', True),
('new:doubtful_ally', 90, 'Bind your fortunes to a new acquaintance on their ground.', {'standing':'-'}, 'The unhappy Mouse was soon suffocated by the water', True),
('new:doubtful_ally', 102, 'Swear friendship with an impressive ally from another field.', {'relationships.ally':'-'}, 'The Lion abused him as a traitor.', True),
('new:doubtful_ally', 182, 'Decline to join the weak side against the powerful.', {'relationships.weak_side':'-'}, M, False),
('new:doubtful_ally', 184, 'Refuse a loan guaranteed by someone known for taking and running.', {'relationships.borrower':'-'}, 'How then shall I be able to find you, when the day of payment comes?', False),
('new:doubtful_ally', 256, 'Argue both sides of a question in front of a new ally.', {'relationships.ally':'-'}, 'I can no longer consider you as a friend', True),
('new:doubtful_ally', 259, 'Take from a weaker neighbour who trusted you.', {'standing':'-'}, 'the eaglets, as yet unfledged and helpless, were roasted in their nest and dropped down dead at the bottom of the tree.', False),
('new:doubtful_ally', 277, 'Ask for a surety before allying with someone who can walk away.', {'relationships.ally':'-'}, M, False),
('new:doubtful_ally', 284, 'Doubt a large promise from someone who refuses a small favour now.', {'relationships.partner':'-'}, 'I can’t think that you, who refuse me a little matter now, will by and by confer on me a greater benefit.', False),
('new:doubtful_ally', 299, 'Accept a partner on the strength of their boasts.', {'relationships.partner':'-'}, 'brought back the shabbiest possible mouse, stinking from the length of time it had lain about the fields.', True),
('own_words_back', 120, 'Hold your companion to their own words.', {'relationships.companion':'-'}, M, False),
('own_words_back', 67, 'Rob the client while doing the job, then sue for your fee.', {'money':'-'}, 'I on the contrary affirm that I am still blind;', False),
('newcomer', 254, 'Fall in first with the idlest of the group.', {'standing':'-'}, 'the man put a halter on him and led him back to his owner.', True),
# ---- the lion's share, and predators let in -------------------------------------------------------
('new:lions_share', 3, 'Do the risky job first and ask for the fee after.', {'money':'-'}, M, True),
('new:lions_share', 101, 'Go into a venture with a far stronger partner.', {'money':'-'}, 'unless you willingly resign it to me, and set off as fast as you can.', True),
('new:lions_share', 158, 'Divide the spoils fairly with the powerful partner.', {'standing':'-'}, 'The Lion, bursting out into a great rage, devoured the Ass.', True),
('new:lions_share', 158, 'Leave yourself the smallest share.', {'money':'-', 'standing':'+'}, 'Who has taught you, my very excellent fellow, the art of division? You are perfect to a fraction.', True),
('new:lions_share', 274, 'Go it alone because the stronger partner takes most.', {'standing':'-'}, 'he himself fell prey to the huntsmen and hounds.', True),
('new:predator_inside', 83, 'Train someone to take from others for you.', {'relationships.pupil':'-'}, 'Since you have taught me to steal, you must keep a sharp lookout, or you will lose some of your own flock.', False),
('new:predator_inside', 129, 'Shut someone far more dangerous in with you.', {'money':'-'}, 'he flew upon the sheep and killed them, and then attacked the oxen.', True),
('new:predator_inside', 164, 'Warn the boss before the predator is let in.', {'relationships.boss':'+'}, 'Master, how can you expect the sheep to be safe if you admit a wolf into the fold?', False),
('new:predator_inside', 210, 'Leave someone long harmless in charge.', {'money':'-'}, 'The Wolf, now that he had the opportunity, fell upon the sheep, and destroyed the greater part of the flock.', True),
('new:predator_inside', 233, 'Refuse to admit a newcomer with a predator\'s instincts.', {'relationships.newcomer':'-'}, M, False),
('new:predator_inside', 281, 'Nurture newcomers likely to turn on everyone.', {'relationships.newcomers':'-'}, 'will inflict injury on all, beginning with yourself?', False),
('mercy_or_law', 86, 'Praise a small ill-gotten gain from someone in your charge.', {'relationships.charge':'-'}, 'he quickly seized her ear with his teeth and bit it off.', True),
# ---- heedless, warnings, records ------------------------------------------------------------------
('new:heedless', 13, 'Grab the next easy win without looking.', {'money':'-'}, 'If you had but touched me, my friend, you would have lost me, and all your locusts too!', False),
('new:heedless', 30, 'Go in because someone praises it, without checking the way out.', {'standing':'-'}, 'nor have exposed yourself to dangers from which you had no means of escape.', True),
('new:heedless', 32, 'Rush at what you need the moment it appears.', {'standing':'-'}, 'Having broken her wings by the blow, she fell to the ground, and was caught by one of the bystanders.', True),
('new:heedless', 62, 'Keep your eyes on lofty plans and ignore what is at your feet.', {'standing':'-'}, 'he fell accidentally into a deep well.', True),
('new:heedless', 107, 'Guard only against the danger you expect.', {'standing':'-'}, 'Some boatmen sailing by saw her, and taking a successful aim, mortally wounded her.', True),
('new:heedless', 140, 'Ask how you would get out before you go in.', {'standing':'+'}, M, False),
('new:heedless', 150, 'Doze on the edge of danger, and blame luck if you fall.', {'standing':'-'}, M, False),
('new:heedless', 216, 'Stay heavy and slow while danger comes for everyone.', {'standing':'-'}, 'the Geese, being slower of flight and heavier in their bodies, were captured.', True),
('new:heedless', 222, 'Stalk your target without watching your own feet.', {'standing':'-'}, 'he unknowingly trod upon a Viper asleep just before his feet.', True),
('new:heedless', 289, 'Swallow the new thing whole because it looks like the familiar.', {'standing':'-'}, 'Soon afterwards suffering great pain in his stomach', True),
('new:heedless', 308, 'Seize the easy prize lying unguarded.', {'standing':'-'}, 'The Serpent, turning about, bit the Crow with a mortal wound.', True),
('sign', 247, 'Sell your last safeguard on an early sign of good times.', {'money':'-'}, 'winter set in again with renewed frost and cold.', True),
('warning', 35, 'Ignore threats that have never been carried out.', {'standing':'-'}, 'The Farmer, on seeing this, charged his sling with stones, and killed a great number.', True),
('warning', 112, 'Prepare while things are quiet.', {'money':'-'}, M, False),
('warning', 214, 'Stay put while they only ask others, and move when they will act themselves.', {'standing':'+'}, 'It is time now to be off, my little ones, for the man is in earnest this time', False),
('warning', 241, 'Put off building your security once times are easy.', {'money':'+'}, 'it would be neither an easy nor a necessary work to make himself such a house as would accommodate him.', False),
('warning', 249, 'Warn early, though they call you mad.', {'standing':'+'}, 'they wondered at her knowledge and deemed her to be the wisest of birds.', True),
('go_and_look', 106, 'Agree the plan, then wait for someone else to carry it out.', {'standing':'-'}, 'there was no one found to do it.', False),
('new:bad_record', 54, 'Raise a real alarm after false ones.', {'money':'-'}, 'The Wolf, having no cause of fear, at his leisure lacerated or destroyed the whole flock.', True),
('new:bad_record', 104, 'Look for help after cheating everyone who could give it.', {'relationships.allies':'-'}, 'which of the gods do you think will pity you?', True),
('new:bad_record', 235, 'Make a show of a favour you could never have used yourself.', {'relationships.neighbour':'-'}, M, False),
('new:bad_record', 243, 'Make a new vow to a new patron after breaking the last.', {'relationships.patron':'-'}, 'how can I believe thee, who hast disowned and wronged thy former patron?', True),
('waiting', 267, 'Keep waiting for an out-of-season promise to come good.', {'money':'-'}, 'you are indulging a hope strong enough to cheat you, but which will never reward you with enjoyment.', False),
('waiting', 280, 'Wait on an angry threat that seems to promise you a windfall.', {'money':'-'}, 'The Wolf, hearing these words, went home, gasping with cold and hunger.', False),
# ---- criticism, the strong and the weak, revenge -------------------------------------------------
('new:pot_and_kettle', 38, 'Urge everyone to give up what you have lost.', {'standing':'-'}, 'If you had not yourself lost your tail, my friend, you would not thus counsel us.', True),
('new:pot_and_kettle', 84, 'Correct a junior for a fault you share.', {'standing':'-'}, 'The Mother tried in vain, and submitted without remonstrance to the reproof of her child.', True),
('new:pot_and_kettle', 111, 'Tell those who blame you to stop joining you.', {'relationships.critics':'-'}, 'Pray cease to flow into me, and then you will not be made briny.', False),
('new:pot_and_kettle', 116, 'Recommend your own trade as the answer to a crisis.', {'standing':'-'}, M, False),
('new:pot_and_kettle', 153, 'Condemn those above for what you do to those below.', {'standing':'-'}, 'Mercury presented himself, and striking the Philosopher with his wand', True),
('new:pot_and_kettle', 257, 'Find fault with everyone\'s work out of envy.', {'standing':'-'}, 'drove him from his office of judge, and expelled him from the mansions of Olympus.', True),
('wrongly_judged', 9, 'Point out that you have long been ready and are waiting on them.', {'relationships.boss':'-'}, M, False),
('called_to_account', 240, 'Turn the boss\'s anger back on the one accusing you.', {'standing':'+', 'relationships.accuser':'-'}, 'The Wolf was at once taken and flayed;', True),
('new:needling_the_strong', 49, 'Make a great deal of a small liberty.', {'standing':'-'}, 'A fine Lion you are, to be frightened of a Mouse.', False),
('new:needling_the_strong', 66, 'Taunt someone powerful while you are out of their reach.', {'standing':'+'}, M, False),
('new:needling_the_strong', 145, 'Sting the powerful again from your safe place.', {'standing':'+'}, 'The great do not always prevail.', True),
('new:needling_the_strong', 159, 'Attack someone bigger while they shelter from a worse threat.', {'relationships.bigger':'-'}, 'Let that monster go away and I will soon let you know what is the respective strength of a Goat and a Bull.', False),
('new:needling_the_strong', 252, 'Mock a colleague who has just been brought down.', {'standing':'-'}, 'While the Sparrow was thus speaking, a hawk suddenly seized him and killed him.', False),
('new:needling_the_strong', 285, 'Bully the weak and flatter the strong.', {'standing':'+'}, 'I know whom I may bully and whom I must flatter; and I thus prolong my life to a good old age.', False),
('new:needling_the_strong', 298, 'Pelt someone dangerous while they are down.', {'standing':'-'}, 'killing the Shepherds who had attacked him, raged with angry fury.', True),
('new:needling_the_strong', 298, 'Feed someone dangerous while they are down.', {'relationships.powerful':'+'}, 'I return as an enemy only to those who injured me.', True),
('new:needling_the_strong', 305, 'Strike at the small tormentor, whatever it costs you.', {'standing':'-'}, 'gave himself a heavy slap.', True),
('revenge', 149, 'Take a cruel revenge on the one who robbed you.', {'money':'-'}, 'the Farmer reaped nothing that year and returned home grieving sorely.', True),
('revenge', 166, 'Take your tormentor down with you.', {'standing':'-'}, 'At least my enemy and I shall perish together.', True),
('revenge', 244, 'Humiliate a guest with hospitality they cannot use.', {'relationships.guest':'-'}, 'The Fox, unable even to taste it, met with a fitting requital, after the fashion of her own hospitality.', True),
('revenge', 303, 'Pursue the powerful one who wronged you, even into their protector\'s presence.', {'relationships.powerful':'-'}, M, False),
('owning_up', 48, 'Offer peace to someone you hurt, who hurt you worse.', {'relationships.wronged':'-'}, 'There can henceforth be no peace between us;', True),
# ---- hard lots, used up, pleasing everyone, making them move --------------------------------------
('new:hard_lot', 61, 'Grasp the prickly problem boldly.', {'standing':'+'}, 'grasp it boldly, and it will be soft as silk to your hand, and not in the least hurt you.', False),
('new:hard_lot', 72, 'Keep bemoaning the post you have lost.', {'standing':'-'}, 'Forbear', False),
('new:hard_lot', 191, 'Try something new, a little at a time.', {'money':'+'}, 'until he brought the water within his reach and thus saved his life.', True),
('new:hard_lot', 195, 'Walk away saying you never wanted it.', {'standing':'+'}, 'The Grapes are sour, and not ripe as I thought.', False),
('new:hard_lot', 301, 'Rage against the confinement meant to protect you.', {'standing':'-'}, 'A violent fever suddenly set in, from which he died not many days later.', True),
('new:used_up', 74, 'Work a helper into the ground once the crisis has passed.', {'relationships.helper':'-'}, 'You must now go to the war on foot', True),
('new:used_up', 118, 'Leave before the one who sacrificed others turns to you.', {'relationships.boss':'-'}, M, False),
('new:used_up', 119, 'Ask to be praised for what you were, not blamed for what you are.', {'relationships.boss':'+'}, 'I rather deserve to be praised for what I have been, than to be blamed for what I am.', False),
('new:used_up', 161, 'Complain that you are always given the hardest jobs.', {'relationships.boss':'-'}, 'You have only to thank yourselves for the misfortunes to which you are exposed', False),
('new:please_everyone', 59, 'Let each party remake you to their taste.', {'relationships.parties':'-'}, M, True),
('new:please_everyone', 143, 'Change how you work at every critic\'s word.', {'money':'-', 'standing':'-'}, 'by endeavoring to please everybody he had pleased nobody, and lost his Ass in the bargain.', True),
('new:make_them_move', 14, 'Stop coaxing and use the tool you have.', {'money':'+'}, 'casting his net into the sea, made an excellent haul of fish.', True),
('new:make_them_move', 69, 'Tell your successors there is treasure hidden in the work.', {'money':'+'}, 'the vines repaid their labor by an extraordinary and superabundant crop.', True),
('new:make_them_move', 242, 'Use warmth, not force.', {'standing':'+'}, 'The Traveler no sooner felt his genial rays than he took off one garment after another', True),
('new:make_them_move', 242, 'Push harder.', {'standing':'-'}, 'the keener his blasts, the closer the Traveler wrapped his cloak around him', True),
]
@@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""Render groups.json + records.json + options_by_entry.json as one self-contained page: REFERENCE.html."""
import html, json, pathlib
here = pathlib.Path(__file__).parent
d = json.loads((here / 'groups.json').read_text())
recs = {r['chunk']: r for r in json.loads((here / 'records.json').read_text())}
stories = json.loads((here.parent / 'stories.json').read_text())
obe = json.loads((here / 'options_by_entry.json').read_text())
new = obe.pop('_new_entries')
e = html.escape
SRC = {'archetype': 'archetype', 'filled': 'filled gap'}
def src(o):
if o['source'].startswith('scene:'):
n = o['source'].split(':')[1]
return f'from fable {n}' + ('' if o['in_group'] else ' <span class="warn">(outside this group)</span>')
return SRC[o['source']]
def moves(m): return ' '.join(f'<code>{e(k)} {e(v)}</code>' for k, v in m.items())
def scene(c):
r = recs[c]
moral = f'<br><i>Moral:</i> {e(r["moral"])}' if r['moral'] else ''
return (f'<details><summary><b>{c}</b> · <i>{e(stories[r["story"]]["title"])}</i> — {e(r["predicament"])}</summary>'
f'<p class="sum">{e(r["summary"])}{moral}</p></details>')
def section(g):
tag = ' <span class="warn">small group, below the 13-fable rule</span>' if g['small'] else ''
return f'''<section><h2>{g["id"]} · {e(g["name"])}{tag}</h2>
<p class="pred">{e(g["predicament"])}</p>
<p class="meta">{e(g["sentence"])} · {len(g["members"])} fables · stage <b>{g["stage"]}</b></p>
<h3>Options</h3><ol>{"".join(f"<li>{e(o['text'])} <span class='src'>{src(o)}</span><br>{moves(o['moves'])}</li>" for o in g["options"])}</ol>
<h3>Fables</h3>{"".join(scene(m["chunk"]) for m in g["members"])}</section>'''
big = ''.join(section(g) for g in d['groups'] if not g['small'])
small = ''.join(section(g) for g in d['groups'] if g['small'])
un = ''.join(scene(c) for c in d['unassigned'])
def entry(k, v):
name = new[k]['name'] + ' <span class="src">(new)</span>' if k in new else e(k)
rows = ''.join(f'<li>{e(o["text"])} {moves(o["moves"])} <span class="src">fable {o["source"].split(":")[1]} · '
f'{"<b>stated</b>" if o["stated"] else "inferred"}</span><br><span class="sum">“{e(o["evidence"])}”</span></li>'
for o in v)
return f'<details><summary><b>{name}</b> — {len(v)} options, {sum(o["stated"] for o in v)} stated</summary><ul>{rows}</ul></details>'
opts = [o for v in obe.values() for o in v]
meas = ''.join(entry(k, v) for k, v in obe.items())
page = f'''<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><title>Aesop Reference Groups</title>
<style>
:root{{--bg:#fbfaf7;--fg:#1d1d1b;--mute:#6b6760;--line:#e2ded6;--acc:#2f5d8a;--warn:#a4481c}}
@media (prefers-color-scheme:dark){{:root:not([data-theme="light"]){{--bg:#191917;--fg:#e8e6e1;--mute:#a09c94;--line:#34332f;--acc:#8ab4dc;--warn:#e6915f}}}}
:root[data-theme="dark"]{{--bg:#191917;--fg:#e8e6e1;--mute:#a09c94;--line:#34332f;--acc:#8ab4dc;--warn:#e6915f}}
body{{background:var(--bg);color:var(--fg);font:16px/1.5 Georgia,serif;max-width:52rem;margin:0 auto;padding:1rem 16px 4rem}}
h1{{font-size:1.5rem}} h2{{font-size:1.15rem;margin:0 0 .3rem}} h3{{font-size:.8rem;text-transform:uppercase;letter-spacing:.06em;color:var(--mute);margin:.9rem 0 .3rem}}
section{{border-top:1px solid var(--line);padding:1.2rem 0}} .pred{{font-size:1.05rem;margin:.2rem 0}}
.meta,.src,.sum{{color:var(--mute);font-size:.88rem}} .warn{{color:var(--warn);font-size:.85rem}}
code{{font:12px ui-monospace,monospace;border:1px solid var(--line);border-radius:3px;padding:0 .3em;margin-right:.2em;overflow-wrap:anywhere}}
li{{margin:.35rem 0}} details{{margin:.2rem 0}} summary{{cursor:pointer}} .sum{{margin:.2rem 0 .5rem 1.2rem}}
</style></head><body>
<h1>Aesop's Fables: reference groups</h1>
<p class="meta"><i>Three Hundred Aesop's Fables</i>, tr. George Fyler Townsend, 1867 (Project Gutenberg). 313 fables, one scene
per fable. Summaries, predicaments, groups, options and the options measurement written by Claude reading every fable:
no inference of any kind. A comparison baseline, not a pack. Option moves give a direction only, never a number.</p>
{big}
<section><h2>Small groups (5-12 fables)</h2><p class="meta">Situations that recur, but below the 13-fable rule this corpus's size sets.</p></section>
{small}
<section><h2>Unassigned ({len(d["unassigned"])})</h2><p class="meta">Fables that fit no situation recurring in five or more fables.</p>{un}</section>
<section><h2>Fables as an option source</h2>
<p class="meta">{len(opts)} options for {len(obe)} catalogue situations, {sum(o["stated"] for o in opts)} of them
<b>stated</b>: the fable's own outcome or moral shows where every move goes. The rest are inferred.</p>{meas}</section>
</body></html>'''
(here / 'REFERENCE.html').write_text(page, encoding='utf-8')
print('wrote', here / 'REFERENCE.html', len(page), 'bytes')
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,36 @@
"""Split the Project Gutenberg text of *Three Hundred Aesop's Fables*, translated by George Fyler Townsend (1867,
eBook #21), one story per fable and ONE SCENE PER FABLE. This departs from the usual scene rule on purpose:
most fables are under 80 words, so the rule's 80-word tail would discard them, and a fable split in two would
lose its turn. Titles come from the contents (after its AESOP'S FABLES line); each must be found, in order, as
a line of its own in the body. The preface and the life of Aesop are dropped. The printed drop capital was lost
in transcription, so many fables open with the first word's initial letter missing (e.g. "WOLF" for "A WOLF")."""
import json, pathlib, re, sys
sys.path.insert(0, '..')
from gutenberg_chunks import gutenberg_body, paragraphs
VOLUME = "Three Hundred Aesop's Fables (tr. George Fyler Townsend, 1867)"
lines = gutenberg_body('corpus/three-hundred-aesops-fables.txt')
c0 = next(i for i, l in enumerate(lines) if l.strip() == 'CONTENTS')
f0 = next(i for i, l in enumerate(lines) if i > c0 and l.strip().upper().replace('Æ', 'AE') in ("AESOP’S FABLES", "AESOP'S FABLES"))
titles = []
for l in lines[f0 + 1:]:
if l.strip(): titles.append(l.strip())
elif titles and not any(x.strip() for x in lines[lines.index(l) + 1:lines.index(l) + 3]): break
titles = [t for t in titles if t not in ('FOOTNOTES', 'INDEX')] # back matter listed in the contents
body0 = next(i for i, l in enumerate(lines) if i > f0 + len(titles) and l.strip() == titles[0])
heads, at = [], body0
for t in titles:
at = next(i for i in range(at, len(lines)) if lines[i].strip() == t and not lines[i].startswith(' '))
heads.append(at); at += 1
end = next(i for i in range(heads[-1] + 1, len(lines)) if lines[i].strip() in ('FOOTNOTES', 'INDEX'))
stories, chunks = [], []
for n, (t, a) in enumerate(zip(titles, heads)):
text = ' '.join(paragraphs('\n'.join(lines[a + 1:(heads[n + 1] if n + 1 < len(heads) else end)])))
w = len(text.split())
stories.append({'volume': VOLUME, 'title': t, 'words': w, 'text': text})
chunks.append({'story': n, 'title': t, 'volume': VOLUME, 'text': text, 'words': w})
pathlib.Path('stories.json').write_text(json.dumps(stories, indent=1, ensure_ascii=False), encoding='utf-8')
pathlib.Path('chunks.json').write_text(json.dumps(chunks, ensure_ascii=False), encoding='utf-8')
ws = sorted(c['words'] for c in chunks)
print(f'fables {len(stories)} words/fable min {ws[0]} median {ws[len(ws)//2]} max {ws[-1]} total {sum(ws):,}')
print('shortest:', min(stories, key=lambda s: s['words'])['title'], '| longest:', max(stories, key=lambda s: s['words'])['title'])
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
test: 41 candidates, 5 labelled 2
FROZEN RULE S1 >= 0.6119 AND S6 <= 0.1603
accepted 5 P2 0.40 P1+ 1.00 R2 0.40
baseline accept all candidates accepted 41 P2 0.12 P1+ 0.51 R2 1.00
baseline B0: tightness filter accepted 18 P2 0.17 P1+ 0.44 R2 0.60
accepted:
T05 label 1 schemes, cons and theft over money
T07 label 1 courtship
T25 label 1 trying to impress or win someone's favour
T35 label 2 coercion over money: threats, bribes, demands for payment
T39 label 2 a spouse schemes against or confronts a spouse over infidelity
rejected label-2:
T16 a newcomer seeks acceptance in a social circle
T17 a proposal or engagement, accepted or refused
T30 a run-in with the police
+77
View File
@@ -0,0 +1,77 @@
"""Check a saved review and turn it into situations.json.
python3 apply_review.py review/k60-s24 # finds the saved review itself
python3 apply_review.py review/k60-s24 ~/Downloads/review-k60-s24.json
Without a file named, it uses the newest review-<run>*.json in the run directory, and
otherwise the newest one in ~/Downloads, where a browser saves it. It prints every problem
at once and writes nothing until the review passes; then it writes situations.json into the
run directory.
"""
import datetime, json, os, pathlib, sys
import review_format as rf
def downloads_dir():
return pathlib.Path(os.environ.get('STP_DOWNLOADS', pathlib.Path.home() / 'Downloads'))
def find_review(run_dir, run_id):
for folder in (run_dir, downloads_dir()):
if folder.is_dir():
found = sorted(folder.glob(f'review-{run_id}*.json'), key=lambda p: p.stat().st_mtime, reverse=True)
if found:
return found[0]
return None
def main(argv):
if len(argv) not in (2, 3):
print(__doc__.strip())
return 2
run_dir = pathlib.Path(argv[1])
cand_path = run_dir / 'candidates.json'
if not cand_path.exists():
print(f'{run_dir} has no candidates.json. Check the folder name, or run review.py first.')
return 1
candidates = json.loads(cand_path.read_text(encoding='utf-8'))
run_id = candidates['run']['id']
review_path = pathlib.Path(argv[2]).expanduser() if len(argv) == 3 else find_review(run_dir, run_id)
if review_path is None or not review_path.exists():
print(f'No saved review found for run {run_id}.')
print(f'Open {run_dir / "REVIEW.html"} in a browser, and at the end press "Save review file".')
print(f'It saves review-{run_id}.json into Downloads, where this looks for it.')
return 1
print(f'reading {review_path}')
try:
form = json.loads(review_path.read_text(encoding='utf-8'))
except json.JSONDecodeError:
print('That file is damaged and cannot be read. Save the review again from REVIEW.html; '
'your progress is still in the browser.')
return 1
errors, warnings, result = rf.validate(candidates, form)
for w in warnings:
print(f'note: {w}')
if errors:
print(f'\nThe review is not finished. {len(errors)} thing(s) to sort out in REVIEW.html, '
f'then save the review file again:')
for e in errors:
print(f' - {e}')
return 1
result['applied'] = datetime.datetime.now().astimezone().isoformat(timespec='seconds')
result['review_file'] = str(review_path)
out = run_dir / 'situations.json'
out.write_text(json.dumps(result, indent=1, ensure_ascii=False), encoding='utf-8')
c = result['counts']
print(f'\n{out}: {c["situations"]} situations ({c["kept"]} kept, {c["same"]} merged in, '
f'{c["dropped"]} dropped, {c["left_out"]} extras left out).')
for s in result['situations']:
print(f' {s["id"]} {s["situation"]} ({s["size"]} scenes, {s["stories"]} stories)')
return 0
if __name__ == '__main__':
sys.exit(main(sys.argv))
+106
View File
@@ -0,0 +1,106 @@
# Bierce reference set: *Tales of Soldiers and Civilians* (1891)
A hand-made comparison baseline for the story-to-pack pipeline, a sibling of the Jacobs set in
`../jacobs/` and the Wharton set in `../wharton/`. Claude read every scene and wrote each summary,
predicament, group and option. **No local inference was used.** Compare the local-model runs against it.
Do not treat it as ground truth: it is one careful reading.
| File | What it is |
| --- | --- |
| `corpus/tales-of-soldiers-and-civilians.txt` | Project Gutenberg #13334 ("Collected Works of Ambrose Bierce, Vol. II", which holds this book and nothing else) |
| `split_gutenberg.py` | → `stories.json` (26 stories: 15 "Soldiers", 11 "Civilians") and `chunks.json` (299 scenes, same 320-word paragraph rule as `../chunk.py`) |
| `dump.py` | `python3 dump.py A B` prints scenes A..B for reading |
| `claude/batches/sNN.json` | per story: `{chunk, story, summary, predicament}`, the same schema as `../jacobs/claude/batches/` |
| `claude/groups_src.py` | the groups, options and per-story protagonist standing |
| `claude/build.py` | checks everything, then writes `claude/records.json` and `claude/groups.json` (unchanged from Jacobs: it resolves paths relative to itself) |
| `claude/page.py` | → `claude/REFERENCE.html`, the readable page |
Rebuild: `python3 split_gutenberg.py && cd claude && python3 build.py && python3 page.py`.
## What is in it
- **299 predicaments**: one sentence each (median 20 words, min 15, max 24), written to the
`redescribe.py` rules: no names, jobs, ranks, places or story-only objects. The `topic_words`
leak check flags 20 of 299. 18 are role or kin words (partner, newcomer, visitor, lover, father,
guest), kept on purpose because the design's cast is roles. The other 2 are "judge" used as a
verb. War roles are kept as roles, not ranks: *enemy*, *comrades*, *superior*, *subordinate*,
*prisoner*, *captor*.
- **31 groups**, each a situation that recurs in **three or more stories** (18 span 3, 10 span 4,
2 span 5, 1 spans 6). No story supplies more than half of any group's members. Each scene sits in at
most one group: **201 are assigned and 98 fit nothing that recurs**. All 26 stories feed at
least one group.
- **Options**: 3 to 5 per group, 128 in all (25 groups have 4, 5 have 5, 1 has 3). 96 come from what
someone in a scene did or refused to do (`scene:N`), 15 are archetypes and 17 fill gaps. **22
scene options cite a scene outside their own group.** That is looser than DESIGN.md's "drawn from
other scenes in the cluster", and `groups.json` marks each one `in_group: false`. As in Wharton,
most of these are the scene just before or after a member, where the character acts.
- **Moves**: spine paths with a direction only (`+`, `-`, `risk`, `set`). There are no magnitudes,
as the design requires. `standing` is moved 65 times, the two skills 66 times and `money` only 9.
Relationship roles are generic (`authority`, `comrades`, `loved_one`, `family`, `rival`, `captor`,
`doubters` and others), and flags record what the player did (`slept_on_watch`,
`duty_over_love`, `struck_blind`, `took_dare`, `guilty_secret`).
- **Skills** proposed for this corpus: `nerve`, reused from Jacobs, for holding steady under fear, and
`wits`, for reading signs and thinking coolly in a tight spot. `wits` fits Bierce's scouts,
trapped men and sceptics better than Jacobs' `cunning` would.
- **Stage** comes from protagonist standing, judged **per story, not per scene**, in `STANDING` in
`groups_src.py`. Enlisted men, a captive, a child, a pauper and two frontier loners rank low.
Officers and professionals rank middle. A planter, a governor, a camp's leading citizen, a rich
idler and an heiress rank high. The groups come out 19 middle, 9 low and 3 high. The skew is the
book's own: most soldier stories are told from an officer's side.
## Against the existing catalogue
The 36-entry catalogue (O. Henry, Wharton, Jacobs) is mostly courtship, secrets and schemes.
**Nine groups overlap an existing entry closely:** G04 private score (`power_over_you`),
G13 alone with the dead (`dread`), G19 truth nobody would believe (`cant_prove_it`), G21 judged
before heard (`wrongly_judged`), G22 tempting chance (`sure_thing` / `strings`), G26 devoted to
one who wronged you (`sacrifice`), G27 someone thought gone turns up (`past_comes_back`), G31 new
to a hardened company (`newcomer`) and G18 warning easy to laugh off (between `dread` and `sign`).
**Three overlap in part:** G03 told to obey without question (`pressed_to_wrong`, plus
`own_words_back` in scene 138), G05 stranger not what they seem (`sure_thing`) and G20 careless
words cost a life (`owning_up`).
**Nineteen are new situation types**:
- **Duty and orders:** G02 duty demands you harm someone you love; G06 your heart is on the other side.
- **Watch and danger:** G01 on watch, and your guard slips; G07 the only way to know is to go and look; G17 the danger you can only wait for; G16 cornered, looking for a way out; G15 condemned and waiting to die.
- **Courage and fear:** G09 refusing the safe way out lest it look like fear; G10 your courage called into question; G11 finding out you are afraid; G12 a gaze you cannot escape.
- **Harm to others:** G14 the one you struck in the dark (mistaken-identity violence); G08 one bold act sweeps everyone into slaughter.
- **Help and suffering:** G23 turned away when you need help most; G24 watching others suffer from safety; G25 trying to save someone who will not be saved.
- **Loss and isolation:** G28 coming home too late; G29 alone with an old grief; G30 lost in the dark, far from help.
## How this corpus differs from Jacobs and Wharton
It is the largest reference set so far, 26 stories against Jacobs' 12 and Wharton's 10. That is
the first corpus inside DESIGN.md's 20-30 range. So the bar was raised to three stories per group,
and 31 groups still cleared it.
**The register is war and horror, and the situations are mostly inner ones.** Where Jacobs and
Wharton recur on money, marriage and secrets, Bierce recurs on fear, duty, courage and dying. That
shows in the moves. `money` hardly appears, and `nerve` carries much of the weight. A pack built
from this corpus will need its numeric search to make `nerve` and `standing` do the work that
`money` does in the other packs.
**Groups straddle the two halves of the book**, and a tool that groups by setting or mood should
split them. G14 "the one you struck in the dark" holds a soldier who shoots his twin brother on
picket and a lawyer who shoots a woman he takes for a panther. G12 "a gaze you cannot escape"
holds a trapped scout staring into his own rifle, a guest facing a snake, a mother facing a
panther and a girl under a mesmerist. G10 "your courage called into question" holds a
battlefield suicide and a ghost-story dare.
**Twist endings concentrate meaning in the last scene.** Many stories turn on a final line: the
rider was his father, the snake was stuffed, the applicant was the founder, the child was a deaf
mute. Those closing scenes often belong to a different situation from the story's body, or to
none. That is one reason 98 scenes stay unassigned. Most of the rest are scene-setting narration
(the Chickamauga march, Owl Creek's dying dream, the Coulter's Notch gun duel) or one-story
material with no second instance, such as the mercy killing in *The Coup de Grâce*.
## Known defects
- Scene 166 ends with the stray part heading `CIVILIANS`. The in-story subtitles and section headings of
*A Son of the Gods*, *An Affair of Outposts*, *One Kind of Officer*, *George Thurston*, *The
Suitable Surroundings* and *The Eyes of the Panther* (for example "HOW TO SAY WHAT IS WORTH
HEARING") stay inline in scene text. They are
not removed, so that `chunks.json` stays the corpus as split.
- Scene 227 is only the mock-archaic epigraph of *The Man and the Snake*. It was summarised anyway
and is in no group.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,62 @@
[
{
"chunk": 0,
"story": 0,
"summary": "A Union soldier lies asleep at his post in a clump of laurel on a cliff-top above a valley, a crime for which the lawful penalty is death.",
"predicament": "Someone trusted to keep watch has fallen asleep at the post, where being found out would cost them their life."
},
{
"chunk": 1,
"story": 0,
"summary": "Five regiments lie hidden in the valley below, meaning to climb past the sleeping sentinel at nightfall and surprise the enemy camp; if they are seen, they are trapped.",
"predicament": "A whole company's plan depends on staying unseen, and one careless lookout could give every one of them away."
},
{
"chunk": 2,
"story": 0,
"summary": "The sentinel, only son of a wealthy Virginian family, had told his father he was joining the Union; the father called him a traitor, told him to do his duty, and asked him not to disturb his dying mother.",
"predicament": "A son tells his father he is going over to the other side, and is told coldly to do his duty."
},
{
"chunk": 3,
"story": 0,
"summary": "He left home, proved himself brave and was picked for this dangerous outpost, but fatigue beat him; now something wakes him and he grips his rifle.",
"predicament": "Someone who left home to prove their worth wakes suddenly at a dangerous post, having failed the trust placed in them."
},
{
"chunk": 4,
"story": 0,
"summary": "He sees on the cliff edge a Confederate horseman, motionless as a statue, looking down into the valley where the regiments hide.",
"predicament": "Someone on watch sees an enemy standing in plain view, looking straight down at the comrades they are meant to protect."
},
{
"chunk": 5,
"story": 0,
"summary": "He aims at the horseman's breast, but the rider turns and seems to look into his eyes; the young soldier shakes, turns faint and drops his face to the leaves.",
"predicament": "Someone who must kill a stranger to protect their comrades is overcome by horror at the very moment of doing it."
},
{
"chunk": 6,
"story": 0,
"summary": "Recovering, he tells himself the rider may have seen nothing and may ride away, then sees an escort watering horses in plain view in the meadow below; the secret is out.",
"predicament": "Someone hoping they will not have to kill sees proof that the other already knows the secret that endangers everyone."
},
{
"chunk": 7,
"story": 0,
"summary": "Recalling his father's parting words about duty, he aims at the horse instead and fires calmly; below, an officer looks up and sees a horseman riding down through the air.",
"predicament": "Someone forced to choose between duty and the life of a person they love remembers a parent's words, and fires."
},
{
"chunk": 8,
"story": 0,
"summary": "The officer watches horse and rider fall, collapses with terror, then searches for the body in the wrong place and returns to camp.",
"predicament": "Someone who has just seen something impossible is too shaken to make sense of it, and searches in the wrong place."
},
{
"chunk": 9,
"story": 0,
"summary": "The officer, knowing better than to tell an incredible truth, says nothing of it; the sentinel tells a sergeant he shot a horse off the rock, and that the rider was his father.",
"predicament": "Someone who has killed their own parent in the line of duty is ordered to report it, and answers without emotion."
}
]
@@ -0,0 +1,92 @@
[
{
"chunk": 10,
"story": 1,
"summary": "A man with bound hands and a noose round his neck stands on planks laid across a railroad bridge, attended by two soldiers and a sergeant, while a captain watches and sentinels close both ends.",
"predicament": "Someone bound and condemned stands at the edge of their own death while the people carrying it out go calmly about it."
},
{
"chunk": 11,
"story": 1,
"summary": "A fort commands the bridge and a company of infantry stands motionless in line to witness the hanging; nobody moves, as military etiquette requires in the presence of death.",
"predicament": "A condemned person faces a silent crowd drawn up to watch, and not one of them will move to help."
},
{
"chunk": 12,
"story": 1,
"summary": "The condemned man is a well-dressed civilian planter of about thirty-five with a kindly face, plainly no common criminal.",
"predicament": "A respectable person, plainly no common criminal, is about to be put to death for something done in a cause."
},
{
"chunk": 13,
"story": 1,
"summary": "The plank he stands on is now held only by the sergeant's weight; unblindfolded, he stares at the water and watches a piece of driftwood that seems to move very slowly.",
"predicament": "Someone whose life hangs on another's single step waits for the signal, and finds their mind drifting to trifles."
},
{
"chunk": 14,
"story": 1,
"summary": "Trying to think of his wife and children, he is tormented by the loud ticking of his watch; he imagines freeing his hands, diving and escaping home, and the captain nods to the sergeant.",
"predicament": "Someone about to die tries to fix their last thoughts on their family, and instead finds themselves planning a desperate escape."
},
{
"chunk": 15,
"story": 1,
"summary": "In a flashback, the planter, an ardent secessionist kept out of the army, longs for distinction; a grey-clad rider tells him that any civilian caught interfering with the railroad will be hanged.",
"predicament": "Someone who has longed to prove themselves in a cause is told of a dangerous chance to do it, and a harsh penalty."
},
{
"chunk": 16,
"story": 1,
"summary": "The rider tells him the bridge is lightly guarded and that dry driftwood lodged against it would burn; the planter is tempted, and the rider turns out to be an enemy scout.",
"predicament": "A friendly stranger hands someone eager to prove themselves an easy chance at glory, and it is really a trap."
},
{
"chunk": 17,
"story": 1,
"summary": "Falling through the bridge, he feels agony and swinging, then plunges into the water; he believes the rope has broken and rises toward the surface, determined not to be shot.",
"predicament": "Someone who should already be dead believes they have been given an impossible second chance, and grabs at it."
},
{
"chunk": 18,
"story": 1,
"summary": "His hands seem to free themselves and tear off the noose; in agony, he surfaces and draws a great shrieking breath.",
"predicament": "Someone whose body fights to live against their own will struggles to the surface in unbearable pain."
},
{
"chunk": 19,
"story": 1,
"summary": "His senses are unnaturally sharp; he sees every leaf and insect, and turning, sees his executioners on the bridge pointing and shouting at him.",
"predicament": "Someone escaping death sees the world with impossible clarity, and sees the people who meant to kill them pointing at them."
},
{
"chunk": 20,
"story": 1,
"summary": "The sentinels fire and miss; a lieutenant calmly orders a volley, and he dives deep as the bullets sink around him.",
"predicament": "Someone fleeing for their life hears the calm order that will send a volley after them, and has only seconds to hide."
},
{
"chunk": 21,
"story": 1,
"summary": "He swims downstream, reasoning coolly about the next volley, as the fort's cannon sends a shot plunging near him; he resolves to watch the gun's smoke.",
"predicament": "Someone hunted by a far stronger force keeps their head and tries to reason out the enemy's next move."
},
{
"chunk": 22,
"story": 1,
"summary": "A whirlpool flings him ashore out of sight; he weeps with joy, then grapeshot sends him running into a forest that seems endless and uncanny.",
"predicament": "Someone who seems to have escaped certain death is overwhelmed with joy, then finds the familiar world strangely changed."
},
{
"chunk": 23,
"story": 1,
"summary": "Exhausted and starving, he follows a strange straight road under unfamiliar stars, hearing whispers in an unknown tongue, his neck swollen and his feet no longer feeling the ground.",
"predicament": "Someone struggling home to their family finds the road and the stars wrong, and their own body failing them."
},
{
"chunk": 24,
"story": 1,
"summary": "He reaches his home and his wife comes smiling to meet him, but as he reaches for her there is a blow and blinding light; he was hanged, and his body swings beneath the bridge.",
"predicament": "Someone reaches home and the person they love at last, only for it all to prove a dying dream."
}
]
@@ -0,0 +1,62 @@
[
{
"chunk": 25,
"story": 2,
"summary": "A small child slips away from his poor home into a forest, delighted by freedom and the chance of adventure.",
"predicament": "A child who has slipped away from home unseen is thrilled by freedom, with no idea of the danger ahead."
},
{
"chunk": 26,
"story": 2,
"summary": "The six-year-old son of a planter who was once a soldier plays at war with a wooden sword, chasing imaginary enemies across a brook far from home.",
"predicament": "A child playing at the glory an elder taught them pushes further and further from safety, sure of their own victory."
},
{
"chunk": 27,
"story": 2,
"summary": "A rabbit sends him fleeing in terror; lost and torn by brambles, he cries himself to sleep between two rocks while distant thunder sounds and his mother searches frantically at home.",
"predicament": "A child who ran from a small fright is lost far from home, while a parent searches desperately and cannot find them."
},
{
"chunk": 28,
"story": 2,
"summary": "Waking at dusk, he goes the wrong way and sees strange shapes approaching, many of them, all moving toward the brook.",
"predicament": "A lost child alone at dusk sees strange shapes creeping toward them, too many to count, and cannot tell what they are."
},
{
"chunk": 29,
"story": 2,
"summary": "They are wounded men, hundreds of them, crawling toward the creek on hands and knees; some stop and die, others gesture as if praying.",
"predicament": "Someone comes upon a vast crowd of the dying, all crawling silently toward water, and does not understand what they see."
},
{
"chunk": 30,
"story": 2,
"summary": "The child thinks the bloodied men are clowns and rides one like a horse; the man, whose lower jaw is shot away, throws him off and shakes a fist, and the child hides behind a tree.",
"predicament": "An innocent treats other people's agony as a game, until one of the suffering turns on them in fury."
},
{
"chunk": 31,
"story": 2,
"summary": "A red light rises beyond the trees; the child puts himself at the head of the crawling wounded with his wooden sword and solemnly leads them toward it.",
"predicament": "An innocent who understands nothing of the disaster around them puts themselves at the head of its victims and leads them on."
},
{
"chunk": 32,
"story": 2,
"summary": "The ground is littered with the debris of a retreat; a battle had been fought all around the sleeping child hours before, and he heard none of it.",
"predicament": "Someone slept through a catastrophe that happened all around them, and now walks through its wreckage without knowing it."
},
{
"chunk": 33,
"story": 2,
"summary": "The stones in the creek are red with blood; the strongest of the wounded drink and drown, and the child waves his cap to encourage the rest toward the fire.",
"predicament": "Someone cheerfully urges on a group of the doomed, not seeing that those who reached what they wanted have died of it."
},
{
"chunk": 34,
"story": 2,
"summary": "He reaches a burning house and dances before it, then recognises it as his own home and finds his mother dead, killed by a shell; he is a deaf mute and can only make wordless cries.",
"predicament": "Someone returns to the home they wandered from and finds it destroyed and the person they love dead, and cannot even cry out."
}
]
@@ -0,0 +1,68 @@
[
{
"chunk": 35,
"story": 3,
"summary": "An advancing army halts at the edge of a wood before open country and a stone wall on a distant crest; after days of fighting the enemy has vanished, and nobody knows whether they wait behind the wall.",
"predicament": "A large group must cross open ground toward a place where an enemy may be waiting, and nobody knows whether they are."
},
{
"chunk": 36,
"story": 3,
"summary": "The narrator reflects on how strange the enemy's leavings seem, and reads from the tracks that the enemy withdrew in good order up the hill rather than fleeing.",
"predicament": "People who have fought an unseen enemy for days study the signs they left, trying to guess whether they are truly gone."
},
{
"chunk": 37,
"story": 3,
"summary": "The commander orders the skirmish line forward; a young officer in full dress on a white horse with a scarlet blanket gallops up, and the whole line laughs at his vanity.",
"predicament": "A showy young newcomer rides in front of hardened veterans, who laugh at him as a vain fool."
},
{
"chunk": 38,
"story": 3,
"summary": "The young officer asks the commander something the older man is reluctant to grant, then rides alone at a walk straight toward the wall; the skirmishers are halted, and the watching army is stirred to adoration and shame for having laughed.",
"predicament": "Someone the crowd mocked a moment ago volunteers to walk alone toward almost certain death so that the others need not."
},
{
"chunk": 39,
"story": 3,
"summary": "The whole army watches in silence; the horseman swerves toward a break in the wall, meaning to ride through and see what lies beyond.",
"predicament": "Someone takes a mortal risk alone while a whole crowd holds its breath, unable to do anything but watch."
},
{
"chunk": 40,
"story": 3,
"summary": "The narrator explains that skirmishers sent to find out whether the enemy holds the ridge would be slaughtered; the young officer is paying the price alone.",
"predicament": "The only way to learn whether danger waits ahead is to send someone into it, and one person offers to pay the whole price."
},
{
"chunk": 41,
"story": 3,
"summary": "He could surrender and live, but that would answer nothing; for his errand to succeed he must either return unharmed or be shot dead before his comrades' eyes.",
"predicament": "Someone on a lonely errand could save themselves by giving up, but that would make the whole sacrifice worthless."
},
{
"chunk": 42,
"story": 3,
"summary": "Having seen the enemy line, he gallops back and forth along the crest trying to force the hidden troops to reveal themselves by firing on him, while they hold their fire.",
"predicament": "Someone who has found out the truth cannot bring word back, so they try to force the enemy to reveal it themselves."
},
{
"chunk": 43,
"story": 3,
"summary": "He dashes at the wall, then turns and races back; the wall erupts in rifle fire and artillery, he survives the first volley to a great cheer, and shells begin falling among the watching troops.",
"predicament": "Someone who has drawn every enemy weapon onto themselves races for home while their comrades cheer and the danger spreads to them."
},
{
"chunk": 44,
"story": 3,
"summary": "His horse is killed; he stands and salutes with his sabre before he falls, and his comrades, choking with emotion, surge forward against orders into the enemy's fire.",
"predicament": "A crowd moved by one person's sacrifice rushes forward against orders, throwing away the very lives it was meant to save."
},
{
"chunk": 45,
"story": 3,
"summary": "The commander, unmoved, sounds the halt; the lines fall back carrying their wounded and the dead, and the narrator laments the many needless deaths and the hero's vain devotion.",
"predicament": "Someone in command must call back a crowd carried away by grief, knowing the sacrifice that inspired them was wasted."
}
]
@@ -0,0 +1,122 @@
[
{
"chunk": 46,
"story": 4,
"summary": "A fearless private detailed as a scout slips out past the earthworks, sent by his general to creep as close to the enemy lines as possible and learn all he can.",
"predicament": "Someone trusted for their nerve is sent alone into hostile ground to find out what the enemy is doing."
},
{
"chunk": 47,
"story": 4,
"summary": "He passes the picket line, where a man remarks that the enemy will soon have his rifle, and creeps forward through the thicket, steady and alert.",
"predicament": "Someone creeping alone toward an enemy's lines hears their own comrades already writing them off as dead."
},
{
"chunk": 48,
"story": 4,
"summary": "He sees an enemy rifle-pit, watches it, reads the signs correctly and stands up: the enemy has gone.",
"predicament": "Someone creeping toward danger must judge from small signs whether the enemy is still there, and stake their life on the answer."
},
{
"chunk": 49,
"story": 4,
"summary": "To be certain, he pushes on to a derelict plantation and hides in a tumbledown shed on posts, from which he sees the enemy's rear-guard climbing a mountain road.",
"predicament": "Someone who already knows enough to report pushes further on to be certain, into a shaky and exposed hiding place."
},
{
"chunk": 50,
"story": 4,
"summary": "His duty is to hurry back and report, but the retreating column tempts him, and he cocks and sets his rifle to fire into it.",
"predicament": "Someone whose duty is to hurry back with news is tempted to stay and strike a blow that changes nothing."
},
{
"chunk": 51,
"story": 4,
"summary": "The narrator traces how fate brought a deserter from Europe to command an enemy battery two miles away, where, idling, he fires a shot at what he takes for enemy officers, and it flies high.",
"predicament": "A careless act by a stranger far away, done only to pass the time, is about to decide someone's fate."
},
{
"chunk": 52,
"story": 4,
"summary": "The stray shell smashes a post and brings the shed down on him; half-conscious, he imagines himself buried with his widow kneeling on his grave.",
"predicament": "Someone knocked senseless in an instant wakes believing they are already dead and buried, unable to speak to those they love."
},
{
"chunk": 53,
"story": 4,
"summary": "Coming round, he hears a voice saying he is caught like a rat in a trap, and calmly opens his eyes to reconnoitre and plan his defence.",
"predicament": "Someone who realises they are trapped gathers themselves to size up their situation calmly, as if it were an enemy."
},
{
"chunk": 54,
"story": 4,
"summary": "He finds himself pinned by beams across his chest, legs and head, with only his right arm partly free; he is unhurt.",
"predicament": "Someone pinned and helpless, unhurt but unable to move, takes stock of the little freedom left to them."
},
{
"chunk": 55,
"story": 4,
"summary": "He tries every way to get his free hand onto the beam across his chest and cannot, and gives up that attempt.",
"predicament": "Someone trapped tries every way to free themselves, and has to admit the first way out is no way at all."
},
{
"chunk": 56,
"story": 4,
"summary": "He sees the muzzle of his own rifle protruding from the wreckage, aimed at the exact centre of his forehead.",
"predicament": "Someone trapped and helpless finds their own weapon pointed straight at them, and can do nothing to turn it away."
},
{
"chunk": 57,
"story": 4,
"summary": "He remembers he left it cocked with a hair trigger; he feels uneasy but not afraid, recalls facing guns before, and looks away.",
"predicament": "Someone proud of their courage faces a danger they made themselves, and tells themselves it is nothing to fear."
},
{
"chunk": 58,
"story": 4,
"summary": "Struggling to free his feet, he fears disturbing the rifle; he recalls a past blunder with a loaded weapon, then distracts himself with the trees and the thought that he faces north, toward his family.",
"predicament": "Someone trapped under a threat they cannot remove tries to distract themselves, and their thoughts drift to their family."
},
{
"chunk": 59,
"story": 4,
"summary": "He decides to wait to be found, but a pain grows in his forehead whenever he closes his eyes; he drifts into childhood memories, and when he cries out he hears fear in his own voice and denies it.",
"predicament": "Someone who has always been fearless hears terror in their own voice for the first time, and lies to themselves about it."
},
{
"chunk": 60,
"story": 4,
"summary": "He cannot look away from the muzzle, beats his hand bloody in delirium, resolves at least to die game, and curses rats that climb near the trigger.",
"predicament": "Someone certain they are going to die resolves at least to die bravely, but cannot bear not knowing when."
},
{
"chunk": 61,
"story": 4,
"summary": "The pain seems to bore through his head; memory, home and glory are erased, and the strong, brave man trembles and screams with terror.",
"predicament": "Someone renowned for courage is broken by waiting for death, until nothing is left of them but fear."
},
{
"chunk": 62,
"story": 4,
"summary": "He works a loose strip of board free and inches it toward the rifle, hoping to push the muzzle aside or deflect the bullet; the effort steadies him, though he is still terrified.",
"predicament": "Someone paralysed by terror finds one small thing to do against the danger, and doing it steadies them."
},
{
"chunk": 63,
"story": 4,
"summary": "The board jams short; terror returns, then he grows calm and deliberately pushes it against the trigger. The rifle was already empty, but the shock kills him.",
"predicament": "Someone who can no longer bear waiting for death decides to face it head on and end the waiting themselves."
},
{
"chunk": 64,
"story": 4,
"summary": "The scout's brother, commanding the picket line, hears a distant crash and notes the time; he is then ordered to advance, since the enemy is thought to have retreated.",
"predicament": "Someone waiting anxiously for a relative sent into danger hears a distant sound, and is ordered forward before knowing what it means."
},
{
"chunk": 65,
"story": 4,
"summary": "The skirmishers pass the ruin; the brother glances at a dust-grey body in the timbers, takes it for an enemy dead a week, and moves on, checking his watch: twenty-two minutes have passed.",
"predicament": "Someone passes the body of their own relative without recognising it, and dismisses it as a long-dead stranger."
}
]
@@ -0,0 +1,56 @@
[
{
"chunk": 66,
"story": 5,
"summary": "The narrator introduces the best officer on the general's staff, a tall, handsome, courteous and fearless newcomer from another regiment, conspicuous in full uniform in action.",
"predicament": "A gifted newcomer joins a close-knit group whose members know nothing of him, and quickly stands out among them."
},
{
"chunk": 67,
"story": 5,
"summary": "The staff grow fond of him but are dismayed that he is vain of his courage: in every battle he refuses to take cover, standing exposed where braver, older men sensibly hide.",
"predicament": "Someone admired by everyone refuses ever to take cover, and the people who like them watch fearing the worst."
},
{
"chunk": 68,
"story": 5,
"summary": "Sent with orders across fire-swept ground, he walks upright instead of running, holding every eye; a fellow officer offers to bet he will be killed before he reaches the ditch.",
"predicament": "Someone walks upright through danger where everyone else runs, while their own comrades lay bets on how soon they will fall."
},
{
"chunk": 69,
"story": 5,
"summary": "He never boasts and turns remonstrance aside with a joke; when the captain who warned him is killed, he tends the body in the middle of a fire-swept road, and his comrades wish he were not a fool.",
"predicament": "Friends try to talk someone out of needless risks, and are laughed off with a joke they have no answer to."
},
{
"chunk": 70,
"story": 5,
"summary": "Sent with a message the long, safe way round, he instead canters into the open field under the enemy's guns; the general shouts to stop him, and the private who tries is killed at once.",
"predicament": "Someone given a safe way to carry out an order takes the deadly way instead, and another dies trying to stop them."
},
{
"chunk": 71,
"story": 5,
"summary": "He rides calmly along the enemy's front; both lines break into a furious battle around him, and he ends up standing alone in the shot-swept space, his horse dead.",
"predicament": "One person's needless display of courage sets off a fight in which many others die, and now leaves them stranded alone."
},
{
"chunk": 72,
"story": 5,
"summary": "Blocked by a gully he could have sheltered in, he will neither advance nor retreat and stands waiting to be killed; both sides stop firing, the enemy honours his body, and a year later the narrator finds a woman's love letter in his pocketbook.",
"predicament": "Someone who could save themselves by taking cover will not be seen to shelter, and stands still until they are killed."
},
{
"chunk": 73,
"story": 5,
"summary": "The letter reveals that a rival had told the woman her lover was seen cowering behind a tree, and that she could bear his death but not his cowardice; the narrator visits her to return it, and she is charming and careless.",
"predicament": "Someone learns that a person they love would rather see them dead than hear them called a coward."
},
{
"chunk": 74,
"story": 5,
"summary": "Seeing the bloodstain, she throws the letter into the fire, saying she cannot bear blood, and asks how he died; the narrator, seeing her beauty and her heartlessness, answers that he was bitten by a snake.",
"predicament": "Someone faces the person whose careless words drove a friend to his death, and must decide whether to tell them."
}
]
@@ -0,0 +1,80 @@
[
{
"chunk": 75,
"story": 6,
"summary": "A general, half-mocking, asks a colonel whether his much-praised young artillery captain would like to put a gun in a narrow notch facing twelve enemy guns; the colonel defends his man, and the general says it is the only place.",
"predicament": "A superior teases someone about the courage they boasted of in a subordinate, then turns the tease into a deadly order."
},
{
"chunk": 76,
"story": 6,
"summary": "Among dead horses and men in the notch, the colonel points out there is room for one gun against twelve; the general ironically says the captain is a battery in himself, and the angry colonel cannot retort.",
"predicament": "Someone sees a superior sending a subordinate into hopeless danger out of spite, and their position forbids them to object."
},
{
"chunk": 77,
"story": 6,
"summary": "The young captain rides up and is told to bring up a gun and engage the twelve; after a silence, he asks with effort whether the enemy guns are near the house.",
"predicament": "Someone ordered to open fire on a target realises, with a sinking heart, exactly where that target is."
},
{
"chunk": 78,
"story": 6,
"summary": "Pale and hoarse, he asks whether the order is imperative; the general rides off without a sign, the mortified colonel nearly arrests him, and then the captain brings up a gun and opens fire.",
"predicament": "Someone ordered to do something that will destroy what they love asks whether it is truly required, and is met with silence."
},
{
"chunk": 79,
"story": 6,
"summary": "A hopeless duel of one gun against twelve begins; the gunners concentrate on the gun in front of the house, shells break into its roof, and the colonel remarks on the captain's reluctance to obey.",
"predicament": "Someone who hesitated before an order now carries it out with terrible thoroughness while the onlookers remember only their hesitation."
},
{
"chunk": 80,
"story": 6,
"summary": "The colonel asks that the captain's hesitation be kept quiet; he refuses the infantry permission to silence the guns, as the general forbade it, and an adjutant begins to hint that the captain is a Southerner.",
"predicament": "Someone begins to suspect that a subordinate's deadly order was never about the enemy at all, but a private grudge."
},
{
"chunk": 81,
"story": 6,
"summary": "The adjutant reveals that the general once camped near the captain's home, there was trouble over the captain's wife and a complaint, and the general was transferred; the indignant colonel goes to withdraw the gun himself.",
"predicament": "Someone learns an order was a superior's revenge on a subordinate's family, and resolves to stop it on their own authority."
},
{
"chunk": 82,
"story": 6,
"summary": "The colonel races down the hill with his retinue to the notch, where the sight appals them.",
"predicament": "Someone hurries to call off an order they now know was malicious, afraid of what they will find when they arrive."
},
{
"chunk": 83,
"story": 6,
"summary": "Four guns lie wrecked in the notch; the half-naked, bloodied survivors work the fifth like madmen, sponging it with their comrades' blood, each man replaced as he falls.",
"predicament": "People carrying out a hopeless order keep at it with mad devotion, each one stepping in as the one before falls."
},
{
"chunk": 84,
"story": 6,
"summary": "Riding over the dead to the gun, the colonel stops the firing; a blackened, fiendish figure turns to him and obeys his signal to withdraw. It is the captain.",
"predicament": "Someone steps into a scene of carnage to end it, and finds the person they sent there changed almost beyond recognition."
},
{
"chunk": 85,
"story": 6,
"summary": "The enemy stops firing too, having long since retreated; the brigade camps on the enemy's ground and the colonel's staff settle comfortably in the shattered plantation house, until an orderly reports something wrong in the cellar.",
"predicament": "Victors settle comfortably into the home they wrecked, until someone uneasy reports a presence hidden below them."
},
{
"chunk": 86,
"story": 6,
"summary": "In the cellar they find a man clasping a dead woman and a dead baby, killed by a shell that broke through the floor above.",
"predicament": "Someone discovers that the destruction they ordered has killed a mother and child hidden where no one had looked."
},
{
"chunk": 87,
"story": 6,
"summary": "The man raises his blackened face and calmly says the house is his, the dead are his wife and child, and he is the captain who shelled it.",
"predicament": "Someone forced to obey an order has destroyed their own home and family with their own hands, and sits with the dead."
}
]

Some files were not shown because too many files have changed in this diff Show More