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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6UDQ9o6L6Ey173U7XVou6
2026-09-15 10:23:15 -04:00
39 changed files with 2963 additions and 150 deletions
+27 -29
View File
@@ -25,7 +25,7 @@ real browser.
| Event selection, option gating | done |
| Turn loop, per-turn upkeep | done |
| Content validator + reachability tests | done |
| Corporate Ladder pack — 31 events, two tiers | done |
| Corporate Ladder pack — 32 events, two tiers | done |
| Turn screen, retrospective, feedback widget | done |
| Local-storage save, file export/import | done |
| Single-file build | done |
@@ -34,10 +34,10 @@ real browser.
| 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 |
| Second content pack — The Frontier, 37 events, two tiers | done |
| Setting picker, one save slot per pack | done |
| Pack conformance suite (every pack, every check) | done |
| Played in a real browser | Corporate Ladder yes, first playtest 2026-09-09; **The Frontier not yet** |
| Played in a real browser | Corporate Ladder since 2026-09-09; The Frontier 2026-09-15 (112 turns, 0.3.3) |
The first playtest confirmed the interface, saves and the exported log all work
in a real browser. Its findings — an unexplained daily drain, no sense of the
@@ -58,7 +58,7 @@ 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.
`0.3.4 · dev` when running from source, and the commit it came from when built.
A server is needed during development because browsers refuse ES module imports
over `file://`. If port 8080 is taken, run `python3 -m http.server <port>`
@@ -74,7 +74,7 @@ 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
npm test # 162 tests: engine, both packs, UI wiring, and the build
```
Tests are in three layers. `test/conformance.test.js` runs over **every pack in
@@ -101,34 +101,32 @@ 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.
The MVP is complete and has survived four playtests, and a second setting exists
to prove the engine/content split holds. Adding it required **no engine change**
— and three interface changes, two of which were latent defects a second pack
made visible (a single global save slot, and the one place the UI still named a
setting). See DECISIONS.md #34.
The Frontier had its first human session on 2026-09-15: 112 turns, promoted on
turn 44, both lifestyle purchases taken and the standing charge firing as
designed. What it found — an overcharging debt repayment in both packs, wages no
one could recognise, a sale barn offering what the player already owned, and an
office stage too thin — is what 0.3.4 fixes. The repayment fix needed one small
engine addition: an effect's amount may be read from state. See DECISIONS #38–40.
Open questions, in the order they are likely to matter:
- **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.
- **0.3.4 has not been played.** Everything in it is asserted by tests and
archetype runs, which is the evidence that was not enough before. In simulation
promoted Frontier runs now end with about twice the money they did ($164 against
$88) and less standing, and why is not yet isolated. The next Frontier session
should watch whether money stops mattering at the top. See DECISIONS #40.
- **Dispatch is twelve events against the mailroom's eighteen.** It passes the
variety guard, but it has had far less play than the mailroom. The Frontier's
office had the same thinness in a real session; Dispatch may too.
- **A third tier in either pack.** Two rungs is what both settings have. The
split is now proven across settings; it is not proven across a long ladder.
Waits until the Frontier has been played.
split is now proven across settings; it is not proven across a long ladder. The
Frontier has now been played once, so this is the designer's call to open.
Settled, and not open: Corporate Ladder's negotiation gate stays as it is, and
the feedback widget stays as it is (DECISIONS #35). Money mattering late is
+10 -6
View File
@@ -98,14 +98,17 @@ export const hardTimes = [
'A man from the lender calls the mailroom phone. Marlene answers it. She ' +
'hands it to you without expression and then, notably, without leaving.',
options: [
// Both take what is owed up to their size, never more — the same defect
// a real Frontier session found at the store, present here unplayed.
// Money first, because both effects read `debt` before it moves. See #38.
{
id: 'pay_chunk',
label: 'Pay a real chunk of it down.',
label: 'Pay a real chunk of it down, up to $350.',
requires: [{ path: 'money', op: '>=', value: 350 }],
result: 'He is audibly disappointed.',
effects: [
{ path: 'money', op: 'add', value: -350 },
{ path: 'debt', op: 'add', value: -350 },
{ path: 'money', op: 'add', value: { from: 'debt', times: -1, cap: 350 } },
{ path: 'debt', op: 'add', value: { from: 'debt', times: -1, cap: 350 } },
{ path: 'reputation', op: 'add', value: 1 },
],
},
@@ -113,10 +116,11 @@ export const hardTimes = [
id: 'pay_minimum',
label: 'Pay what you can.',
requires: [{ path: 'money', op: '>=', value: 60 }],
result: 'Sixty dollars, fifty of which is the loan.',
result: 'Up to fifty against the loan, and ten for the privilege of the call.',
effects: [
{ path: 'money', op: 'add', value: -60 },
{ path: 'debt', op: 'add', value: -50 },
{ path: 'money', op: 'add', value: -10 },
{ path: 'money', op: 'add', value: { from: 'debt', times: -1, cap: 50 } },
{ path: 'debt', op: 'add', value: { from: 'debt', times: -1, cap: 50 } },
],
},
{
+1 -1
View File
@@ -15,7 +15,7 @@ export const corporateLadder = {
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',
version: '0.1.1',
turnUnit: 'day',
startingStage: 'mailroom',
// What the interface calls the panel of people. The UI used to hardcode
+122
View File
@@ -0,0 +1,122 @@
// Harrow & Pike's counting house, as it might be staffed.
//
// Nobody here is fixed. Each role has several people who could fill it, and a
// run draws one partner, one chief clerk and two to four clerks from them
// (DECISIONS #41), so a second career in the same pack meets a different
// office. Every candidate carries the same four attributes the player does,
// and a description that should agree with them: a partner written as sharp
// has the wits to see through a bluff, and one written as easily flattered
// does not.
//
// Descriptions may name another role in braces, filled from whoever was drawn
// into it, so a nephew can be the nephew of whichever partner this run has.
export const characters = [
{
id: 'partner',
role_type: 'boss',
title: 'Senior Partner',
candidates: [
{
name: 'Josiah Crane',
description:
'Built the house from one room and a borrowed desk. Remembers every figure ' +
'he has ever been shown, and forgives very few of them.',
attributes: { nerve: 7, wits: 8, charm: 3, resolve: 8 },
startingRelationship: { trust: 40 },
},
{
name: 'Harriet Voss',
description:
'Took over the firm when her husband died and has doubled it since. ' +
'Listens to everything and says about a fifth of it.',
attributes: { nerve: 6, wits: 9, charm: 5, resolve: 7 },
},
{
name: 'Lionel Ashby',
description:
'Inherited the chair and would rather be at his club. Easily flattered, ' +
'easily bored, and sharper than he lets anyone see.',
attributes: { nerve: 4, wits: 6, charm: 7, resolve: 3 },
startingRelationship: { regard: 55 },
},
],
},
{
id: 'chief',
role_type: 'supervisor',
title: 'Chief Clerk',
candidates: [
{
name: 'Silas Pruett',
description:
'Thirty years at the same high desk. Counts the pen nibs on Fridays and ' +
'knows to the penny what the stationery cupboard should hold.',
attributes: { nerve: 5, wits: 6, charm: 2, resolve: 9 },
},
{
name: 'Ada Kemble',
description:
'Runs the floor with a pencil behind her ear and a list, kept in the ' +
'same pencil, of everybody\'s lapses.',
attributes: { nerve: 6, wits: 7, charm: 4, resolve: 7 },
startingRelationship: { trust: 40 },
},
{
name: 'Horace Lumb',
description:
'Wanted your desk for somebody else\'s nephew and has not forgotten it. ' +
'Polite to your face, in a way that takes some effort.',
attributes: { nerve: 4, wits: 5, charm: 5, resolve: 6 },
startingRelationship: { regard: 35 },
},
],
},
{
id: 'clerk',
role_type: 'peer',
title: 'Clerk',
count: [2, 4],
candidates: [
{
name: 'Tom Bassett',
description: 'Cheerful, generous with other people\'s time, and a borrower of small sums he means to repay.',
attributes: { nerve: 5, wits: 4, charm: 7, resolve: 3 },
},
{
name: 'Nell Farrow',
description: 'The quickest pen on the floor and does not mind who knows it. Wants the next desk up, and soon.',
attributes: { nerve: 5, wits: 8, charm: 5, resolve: 6 },
startingRelationship: { regard: 40 },
},
{
name: 'Arthur Quill',
description: 'Timid, exact, and never wrong about a column of figures. Apologises to doors.',
attributes: { nerve: 2, wits: 7, charm: 3, resolve: 7 },
},
{
name: 'Joe Madden',
description: 'The loudest laugh in the building and most of the jokes, not all of them kind.',
attributes: { nerve: 7, wits: 4, charm: 6, resolve: 3 },
},
{
name: 'Lottie Swain',
description: 'Types the partner\'s letters and so knows everything a week early. Tells almost none of it.',
attributes: { nerve: 4, wits: 7, charm: 7, resolve: 5 },
},
{
name: 'Percy Dyer',
description: 'A nephew of {partner}\'s, sent to learn the business. Has so far learned where the kettle is.',
attributes: { nerve: 3, wits: 3, charm: 6, resolve: 2 },
startingRelationship: { trust: 40 },
},
{
name: 'Walt Rudge',
description: 'Twenty years a clerk and content with it. The one everyone asks, and the last to be thanked.',
attributes: { nerve: 6, wits: 6, charm: 4, resolve: 8 },
},
],
},
];
export default characters;
+882
View File
@@ -0,0 +1,882 @@
// A clerk's days at Harrow & Pike.
//
// Every situation here comes from the story-to-pack catalogue (tools/
// story-to-pack/probe/catalogue/v3), where it recurred across public-domain
// office fiction: Torchy's door, Keefe's pay talk, Pooter's cheeky juniors,
// Lorimer's hard boss. The id names the catalogue entry where one fits.
//
// This pack exists to try checks and a drawn cast (DECISIONS #41). Most options
// test one of the four attributes, against a named person or against a set
// difficulty, and say what happens either way; the player sees only the odds
// in words. Names are written as `{partner}`, `{chief}` and `{clerk}` and filled
// from whoever this run drew. An event about "a clerk" lists `clerk` in
// `involves`, and the engine picks one present clerk for the turn.
const moment = (text) => ({ path: 'notable', op: 'push', value: text });
export const events = [
{
id: 'first_day',
stage: 'desk',
weight: 1000,
once: true,
involves: ['clerk'],
description:
'Your first morning at Harrow & Pike. {chief} shows you a stool, a pen, a ' +
'blotter and the clock, in that order, and leaves you to draw your own ' +
'conclusions about which matters most.',
options: [
{
id: 'before_the_clock',
label: 'Be on the stool before the clock is.',
result: '{chief} notices, and writes something down. It may even be good.',
effects: [
{ path: 'relationships.chief.regard', op: 'add', value: 4 },
moment('Began at Harrow & Pike, early.'),
],
},
{
id: 'meet_the_floor',
label: 'Go round and introduce yourself to {clerk}.',
result: '{clerk} shakes your hand and tells you which of the pens leak.',
effects: [
{ path: 'relationships.{clerk}.regard', op: 'add', value: 5 },
moment('Began at Harrow & Pike, and met {clerk} first.'),
],
},
],
},
// The floor: the one event nothing can exclude.
{
id: 'the_ledgers',
stage: 'desk',
weight: 40,
description:
'The ledgers are the same as last week\'s, only longer. {chief} has left a ' +
'note on your blotter about the width of your margins.',
options: [
{
id: 'head_down',
label: 'Keep your head down and work.',
result: 'The day goes into the columns, as days here do.',
effects: [
{ path: 'standing', op: 'add', value: 1 },
{ path: 'relationships.chief.trust', op: 'add', value: 1 },
],
},
{
id: 'stay_late',
label: 'Stay on after six to finish the day book.',
result: 'Overtime is paid, at the rate the firm thinks overtime is worth.',
effects: [
{ path: 'money', op: 'add', value: 2 },
{ path: 'relationships.chief.regard', op: 'add', value: 1 },
],
},
{
id: 'ease_off',
label: 'Make the day last.',
result: 'Nobody notices, today.',
effects: [{ path: 'standing', op: 'add', value: -1 }],
},
],
},
{
id: 'ask_for_more',
stage: 'desk',
weight: 25,
cooldown: 12,
requires: [
{ path: 'turn', op: '>=', value: 10 },
{ path: 'rises', op: '<', value: 3 },
],
description:
'You have been at the desk long enough to know what the work is worth, and ' +
'your lodgings have not got any cheaper. {partner} is in, and alone.',
options: [
{
id: 'ask',
label: 'Knock, and ask {partner} for a rise.',
check: { attribute: 'charm', against: 'partner', opposedBy: 'wits', relationship: 'trust' },
success: {
result: '{partner} looks at you for a long moment and says the firm can manage something.',
effects: [
{ path: 'rises', op: 'add', value: 1 },
{ path: 'standing', op: 'add', value: 3 },
moment('Asked {partner} for a rise, and got it.'),
],
},
failure: {
result: '{partner} asks whether you have any idea what a clerk is worth. You find you do not.',
effects: [
{ path: 'relationships.partner.trust', op: 'add', value: -5 },
{ path: 'standing', op: 'add', value: -2 },
],
},
},
{
id: 'hint',
label: 'Say nothing directly; mention the price of lodgings.',
check: { attribute: 'wits', difficulty: 5 },
success: {
result: '{partner} takes the point and, better, thinks it was a delicate one.',
effects: [{ path: 'relationships.partner.regard', op: 'add', value: 4 }],
},
failure: {
result: '{partner} agrees that lodgings are very dear and goes back to the post.',
effects: [],
},
},
{
id: 'not_yet',
label: 'Wait for a better moment.',
result: 'There will be one. There usually is.',
effects: [],
},
],
},
{
id: 'called_in',
stage: 'desk',
weight: 30,
cooldown: 8,
involves: ['clerk'],
description:
'{chief} has found a column that does not add up, and it is in your hand. You ' +
'are to explain it, standing, in front of the high desk.',
options: [
{
id: 'own_up',
label: 'Say plainly that it was your mistake.',
check: { attribute: 'resolve', difficulty: 4 },
success: {
result: 'It is noted, and then, which is rarer, it is let go.',
effects: [
{ path: 'relationships.partner.trust', op: 'add', value: 4 },
{ path: 'attributes.resolve', op: 'add', value: 1 },
],
},
failure: {
result: 'You start well and finish apologising for the weather.',
effects: [{ path: 'standing', op: 'add', value: -2 }],
},
},
{
id: 'argue',
label: 'Argue that the figures were right.',
check: { attribute: 'wits', against: 'chief' },
success: {
result: 'They were. {chief} checks twice and hands the page back without a word.',
effects: [
{ path: 'standing', op: 'add', value: 3 },
{ path: 'relationships.chief.regard', op: 'add', value: -4 },
],
},
failure: {
result: 'They were not, and now everybody knows you thought they were.',
effects: [
{ path: 'standing', op: 'add', value: -4 },
{ path: 'relationships.partner.trust', op: 'add', value: -3 },
],
},
},
{
id: 'blame',
label: 'Say the figures came from {clerk}\'s desk.',
check: { attribute: 'wits', against: 'chief' },
success: {
result: '{chief} goes to find {clerk}. You do not watch.',
effects: [
{ path: 'standing', op: 'add', value: 1 },
{ path: 'relationships.{clerk}.trust', op: 'add', value: -8 },
],
},
failure: {
result: '{chief} has the day book open at your page before you finish the sentence.',
effects: [
{ path: 'standing', op: 'add', value: -5 },
{ path: 'relationships.{clerk}.trust', op: 'add', value: -10 },
{ path: 'relationships.partner.trust', op: 'add', value: -5 },
moment('Tried to put a mistake on {clerk}, and was found out.'),
],
},
},
],
},
{
id: 'the_door',
stage: 'desk',
weight: 30,
cooldown: 6,
description:
'A caller in a good coat wants {partner} and will not give his name. You ' +
'were told the partner was not to be disturbed before three.',
options: [
{
id: 'turn_away',
label: 'Tell him the partner is out.',
check: { attribute: 'nerve', difficulty: 5 },
success: {
result: 'He goes. {partner} hears of it later and approves of the lie.',
effects: [{ path: 'relationships.partner.trust', op: 'add', value: 3 }],
},
failure: {
result: 'He sits down to wait, and {partner} comes out at a quarter past and finds him there.',
effects: [
{ path: 'relationships.partner.regard', op: 'add', value: -3 },
{ path: 'standing', op: 'add', value: -1 },
],
},
},
{
id: 'size_him_up',
label: 'Size him up and decide for yourself.',
check: { attribute: 'wits', difficulty: 6 },
success: {
result: 'You read him right. He mattered, and he got in, and {partner} knows who let him.',
effects: [
{ path: 'relationships.partner.trust', op: 'add', value: 5 },
{ path: 'standing', op: 'add', value: 2 },
moment('Judged a caller at the door, and judged him right.'),
],
},
failure: {
result: 'He was selling encyclopaedias, and {partner} bought one to get rid of him.',
effects: [{ path: 'relationships.partner.trust', op: 'add', value: -4 }],
},
},
{
id: 'pass_it_on',
label: 'Fetch {chief} and let it be somebody else\'s decision.',
result: '{chief} deals with it, and with you, briefly.',
effects: [{ path: 'standing', op: 'add', value: -1 }],
},
],
},
{
id: 'needled',
stage: 'desk',
weight: 40,
cooldown: 4,
involves: ['clerk'],
description:
'{clerk} reads your last letter aloud in a voice meant to be yours, and the ' +
'floor laughs, and keeps laughing longer than it deserves.',
options: [
{
id: 'let_it_go',
label: 'Let it pass.',
result: 'It passes. They will remember that you let it.',
effects: [
{ path: 'relationships.{clerk}.regard', op: 'add', value: 1 },
{ path: 'standing', op: 'add', value: -1 },
],
},
{
id: 'retort',
label: 'Answer {clerk} back, with the floor listening.',
check: { attribute: 'wits', against: '{clerk}', relationship: 'regard' },
success: {
result: 'It gets a bigger laugh than the letter did, and {clerk} laughs too.',
effects: [
{ path: 'standing', op: 'add', value: 2 },
{ path: 'relationships.{clerk}.regard', op: 'add', value: 3 },
],
},
failure: {
result: 'It comes out too sharp, and the laugh turns on you.',
effects: [
{ path: 'standing', op: 'add', value: -2 },
{ path: 'relationships.{clerk}.regard', op: 'add', value: -3 },
],
},
},
{
id: 'report',
label: 'Mention it to {chief}.',
result: '{chief} has a word. {clerk} knows where it came from.',
effects: [
{ path: 'relationships.chief.trust', op: 'add', value: 2 },
{ path: 'relationships.{clerk}.trust', op: 'add', value: -6 },
{ path: 'standing', op: 'add', value: -1 },
],
},
],
},
{
id: 'red_ink',
stage: 'desk',
weight: 30,
cooldown: 6,
description:
'{chief} has gone over your morning\'s work in red ink, and left the page ' +
'open on the high desk where the whole floor can see it.',
options: [
{
id: 'swallow_it',
label: 'Take it, and do the page again.',
result: 'You do it again. {chief} initials it without looking up.',
effects: [
{ path: 'relationships.chief.regard', op: 'add', value: 2 },
{ path: 'standing', op: 'add', value: -1 },
],
},
{
id: 'answer_back',
label: 'Point out that the red ink is wrong.',
check: { attribute: 'wits', against: 'chief' },
success: {
result: 'It is, in two places. {chief} says nothing, which from {chief.first} is a concession.',
effects: [
{ path: 'standing', op: 'add', value: 2 },
{ path: 'relationships.chief.regard', op: 'add', value: 3 },
],
},
failure: {
result: 'The red ink is right. It usually is.',
effects: [
{ path: 'standing', op: 'add', value: -2 },
{ path: 'relationships.chief.regard', op: 'add', value: -5 },
],
},
},
{
id: 'over_the_head',
label: 'Take the page to {partner}.',
check: { attribute: 'charm', against: 'partner', opposedBy: 'resolve', relationship: 'trust' },
success: {
result: '{partner} reads it, and has a word with {chief}, and {chief} has one with you.',
effects: [
{ path: 'relationships.partner.trust', op: 'add', value: 3 },
{ path: 'relationships.chief.regard', op: 'add', value: -8 },
{ path: 'standing', op: 'add', value: 2 },
moment('Went over {chief}\'s head, and it worked.'),
],
},
failure: {
result: '{partner} says the chief clerk runs the floor, and asks whether there was anything else.',
effects: [
{ path: 'relationships.partner.trust', op: 'add', value: -4 },
{ path: 'relationships.chief.regard', op: 'add', value: -8 },
{ path: 'standing', op: 'add', value: -3 },
],
},
},
],
},
{
id: 'sure_thing',
stage: 'desk',
weight: 15,
cooldown: 20,
description:
'A pleasant man at the chop-house has a share in a tramway in Brazil going ' +
'begging. Twelve dollars today, he says, will be forty by Christmas.',
options: [
{
id: 'buy_in',
label: 'Put in twelve dollars.',
effects: [{ path: 'money', op: 'add', value: -12 }],
check: { attribute: 'wits', difficulty: 7 },
success: {
result: 'Against every expectation, including his, it pays.',
effects: [{ path: 'money', op: 'add', value: 30 }, moment('Bought a share in a tramway, and it paid.')],
},
failure: {
result: 'There is no tramway. There is, briefly, a man.',
effects: [moment('Bought a share in a tramway that did not exist.')],
},
},
{
id: 'see_through',
label: 'Ask him the one question he cannot answer.',
check: { attribute: 'wits', difficulty: 6 },
success: {
result: 'He finds he has an appointment. You find you are sharper than you were.',
effects: [{ path: 'attributes.wits', op: 'add', value: 1 }, moment('Saw through a tramway in Brazil.')],
},
failure: {
result: 'He answers it smoothly, and you are left not knowing.',
effects: [],
},
},
{
id: 'decline',
label: 'Keep your money in your pocket.',
result: 'He is sorry for you, he says, and goes to find someone less careful.',
effects: [],
},
],
},
{
id: 'the_round',
stage: 'desk',
weight: 30,
cooldown: 5,
involves: ['clerk'],
description:
'{clerk} and the others are going to the chop-house after work, and they ' +
'have kept you a place. The round there is not cheap.',
options: [
{
id: 'go',
label: 'Go, and stand your round.',
result: 'A good evening, and a dear one.',
effects: [
{ path: 'money', op: 'add', value: -5 },
{ path: 'relationships.{clerk}.regard', op: 'add', value: 4 },
{ path: 'standing', op: 'add', value: 1 },
],
},
{
id: 'talk_your_way',
label: 'Go, and talk your way out of the round.',
check: { attribute: 'charm', against: '{clerk}', opposedBy: 'wits' },
success: {
result: 'Nobody notices you never reached for your purse, and {clerk} laughs at all your jokes.',
effects: [{ path: 'relationships.{clerk}.regard', op: 'add', value: 3 }],
},
failure: {
result: '{clerk} notices, and says so, and you pay for the round and the remark.',
effects: [
{ path: 'money', op: 'add', value: -5 },
{ path: 'relationships.{clerk}.regard', op: 'add', value: -2 },
],
},
},
{
id: 'excuse',
label: 'Make an excuse and go home.',
result: 'They go without you. They will ask again, perhaps.',
effects: [{ path: 'relationships.{clerk}.regard', op: 'add', value: -2 }],
},
],
},
{
id: 'night_school',
stage: 'desk',
weight: 25,
cooldown: 5,
requires: [{ path: 'money', op: '>=', value: 3 }],
description:
'A card in the tram window: evening classes in bookkeeping, in the art of ' +
'address, and at the boxing club behind the Institute. Three dollars a course.',
options: [
{
id: 'bookkeeping',
label: 'Take the bookkeeping course.',
result: 'Double entry, twice a week. Your columns begin to add up before you do.',
effects: [{ path: 'money', op: 'add', value: -3 }, { path: 'attributes.wits', op: 'add', value: 1 }],
},
{
id: 'address',
label: 'Take the art of address.',
result: 'You learn when to say nothing, which is most of it.',
effects: [{ path: 'money', op: 'add', value: -3 }, { path: 'attributes.charm', op: 'add', value: 1 }],
},
{
id: 'boxing',
label: 'Join the boxing club.',
result: 'You are hit in the face by a draper. It does you a world of good.',
effects: [{ path: 'money', op: 'add', value: -3 }, { path: 'attributes.nerve', op: 'add', value: 1 }],
},
{
id: 'not_this_term',
label: 'Not this term.',
result: 'The card is still there next week.',
effects: [],
},
],
},
{
id: 'stand_in',
stage: 'desk',
weight: 15,
cooldown: 10,
requires: [{ path: 'standing', op: '>=', value: 45 }],
description:
'{partner} is laid up and sends you, of all people, to sit in on the meeting ' +
'with the shipping agents. You are to listen, and to say as little as possible.',
options: [
{
id: 'listen',
label: 'Listen, and take good notes.',
result: 'Your notes are better than the minutes. {partner} keeps them.',
effects: [
{ path: 'relationships.partner.trust', op: 'add', value: 2 },
{ path: 'standing', op: 'add', value: 1 },
],
},
{
id: 'speak_up',
label: 'Say the thing you noticed in their figures.',
check: { attribute: 'wits', difficulty: 7 },
success: {
result: 'You were right, the agents know it, and so, by the afternoon, does {partner}.',
effects: [
{ path: 'relationships.partner.trust', op: 'add', value: 6 },
{ path: 'standing', op: 'add', value: 5 },
moment('Spoke up at the agents\' meeting in {partner}\'s place, and was right.'),
],
},
failure: {
result: 'You were wrong in front of people who were paid to be right.',
effects: [
{ path: 'relationships.partner.trust', op: 'add', value: -4 },
{ path: 'standing', op: 'add', value: -3 },
],
},
},
{
id: 'charm_them',
label: 'Charm the agents over lunch.',
check: { attribute: 'charm', difficulty: 6 },
success: {
result: 'They like you. They say so to {partner}, which is the point.',
effects: [
{ path: 'relationships.partner.regard', op: 'add', value: 5 },
{ path: 'standing', op: 'add', value: 3 },
],
},
failure: {
result: 'The lunch is long, and you pay for it.',
effects: [
{ path: 'money', op: 'add', value: -4 },
{ path: 'standing', op: 'add', value: -1 },
],
},
},
],
},
{
id: 'lost_bill',
stage: 'desk',
weight: 20,
cooldown: 12,
involves: ['clerk'],
description:
'{clerk} has lost a bill of lading, and {chief} is talking about a letter to ' +
'the partner. {clerk} has not looked up from the desk all morning.',
options: [
{
id: 'speak_for',
label: 'Put {clerk}\'s case to {partner} yourself.',
check: { attribute: 'charm', against: 'partner', opposedBy: 'resolve', relationship: 'trust' },
success: {
result: '{partner} hears you out and lets it go. {clerk} does not forget who spoke.',
effects: [
{ path: 'relationships.{clerk}.trust', op: 'add', value: 10 },
{ path: 'relationships.partner.regard', op: 'add', value: 2 },
moment('Spoke up for {clerk}, and it held.'),
],
},
failure: {
result: '{partner} asks whether you would like to share the letter.',
effects: [
{ path: 'relationships.partner.trust', op: 'add', value: -3 },
{ path: 'relationships.{clerk}.trust', op: 'add', value: 4 },
],
},
},
{
id: 'help_look',
label: 'Stay late and help look for it.',
check: { attribute: 'resolve', difficulty: 5 },
success: {
result: 'It is in the wrong drawer, under the right one. You find it at nine.',
effects: [
{ path: 'relationships.{clerk}.trust', op: 'add', value: 8 },
{ path: 'relationships.{clerk}.regard', op: 'add', value: 4 },
],
},
failure: {
result: 'It is not found, but {clerk} saw you look.',
effects: [{ path: 'relationships.{clerk}.regard', op: 'add', value: 2 }],
},
},
{
id: 'keep_out',
label: 'Keep out of it.',
result: 'The letter goes. So, very nearly, does {clerk}.',
effects: [{ path: 'relationships.{clerk}.trust', op: 'add', value: -3 }],
},
],
},
{
id: 'the_butt',
stage: 'desk',
weight: 20,
cooldown: 8,
involves: ['clerk'],
description:
'Somebody has pinned a card to your back, and you have walked the length of ' +
'the floor to {chief}\'s desk wearing it. {clerk} cannot keep a straight face.',
options: [
{
id: 'laugh_along',
label: 'Laugh louder than anybody.',
check: { attribute: 'charm', difficulty: 4 },
success: {
result: 'It becomes your joke instead of theirs.',
effects: [
{ path: 'standing', op: 'add', value: 1 },
{ path: 'relationships.{clerk}.regard', op: 'add', value: 3 },
],
},
failure: {
result: 'The laugh comes out wrong, and everyone hears that it did.',
effects: [{ path: 'standing', op: 'add', value: -2 }],
},
},
{
id: 'dignity',
label: 'Take it off and say nothing at all.',
check: { attribute: 'resolve', difficulty: 4 },
success: {
result: 'It is not funny for long, when nobody is laughing at the front.',
effects: [{ path: 'standing', op: 'add', value: 1 }],
},
failure: {
result: 'Your ears give you away.',
effects: [
{ path: 'standing', op: 'add', value: -1 },
{ path: 'relationships.{clerk}.regard', op: 'add', value: -2 },
],
},
},
{
id: 'get_even',
label: 'Resolve to do something about {clerk} tomorrow.',
result: 'You do. It is not as funny, and {clerk} knows exactly who it was.',
effects: [
{ path: 'relationships.{clerk}.regard', op: 'add', value: -2 },
{ path: 'relationships.{clerk}.trust', op: 'add', value: -3 },
],
},
],
},
{
id: 'saturday',
stage: 'desk',
weight: 60,
cooldown: 5,
requires: [{ path: 'calendar.name', op: '==', value: 'Saturday' }],
description:
'Saturday, and the office shuts at one. The pay envelope is in your inside ' +
'pocket, and the whole of the afternoon is in front of you.',
options: [
{
id: 'new_collar',
label: 'Buy the collar the senior clerks wear.',
result: 'It pinches, and it is noticed.',
effects: [
{ path: 'money', op: 'add', value: -5 },
{ path: 'standing', op: 'add', value: 2 },
],
},
{
id: 'good_dinner',
label: 'Dine somewhere with a tablecloth.',
result: 'A dinner you will describe, later, to people who were not there.',
effects: [{ path: 'money', op: 'add', value: -4 }],
},
{
id: 'pay_back',
label: 'Pay back what you owe the floor.',
requires: [{ path: 'owed', op: '>', value: 0 }],
whenLocked: 'hide',
result: 'Squared, or nearer it. It is a better feeling than the dinner would have been.',
// The money is read from the balance before either moves, so a small
// debt costs what it is and no more (DECISIONS #38).
effects: [
{ path: 'money', op: 'add', value: { from: 'owed', times: -1, cap: 10 } },
{ path: 'owed', op: 'add', value: { from: 'owed', times: -1, cap: 10 } },
],
},
{
id: 'straight_home',
label: 'Straight home, envelope sealed.',
result: 'The envelope is lighter than it was in your imagination, but it is all there.',
effects: [],
},
],
},
{
id: 'family_errand',
stage: 'desk',
weight: 20,
cooldown: 8,
involves: ['clerk'],
description:
'A parcel for {partner}\'s sister has to cross the city before four, in the ' +
'rain, and {partner} has asked for you by name. It is not in the job.',
options: [
{
id: 'take_it',
label: 'Take it yourself, and pay your own tram.',
result: 'The sister gives you tea, and a message for {partner} that is more about you than the parcel.',
effects: [
{ path: 'money', op: 'add', value: -1 },
{ path: 'relationships.partner.regard', op: 'add', value: 4 },
{ path: 'standing', op: 'add', value: -1 },
],
},
{
id: 'make_an_impression',
label: 'Take it, and make yourself agreeable when you get there.',
check: { attribute: 'charm', difficulty: 5 },
success: {
result: 'The sister writes to {partner} that evening. You are mentioned warmly.',
effects: [
{ path: 'relationships.partner.regard', op: 'add', value: 6 },
{ path: 'relationships.partner.trust', op: 'add', value: 2 },
moment('Carried a parcel for {partner}\'s sister, and made a friend of her.'),
],
},
failure: {
result: 'You drip on the carpet and say something about the weather twice.',
effects: [{ path: 'relationships.partner.regard', op: 'add', value: 1 }],
},
},
{
id: 'hand_it_on',
label: 'Hand it on to {clerk}, who is going that way.',
result: '{clerk} goes, and gets the tea, and does not say whose errand it was.',
effects: [
{ path: 'relationships.{clerk}.regard', op: 'add', value: -3 },
{ path: 'relationships.partner.regard', op: 'add', value: -2 },
],
},
],
},
{
id: 'window_desk',
stage: 'desk',
weight: 20,
cooldown: 10,
involves: ['clerk'],
description:
'The desk by the window has come free. {chief} says it will go to whoever ' +
'deserves it, and {clerk} has been in before the clock all week.',
options: [
{
id: 'outwork',
label: 'Come in earlier than {clerk} does.',
check: { attribute: 'resolve', against: '{clerk}' },
success: {
result: 'You are there before the cleaners. The desk is yours, and so is the draught.',
effects: [
{ path: 'standing', op: 'add', value: 4 },
{ path: 'relationships.{clerk}.regard', op: 'add', value: -3 },
moment('Won the desk by the window from {clerk}.'),
],
},
failure: {
result: '{clerk} was there before you, every morning, and has the desk.',
effects: [{ path: 'standing', op: 'add', value: -1 }],
},
},
{
id: 'word_with_chief',
label: 'Have a quiet word with {chief}.',
check: { attribute: 'charm', against: 'chief', opposedBy: 'wits', relationship: 'regard' },
success: {
result: '{chief} decides the matter in your favour, and makes sure {clerk} knows why.',
effects: [
{ path: 'standing', op: 'add', value: 3 },
{ path: 'relationships.{clerk}.trust', op: 'add', value: -6 },
],
},
failure: {
result: '{chief} writes your name down, next to the word "canvassing".',
effects: [
{ path: 'relationships.chief.trust', op: 'add', value: -4 },
{ path: 'standing', op: 'add', value: -2 },
],
},
},
{
id: 'let_them_have_it',
label: 'Let {clerk} have it.',
result: '{clerk} takes the desk and, to your surprise, remembers who did not fight for it.',
effects: [
{ path: 'relationships.{clerk}.regard', op: 'add', value: 5 },
{ path: 'relationships.{clerk}.trust', op: 'add', value: 3 },
],
},
],
},
{
id: 'hard_times',
stage: 'desk',
weight: 400,
cooldown: 2,
requires: [{ path: 'money', op: '<', value: 0 }],
involves: ['clerk'],
description:
'The landlady has knocked twice about the week, and the purse under your ' +
'pillow is empty. It is a long walk to Saturday.',
options: [
{
id: 'copying',
label: 'Take in copying work to do at night.',
result: 'Law papers, by lamp, at a halfpenny a folio. It keeps the landlady quiet.',
effects: [
{ path: 'money', op: 'add', value: 8 },
{ path: 'attributes.resolve', op: 'add', value: 1 },
],
},
{
id: 'borrow',
label: 'Ask {clerk} for the loan of ten dollars.',
check: { attribute: 'charm', against: '{clerk}', opposedBy: 'resolve', relationship: 'trust' },
success: {
result: '{clerk} lends it, and tells you not to mention it, and you will not forget.',
effects: [
{ path: 'money', op: 'add', value: 10 },
{ path: 'owed', op: 'add', value: 10 },
{ path: 'relationships.{clerk}.trust', op: 'add', value: -2 },
],
},
failure: {
result: '{clerk} is short too, but finds you three, and makes a face about it.',
effects: [
{ path: 'money', op: 'add', value: 3 },
{ path: 'owed', op: 'add', value: 3 },
{ path: 'relationships.{clerk}.regard', op: 'add', value: -3 },
],
},
},
{
id: 'pawn_the_watch',
label: 'Pawn your father\'s watch.',
requires: [{ path: 'flags.pawned_watch', op: '==', value: false }],
whenLocked: 'hide',
result: 'Twelve dollars and a ticket. You will get it back. Everybody says that.',
effects: [
{ path: 'money', op: 'add', value: 12 },
{ path: 'flags.pawned_watch', op: 'set', value: true },
moment('Pawned your father\'s watch to pay the landlady.'),
],
},
],
},
];
export default events;
+136
View File
@@ -0,0 +1,136 @@
// The Counting House — a prototype pack for checks and a drawn cast.
//
// Harrow & Pike, a trading firm's counting house, somewhere about 1910. You
// are the newest clerk. The situations are the ones public-domain office
// fiction kept returning to (tools/story-to-pack, catalogue v3); the people are
// drawn fresh each run from several candidates per role, two to four clerks
// among them; and most choices are a check, shown to the player only as odds
// in words. See DECISIONS #41.
//
// One stage, deliberately: this pack exists to find out whether chance and a
// varying cast make the same situations worth replaying, before a whole career
// is built on them.
import { characters } from './characters.js';
import { events } from './events.js';
export const countingHouse = {
id: 'countinghouse',
name: 'The Counting House',
blurb:
'Harrow & Pike, about 1910: a stool, a pen, a chief clerk with red ink, and a ' +
'different office every time you start.',
version: '0.1.0',
turnUnit: 'day',
startingStage: 'desk',
peopleHeading: 'Harrow & Pike',
stages: {
desk: { label: 'The Desk', title: 'Junior Clerk' },
},
// A six-day week: the office shuts at one on Saturday, and that is pay day.
calendar: {
cycleLength: 6,
cycleName: 'Week',
unitNames: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'],
},
// The four attributes, the same for the player and for everyone in the cast.
// A pack may call them what it likes; these are the prototype's names.
attributes: {
nerve: { label: 'Nerve' },
wits: { label: 'Wits' },
charm: { label: 'Charm' },
resolve: { label: 'Resolve' },
},
// How attributes turn into chances. The player sees only the words.
// chance = 50 + 8 x (yours - theirs) + 0.4 x (their regard - 50), from 5 to 95
// A new clerk (3) against a sharp partner (8) is 10%, a long shot; against an
// ordinary task (4) it is 42%. Each point of training is worth eight.
checks: {
base: 50,
perPoint: 8,
unopposed: 4,
relationshipPerPoint: 0.4,
relationshipCentre: 50,
floor: 5,
ceiling: 95,
odds: [
{ upTo: 20, words: 'a long shot' },
{ upTo: 40, words: 'unlikely' },
{ upTo: 60, words: 'an even chance' },
{ upTo: 80, words: 'likely' },
{ upTo: 95, words: 'a safe bet' },
],
said: { success: 'and it came off.', failure: 'and it did not come off.' },
},
state: {
'money': { initial: 20, min: -200, max: 100000 },
'standing': { initial: 40, min: 0, max: 100 },
'attributes.nerve': { initial: 3, min: 0, max: 10 },
'attributes.wits': { initial: 3, min: 0, max: 10 },
'attributes.charm': { initial: 3, min: 0, max: 10 },
'attributes.resolve': { initial: 3, min: 0, max: 10 },
// Borrowed from the other clerks when times are hard, repaid on a Saturday.
'owed': { initial: 0, min: 0, max: 100000 },
// Rises won from the partner: each is a permanent addition to the wage.
'rises': { initial: 0, min: 0, max: 3 },
'flags.pawned_watch': { initial: false },
'notable': { initial: [] },
},
// Two axes, not one: whether they believe you, and what they think of you.
relationshipStats: {
trust: { label: 'Trusts you', initial: 50, min: 0, max: 100 },
regard: { label: 'Thinks well of you', initial: 50, min: 0, max: 100 },
},
characters,
events,
// Per six-day week: -12 board and lodging +16 wages = +4.
// Each rise adds four; a rise is earned, so it is outside the baseline.
upkeep: [
{ label: 'Board and lodging', effects: [{ path: 'money', op: 'add', value: -2 }] },
{
label: 'Wages from Harrow & Pike',
every: 6,
offset: 5,
effects: [{ path: 'money', op: 'add', value: 16 }],
},
...[1, 2, 3].map((n) => ({
label: n === 1 ? 'Your rise' : `Your rise (${n})`,
every: 6,
offset: 5,
requires: [{ path: 'rises', op: '>=', value: n }],
effects: [{ path: 'money', op: 'add', value: 4 }],
})),
],
tuning: {
baselinePerCycle: { desk: [0, 10] },
baselineExcludes: ['rises'],
},
display: {
'money': { label: 'Money', format: 'money', order: 1 },
'owed': { label: 'Owed to the floor', format: 'money', order: 2, hideWhenZero: true },
'standing': { label: 'Standing', format: 'meter', order: 2 },
'attributes.nerve': { label: 'Nerve', format: 'meter', order: 3 },
'attributes.wits': { label: 'Wits', format: 'meter', order: 4 },
'attributes.charm': { label: 'Charm', format: 'meter', order: 5 },
'attributes.resolve': { label: 'Resolve', format: 'meter', order: 6 },
'rises': { label: 'Rises', order: 7, hideWhenZero: true },
'notable': { hidden: true, inLedger: false },
'flags.pawned_watch': {
label: 'Your father\'s watch', hidden: true,
whenTrue: 'Your father\'s watch is at the pawnbroker\'s.',
whenFalse: 'You have your father\'s watch back.',
},
},
};
export default countingHouse;
+309 -24
View File
@@ -160,7 +160,7 @@ export const deputy = [
options: [
{
id: 'wade_in',
label: 'Go in and take hold of the nearest one.',
label: 'Go in and take hold of the nearest man who is fighting.',
result:
'It stops. Your sleeve does not survive it and neither does a ' +
'table, and the whole street knows by morning which it was.',
@@ -580,15 +580,281 @@ export const deputy = [
],
},
// Five more days in the office, added after the first real play of this
// stage: 69 turns saw 13 distinct situations, and `office_day` and
// `the_cells` were a sixth of the stage each. See DECISIONS #40.
{
id: 'stage_came_in_short',
stage: 'deputy',
weight: 50,
cooldown: 7,
description:
'The stage came in with the strongbox eleven pounds lighter than the ' +
'manifest, and the driver swears on his mother that he never once ' +
'stopped between the Wells and here.',
options: [
{
id: 'wire_the_county',
label: 'Wire the county and let them send a man.',
result:
'The county sends a man in nine days, by which time the driver has ' +
'told the story often enough to believe every word of it.',
effects: [
{ path: 'skills.trade', op: 'add', value: 2 },
{ path: 'relationships.ward.trusts_you', op: 'add', value: 2 },
{ path: 'standing', op: 'add', value: -1 },
],
},
{
id: 'ride_the_route',
label: 'Ride the route back yourself, looking at the ground.',
result:
'Wheel ruts off the road at the second creek, where the driver says ' +
'he never stopped. You do not find the eleven pounds. You find where ' +
'they got off.',
effects: [
{ path: 'money', op: 'add', value: -3 },
{ path: 'skills.nerve', op: 'add', value: 3 },
{ path: 'standing', op: 'add', value: 3 },
],
},
{
id: 'lean_on_the_driver',
label: 'Lean on the driver until his story changes.',
requires: [{ path: 'skills.nerve', op: '>=', value: 30 }],
result:
'It changes on the fourth telling, at the second creek, and then ' +
'again at the part where somebody was expecting him.',
effects: [
{ path: 'skills.nerve', op: 'add', value: 3 },
{ path: 'standing', op: 'add', value: 4 },
{ path: 'relationships.ward.wary_of_you', op: 'add', value: 5 },
{ path: 'notable', op: 'push', value: 'Leaned on a stage driver until the story changed.' },
],
},
],
},
{
id: 'the_county_fees',
stage: 'deputy',
weight: 45,
cooldown: 8,
description:
'The county wants its licence fees collected from every business on the ' +
'street, and the list it sent includes the woman who sells pies out of ' +
'her kitchen window on Sundays.',
options: [
{
id: 'collect_all',
label: 'Collect from everybody on the list.',
result:
'All of it, pies included. The county is pleased. The street is ' +
'polite to you in a way it was not last week.',
effects: [
{ path: 'standing', op: 'add', value: -4 },
{ path: 'skills.trade', op: 'add', value: 1 },
{ path: 'relationships.ward.trusts_you', op: 'add', value: 4 },
],
},
{
id: 'pay_hers',
label: 'Collect from the rest, and pay hers out of your own pocket.',
result:
'A dollar fifty. Nobody is told, and by Sunday everybody knows, which ' +
'is how the street keeps its accounts.',
effects: [
{ path: 'money', op: 'add', value: -5 },
{ path: 'standing', op: 'add', value: 5 },
{ path: 'notable', op: 'push', value: 'Paid the pie licence out of your own pocket.' },
],
},
{
id: 'strike_her_off',
label: 'Strike her off the list and let the county notice if it can.',
requires: [{ path: 'skills.trade', op: '>=', value: 28 }],
result:
'You find the statute that exempts a kitchen window, and cite it in ' +
'the return. The county never writes back, which is the county ' +
'agreeing.',
effects: [
{ path: 'skills.trade', op: 'add', value: 3 },
{ path: 'standing', op: 'add', value: 3 },
{ path: 'relationships.ward.wary_of_you', op: 'add', value: 4 },
],
},
],
},
{
id: 'the_bounty_man',
stage: 'deputy',
weight: 40,
cooldown: 9,
description:
'A man with a paper from Bitter Wells and a rifle he keeps too clean ' +
'wants to know whether a teamster called Pike is in Cold Fork. There is ' +
'a teamster called Pike in Cold Fork, and he has been decent to you.',
options: [
{
id: 'tell_him',
label: 'Tell him where Pike is.',
result:
'He thanks you and leaves money on the desk, which you did not ask ' +
'for and do not give back. The teamsters hear about the money first.',
effects: [
{ path: 'money', op: 'add', value: 6 },
{ path: 'standing', op: 'add', value: -3 },
{ path: 'relationships.ward.trusts_you', op: 'add', value: 3 },
{ path: 'relationships.dutch.wary_of_you', op: 'add', value: 6 },
],
},
{
id: 'send_him_on',
label: 'Tell him Pike went west a month ago.',
result:
'He looks at you for long enough that you understand he knows. Then ' +
'he rides west, because the paper does not pay him to argue.',
effects: [
{ path: 'skills.nerve', op: 'add', value: 2 },
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 5 },
{ path: 'relationships.ward.wary_of_you', op: 'add', value: 5 },
],
},
{
id: 'check_the_paper',
label: 'Read his paper properly before you say anything at all.',
requires: [{ path: 'skills.trade', op: '>=', value: 30 }],
result:
'It names a Pike, a year and a county, and two of the three are ' +
'wrong. You tell him so, with the statute, and he goes home.',
effects: [
{ path: 'skills.trade', op: 'add', value: 3 },
{ path: 'standing', op: 'add', value: 4 },
{ path: 'relationships.ward.trusts_you', op: 'add', value: 4 },
{ path: 'notable', op: 'push', value: 'Read a bounty man’s paper closely enough to send him home.' },
],
},
],
},
{
id: 'fire_at_the_store',
stage: 'deputy',
weight: 35,
cooldown: 12,
description:
'A lamp goes over in the back room of the store an hour after midnight, ' +
'and by the time you are in your boots the whole street is standing in ' +
'it in its nightclothes.',
options: [
{
id: 'bucket_line',
label: 'Get a bucket line going from the trough.',
result:
'It is out by two. The back room is gone and the front is black to ' +
'the ceiling, and the storekeeper shakes every hand on the line.',
effects: [
{ path: 'money', op: 'add', value: -2 },
{ path: 'skills.nerve', op: 'add', value: 2 },
{ path: 'standing', op: 'add', value: 5 },
],
},
{
id: 'go_in_for_the_book',
label: 'Go in for the book on the counter.',
requires: [{ path: 'skills.nerve', op: '>=', value: 34 }],
result:
'You come out with it under your coat and no eyebrows. Half the town ' +
'is in that book, and every one of them knows who carried it out.',
effects: [
{ path: 'skills.nerve', op: 'add', value: 4 },
{ path: 'standing', op: 'add', value: 6 },
{ path: 'notable', op: 'push', value: 'Went into the burning store for the book.' },
],
},
{
id: 'hold_the_crowd',
label: 'Keep the crowd back and let it burn itself out.',
result:
'Nobody else is hurt, which Ward says is the only part of a fire that ' +
'is the office’s business. The storekeeper does not agree, loudly.',
effects: [
{ path: 'skills.trade', op: 'add', value: 2 },
{ path: 'standing', op: 'add', value: -2 },
{ path: 'relationships.ward.trusts_you', op: 'add', value: 3 },
],
},
],
},
{
id: 'dutch_wants_the_ridge',
stage: 'deputy',
weight: 40,
cooldown: 10,
description:
'Dutch comes to the office sober and early, which has not happened ' +
'before, and asks whether a deputy has any say in who drives the Ridge ' +
'road now that Vane is short a man.',
options: [
{
id: 'put_him_forward',
label: 'Say you will put his name in with Vane.',
result:
'He nods once and goes, and Vane hears about it from Dutch before he ' +
'hears about it from you, which is not how you meant it to go.',
effects: [
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 8 },
{ path: 'relationships.dutch.owes_you', op: 'add', value: 10 },
{ path: 'relationships.hollis.wary_of_you', op: 'add', value: 5 },
],
},
{
id: 'office_stays_out',
label: 'Say the office does not hire teamsters.',
result:
'He says that is true, and it is, and he takes his hat off the desk ' +
'and goes back down the street to wait for somebody else to decide.',
effects: [
{ path: 'standing', op: 'add', value: 2 },
{ path: 'relationships.dutch.trusts_you', op: 'add', value: -5 },
{ path: 'relationships.ward.trusts_you', op: 'add', value: 2 },
],
},
{
id: 'ask_vane_for_him',
label: 'Walk across the street and ask Vane yourself.',
requires: [{ path: 'relationships.hollis.trusts_you', op: '>=', value: 40 }],
result:
'Vane hears you out in the doorway and says he will think about it, ' +
'and Dutch is on the Ridge road on Monday.',
effects: [
{ path: 'standing', op: 'add', value: 3 },
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 10 },
{ path: 'relationships.hollis.trusts_you', op: 'add', value: -2 },
{ path: 'notable', op: 'push', value: 'Asked Vane to put Dutch on the Ridge road.' },
],
},
],
},
{
// Corporate Ladder's car lot, in Cold Fork's terms and at its scale. Same
// structure deliberately: an offer, never an obligation, with the weekly
// charge for falling short of it sitting in upkeep instead.
//
// The horse and the room used to be one event, whose description was
// always about the horse. A real session was offered the room under that
// text, and shown "Buy the bay" locked after buying it — a door that could
// never open. Now each is its own event, gated on the tier it offers, so
// neither can appear once it is bought. See DECISIONS #40.
id: 'the_sale_barn',
stage: 'deputy',
weight: 45,
cooldown: 6,
requires: [{ path: 'lifestyle', op: '<', value: 2 }],
requires: [{ path: 'lifestyle', op: '==', value: 0 }],
description:
'The sale barn has a bay with four good years in him and a price on ' +
'him. Ward says nothing about it, except that the county pays mileage ' +
@@ -597,10 +863,7 @@ export const deputy = [
{
id: 'buy_the_bay',
label: 'Buy the bay.',
requires: [
{ path: 'lifestyle', op: '==', value: 0 },
{ path: 'money', op: '>=', value: 70 },
],
requires: [{ path: 'money', op: '>=', value: 70 }],
result:
'He is a better animal than the livery has ever put under you, and ' +
'the whole street watches you ride him down it once.',
@@ -611,24 +874,6 @@ export const deputy = [
{ 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.',
@@ -639,6 +884,46 @@ export const deputy = [
},
],
},
{
id: 'the_ellis_room',
stage: 'deputy',
weight: 45,
cooldown: 6,
requires: [{ path: 'lifestyle', op: '==', value: 1 }],
description:
'The room over the Ellis place is empty again: a stove, a door that ' +
'locks, and a window over the street. The Cordwood charges a deputy the ' +
'same as a teamster, and says so to both of them.',
options: [
{
id: 'take_the_room',
label: 'Take the room over the Ellis place, with the stove.',
requires: [{ path: 'money', op: '>=', value: 150 }],
result:
'A door that locks, a stove, and a window over the street. The ' +
'Cordwood does not hold it against you; the Cordwood holds nothing ' +
'against anybody who pays up.',
effects: [
{ path: 'money', op: 'add', value: -150 },
{ path: 'lifestyle', op: 'set', value: 2 },
{ path: 'standing', op: 'add', value: 5 },
{ path: 'notable', op: 'push', value: 'Took the room with the stove over the Ellis place.' },
],
},
{
id: 'stay_at_the_cordwood',
label: 'Stay at the Cordwood.',
result:
'The walls are thin and the talk comes through them, and some of what ' +
'comes through is worth a deputy hearing.',
effects: [
{ path: 'skills.trade', op: 'add', value: 1 },
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 2 },
],
},
],
},
];
export default deputy;
+9 -6
View File
@@ -99,14 +99,16 @@ export const hardTimes = [
'book and yours is one of them. He mentions this to you in the street, ' +
'in daylight, in front of two other people.',
options: [
// Both payments take what is owed up to their size, never more — money
// first, because both effects read `owed` before it moves. See #38.
{
id: 'pay_chunk',
label: 'Pay a real piece of it down.',
label: 'Pay a real piece of it down, up to forty-five dollars.',
requires: [{ path: 'money', op: '>=', value: 45 }],
result: 'He rules a line through most of it and is disappointed about it.',
effects: [
{ path: 'money', op: 'add', value: -45 },
{ path: 'owed', op: 'add', value: -45 },
{ path: 'money', op: 'add', value: { from: 'owed', times: -1, cap: 45 } },
{ path: 'owed', op: 'add', value: { from: 'owed', times: -1, cap: 45 } },
{ path: 'standing', op: 'add', value: 2 },
],
},
@@ -114,10 +116,11 @@ export const hardTimes = [
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.',
result: 'Up to ten dollars against the line, and two dollars of coffee while you are in there.',
effects: [
{ path: 'money', op: 'add', value: -12 },
{ path: 'owed', op: 'add', value: -10 },
{ path: 'money', op: 'add', value: -2 },
{ path: 'money', op: 'add', value: { from: 'owed', times: -1, cap: 10 } },
{ path: 'owed', op: 'add', value: { from: 'owed', times: -1, cap: 10 } },
],
},
{
+52
View File
@@ -10,6 +10,58 @@
// than a mistake.
export const promotion = [
{
// The first real player asked, thirty turns in, how they would know they
// were getting close. Nothing said. This does, in the town's own terms: it
// names what the offer is waiting for without printing a threshold. It
// comes once, somewhat before the offer can, and only to a hand already
// heading that way. See DECISIONS #40.
id: 'ward_takes_notice',
stage: 'hand',
weight: 300,
once: true,
requires: [
{ path: 'turn', op: '>=', value: 24 },
{ path: 'standing', op: '>=', value: 55 },
],
description:
'Ward stops at the yard gate on his way past and says, to nobody in ' +
'particular, that the county will want a second man by winter. The man ' +
'will need a name the street says for the right reasons, and he will ' +
'need to be one Ward can take at his word — or one who has already ' +
'walked into trouble on the Ridge road and walked back out of it.',
options: [
{
id: 'ask_what_it_takes',
label: 'Ask him how long a man would have to wait.',
result:
'He says not before the fortieth day of anybody’s employment, and ' +
'that he is not talking about anybody in particular, and goes on up ' +
'the street.',
effects: [
{ path: 'relationships.ward.trusts_you', op: 'add', value: 3 },
{ path: 'notable', op: 'push', value: 'Heard from Ward that the county would want a second man.' },
],
},
{
id: 'happy_in_the_yard',
label: 'Say you are happy in the yard.',
result: 'Vane, in the office doorway, hears you say it.',
effects: [
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 3 },
],
},
{
id: 'remember_it',
label: 'Say nothing, and remember it.',
result: 'He did not expect an answer, and seems to like not getting one.',
effects: [
{ path: 'skills.nerve', op: 'add', value: 1 },
],
},
],
},
{
id: 'deputy_offer',
stage: 'hand',
+8 -4
View File
@@ -243,7 +243,7 @@ export const routine = [
},
{
id: 'stay_out',
label: 'Have no view on the axle.',
label: 'Stay out of it, and take neither side about the axle.',
result: 'Both of them decide, separately, that you are no use to them.',
effects: [
{ path: 'relationships.dutch.trusts_you', op: 'add', value: -4 },
@@ -298,7 +298,7 @@ export const routine = [
},
{
id: 'pay_down',
label: 'Put money against the book.',
label: 'Pay the book down, up to twenty dollars.',
// Hidden rather than greyed out when there is nothing owed. A locked
// door is worth showing when it is a door the player could open later
// by playing differently; "pay down what you owe" shown to a man who
@@ -310,9 +310,13 @@ export const routine = [
{ path: 'money', op: '>=', value: 20 },
],
result: 'He crosses a line through it and turns the page back over.',
// What is owed, up to twenty, and never more. A flat -20 against a
// four-dollar balance took sixteen dollars for nothing in a real
// session. Money comes first: both effects read `owed` before it moves.
// See DECISIONS #38.
effects: [
{ path: 'money', op: 'add', value: -20 },
{ path: 'owed', op: 'add', value: -20 },
{ path: 'money', op: 'add', value: { from: 'owed', times: -1, cap: 20 } },
{ path: 'owed', op: 'add', value: { from: 'owed', times: -1, cap: 20 } },
{ path: 'standing', op: 'add', value: 2 },
],
},
+6 -3
View File
@@ -20,7 +20,7 @@ export const 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',
version: '0.1.1',
turnUnit: 'day',
startingStage: 'hand',
peopleHeading: 'Cold Fork',
@@ -100,15 +100,18 @@ export const frontier = {
offset: 3,
effects: [{ path: 'money', op: 'add', value: -8 }],
},
// Labelled as wages, because they are. "Settlement" read as jargon to the
// first real player, who could not tell it was the twenty-four a run the
// first morning quotes; the ledger is written for a player (DECISIONS #27).
{
label: 'Settlement',
label: 'Wages from Vane',
every: 4,
offset: 4,
requires: [{ path: 'stage', op: '==', value: 'hand' }],
effects: [{ path: 'money', op: 'add', value: 24 }],
},
{
label: 'Deputy pay',
label: 'Wages from the county',
every: 4,
offset: 4,
// The town pays a real wage, and it is still not much of a living. What
+2 -1
View File
@@ -10,8 +10,9 @@
import { corporateLadder } from './corporateladder/index.js';
import { frontier } from './frontier/index.js';
import { countingHouse } from './countinghouse/index.js';
export const packs = [corporateLadder, frontier];
export const packs = [corporateLadder, frontier, countingHouse];
/** A pack by id, or undefined. Used to resume into whatever was last played. */
export function packById(id) {
+198
View File
@@ -656,3 +656,201 @@ promoted is not independent of how they spend, so the comparison mostly measured
the mix. It inverted the moment there was something to spend money on. It is now
derived from upkeep arithmetic, and the lifestyle's effect is tested with two
players who differ in exactly one habit.
## 38. A repayment pays what is owed, never more
Recorded 2026-09-15, for 0.3.4. Designer's call: a small engine addition rather
than a content workaround.
The first human session of the Frontier on a current build — 112 turns — owed the
store four dollars on turn 27 and chose "Put money against the book". It took
twenty dollars and cleared four: the option was a flat `money -20, owed -20`, and
`owed` clamped at zero while `money` did not. The ledger showed "−$4 at its
limit", which the player read, reasonably, as a debt still standing.
The same shape was in five options across both packs — the Frontier's store and
both of its hard-times repayments, and both of Corporate Ladder's loan
repayments — and none had been caught, because every archetype that pays debt
pays large debts.
**Content could not fix it.** Effects add or set a number the pack wrote, and a
balance built from +14, +32, +4, −45 and −10 can be anything, so no fixed payment
can equal it. The alternatives were to gate each instalment on the balance
covering it (leaving small balances to cost more than they are) or to retune
every credit amount to a common step (changing tuned numbers to suit a missing
primitive).
**So an `add` effect's value may now be read from state:**
`{ path: 'money', op: 'add', value: { from: 'owed', times: -1, cap: 20 } }` is
"take away whatever is owed, but no more than 20". The amount is resolved at the
moment the effect applies, so a repayment lists the money effect before the
balance effect — both read the balance before it moves. The change record keeps
the number actually applied, with the reference in `source`.
This is not the engine learning about debt. It reads a number from a path without
knowing what the path means, exactly as a condition does, and #1 and #2 still
hold: it is data, validated at load. The validator requires the referenced path to
be declared and `times` and `cap` to be numbers. The test archetypes score such an
effect at its cap, the most a player reading the label could expect.
A conformance test now holds every pack to it: an option that spends money and
reduces a balance — any money-formatted stat other than money — must read the
amount from the balance, or be gated on the balance covering the whole instalment.
## 39. A hidden option is not a locked door in the log either
`whenLocked: 'hide'` (#37) kept "Put money against the book" off the screen of a
player who owed nothing, and it worked. But the turn record listed offered options
without saying which were hidden, so `npm run analyze` reported the option as a
locked door shown three times to a player who never saw it. The instrument was
counting the very thing the fix removed.
The turn record now carries `hidden` for every offered option, and the analyser
skips hidden options when counting locked doors. Logs exported before 0.3.4 do not
have the field and still over-count.
The same session's exports also put a save file next to the feedback log in
`logs/`. The analyser read "the newest `.json`", so whichever of the two was
exported last would have been read as the log. It now reads only
`theladder-feedback-*.json` when no file is named.
## 40. What the first Frontier playtest changed
The session (112 turns, 0.3.3, promoted on turn 44) confirmed the systems #36 and
#37 were built for: both lifestyle purchases were found and taken, the standing
charge fired as designed, money never went below zero, and the run ended flat —
the break-even the Frontier intends at the top. Everything it found wrong was
legibility, plus the defect in #38. All eight of the player's comments were
questions about what a number meant, and both thumbs-down were on events that
confused them.
- **Wages are called wages.** "Settlement" and "Deputy pay" became "Wages from
Vane" and "Wages from the county". The player could not connect "Settlement"
to the twenty-four a run the first morning quotes, and read a run's own +$9 as
the pay going missing (#27: the ledger is written for a player).
- **A clamped change says where it stopped** — "stopped at $0", "stopped at 100" —
instead of "at its limit", which was read as a balance still outstanding.
- **Two option labels were rewritten** that the player could not parse: "Have no
view on the axle" and "take hold of the nearest one".
- **The horse and the room are separate events.** One event offered both, with a
description that was always about the horse, so the room was offered under
horse text and "Buy the bay" was shown locked to a player who had bought it — a
door that could never open. Each is now gated on the tier it sells, and a test
holds that neither is offered once bought.
- **The promotion gets a warning.** "How do I find out how close I am to
promotion?" had no answer anywhere. `ward_takes_notice` comes once, to a hand
past turn 24 with standing 55, and says in the town's terms what the offer waits
for — a name the street says for the right reasons, and a man Ward takes at his
word or one with the Ridge road behind him — without printing a threshold. Across
72 archetype runs it fired in 67, at a median of turn 29, always before the offer.
A numeric progress display was not built: it would need the interface to know
what a promotion is, which #22 keeps in content.
- **The office got five more days.** 69 deputy turns saw 13 distinct situations,
and `office_day` and `the_cells` were 17% of the stage each — the same thinness
#24 caught in Dispatch. Five events were added (the stage in short, the county's
fees, a bounty hunter, a fire at the store, Dutch wanting the Ridge road) and the
horse/room split adds a sixth. Measured across 72 archetype runs of 250 turns,
the three floor events fell from about 17.5% of deputy turns each to about
13.5%. Every new event has an ungated way through, and its skill gates (nerve 30
and 34, trade 28 and 30) sit under growth available without them (#32).
**Measured and not yet understood.** In the same simulation, promoted runs now end
with a median of $164 rather than $88, and a median standing of 89 rather than 98.
Two changes plausibly account for it — repayments no longer overcharge, and lower
standing triggers the $4 standing charge less often — but it has not been
isolated. The declared baselines and every tuning test still pass. The Frontier is
meant to be roughly level at the top, so the next Frontier session should watch
whether money stops mattering again, as it did in Corporate Ladder before #36.
"Owed at the store" was asked for as "a major field at the top". It already is,
second after Money whenever anything is owed. Whether it is prominent enough can
only be judged in a browser, which this machine cannot render (#18).
## 41. Chance, and a cast that changes between runs
Recorded 2026-09-23. Designer's call: a change to the base engine, generic, which
every pack may use and none has to. Corporate Ladder and the Frontier use none of
it and replay exactly as before.
Until now an option did exactly what it said. Skills only decided whether an
option was on the menu (#15, #32). The story-to-pack catalogue of recurring
situations (asking for a rise, the door to the powerful, a colleague's needling)
is made of moments whose outcome depends on who you are and who you are dealing
with, so three things were added. `content/countinghouse` is the pack that exercises them.
**Attributes.** A pack names them in `pack.attributes`. The player's live in
ordinary declared state (`attributes.<id>`), so they grow by effects like anything
else. The prototype's four, agreed with the designer, are Nerve, Wits, Charm and
Resolve. Another pack may rename them, as #14 lets it rename the interface.
**Checks.** An option may carry `check: { attribute, against?, opposedBy?,
relationship?, difficulty?, modifier? }` and then must carry `success` and
`failure`, each with its own `result` and `effects`. Its plain `effects` apply
either way. The chance is
base + perPoint × (yours − theirs) + relationshipPerPoint × (their regard − centre) + modifier,
clamped to [floor, ceiling]
where "theirs" is the opposing character's attribute, or the check's `difficulty`,
or the pack's `unopposed`. Every number is the pack's, in `pack.checks` (#9).
**The player never sees a number.** Options show the odds in words ("a long shot",
"likely"), from bands the pack may rename, and the outcome says whether it came
off. The roll is one draw from the run's own generator, so a run with checks still
replays exactly from its seed and choices (#5, #6). A pack that never checks draws
nothing extra.
**A cast drawn per run.** A character may be a *role* with `candidates`, several
authored people who could fill it, and one is drawn when a run starts. A role may
be a *group* with `count: [min, max]` (two to four clerks), which draws that many
different people into numbered slots (`clerk_1` .. `clerk_4`). Every candidate
carries a name, a description and the same attributes the player has, so a
replayed pack meets a different office with different strengths. Candidates are
authored rather than generated from random parts: a character written as sharp
has the wits to see through a bluff, which a random attribute roll pasted onto a
random description would not guarantee.
- **The draw is saved in engine-owned state** (`cast`, now a reserved root, #4)
and made on its own stream derived from the seed, so it neither moves the event
generator nor depends on it. `hydrate` (#36) draws one for a save that predates
it, and draws the same one every time.
- **Every slot up to a group's maximum is declared in the schema from the start.**
Relationship state for `clerk_3` exists whether or not anyone was drawn into it,
so it validates and saves like any other path. An empty slot is recorded as
`null` and is simply absent from the run.
- **Text names people by role.** `{partner}`, `{chief.first}` and `{clerk.title}`
are filled in from whoever was drawn. An event about "a clerk" says
`involves: ['clerk']`. When it is drawn, one present clerk is bound to it for
the turn, and `{clerk}` in its text, `relationships.{clerk}.trust` in its effects
and `against: '{clerk}'` in a check all mean that person. The binding is saved
with the run. An event cannot fire in a run with nobody to bind. Text kept in
state, such as a notable moment, has its placeholders pinned to the slot
(`{clerk_2}`), so the retrospective names the right person later.
- **The validator holds all of it:**
- attributes declared, and every drawn person carrying all of them;
- enough candidates for a group's maximum, and unique names;
- checks naming real attributes and people, with both branches;
- `{placeholders}` that name real characters, where a group is only named inside an event that binds it;
- no placeholder in an event's own `requires`, which is evaluated before anyone is bound.
**What the test harness had to learn.** The archetypes score an option by its
effects (#21). A checked option is now scored at its expected value: plain effects
in full, and each branch weighted by its chance, the way a player reading the odds
would. The conformance check that no repayment takes more than is owed (#38) now
reads both branches, since a check can pay or spend on either.
**The prototype pack was tuned by measurement, not by eye.** Its first draft
failed three conformance tests:
- the routine ledgers were a third of every player's turns;
- a careless player was broke 40% of a run;
- it had no balance a borrower could owe.
Two more catalogue situations (an errand for the partner's family, and a desk you
and a clerk both want), a lighter floor event, a loan that is refused but still
helps a little, and an "owed to the floor" balance repaid on Saturdays brought
those to 22% at worst, 12% at worst, and a debt that is repaid by reading the
balance (#38).
**Not yet known.** Whether chance makes the same situations worth replaying, and
whether the odds in words are legible, can only be judged by someone playing it
(#18). The pack has one stage on purpose, so that question can be answered before
a career is built on it.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "theladder",
"version": "0.3.3",
"version": "0.4.0",
"private": true,
"description": "A turn-based career simulation game. Engine is theme-agnostic; settings are content packs.",
"type": "module",
+139
View File
@@ -0,0 +1,139 @@
// Who is in this run.
//
// A pack's characters are fixed people by default: a name and a description,
// written once. A character may instead be a *role* with `candidates` — several
// authored people who could fill it — and one is drawn when a run starts. A role
// may also be a *group* with a `count` range, like two to four co-workers, in
// which case that many different candidates are drawn into numbered slots.
// Replaying a pack therefore meets a different cast, which is the point.
//
// Slots are what state is keyed by: a single role is its own id (`boss`), a
// group's members are `coworker_1` .. `coworker_<max>`. Every slot up to the
// group's maximum exists in the schema from the start, so relationship state
// is declared, validated and saved like any other; a slot nobody was drawn into
// is simply not present in the run. See DECISIONS #41.
//
// The draw uses its own stream derived from the run's seed, so it neither moves
// the event generator nor depends on it: a pack that gains a cast does not
// change which events any other pack draws, and a save that predates the cast
// can have one drawn for it on load, reproducibly.
import { createRng } from './rng.js';
import { getPath } from './paths.js';
/** The count range of a group role, or null for a single character. */
export function groupRange(character) {
if (character.count === undefined) return null;
return Array.isArray(character.count) ? character.count : [character.count, character.count];
}
/** Every state slot a character occupies: its own id, or one per possible group member. */
export function slotsOf(character) {
const range = groupRange(character);
if (!range) return [character.id];
return Array.from({ length: range[1] }, (_, i) => `${character.id}_${i + 1}`);
}
/** Whether a pack draws any part of its cast per run. */
export function hasDrawnCast(pack) {
return (pack.characters ?? []).some((c) => c.candidates?.length || groupRange(c));
}
/** The authored person behind a candidate entry, in the shape the state keeps. */
function personOf(character, candidate, index) {
return {
role: character.id,
candidate: index,
name: candidate.name,
title: candidate.title ?? character.title ?? null,
description: candidate.description ?? '',
attributes: { ...(candidate.attributes ?? {}) },
};
}
/**
* Draw the cast for a run. Returns `{ <slot>: person | null }` for every slot of
* every drawn role; a group slot left empty is `null`, which is how "not in this
* run" is recorded. Fixed characters are not in it: they need no drawing.
*/
export function drawCast(pack, seed) {
const rng = createRng(`cast:${seed}`);
const cast = {};
for (const character of pack.characters ?? []) {
const pool = character.candidates ?? [];
const range = groupRange(character);
if (!range && !pool.length) continue;
const slots = slotsOf(character);
const wanted = range ? range[0] + rng.int(range[1] - range[0] + 1) : 1;
const remaining = pool.map((candidate, index) => ({ candidate, index }));
for (let i = 0; i < slots.length; i++) {
if (i >= wanted || !remaining.length) {
cast[slots[i]] = null;
continue;
}
const [picked] = remaining.splice(rng.int(remaining.length), 1);
cast[slots[i]] = personOf(character, picked.candidate, picked.index);
}
}
return cast;
}
/** Starting relationship overrides a drawn person brings with them. */
export function drawnRelationships(pack, cast) {
const overrides = [];
for (const character of pack.characters ?? []) {
for (const slot of slotsOf(character)) {
const person = cast?.[slot];
if (!person) continue;
const candidate = character.candidates?.[person.candidate];
for (const [stat, value] of Object.entries(candidate?.startingRelationship ?? {})) {
overrides.push({ path: `relationships.${slot}.${stat}`, value });
}
}
}
return overrides;
}
/** The character definition that owns a slot. */
export function characterForSlot(pack, slot) {
for (const character of pack.characters ?? []) {
if (slotsOf(character).includes(slot)) return character;
}
return undefined;
}
/**
* The person in a slot as the interface and the checks see them: name, title,
* description and attributes, whether drawn for this run or written into the
* pack. Undefined if the slot is empty this run or unknown.
*/
export function personIn(pack, state, slot) {
const character = characterForSlot(pack, slot);
if (!character) return undefined;
const drawn = getPath(state, `cast.${slot}`);
if (drawn === null) return undefined;
if (drawn) return { id: slot, ...drawn };
if (character.candidates?.length || groupRange(character)) return undefined;
return {
id: slot,
role: character.id,
name: character.name,
title: character.title ?? null,
description: character.description ?? '',
attributes: { ...(character.attributes ?? {}) },
};
}
/** Everyone present in this run, in pack order. */
export function presentPeople(pack, state) {
return (pack.characters ?? []).flatMap((character) =>
slotsOf(character).map((slot) => personIn(pack, state, slot)).filter(Boolean));
}
/** The slots of a role that are filled in this run. */
export function presentSlots(pack, state, roleId) {
const character = (pack.characters ?? []).find((c) => c.id === roleId);
if (!character) return [];
return slotsOf(character).filter((slot) => personIn(pack, state, slot));
}
+87
View File
@@ -0,0 +1,87 @@
// Options whose outcome is not certain.
//
// An option may carry a `check`: which of the player's attributes it tests,
// optionally *against* a character who opposes it with their own attribute, and
// optionally leaning on how that character regards the player. It then has a
// `success` and a `failure`, each with its own result text and effects; the
// option's plain `effects` apply either way. The engine works out the chance,
// rolls on the run's own generator, and records which way it went.
//
// The engine knows the arithmetic and nothing about what any attribute means:
// the numbers are the pack's (`pack.checks`), the attribute names are the
// pack's, and so are the words the player sees in place of a percentage. A
// player is shown "a long shot" or "likely", never a number. See DECISIONS #41.
//
// chance = base + perPoint × (yours − theirs)
// + relationshipPerPoint × (their regard − relationshipCentre)
// + modifier, clamped to [floor, ceiling]
//
// where "theirs" is the opposing character's attribute when the check is
// `against` someone, and the check's `difficulty` (or the pack's `unopposed`)
// when it is not.
import { getPath } from './paths.js';
import { personIn } from './cast.js';
import { resolveSlot } from './text.js';
/** Odds words used when a pack does not name its own. Upper bounds, in percent. */
export const DEFAULT_ODDS = [
{ upTo: 20, words: 'a long shot' },
{ upTo: 40, words: 'unlikely' },
{ upTo: 60, words: 'an even chance' },
{ upTo: 80, words: 'likely' },
{ upTo: 100, words: 'a safe bet' },
];
/** The words for a chance, from the pack's bands. */
export function oddsWords(pack, chance) {
const bands = pack.checks?.odds ?? DEFAULT_ODDS;
return (bands.find((band) => chance <= band.upTo) ?? bands[bands.length - 1]).words;
}
/** The slot a check is made against this turn, if any. */
function opponentSlot(check, bindings) {
if (!check.against) return undefined;
const id = check.against.replace(/^\{|\}$/g, '');
return resolveSlot(id, bindings);
}
/**
* The chance of an option's check succeeding in this state, as a whole
* percentage, with the parts it was built from. Undefined for an option with
* no check.
*/
export function chanceOf(pack, state, option, bindings = state?.meta?.bindings ?? {}) {
const check = option.check;
if (!check) return undefined;
const rules = pack.checks ?? {};
const yours = getPath(state, `attributes.${check.attribute}`) ?? 0;
const slot = opponentSlot(check, bindings);
const opponent = slot ? personIn(pack, state, slot) : undefined;
const theirs = opponent
? opponent.attributes?.[check.opposedBy ?? check.attribute] ?? 0
: check.difficulty ?? rules.unopposed ?? 0;
let regard = 0;
if (check.relationship && slot) {
const value = getPath(state, `relationships.${slot}.${check.relationship}`);
if (typeof value === 'number') {
regard = (rules.relationshipPerPoint ?? 0) * (value - (rules.relationshipCentre ?? 50));
}
}
const raw = (rules.base ?? 50) + (rules.perPoint ?? 0) * (yours - theirs) + regard + (check.modifier ?? 0);
const chance = Math.round(Math.min(rules.ceiling ?? 95, Math.max(rules.floor ?? 5, raw)));
return { chance, words: oddsWords(pack, chance), yours, theirs, against: slot ?? null };
}
/**
* Roll a check. `rng` is the run's generator; exactly one number is drawn, so
* a replay from the same seed and choices rolls the same way.
*/
export function rollCheck(pack, state, option, rng, bindings) {
const odds = chanceOf(pack, state, option, bindings);
const roll = rng.next() * 100;
return { ...odds, succeeded: roll < odds.chance };
}
+32 -5
View File
@@ -25,23 +25,50 @@ function clamp(value, rule) {
return out;
}
/** Whether an effect's value is a reference to state rather than a literal. */
export function isAmountFrom(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value)
&& typeof value.from === 'string';
}
/**
* The amount an `add` effect applies. Usually the number the pack wrote. It may
* instead be read from state at the moment the effect applies:
*
* { path: 'money', op: 'add', value: { from: 'owed', times: -1, cap: 20 } }
*
* is "take away whatever is owed, but no more than 20". A fixed instalment
* cannot pay off a balance that could be anything, and charging the whole
* instalment against a smaller balance took a playtester's money for a debt
* that did not exist (DECISIONS #38). It is still data, and still generic: the
* engine reads a number from a path without knowing what the path means.
*/
export function resolveAmount(state, value) {
if (!isAmountFrom(value)) return value;
const current = getPath(state, value.from);
let amount = typeof current === 'number' ? current : 0;
if (typeof value.cap === 'number') amount = Math.min(amount, value.cap);
return amount * (typeof value.times === 'number' ? value.times : 1);
}
/**
* Apply one effect. `schema` is the resolved state schema, consulted only for
* clamping — the engine still has no idea what any given path means.
*/
export function applyEffect(state, effect, schema = {}) {
const { path, op, value } = effect;
const { path, op } = effect;
const operate = OPS[op];
if (!operate) throw new TypeError(`unknown effect operation ${JSON.stringify(op)}`);
const value = op === 'add' ? resolveAmount(state, effect.value) : effect.value;
const from = getPath(state, path);
const raw = operate(from, value);
const to = clamp(raw, schema[path]);
return {
state: setPath(state, path, to),
change: { path, op, value, from, to, clamped: raw !== to },
};
const change = { path, op, value, from, to, clamped: raw !== to };
// The log keeps the number actually applied, and says where it came from.
if (value !== effect.value) change.source = effect.value;
return { state: setPath(state, path, to), change };
}
/** Apply a list of effects in order. Returns the new state and every change. */
+40 -4
View File
@@ -9,8 +9,10 @@ import { createRng } from './rng.js';
import { applyEffects } from './effects.js';
import { check } from './conditions.js';
import { resolveSchema, createState } from './state.js';
import { selectEvent, resolveOptions, findOption } from './select.js';
import { selectEvent, resolveOptions, findOption, bindRoles } from './select.js';
import { setPath, getPath } from './paths.js';
import { chanceOf, rollCheck } from './checks.js';
import { bindEffects, fillText } from './text.js';
export class ContentError extends Error {}
export class MoveError extends Error {}
@@ -28,6 +30,10 @@ function advance(pack, state) {
);
}
let next = setPath(dated, 'meta.currentEventId', event.id);
// Which co-worker this event is about, drawn after the event itself. A pack
// with no `involves` draws nothing here, so its runs are unchanged.
const bindings = bindRoles(pack, next, event, rng);
if (Object.keys(bindings).length || next.meta.bindings) next = setPath(next, 'meta.bindings', bindings);
next = setPath(next, 'meta.rngPosition', rng.getPosition());
return next;
}
@@ -101,7 +107,10 @@ export function currentEvent(pack, state) {
/** The current event together with each option's availability. */
export function currentTurn(pack, state) {
const event = currentEvent(pack, state);
return { event, options: resolveOptions(state, event) };
const options = resolveOptions(state, event).map((resolved) => (resolved.option.check
? { ...resolved, odds: chanceOf(pack, state, resolved.option) }
: resolved));
return { event, options, bindings: state.meta.bindings ?? {} };
}
/**
@@ -120,7 +129,27 @@ export function takeTurn(pack, state, optionId, { schema = resolveSchema(pack),
throw new MoveError(`option "${optionId}" is locked: ${JSON.stringify(chosen.failed)}`);
}
const applied = applyEffects(state, option.effects, schema);
const bindings = state.meta.bindings ?? {};
let effects = bindEffects(option.effects, bindings);
let check = null;
let rngState = state;
if (option.check) {
// One number from the run's own generator, so the roll replays with the run.
const rng = createRng(state.meta.seed, state.meta.rngPosition ?? undefined);
const rolled = rollCheck(pack, state, option, rng, bindings);
const outcome = rolled.succeeded ? option.success : option.failure;
effects = [...effects, ...bindEffects(outcome?.effects, bindings)];
check = {
attribute: option.check.attribute,
against: rolled.against,
chance: rolled.chance,
odds: rolled.words,
outcome: rolled.succeeded ? 'success' : 'failure',
};
rngState = setPath(state, 'meta.rngPosition', rng.getPosition());
}
const applied = applyEffects(rngState, effects, schema);
let next = applied.state;
const seenCount = getPath(next, `seen.${event.id}`) ?? 0;
@@ -135,7 +164,10 @@ export function takeTurn(pack, state, optionId, { schema = resolveSchema(pack),
stage: next.stage,
eventId: event.id,
optionId: option.id,
optionLabel: option.label,
// Names filled in, so the log and the retrospective read as the player saw it.
optionLabel: fillText(pack, state, option.label, bindings),
...(check ? { check } : {}),
...(Object.keys(bindings).length ? { bindings } : {}),
changes: applied.changes,
upkeep: upkeep.changes,
offered: resolved.map((r) => ({
@@ -143,6 +175,10 @@ export function takeTurn(pack, state, optionId, { schema = resolveSchema(pack),
label: r.option.label,
available: r.available,
failed: r.failed,
// A hidden option was never on the player's screen. Without this the log
// cannot tell it from a locked door they saw, and the analyser counted
// one the first real Frontier player never met. See DECISIONS #39.
hidden: r.hidden,
})),
at: now,
};
+3
View File
@@ -7,3 +7,6 @@ export { createState, resolveSchema, indexCharacters, RESERVED_ROOTS } from './s
export { eligibleEvents, selectEvent, resolveOptions, findOption } from './select.js';
export { startGame, currentEvent, currentTurn, takeTurn, ContentError, MoveError } from './game.js';
export { validatePack, assertValidPack } from './validate.js';
export { drawCast, presentPeople, presentSlots, personIn, slotsOf, hasDrawnCast } from './cast.js';
export { fillText, placeholders, bindEffects, bindConditions } from './text.js';
export { chanceOf, rollCheck, oddsWords, DEFAULT_ODDS } from './checks.js';
+22 -1
View File
@@ -9,6 +9,21 @@
import { check } from './conditions.js';
import { getPath } from './paths.js';
import { presentSlots } from './cast.js';
import { bindConditions } from './text.js';
/**
* Pick, for each role an event `involves`, one person present in this run. An
* event about "a co-worker" is about one particular co-worker for the turn.
*/
export function bindRoles(pack, state, event, rng) {
const bindings = {};
for (const role of event.involves ?? []) {
const slots = presentSlots(pack, state, role);
if (slots.length) bindings[role] = slots.length === 1 ? slots[0] : rng.pick(slots);
}
return bindings;
}
/** Events that could fire right now, with the reason excluded ones could not. */
export function eligibleEvents(pack, state) {
@@ -37,6 +52,11 @@ export function eligibleEvents(pack, state) {
excluded.push({ event, reason: 'requires', failed: gate.failed });
continue;
}
// An event about a co-worker cannot fire in a run that drew none.
if ((event.involves ?? []).some((role) => !presentSlots(pack, state, role).length)) {
excluded.push({ event, reason: 'involves' });
continue;
}
eligible.push(event);
}
@@ -60,8 +80,9 @@ export function selectEvent(pack, state, rng) {
* strongest signal that the choice set is too narrow.
*/
export function resolveOptions(state, event) {
const bindings = state?.meta?.bindings ?? {};
return (event.options ?? []).map((option) => {
const gate = check(state, option.requires);
const gate = check(state, bindConditions(option.requires, bindings));
return {
option,
available: gate.passed,
+28 -8
View File
@@ -5,6 +5,7 @@
// names no stat of its own beyond its own bookkeeping.
import { getPath, setPath } from './paths.js';
import { slotsOf, drawCast, drawnRelationships, hasDrawnCast } from './cast.js';
/**
* Paths the engine owns. Content may *read* these in conditions — "only after
@@ -12,7 +13,7 @@ import { getPath, setPath } from './paths.js';
* them, which is what keeps engine bookkeeping from being corrupted by data.
*/
export const RESERVED_ROOTS =
new Set(['turn', 'stage', 'seen', 'lastSeen', 'history', 'meta', 'calendar']);
new Set(['turn', 'stage', 'seen', 'lastSeen', 'history', 'meta', 'calendar', 'cast']);
/**
* Flatten a pack's state declarations into one path -> rule map, expanding the
@@ -27,17 +28,30 @@ export function resolveSchema(pack) {
schema[path] = { ...rule };
}
for (const character of pack.characters ?? []) {
for (const [stat, rule] of Object.entries(pack.relationshipStats ?? {})) {
// A character may start somewhere other than the pack default — a rival
// who already resents you, a boss who has not formed an opinion.
const override = character.startingRelationship?.[stat];
schema[`relationships.${character.id}.${stat}`] =
override === undefined ? { ...rule } : { ...rule, initial: override };
// A group role has one slot per possible member (coworker_1 .. coworker_4),
// all declared up front so every one is validated and saved. See cast.js.
for (const slot of slotsOf(character)) {
for (const [stat, rule] of Object.entries(pack.relationshipStats ?? {})) {
// A character may start somewhere other than the pack default — a rival
// who already resents you, a boss who has not formed an opinion.
const override = character.startingRelationship?.[stat];
schema[`relationships.${slot}.${stat}`] =
override === undefined ? { ...rule } : { ...rule, initial: override };
}
}
}
return schema;
}
/** Draw a run's cast into state, with the starting relationships the drawn people bring. */
function withCast(pack, state) {
if (!hasDrawnCast(pack)) return state;
const cast = drawCast(pack, state.meta.seed);
let next = setPath(state, 'cast', cast);
for (const { path, value } of drawnRelationships(pack, cast)) next = setPath(next, path, value);
return next;
}
/** A fresh game state for `pack`, seeded for reproducibility. */
export function createState(pack, { seed, schema = resolveSchema(pack) } = {}) {
let state = {
@@ -58,7 +72,7 @@ export function createState(pack, { seed, schema = resolveSchema(pack) } = {}) {
for (const [path, rule] of Object.entries(schema)) {
state = setPath(state, path, structuredClone(rule.initial));
}
return state;
return withCast(pack, state);
}
/**
@@ -82,6 +96,12 @@ export function hydrate(pack, state, schema = resolveSchema(pack)) {
next = setPath(next, path, structuredClone(rule.initial));
added.push(path);
}
// A run saved before its pack drew a cast gets one now, from its own seed, so
// the same save always wakes up with the same people.
if (hasDrawnCast(pack) && next.cast === undefined) {
next = withCast(pack, next);
added.push('cast');
}
return { state: next, added };
}
+77
View File
@@ -0,0 +1,77 @@
// Names in text, and which person an event is about.
//
// Content that meets a different cast each run cannot write "Dutch" into its
// prose. It writes `{rival}`, and the name is filled in from whoever was drawn.
// `{rival.first}` is the first name, `{rival.title}` the title.
//
// A group role (two to four co-workers) cannot be named by its role alone —
// which one? An event says so with `involves: ['coworker']`, and when the event
// is drawn one present member is bound to it for the turn. `{coworker}` in its
// text, `relationships.{coworker}.trust` in its effects and `against:
// '{coworker}'` in a check all mean that person. The binding is saved with the
// run, so a replay binds the same person. See DECISIONS #41.
import { personIn } from './cast.js';
const PLACEHOLDER = /\{([A-Za-z0-9_]+)(?:\.(name|first|title))?\}/g;
/** Every `{id}` / `{id.part}` in a piece of text, as `{ id, part }`. */
export function placeholders(text) {
if (typeof text !== 'string') return [];
return [...text.matchAll(PLACEHOLDER)].map((m) => ({ id: m[1], part: m[2] ?? 'name' }));
}
/** The slot a placeholder id means this turn: a bound group member, or the id itself. */
export function resolveSlot(id, bindings = {}) {
return bindings[id] ?? id;
}
/** Replace `{id}` in a path or a string with the slot bound to it this turn. */
export function bindPath(path, bindings = {}) {
if (typeof path !== 'string') return path;
return path.replace(/\{([A-Za-z0-9_]+)\}/g, (whole, id) => resolveSlot(id, bindings));
}
/** Fill names into text. Anything it cannot resolve is left as written. */
export function fillText(pack, state, text, bindings = state?.meta?.bindings ?? {}) {
if (typeof text !== 'string') return text;
return text.replace(PLACEHOLDER, (whole, id, part = 'name') => {
const person = personIn(pack, state, resolveSlot(id, bindings));
if (!person) return whole;
if (part === 'first') return person.name.split(' ')[0];
if (part === 'title') return person.title ?? '';
return person.name;
});
}
/** Conditions with any bound placeholders in their paths replaced by the slot. */
export function bindConditions(conditions, bindings = {}) {
if (!conditions || !Object.keys(bindings).length) return conditions;
const walk = (c) => {
if (!c || typeof c !== 'object') return c;
if (Array.isArray(c)) return c.map(walk);
if ('all' in c) return { ...c, all: c.all.map(walk) };
if ('any' in c) return { ...c, any: c.any.map(walk) };
if ('not' in c) return { ...c, not: walk(c.not) };
return { ...c, path: bindPath(c.path, bindings) };
};
return walk(conditions);
}
/** Effects with any bound placeholders in their paths replaced by the slot. */
export function bindEffects(effects, bindings = {}) {
return (effects ?? []).map((effect) => {
const path = bindPath(effect.path, bindings);
let value = effect.value;
if (value && typeof value === 'object' && typeof value.from === 'string') {
value = { ...value, from: bindPath(value.from, bindings) };
} else if (typeof value === 'string') {
// Text kept in state (a notable moment) outlives the turn's bindings, so
// `{clerk}` is pinned to the slot it meant: `{clerk_2}` still names the
// same person when the retrospective reads it back.
value = value.replace(PLACEHOLDER, (whole, id, part) =>
(bindings[id] ? `{${bindings[id]}${part ? `.${part}` : ''}}` : whole));
}
return path === effect.path && value === effect.value ? effect : { ...effect, path, value };
});
}
+192 -13
View File
@@ -6,8 +6,10 @@
import { RESERVED_ROOTS, resolveSchema, indexCharacters } from './state.js';
import { OPERATORS, pathsUsed as conditionPaths } from './conditions.js';
import { OPERATIONS } from './effects.js';
import { OPERATIONS, isAmountFrom } from './effects.js';
import { parsePath } from './paths.js';
import { groupRange } from './cast.js';
import { placeholders } from './text.js';
const ID = /^[A-Za-z0-9_]+$/;
@@ -40,14 +42,108 @@ export function validatePack(pack) {
}
}
// Attributes are ordinary declared state (`attributes.<id>` for the player);
// pack.attributes names them, so checks and characters can be held to the list.
const attributeIds = new Set(Object.keys(pack.attributes ?? {}));
for (const id of attributeIds) {
if (!ID.test(id)) fail(`attribute "${id}"`, `id must match ${ID}`);
if (!(`attributes.${id}` in schema)) fail(`attribute "${id}"`, `the player needs "attributes.${id}" in pack.state`);
}
const checkAttributes = (where, attributes, { complete }) => {
for (const key of Object.keys(attributes ?? {})) {
if (!attributeIds.has(key)) fail(where, `attribute "${key}" is not in pack.attributes`);
else if (typeof attributes[key] !== 'number') fail(where, `attribute "${key}" must be a number`);
}
if (complete) {
for (const id of attributeIds) {
if (typeof attributes?.[id] !== 'number') fail(where, `has no "${id}", which every drawn person needs`);
}
}
};
const seenCharacters = new Set();
const names = new Map();
const nameOnce = (where, name) => {
if (names.has(name)) fail(where, `name "${name}" is already used by ${names.get(name)}`);
else names.set(name, where);
};
for (const character of pack.characters ?? []) {
const where = `character "${character.id}"`;
if (!character.id || !ID.test(character.id)) fail(where, `id must match ${ID}`);
if (seenCharacters.has(character.id)) fail(where, 'duplicate id');
seenCharacters.add(character.id);
if (!character.name) fail(where, 'name is required');
if (!character.role_type) warn(where, 'no role_type tag; content cannot filter on it');
const range = groupRange(character);
const pool = character.candidates ?? [];
if (range) {
const [min, max] = range;
if (!Number.isInteger(min) || !Number.isInteger(max) || min < 0 || max < 1 || min > max) {
fail(where, `count ${JSON.stringify(character.count)} must be [min, max] whole numbers with 0 <= min <= max and max >= 1`);
} else if (pool.length < max) {
fail(where, `can hold up to ${max} people but has only ${pool.length} candidates`);
}
}
if (range || pool.length) {
if (character.name) warn(where, 'has a name as well as candidates; the drawn candidate\'s name is used');
pool.forEach((candidate, index) => {
const at = `${where} candidate ${index}`;
if (!candidate.name) fail(at, 'name is required');
else nameOnce(at, candidate.name);
if (!candidate.description) warn(at, 'no description');
checkAttributes(at, candidate.attributes, { complete: attributeIds.size > 0 });
for (const stat of Object.keys(candidate.startingRelationship ?? {})) {
if (!(stat in (pack.relationshipStats ?? {}))) fail(at, `startingRelationship names unknown stat "${stat}"`);
}
});
} else {
if (!character.name) fail(where, 'name is required');
else nameOnce(where, character.name);
checkAttributes(where, character.attributes, { complete: false });
}
}
const characterById = Object.fromEntries((pack.characters ?? []).map((c) => [c.id, c]));
const isGroup = (id) => Boolean(characterById[id] && groupRange(characterById[id]));
/**
* A path or text may name a character as `{id}`. A single character is always
* there to name; a group member only when the event binds one with `involves`.
* Returns the path with each placeholder replaced by a representative slot, so
* it can be checked against the schema like any written path.
*/
const bindForCheck = (where, text, involves) => {
for (const { id } of placeholders(text)) {
if (!characterById[id]) fail(where, `{${id}} names no character`);
else if (isGroup(id) && !involves.includes(id)) {
fail(where, `{${id}} is a group; the event must list "${id}" in involves to say which one`);
}
}
return typeof text === 'string'
? text.replace(/\{([A-Za-z0-9_]+)\}/g, (w, id) => (isGroup(id) ? `${id}_1` : id))
: text;
};
// Chances are the pack's numbers, like every other tuning value (#9).
const usesChecks = (pack.events ?? []).some((e) => (e.options ?? []).some((o) => o.check));
if (usesChecks) {
const rules = pack.checks;
if (!rules) fail('checks', 'options carry checks but the pack declares no checks block');
else {
for (const key of ['base', 'perPoint', 'floor', 'ceiling']) {
if (typeof rules[key] !== 'number') fail('checks', `${key} must be a number`);
}
if (rules.floor > rules.ceiling) fail('checks', 'floor is above ceiling');
if (rules.odds) {
let last = -Infinity;
for (const band of rules.odds) {
if (typeof band.upTo !== 'number' || band.upTo <= last) fail('checks', 'odds bands must rise by upTo');
if (!band.words) fail('checks', 'every odds band needs words');
last = band.upTo;
}
if (last < (rules.ceiling ?? 100)) fail('checks', `odds bands stop at ${last}, below the ceiling`);
}
}
}
const readable = (path) => {
@@ -164,6 +260,31 @@ export function validatePack(pack) {
fail(where, `weight ${event.weight} must be greater than zero, or the event can never fire`);
}
checkConditions(where, event.requires);
const involves = event.involves ?? [];
for (const role of involves) {
if (!characterById[role]) fail(where, `involves "${role}", which is no character`);
}
// Bindings are drawn after the event is chosen, so the event's own gate
// cannot depend on who they are.
const gatePaths = event.requires
? (Array.isArray(event.requires) ? event.requires : [event.requires]).flatMap((c) => conditionPaths(c))
: [];
if (gatePaths.some((p) => p.includes('{'))) {
fail(where, 'requires names a {character}; only options and effects can, once the event is drawn');
}
bindForCheck(where, event.description, involves);
const bound = (conditions) => {
if (!conditions) return conditions;
const walk = (c) => {
if (!c || typeof c !== 'object') return c;
if (Array.isArray(c)) return c.map(walk);
if ('all' in c) return { ...c, all: c.all.map(walk) };
if ('any' in c) return { ...c, any: c.any.map(walk) };
if ('not' in c) return { ...c, not: walk(c.not) };
return { ...c, path: bindForCheck(where, c.path, involves) };
};
return walk(conditions);
};
// A fallback must be one that *no* exclusion rule can remove: requires,
// once and cooldown are exactly the three reasons eligibleEvents drops an
// event, so a floor event may carry none of them.
@@ -180,20 +301,78 @@ export function validatePack(pack) {
if (seenOptions.has(option.id)) fail(optionWhere, 'duplicate id');
seenOptions.add(option.id);
if (!option.label) fail(optionWhere, 'label is required');
checkConditions(optionWhere, option.requires);
bindForCheck(optionWhere, option.label, involves);
bindForCheck(optionWhere, option.result, involves);
checkConditions(optionWhere, bound(option.requires));
for (const effect of option.effects ?? []) {
if (!OPERATIONS.includes(effect.op)) {
fail(optionWhere, `unknown effect operation ${JSON.stringify(effect.op)}`);
const checkEffects = (at, effects) => {
for (const effect of effects ?? []) {
if (!OPERATIONS.includes(effect.op)) {
fail(at, `unknown effect operation ${JSON.stringify(effect.op)}`);
}
if (effect.op === 'add' && typeof effect.value !== 'number') {
if (isAmountFrom(effect.value)) {
// An amount read from state must read something the pack declares,
// and scale and cap it by numbers. See DECISIONS #38.
const problem = readable(bindForCheck(at, effect.value.from, involves));
if (problem) fail(at, `effect "${effect.path}" reads its amount from ${problem}`);
for (const key of ['times', 'cap']) {
if (effect.value[key] !== undefined && typeof effect.value[key] !== 'number') {
fail(at, `effect "${effect.path}" has a non-numeric ${key}`);
}
}
} else {
fail(at, `effect "${effect.path}" uses ${effect.op} with a non-numeric value`);
}
}
}
if (['add'].includes(effect.op) && typeof effect.value !== 'number') {
fail(optionWhere, `effect "${effect.path}" uses ${effect.op} with a non-numeric value`);
for (const effect of effects ?? []) {
if (typeof effect.path !== 'string') continue;
const problem = writable(bindForCheck(at, effect.path, involves), effect, at);
if (problem) fail(at, `effect writes ${problem}`);
}
}
for (const effect of option.effects ?? []) {
if (typeof effect.path !== 'string') continue;
const problem = writable(effect.path, effect, optionWhere);
if (problem) fail(optionWhere, `effect writes ${problem}`);
};
checkEffects(optionWhere, option.effects);
if (option.check) {
const c = option.check;
const at = `${optionWhere} check`;
if (!attributeIds.has(c.attribute)) fail(at, `attribute "${c.attribute}" is not in pack.attributes`);
if (c.opposedBy !== undefined && !attributeIds.has(c.opposedBy)) {
fail(at, `opposedBy "${c.opposedBy}" is not in pack.attributes`);
}
if (c.against !== undefined) {
const id = String(c.against).replace(/^\{|\}$/g, '');
const character = characterById[id];
if (!character) fail(at, `against "${c.against}" is no character`);
else {
if (isGroup(id) && !involves.includes(id)) {
fail(at, `against "${c.against}" is a group; the event must list "${id}" in involves`);
}
const attribute = c.opposedBy ?? c.attribute;
const people = character.candidates?.length ? character.candidates : [character];
if (people.some((p) => typeof p.attributes?.[attribute] !== 'number')) {
fail(at, `not everyone who can be "${id}" has "${attribute}" to oppose with`);
}
}
} else if (c.relationship !== undefined) {
fail(at, 'relationship needs against: whose regard is it?');
}
if (c.relationship !== undefined && !(c.relationship in (pack.relationshipStats ?? {}))) {
fail(at, `relationship "${c.relationship}" is not in relationshipStats`);
}
for (const key of ['difficulty', 'modifier']) {
if (c[key] !== undefined && typeof c[key] !== 'number') fail(at, `${key} must be a number`);
}
for (const branch of ['success', 'failure']) {
const outcome = option[branch];
if (!outcome) { fail(at, `has no ${branch}; a check needs both ways it can go`); continue; }
if (!outcome.result) warn(`${optionWhere} ${branch}`, 'no result text; the player will not be told');
bindForCheck(`${optionWhere} ${branch}`, outcome.result, involves);
checkEffects(`${optionWhere} ${branch}`, outcome.effects);
}
} else if (option.success || option.failure) {
fail(optionWhere, 'has success or failure but no check to decide between them');
}
}
}
+11 -5
View File
@@ -4,6 +4,7 @@ import { h } from './dom.js';
import { formatValue, labelFor, headerStats } from './format.js';
import { APP, versionLine } from '../version.js';
import { getPath } from '../engine/paths.js';
import { fillText } from '../engine/text.js';
/**
* What you are playing, which pack, and who you currently are — plus the build,
@@ -57,12 +58,14 @@ export function statPanel(pack, state, schema) {
/** One character, with how they currently regard the player. */
export function characterCard(pack, state, schema, character) {
// `character` is a person as presentPeople() returns them: drawn for this run
// or written into the pack, keyed by the slot their relationships live under.
return h('article', { class: 'person' },
h('header', { class: 'person__head' },
h('h3', { class: 'person__name' }, character.name),
character.title && h('p', { class: 'person__title' }, character.title),
),
h('p', { class: 'person__description' }, character.description),
h('p', { class: 'person__description' }, fillText(pack, state, character.description)),
h('div', { class: 'person__meters' },
Object.keys(pack.relationshipStats ?? {}).map((stat) => {
const path = `relationships.${character.id}.${stat}`;
@@ -78,7 +81,7 @@ export function characterCard(pack, state, schema, character) {
}
/** The change list shown after a choice: what moved, and by how much. */
export function changeList(pack, changes, { className = 'changes' } = {}) {
export function changeList(pack, changes, { className = 'changes', state } = {}) {
const meaningful = changes.filter((change) => {
if (change.from === change.to) return false;
// Some state is bookkeeping the player has no use for — `notable` is the
@@ -95,7 +98,7 @@ export function changeList(pack, changes, { className = 'changes' } = {}) {
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'}`;
?? `${labelFor(pack, change.path, state)}: ${change.to ? 'yes' : 'no longer'}`;
return h('li', { class: `change change--note change--${change.to ? 'up' : 'down'}` },
h('span', { class: 'change__phrase' }, phrase),
);
@@ -105,9 +108,12 @@ export function changeList(pack, changes, { className = 'changes' } = {}) {
return h('li', { class: `change change--${rising ? 'up' : 'down'}` },
// Upkeep changes carry the label of the entry that caused them —
// "Rent", "Wages" — which is far more use than the path's own name.
h('span', { class: 'change__label' }, change.label ?? labelFor(pack, change.path)),
h('span', { class: 'change__label' }, change.label ?? labelFor(pack, change.path, state)),
h('span', { class: 'change__delta' }, deltaText(pack, change)),
change.clamped && h('span', { class: 'change__note' }, 'at its limit'),
// Say where it stopped. "−$4 at its limit" was read by a real player as
// a debt of four still standing, when it meant the balance hit zero.
change.clamped && h('span', { class: 'change__note' },
`stopped at ${formatValue(pack, change.path, change.to)}`),
);
}),
);
+18 -12
View File
@@ -8,6 +8,7 @@
import { getPath } from '../engine/paths.js';
import { check } from '../engine/conditions.js';
import { personIn } from '../engine/cast.js';
const titleCase = (text) =>
text.replace(/_/g, ' ').replace(/^\w/, (c) => c.toUpperCase());
@@ -17,16 +18,21 @@ export function displayFor(pack, path) {
return pack.display?.[path] ?? null;
}
/** A human name for a path, derived from the path when the pack is silent. */
export function labelFor(pack, path) {
/**
* A human name for a path, derived from the path when the pack is silent.
* Given the run's state, a relationship path names whoever is in that slot this
* run — a drawn boss, the second of three co-workers — not the role.
*/
export function labelFor(pack, path, state) {
const declared = displayFor(pack, path)?.label;
if (declared) return declared;
const segments = path.split('.');
if (segments[0] === 'relationships' && segments.length === 3) {
const character = pack.characters?.find((c) => c.id === segments[1]);
const name = (state && personIn(pack, state, segments[1])?.name)
?? pack.characters?.find((c) => c.id === segments[1])?.name;
const stat = pack.relationshipStats?.[segments[2]]?.label ?? titleCase(segments[2]);
return `${character?.name ?? titleCase(segments[1])} — ${stat.toLowerCase()}`;
return `${name ?? titleCase(segments[1])} — ${stat.toLowerCase()}`;
}
return titleCase(segments.at(-1));
}
@@ -61,15 +67,15 @@ export function formatDelta(pack, path, from, to) {
* One condition as a requirement the player can read: "Negotiation 15+",
* "Trevor — likes you 48+", "not in debt".
*/
export function describeCondition(pack, condition) {
export function describeCondition(pack, condition, state) {
if (!condition || typeof condition !== 'object') return '';
if (Array.isArray(condition)) return condition.map((c) => describeCondition(pack, c)).join(', ');
if (condition.all) return condition.all.map((c) => describeCondition(pack, c)).join(', ');
if (condition.any) return condition.any.map((c) => describeCondition(pack, c)).join(' or ');
if (condition.not) return `not ${describeCondition(pack, condition.not)}`;
if (Array.isArray(condition)) return condition.map((c) => describeCondition(pack, c, state)).join(', ');
if (condition.all) return condition.all.map((c) => describeCondition(pack, c, state)).join(', ');
if (condition.any) return condition.any.map((c) => describeCondition(pack, c, state)).join(' or ');
if (condition.not) return `not ${describeCondition(pack, condition.not, state)}`;
const { path, op, value } = condition;
const label = labelFor(pack, path);
const label = labelFor(pack, path, state);
if (typeof value === 'boolean') return value ? label.toLowerCase() : `no ${label.toLowerCase()}`;
@@ -86,8 +92,8 @@ export function describeCondition(pack, condition) {
}
/** What a locked option is waiting for. */
export function requirementText(pack, failedConditions) {
const parts = failedConditions.map((c) => describeCondition(pack, c)).filter(Boolean);
export function requirementText(pack, failedConditions, state) {
const parts = failedConditions.map((c) => describeCondition(pack, c, state)).filter(Boolean);
return parts.length ? `Needs ${parts.join(', ')}` : 'Not available yet';
}
+4 -2
View File
@@ -6,6 +6,8 @@
import { h } from '../dom.js';
import { statPanel, characterCard, statusBar } from '../components.js';
import { presentPeople } from '../../engine/cast.js';
import { fillText } from '../../engine/text.js';
import { formatMoney, labelFor } from '../format.js';
import { summarize } from '../summary.js';
@@ -58,14 +60,14 @@ export function retrospectiveScreen(pack, state, schema, actions) {
h('section', { class: 'retro__section' },
h('h2', { class: 'retro__heading' }, 'Where you stand with people'),
h('div', { class: 'retro__people' },
(pack.characters ?? []).map((character) => characterCard(pack, state, schema, character)),
presentPeople(pack, state).map((person) => characterCard(pack, state, schema, person)),
),
),
h('section', { class: 'retro__section' },
h('h2', { class: 'retro__heading' }, 'Notable moments'),
summary.notable.length
? h('ol', { class: 'moments' }, summary.notable.map((moment) => h('li', { class: 'moment' }, moment)))
? h('ol', { class: 'moments' }, summary.notable.map((moment) => h('li', { class: 'moment' }, fillText(pack, state, moment))))
: h('p', { class: 'moments moments--none' },
'Nothing that anybody would write down. That happens.'),
),
+28 -13
View File
@@ -12,6 +12,8 @@
import { h } from '../dom.js';
import { statPanel, characterCard, changeList, statusBar } from '../components.js';
import { requirementText, turnHeading, upcomingUpkeep } from '../format.js';
import { presentPeople } from '../../engine/cast.js';
import { fillText } from '../../engine/text.js';
export function turnScreen(pack, state, schema, view, actions) {
const heading = turnHeading(pack, state);
@@ -33,12 +35,12 @@ export function turnScreen(pack, state, schema, view, actions) {
h('div', { class: 'layout' },
h('main', { class: 'scene' },
view.phase === 'outcome'
? outcomeView(pack, view, actions)
: choosingView(pack, view, actions),
? outcomeView(pack, state, view, actions)
: choosingView(pack, state, view, actions),
),
h('aside', { class: 'people' },
h('h2', { class: 'people__heading' }, pack.peopleHeading ?? 'The people'),
(pack.characters ?? []).map((character) => characterCard(pack, state, schema, character)),
presentPeople(pack, state).map((person) => characterCard(pack, state, schema, person)),
),
),
@@ -84,44 +86,55 @@ function diary(pack, state) {
);
}
function choosingView(pack, view, actions) {
function choosingView(pack, state, view, actions) {
const { event, options } = view.turn;
const text = (t) => fillText(pack, state, t, view.turn.bindings);
const visible = options.filter((o) => !o.hidden);
// The first option a player can actually take gets the focus, so Enter plays
// it and Tab walks to the others.
const firstAvailable = visible.find((o) => o.available)?.option.id;
return h('section', { class: 'scenario' },
h('p', { class: 'scenario__text' }, event.description),
h('p', { class: 'scenario__text' }, text(event.description)),
h('ul', { class: 'options' },
visible.map(({ option, available, failed }) => h('li', { class: 'options__item' },
visible.map(({ option, available, failed, odds }) => h('li', { class: 'options__item' },
h('button', {
class: `option${available ? '' : ' option--locked'}`,
disabled: !available,
dataset: option.id === firstAvailable ? { autofocus: 'true' } : {},
onClick: available ? () => actions.onChoose(option.id) : null,
},
h('span', { class: 'option__label' }, option.label),
!available && h('span', { class: 'option__requirement' }, requirementText(pack, failed)),
h('span', { class: 'option__label' }, text(option.label)),
// Odds are shown in words, never as a number: "a long shot", "likely".
available && odds && h('span', { class: 'option__odds' }, odds.words),
!available && h('span', { class: 'option__requirement' }, requirementText(pack, failed, state)),
),
)),
),
);
}
function outcomeView(pack, view, actions) {
function outcomeView(pack, state, view, actions) {
const { record, option } = view;
// The state has already moved on to the next event, so the names in this
// outcome come from the bindings the turn was played with, not the new ones.
const text = (t) => fillText(pack, state, t, record.bindings ?? {});
const branch = record.check ? option[record.check.outcome] : null;
const result = branch ? branch.result : option.result;
const said = pack.checks?.said ?? { success: 'It came off.', failure: 'It did not come off.' };
return h('section', { class: 'outcome' },
h('p', { class: 'outcome__chose' }, option.label),
option.result && h('p', { class: 'outcome__text' }, option.result),
h('p', { class: 'outcome__chose' }, record.optionLabel ?? text(option.label)),
record.check && h('p', { class: `outcome__check outcome__check--${record.check.outcome}` },
`${capitalise(record.check.odds)} — ${said[record.check.outcome]}`),
result && h('p', { class: 'outcome__text' }, text(result)),
h('div', { class: 'outcome__ledger' },
h('h3', { class: 'outcome__heading' }, 'What that changed'),
changeList(pack, record.changes),
changeList(pack, record.changes, { state }),
record.upkeep.length > 0 && h('div', { class: 'outcome__upkeep' },
h('h3', { class: 'outcome__heading' }, 'And then the day happened'),
changeList(pack, record.upkeep),
changeList(pack, record.upkeep, { state }),
),
),
@@ -185,3 +198,5 @@ function feedbackWidget(view, actions) {
feedback.saved && h('p', { class: 'feedback__saved' }, 'Noted — thank you.'),
);
}
const capitalise = (words) => words.charAt(0).toUpperCase() + words.slice(1);
+7
View File
@@ -171,6 +171,13 @@ body {
.option__requirement {
font-size: 12px; letter-spacing: 0.04em; color: var(--ink-faint); font-style: italic;
}
/* The odds, in words: "a long shot", "likely". Never a number. */
.option__odds {
font-size: 12px; letter-spacing: 0.04em; color: var(--accent); font-style: italic;
}
.outcome__check { margin: 0 0 8px; font-size: 14px; font-style: italic; }
.outcome__check--success { color: var(--up); }
.outcome__check--failure { color: var(--down); }
/* ---------- outcome ---------- */
+1 -1
View File
@@ -8,7 +8,7 @@ export const APP = {
// 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',
version: '0.4.0',
};
/** "0.3.0 · a1b2c3d" — or "0.3.0 · dev" when running from source unstamped. */
+185
View File
@@ -0,0 +1,185 @@
// The cast drawn per run, names in text, and options whose outcome is a check.
// See DECISIONS #41. Runs against test/fixtures/cast-pack.js, never real content.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { startGame, currentTurn, takeTurn } from '../src/engine/game.js';
import { validatePack } from '../src/engine/validate.js';
import { hydrate, createState, resolveSchema } from '../src/engine/state.js';
import { presentPeople, presentSlots, personIn } from '../src/engine/cast.js';
import { fillText } from '../src/engine/text.js';
import { chanceOf, oddsWords } from '../src/engine/checks.js';
import { getPath, setPath } from '../src/engine/paths.js';
import { castPack } from './fixtures/cast-pack.js';
import { testPack } from './fixtures/test-pack.js';
const clone = (value) => structuredClone(value);
/** Advance until the current event is `eventId`, taking `work` otherwise. */
function until(state, eventId, pack = castPack) {
let next = state;
for (let i = 0; i < 200 && currentTurn(pack, next).event.id !== eventId; i++) {
next = takeTurn(pack, next, currentTurn(pack, next).options[0].option.id).state;
}
assert.equal(currentTurn(pack, next).event.id, eventId);
return next;
}
test('the cast pack is valid, with no warnings', () => {
const { errors, warnings } = validatePack(castPack);
assert.deepEqual(errors, []);
assert.deepEqual(warnings, []);
});
test('a run draws one boss and two to four different co-workers', () => {
const counts = new Set();
for (let seed = 0; seed < 40; seed++) {
const state = startGame(castPack, { seed });
const people = presentPeople(castPack, state);
assert.equal(people.filter((p) => p.role === 'boss').length, 1);
const coworkers = presentSlots(castPack, state, 'coworker');
assert.ok(coworkers.length >= 2 && coworkers.length <= 4, `drew ${coworkers.length}`);
counts.add(coworkers.length);
const names = people.map((p) => p.name);
assert.equal(new Set(names).size, names.length, 'nobody is drawn twice');
assert.ok(names.includes('Old Sam'), 'a fixed character is always present');
}
assert.deepEqual([...counts].sort(), [2, 3, 4], 'every head count turns up across seeds');
});
test('the cast is the seed\'s: the same seed draws the same people, others differ', () => {
const names = (seed) => presentPeople(castPack, startGame(castPack, { seed })).map((p) => p.name).join('|');
assert.equal(names('a'), names('a'));
const distinct = new Set(Array.from({ length: 20 }, (_, i) => names(`s${i}`)));
assert.ok(distinct.size > 10, `only ${distinct.size} different casts in 20 runs`);
});
test('an empty group slot is not in the run, and a drawn person brings their own starting regard', () => {
let seed = 0;
let state;
do state = startGame(castPack, { seed: seed++ });
while (presentSlots(castPack, state, 'coworker').length !== 2 || personIn(castPack, state, 'boss').name !== 'Walter Grose');
assert.equal(getPath(state, 'cast.coworker_3'), null);
assert.equal(personIn(castPack, state, 'coworker_3'), undefined);
assert.equal(getPath(state, 'relationships.boss.trust'), 30);
});
test('names are filled into text from whoever was drawn', () => {
const state = startGame(castPack, { seed: 'names' });
const boss = personIn(castPack, state, 'boss');
assert.equal(fillText(castPack, state, 'Ask {boss.first}, the {boss.title}.'),
`Ask ${boss.name.split(' ')[0]}, the Manager.`);
assert.equal(fillText(castPack, state, '{porter} nods.'), 'Old Sam nods.');
assert.equal(fillText(castPack, state, '{nobody} stays as written.'), '{nobody} stays as written.');
});
test('an event about a co-worker binds one who is present, and its effects land on them', () => {
const state = until(startGame(castPack, { seed: 'bind' }), 'needled');
const turn = currentTurn(castPack, state);
const slot = turn.bindings.coworker;
assert.ok(presentSlots(castPack, state, 'coworker').includes(slot));
assert.match(fillText(castPack, state, turn.event.description), new RegExp(personIn(castPack, state, slot).name));
const before = getPath(state, `relationships.${slot}.regard`);
const { state: after, record } = takeTurn(castPack, state, 'ignore');
assert.equal(getPath(after, `relationships.${slot}.regard`), before - 2);
assert.deepEqual(record.bindings, { coworker: slot });
});
test('the chance follows the formula, and the odds are words', () => {
let state = startGame(castPack, { seed: 'odds' });
const option = castPack.events[0].options[2]; // wits against difficulty 5
state = setPath(state, 'attributes.wits', 5);
assert.equal(chanceOf(castPack, state, option).chance, 50);
state = setPath(state, 'attributes.wits', 7);
assert.equal(chanceOf(castPack, state, option).chance, 70);
state = setPath(state, 'attributes.wits', 10);
assert.equal(chanceOf(castPack, state, option).chance, 100, 'clamped at the ceiling');
assert.equal(oddsWords(castPack, 15), 'a long shot');
assert.equal(oddsWords(castPack, 50), 'an even chance');
assert.equal(oddsWords(castPack, 90), 'a safe bet');
});
test('an opposed check reads the other person\'s attribute, and their regard moves it', () => {
let state = startGame(castPack, { seed: 'opposed' });
const option = castPack.events[0].options[1]; // charm against the boss's wits, leaning on trust
const boss = personIn(castPack, state, 'boss');
state = setPath(state, 'attributes.charm', 6);
state = setPath(state, 'relationships.boss.trust', 50);
const even = chanceOf(castPack, state, option);
assert.equal(even.theirs, boss.attributes.wits);
assert.equal(even.chance, Math.min(100, Math.max(0, 50 + 10 * (6 - boss.attributes.wits))));
state = setPath(state, 'relationships.boss.trust', 70);
assert.equal(chanceOf(castPack, state, option).chance, Math.min(100, even.chance + 10));
});
test('a check applies the branch it rolled, plus the option\'s own effects', () => {
let state = until(startGame(castPack, { seed: 'roll' }), 'routine');
const sure = setPath(setPath(state, 'attributes.charm', 10), 'relationships.boss.trust', 100);
const won = takeTurn(castPack, sure, 'ask_for_more');
assert.equal(won.record.check.outcome, 'success');
assert.equal(getPath(won.state, 'money'), getPath(sure, 'money') + 50);
assert.equal(getPath(won.state, 'relationships.boss.regard'), getPath(sure, 'relationships.boss.regard') - 1);
const hopeless = setPath(setPath(state, 'attributes.charm', 0), 'relationships.boss.trust', 0);
const lost = takeTurn(castPack, hopeless, 'ask_for_more');
assert.equal(lost.record.check.outcome, 'failure');
assert.equal(getPath(lost.state, 'money'), getPath(hopeless, 'money'));
assert.equal(getPath(lost.state, 'relationships.boss.trust'), 0, 'clamped');
assert.match(lost.record.check.odds, /long shot/);
});
test('a run with checks and bindings replays identically from its seed and choices', () => {
const play = () => {
let state = startGame(castPack, { seed: 'replay' });
const records = [];
for (let i = 0; i < 60; i++) {
const turn = currentTurn(castPack, state);
const pick = turn.options[i % turn.options.length].option.id;
const result = takeTurn(castPack, state, pick);
records.push(result.record);
state = i === 30 ? JSON.parse(JSON.stringify(result.state)) : result.state;
}
return { state, records };
};
const a = play();
const b = play();
assert.deepEqual(a.records.map((r) => r.check?.outcome ?? '-'), b.records.map((r) => r.check?.outcome ?? '-'));
assert.deepEqual(a.state, b.state);
assert.ok(a.records.some((r) => r.check?.outcome === 'success') && a.records.some((r) => r.check?.outcome === 'failure'));
});
test('a save from before the pack drew a cast gets one on load, the same one every time', () => {
const schema = resolveSchema(castPack);
const fresh = createState(castPack, { seed: 'old', schema });
const old = clone(fresh);
delete old.cast;
const once = hydrate(castPack, clone(old), schema);
const twice = hydrate(castPack, clone(old), schema);
assert.ok(once.added.includes('cast'));
assert.deepEqual(once.state.cast, twice.state.cast);
assert.deepEqual(once.state.cast, fresh.cast);
});
test('a pack with no drawn cast gets no cast and no bindings: its runs are unchanged', () => {
const state = startGame(testPack, { seed: 'plain' });
assert.equal(state.cast, undefined);
assert.equal(state.meta.bindings, undefined);
assert.equal(presentPeople(testPack, state).map((p) => p.name).join(), 'The Chief,Pat');
});
test('the validator catches cast and check mistakes', () => {
const broken = clone(castPack);
broken.events[0].options[1].check.attribute = 'strength';
broken.events[0].options[2].failure = undefined;
broken.events[0].description = 'Blame {coworker}.';
broken.events.push({ id: 'ghost', stage: 'desk', description: '{ghost} appears.', options: [{ id: 'x', label: 'x', effects: [] }] });
broken.characters[1].count = [2, 6];
const { errors } = validatePack(broken);
const has = (fragment) => assert.ok(errors.some((e) => e.includes(fragment)), `expected an error mentioning "${fragment}":\n${errors.join('\n')}`);
has('attribute "strength" is not in pack.attributes');
has('has no failure');
has('{coworker} is a group');
has('{ghost} names no character');
has('has only 5 candidates');
});
+35
View File
@@ -132,6 +132,41 @@ for (const pack of packs) {
assert.deepEqual(trapped, [], 'events whose every option is gated');
});
test(`${where} no repayment can take more than is owed`, () => {
// A fixed instalment against a smaller balance takes money for a debt that
// does not exist; the Frontier's store did it to a real player. A balance
// is any money-formatted stat other than money itself. An option that pays
// one down with money must read the amount from the balance, or be gated
// on the balance covering the whole instalment. See DECISIONS #38.
const balances = Object.entries(pack.display ?? {})
.filter(([path, rule]) => rule.format === 'money' && path !== 'money')
.map(([path]) => path);
const overcharging = [];
for (const event of pack.events) {
for (const option of event.options ?? []) {
// A check's branches pay and spend too (DECISIONS #41): each is judged
// together with the effects that apply either way.
const branches = option.check
? [option.success, option.failure].map((b) => [...(option.effects ?? []), ...(b?.effects ?? [])])
: [option.effects ?? []];
for (const effects of branches) {
const spends = effects.some((e) => e.path === 'money' && e.op === 'add'
&& (typeof e.value === 'number' ? e.value < 0 : (e.value?.times ?? 1) < 0));
if (!spends) continue;
for (const e of effects) {
if (!balances.includes(e.path) || e.op !== 'add') continue;
if (typeof e.value !== 'number' || e.value >= 0) continue;
const covered = (option.requires ?? [])
.some((r) => r.path === e.path && r.op === '>=' && r.value >= -e.value);
if (!covered) overcharging.push(`${event.id}.${option.id} (${e.path} ${e.value})`);
}
}
}
}
assert.ok(balances.length > 0 || pack.display === undefined, 'a pack with a balance should declare it as money');
assert.deepEqual(overcharging, [], 'options that can charge for more than is owed');
});
test(`${where} every turn always offers something to do, however badly it is going`, () => {
for (let seed = 100; seed < 130; seed++) randomRun(pack, seed, 150);
});
+31
View File
@@ -67,6 +67,37 @@ test('an unknown operation is a loud failure', () => {
assert.throws(() => applyEffect(state, { path: 'money', op: 'multiply', value: 2 }), /unknown effect operation/);
});
test('an amount can be read from state, capped and signed', () => {
// The Frontier's store, turn 27 of a real session: four owed, twenty taken.
const ledger = { owed: { initial: 0, min: 0, max: 1000 } };
const pay = [
{ path: 'money', op: 'add', value: { from: 'owed', times: -1, cap: 20 } },
{ path: 'owed', op: 'add', value: { from: 'owed', times: -1, cap: 20 } },
];
const small = applyEffects({ money: 110, owed: 4 }, pay, ledger);
assert.equal(small.state.money, 106, 'pays what is owed, not the instalment');
assert.equal(small.state.owed, 0);
assert.equal(small.changes[1].clamped, false, 'nothing is lost to a clamp');
assert.equal(small.changes[0].value, -4, 'the change records the amount applied');
assert.deepEqual(small.changes[0].source, { from: 'owed', times: -1, cap: 20 });
const large = applyEffects({ money: 110, owed: 50 }, pay, ledger);
assert.equal(large.state.money, 90, 'the cap holds when more is owed');
assert.equal(large.state.owed, 30);
});
test('an amount read from a path that is not a number is zero', () => {
const result = applyEffect({ money: 10 }, { path: 'money', op: 'add', value: { from: 'owed', times: -1 } }, {});
assert.equal(result.state.money, 10);
assert.equal(result.change.value === 0 || Object.is(result.change.value, -0), true);
});
test('a literal amount records no source', () => {
const result = applyEffect(state, { path: 'money', op: 'add', value: 5 }, schema);
assert.equal('source' in result.change, false);
});
test('an empty effect list is a no-op', () => {
const result = applyEffects(state, [], schema);
assert.deepEqual(result.changes, []);
+114
View File
@@ -0,0 +1,114 @@
// A small pack for the cast and checks (DECISIONS #41): a drawn single role, a
// group role of two to four, a fixed character with attributes, opposed and
// unopposed checks, and an event about "one of the co-workers". Its checks run
// from 0 to 100 so tests can force an outcome; a real pack keeps a floor and a
// ceiling so nothing is ever certain.
export const castPack = {
id: 'castpack',
name: 'Cast Pack',
version: '1.0.0',
turnUnit: 'day',
startingStage: 'desk',
attributes: {
nerve: { label: 'Nerve' },
wits: { label: 'Wits' },
charm: { label: 'Charm' },
resolve: { label: 'Resolve' },
},
checks: {
base: 50, perPoint: 10, unopposed: 5,
relationshipPerPoint: 0.5, relationshipCentre: 50,
floor: 0, ceiling: 100,
},
state: {
'money': { initial: 100, min: -500, max: 10000 },
'attributes.nerve': { initial: 5, min: 0, max: 10 },
'attributes.wits': { initial: 5, min: 0, max: 10 },
'attributes.charm': { initial: 5, min: 0, max: 10 },
'attributes.resolve': { initial: 5, min: 0, max: 10 },
},
relationshipStats: {
trust: { label: 'Trusts you', initial: 50, min: 0, max: 100 },
regard: { label: 'Thinks of you', initial: 50, min: 0, max: 100 },
},
characters: [
{
id: 'boss',
role_type: 'boss',
title: 'Manager',
candidates: [
{ name: 'Agnes Pell', description: 'Reads every memo twice.', attributes: { nerve: 6, wits: 8, charm: 3, resolve: 7 } },
{ name: 'Walter Grose', description: 'Loud, and quicker than he looks.', attributes: { nerve: 7, wits: 6, charm: 6, resolve: 4 },
startingRelationship: { trust: 30 } },
{ name: 'Mina Hart', description: 'Has never raised her voice.', attributes: { nerve: 4, wits: 7, charm: 8, resolve: 6 } },
],
},
{
id: 'coworker',
role_type: 'peer',
title: 'Clerk',
count: [2, 4],
candidates: [
{ name: 'Ben Aske', description: 'Owes {boss} a favour.', attributes: { nerve: 3, wits: 4, charm: 6, resolve: 5 } },
{ name: 'Cora Dunn', description: 'First in, last out.', attributes: { nerve: 5, wits: 5, charm: 4, resolve: 8 } },
{ name: 'Del Fane', description: 'Knows everyone\'s business.', attributes: { nerve: 4, wits: 7, charm: 5, resolve: 3 } },
{ name: 'Edie Gow', description: 'Quiet until she is not.', attributes: { nerve: 7, wits: 5, charm: 3, resolve: 6 } },
{ name: 'Fitz Hale', description: 'Charming, unreliable.', attributes: { nerve: 5, wits: 3, charm: 8, resolve: 2 } },
],
},
{ id: 'porter', name: 'Old Sam', role_type: 'staff', description: 'Has seen everyone come and go.',
attributes: { nerve: 2, wits: 9, charm: 4, resolve: 9 } },
],
events: [
{
id: 'routine',
stage: 'desk',
weight: 100,
description: 'A day at the desk under {boss}.',
options: [
{ id: 'work', label: 'Work.', effects: [{ path: 'money', op: 'add', value: 10 }] },
{
id: 'ask_for_more',
label: 'Ask {boss.first} for more money.',
check: { attribute: 'charm', against: 'boss', opposedBy: 'wits', relationship: 'trust' },
effects: [{ path: 'relationships.boss.regard', op: 'add', value: -1 }],
success: { result: '{boss} agrees.', effects: [{ path: 'money', op: 'add', value: 50 }] },
failure: { result: '{boss} laughs.', effects: [{ path: 'relationships.boss.trust', op: 'add', value: -5 }] },
},
{
id: 'hard_sum',
label: 'Try the hard sum.',
check: { attribute: 'wits', difficulty: 5 },
success: { result: 'It adds up.', effects: [{ path: 'attributes.wits', op: 'add', value: 1 }] },
failure: { result: 'It does not.', effects: [] },
},
],
},
{
id: 'needled',
stage: 'desk',
weight: 100,
involves: ['coworker'],
description: '{coworker} makes a remark about your tie.',
options: [
{ id: 'ignore', label: 'Let it go.', effects: [{ path: 'relationships.{coworker}.regard', op: 'add', value: -2 }] },
{
id: 'retort',
label: 'Answer {coworker.first} back.',
check: { attribute: 'nerve', against: '{coworker}', relationship: 'regard' },
success: { result: '{coworker} laughs, and means it.', effects: [{ path: 'relationships.{coworker}.regard', op: 'add', value: 5 }] },
failure: { result: 'It lands badly.', effects: [{ path: 'relationships.{coworker}.trust', op: 'add', value: -5 }] },
},
],
},
],
};
export default castPack;
+35 -7
View File
@@ -13,13 +13,41 @@
import { startGame, currentTurn, takeTurn } from '../../src/engine/game.js';
import { createRng } from '../../src/engine/rng.js';
const moneyGain = (resolved) => (resolved.option.effects ?? [])
.filter((e) => e.path === 'money' && e.op === 'add')
.reduce((sum, e) => sum + e.value, 0);
/**
* What an effect is worth to a bot. An amount read from state (DECISIONS #38)
* is scored at its cap: the most it could be, which is what a player weighing
* the option sees on the label.
*/
const amountOf = (effect) => {
if (typeof effect.value === 'number') return effect.value;
if (typeof effect.value?.cap === 'number') return effect.value.cap * (effect.value.times ?? 1);
return 0;
};
const relationshipGain = (resolved) => (resolved.option.effects ?? [])
.filter((e) => e.path.startsWith('relationships.'))
.reduce((sum, e) => sum + e.value, 0);
/**
* Every effect an option can have, with the weight a player reading the odds
* would give it: plain effects count in full, a check's success and failure by
* their chance (DECISIONS #41). An option with no check is just its effects.
*/
export const weightedEffects = (resolved) => {
const { option, odds } = resolved;
const plain = (option.effects ?? []).map((effect) => ({ effect, weight: 1 }));
if (!option.check) return plain;
const p = (odds?.chance ?? 50) / 100;
return [
...plain,
...(option.success?.effects ?? []).map((effect) => ({ effect, weight: p })),
...(option.failure?.effects ?? []).map((effect) => ({ effect, weight: 1 - p })),
];
};
const moneyGain = (resolved) => weightedEffects(resolved)
.filter(({ effect }) => effect.path === 'money' && effect.op === 'add')
.reduce((sum, { effect, weight }) => sum + amountOf(effect) * weight, 0);
const relationshipGain = (resolved) => weightedEffects(resolved)
.filter(({ effect }) => effect.path.startsWith('relationships.'))
.reduce((sum, { effect, weight }) => sum + amountOf(effect) * weight, 0);
const best = (options, score) => [...options].sort((a, b) => score(b) - score(a))[0];
@@ -48,7 +76,7 @@ export const strategies = {
*/
lifer: (options) => {
const stays = options.filter(
(o) => !(o.option.effects ?? []).some((e) => e.path === 'stage'),
(o) => !weightedEffects(o).some(({ effect }) => effect.path === 'stage'),
);
return best(stays.length ? stays : options, relationshipGain);
},
+31 -1
View File
@@ -11,6 +11,8 @@ import assert from 'node:assert/strict';
import { frontier as pack } from '../content/frontier/index.js';
import { startGame, currentTurn, takeTurn } from '../src/engine/game.js';
import { check } from '../src/engine/conditions.js';
import { applyEffects } from '../src/engine/effects.js';
import { resolveSchema } from '../src/engine/state.js';
import { play, sweep, median } from './fixtures/strategies.js';
/**
@@ -58,7 +60,7 @@ test('the settlement and the room land on the days the pack says', () => {
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 === 'Wages from Vane') settlement.push(state.turn);
if (change.label === 'Room at the Cordwood') room.push(state.turn);
}
state = result.state;
@@ -250,6 +252,34 @@ test('a player who wants a skill does not wait long for the first door', () => {
}
});
test('the horse and the room are each offered only while they can still be bought', () => {
// A real session was shown "Buy the bay" locked after buying it, and offered
// the room under a description about the horse. See DECISIONS #40.
const offered = (lifestyle) => pack.events
.filter((e) => ['the_sale_barn', 'the_ellis_room'].includes(e.id))
.filter((e) => check({ stage: 'deputy', lifestyle }, e.requires).passed)
.map((e) => e.id);
assert.deepEqual(offered(0), ['the_sale_barn']);
assert.deepEqual(offered(1), ['the_ellis_room']);
assert.deepEqual(offered(2), []);
});
test('paying the book down never takes more than is owed', () => {
// Turn 27 of that session: four dollars owed, twenty taken. See DECISIONS #38.
const schema = resolveSchema(pack);
const payDown = pack.events.find((e) => e.id === 'the_store')
.options.find((o) => o.id === 'pay_down');
const base = startGame(pack, { seed: 'the-book' });
const small = applyEffects({ ...base, money: 110, owed: 4 }, payDown.effects, schema).state;
assert.equal(small.money, 106, 'four owed costs four');
assert.equal(small.owed, 0);
const large = applyEffects({ ...base, money: 110, owed: 50 }, payDown.effects, schema).state;
assert.equal(large.money, 90, 'more than twenty owed costs twenty');
assert.equal(large.owed, 30);
});
test('a deliberate player can put Dutch in their debt', () => {
// The deepest relationship gate in the pack: `send_for_dutch` in the
// marshal's office needs a favour banked two rungs earlier.
+1
View File
@@ -141,6 +141,7 @@ test('the turn record carries the diff and the options the player could not take
const locked = record.offered.find((o) => o.id === 'charm');
assert.equal(locked.available, false);
assert.equal(locked.failed[0].path, 'skills.talking');
assert.equal(locked.hidden, false, 'the record says whether a locked option was on screen');
});
test('history accumulates every turn and is what the retrospective reads', () => {
+49
View File
@@ -527,3 +527,52 @@ test('the header reads the second pack\'s display map, not the first\'s', async
dom.restore();
}
});
// ---- a drawn cast, and checks (DECISIONS #41) ----------------------------------
test('a pack with a drawn cast shows whoever this run drew, and never a placeholder', async () => {
const dom = await boot({ pack: 'countinghouse' });
try {
const people = byClass(dom.root, 'person');
// One partner, one chief clerk, and two to four clerks.
assert.ok(people.length >= 4 && people.length <= 6, `${people.length} people in the panel`);
const titles = byClass(dom.root, 'person__title').map((t) => t.textContent);
assert.equal(titles.filter((t) => t === 'Senior Partner').length, 1);
assert.equal(titles.filter((t) => t === 'Chief Clerk').length, 1);
const clerks = titles.filter((t) => t === 'Clerk').length;
assert.ok(clerks >= 2 && clerks <= 4, `${clerks} clerks`);
for (let turn = 0; turn < 25; turn++) {
assert.doesNotMatch(text(dom.root), /\{[a-z_0-9.]+\}/, 'a {placeholder} reached the screen');
enabledOptions(dom.root)[0].click();
assert.doesNotMatch(text(dom.root), /\{[a-z_0-9.]+\}/, 'a {placeholder} reached the outcome');
firstByClass(dom.root, 'button--next').click();
}
} finally {
dom.restore();
}
});
test('a checked option shows its odds in words, and the outcome says whether it came off', async () => {
const dom = await boot({ pack: 'countinghouse' });
try {
let checked;
for (let turn = 0; turn < 40 && !checked; turn++) {
checked = optionButtons(dom.root).find((b) => !b.disabled && firstByClass(b, 'option__odds'));
if (checked) break;
enabledOptions(dom.root)[0].click();
firstByClass(dom.root, 'button--next').click();
}
assert.ok(checked, 'a checked option turned up within forty days');
const odds = firstByClass(checked, 'option__odds').textContent;
assert.match(odds, /^(a long shot|unlikely|an even chance|likely|a safe bet)$/);
assert.doesNotMatch(text(dom.root), /\d+%/, 'no percentage is ever shown');
checked.click();
const said = text(firstByClass(dom.root, 'outcome__check'));
assert.match(said, /and it (came|did not come) off\./);
assert.ok(said.toLowerCase().startsWith(odds), `the outcome repeats the odds: "${said}"`);
assert.ok(firstByClass(dom.root, 'outcome__text'), 'and says what happened');
} finally {
dom.restore();
}
});
+17
View File
@@ -25,6 +25,23 @@ test('an effect writing an undeclared path is caught', () => {
assert.match(messages(result), /skills\.wleding.*not declared/s);
});
test('an amount read from state must read a declared path, with numeric scale and cap', () => {
const unknown = broken((pack) => {
pack.events[0].options[0].effects.push({ path: 'money', op: 'add', value: { from: 'skills.wleding', times: -1 } });
});
assert.match(messages(unknown), /reads its amount from "skills\.wleding".*not declared/s);
const badCap = broken((pack) => {
pack.events[0].options[0].effects.push({ path: 'money', op: 'add', value: { from: 'money', cap: 'lots' } });
});
assert.match(messages(badCap), /non-numeric cap/);
const fine = broken((pack) => {
pack.events[0].options[0].effects.push({ path: 'money', op: 'add', value: { from: 'money', times: -1, cap: 5 } });
});
assert.deepEqual(fine.errors, []);
});
test('a relationship effect naming a character who does not exist is caught', () => {
const result = broken((pack) => {
pack.events[0].options[0].effects.push({ path: 'relationships.dave.likes_you', op: 'add', value: 1 });
+13 -3
View File
@@ -17,11 +17,18 @@ 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. */
/**
* The most recently modified feedback log in logs/, when none was named.
*
* Feedback logs only. A save export is a .json too, and a playtester who
* exports both — as the first one to do so did — would otherwise have the save
* read as a log whenever it was the later of the two.
*/
async function newestLog() {
let names;
try {
names = (await readdir(logsDir)).filter((name) => name.endsWith('.json'));
names = (await readdir(logsDir))
.filter((name) => name.startsWith('theladder-feedback-') && name.endsWith('.json'));
} catch {
return null;
}
@@ -130,7 +137,10 @@ function reportGates(entries) {
const blocked = new Map();
for (const entry of entries) {
for (const option of entry.offered ?? []) {
if (option.available) continue;
// A hidden option was never on screen, so it was never a door anybody
// met. Logs from before 0.3.4 do not record `hidden`, and still count
// such options here. See DECISIONS #39.
if (option.available || option.hidden) continue;
const reasons = (option.failed ?? [])
.map((c) => `${c.path} ${c.op} ${JSON.stringify(c.value)}`)
.join(', ') || 'unstated';