diff --git a/README.md b/README.md index dc5b8e6..b3e0aac 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/content/corporateladder/index.js b/content/corporateladder/index.js index f2fcaff..eb43d84 100644 --- a/content/corporateladder/index.js +++ b/content/corporateladder/index.js @@ -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: { diff --git a/content/frontier/characters.js b/content/frontier/characters.js new file mode 100644 index 0000000..39f3620 --- /dev/null +++ b/content/frontier/characters.js @@ -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; diff --git a/content/frontier/events/deputy.js b/content/frontier/events/deputy.js new file mode 100644 index 0000000..b66e7a9 --- /dev/null +++ b/content/frontier/events/deputy.js @@ -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; diff --git a/content/frontier/events/hard-times.js b/content/frontier/events/hard-times.js new file mode 100644 index 0000000..c3baaad --- /dev/null +++ b/content/frontier/events/hard-times.js @@ -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; diff --git a/content/frontier/events/index.js b/content/frontier/events/index.js new file mode 100644 index 0000000..cce288b --- /dev/null +++ b/content/frontier/events/index.js @@ -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; diff --git a/content/frontier/events/people.js b/content/frontier/events/people.js new file mode 100644 index 0000000..1c6f085 --- /dev/null +++ b/content/frontier/events/people.js @@ -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; diff --git a/content/frontier/events/promotion.js b/content/frontier/events/promotion.js new file mode 100644 index 0000000..952f1bf --- /dev/null +++ b/content/frontier/events/promotion.js @@ -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; diff --git a/content/frontier/events/routine.js b/content/frontier/events/routine.js new file mode 100644 index 0000000..af2e990 --- /dev/null +++ b/content/frontier/events/routine.js @@ -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; diff --git a/content/frontier/index.js b/content/frontier/index.js new file mode 100644 index 0000000..2fb53cf --- /dev/null +++ b/content/frontier/index.js @@ -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; diff --git a/content/index.js b/content/index.js new file mode 100644 index 0000000..a529356 --- /dev/null +++ b/content/index.js @@ -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; diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index e0997cc..498411f 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -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.`, 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. diff --git a/package.json b/package.json index 3bcbd00..28fb522 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/io/save.js b/src/io/save.js index ed919a9..918419c 100644 --- a/src/io/save.js +++ b/src/io/save.js @@ -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. */ diff --git a/src/ui/app.js b/src/ui/app.js index 6def63b..fd07f7a 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -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; } diff --git a/src/ui/screens/packs.js b/src/ui/screens/packs.js new file mode 100644 index 0000000..3e30320 --- /dev/null +++ b/src/ui/screens/packs.js @@ -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; diff --git a/src/ui/screens/turn.js b/src/ui/screens/turn.js index 5093492..01215c7 100644 --- a/src/ui/screens/turn.js +++ b/src/ui/screens/turn.js @@ -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'), ), ); } diff --git a/src/ui/styles.css b/src/ui/styles.css index 71fbe82..75e3c44 100644 --- a/src/ui/styles.css +++ b/src/ui/styles.css @@ -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; } diff --git a/test/build.test.js b/test/build.test.js index 7ff8a0f..15f8008 100644 --- a/test/build.test.js +++ b/test/build.test.js @@ -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/); diff --git a/test/conformance.test.js b/test/conformance.test.js new file mode 100644 index 0000000..22d0d8d --- /dev/null +++ b/test/conformance.test.js @@ -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], + ); + }); + } +} diff --git a/test/content.test.js b/test/content.test.js index 61f5637..70580d8 100644 --- a/test/content.test.js +++ b/test/content.test.js @@ -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`); - } -}); diff --git a/test/frontier.test.js b/test/frontier.test.js new file mode 100644 index 0000000..f5c5044 --- /dev/null +++ b/test/frontier.test.js @@ -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'}`); +}); diff --git a/test/ui.test.js b/test/ui.test.js index bf8a908..87a4d93 100644 --- a/test/ui.test.js +++ b/test/ui.test.js @@ -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(); + } +}); diff --git a/tools/analyze-log.js b/tools/analyze-log.js index 8acba7f..cd3b31c 100644 --- a/tools/analyze-log.js +++ b/tools/analyze-log.js @@ -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) => { diff --git a/tools/build.js b/tools/build.js index d7ec36d..4012dc6 100644 --- a/tools/build.js +++ b/tools/build.js @@ -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('.'));