Compare commits
2
Commits
story-to-pack
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3108319a2b | ||
|
|
112e558dbb |
@@ -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
|
||||
|
||||
@@ -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 } },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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 } },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Vendored
+32
-5
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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, []);
|
||||
|
||||
Vendored
+114
@@ -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;
|
||||
Vendored
+35
-7
@@ -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
@@ -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.
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user