Add The Frontier, a second setting, and a picker to choose between them
Cold Fork is a freight yard: 29 events across two stages, a four-day cycle, and its own relationship and skill vocabulary. src/engine/ was not touched. Adding it needed three changes outside the engine: one save slot per pack, app.js holding the current pack, and turn.js no longer naming the mailroom (DECISIONS #34). build.js now handles imports wrapped across lines. A conformance suite runs over every registered pack. DECISIONS #35 records three calls on open questions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RxdJFbLq1rBKsUeticGV1g
This commit is contained in:
co-authored by
Claude Opus 5
parent
4957c24e31
commit
7f20be83e8
@@ -9,13 +9,14 @@ retrospective.
|
||||
|
||||
The engine knows nothing about offices, mailrooms or bosses. A **setting is a
|
||||
content pack** — characters, events, choices, stat names and tuning, all data.
|
||||
`corporateladder` is the default pack; a Wild West or lemonade-stand pack would
|
||||
be new data and no new code.
|
||||
Two ship: **Corporate Ladder**, a modern office and a mailroom, and **The
|
||||
Frontier**, a freight yard at the end of a stage line. They share every line of
|
||||
engine code and no content at all.
|
||||
|
||||
## Status
|
||||
|
||||
Engine and the first content pack are built and tested. No UI yet — the game is
|
||||
currently playable only from a test harness.
|
||||
Engine, interface and two content packs, all built and tested, and played in a
|
||||
real browser.
|
||||
|
||||
| Piece | State |
|
||||
| --- | --- |
|
||||
@@ -33,7 +34,10 @@ currently playable only from a test harness.
|
||||
| Repetition analytics (`npm run analyze`) | done |
|
||||
| Version and build shown on screen | done |
|
||||
| Keyboard play (Enter / Tab) | done |
|
||||
| Played in a real browser | done — first playtest 2026-09-09 |
|
||||
| Second content pack — The Frontier, 29 events, two tiers | done |
|
||||
| Setting picker, one save slot per pack | done |
|
||||
| Pack conformance suite (every pack, every check) | done |
|
||||
| Played in a real browser | Corporate Ladder yes, first playtest 2026-09-09; **The Frontier not yet** |
|
||||
|
||||
The first playtest confirmed the interface, saves and the exported log all work
|
||||
in a real browser. Its findings — an unexplained daily drain, no sense of the
|
||||
@@ -70,9 +74,16 @@ 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 # 121 tests: engine, content, UI wiring, and the build
|
||||
npm test # 149 tests: engine, both packs, UI wiring, and the build
|
||||
```
|
||||
|
||||
Tests are in three layers. `test/conformance.test.js` runs over **every pack in
|
||||
the registry** and asserts only what is true of a pack because it is a pack —
|
||||
validity, no dead content, the declared economic baseline, variety, replay from
|
||||
seed, a way out of every event. `test/content.test.js` and
|
||||
`test/frontier.test.js` hold each pack to its own tuning. The engine suite runs
|
||||
against a fixture pack, so tuning a setting can never break it.
|
||||
|
||||
To see what a playtest actually did — turns against distinct situations, how
|
||||
often each recurred, which locked gates players kept meeting, and anything they
|
||||
typed into the feedback box:
|
||||
@@ -90,26 +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. Two tiers, 31 events, a
|
||||
retrospective, saves, and a feedback log that has already found one real bug.
|
||||
The MVP is complete and has survived three playtests, and a second setting now
|
||||
exists to prove the engine/content split holds. Adding it required **no engine
|
||||
change** — and three interface changes, two of which were latent defects a
|
||||
second pack made visible (a single global save slot, and the one place the UI
|
||||
still named a setting). See DECISIONS.md #34.
|
||||
|
||||
Open questions, in the order they are likely to matter:
|
||||
|
||||
- **Does money stop mattering late?** A careful player ends around $8,300 over
|
||||
200 turns. Tuning has been corrected twice from playtest logs and should be
|
||||
judged from a session, not from simulated play — that is how the last trap got
|
||||
in.
|
||||
- **`skills.negotiation >= 15` gates four different early options.** The logs
|
||||
show players bouncing off it repeatedly in the first thirty turns. It is
|
||||
probably set too high for how slowly the skill grows.
|
||||
- **Nobody uses the feedback widget.** Zero ratings across three sessions. The
|
||||
locked-gate data in the log has been more useful than anything typed; the
|
||||
widget may need to be more present, or may not need to exist.
|
||||
- **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.
|
||||
|
||||
- **Money stops mattering late, and it should not.** A careful player ends
|
||||
around $8,300 over 200 turns. The direction is set: standing should cost
|
||||
money to keep — a better car, a better place to live, better clothes — so
|
||||
expenses climb as the player rises. Not built yet.
|
||||
- **Dispatch is eleven events against the mailroom's nineteen.** It passes the
|
||||
variety guard, but it has had far less play than the mailroom.
|
||||
- **The third tier, or the second content pack.** Promotion working entirely as
|
||||
data is decent evidence the engine/content split holds; a second *setting*
|
||||
would be the proof.
|
||||
variety guard, but it has had far less play than the mailroom. Waits on more
|
||||
playtesting.
|
||||
- **A third tier in either pack.** Two rungs is what both settings have. The
|
||||
split is now proven across settings; it is not proven across a long ladder.
|
||||
Waits until the Frontier has been played.
|
||||
|
||||
Settled, and not open: Corporate Ladder's negotiation gate stays as it is, and
|
||||
the feedback widget stays as it is (DECISIONS #35).
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -124,13 +141,16 @@ src/engine/ the game engine — imports nothing from content/, or from src/u
|
||||
rng.js seeded, serializable RNG
|
||||
validate.js load-time content checking
|
||||
src/ui/ screens, formatting and the app controller
|
||||
app.js the only module that names a content pack
|
||||
screens/ the turn screen and the retrospective
|
||||
app.js the only module that knows there is more than one pack
|
||||
screens/ the turn screen, the retrospective, the setting picker
|
||||
format.js state paths and conditions rendered as English
|
||||
src/io/ local-storage save, file export/import, the feedback log
|
||||
content/ content packs (settings)
|
||||
src/io/ local-storage save (one slot per pack), export/import, feedback log
|
||||
content/
|
||||
index.js the pack registry — the only list of settings
|
||||
corporateladder/ a modern office and a mailroom
|
||||
frontier/ a freight yard at the end of a stage line
|
||||
tools/build.js flattens everything into one self-contained HTML file
|
||||
test/ engine tests against a fixture pack; content and UI tests
|
||||
test/ engine tests against a fixture pack; conformance, per-pack, UI
|
||||
docs/ DECISIONS.md — why things are the way they are
|
||||
Planning/ the original design documents
|
||||
```
|
||||
|
||||
@@ -10,9 +10,17 @@ import { events } from './events/index.js';
|
||||
export const corporateLadder = {
|
||||
id: 'corporateladder',
|
||||
name: 'Corporate Ladder',
|
||||
// One sentence for the setting picker. The interface reads it and knows
|
||||
// nothing else about what this pack is.
|
||||
blurb:
|
||||
'A modern office and a mailroom, two people who have been here longer ' +
|
||||
'than you, and a laminated org chart with your name not on it.',
|
||||
version: '0.1.0',
|
||||
turnUnit: 'day',
|
||||
startingStage: 'mailroom',
|
||||
// What the interface calls the panel of people. The UI used to hardcode
|
||||
// "The mailroom" here, which was the one place it named a setting.
|
||||
peopleHeading: 'The mailroom',
|
||||
|
||||
stages: {
|
||||
mailroom: { label: 'Mailroom', title: 'Mail Associate I' },
|
||||
@@ -59,10 +67,11 @@ export const corporateLadder = {
|
||||
// rent out every Monday, and only small change in between.
|
||||
//
|
||||
// Keep this arithmetic honest when tuning: wages, minus two rents, minus
|
||||
// fourteen days of small change. It should come to roughly forty dollars
|
||||
// down over a fortnight — a drift, not a bleed. Rent was once raised without
|
||||
// re-deriving it, which quietly made the baseline four times steeper and
|
||||
// trapped a real playtester below zero for 30% of a 182-turn run.
|
||||
// fourteen days of small change. Per week that is 560 - 455 - 126 = -21, so
|
||||
// about forty dollars down over a fortnight — a drift, not a bleed. Rent was
|
||||
// once raised without re-deriving it, which quietly made the baseline four
|
||||
// times steeper and trapped a real playtester below zero for 30% of a
|
||||
// 182-turn run. The range is asserted from `tuning` below.
|
||||
upkeep: [
|
||||
{ label: 'Coffee, transit, lunch', effects: [{ path: 'money', op: 'add', value: -18 }] },
|
||||
// Turn 1 is a Monday, so offset 4 is the first Friday and every 14 makes it
|
||||
@@ -113,6 +122,18 @@ export const corporateLadder = {
|
||||
},
|
||||
],
|
||||
|
||||
// What the conformance suite holds this pack to. The numbers a setting must
|
||||
// hit are the setting's business, so they are declared here rather than
|
||||
// written into a test that names this pack. See DECISIONS.md #31.
|
||||
tuning: {
|
||||
// Net movement of `money` from upkeep alone, per calendar cycle — here a
|
||||
// week — at each stage, with no debt outstanding.
|
||||
baselinePerCycle: {
|
||||
mailroom: [-45, 5],
|
||||
dispatch: [5, 125],
|
||||
},
|
||||
},
|
||||
|
||||
// How the interface names and formats this pack's state. The UI reads only
|
||||
// this map, so renaming a stat or reordering the header is content's job.
|
||||
display: {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// Cold Fork, as staffed.
|
||||
//
|
||||
// `description` is pure colour — the engine never reads it. What it does read
|
||||
// is `startingRelationship`, where a character departs from the pack defaults.
|
||||
// Nobody in a freight yard trusts a new hand on sight, and Dutch has had the
|
||||
// good runs to himself for two years and can count.
|
||||
|
||||
export const characters = [
|
||||
{
|
||||
id: 'hollis',
|
||||
name: 'Hollis Vane',
|
||||
title: 'Vane & Co. Freight',
|
||||
role_type: 'boss',
|
||||
description:
|
||||
'Owns the outfit, the yard, and the note on both. Settles up in the ' +
|
||||
'doorway of the office rather than inside it, which saves having to ' +
|
||||
'offer anyone a chair.',
|
||||
startingRelationship: { trusts_you: 35 },
|
||||
},
|
||||
{
|
||||
id: 'dutch',
|
||||
name: 'Dutch Rennels',
|
||||
title: 'Teamster',
|
||||
role_type: 'rival',
|
||||
description:
|
||||
'Two years on the Ridge road and has driven it in every kind of weather ' +
|
||||
'there is, as he will tell you. Keeps his own tally of who took which ' +
|
||||
'run, in a book, in pencil.',
|
||||
startingRelationship: { trusts_you: 30, wary_of_you: 25 },
|
||||
},
|
||||
{
|
||||
id: 'ward',
|
||||
name: 'Marshal Ward',
|
||||
title: 'Town Marshal',
|
||||
role_type: 'authority',
|
||||
description:
|
||||
'Came to Cold Fork to retire and has been six years about it. Knows who ' +
|
||||
'owes the store and how much, and considers that most of the job.',
|
||||
startingRelationship: { trusts_you: 25, wary_of_you: 30 },
|
||||
},
|
||||
];
|
||||
|
||||
export default characters;
|
||||
@@ -0,0 +1,584 @@
|
||||
// The second rung: the marshal's office.
|
||||
//
|
||||
// `rounds`, `office_day` and `the_cells` are the floor here — no conditions,
|
||||
// no cooldown, no `once` — so this stage can never run dry. The pay is real
|
||||
// and the days are worse, which is the trade the offer did not mention.
|
||||
|
||||
export const deputy = [
|
||||
{
|
||||
id: 'rounds',
|
||||
stage: 'deputy',
|
||||
weight: 100,
|
||||
description:
|
||||
'The evening round: the length of the street, the back of the store, ' +
|
||||
'the yard gate, and the Cordwood twice. Mostly it is a walk. The ' +
|
||||
'difference between the nights it is a walk and the nights it is not ' +
|
||||
'announces itself late.',
|
||||
options: [
|
||||
{
|
||||
id: 'walk_it',
|
||||
label: 'Walk it properly, both ends.',
|
||||
result:
|
||||
'Two doors unlocked that should not have been, and the storekeeper ' +
|
||||
'hears about both from you rather than from a stranger.',
|
||||
effects: [
|
||||
{ path: 'standing', op: 'add', value: 2 },
|
||||
{ path: 'skills.nerve', op: 'add', value: 1 },
|
||||
{ path: 'relationships.ward.trusts_you', op: 'add', value: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sit_the_cordwood',
|
||||
label: 'Do it from a chair at the Cordwood.',
|
||||
result:
|
||||
'You hear more in two hours than the street gives up in a week, and ' +
|
||||
'everybody in the room adjusts what they say in front of you.',
|
||||
effects: [
|
||||
{ path: 'skills.trade', op: 'add', value: 2 },
|
||||
{ path: 'standing', op: 'add', value: -2 },
|
||||
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'walk_the_yard',
|
||||
label: 'Take the long way past Vane’s yard.',
|
||||
result:
|
||||
'The office light is on at eleven and Vane does not come to the ' +
|
||||
'window. He knows the sound of the round by now.',
|
||||
effects: [
|
||||
{ path: 'skills.nerve', op: 'add', value: 2 },
|
||||
{ path: 'relationships.hollis.wary_of_you', op: 'add', value: 4 },
|
||||
{ path: 'relationships.ward.trusts_you', op: 'add', value: 3 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'office_day',
|
||||
stage: 'deputy',
|
||||
weight: 100,
|
||||
description:
|
||||
'Paper. The county wants its returns, Ward has not filed since March, ' +
|
||||
'and the stove is the only warm thing in the building.',
|
||||
options: [
|
||||
{
|
||||
id: 'do_the_returns',
|
||||
label: 'Do the returns.',
|
||||
result:
|
||||
'Four hours and a hand that will not straighten. The county sends a ' +
|
||||
'letter of acknowledgement, which Ward pins up, which is the most ' +
|
||||
'sentimental act you have seen from him.',
|
||||
effects: [
|
||||
{ path: 'skills.trade', op: 'add', value: 3 },
|
||||
{ path: 'relationships.ward.trusts_you', op: 'add', value: 5 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'read_the_paper',
|
||||
label: 'Read six months of the county paper instead.',
|
||||
result:
|
||||
'Three names in it you have loaded freight for, and one of them ' +
|
||||
'twice. You start a list of your own.',
|
||||
effects: [
|
||||
{ path: 'skills.trade', op: 'add', value: 2 },
|
||||
{ path: 'skills.nerve', op: 'add', value: 1 },
|
||||
{ path: 'standing', op: 'add', value: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ride_out',
|
||||
label: 'Leave the paper and ride out to the Ridge camp.',
|
||||
result:
|
||||
'Nothing is wrong at the Ridge camp. Being seen there when nothing ' +
|
||||
'is wrong turns out to be most of the job.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: -2 },
|
||||
{ path: 'standing', op: 'add', value: 4 },
|
||||
{ path: 'skills.nerve', op: 'add', value: 2 },
|
||||
{ path: 'relationships.ward.trusts_you', op: 'add', value: -2 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'the_cells',
|
||||
stage: 'deputy',
|
||||
weight: 100,
|
||||
description:
|
||||
'One cell, one occupant, and eleven hours until the county wagon. He ' +
|
||||
'has been talking since six and has not yet said anything about why he ' +
|
||||
'is in there.',
|
||||
options: [
|
||||
{
|
||||
id: 'let_him_talk',
|
||||
label: 'Let him talk.',
|
||||
result:
|
||||
'By the ninth hour he has told you about a horse sold twice in ' +
|
||||
'Bitter Wells, and does not appear to notice that he has.',
|
||||
effects: [
|
||||
{ path: 'skills.trade', op: 'add', value: 3 },
|
||||
{ path: 'relationships.ward.trusts_you', op: 'add', value: 4 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'feed_him_proper',
|
||||
label: 'Buy him a proper dinner out of your own pocket.',
|
||||
result:
|
||||
'Two dollars forty. He is quiet afterwards, and the county wagon ' +
|
||||
'takes a man who is not angry, which the driver notices.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: -3 },
|
||||
{ path: 'standing', op: 'add', value: 3 },
|
||||
{ path: 'skills.nerve', op: 'add', value: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'shut_him_up',
|
||||
label: 'Tell him to be quiet.',
|
||||
result:
|
||||
'He is quiet. Eleven hours is a long time in a building with two ' +
|
||||
'rooms in it and one of you able to leave.',
|
||||
effects: [
|
||||
{ path: 'skills.nerve', op: 'add', value: 2 },
|
||||
{ path: 'standing', op: 'add', value: -2 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'saloon_trouble',
|
||||
stage: 'deputy',
|
||||
weight: 70,
|
||||
cooldown: 4,
|
||||
description:
|
||||
'Something has gone wrong at the Cordwood and the noise of it reaches ' +
|
||||
'the office before anybody does. Ward is at the county seat until ' +
|
||||
'Thursday.',
|
||||
options: [
|
||||
{
|
||||
id: 'wade_in',
|
||||
label: 'Go in and take hold of the nearest one.',
|
||||
result:
|
||||
'It stops. Your sleeve does not survive it and neither does a ' +
|
||||
'table, and the whole street knows by morning which it was.',
|
||||
effects: [
|
||||
{ path: 'skills.nerve', op: 'add', value: 4 },
|
||||
{ path: 'standing', op: 'add', value: 4 },
|
||||
{ path: 'money', op: 'add', value: -4 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'wait_outside',
|
||||
label: 'Stand in the doorway and wait for it to come to you.',
|
||||
result:
|
||||
'It comes to you, in ones, which is the entire trick and takes a ' +
|
||||
'nerve you did not have a year ago.',
|
||||
effects: [
|
||||
{ path: 'skills.nerve', op: 'add', value: 2 },
|
||||
{ path: 'skills.trade', op: 'add', value: 2 },
|
||||
{ path: 'standing', op: 'add', value: 6 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'send_for_dutch',
|
||||
label: 'Send for Dutch.',
|
||||
requires: [{ path: 'relationships.dutch.owes_you', op: '>=', value: 25 }],
|
||||
result:
|
||||
'He arrives inside four minutes, takes one man out by the collar, ' +
|
||||
'and says afterwards that the book is square now. It is not, and ' +
|
||||
'both of you let that stand.',
|
||||
effects: [
|
||||
{ path: 'skills.trade', op: 'add', value: 2 },
|
||||
{ path: 'standing', op: 'add', value: 3 },
|
||||
{ path: 'relationships.dutch.owes_you', op: 'add', value: -20 },
|
||||
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 5 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'vane_across_the_street',
|
||||
stage: 'deputy',
|
||||
weight: 55,
|
||||
cooldown: 6,
|
||||
description:
|
||||
'Vane has a load going out that the manifest describes as agricultural ' +
|
||||
'implements, and it is going out at four in the morning, which no ' +
|
||||
'agricultural implement in the county has ever needed to do.',
|
||||
options: [
|
||||
{
|
||||
id: 'stop_it',
|
||||
label: 'Be at the yard gate at four.',
|
||||
result:
|
||||
'He does not argue and he does not open the crate. He turns the ' +
|
||||
'wagon round in front of you and takes it back inside, and that is ' +
|
||||
'the last civil word between you.',
|
||||
effects: [
|
||||
{ path: 'standing', op: 'add', value: 7 },
|
||||
{ path: 'skills.nerve', op: 'add', value: 3 },
|
||||
{ path: 'relationships.ward.trusts_you', op: 'add', value: 8 },
|
||||
{ path: 'relationships.hollis.trusts_you', op: 'add', value: -18 },
|
||||
{ path: 'relationships.hollis.wary_of_you', op: 'add', value: 15 },
|
||||
{ path: 'notable', op: 'push', value: 'Stood at Vane’s gate at four in the morning.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'let_it_go',
|
||||
label: 'Be somewhere else at four.',
|
||||
result:
|
||||
'The wagon goes. Nothing comes of it, which is the part that stays ' +
|
||||
'with you, because nothing coming of it means nothing was ever ' +
|
||||
'going to.',
|
||||
effects: [
|
||||
{ path: 'standing', op: 'add', value: -5 },
|
||||
{ path: 'skills.trade', op: 'add', value: 1 },
|
||||
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 10 },
|
||||
{ path: 'relationships.ward.trusts_you', op: 'add', value: -8 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'go_and_ask',
|
||||
label: 'Go to the office at nine and ask him straight out.',
|
||||
requires: [{ path: 'skills.trade', op: '>=', value: 32 }],
|
||||
result:
|
||||
'He tells you, in the doorway, the same as he told you about the ' +
|
||||
'crate. Then he asks whether the star has changed what you intend ' +
|
||||
'to do about it. It has, and you say so.',
|
||||
effects: [
|
||||
{ path: 'skills.trade', op: 'add', value: 4 },
|
||||
{ path: 'skills.nerve', op: 'add', value: 3 },
|
||||
{ path: 'standing', op: 'add', value: 5 },
|
||||
{ path: 'relationships.hollis.trusts_you', op: 'add', value: -4 },
|
||||
{ path: 'relationships.ward.trusts_you', op: 'add', value: 6 },
|
||||
{ path: 'notable', op: 'push', value: 'Asked Vane straight out, wearing the star.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'ward_tired',
|
||||
stage: 'deputy',
|
||||
weight: 45,
|
||||
cooldown: 8,
|
||||
requires: [{ path: 'flags.sworn_in', op: '==', value: true }],
|
||||
description:
|
||||
'Ward has been six years retiring and today he says the word out loud ' +
|
||||
'for the first time, sitting on the office step with his boots off.',
|
||||
options: [
|
||||
{
|
||||
id: 'talk_him_round',
|
||||
label: 'Tell him the town is not ready.',
|
||||
result:
|
||||
'He says the town is never ready and that is not a reason. He stays ' +
|
||||
'anyway, for now, which both of you understand is a favour to you ' +
|
||||
'and not to the town.',
|
||||
effects: [
|
||||
{ path: 'skills.trade', op: 'add', value: 2 },
|
||||
{ path: 'relationships.ward.trusts_you', op: 'add', value: 6 },
|
||||
{ path: 'relationships.ward.owes_you', op: 'add', value: 5 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'tell_him_go',
|
||||
label: 'Tell him to go.',
|
||||
result:
|
||||
'He looks at you for a while. Then he laughs, once, and puts his ' +
|
||||
'boots back on, and does not mention it again for a month.',
|
||||
effects: [
|
||||
{ path: 'skills.nerve', op: 'add', value: 4 },
|
||||
{ path: 'standing', op: 'add', value: 3 },
|
||||
{ path: 'relationships.ward.trusts_you', op: 'add', value: 10 },
|
||||
{ path: 'notable', op: 'push', value: 'Told Ward to go and retire.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'say_nothing_ward',
|
||||
label: 'Let him sit.',
|
||||
result: 'You sit on the step with him until it gets dark.',
|
||||
effects: [
|
||||
{ path: 'relationships.ward.trusts_you', op: 'add', value: 4 },
|
||||
{ path: 'relationships.ward.wary_of_you', op: 'add', value: -5 },
|
||||
{ path: 'skills.nerve', op: 'add', value: 1 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'county_wagon',
|
||||
stage: 'deputy',
|
||||
weight: 50,
|
||||
cooldown: 5,
|
||||
description:
|
||||
'The county wagon is a day late, there are two men in one cell who ' +
|
||||
'should not be in one cell, and the second cell has never had a lock ' +
|
||||
'that works.',
|
||||
options: [
|
||||
{
|
||||
id: 'sit_up_with_them',
|
||||
label: 'Sit up all night in the office.',
|
||||
result:
|
||||
'Nothing happens, twice, at two and at four. In the morning the ' +
|
||||
'wagon comes and you have been awake for thirty hours.',
|
||||
effects: [
|
||||
{ path: 'skills.nerve', op: 'add', value: 3 },
|
||||
{ path: 'relationships.ward.trusts_you', op: 'add', value: 5 },
|
||||
{ path: 'standing', op: 'add', value: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'fix_the_lock',
|
||||
label: 'Spend the afternoon making the second lock work.',
|
||||
result:
|
||||
'It works. It has not worked since before Ward came, and he stands ' +
|
||||
'in the doorway turning the key for longer than the job took.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: -5 },
|
||||
{ path: 'skills.trade', op: 'add', value: 3 },
|
||||
{ path: 'relationships.ward.trusts_you', op: 'add', value: 7 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'release_one',
|
||||
label: 'Turn the quieter one loose on his word.',
|
||||
requires: [{ path: 'skills.nerve', op: '>=', value: 32 }],
|
||||
result:
|
||||
'He comes back at eight in the morning, as he said he would, and ' +
|
||||
'the county never knows there was a decision in it.',
|
||||
effects: [
|
||||
{ path: 'skills.nerve', op: 'add', value: 3 },
|
||||
{ path: 'standing', op: 'add', value: 6 },
|
||||
{ path: 'relationships.ward.wary_of_you', op: 'add', value: 8 },
|
||||
{ path: 'notable', op: 'push', value: 'Turned a man loose on his word, and was right.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'dutch_in_the_cells',
|
||||
stage: 'deputy',
|
||||
weight: 40,
|
||||
cooldown: 10,
|
||||
requires: [{ path: 'turn', op: '>=', value: 12 }],
|
||||
description:
|
||||
'Dutch is in the cell. It is the Cordwood and a table and nobody hurt, ' +
|
||||
'and it is also the third time this season, and the storekeeper has ' +
|
||||
'started using the word "again."',
|
||||
options: [
|
||||
{
|
||||
id: 'let_him_out',
|
||||
label: 'Let him out in the morning and say nothing to the county.',
|
||||
result:
|
||||
'He goes to work. Neither of you refers to it, then or later, and ' +
|
||||
'the storekeeper notices that neither of you did.',
|
||||
effects: [
|
||||
{ path: 'standing', op: 'add', value: -4 },
|
||||
{ path: 'relationships.dutch.owes_you', op: 'add', value: 20 },
|
||||
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 8 },
|
||||
{ path: 'relationships.ward.wary_of_you', op: 'add', value: 6 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'book_him',
|
||||
label: 'Book him properly.',
|
||||
result:
|
||||
'Eight dollars and a night, and his name on a county return with ' +
|
||||
'your hand on it. He pays the eight dollars. He reads the return ' +
|
||||
'twice.',
|
||||
effects: [
|
||||
{ path: 'standing', op: 'add', value: 5 },
|
||||
{ path: 'relationships.ward.trusts_you', op: 'add', value: 6 },
|
||||
{ path: 'relationships.dutch.trusts_you', op: 'add', value: -18 },
|
||||
{ path: 'relationships.dutch.wary_of_you', op: 'add', value: 14 },
|
||||
{ path: 'notable', op: 'push', value: 'Booked Dutch on a county return.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'talk_to_him',
|
||||
label: 'Sit outside the cell until he says what it is about.',
|
||||
requires: [{ path: 'relationships.dutch.trusts_you', op: '>=', value: 50 }],
|
||||
result:
|
||||
'It is about two years on the Ridge road and a book with nobody ' +
|
||||
'else’s name in it. He gets most of the way to saying so.',
|
||||
effects: [
|
||||
{ path: 'skills.trade', op: 'add', value: 3 },
|
||||
{ path: 'skills.nerve', op: 'add', value: 2 },
|
||||
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 12 },
|
||||
{ path: 'relationships.dutch.wary_of_you', op: 'add', value: -10 },
|
||||
{ path: 'notable', op: 'push', value: 'Sat outside the cell with Dutch until he talked.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'the_ridge_camp_dispute',
|
||||
stage: 'deputy',
|
||||
weight: 45,
|
||||
cooldown: 7,
|
||||
description:
|
||||
'Two men at the Ridge camp both hold paper on the same claim and one of ' +
|
||||
'them has a brother in Bitter Wells. The camp has asked for the marshal ' +
|
||||
'and the marshal has sent you.',
|
||||
options: [
|
||||
{
|
||||
id: 'read_the_paper_claim',
|
||||
label: 'Read both papers all the way through.',
|
||||
result:
|
||||
'Both are good. Both were issued by the same clerk eleven days ' +
|
||||
'apart. The problem is in Bitter Wells and not on the Ridge, and ' +
|
||||
'saying so ends it.',
|
||||
effects: [
|
||||
{ path: 'skills.trade', op: 'add', value: 4 },
|
||||
{ path: 'standing', op: 'add', value: 5 },
|
||||
{ path: 'relationships.ward.trusts_you', op: 'add', value: 5 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'stand_between',
|
||||
label: 'Stand between them until the county can rule.',
|
||||
result:
|
||||
'Six weeks of standing between them, in instalments. Nobody is shot ' +
|
||||
'and nobody is satisfied, which Ward says is the job described.',
|
||||
effects: [
|
||||
{ path: 'skills.nerve', op: 'add', value: 4 },
|
||||
{ path: 'standing', op: 'add', value: 2 },
|
||||
{ path: 'money', op: 'add', value: 4 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'take_a_side',
|
||||
label: 'Decide it yourself, on the spot.',
|
||||
result:
|
||||
'You are probably right. The brother from Bitter Wells arrives on ' +
|
||||
'Thursday to find out how right, and stays four days.',
|
||||
effects: [
|
||||
{ path: 'skills.nerve', op: 'add', value: 3 },
|
||||
{ path: 'standing', op: 'add', value: -3 },
|
||||
{ path: 'relationships.ward.wary_of_you', op: 'add', value: 10 },
|
||||
{ path: 'money', op: 'add', value: 8 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'the_star_and_the_yard',
|
||||
stage: 'deputy',
|
||||
weight: 30,
|
||||
cooldown: 14,
|
||||
requires: [
|
||||
{ path: 'turn', op: '>=', value: 20 },
|
||||
{ path: 'relationships.hollis.trusts_you', op: '<=', value: 30 },
|
||||
],
|
||||
description:
|
||||
'Vane & Co. has a note falling due and the bank in Bitter Wells has ' +
|
||||
'declined it. Vane is fifty-one and has not asked anybody in Cold Fork ' +
|
||||
'for anything in nineteen years, and is standing in the office doorway ' +
|
||||
'across the street looking at your building.',
|
||||
options: [
|
||||
{
|
||||
id: 'go_over',
|
||||
label: 'Cross the street.',
|
||||
result:
|
||||
'He does not ask. You do not offer. The two of you stand in the ' +
|
||||
'doorway of the office for a while, which is where every ' +
|
||||
'conversation you have ever had with him has happened.',
|
||||
effects: [
|
||||
{ path: 'skills.nerve', op: 'add', value: 3 },
|
||||
{ path: 'standing', op: 'add', value: 3 },
|
||||
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 12 },
|
||||
{ path: 'notable', op: 'push', value: 'Crossed the street to Vane when the note fell due.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'lend_him',
|
||||
label: 'Put your own money into the note.',
|
||||
requires: [{ path: 'money', op: '>=', value: 60 }],
|
||||
result:
|
||||
'Sixty dollars, and the outfit keeps its animals. He pays it back ' +
|
||||
'over a year, in the doorway, in instalments, never once in front ' +
|
||||
'of Dutch.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: -60 },
|
||||
{ path: 'relationships.hollis.owes_you', op: 'add', value: 40 },
|
||||
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 25 },
|
||||
{ path: 'standing', op: 'add', value: 4 },
|
||||
{ path: 'notable', op: 'push', value: 'Put sixty dollars of your own into Vane’s note.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'stay_put',
|
||||
label: 'Stay on your own side of the street.',
|
||||
result:
|
||||
'The note goes. The yard is sold in the spring and the new outfit ' +
|
||||
'brings its own men, and the Ridge road is driven by somebody you ' +
|
||||
'do not know.',
|
||||
effects: [
|
||||
{ path: 'skills.nerve', op: 'add', value: 1 },
|
||||
{ path: 'standing', op: 'add', value: -2 },
|
||||
{ path: 'relationships.hollis.trusts_you', op: 'add', value: -10 },
|
||||
{ path: 'relationships.dutch.trusts_you', op: 'add', value: -8 },
|
||||
{ path: 'notable', op: 'push', value: 'Stayed on your own side of the street.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'quiet_week',
|
||||
stage: 'deputy',
|
||||
weight: 60,
|
||||
cooldown: 4,
|
||||
description:
|
||||
'Nothing. Four days of nothing. The stove, the returns, the length of ' +
|
||||
'the street twice a night, and a town that does not need you at all ' +
|
||||
'this week and might not next week either.',
|
||||
options: [
|
||||
{
|
||||
id: 'learn_the_law',
|
||||
label: 'Read the county statutes properly.',
|
||||
result:
|
||||
'Most of it is fees. Some of it is not, and the part that is not is ' +
|
||||
'the part nobody in Cold Fork has ever read.',
|
||||
effects: [
|
||||
{ path: 'skills.trade', op: 'add', value: 3 },
|
||||
{ path: 'relationships.ward.trusts_you', op: 'add', value: 3 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'work_the_yard_again',
|
||||
label: 'Go and load freight for a day, for the money.',
|
||||
result:
|
||||
'Vane pays you the hand’s rate without comment. Dutch says one ' +
|
||||
'thing about the star and then works alongside you all afternoon.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: 9 },
|
||||
{ path: 'standing', op: 'add', value: -3 },
|
||||
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 5 },
|
||||
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 5 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ride_the_county',
|
||||
label: 'Ride the county line and look at it.',
|
||||
result:
|
||||
'Two days out. Nobody sends for you and nothing needs you, and you ' +
|
||||
'come back having decided something you do not tell anybody.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: -3 },
|
||||
{ path: 'skills.nerve', op: 'add', value: 3 },
|
||||
{ path: 'standing', op: 'add', value: 1 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default deputy;
|
||||
@@ -0,0 +1,139 @@
|
||||
// Running out of money.
|
||||
//
|
||||
// No game over and no failure mode in the engine — these are ordinary events
|
||||
// whose conditions happen to be about money, weighted heavily enough to crowd
|
||||
// out an ordinary day. Neither declares a stage: being broke follows you up
|
||||
// the ladder rather than vanishing when you take the star.
|
||||
//
|
||||
// The arithmetic that matters, and the one Corporate Ladder got wrong once:
|
||||
// `every_run` pays 30 against a room-and-board cycle of 20 and a fully-loaded
|
||||
// store charge of 10, so there is always a way out that is worth more than the
|
||||
// hole is deep. Change either number and re-derive the other.
|
||||
|
||||
export const hardTimes = [
|
||||
{
|
||||
id: 'store_cut_off',
|
||||
// Heavy, but on a cooldown: being broke should colour a run, not become
|
||||
// the run. Corporate Ladder learned this the expensive way at weight 5000
|
||||
// with no cooldown.
|
||||
weight: 900,
|
||||
cooldown: 3,
|
||||
requires: [{ path: 'money', op: '<', value: 0 }],
|
||||
description:
|
||||
'The storekeeper turns the book round on the counter so you can see ' +
|
||||
'your own line in it, and does not say anything at all, which is worse ' +
|
||||
'than the several things he could have said.',
|
||||
options: [
|
||||
{
|
||||
id: 'on_the_book',
|
||||
label: 'Ask him to carry you a while longer.',
|
||||
// He stops eventually. Past that, the ways out cost standing and
|
||||
// favours rather than interest, which is where the spiral gets real.
|
||||
requires: [{ path: 'owed', op: '<', value: 120 }],
|
||||
result:
|
||||
'He turns the book back round and writes it. The figure at the ' +
|
||||
'bottom of your line is now larger than a month of runs.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: 30 },
|
||||
{ path: 'owed', op: 'add', value: 32 },
|
||||
{ path: 'flags.owes_the_store', op: 'set', value: true },
|
||||
{ path: 'standing', op: 'add', value: -2 },
|
||||
{ path: 'notable', op: 'push', value: 'Went further into the book at the store.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ask_dutch',
|
||||
label: 'Ask Dutch to settle a line of his own.',
|
||||
requires: [{ path: 'relationships.dutch.owes_you', op: '>=', value: 20 }],
|
||||
result:
|
||||
'He pays it without a word and crosses it out of the book in front ' +
|
||||
'of you, and afterwards you are a man he has settled with rather ' +
|
||||
'than a man he owes.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: 18 },
|
||||
{ path: 'relationships.dutch.owes_you', op: 'add', value: -20 },
|
||||
{ path: 'relationships.dutch.trusts_you', op: 'add', value: -3 },
|
||||
{ path: 'notable', op: 'push', value: 'Called in what Dutch owed.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ask_hollis',
|
||||
label: 'Ask Vane for the smith’s money back.',
|
||||
requires: [{ path: 'relationships.hollis.owes_you', op: '>=', value: 20 }],
|
||||
result:
|
||||
'He pays in the doorway, in full, and finds a reason not to look at ' +
|
||||
'you for the rest of the week.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: 22 },
|
||||
{ path: 'relationships.hollis.owes_you', op: 'add', value: -20 },
|
||||
{ path: 'relationships.hollis.trusts_you', op: 'add', value: -4 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'every_run',
|
||||
label: 'Take every run going.',
|
||||
// Has to be worth more than a cycle of room and board plus the fully
|
||||
// stepped-up store charge, or it is not a way out — it is the hole
|
||||
// with extra hours in it.
|
||||
result:
|
||||
'Eleven days without one off. The Ridge road stops being a road ' +
|
||||
'after a while and becomes a sequence of turns you take.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: 30 },
|
||||
{ path: 'skills.nerve', op: 'add', value: 2 },
|
||||
{ path: 'standing', op: 'add', value: -1 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'the_book_called',
|
||||
// Often enough to matter. At weight 25 Corporate Ladder's equivalent
|
||||
// appeared six times in 262 turns and debt became a one-way ratchet.
|
||||
weight: 45,
|
||||
cooldown: 4,
|
||||
requires: [{ path: 'owed', op: '>', value: 0 }],
|
||||
description:
|
||||
'The storekeeper has written to the county about three lines in his ' +
|
||||
'book and yours is one of them. He mentions this to you in the street, ' +
|
||||
'in daylight, in front of two other people.',
|
||||
options: [
|
||||
{
|
||||
id: 'pay_chunk',
|
||||
label: 'Pay a real piece of it down.',
|
||||
requires: [{ path: 'money', op: '>=', value: 45 }],
|
||||
result: 'He rules a line through most of it and is disappointed about it.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: -45 },
|
||||
{ path: 'owed', op: 'add', value: -45 },
|
||||
{ path: 'standing', op: 'add', value: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'pay_minimum',
|
||||
label: 'Pay what you have on you.',
|
||||
requires: [{ path: 'money', op: '>=', value: 12 }],
|
||||
result: 'Twelve dollars, ten of which is the line and two of which is coffee.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: -12 },
|
||||
{ path: 'owed', op: 'add', value: -10 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'stall',
|
||||
label: 'Tell him after the next stage.',
|
||||
result:
|
||||
'He says after the next stage. The two other people in the street ' +
|
||||
'say nothing, and repeat all of it by evening.',
|
||||
effects: [
|
||||
{ path: 'owed', op: 'add', value: 4 },
|
||||
{ path: 'standing', op: 'add', value: -3 },
|
||||
{ path: 'relationships.ward.wary_of_you', op: 'add', value: 4 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default hardTimes;
|
||||
@@ -0,0 +1,8 @@
|
||||
import { routine } from './routine.js';
|
||||
import { people } from './people.js';
|
||||
import { promotion } from './promotion.js';
|
||||
import { deputy } from './deputy.js';
|
||||
import { hardTimes } from './hard-times.js';
|
||||
|
||||
export const events = [...routine, ...people, ...promotion, ...deputy, ...hardTimes];
|
||||
export default events;
|
||||
@@ -0,0 +1,415 @@
|
||||
// The three people in Cold Fork who have an opinion about you.
|
||||
//
|
||||
// Relationship-gated content, and the counterweight to the yard: the runs pay
|
||||
// the room, but Dutch's book, Vane's ledger and Ward's slow appraisal are what
|
||||
// decide whether the star is ever offered.
|
||||
|
||||
export const people = [
|
||||
{
|
||||
id: 'dutch_book',
|
||||
stage: 'hand',
|
||||
weight: 70,
|
||||
cooldown: 5,
|
||||
description:
|
||||
'Dutch has his tally book open on the tailgate and is working through ' +
|
||||
'who took which run this month. He has arrived at a number he does not ' +
|
||||
'like and is now looking for somewhere to put it.',
|
||||
options: [
|
||||
{
|
||||
id: 'let_him_talk',
|
||||
label: 'Let him get all the way to the end of it.',
|
||||
result:
|
||||
'It takes twenty minutes. By the end he has talked himself into ' +
|
||||
'thinking it was about the weather, which suits both of you.',
|
||||
effects: [
|
||||
{ path: 'skills.trade', op: 'add', value: 2 },
|
||||
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 6 },
|
||||
{ path: 'relationships.dutch.wary_of_you', op: 'add', value: -4 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'check_the_book',
|
||||
label: 'Ask to see the book.',
|
||||
result:
|
||||
'The tally is right and his arithmetic is wrong by two runs, in his ' +
|
||||
'own favour. You say so quietly. He does not thank you and does not ' +
|
||||
'forget it either.',
|
||||
effects: [
|
||||
{ path: 'skills.trade', op: 'add', value: 3 },
|
||||
{ path: 'relationships.dutch.wary_of_you', op: 'add', value: 7 },
|
||||
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 3 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'trade_a_run',
|
||||
label: 'Offer him the next Ridge run to square it.',
|
||||
requires: [{ path: 'relationships.dutch.trusts_you', op: '>=', value: 45 }],
|
||||
result:
|
||||
'He takes it, and the book gets a line in it on your side for the ' +
|
||||
'first time. Dutch settles his lines.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: -6 },
|
||||
{ path: 'skills.trade', op: 'add', value: 2 },
|
||||
{ path: 'relationships.dutch.owes_you', op: 'add', value: 18 },
|
||||
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 5 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'dutch_drunk',
|
||||
stage: 'hand',
|
||||
weight: 40,
|
||||
cooldown: 9,
|
||||
requires: [{ path: 'turn', op: '>=', value: 12 }],
|
||||
description:
|
||||
'Dutch did not come in for the morning run and the morning run is ' +
|
||||
'loaded. He is at the Cordwood, upright, and in no condition to be ' +
|
||||
'within thirty feet of a horse.',
|
||||
options: [
|
||||
{
|
||||
id: 'cover',
|
||||
label: 'Take his run and say nothing about it.',
|
||||
result:
|
||||
'Vane never learns. Dutch learns, in the afternoon, from the state ' +
|
||||
'of the harness, and comes and stands near you for a while without ' +
|
||||
'saying anything.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: 7 },
|
||||
{ path: 'skills.nerve', op: 'add', value: 2 },
|
||||
{ path: 'flags.covered_for_dutch', op: 'set', value: true },
|
||||
{ path: 'relationships.dutch.owes_you', op: 'add', value: 25 },
|
||||
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 8 },
|
||||
{ path: 'notable', op: 'push', value: 'Covered Dutch’s run and never mentioned it.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'tell_vane',
|
||||
label: 'Tell Vane the run needs a driver.',
|
||||
result:
|
||||
'Vane takes it out himself at fifty-one years of age and says one ' +
|
||||
'sentence to Dutch on his return. Dutch works the rest of the month ' +
|
||||
'like a man being watched, because he is.',
|
||||
effects: [
|
||||
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 7 },
|
||||
{ path: 'relationships.dutch.trusts_you', op: 'add', value: -15 },
|
||||
{ path: 'relationships.dutch.wary_of_you', op: 'add', value: 12 },
|
||||
{ path: 'standing', op: 'add', value: -2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sober_him',
|
||||
label: 'Walk him round the yard until he can stand up to it.',
|
||||
requires: [{ path: 'skills.nerve', op: '>=', value: 12 }],
|
||||
result:
|
||||
'Four circuits and a bucket. He drives it badly but he drives it, ' +
|
||||
'and that distinction is the entire point.',
|
||||
effects: [
|
||||
{ path: 'skills.nerve', op: 'add', value: 2 },
|
||||
{ path: 'relationships.dutch.owes_you', op: 'add', value: 14 },
|
||||
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 10 },
|
||||
{ path: 'relationships.dutch.wary_of_you', op: 'add', value: -6 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'vane_ledger',
|
||||
stage: 'hand',
|
||||
weight: 50,
|
||||
cooldown: 7,
|
||||
description:
|
||||
'The office door is open for once. Vane has the ledger out and the look ' +
|
||||
'of a man who has added a column twice and got two answers.',
|
||||
options: [
|
||||
{
|
||||
id: 'offer_help',
|
||||
label: 'Offer to run the column.',
|
||||
result:
|
||||
'You find it in ten minutes: a freight bill entered twice in March. ' +
|
||||
'He does not say thank you. He leaves the door open after that.',
|
||||
effects: [
|
||||
{ path: 'skills.trade', op: 'add', value: 4 },
|
||||
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 9 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ask_for_more',
|
||||
label: 'Ask him what the good runs pay.',
|
||||
requires: [{ path: 'skills.trade', op: '>=', value: 22 }],
|
||||
result:
|
||||
'Twenty-two, and he has been giving them to Dutch for two years ' +
|
||||
'because Dutch asked in his first month. Nobody had told you that ' +
|
||||
'asking was the mechanism.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: 22 },
|
||||
{ path: 'skills.trade', op: 'add', value: 3 },
|
||||
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 3 },
|
||||
{ path: 'relationships.dutch.wary_of_you', op: 'add', value: 6 },
|
||||
{ path: 'notable', op: 'push', value: 'Asked Vane for the good runs.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'call_in_the_debt',
|
||||
label: 'Mention the smith’s bill you stood for.',
|
||||
requires: [{ path: 'relationships.hollis.owes_you', op: '>=', value: 20 }],
|
||||
result:
|
||||
'He pays it back to the cent, in front of Dutch, and is short with ' +
|
||||
'you for two days afterwards for having had to.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: 24 },
|
||||
{ path: 'relationships.hollis.owes_you', op: 'add', value: -20 },
|
||||
{ path: 'relationships.hollis.trusts_you', op: 'add', value: -4 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'leave_him_to_it',
|
||||
label: 'Leave him to it.',
|
||||
result: 'The door is shut again within the hour.',
|
||||
effects: [
|
||||
{ path: 'skills.nerve', op: 'add', value: 1 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'ward_asks',
|
||||
stage: 'hand',
|
||||
weight: 45,
|
||||
cooldown: 6,
|
||||
requires: [{ path: 'turn', op: '>=', value: 6 }],
|
||||
description:
|
||||
'Marshal Ward is leaning on the yard gate with no particular business ' +
|
||||
'there. He asks, eventually and as though it has just occurred to him, ' +
|
||||
'what came in on the stage.',
|
||||
options: [
|
||||
{
|
||||
id: 'tell_him',
|
||||
label: 'Tell him what came in on the stage.',
|
||||
result:
|
||||
'Hinges, nails, and a crate consigned to a name nobody in Cold Fork ' +
|
||||
'has heard of. He writes none of it down and remembers all of it.',
|
||||
effects: [
|
||||
{ path: 'relationships.ward.trusts_you', op: 'add', value: 8 },
|
||||
{ path: 'relationships.ward.wary_of_you', op: 'add', value: -4 },
|
||||
{ path: 'standing', op: 'add', value: -1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'say_nothing',
|
||||
label: 'Say that is Vane’s business and not yours.',
|
||||
result:
|
||||
'He agrees that it is, pleasantly, and stays leaning on the gate for ' +
|
||||
'another twenty minutes.',
|
||||
effects: [
|
||||
{ path: 'skills.nerve', op: 'add', value: 2 },
|
||||
{ path: 'relationships.ward.wary_of_you', op: 'add', value: 6 },
|
||||
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 5 },
|
||||
{ path: 'standing', op: 'add', value: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ask_him_back',
|
||||
label: 'Ask him what he is actually looking for.',
|
||||
requires: [{ path: 'skills.trade', op: '>=', value: 12 }],
|
||||
result:
|
||||
'He tells you, which surprises both of you. A crate a month, six ' +
|
||||
'months running, and no consignee that exists.',
|
||||
effects: [
|
||||
{ path: 'skills.trade', op: 'add', value: 3 },
|
||||
{ path: 'skills.nerve', op: 'add', value: 2 },
|
||||
{ path: 'relationships.ward.trusts_you', op: 'add', value: 10 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'ward_deputizes',
|
||||
stage: 'hand',
|
||||
weight: 35,
|
||||
cooldown: 10,
|
||||
requires: [
|
||||
{ path: 'turn', op: '>=', value: 18 },
|
||||
{ path: 'relationships.ward.trusts_you', op: '>=', value: 45 },
|
||||
],
|
||||
description:
|
||||
'There is a man in the one cell who needs moving to the county seat and ' +
|
||||
'Ward cannot leave the town without somebody in it. He is not offering ' +
|
||||
'you a job. He is offering you two days.',
|
||||
options: [
|
||||
{
|
||||
id: 'ride_along',
|
||||
label: 'Ride along.',
|
||||
result:
|
||||
'Two days out and two back, and the prisoner talks for all four. ' +
|
||||
'Ward does not, which turns out to be the instruction.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: 12 },
|
||||
{ path: 'skills.nerve', op: 'add', value: 4 },
|
||||
{ path: 'standing', op: 'add', value: 5 },
|
||||
{ path: 'relationships.ward.trusts_you', op: 'add', value: 12 },
|
||||
{ path: 'relationships.hollis.trusts_you', op: 'add', value: -4 },
|
||||
{ path: 'notable', op: 'push', value: 'Rode to the county seat with Ward and a prisoner.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'mind_the_town',
|
||||
label: 'Offer to mind the town instead.',
|
||||
requires: [{ path: 'skills.nerve', op: '>=', value: 22 }],
|
||||
result:
|
||||
'Two days with the keys and nothing whatever happens, which you ' +
|
||||
'later understand was the test.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: 12 },
|
||||
{ path: 'skills.nerve', op: 'add', value: 3 },
|
||||
{ path: 'standing', op: 'add', value: 8 },
|
||||
{ path: 'relationships.ward.trusts_you', op: 'add', value: 15 },
|
||||
{ path: 'notable', op: 'push', value: 'Held the town for two days while Ward was gone.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'decline_ward',
|
||||
label: 'Say the yard cannot spare you.',
|
||||
result:
|
||||
'It can. Both of you know it can. He takes the storekeeper instead ' +
|
||||
'and the storekeeper tells the story for a year.',
|
||||
effects: [
|
||||
{ path: 'skills.trade', op: 'add', value: 2 },
|
||||
{ path: 'relationships.ward.trusts_you', op: 'add', value: -8 },
|
||||
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 6 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'saturday_night',
|
||||
stage: 'hand',
|
||||
weight: 55,
|
||||
cooldown: 5,
|
||||
description:
|
||||
'The Cordwood on a Rest night: Dutch at the long table, half the yard ' +
|
||||
'with him, and a game of cards that everybody present knows the ' +
|
||||
'storekeeper’s nephew cannot afford to be in.',
|
||||
options: [
|
||||
{
|
||||
id: 'sit_in',
|
||||
label: 'Sit in.',
|
||||
result:
|
||||
'You come out four dollars ahead and the nephew comes out eleven ' +
|
||||
'behind. Nobody says anything about it, then or afterwards.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: 4 },
|
||||
{ path: 'skills.trade', op: 'add', value: 2 },
|
||||
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 5 },
|
||||
{ path: 'standing', op: 'add', value: -2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'buy_the_table',
|
||||
label: 'Buy the table a round and stay out of the game.',
|
||||
result:
|
||||
'Six dollars, and for the rest of the month you are a man who bought ' +
|
||||
'the table a round, which in Cold Fork is a kind of address.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: -6 },
|
||||
{ path: 'standing', op: 'add', value: 5 },
|
||||
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 4 },
|
||||
{ path: 'relationships.dutch.wary_of_you', op: 'add', value: -3 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'walk_the_nephew_out',
|
||||
label: 'Get the nephew out of the game.',
|
||||
requires: [{ path: 'skills.nerve', op: '>=', value: 12 }],
|
||||
result:
|
||||
'You do it badly and in front of everyone, and it works. The ' +
|
||||
'storekeeper hears about it before you are back at the yard.',
|
||||
effects: [
|
||||
{ path: 'skills.nerve', op: 'add', value: 3 },
|
||||
{ path: 'standing', op: 'add', value: 7 },
|
||||
{ path: 'relationships.dutch.wary_of_you', op: 'add', value: 5 },
|
||||
{ path: 'relationships.ward.trusts_you', op: 'add', value: 5 },
|
||||
{ path: 'notable', op: 'push', value: 'Pulled the storekeeper’s nephew out of a card game.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'early_night',
|
||||
label: 'Go up early.',
|
||||
result: 'The ceiling of a room at the Cordwood, at nine at night.',
|
||||
effects: [
|
||||
{ path: 'skills.trade', op: 'add', value: 1 },
|
||||
{ path: 'relationships.dutch.trusts_you', op: 'add', value: -2 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'the_consignee',
|
||||
stage: 'hand',
|
||||
weight: 25,
|
||||
cooldown: 12,
|
||||
requires: [
|
||||
{ path: 'turn', op: '>=', value: 24 },
|
||||
{ path: 'relationships.ward.trusts_you', op: '>=', value: 50 },
|
||||
],
|
||||
description:
|
||||
'The crate with no consignee is on the platform again, and this month ' +
|
||||
'the manifest has Vane’s own hand on it. Ward is not at the gate today, ' +
|
||||
'which is itself a decision somebody made.',
|
||||
options: [
|
||||
{
|
||||
id: 'load_it',
|
||||
label: 'Load it and drive it out to the Ridge like any other crate.',
|
||||
result:
|
||||
'It is heavier than hinges. Vane pays you eighteen dollars for a ' +
|
||||
'nine dollar run and neither of you mentions the difference.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: 18 },
|
||||
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 10 },
|
||||
{ path: 'relationships.ward.trusts_you', op: 'add', value: -12 },
|
||||
{ path: 'standing', op: 'add', value: -3 },
|
||||
{ path: 'notable', op: 'push', value: 'Ran the unmarked crate out to the Ridge for Vane.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'tell_ward',
|
||||
label: 'Go and find Ward.',
|
||||
result:
|
||||
'He looks at the manifest for a long time and then puts it back on ' +
|
||||
'the platform exactly as he found it. Nothing happens for eleven ' +
|
||||
'days, and then a great deal happens at once.',
|
||||
effects: [
|
||||
{ path: 'skills.nerve', op: 'add', value: 4 },
|
||||
{ path: 'standing', op: 'add', value: 6 },
|
||||
{ path: 'relationships.ward.trusts_you', op: 'add', value: 20 },
|
||||
{ path: 'relationships.hollis.trusts_you', op: 'add', value: -14 },
|
||||
{ path: 'notable', op: 'push', value: 'Took Vane’s manifest to Marshal Ward.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ask_vane_direct',
|
||||
label: 'Ask Vane what is in it.',
|
||||
requires: [{ path: 'skills.trade', op: '>=', value: 22 }],
|
||||
result:
|
||||
'He tells you, standing in the doorway of the office as always, and ' +
|
||||
'then asks what you intend to do about it. It is the first real ' +
|
||||
'question anybody in Cold Fork has asked you.',
|
||||
effects: [
|
||||
{ path: 'skills.trade', op: 'add', value: 4 },
|
||||
{ path: 'skills.nerve', op: 'add', value: 3 },
|
||||
{ path: 'standing', op: 'add', value: 2 },
|
||||
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 6 },
|
||||
{ path: 'notable', op: 'push', value: 'Asked Vane what was in the crate, and got an answer.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default people;
|
||||
@@ -0,0 +1,93 @@
|
||||
// Taking the star.
|
||||
//
|
||||
// The same shape as Corporate Ladder's Dispatch offer, and for the same
|
||||
// reason: the engine has no notion of a promotion, only of a stage that
|
||||
// determines which events are in the pool. The offer is an ordinary event
|
||||
// whose effects happen to include { path: 'stage', op: 'set', value: 'deputy' }.
|
||||
//
|
||||
// Declining is a real option. It comes back after a cooldown, and staying in
|
||||
// the yard to build trade and a book full of favours is a way to play rather
|
||||
// than a mistake.
|
||||
|
||||
export const promotion = [
|
||||
{
|
||||
id: 'deputy_offer',
|
||||
stage: 'hand',
|
||||
weight: 800,
|
||||
cooldown: 12,
|
||||
// A long way in, deliberately. The yard is where most of this pack lives,
|
||||
// and the star at turn twenty would skip it.
|
||||
requires: [
|
||||
{ path: 'standing', op: '>=', value: 70 },
|
||||
{ path: 'turn', op: '>=', value: 40 },
|
||||
// Two ways to be offered it: the marshal has decided about you, or the
|
||||
// town has, on the evidence of what you have walked into.
|
||||
{
|
||||
any: [
|
||||
{ path: 'relationships.ward.trusts_you', op: '>=', value: 68 },
|
||||
{ path: 'skills.nerve', op: '>=', value: 40 },
|
||||
],
|
||||
},
|
||||
],
|
||||
description:
|
||||
'Ward has the county paper that makes a deputy and has already written a ' +
|
||||
'name on it. He says the pay is thirty-eight a run and the work is mostly ' +
|
||||
'walking about, and that the second half of that is a lie he tells ' +
|
||||
'everybody once.',
|
||||
options: [
|
||||
{
|
||||
id: 'accept',
|
||||
label: 'Take the star.',
|
||||
result:
|
||||
'Vane says nothing at all, which is worse than anything he could ' +
|
||||
'have said. Dutch carries your kit up the street and talks the ' +
|
||||
'entire way about the Ridge road.',
|
||||
effects: [
|
||||
{ path: 'stage', op: 'set', value: 'deputy' },
|
||||
{ path: 'flags.sworn_in', op: 'set', value: true },
|
||||
{ path: 'standing', op: 'add', value: 6 },
|
||||
{ path: 'relationships.ward.trusts_you', op: 'add', value: 5 },
|
||||
{ path: 'relationships.hollis.trusts_you', op: 'add', value: -14 },
|
||||
{ path: 'relationships.dutch.wary_of_you', op: 'add', value: 10 },
|
||||
{ path: 'notable', op: 'push', value: 'Took the star.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'accept_and_speak',
|
||||
label: 'Take it, and put Dutch up for the good runs.',
|
||||
requires: [{ path: 'relationships.dutch.trusts_you', op: '>=', value: 55 }],
|
||||
result:
|
||||
'Vane writes the name down in front of you, which is as close as he ' +
|
||||
'comes to a farewell. Dutch is insufferable about it for a month and ' +
|
||||
'then, quietly, is not.',
|
||||
effects: [
|
||||
{ path: 'stage', op: 'set', value: 'deputy' },
|
||||
{ path: 'flags.sworn_in', op: 'set', value: true },
|
||||
{ path: 'flags.spoke_for_dutch', op: 'set', value: true },
|
||||
{ path: 'standing', op: 'add', value: 4 },
|
||||
{ path: 'relationships.ward.trusts_you', op: 'add', value: 5 },
|
||||
{ path: 'relationships.hollis.trusts_you', op: 'add', value: -8 },
|
||||
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 15 },
|
||||
{ path: 'relationships.dutch.owes_you', op: 'add', value: 20 },
|
||||
{ path: 'notable', op: 'push', value: 'Took the star, and put Dutch up for the good runs.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'decline',
|
||||
label: 'Not yet.',
|
||||
result:
|
||||
'He folds the paper back into his coat. He has carried it around ' +
|
||||
'unsigned for six years and is in no hurry now.',
|
||||
effects: [
|
||||
{ path: 'skills.nerve', op: 'add', value: 3 },
|
||||
{ path: 'standing', op: 'add', value: -3 },
|
||||
{ path: 'relationships.ward.trusts_you', op: 'add', value: -6 },
|
||||
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 8 },
|
||||
{ path: 'notable', op: 'push', value: 'Turned down the star.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default promotion;
|
||||
@@ -0,0 +1,422 @@
|
||||
// Ordinary days in the yard.
|
||||
//
|
||||
// `stage_day`, `ridge_road` and `yard_work` carry no conditions, no cooldown
|
||||
// and no `once`, which makes them this stage's floor: whatever else is
|
||||
// excluded, there is always a turn to play.
|
||||
//
|
||||
// Note what the ungated options give. Corporate Ladder put four of its best
|
||||
// negotiation gains behind a negotiation gate, and its logs show players
|
||||
// bouncing off that wall for thirty turns. Every gate in this pack has ungated
|
||||
// repeatable growth underneath it — see DECISIONS.md #32.
|
||||
|
||||
export const routine = [
|
||||
{
|
||||
id: 'first_morning',
|
||||
stage: 'hand',
|
||||
weight: 99999,
|
||||
once: true,
|
||||
description:
|
||||
'Hollis Vane looks at your hands rather than your face, says the pay is ' +
|
||||
'twenty-four dollars a run, the room at the Cordwood is eight and the ' +
|
||||
'feed is what it is, and lets you do that arithmetic yourself. Dutch ' +
|
||||
'Rennels is already harnessing.',
|
||||
options: [
|
||||
{
|
||||
id: 'ask_the_rate',
|
||||
label: 'Ask what a man makes on top of the twenty-four.',
|
||||
result:
|
||||
'He says that depends entirely on you, and goes inside. It is the ' +
|
||||
'most useful thing anybody tells you all week.',
|
||||
effects: [
|
||||
{ path: 'skills.trade', op: 'add', value: 3 },
|
||||
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 2 },
|
||||
{ path: 'notable', op: 'push', value: 'Asked Vane what the work was really worth.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'get_to_work',
|
||||
label: 'Say nothing and pick up a harness.',
|
||||
result:
|
||||
'Dutch watches you do it wrong, then watches you do it right, and ' +
|
||||
'does not offer a word either time.',
|
||||
effects: [
|
||||
{ path: 'skills.nerve', op: 'add', value: 2 },
|
||||
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 4 },
|
||||
{ path: 'relationships.dutch.wary_of_you', op: 'add', value: 3 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ask_dutch',
|
||||
label: 'Ask Dutch which of these animals bites.',
|
||||
result:
|
||||
'He tells you. He also tells you he has driven the Ridge road two ' +
|
||||
'years, which is not an answer to the question you asked.',
|
||||
effects: [
|
||||
{ path: 'skills.trade', op: 'add', value: 1 },
|
||||
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 5 },
|
||||
{ path: 'relationships.dutch.wary_of_you', op: 'add', value: -5 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'stage_day',
|
||||
stage: 'hand',
|
||||
weight: 100,
|
||||
description:
|
||||
'The stage is in and there are nine crates on the platform for a wagon ' +
|
||||
'that holds seven. The driver wants to be gone inside the hour and is ' +
|
||||
'saying so at length.',
|
||||
options: [
|
||||
{
|
||||
id: 'two_trips',
|
||||
label: 'Run it twice. Nobody has to know it took all day.',
|
||||
result: 'It takes all day. Everybody knows.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: 4 },
|
||||
{ path: 'skills.trade', op: 'add', value: 2 },
|
||||
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'load_high',
|
||||
label: 'Stack it high and rope it down.',
|
||||
result:
|
||||
'Seven crates and two more where a man would sit. It holds as far as ' +
|
||||
'the creek crossing, which is farther than you expected.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: 8 },
|
||||
{ path: 'skills.nerve', op: 'add', value: 2 },
|
||||
{ path: 'standing', op: 'add', value: -2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'talk_the_driver',
|
||||
label: 'Talk the driver into leaving two for the next stage.',
|
||||
requires: [{ path: 'skills.trade', op: '>=', value: 12 }],
|
||||
result:
|
||||
'He leaves two. He also mentions your name at the far end of the ' +
|
||||
'line, favourably, which you will not hear about for a month.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: 6 },
|
||||
{ path: 'skills.trade', op: 'add', value: 3 },
|
||||
{ path: 'standing', op: 'add', value: 3 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'ridge_road',
|
||||
stage: 'hand',
|
||||
weight: 100,
|
||||
description:
|
||||
'A load for the Ridge camp, out and back in a day if the weather holds. ' +
|
||||
'The last mile is the one with the bad turn on it, and everybody knows ' +
|
||||
'which mile that is.',
|
||||
options: [
|
||||
{
|
||||
id: 'drive_it',
|
||||
label: 'Take it out yourself.',
|
||||
result:
|
||||
'The bad turn is exactly as advertised. You come back with the wagon ' +
|
||||
'and the animals and most of your composure.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: 9 },
|
||||
{ path: 'skills.nerve', op: 'add', value: 3 },
|
||||
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'go_with_dutch',
|
||||
label: 'Ask Dutch to take the reins and ride along.',
|
||||
result:
|
||||
'He drives it in silence and points at the bad turn without slowing. ' +
|
||||
'You learn the road. You do not learn much else.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: 4 },
|
||||
{ path: 'skills.trade', op: 'add', value: 2 },
|
||||
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 4 },
|
||||
{ path: 'relationships.dutch.owes_you', op: 'add', value: -2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'take_the_gun',
|
||||
label: 'Ask Hollis for the scattergun and go alone.',
|
||||
requires: [{ path: 'skills.nerve', op: '>=', value: 12 }],
|
||||
result:
|
||||
'He hands it over without a word, which is the first time he has ' +
|
||||
'agreed to anything you asked for.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: 14 },
|
||||
{ path: 'skills.nerve', op: 'add', value: 3 },
|
||||
{ path: 'flags.carries_iron', op: 'set', value: true },
|
||||
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 5 },
|
||||
{ path: 'standing', op: 'add', value: 2 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'yard_work',
|
||||
stage: 'hand',
|
||||
weight: 100,
|
||||
description:
|
||||
'No stage, no run, and a yard full of things that are nearly broken. ' +
|
||||
'Hollis is in the office with the ledger and the door shut.',
|
||||
options: [
|
||||
{
|
||||
id: 'mend_harness',
|
||||
label: 'Mend the harness that keeps parting.',
|
||||
result:
|
||||
'You find where it parts. It is not where anybody has been mending ' +
|
||||
'it, which explains the last two years.',
|
||||
effects: [
|
||||
{ path: 'skills.trade', op: 'add', value: 2 },
|
||||
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 3 },
|
||||
{ path: 'relationships.dutch.wary_of_you', op: 'add', value: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'shoe_the_team',
|
||||
label: 'Walk the team down to the smith and stand for the bill.',
|
||||
result:
|
||||
'Eleven dollars of your own money on Vane’s animals. The smith ' +
|
||||
'writes your name in his book, and so, eventually, does Vane.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: -11 },
|
||||
{ path: 'skills.trade', op: 'add', value: 2 },
|
||||
{ path: 'relationships.hollis.owes_you', op: 'add', value: 12 },
|
||||
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 4 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'idle',
|
||||
label: 'Sit in the shade until something needs doing.',
|
||||
result:
|
||||
'Nothing needs doing. It is the first hour of the week that has been ' +
|
||||
'yours, and you notice it going.',
|
||||
effects: [
|
||||
{ path: 'skills.nerve', op: 'add', value: 1 },
|
||||
{ path: 'relationships.hollis.trusts_you', op: 'add', value: -3 },
|
||||
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 2 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'broken_axle',
|
||||
stage: 'hand',
|
||||
weight: 55,
|
||||
cooldown: 5,
|
||||
description:
|
||||
'The near wagon comes back with a cracked axle and a driver who says it ' +
|
||||
'was cracked when he took it. Dutch says it was not.',
|
||||
options: [
|
||||
{
|
||||
id: 'fix_it',
|
||||
label: 'Get under it and put a splint on.',
|
||||
result:
|
||||
'It will hold for a season, which is longer than the last three ' +
|
||||
'repairs on the same wagon.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: 3 },
|
||||
{ path: 'skills.trade', op: 'add', value: 3 },
|
||||
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 3 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'back_dutch',
|
||||
label: 'Say Dutch is right, because Dutch is right.',
|
||||
result:
|
||||
'The driver takes it badly and the yard takes note. Dutch says ' +
|
||||
'nothing, and writes something in his book.',
|
||||
effects: [
|
||||
{ path: 'standing', op: 'add', value: -3 },
|
||||
{ path: 'skills.nerve', op: 'add', value: 2 },
|
||||
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 8 },
|
||||
{ path: 'relationships.dutch.owes_you', op: 'add', value: 6 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'stay_out',
|
||||
label: 'Have no view on the axle.',
|
||||
result: 'Both of them decide, separately, that you are no use to them.',
|
||||
effects: [
|
||||
{ path: 'relationships.dutch.trusts_you', op: 'add', value: -4 },
|
||||
{ path: 'relationships.hollis.trusts_you', op: 'add', value: -2 },
|
||||
{ path: 'skills.trade', op: 'add', value: 1 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'the_store',
|
||||
stage: 'hand',
|
||||
weight: 60,
|
||||
cooldown: 4,
|
||||
description:
|
||||
'Everything in Cold Fork that is not freight is in this one building, ' +
|
||||
'and the book on the counter is where the town keeps its real accounts.',
|
||||
options: [
|
||||
{
|
||||
id: 'buy_plain',
|
||||
label: 'Buy what you need and pay for it.',
|
||||
result: 'Coffee, a shirt, and a second blanket. The nights are turning.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: -6 },
|
||||
{ path: 'standing', op: 'add', value: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'on_credit',
|
||||
label: 'Put it on the book.',
|
||||
result:
|
||||
'He writes it without looking up. Being in the book is not a ' +
|
||||
'disgrace here; being in it a long time is.',
|
||||
effects: [
|
||||
{ path: 'owed', op: 'add', value: 14 },
|
||||
{ path: 'flags.owes_the_store', op: 'set', value: true },
|
||||
{ path: 'standing', op: 'add', value: -1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'haggle',
|
||||
label: 'Argue the price of the coffee on principle.',
|
||||
result:
|
||||
'You win eleven cents and an hour of your life goes into it. He ' +
|
||||
'enjoys it more than you do, and remembers you afterwards.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: -4 },
|
||||
{ path: 'skills.trade', op: 'add', value: 3 },
|
||||
{ path: 'standing', op: 'add', value: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'pay_down',
|
||||
label: 'Put money against the book.',
|
||||
requires: [
|
||||
{ path: 'owed', op: '>', value: 0 },
|
||||
{ path: 'money', op: '>=', value: 20 },
|
||||
],
|
||||
result: 'He crosses a line through it and turns the page back over.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: -20 },
|
||||
{ path: 'owed', op: 'add', value: -20 },
|
||||
{ path: 'standing', op: 'add', value: 2 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'weather',
|
||||
stage: 'hand',
|
||||
weight: 45,
|
||||
cooldown: 6,
|
||||
description:
|
||||
'It has been raining since the small hours and the creek crossing is ' +
|
||||
'four feet up. There is a load for the camp that was wanted yesterday.',
|
||||
options: [
|
||||
{
|
||||
id: 'wait_it_out',
|
||||
label: 'Nothing moves today.',
|
||||
result:
|
||||
'The creek is down by evening and the camp waits a day. It is the ' +
|
||||
'right call, which is not the same as a popular one.',
|
||||
effects: [
|
||||
{ path: 'skills.trade', op: 'add', value: 2 },
|
||||
{ path: 'relationships.hollis.trusts_you', op: 'add', value: -2 },
|
||||
{ path: 'standing', op: 'add', value: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ford_it',
|
||||
label: 'Ford it.',
|
||||
result:
|
||||
'The wagon floats for about six feet, which is six feet more than a ' +
|
||||
'wagon should. The load gets there wet and on time.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: 16 },
|
||||
{ path: 'skills.nerve', op: 'add', value: 4 },
|
||||
{ path: 'standing', op: 'add', value: 3 },
|
||||
{ path: 'relationships.dutch.wary_of_you', op: 'add', value: 6 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'long_way',
|
||||
label: 'Take the long road round the head of the creek.',
|
||||
requires: [{ path: 'skills.trade', op: '>=', value: 12 }],
|
||||
result:
|
||||
'Eleven extra miles and a dry load. Dutch did not know that road was ' +
|
||||
'passable, and now has to know that you did.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: 11 },
|
||||
{ path: 'skills.trade', op: 'add', value: 2 },
|
||||
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 4 },
|
||||
{ path: 'relationships.dutch.wary_of_you', op: 'add', value: 4 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'road_agents',
|
||||
stage: 'hand',
|
||||
weight: 30,
|
||||
cooldown: 8,
|
||||
requires: [{ path: 'turn', op: '>=', value: 8 }],
|
||||
description:
|
||||
'Two riders come out of the rocks a mile short of the camp and sit their ' +
|
||||
'horses across the road. Neither of them looks like he has done this ' +
|
||||
'often enough to be calm about it.',
|
||||
options: [
|
||||
{
|
||||
id: 'hand_it_over',
|
||||
label: 'Give them the box and remember the horses.',
|
||||
result:
|
||||
'They take it and go. You give Marshal Ward a description of two ' +
|
||||
'horses that is better than anything he has had in a year.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: -8 },
|
||||
{ path: 'skills.nerve', op: 'add', value: 2 },
|
||||
{ path: 'relationships.ward.trusts_you', op: 'add', value: 8 },
|
||||
{ path: 'notable', op: 'push', value: 'Was stopped on the Ridge road, and gave Ward the horses.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'talk_them_down',
|
||||
label: 'Tell them what is actually in the box.',
|
||||
result:
|
||||
'It is a crate of hinges and forty pounds of nails. The older one ' +
|
||||
'laughs. They let you by, which is worse for them than it is for you.',
|
||||
effects: [
|
||||
{ path: 'skills.trade', op: 'add', value: 4 },
|
||||
{ path: 'standing', op: 'add', value: 4 },
|
||||
{ path: 'notable', op: 'push', value: 'Talked two road agents out of a crate of hinges.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'bring_it_up',
|
||||
label: 'Bring the scattergun up off the floorboards.',
|
||||
requires: [{ path: 'flags.carries_iron', op: '==', value: true }],
|
||||
result:
|
||||
'Nobody fires. They go back into the rocks and the story is around ' +
|
||||
'Cold Fork before the wagon is.',
|
||||
effects: [
|
||||
{ path: 'skills.nerve', op: 'add', value: 5 },
|
||||
{ path: 'standing', op: 'add', value: 8 },
|
||||
{ path: 'relationships.ward.trusts_you', op: 'add', value: 6 },
|
||||
{ path: 'relationships.dutch.wary_of_you', op: 'add', value: 8 },
|
||||
{ path: 'notable', op: 'push', value: 'Brought the scattergun up on the Ridge road.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default routine;
|
||||
@@ -0,0 +1,192 @@
|
||||
// The Frontier — the second content pack for The Ladder.
|
||||
//
|
||||
// Cold Fork, a freight town at the end of a stage line. You are a hired hand
|
||||
// in Vane & Co.'s yard; the town has a marshal who is tired and a road that is
|
||||
// not safe. Two rungs, the same as Corporate Ladder, and not one line of
|
||||
// engine code between them.
|
||||
//
|
||||
// This pack exists to prove the split holds. Everything that makes it a
|
||||
// different game from Corporate Ladder is in this directory: a four-day cycle
|
||||
// instead of a week, money that arrives from work rather than from a wage,
|
||||
// relationship stats named for a place where being owed a favour matters more
|
||||
// than being liked, and a skill pair of nerve and trade.
|
||||
|
||||
import { characters } from './characters.js';
|
||||
import { events } from './events/index.js';
|
||||
|
||||
export const frontier = {
|
||||
id: 'frontier',
|
||||
name: 'The Frontier',
|
||||
blurb:
|
||||
'Cold Fork, at the end of a stage line: a freight yard, a road that is ' +
|
||||
'not safe, and a marshal who has been six years retiring.',
|
||||
version: '0.1.0',
|
||||
turnUnit: 'day',
|
||||
startingStage: 'hand',
|
||||
peopleHeading: 'Cold Fork',
|
||||
|
||||
stages: {
|
||||
hand: { label: 'The Yard', title: 'Hired Hand' },
|
||||
deputy: { label: 'The Office', title: 'Deputy Marshal' },
|
||||
},
|
||||
|
||||
// Not a week. The stage comes every fourth day, and the whole town runs on
|
||||
// that: the outfit settles up when the stage comes in, the boarding house
|
||||
// collects on the rest day before it. A pack that wants a seven-day week
|
||||
// says seven here; the engine has no opinion either way, and Corporate
|
||||
// Ladder's calendar is the same field with different numbers in it.
|
||||
calendar: {
|
||||
cycleLength: 4,
|
||||
cycleName: 'Run',
|
||||
unitNames: ['Stage day', 'Haul', 'Yard', 'Rest'],
|
||||
},
|
||||
|
||||
// Money is smaller here than in Corporate Ladder by a factor of about
|
||||
// fifteen, and it behaves differently: the settlement covers board and room
|
||||
// and very little else, so the difference between a good run and a bad one
|
||||
// is what you took from the work. See `tuning` below for the arithmetic that
|
||||
// is actually asserted.
|
||||
state: {
|
||||
'money': { initial: 40, min: -400, max: 100000 },
|
||||
// The store ledger. Named for what it is rather than for a loan: nobody in
|
||||
// Cold Fork lends money, they let you carry a balance.
|
||||
'owed': { initial: 0, min: 0, max: 100000 },
|
||||
'standing': { initial: 50, min: 0, max: 100 },
|
||||
'skills.nerve': { initial: 5, min: 0, max: 100 },
|
||||
'skills.trade': { initial: 5, min: 0, max: 100 },
|
||||
'flags.carries_iron': { initial: false },
|
||||
'flags.owes_the_store': { initial: false },
|
||||
'flags.covered_for_dutch': { initial: false },
|
||||
'flags.sworn_in': { initial: false },
|
||||
'flags.spoke_for_dutch': { initial: false },
|
||||
'notable': { initial: [] },
|
||||
},
|
||||
|
||||
// A different vocabulary from Corporate Ladder's likes/fears/helps, and the
|
||||
// reason the vocabulary is content's to choose. Out here the useful question
|
||||
// about a person is not whether they like you: it is whether they will take
|
||||
// your word, and whether they are carrying a debt to you they mean to pay.
|
||||
relationshipStats: {
|
||||
trusts_you: { label: 'Takes your word', initial: 45, min: 0, max: 100 },
|
||||
owes_you: { label: 'Owes you', initial: 10, min: 0, max: 100 },
|
||||
wary_of_you: { label: 'Wary of you', initial: 15, min: 0, max: 100 },
|
||||
},
|
||||
|
||||
// The economic shape, and the thing that makes this pack a real test of the
|
||||
// engine rather than Corporate Ladder in a hat: there is no wage worth
|
||||
// living on. Vane settles up when the stage comes in and it covers the room
|
||||
// and the feed and four dollars besides. Everything else you earn, you earn
|
||||
// on a turn, by choosing to — and what the choices cost is where the
|
||||
// pressure comes from, not an unexplained drain.
|
||||
//
|
||||
// Keep this arithmetic honest when tuning. Per four-day run:
|
||||
// hand: -12 board -8 room +24 settlement = +4
|
||||
// deputy: -12 board -8 room +38 pay = +18
|
||||
// Both are asserted directly by the conformance suite against `tuning`
|
||||
// below, so a number changed here without re-deriving it fails the build
|
||||
// rather than trapping a player forty turns in.
|
||||
upkeep: [
|
||||
{ label: 'Board and feed', effects: [{ path: 'money', op: 'add', value: -3 }] },
|
||||
// Turn 1 is a Stage day, so offset 3 is the first Rest day and offset 4
|
||||
// the next Stage day — the room is collected the night before the outfit
|
||||
// settles up, which is the boarding house's whole business model.
|
||||
{
|
||||
label: 'Room at the Cordwood',
|
||||
every: 4,
|
||||
offset: 3,
|
||||
effects: [{ path: 'money', op: 'add', value: -8 }],
|
||||
},
|
||||
{
|
||||
label: 'Settlement',
|
||||
every: 4,
|
||||
offset: 4,
|
||||
requires: [{ path: 'stage', op: '==', value: 'hand' }],
|
||||
effects: [{ path: 'money', op: 'add', value: 24 }],
|
||||
},
|
||||
{
|
||||
label: 'Deputy pay',
|
||||
every: 4,
|
||||
offset: 4,
|
||||
// The town pays a real wage, and it is still not much of a living. What
|
||||
// changes with the star is not the money; it is what the days contain.
|
||||
requires: [{ path: 'stage', op: '==', value: 'deputy' }],
|
||||
effects: [{ path: 'money', op: 'add', value: 38 }],
|
||||
},
|
||||
// Store credit costs more the deeper in you are, the same step function
|
||||
// Corporate Ladder uses for its loan — a shape, not a setting.
|
||||
{
|
||||
label: 'Store credit',
|
||||
every: 4,
|
||||
offset: 4,
|
||||
requires: [{ path: 'owed', op: '>', value: 0 }],
|
||||
effects: [{ path: 'money', op: 'add', value: -3 }],
|
||||
},
|
||||
{
|
||||
label: 'Store credit (carried)',
|
||||
every: 4,
|
||||
offset: 4,
|
||||
requires: [{ path: 'owed', op: '>=', value: 60 }],
|
||||
effects: [{ path: 'money', op: 'add', value: -3 }],
|
||||
},
|
||||
{
|
||||
label: 'Store credit (posted)',
|
||||
every: 4,
|
||||
offset: 4,
|
||||
requires: [{ path: 'owed', op: '>=', value: 120 }],
|
||||
effects: [{ path: 'money', op: 'add', value: -4 }],
|
||||
},
|
||||
],
|
||||
|
||||
// What the conformance suite holds this pack to. Declaring it here rather
|
||||
// than writing it into a test is the same principle as the rest of the pack:
|
||||
// the numbers a setting must hit are the setting's business. See
|
||||
// DECISIONS.md #31.
|
||||
tuning: {
|
||||
// Net movement of `money` from upkeep alone, per calendar cycle, at each
|
||||
// stage. Upkeep is the floor a player plays against: barely above water in
|
||||
// the yard, and a real if unremarkable living with the star.
|
||||
baselinePerCycle: {
|
||||
hand: [0, 12],
|
||||
deputy: [10, 30],
|
||||
},
|
||||
},
|
||||
|
||||
display: {
|
||||
'money': { label: 'Money', format: 'money', order: 1 },
|
||||
'owed': { label: 'Owed at the store', format: 'money', order: 2, hideWhenZero: true },
|
||||
'standing': { label: 'Standing', format: 'meter', order: 3 },
|
||||
'skills.nerve': { label: 'Nerve', format: 'meter', order: 4 },
|
||||
'skills.trade': { label: 'Trade', format: 'meter', order: 5 },
|
||||
'notable': { hidden: true, inLedger: false },
|
||||
'flags.carries_iron': {
|
||||
label: 'The scattergun', hidden: true,
|
||||
whenTrue: 'You are carrying Vane’s scattergun on the box.',
|
||||
whenFalse: 'The scattergun is back under the office counter.',
|
||||
},
|
||||
'flags.owes_the_store': {
|
||||
label: 'The store ledger', hidden: true,
|
||||
whenTrue: 'Your name is in the book at the store.',
|
||||
whenFalse: 'You are square with the store.',
|
||||
},
|
||||
'flags.covered_for_dutch': {
|
||||
label: 'Dutch’s run', hidden: true,
|
||||
whenTrue: 'Dutch owes you a run.',
|
||||
whenFalse: 'You and Dutch are square.',
|
||||
},
|
||||
'flags.sworn_in': {
|
||||
label: 'The star', hidden: true,
|
||||
whenTrue: 'You are sworn in.',
|
||||
whenFalse: 'You are back in the yard.',
|
||||
},
|
||||
'flags.spoke_for_dutch': {
|
||||
label: 'Dutch’s name', hidden: true,
|
||||
whenTrue: 'Dutch’s name is in for the good runs.',
|
||||
whenFalse: 'Dutch’s name is not in for the good runs.',
|
||||
},
|
||||
},
|
||||
|
||||
characters,
|
||||
events,
|
||||
};
|
||||
|
||||
export default frontier;
|
||||
@@ -0,0 +1,23 @@
|
||||
// The packs this build ships with.
|
||||
//
|
||||
// A setting is a content pack, and this is the only list of them. Adding one
|
||||
// is an import and an entry — no engine change, no interface change, and
|
||||
// nothing anywhere else that names it. `packs[0]` is what a first-time player
|
||||
// starts in.
|
||||
//
|
||||
// The bundler follows static imports from the entry module, so appearing in
|
||||
// this file is also what gets a pack into `dist/theladder.html`.
|
||||
|
||||
import { corporateLadder } from './corporateladder/index.js';
|
||||
import { frontier } from './frontier/index.js';
|
||||
|
||||
export const packs = [corporateLadder, frontier];
|
||||
|
||||
/** A pack by id, or undefined. Used to resume into whatever was last played. */
|
||||
export function packById(id) {
|
||||
return packs.find((pack) => pack.id === id);
|
||||
}
|
||||
|
||||
export const defaultPack = packs[0];
|
||||
|
||||
export default packs;
|
||||
@@ -421,3 +421,129 @@ Spans and pair-counts are now accumulated per run and pooled, so the figure is a
|
||||
proper weighted average of within-run gaps. It is worth stating the general
|
||||
form: any statistic over a log has to respect the run boundary, because a log is
|
||||
not one sequence.
|
||||
|
||||
## 31. A pack declares the numbers it must hit
|
||||
|
||||
Decision #29 added a test that asserts Corporate Ladder's economic baseline
|
||||
directly, because a tuning change made without re-deriving what it meant had
|
||||
trapped a real playtester. That test named the pack, named its stages and
|
||||
hard-coded its ranges, which meant a second pack got none of it — the guard
|
||||
that exists precisely because a number went unchecked would itself have gone
|
||||
uncopied.
|
||||
|
||||
So the numbers moved into the pack, as `tuning.baselinePerCycle`:
|
||||
|
||||
```js
|
||||
tuning: {
|
||||
baselinePerCycle: { hand: [0, 12], deputy: [10, 30] },
|
||||
},
|
||||
```
|
||||
|
||||
The conformance suite reads that and holds each pack to its own declared range.
|
||||
A new setting is checked the moment it is in the registry, and what it is
|
||||
checked against is its designer's stated intent rather than another setting's
|
||||
numbers. The pack is required to declare it: a missing `tuning` block fails,
|
||||
because the silent failure mode here is a pack that is never economically
|
||||
tested at all.
|
||||
|
||||
The figure is **derived, not simulated**. An upkeep entry worth `v` every `n`
|
||||
turns is worth `v * cycleLength / n` per cycle, exactly. Simulating it over a
|
||||
fixed window reads differently depending on where the last rent lands: Corporate
|
||||
Ladder measures −19 a fortnight over 280 turns and −42 over 560, and only the
|
||||
second is the steady state. The original test used the 280-turn window and was
|
||||
reading a number 23 dollars off the truth — inside its own tolerance, so it
|
||||
never said so.
|
||||
|
||||
## 32. A skill gate is never gated behind itself
|
||||
|
||||
Corporate Ladder gates four options on `skills.negotiation >= 15`, and several
|
||||
of its largest negotiation gains sit behind that same gate — `+4` on
|
||||
`marlene_confides.ask_how_to_leave`, `+3` on `trevor_favor.trade`. Its logs show
|
||||
players meeting the wall repeatedly through the first thirty turns.
|
||||
|
||||
Measured, the gate is not unreachable: a player steering directly for the skill
|
||||
is through it by turn 11. What the logs actually show is ordinary players not
|
||||
prioritising it and having no incidental route, which is a pacing failure rather
|
||||
than a reachability one — and the reachability test could never have caught it,
|
||||
because reachability is exactly what holds.
|
||||
|
||||
The Frontier is built to a rule instead, asserted in `test/frontier.test.js`:
|
||||
for every skill threshold in the pack, the growth available *without* passing
|
||||
that threshold must be able to reach it, from repeatable events — a single
|
||||
one-off award is not a route. A second test measures it, requiring a player who
|
||||
steers for a skill to be through its lowest gate inside twenty turns.
|
||||
|
||||
The rule is deliberately not in the conformance suite, because Corporate Ladder
|
||||
does not satisfy it and retuning that pack is a separate decision from adding
|
||||
this one. It is written down here so the choice is visible rather than implied.
|
||||
|
||||
## 33. One save slot per pack, and the picker is the front door
|
||||
|
||||
`theladder.save.v1` was a single global slot. The app guarded it by comparing
|
||||
the packId inside the save against the pack it was about to run, and started a
|
||||
new game when they differed — so with two settings, the first switch would have
|
||||
silently destroyed the other career.
|
||||
|
||||
Slots are now `theladder.save.v1.<packId>`, one per setting, and switching is
|
||||
non-destructive in both directions: each run is written to its own slot every
|
||||
turn, and coming back resumes rather than restarts. `theladder.pack.v1` holds
|
||||
the last setting played, so a returning player lands back where they were.
|
||||
|
||||
The pre-packs slot is still read once, for Corporate Ladder only, and only when
|
||||
it really holds a Corporate Ladder save. It is never written. Someone mid-career
|
||||
when this shipped finds their career.
|
||||
|
||||
A first-time player — nothing saved, nothing remembered — gets the setting
|
||||
picker rather than being dropped into whichever pack is first in the registry.
|
||||
A build shipping one pack skips it entirely.
|
||||
|
||||
## 34. What a second setting proved, and what it cost
|
||||
|
||||
The engine/content split was a claim until there were two settings. The
|
||||
Frontier is 29 events across two stages, a four-day cycle instead of a
|
||||
seven-day one, a different relationship vocabulary (`trusts_you`, `owes_you`,
|
||||
`wary_of_you`), a different skill pair, and money about fifteen times smaller
|
||||
that arrives from work rather than from a wage. `src/engine/` was not touched.
|
||||
|
||||
Three things outside the engine did have to change, and none of them was about
|
||||
the setting:
|
||||
|
||||
- **`src/io/save.js`** — the single save slot, above.
|
||||
- **`src/ui/app.js`** — it named one pack; it now holds the current one. The
|
||||
screens, the engine and the rest of the io layer still take the pack as an
|
||||
argument and are no less pack-agnostic than before.
|
||||
- **`src/ui/screens/turn.js:40`** — hardcoded `'The mailroom'` as the heading
|
||||
over the people panel. That was the interface naming a setting, against
|
||||
decision #14, and it had gone unnoticed while only one setting existed. It is
|
||||
now `pack.peopleHeading`.
|
||||
|
||||
`tools/build.js` needed one fix, unrelated to packs but exposed by them: its
|
||||
module scan used `.*?` where `transform` used a pattern that spans lines, so a
|
||||
destructured import wrapped across several lines was rewritten into a
|
||||
`__require` for a module the scan had never collected. The bundle built clean
|
||||
and failed only when run. Both now use `[\s\S]*?`.
|
||||
|
||||
The honest summary is that adding a setting required no engine change and three
|
||||
interface changes, two of which were latent defects that a second pack merely
|
||||
made visible.
|
||||
|
||||
## 35. Three open questions, closed by the designer
|
||||
|
||||
Recorded 2026-09-11, alongside 0.3.1.
|
||||
|
||||
**Corporate Ladder's negotiation gate stays.** A gate that takes deliberate work
|
||||
to get past is acceptable, and so is an option that needs groundwork first. #32
|
||||
remains the rule the Frontier is built to and tested against; it is a choice a
|
||||
pack may make, not a standard every pack must meet, and Corporate Ladder is not
|
||||
being retuned toward it.
|
||||
|
||||
**The feedback widget stays exactly as it is.** Zero ratings across three
|
||||
sessions says nothing about the widget: those playtests were rapid, and feedback
|
||||
came back directly rather than through it. It is not evidence either way until a
|
||||
playtest runs without that direct channel.
|
||||
|
||||
**Money has to matter late, through the cost of standing.** A careful player
|
||||
ends around $8,300 over 200 turns, and money stops being a constraint. The
|
||||
direction is that rising standing brings rising expenses — the car, the place to
|
||||
live, the clothes a position expects. Upkeep entries are already gated
|
||||
conditions over state, so this is content work, not an engine change.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "theladder",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"private": true,
|
||||
"description": "A turn-based career simulation game. Engine is theme-agnostic; settings are content packs.",
|
||||
"type": "module",
|
||||
|
||||
+48
-10
@@ -4,10 +4,23 @@
|
||||
// a crashed browser does not take a career with it. A save is the whole game
|
||||
// state and nothing else — the engine keeps no state outside it, so this is a
|
||||
// complete record by construction.
|
||||
//
|
||||
// There is one slot per pack. A single global slot was fine while there was
|
||||
// one setting; the moment a second existed, switching settings would have
|
||||
// silently thrown away the other run — the guard in the app read the packId
|
||||
// off whatever was in the slot and started a new game when it did not match.
|
||||
|
||||
const KEY = 'theladder.save.v1';
|
||||
const PREFIX = 'theladder.save.v1';
|
||||
// The slot as it was before packs were selectable: one save, no pack in the
|
||||
// key. Read once, for Corporate Ladder only, so a run in progress survives
|
||||
// this change. Never written.
|
||||
const LEGACY_KEY = PREFIX;
|
||||
const LEGACY_PACK = 'corporateladder';
|
||||
const LAST_PACK_KEY = 'theladder.pack.v1';
|
||||
const FORMAT = 1;
|
||||
|
||||
const slotFor = (packId) => `${PREFIX}.${packId}`;
|
||||
|
||||
/** Local storage throws in private windows and when site data is blocked. */
|
||||
function safely(action, fallback = null) {
|
||||
try {
|
||||
@@ -17,24 +30,49 @@ function safely(action, fallback = null) {
|
||||
}
|
||||
}
|
||||
|
||||
export function saveLocal(state) {
|
||||
export function saveLocal(packId, state) {
|
||||
return safely(() => {
|
||||
localStorage.setItem(KEY, JSON.stringify({ format: FORMAT, savedAt: new Date().toISOString(), state }));
|
||||
localStorage.setItem(
|
||||
slotFor(packId),
|
||||
JSON.stringify({ format: FORMAT, savedAt: new Date().toISOString(), state }),
|
||||
);
|
||||
return true;
|
||||
}, false);
|
||||
}
|
||||
|
||||
export function loadLocal() {
|
||||
/**
|
||||
* The saved run for one pack, or null. Falls back to the pre-packs slot for
|
||||
* Corporate Ladder, and only when that slot really holds a Corporate Ladder
|
||||
* save — a stale slot from some other pack is not a reason to load a run into
|
||||
* a game whose events do not match it.
|
||||
*/
|
||||
export function loadLocal(packId) {
|
||||
return safely(() => {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed?.state ?? null;
|
||||
const raw = localStorage.getItem(slotFor(packId));
|
||||
if (raw) return JSON.parse(raw)?.state ?? null;
|
||||
if (packId !== LEGACY_PACK) return null;
|
||||
const legacy = localStorage.getItem(LEGACY_KEY);
|
||||
if (!legacy) return null;
|
||||
const state = JSON.parse(legacy)?.state ?? null;
|
||||
return state?.meta?.packId === LEGACY_PACK ? state : null;
|
||||
});
|
||||
}
|
||||
|
||||
export function clearLocal() {
|
||||
return safely(() => { localStorage.removeItem(KEY); return true; }, false);
|
||||
export function clearLocal(packId) {
|
||||
return safely(() => {
|
||||
localStorage.removeItem(slotFor(packId));
|
||||
if (packId === LEGACY_PACK) localStorage.removeItem(LEGACY_KEY);
|
||||
return true;
|
||||
}, false);
|
||||
}
|
||||
|
||||
/** Which pack was last played, so a returning player lands back in it. */
|
||||
export function lastPlayed() {
|
||||
return safely(() => localStorage.getItem(LAST_PACK_KEY));
|
||||
}
|
||||
|
||||
export function rememberPack(packId) {
|
||||
return safely(() => { localStorage.setItem(LAST_PACK_KEY, packId); return true; }, false);
|
||||
}
|
||||
|
||||
/** True if the browser will actually remember anything. */
|
||||
|
||||
+125
-35
@@ -1,8 +1,11 @@
|
||||
// The application: wiring the engine to the screens.
|
||||
//
|
||||
// This is the one module that names a content pack, which makes it the seam a
|
||||
// future pack-picker slots into. Everything below it takes the pack as an
|
||||
// argument.
|
||||
// This is the one module that knows there is more than one content pack. It
|
||||
// holds the current pack and its schema, and everything below it still takes
|
||||
// the pack as an argument — the screens, the engine and the io layer are all
|
||||
// as pack-agnostic as they were when there was only one.
|
||||
//
|
||||
// Each pack has its own save slot, so switching settings never destroys a run.
|
||||
//
|
||||
// Rendering is a full rebuild of the root element. At this size it is fast and
|
||||
// removes a whole class of stale-view bug — with one deliberate exception: the
|
||||
@@ -10,21 +13,24 @@
|
||||
// rebuilding the DOM under a cursor would throw the player out of the box they
|
||||
// are typing in.
|
||||
|
||||
import { corporateLadder } from '../../content/corporateladder/index.js';
|
||||
import { packs, packById, defaultPack } from '../../content/index.js';
|
||||
import { validatePack } from '../engine/validate.js';
|
||||
import { resolveSchema } from '../engine/state.js';
|
||||
import { startGame, currentTurn, takeTurn, findOptionById } from './engine-bridge.js';
|
||||
import { turnScreen } from './screens/turn.js';
|
||||
import { retrospectiveScreen } from './screens/retrospective.js';
|
||||
import { packsScreen } from './screens/packs.js';
|
||||
import { fill, focusMarked } from './dom.js';
|
||||
import { saveLocal, loadLocal, clearLocal, serializeSave, parseSave, storageAvailable } from '../io/save.js';
|
||||
import {
|
||||
saveLocal, loadLocal, clearLocal, serializeSave, parseSave, storageAvailable,
|
||||
lastPlayed, rememberPack,
|
||||
} from '../io/save.js';
|
||||
import { logTurn, attachFeedback, readLog, serializeLog, clearLog, feedbackCount } from '../io/feedback-log.js';
|
||||
import { downloadText, readFile, stamp } from '../io/download.js';
|
||||
|
||||
const pack = corporateLadder;
|
||||
const schema = resolveSchema(pack);
|
||||
|
||||
const app = {
|
||||
pack: null,
|
||||
schema: null,
|
||||
state: null,
|
||||
view: null,
|
||||
root: null,
|
||||
@@ -35,7 +41,7 @@ function choosingView() {
|
||||
return {
|
||||
screen: 'turn',
|
||||
phase: 'choosing',
|
||||
turn: currentTurn(pack, app.state),
|
||||
turn: currentTurn(app.pack, app.state),
|
||||
feedback: emptyFeedback(),
|
||||
feedbackOpen: false,
|
||||
};
|
||||
@@ -45,23 +51,41 @@ const emptyFeedback = () =>
|
||||
({ rating: null, comment: '', desiredAction: '', saved: false, touched: false, turnId: null });
|
||||
|
||||
function render() {
|
||||
const screen = app.view.screen === 'retrospective'
|
||||
? retrospectiveScreen(pack, app.state, schema, actions)
|
||||
: turnScreen(pack, app.state, schema, app.view, actions);
|
||||
const screen = app.view.screen === 'packs'
|
||||
? packsScreen(packs, packsView(), actions)
|
||||
: app.view.screen === 'retrospective'
|
||||
? retrospectiveScreen(app.pack, app.state, app.schema, actions)
|
||||
: turnScreen(app.pack, app.state, app.schema, app.view, actions);
|
||||
fill(app.root, screen);
|
||||
// After the screen is in the document, put the caret where the player most
|
||||
// likely wants it. This is what makes the game playable on Enter alone.
|
||||
focusMarked(app.root);
|
||||
}
|
||||
|
||||
/**
|
||||
* The picker's view of the world: which pack is current, and what is saved in
|
||||
* each of the others. Reading every slot is cheap and means the screen can say
|
||||
* "day 41, Dispatch Coordinator" rather than just "saved".
|
||||
*/
|
||||
function packsView() {
|
||||
const runs = {};
|
||||
for (const candidate of packs) {
|
||||
const saved = candidate.id === app.pack?.id
|
||||
? app.state
|
||||
: loadLocal(candidate.id);
|
||||
if (saved) runs[candidate.id] = { turn: saved.turn, stage: saved.stage };
|
||||
}
|
||||
return { currentPackId: app.pack?.id ?? null, runs, canGoBack: app.state !== null };
|
||||
}
|
||||
|
||||
const actions = {
|
||||
onChoose(optionId) {
|
||||
const option = findOptionById(app.view.turn.event, optionId);
|
||||
const { state, record } = takeTurn(pack, app.state, optionId);
|
||||
const { state, record } = takeTurn(app.pack, app.state, optionId);
|
||||
app.state = state;
|
||||
saveLocal(state);
|
||||
saveLocal(app.pack.id, state);
|
||||
|
||||
const entry = logTurn(record, state, pack);
|
||||
const entry = logTurn(record, state, app.pack);
|
||||
app.view = {
|
||||
screen: 'turn',
|
||||
phase: 'outcome',
|
||||
@@ -120,14 +144,37 @@ const actions = {
|
||||
onNewGame() {
|
||||
const played = app.state?.history?.length ?? 0;
|
||||
if (played > 0 && !confirm(
|
||||
`This ends the current run at ${pack.turnUnit} ${app.state.turn}. ` +
|
||||
`This ends the current run at ${app.pack.turnUnit} ${app.state.turn}. ` +
|
||||
'Export a save first if you want to keep it. Start a new game?',
|
||||
)) return;
|
||||
flushFeedback();
|
||||
app.state = startGame(pack, { seed: String(Date.now()), schema, now: new Date().toISOString() });
|
||||
clearLocal();
|
||||
saveLocal(app.state);
|
||||
app.view = choosingView();
|
||||
clearLocal(app.pack.id);
|
||||
beginRun(app.pack);
|
||||
render();
|
||||
},
|
||||
|
||||
/** Open the setting picker. */
|
||||
onPacks() {
|
||||
flushFeedback();
|
||||
app.view = { ...(app.view ?? {}), screen: 'packs' };
|
||||
render();
|
||||
},
|
||||
|
||||
/**
|
||||
* Switch settings. The run you are leaving has already been written to its
|
||||
* own slot every turn, so this is not destructive in either direction — and
|
||||
* coming back to a pack resumes it rather than restarting it.
|
||||
*/
|
||||
onChoosePack(packId) {
|
||||
const chosen = packById(packId);
|
||||
if (!chosen) return;
|
||||
flushFeedback();
|
||||
if (chosen.id === app.pack?.id) {
|
||||
app.view = choosingView();
|
||||
render();
|
||||
return;
|
||||
}
|
||||
if (!enterPack(chosen)) return;
|
||||
render();
|
||||
},
|
||||
|
||||
@@ -143,9 +190,9 @@ const actions = {
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const state = parseSave(await readFile(file), pack.id);
|
||||
const state = parseSave(await readFile(file), app.pack.id);
|
||||
app.state = state;
|
||||
saveLocal(state);
|
||||
saveLocal(app.pack.id, state);
|
||||
app.view = choosingView();
|
||||
render();
|
||||
} catch (error) {
|
||||
@@ -194,30 +241,73 @@ function flushFeedback() {
|
||||
});
|
||||
}
|
||||
|
||||
export function boot(rootElement) {
|
||||
/**
|
||||
* Make `pack` the current setting: validate it, adopt its schema, and either
|
||||
* resume its saved run or start one. Returns false when the pack will not
|
||||
* load, having already put the error on screen.
|
||||
*/
|
||||
function enterPack(pack) {
|
||||
const validation = validatePack(pack);
|
||||
if (!validation.valid) {
|
||||
fill(rootElement, errorScreen(validation.errors));
|
||||
return;
|
||||
fill(app.root, errorScreen(pack, validation.errors));
|
||||
return false;
|
||||
}
|
||||
for (const warning of validation.warnings) console.warn(`[content] ${warning}`);
|
||||
for (const warning of validation.warnings) console.warn(`[content ${pack.id}] ${warning}`);
|
||||
|
||||
app.pack = pack;
|
||||
app.schema = resolveSchema(pack);
|
||||
rememberPack(pack.id);
|
||||
|
||||
const restored = loadLocal(pack.id);
|
||||
// The packId check is belt and braces now that slots are per pack, but an
|
||||
// imported or hand-edited save can still carry the wrong name.
|
||||
if (restored && restored.meta?.packId === pack.id) {
|
||||
app.state = restored;
|
||||
app.view = choosingView();
|
||||
} else {
|
||||
beginRun(pack);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** A fresh run in `pack`, written to that pack's slot. */
|
||||
function beginRun(pack) {
|
||||
app.state = startGame(pack, {
|
||||
seed: String(Date.now()),
|
||||
schema: app.schema,
|
||||
now: new Date().toISOString(),
|
||||
});
|
||||
saveLocal(pack.id, app.state);
|
||||
app.view = choosingView();
|
||||
}
|
||||
|
||||
export function boot(rootElement) {
|
||||
app.root = rootElement;
|
||||
|
||||
if (!storageAvailable()) {
|
||||
console.warn('[save] local storage is unavailable; progress will not persist. Use Export save.');
|
||||
}
|
||||
|
||||
app.root = rootElement;
|
||||
const restored = loadLocal();
|
||||
app.state = restored && restored.meta?.packId === pack.id
|
||||
? restored
|
||||
: startGame(pack, { seed: String(Date.now()), schema, now: new Date().toISOString() });
|
||||
app.view = choosingView();
|
||||
render();
|
||||
// Land a returning player back where they were. A first-time player with
|
||||
// more than one setting to choose from gets the picker as the front door
|
||||
// rather than being dropped into whichever pack happens to be first.
|
||||
const remembered = packById(lastPlayed() ?? '');
|
||||
const anySaved = packs.some((pack) => loadLocal(pack.id) !== null);
|
||||
|
||||
if (!remembered && !anySaved && packs.length > 1) {
|
||||
app.view = { screen: 'packs' };
|
||||
render();
|
||||
} else if (!enterPack(remembered ?? defaultPack)) {
|
||||
return;
|
||||
} else {
|
||||
render();
|
||||
}
|
||||
|
||||
// Anything half-typed survives a tab close.
|
||||
window.addEventListener('beforeunload', flushFeedback);
|
||||
}
|
||||
|
||||
function errorScreen(errors) {
|
||||
function errorScreen(pack, errors) {
|
||||
const list = document.createElement('ul');
|
||||
for (const error of errors) {
|
||||
const item = document.createElement('li');
|
||||
@@ -227,7 +317,7 @@ function errorScreen(errors) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'screen screen--error';
|
||||
const heading = document.createElement('h1');
|
||||
heading.textContent = 'This content pack will not load.';
|
||||
heading.textContent = `"${pack.name ?? pack.id}" will not load.`;
|
||||
wrapper.append(heading, list);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// Choosing a setting.
|
||||
//
|
||||
// A setting is a content pack, and this screen is the whole of the interface's
|
||||
// knowledge about there being more than one: it reads `name`, `blurb` and the
|
||||
// stage titles off each pack and knows nothing else about any of them. Adding
|
||||
// a third pack changes this file not at all.
|
||||
//
|
||||
// Each pack keeps its own save slot, so switching is not destructive and the
|
||||
// screen says so — a run in progress is reported with the turn it is on.
|
||||
|
||||
import { h } from '../dom.js';
|
||||
|
||||
export function packsScreen(packs, { currentPackId, runs, canGoBack }, actions) {
|
||||
return h('div', { class: 'screen screen--packs' },
|
||||
h('header', { class: 'packs__head' },
|
||||
h('p', { class: 'packs__eyebrow' }, 'The Ladder'),
|
||||
h('h1', { class: 'packs__title' }, 'Choose a setting'),
|
||||
h('p', { class: 'packs__subtitle' },
|
||||
'The same game underneath. Each setting keeps its own run, so you can ' +
|
||||
'move between them without losing either.'),
|
||||
),
|
||||
|
||||
h('ul', { class: 'packs__list' },
|
||||
packs.map((pack, index) => {
|
||||
const run = runs[pack.id];
|
||||
const current = pack.id === currentPackId;
|
||||
return h('li', { class: `packs__item${current ? ' packs__item--current' : ''}` },
|
||||
h('h2', { class: 'packs__name' }, pack.name),
|
||||
pack.blurb && h('p', { class: 'packs__blurb' }, pack.blurb),
|
||||
h('p', { class: 'packs__state' }, runLine(pack, run, current)),
|
||||
h('button', {
|
||||
class: current ? 'button' : 'button button--quiet',
|
||||
// The setting the player is already in, or failing that the first
|
||||
// one, is what Enter takes — the same keyboard contract as
|
||||
// everywhere else.
|
||||
dataset: (current || (!currentPackId && index === 0)) ? { autofocus: 'true' } : undefined,
|
||||
onClick: () => actions.onChoosePack(pack.id),
|
||||
}, buttonLabel(run, current)),
|
||||
);
|
||||
}),
|
||||
),
|
||||
|
||||
canGoBack && h('footer', { class: 'controls' },
|
||||
h('button', { class: 'button button--quiet', onClick: actions.onResume }, 'Back'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function runLine(pack, run, current) {
|
||||
const unit = pack.turnUnit ?? 'turn';
|
||||
if (!run) return 'Not started.';
|
||||
const title = pack.stages?.[run.stage]?.title ?? run.stage;
|
||||
const where = `${capitalize(unit)} ${run.turn}, ${title}`;
|
||||
return current ? `${where} — where you are now.` : `${where}, saved.`;
|
||||
}
|
||||
|
||||
function buttonLabel(run, current) {
|
||||
if (current) return 'Keep playing';
|
||||
return run ? 'Go back to this run' : 'Start here';
|
||||
}
|
||||
|
||||
const capitalize = (word) => word.charAt(0).toUpperCase() + word.slice(1);
|
||||
|
||||
export default packsScreen;
|
||||
@@ -37,7 +37,7 @@ export function turnScreen(pack, state, schema, view, actions) {
|
||||
: choosingView(pack, view, actions),
|
||||
),
|
||||
h('aside', { class: 'people' },
|
||||
h('h2', { class: 'people__heading' }, 'The mailroom'),
|
||||
h('h2', { class: 'people__heading' }, pack.peopleHeading ?? 'The people'),
|
||||
(pack.characters ?? []).map((character) => characterCard(pack, state, schema, character)),
|
||||
),
|
||||
),
|
||||
@@ -50,6 +50,7 @@ export function turnScreen(pack, state, schema, view, actions) {
|
||||
h('button', { class: 'button button--quiet', onClick: actions.onExportFeedback },
|
||||
'Export feedback log'),
|
||||
h('button', { class: 'button button--quiet', onClick: actions.onNewGame }, 'New game'),
|
||||
h('button', { class: 'button button--quiet', onClick: actions.onPacks }, 'Change setting'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -308,3 +308,32 @@ body {
|
||||
|
||||
.screen--error { padding: 40px 0; }
|
||||
.screen--error h1 { font-size: 20px; color: var(--down); }
|
||||
|
||||
/* The setting picker. One card per content pack; the pack you are in is
|
||||
marked rather than hidden, because "keep playing" is the common case. */
|
||||
.screen--packs { padding: 40px 0; }
|
||||
.packs__eyebrow {
|
||||
margin: 0; font-size: 11px; letter-spacing: 0.14em;
|
||||
text-transform: uppercase; color: var(--ink-faint);
|
||||
}
|
||||
.packs__title { margin: 6px 0 6px; font-size: 32px; font-weight: 600; letter-spacing: -0.02em; }
|
||||
.packs__subtitle {
|
||||
margin: 0 0 28px; max-width: 52ch;
|
||||
color: var(--ink-soft); font-family: var(--serif); font-size: 17px;
|
||||
}
|
||||
.packs__list {
|
||||
list-style: none; margin: 0; padding: 0;
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px;
|
||||
}
|
||||
.packs__item {
|
||||
padding: 18px 18px 16px; background: var(--card);
|
||||
border: 1px solid var(--rule); border-radius: var(--radius);
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
}
|
||||
.packs__item--current { border-color: var(--accent); }
|
||||
.packs__name { margin: 0; font-size: 19px; font-weight: 600; letter-spacing: -0.01em; }
|
||||
.packs__blurb { margin: 0; color: var(--ink-soft); font-family: var(--serif); font-size: 16px; }
|
||||
.packs__state {
|
||||
margin: 0 0 4px; font-size: 12px; letter-spacing: 0.04em; color: var(--ink-faint);
|
||||
}
|
||||
.packs__item .button { margin-top: auto; align-self: flex-start; }
|
||||
|
||||
@@ -36,6 +36,18 @@ test('the bundle builds, and the bundled game boots and plays', () => {
|
||||
// eslint-disable-next-line no-new-func -- running the build output is the point
|
||||
new Function(script)();
|
||||
|
||||
// Nothing is saved and nothing is remembered, so a build shipping more
|
||||
// than one setting opens on the picker. Click into the first one and carry
|
||||
// on — this exercises the front door in the real bundle, which is the one
|
||||
// place the pack registry, the picker screen and the save slots all meet.
|
||||
assert.ok(firstByClass(dom.root, 'screen--packs'), 'the bundled game rendered the picker');
|
||||
assert.match(dom.root.textContent, /Corporate Ladder/, 'the packs are named');
|
||||
assert.match(dom.root.textContent, /The Frontier/);
|
||||
|
||||
const start = byClass(dom.root, 'button').find((b) => /Start here|Keep playing/.test(b.textContent));
|
||||
assert.ok(start, 'the picker offers a way in');
|
||||
start.click();
|
||||
|
||||
assert.ok(firstByClass(dom.root, 'screen--turn'), 'the bundled game rendered a turn');
|
||||
assert.match(dom.root.textContent, /Day 1/);
|
||||
assert.match(dom.root.textContent, /Marlene Hobb/);
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
// What every content pack must do, whatever setting it is.
|
||||
//
|
||||
// This suite runs over `content/index.js` — every pack the build ships — and
|
||||
// asserts only things that are true of a pack because it is a pack, never
|
||||
// because it is about a mailroom or a freight yard. A new setting is therefore
|
||||
// tested the moment it appears in the registry, rather than by copying this
|
||||
// file and changing the nouns.
|
||||
//
|
||||
// Per-pack tuning lives with the pack: content.test.js and frontier.test.js
|
||||
// hold each one to its own economy, its own promotion and its own characters.
|
||||
// Anything a pack must declare for this suite to check it — the economic
|
||||
// baseline, for one — is a field on the pack, not a constant in here.
|
||||
//
|
||||
// One trap, learned the hard way: the chooser's RNG must be seeded
|
||||
// *differently* from the game's. Both draw one number per turn from the same
|
||||
// generator, so sharing a seed correlates the choice index with the event draw
|
||||
// and silently makes whole option combinations unreachable.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { packs } from '../content/index.js';
|
||||
import { validatePack } from '../src/engine/validate.js';
|
||||
import { startGame, currentTurn, takeTurn } from '../src/engine/game.js';
|
||||
import { createRng } from '../src/engine/rng.js';
|
||||
import { play, sweep, median, strategies } from './fixtures/strategies.js';
|
||||
import { summarize } from '../src/ui/summary.js';
|
||||
|
||||
/** Play `turns` turns of `pack` with independently-seeded random choices. */
|
||||
function randomRun(pack, runSeed, turns) {
|
||||
const rng = createRng(`chooser-${runSeed}`);
|
||||
let state = startGame(pack, { seed: `run-${runSeed}` });
|
||||
for (let t = 0; t < turns; t++) {
|
||||
const turn = currentTurn(pack, state);
|
||||
const available = turn.options.filter((o) => o.available);
|
||||
assert.ok(available.length > 0,
|
||||
`${pack.id}: no available option on event "${turn.event.id}"`);
|
||||
state = takeTurn(pack, state, rng.pick(available).option.id).state;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Net movement of `money` from upkeep alone, per calendar cycle, at one stage.
|
||||
*
|
||||
* Derived rather than simulated: an entry worth `v` every `n` turns is worth
|
||||
* `v * cycleLength / n` per cycle, exactly, with no window to fall off the end
|
||||
* of. Simulating this over a fixed number of turns reads differently depending
|
||||
* on where the last rent lands — Corporate Ladder measures -19 a fortnight over
|
||||
* 280 turns and -42 over 560, and only one of those is the steady state.
|
||||
*
|
||||
* Entries gated on a debt path are excluded: the baseline is what a player
|
||||
* faces owing nothing, which is the floor the pack is designed around.
|
||||
*/
|
||||
function baselinePerCycle(pack, stage) {
|
||||
const cycle = pack.calendar?.cycleLength ?? 1;
|
||||
const debtish = (path) => /debt|owed|loan/i.test(path);
|
||||
return (pack.upkeep ?? [])
|
||||
.filter((entry) => {
|
||||
const gate = entry.requires ?? [];
|
||||
if (gate.some((c) => debtish(c.path))) return false;
|
||||
return !gate.some((c) => c.path === 'stage' && c.value !== stage);
|
||||
})
|
||||
.reduce((total, entry) => {
|
||||
const money = entry.effects
|
||||
.filter((e) => e.path === 'money' && e.op === 'add')
|
||||
.reduce((sum, e) => sum + e.value, 0);
|
||||
return total + money * (cycle / (entry.every ?? 1));
|
||||
}, 0);
|
||||
}
|
||||
|
||||
for (const pack of packs) {
|
||||
const where = `[${pack.id}]`;
|
||||
|
||||
test(`${where} the pack is valid, with no warnings`, () => {
|
||||
const result = validatePack(pack);
|
||||
assert.deepEqual(result.errors, []);
|
||||
assert.deepEqual(result.warnings, []);
|
||||
});
|
||||
|
||||
test(`${where} declares what the interface and this suite need from it`, () => {
|
||||
// Everything the picker shows and everything asserted below. A pack that
|
||||
// omits one of these is not a broken game, but it is a pack that will
|
||||
// render badly or go unchecked, and both should fail loudly at build time.
|
||||
assert.ok(pack.name, 'a display name');
|
||||
assert.ok(pack.blurb, 'a one-line blurb for the setting picker');
|
||||
assert.ok(pack.peopleHeading, 'a heading for the panel of characters');
|
||||
assert.ok(pack.stages && Object.keys(pack.stages).length > 0, 'named stages');
|
||||
assert.ok(pack.tuning?.baselinePerCycle, 'a declared economic baseline');
|
||||
for (const stage of Object.keys(pack.stages)) {
|
||||
assert.ok(pack.stages[stage].title, `a title for stage "${stage}"`);
|
||||
assert.ok(pack.tuning.baselinePerCycle[stage], `a baseline for stage "${stage}"`);
|
||||
}
|
||||
});
|
||||
|
||||
test(`${where} no content is dead: every event fires and every option becomes available`, () => {
|
||||
// Across archetypes, not just random play: several things in a pack exist
|
||||
// for a player pursuing something, and "broke but well-liked" in
|
||||
// particular is a state no single-axis strategy ever reaches.
|
||||
//
|
||||
// Options are checked on *availability*, not on being taken. Whether a
|
||||
// particular bot picks an option is an artefact of how the bot scores;
|
||||
// what matters is whether a player is ever offered it unlocked.
|
||||
const { firedEvents, offeredOptions } = sweep(pack, { seeds: 12, turns: 250 });
|
||||
|
||||
const allEvents = pack.events.map((e) => e.id);
|
||||
const allOptions = pack.events.flatMap((e) => e.options.map((o) => `${e.id}.${o.id}`));
|
||||
assert.deepEqual(allEvents.filter((e) => !firedEvents.has(e)), [],
|
||||
'events that can never fire');
|
||||
assert.deepEqual(allOptions.filter((o) => !offeredOptions.has(o)), [],
|
||||
'options no player can ever be offered');
|
||||
});
|
||||
|
||||
test(`${where} every event leaves a player with nothing at least one way out`, () => {
|
||||
// A player with no skills, no money and no friends must still be able to
|
||||
// take a turn. The engine guarantees an event will be drawn; only content
|
||||
// can guarantee it is playable when everything is gated.
|
||||
const trapped = pack.events
|
||||
.filter((event) => !(event.options ?? []).some((option) => !option.requires))
|
||||
.map((event) => event.id);
|
||||
assert.deepEqual(trapped, [], 'events whose every option is gated');
|
||||
});
|
||||
|
||||
test(`${where} every turn always offers something to do, however badly it is going`, () => {
|
||||
for (let seed = 100; seed < 130; seed++) randomRun(pack, seed, 150);
|
||||
});
|
||||
|
||||
test(`${where} a played run replays identically from its seed`, () => {
|
||||
assert.deepEqual(randomRun(pack, 42, 80), randomRun(pack, 42, 80));
|
||||
});
|
||||
|
||||
test(`${where} how you play changes where you end up`, () => {
|
||||
// Measured before promotions land, so this is about how a player handles
|
||||
// money rather than how fast they left the first rung. Mixing the two
|
||||
// hides the economy behind the career.
|
||||
const { byStrategy } = sweep(pack, { seeds: 12, turns: 35 });
|
||||
const finalMoney = (name) => median(byStrategy[name].map((s) => s.money));
|
||||
assert.ok(byStrategy.thrifty.every((s) => s.stage === pack.startingStage),
|
||||
'nobody should be promoted this early');
|
||||
assert.ok(finalMoney('thrifty') > finalMoney('random'),
|
||||
'a careful player should get ahead');
|
||||
assert.ok(finalMoney('random') > finalMoney('spendthrift'),
|
||||
'a careless player should struggle');
|
||||
});
|
||||
|
||||
test(`${where} the upkeep baseline matches what the pack declares`, () => {
|
||||
// The guard that was missing when Corporate Ladder's rent was raised from
|
||||
// 385 to 455 without re-deriving what it meant: the baseline went from
|
||||
// -$3/day to -$13/day and trapped a real playtester below zero for 30% of
|
||||
// a 182-turn run. No archetype caught it — they all either optimised money
|
||||
// or were promoted out of the problem before it bit.
|
||||
for (const [stage, [low, high]] of Object.entries(pack.tuning.baselinePerCycle)) {
|
||||
const net = baselinePerCycle(pack, stage);
|
||||
assert.ok(net > low && net < high,
|
||||
`"${stage}" nets ${net} a cycle; the pack declares (${low}, ${high})`);
|
||||
}
|
||||
});
|
||||
|
||||
test(`${where} nobody spends the run stuck below zero`, () => {
|
||||
// Hard times is a state a player passes through, not one they live in.
|
||||
// The failure this guards against is a debt whose service charge exceeds
|
||||
// anything the escape options can pay — inescapable by arithmetic.
|
||||
for (const strategy of Object.keys(strategies)) {
|
||||
const shares = [];
|
||||
for (let seed = 0; seed < 8; seed++) {
|
||||
let red = 0;
|
||||
let turns = 0;
|
||||
play(pack, { strategy, seed, turns: 150,
|
||||
visit: ({ state }) => { turns++; if (state.money < 0) red++; } });
|
||||
shares.push(red / turns);
|
||||
}
|
||||
const worst = Math.max(...shares);
|
||||
assert.ok(worst < 0.25,
|
||||
`${strategy} spent ${(worst * 100).toFixed(0)}% of a run below zero`);
|
||||
}
|
||||
});
|
||||
|
||||
test(`${where} a run that goes broke can climb back out`, () => {
|
||||
let sawNegative = false;
|
||||
let recovered = false;
|
||||
play(pack, { strategy: 'spendthrift', seed: 'recovery', turns: 200,
|
||||
visit: ({ state }) => {
|
||||
if (state.money < 0) sawNegative = true;
|
||||
else if (sawNegative) recovered = true;
|
||||
} });
|
||||
assert.ok(sawNegative, 'a careless run should go broke');
|
||||
assert.ok(recovered, 'and should be able to climb back out');
|
||||
});
|
||||
|
||||
test(`${where} a long run does not collapse onto a handful of situations`, () => {
|
||||
// Motivated by a real 183-turn session. Before Dispatch was fleshed out,
|
||||
// its two floor events were 46% of every promoted run — one every three
|
||||
// turns. Variety is a property of the pack, and this should fail loudly
|
||||
// when a stage is too thin for the run it invites.
|
||||
for (const strategy of ['sociable', 'thrifty', 'random']) {
|
||||
const counts = new Map();
|
||||
let turns = 0;
|
||||
play(pack, { strategy, seed: 'variety', turns: 200,
|
||||
visit: ({ turn }) => {
|
||||
counts.set(turn.event.id, (counts.get(turn.event.id) ?? 0) + 1);
|
||||
turns++;
|
||||
} });
|
||||
|
||||
const [topEvent, topCount] = [...counts.entries()].sort((a, b) => b[1] - a[1])[0];
|
||||
const share = topCount / turns;
|
||||
assert.ok(share < 0.25,
|
||||
`${strategy}: "${topEvent}" was ${(share * 100).toFixed(0)}% of turns`);
|
||||
|
||||
// Measured against what was actually reachable: a run that never leaves
|
||||
// the first stage cannot see the second, and counting that as thin
|
||||
// content would be measuring career progress rather than variety.
|
||||
const visited = new Set([...counts.keys()]
|
||||
.map((id) => pack.events.find((e) => e.id === id).stage)
|
||||
.filter(Boolean));
|
||||
const reachable = pack.events
|
||||
.filter((e) => e.stage === undefined || visited.has(e.stage)).length;
|
||||
assert.ok(counts.size / reachable > 0.6,
|
||||
`${strategy}: saw ${counts.size} of ${reachable} reachable situations in ${turns} turns`);
|
||||
}
|
||||
});
|
||||
|
||||
test(`${where} the retrospective has something to read back`, () => {
|
||||
const state = randomRun(pack, 3, 60);
|
||||
assert.ok(state.notable.length > 0, 'notable moments should accumulate');
|
||||
assert.equal(state.history.length, 60, 'every turn is recorded');
|
||||
assert.ok(state.history.every((h) => Array.isArray(h.upkeep)), 'upkeep is recorded per turn');
|
||||
});
|
||||
|
||||
test(`${where} the retrospective reports how repetitive the run was`, () => {
|
||||
const state = play(pack, { strategy: 'sociable', seed: 'repeats', turns: 120 });
|
||||
const summary = summarize(pack, state);
|
||||
|
||||
assert.equal(summary.turns, 120);
|
||||
assert.ok(summary.eventsSeen > 0 && summary.eventsSeen <= summary.eventsTotal);
|
||||
assert.ok(summary.averageTimesSeen > 1, 'a long run repeats situations');
|
||||
assert.equal(
|
||||
summary.averageTimesSeen.toFixed(2),
|
||||
(summary.turns / summary.eventsSeen).toFixed(2),
|
||||
'the average is turns over distinct situations',
|
||||
);
|
||||
assert.ok(summary.mostSeen.count >= summary.averageTimesSeen);
|
||||
assert.ok(summary.repeatedSituations <= summary.eventsSeen);
|
||||
assert.ok(summary.stagesVisited.includes(pack.startingStage));
|
||||
});
|
||||
|
||||
if (pack.calendar) {
|
||||
test(`${where} the calendar names the unit and counts the cycle`, () => {
|
||||
const { cycleLength, unitNames } = pack.calendar;
|
||||
let state = startGame(pack, { seed: 'calendar' });
|
||||
assert.deepEqual(
|
||||
[state.calendar.name, state.calendar.cycle, state.calendar.index],
|
||||
[unitNames?.[0] ?? null, 1, 0],
|
||||
'turn 1 is the first unit of the first cycle',
|
||||
);
|
||||
|
||||
// One full cycle on, the name comes round and the cycle has ticked.
|
||||
for (let t = 0; t < cycleLength; t++) {
|
||||
const turn = currentTurn(pack, state);
|
||||
state = takeTurn(pack, state, turn.options.find((o) => o.available).option.id).state;
|
||||
}
|
||||
assert.equal(state.turn, cycleLength + 1);
|
||||
assert.deepEqual(
|
||||
[state.calendar.name, state.calendar.cycle, state.calendar.index],
|
||||
[unitNames?.[0] ?? null, 2, 0],
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
+10
-206
@@ -1,9 +1,13 @@
|
||||
// Tests for the shipped Corporate Ladder pack.
|
||||
// Corporate Ladder's own tuning.
|
||||
//
|
||||
// These are content tests, not engine tests: they assert that the pack is
|
||||
// playable, that none of it is dead, and that its economy behaves. Engine
|
||||
// behaviour is covered against the fixture pack instead, so tuning the game
|
||||
// cannot break the engine suite and vice versa.
|
||||
// What is true of this pack because it is *this* pack: the shape of its week,
|
||||
// when wages and rent land, how the loan behaves, when Dispatch opens and how
|
||||
// deep the Trevor chain is. Everything true of a pack because it is a pack —
|
||||
// validity, dead content, the economic baseline, variety, replay — is in
|
||||
// conformance.test.js, which runs over every pack in the registry.
|
||||
//
|
||||
// Engine behaviour is covered against the fixture pack instead, so tuning the
|
||||
// game cannot break the engine suite and vice versa.
|
||||
//
|
||||
// One trap, learned the hard way: the chooser's RNG must be seeded
|
||||
// *differently* from the game's. Both draw one number per turn from the same
|
||||
@@ -14,65 +18,8 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { corporateLadder as pack } from '../content/corporateladder/index.js';
|
||||
import { validatePack } from '../src/engine/validate.js';
|
||||
import { startGame, currentTurn, takeTurn } from '../src/engine/game.js';
|
||||
import { createRng } from '../src/engine/rng.js';
|
||||
import { play, sweep, median, strategies } from './fixtures/strategies.js';
|
||||
import { summarize } from '../src/ui/summary.js';
|
||||
|
||||
/** Play `turns` turns with independently-seeded random choices. */
|
||||
function randomRun(runSeed, turns, visit = () => {}) {
|
||||
const rng = createRng(`chooser-${runSeed}`);
|
||||
let state = startGame(pack, { seed: `run-${runSeed}` });
|
||||
for (let t = 0; t < turns; t++) {
|
||||
const turn = currentTurn(pack, state);
|
||||
const available = turn.options.filter((o) => o.available);
|
||||
assert.ok(available.length > 0, `no available option on event "${turn.event.id}"`);
|
||||
const choice = rng.pick(available).option.id;
|
||||
visit(turn, state, choice);
|
||||
state = takeTurn(pack, state, choice).state;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
test('the pack is valid, with no warnings', () => {
|
||||
const result = validatePack(pack);
|
||||
assert.deepEqual(result.errors, []);
|
||||
assert.deepEqual(result.warnings, []);
|
||||
});
|
||||
|
||||
test('no content is dead: every event fires and every option becomes available', () => {
|
||||
// Across archetypes, not just random play: several things in the pack exist
|
||||
// for a player pursuing something, and "broke but well-liked" in particular
|
||||
// is a state no single-axis strategy ever reaches.
|
||||
//
|
||||
// Options are checked on *availability*, not on being taken. Whether a
|
||||
// particular bot picks an option is an artefact of how the bot scores; what
|
||||
// matters is whether a player is ever offered it unlocked.
|
||||
const { firedEvents, offeredOptions } = sweep(pack, { seeds: 12, turns: 250 });
|
||||
|
||||
const allEvents = pack.events.map((e) => e.id);
|
||||
const allOptions = pack.events.flatMap((e) => e.options.map((o) => `${e.id}.${o.id}`));
|
||||
assert.deepEqual(allEvents.filter((e) => !firedEvents.has(e)), [], 'events that can never fire');
|
||||
assert.deepEqual(allOptions.filter((o) => !offeredOptions.has(o)), [],
|
||||
'options no player can ever be offered');
|
||||
});
|
||||
|
||||
test('how you play changes where you end up', () => {
|
||||
// Measured before promotions land, so this is about how a player handles
|
||||
// money rather than about how fast they got out of the mailroom. Mixing the
|
||||
// two hides the economy behind the career.
|
||||
const { byStrategy } = sweep(pack, { seeds: 12, turns: 35 });
|
||||
const finalMoney = (name) => median(byStrategy[name].map((s) => s.money));
|
||||
assert.ok(byStrategy.thrifty.every((s) => s.stage === 'mailroom'),
|
||||
'nobody should be promoted this early');
|
||||
|
||||
// The shape that matters: minding the money climbs, ignoring it sinks, and
|
||||
// playing without a plan roughly treads water. Exact figures move with
|
||||
// tuning; the ordering is the design.
|
||||
assert.ok(finalMoney('thrifty') > finalMoney('random'), 'a careful player should get ahead');
|
||||
assert.ok(finalMoney('random') > finalMoney('spendthrift'), 'a careless player should struggle');
|
||||
});
|
||||
import { play, sweep, median } from './fixtures/strategies.js';
|
||||
|
||||
test('the promotion is worth taking, and reachable by a deliberate player', () => {
|
||||
const { byStrategy } = sweep(pack, { seeds: 12, turns: 200 });
|
||||
@@ -108,11 +55,6 @@ test('hard times is uncommon for a careful player and real for a careless one',
|
||||
assert.ok(countHardTimes('spendthrift') > 0, 'ignoring it should land you in it');
|
||||
});
|
||||
|
||||
test('every turn always offers something to do, however badly the run is going', () => {
|
||||
// randomRun asserts this on every turn; 40 long runs is the coverage.
|
||||
for (let seed = 100; seed < 140; seed++) randomRun(seed, 150);
|
||||
});
|
||||
|
||||
test('money arrives and leaves on a rhythm the player can plan around', () => {
|
||||
const wages = [];
|
||||
const rent = [];
|
||||
@@ -145,24 +87,6 @@ test('the calendar names the day and counts the week', () => {
|
||||
assert.deepEqual([state.calendar.name, state.calendar.cycle], ['Friday', 2]);
|
||||
});
|
||||
|
||||
test('hard times is survivable without any relationship or skill', () => {
|
||||
const event = pack.events.find((e) => e.id === 'rent_bounced');
|
||||
const ungated = event.options.filter((o) => !o.requires);
|
||||
assert.ok(ungated.length > 0, 'a player with nothing must still have a way out');
|
||||
});
|
||||
|
||||
test('the run recovers from hard times rather than sticking there', () => {
|
||||
let sawNegative = false;
|
||||
let recovered = false;
|
||||
play(pack, { strategy: 'spendthrift', seed: 'recovery', turns: 200,
|
||||
visit: ({ state }) => {
|
||||
if (state.money < 0) sawNegative = true;
|
||||
else if (sawNegative) recovered = true;
|
||||
} });
|
||||
assert.ok(sawNegative, 'a careless run should go broke');
|
||||
assert.ok(recovered, 'and should be able to climb back out');
|
||||
});
|
||||
|
||||
test('debt cannot spiral without limit: lending stops and the charge escalates', () => {
|
||||
let peakDebt = 0;
|
||||
for (const strategy of ['spendthrift', 'desperate', 'random']) {
|
||||
@@ -197,123 +121,3 @@ test('a deliberate player can reach relationship-gated content in a reasonable r
|
||||
assert.ok(reached && reached < 45, `Trevor's chain opened at turn ${reached ?? 'never'}`);
|
||||
});
|
||||
|
||||
test('a played run replays identically from its seed', () => {
|
||||
const a = randomRun(42, 80);
|
||||
const b = randomRun(42, 80);
|
||||
assert.deepEqual(a, b);
|
||||
});
|
||||
|
||||
test('the retrospective has something to read back', () => {
|
||||
const state = randomRun(3, 60);
|
||||
assert.ok(state.notable.length > 0, 'notable moments should accumulate');
|
||||
assert.equal(state.history.length, 60, 'every turn is recorded');
|
||||
assert.ok(state.history.every((h) => Array.isArray(h.upkeep)), 'upkeep is recorded per turn');
|
||||
});
|
||||
|
||||
test('a long run does not collapse onto a handful of situations', () => {
|
||||
// Motivated by a real 183-turn session. Before Dispatch was fleshed out, its
|
||||
// two floor events were 46% of every promoted run — one every three turns.
|
||||
// This is the guardrail on that: variety is a property of the pack, and it
|
||||
// should fail loudly when a stage is too thin for the run it invites.
|
||||
for (const strategy of ['sociable', 'thrifty', 'random']) {
|
||||
const counts = new Map();
|
||||
let turns = 0;
|
||||
play(pack, { strategy, seed: 'variety', turns: 200,
|
||||
visit: ({ turn }) => {
|
||||
counts.set(turn.event.id, (counts.get(turn.event.id) ?? 0) + 1);
|
||||
turns++;
|
||||
} });
|
||||
|
||||
const [topEvent, topCount] = [...counts.entries()].sort((a, b) => b[1] - a[1])[0];
|
||||
const share = topCount / turns;
|
||||
assert.ok(share < 0.25,
|
||||
`${strategy}: "${topEvent}" was ${(share * 100).toFixed(0)}% of turns`);
|
||||
|
||||
// Measured against what was actually reachable: a run that never leaves
|
||||
// the mailroom cannot see Dispatch, and counting that as thin content
|
||||
// would be measuring career progress rather than variety.
|
||||
const visited = new Set([...counts.keys()]
|
||||
.map((id) => pack.events.find((e) => e.id === id).stage)
|
||||
.filter(Boolean));
|
||||
const reachable = pack.events
|
||||
.filter((e) => e.stage === undefined || visited.has(e.stage)).length;
|
||||
assert.ok(counts.size / reachable > 0.6,
|
||||
`${strategy}: saw ${counts.size} of ${reachable} reachable situations in ${turns} turns`);
|
||||
}
|
||||
});
|
||||
|
||||
test('the retrospective reports how repetitive the run was', () => {
|
||||
const state = play(pack, { strategy: 'sociable', seed: 'repeats', turns: 120 });
|
||||
const summary = summarize(pack, state);
|
||||
|
||||
assert.equal(summary.turns, 120);
|
||||
assert.ok(summary.eventsSeen > 0 && summary.eventsSeen <= summary.eventsTotal);
|
||||
assert.ok(summary.averageTimesSeen > 1, 'a long run repeats situations');
|
||||
assert.equal(
|
||||
summary.averageTimesSeen.toFixed(2),
|
||||
(summary.turns / summary.eventsSeen).toFixed(2),
|
||||
'the average is turns over distinct situations',
|
||||
);
|
||||
assert.ok(summary.mostSeen.count >= summary.averageTimesSeen);
|
||||
assert.ok(summary.repeatedSituations <= summary.eventsSeen);
|
||||
assert.deepEqual(summary.stagesVisited, ['mailroom', 'dispatch'],
|
||||
'the retrospective knows the career moved');
|
||||
});
|
||||
|
||||
test('the mailroom baseline is a drift, not a bleed', () => {
|
||||
// The guard that was missing. Rent was once raised from 385 to 455 without
|
||||
// re-deriving this, which quietly took the baseline from -$3/day to -$13/day
|
||||
// and trapped a real playtester below zero for 30% of a 182-turn session.
|
||||
// Nothing caught it, because every archetype either optimised money or got
|
||||
// promoted out of the problem.
|
||||
// Averaged over many fortnights rather than measured on the first one: the
|
||||
// opening turns sit before rent's offset, so a single early window sees one
|
||||
// rent cheque where the steady state has two.
|
||||
const FORTNIGHTS = 20;
|
||||
const perFortnight = (stage) => {
|
||||
let total = 0;
|
||||
for (let turn = 1; turn <= 14 * FORTNIGHTS; turn++) {
|
||||
for (const entry of pack.upkeep) {
|
||||
const every = entry.every ?? 1;
|
||||
const elapsed = turn - 1 - (entry.offset ?? 0);
|
||||
if (elapsed < 0 || elapsed % every !== 0) continue;
|
||||
// Baseline means no debt: only stage-gated entries count.
|
||||
const gate = entry.requires ?? [];
|
||||
if (gate.some((c) => c.path === 'debt')) continue;
|
||||
if (gate.some((c) => c.path === 'stage' && c.value !== stage)) continue;
|
||||
total += entry.effects
|
||||
.filter((e) => e.path === 'money' && e.op === 'add')
|
||||
.reduce((sum, e) => sum + e.value, 0);
|
||||
}
|
||||
}
|
||||
return Math.round(total / FORTNIGHTS);
|
||||
};
|
||||
|
||||
const mailroom = perFortnight('mailroom');
|
||||
assert.ok(mailroom > -90 && mailroom < 10,
|
||||
`the mailroom fortnight nets ${mailroom}; it should be a small drift downward`);
|
||||
|
||||
const dispatch = perFortnight('dispatch');
|
||||
assert.ok(dispatch > mailroom, 'the promotion should be worth having');
|
||||
assert.ok(dispatch < 250, `Dispatch nets ${dispatch} a fortnight; money stops mattering above that`);
|
||||
});
|
||||
|
||||
test('nobody spends the run stuck below zero', () => {
|
||||
// Hard times is a state a player passes through, not one they live in. The
|
||||
// failure this guards against is a debt whose weekly service charge exceeds
|
||||
// anything the escape options can pay — inescapable by arithmetic, which is
|
||||
// exactly what a real session hit.
|
||||
for (const strategy of Object.keys(strategies)) {
|
||||
const shares = [];
|
||||
for (let seed = 0; seed < 8; seed++) {
|
||||
let red = 0;
|
||||
let turns = 0;
|
||||
play(pack, { strategy, seed, turns: 150,
|
||||
visit: ({ state }) => { turns++; if (state.money < 0) red++; } });
|
||||
shares.push(red / turns);
|
||||
}
|
||||
const worst = Math.max(...shares);
|
||||
assert.ok(worst < 0.25,
|
||||
`${strategy} spent ${(worst * 100).toFixed(0)}% of a run below zero`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
// The Frontier's own tuning.
|
||||
//
|
||||
// What is true of this pack because it is *this* pack: a four-day run instead
|
||||
// of a week, a settlement that barely covers the room, a store ledger rather
|
||||
// than a loan, and when the star is offered. Everything true of a pack because
|
||||
// it is a pack is in conformance.test.js, which runs over every pack in the
|
||||
// registry — including this one.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { frontier as pack } from '../content/frontier/index.js';
|
||||
import { startGame, currentTurn, takeTurn } from '../src/engine/game.js';
|
||||
import { play, sweep, median } from './fixtures/strategies.js';
|
||||
|
||||
test('the calendar is a four-day run, not a week', () => {
|
||||
// The point of this pack, structurally: the engine derives a cycle from
|
||||
// whatever length the pack declares, and nothing anywhere assumes seven.
|
||||
let state = startGame(pack, { seed: 'calendar' });
|
||||
assert.deepEqual(
|
||||
[state.calendar.name, state.calendar.cycle, state.calendar.index],
|
||||
['Stage day', 1, 0],
|
||||
);
|
||||
const names = [];
|
||||
for (let t = 0; t < 7; t++) {
|
||||
names.push(state.calendar.name);
|
||||
const turn = currentTurn(pack, state);
|
||||
state = takeTurn(pack, state, turn.options.find((o) => o.available).option.id).state;
|
||||
}
|
||||
assert.deepEqual(names,
|
||||
['Stage day', 'Haul', 'Yard', 'Rest', 'Stage day', 'Haul', 'Yard']);
|
||||
assert.equal(state.calendar.cycle, 2, 'the second run has begun');
|
||||
});
|
||||
|
||||
test('the settlement and the room land on the days the pack says', () => {
|
||||
const settlement = [];
|
||||
const room = [];
|
||||
let state = startGame(pack, { seed: 'rhythm' });
|
||||
for (let t = 0; t < 16; t++) {
|
||||
const turn = currentTurn(pack, state);
|
||||
const result = takeTurn(pack, state, turn.options.find((o) => o.available).option.id);
|
||||
for (const change of result.record.upkeep) {
|
||||
if (change.label === 'Settlement') settlement.push(state.turn);
|
||||
if (change.label === 'Room at the Cordwood') room.push(state.turn);
|
||||
}
|
||||
state = result.state;
|
||||
}
|
||||
// The boarding house collects on the Rest day, the night before the outfit
|
||||
// settles up on the Stage day. That ordering is the whole reason a hand is
|
||||
// never quite ahead.
|
||||
assert.deepEqual(room, [4, 8, 12, 16], 'the room is collected every Rest day');
|
||||
assert.deepEqual(settlement, [5, 9, 13], 'the outfit settles up every Stage day');
|
||||
});
|
||||
|
||||
test('the star is worth taking, and reachable by a deliberate player', () => {
|
||||
const { byStrategy } = sweep(pack, { seeds: 12, turns: 200 });
|
||||
|
||||
const sworn = Object.values(byStrategy).flat().filter((s) => s.stage === 'deputy');
|
||||
const stayed = Object.values(byStrategy).flat().filter((s) => s.stage === 'hand');
|
||||
assert.ok(sworn.length > 0 && stayed.length > 0, 'both outcomes should occur');
|
||||
assert.ok(median(sworn.map((s) => s.money)) > median(stayed.map((s) => s.money)),
|
||||
'deputy pay should leave a player better off');
|
||||
|
||||
// The yard is a career, not a tutorial: the offer is gated at turn 40 and
|
||||
// should land somewhere in the middle of a run rather than at the very end.
|
||||
const timings = [];
|
||||
for (let seed = 0; seed < 12; seed++) {
|
||||
let at = null;
|
||||
play(pack, { strategy: 'sociable', seed, turns: 200,
|
||||
visit: ({ state }) => { if (state.stage === 'deputy' && at === null) at = state.turn; } });
|
||||
if (at) timings.push(at);
|
||||
}
|
||||
assert.equal(timings.length, 12, 'a sociable player should always get the offer');
|
||||
const typical = median(timings);
|
||||
assert.ok(typical >= 41 && typical < 90, `the star landed at turn ${typical}`);
|
||||
});
|
||||
|
||||
test('hard times is uncommon for a careful player and real for a careless one', () => {
|
||||
const countHardTimes = (strategy) => {
|
||||
let days = 0;
|
||||
play(pack, { strategy, seed: 'ht', turns: 200,
|
||||
visit: ({ turn }) => { if (turn.event.id === 'store_cut_off') days++; } });
|
||||
return days;
|
||||
};
|
||||
assert.equal(countHardTimes('thrifty'), 0, 'minding the money should keep you out of the book');
|
||||
assert.ok(countHardTimes('spendthrift') > 0, 'ignoring it should land you in it');
|
||||
});
|
||||
|
||||
test('the store ledger cannot spiral without limit', () => {
|
||||
let peak = 0;
|
||||
for (const strategy of ['spendthrift', 'desperate', 'random']) {
|
||||
for (let seed = 0; seed < 12; seed++) {
|
||||
play(pack, { strategy, seed, turns: 200,
|
||||
visit: ({ state }) => { peak = Math.max(peak, state.owed); } });
|
||||
}
|
||||
}
|
||||
// He stops carrying you at 120 and the largest single line is 32, so 152
|
||||
// plus a few stalled charges is the ceiling. Well above that means the gate
|
||||
// has stopped working.
|
||||
assert.ok(peak <= 220, `the ledger reached ${peak}; the credit ceiling is not holding`);
|
||||
|
||||
const charges = pack.upkeep.filter((u) => u.label?.startsWith('Store credit'));
|
||||
assert.ok(charges.length >= 3, 'the charge should step up as the ledger grows');
|
||||
});
|
||||
|
||||
test('a skill gate is never gated behind itself', () => {
|
||||
// The lesson Corporate Ladder's logs taught, applied here as a rule rather
|
||||
// than as a hope. Four of its options need negotiation 15 and several of its
|
||||
// largest negotiation gains sit behind that same gate, so a player who is
|
||||
// not deliberately chasing the skill bounces off the wall for thirty turns.
|
||||
//
|
||||
// The structural property that prevents it: for every skill threshold in the
|
||||
// pack, the growth available *without* passing that threshold must be able
|
||||
// to reach it, and repeatably — a single one-off award is not a route.
|
||||
const gates = new Map();
|
||||
for (const event of pack.events) {
|
||||
for (const option of event.options ?? []) {
|
||||
for (const requirement of option.requires ?? []) {
|
||||
if (!requirement.path.startsWith('skills.')) continue;
|
||||
const highest = gates.get(requirement.path) ?? 0;
|
||||
gates.set(requirement.path, Math.max(highest, requirement.value));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(gates.size > 0, 'the pack should gate something on a skill');
|
||||
|
||||
for (const [path, highest] of gates) {
|
||||
const start = pack.state[path].initial;
|
||||
let repeatable = 0;
|
||||
for (const event of pack.events) {
|
||||
if (event.once) continue;
|
||||
for (const option of event.options ?? []) {
|
||||
// Ungated on this skill: a route a player has open from turn one.
|
||||
if ((option.requires ?? []).some((r) => r.path === path)) continue;
|
||||
const gain = (option.effects ?? [])
|
||||
.filter((e) => e.path === path && e.op === 'add')
|
||||
.reduce((sum, e) => sum + e.value, 0);
|
||||
if (gain > 0) repeatable += gain;
|
||||
}
|
||||
}
|
||||
assert.ok(start + repeatable > highest,
|
||||
`${path} tops out at ${highest}, but only ${repeatable} is reachable without it`);
|
||||
}
|
||||
});
|
||||
|
||||
test('a player who wants a skill does not wait long for the first door', () => {
|
||||
// Measured rather than assumed: a player steering for a skill should be
|
||||
// through its lowest gate inside the first two runs of the stage.
|
||||
const firstGate = 12;
|
||||
for (const path of ['skills.nerve', 'skills.trade']) {
|
||||
const gain = (o) => (o.option.effects ?? [])
|
||||
.filter((e) => e.path === path && e.op === 'add')
|
||||
.reduce((sum, e) => sum + e.value, 0);
|
||||
|
||||
const times = [];
|
||||
for (let seed = 0; seed < 12; seed++) {
|
||||
let state = startGame(pack, { seed: `gate-${seed}` });
|
||||
let at = null;
|
||||
for (let t = 0; t < 120 && at === null; t++) {
|
||||
const turn = currentTurn(pack, state);
|
||||
const available = turn.options.filter((o) => o.available);
|
||||
const best = [...available].sort((a, b) => gain(b) - gain(a))[0];
|
||||
state = takeTurn(pack, state, best.option.id).state;
|
||||
const value = path.split('.').reduce((o, k) => o?.[k], state);
|
||||
if (value >= firstGate) at = state.turn;
|
||||
}
|
||||
times.push(at ?? Infinity);
|
||||
}
|
||||
const typical = median(times);
|
||||
assert.ok(typical <= 20,
|
||||
`${path} reached ${firstGate} at turn ${typical}; the first door is too far off`);
|
||||
}
|
||||
});
|
||||
|
||||
test('a deliberate player can put Dutch in their debt', () => {
|
||||
// The deepest relationship gate in the pack: `send_for_dutch` in the
|
||||
// marshal's office needs a favour banked two rungs earlier.
|
||||
const owed = (o) => (o.option.effects ?? [])
|
||||
.filter((e) => e.path === 'relationships.dutch.owes_you')
|
||||
.reduce((sum, e) => sum + e.value, 0);
|
||||
|
||||
let state = startGame(pack, { seed: 'dutch' });
|
||||
let reached = null;
|
||||
for (let t = 0; t < 60 && reached === null; t++) {
|
||||
if (state.relationships.dutch.owes_you >= 25) { reached = state.turn; break; }
|
||||
const turn = currentTurn(pack, state);
|
||||
const best = turn.options.filter((o) => o.available).sort((a, b) => owed(b) - owed(a))[0];
|
||||
state = takeTurn(pack, state, best.option.id).state;
|
||||
}
|
||||
assert.ok(reached && reached < 45, `Dutch's book opened at turn ${reached ?? 'never'}`);
|
||||
});
|
||||
+115
-4
@@ -10,9 +10,17 @@ import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { installFakeDom, byClass, firstByClass, byTag } from './fixtures/fake-dom.js';
|
||||
|
||||
/** Boot the app fresh against a clean fake DOM. */
|
||||
async function boot() {
|
||||
/**
|
||||
* Boot the app fresh against a clean fake DOM.
|
||||
*
|
||||
* `pack` is written to the remembered-pack slot before booting, which is how a
|
||||
* returning player lands back in a setting. Pass null to boot with nothing
|
||||
* remembered and nothing saved, which is the first-time player who gets the
|
||||
* setting picker instead of a turn.
|
||||
*/
|
||||
async function boot({ pack = 'corporateladder' } = {}) {
|
||||
const dom = installFakeDom();
|
||||
if (pack) dom.storage.setItem('theladder.pack.v1', pack);
|
||||
// A fresh module instance per test: the app holds module-level state.
|
||||
const { boot: bootApp } = await import(`../src/ui/app.js?t=${Math.random()}`);
|
||||
bootApp(dom.root);
|
||||
@@ -112,7 +120,7 @@ test('progress is written to local storage and restored on the next boot', async
|
||||
enabledOptions(dom.root)[0].click();
|
||||
firstByClass(dom.root, 'button--next').click();
|
||||
|
||||
const saved = JSON.parse(dom.storage.getItem('theladder.save.v1'));
|
||||
const saved = JSON.parse(dom.storage.getItem('theladder.save.v1.corporateladder'));
|
||||
assert.equal(saved.state.turn, 3);
|
||||
|
||||
// Boot again against the same storage: the run should resume, not restart.
|
||||
@@ -142,7 +150,7 @@ test('every played turn is logged, separately from the save', async () => {
|
||||
assert.ok(Array.isArray(entry.offered), 'the options offered are recorded');
|
||||
assert.equal(entry.rating, null, 'feedback starts empty');
|
||||
|
||||
const save = JSON.parse(dom.storage.getItem('theladder.save.v1'));
|
||||
const save = JSON.parse(dom.storage.getItem('theladder.save.v1.corporateladder'));
|
||||
assert.equal(save.state.history.length, 3);
|
||||
assert.ok(!JSON.stringify(save).includes('rating'), 'feedback is not mixed into the save');
|
||||
} finally {
|
||||
@@ -366,3 +374,106 @@ test('the ledger reads as English, and leaves bookkeeping out of it', async () =
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a first-time player is asked which setting to play', async () => {
|
||||
// Nothing saved and nothing remembered: the front door is the picker rather
|
||||
// than whichever pack happens to be first in the registry.
|
||||
const dom = await boot({ pack: null });
|
||||
try {
|
||||
assert.ok(firstByClass(dom.root, 'screen--packs'), 'the picker rendered');
|
||||
assert.match(text(dom.root), /Corporate Ladder/);
|
||||
assert.match(text(dom.root), /The Frontier/);
|
||||
assert.match(text(dom.root), /Not started/, 'a pack with no run says so');
|
||||
|
||||
const start = byClass(dom.root, 'button').find((b) => /Start here/.test(b.textContent));
|
||||
assert.ok(start, 'the picker offers a way in');
|
||||
start.click();
|
||||
assert.ok(firstByClass(dom.root, 'screen--turn'), 'choosing a setting starts it');
|
||||
} finally {
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('switching settings keeps both runs', async () => {
|
||||
// The defect this guards against: one global save slot, so the first switch
|
||||
// silently threw the other career away.
|
||||
const dom = await boot({ pack: 'corporateladder' });
|
||||
try {
|
||||
enabledOptions(dom.root)[0].click();
|
||||
byClass(dom.root, 'button').find((b) => /Next/.test(b.textContent))?.click();
|
||||
assert.match(text(dom.root), /Day 2/, 'a turn was played in the first setting');
|
||||
|
||||
byClass(dom.root, 'button').find((b) => /Change setting/.test(b.textContent)).click();
|
||||
const toFrontier = byClass(dom.root, 'button')
|
||||
.find((b) => /Start here|Go back to this run/.test(b.textContent));
|
||||
toFrontier.click();
|
||||
|
||||
assert.match(text(dom.root), /Hollis Vane/, 'the second setting is running');
|
||||
assert.match(text(dom.root), /Day 1/, 'and it started fresh');
|
||||
|
||||
// Both slots are on disk, independently.
|
||||
const corporate = JSON.parse(dom.storage.getItem('theladder.save.v1.corporateladder'));
|
||||
const frontier = JSON.parse(dom.storage.getItem('theladder.save.v1.frontier'));
|
||||
assert.equal(corporate.state.turn, 2, 'the first run is still where it was');
|
||||
assert.equal(frontier.state.turn, 1);
|
||||
assert.equal(corporate.state.meta.packId, 'corporateladder');
|
||||
assert.equal(frontier.state.meta.packId, 'frontier');
|
||||
|
||||
// And going back resumes rather than restarting.
|
||||
byClass(dom.root, 'button').find((b) => /Change setting/.test(b.textContent)).click();
|
||||
assert.match(text(dom.root), /Day 2, Mail Associate I, saved/,
|
||||
'the picker reports where the other run is');
|
||||
byClass(dom.root, 'button').find((b) => /Go back to this run/.test(b.textContent)).click();
|
||||
assert.match(text(dom.root), /Day 2/, 'the first career resumed where it was left');
|
||||
assert.match(text(dom.root), /Marlene Hobb/);
|
||||
} finally {
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a run saved before settings were selectable is not lost', async () => {
|
||||
// The old single slot, migrated on read. Someone mid-career when this
|
||||
// shipped should find their career, not a new one.
|
||||
const dom = installFakeDom();
|
||||
try {
|
||||
const { startGame } = await import('../src/engine/game.js');
|
||||
const { corporateLadder } = await import('../content/corporateladder/index.js');
|
||||
const legacy = startGame(corporateLadder, { seed: 'legacy' });
|
||||
legacy.turn = 61;
|
||||
dom.storage.setItem('theladder.save.v1',
|
||||
JSON.stringify({ format: 1, savedAt: null, state: legacy }));
|
||||
|
||||
const { boot: bootApp } = await import(`../src/ui/app.js?t=${Math.random()}`);
|
||||
bootApp(dom.root);
|
||||
|
||||
assert.ok(firstByClass(dom.root, 'screen--turn'), 'it booted into the run, not the picker');
|
||||
assert.match(text(dom.root), /Day 61/, 'the old career came back');
|
||||
} finally {
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('the header reads the second pack\'s display map, not the first\'s', async () => {
|
||||
// `display` is a per-pack contract with the interface, and a typo in it is
|
||||
// only visible at runtime. Corporate Ladder's version is covered above; this
|
||||
// is the same assertion against a pack with entirely different stat names.
|
||||
const dom = await boot({ pack: 'frontier' });
|
||||
try {
|
||||
const stats = text(firstByClass(dom.root, 'stats'));
|
||||
assert.match(stats, /Money/);
|
||||
assert.match(stats, /\$40/, 'money is formatted as money');
|
||||
assert.match(stats, /Nerve/);
|
||||
assert.match(stats, /Trade/);
|
||||
assert.match(stats, /Standing/);
|
||||
assert.doesNotMatch(stats, /Negotiation|Logistics/, 'no leakage from the other pack');
|
||||
assert.doesNotMatch(stats, /Owed at the store/, 'the ledger is hidden while it is zero');
|
||||
|
||||
assert.match(text(dom.root), /Cold Fork/, 'the people panel is named by the pack');
|
||||
assert.match(text(dom.root), /Hollis Vane/);
|
||||
assert.match(text(dom.root), /Marshal Ward/);
|
||||
assert.match(text(dom.root), /Hired Hand/, 'the stage title comes from the pack');
|
||||
assert.match(text(dom.root), /Stage day/, 'and so does the name of the day');
|
||||
} finally {
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
+30
-11
@@ -210,19 +210,38 @@ async function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
const runs = byRun(entries);
|
||||
console.log(`${entries.length} turns logged across ${runs.size} run${runs.size === 1 ? '' : 's'} ` +
|
||||
`(pack ${entries[0].pack_id} ${entries[0].pack_version ?? ''})`.trimEnd());
|
||||
for (const [seed, run] of runs) {
|
||||
const stages = [...new Set(run.map((e) => e.stage))];
|
||||
console.log(` seed ${seed}: ${run.length} turns, ${stages.join(' → ')}`);
|
||||
// One log can hold turns from more than one setting, and pooling them would
|
||||
// put a mailroom and a freight yard in the same repetition table. The same
|
||||
// rule as the run boundary in DECISIONS #30, a level up: every statistic
|
||||
// here is per pack, and a log is not one sequence.
|
||||
const byPack = new Map();
|
||||
for (const entry of entries) {
|
||||
const id = entry.pack_id ?? 'unknown';
|
||||
if (!byPack.has(id)) byPack.set(id, []);
|
||||
byPack.get(id).push(entry);
|
||||
}
|
||||
|
||||
reportRepetition(runs);
|
||||
reportChoices(entries);
|
||||
reportGates(entries);
|
||||
reportFeedback(entries);
|
||||
console.log('');
|
||||
if (byPack.size > 1) {
|
||||
console.log(`${entries.length} turns logged across ${byPack.size} settings.`);
|
||||
}
|
||||
|
||||
for (const [packId, packEntries] of byPack) {
|
||||
const runs = byRun(packEntries);
|
||||
console.log(
|
||||
`${packEntries.length} turns logged across ${runs.size} run${runs.size === 1 ? '' : 's'} ` +
|
||||
`(pack ${packId} ${packEntries[0].pack_version ?? ''})`.trimEnd(),
|
||||
);
|
||||
for (const [seed, run] of runs) {
|
||||
const stages = [...new Set(run.map((e) => e.stage))];
|
||||
console.log(` seed ${seed}: ${run.length} turns, ${stages.join(' → ')}`);
|
||||
}
|
||||
|
||||
reportRepetition(runs);
|
||||
reportChoices(packEntries);
|
||||
reportGates(packEntries);
|
||||
reportFeedback(packEntries);
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
|
||||
+5
-1
@@ -79,7 +79,11 @@ async function collect(entryPath, seen = new Map()) {
|
||||
const source = await readFile(entryPath, 'utf8');
|
||||
seen.set(entryPath, null); // reserve the slot before recursing
|
||||
|
||||
const specifiers = [...source.matchAll(/^[ \t]*import\s.*?from\s*['"]([^'"]+)['"];?[ \t]*$/gm)]
|
||||
// `[\s\S]*?` rather than `.*?`: a destructured import may be wrapped across
|
||||
// several lines, and `transform` below already rewrites those — a scan that
|
||||
// missed them produced a bundle with a __require for a module that was never
|
||||
// collected, which only failed when the bundled game was run.
|
||||
const specifiers = [...source.matchAll(/^[ \t]*import\s[\s\S]*?from\s*['"]([^'"]+)['"];?[ \t]*$/gm)]
|
||||
.map((match) => match[1])
|
||||
.filter((specifier) => specifier.startsWith('.'));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user