Compare commits
12
Commits
6c7d31371e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3108319a2b | ||
|
|
112e558dbb | ||
|
|
d84ae495f4 | ||
|
|
5d645e6452 | ||
|
|
7f20be83e8 | ||
|
|
4957c24e31 | ||
|
|
5c1784d990 | ||
|
|
76072edf4e | ||
|
|
4cab9fc41e | ||
|
|
e482f07157 | ||
|
|
1d4ac0fdbd | ||
|
|
cc59b81155 |
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Exported playtest logs are somebody's real session, including whatever
|
||||||
|
# they typed into the feedback box. Kept local; see logs/README.md.
|
||||||
|
logs/*
|
||||||
|
!logs/README.md
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
# The Ladder
|
||||||
|
|
||||||
|
A turn-based career simulation game that runs entirely in the browser. You
|
||||||
|
start at the bottom of an organisation and play turns — a day at a time —
|
||||||
|
making choices that move your money, your skills and your standing with the
|
||||||
|
people around you. There is no win state and no game over: a bad run means
|
||||||
|
worse options, not an ending, and you can stop whenever you like and read the
|
||||||
|
retrospective.
|
||||||
|
|
||||||
|
The engine knows nothing about offices, mailrooms or bosses. A **setting is a
|
||||||
|
content pack** — characters, events, choices, stat names and tuning, all data.
|
||||||
|
Two ship: **Corporate Ladder**, a modern office and a mailroom, and **The
|
||||||
|
Frontier**, a freight yard at the end of a stage line. They share every line of
|
||||||
|
engine code and no content at all.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Engine, interface and two content packs, all built and tested, and played in a
|
||||||
|
real browser.
|
||||||
|
|
||||||
|
| Piece | State |
|
||||||
|
| --- | --- |
|
||||||
|
| Path-addressed state, conditions, effects | done |
|
||||||
|
| Seeded RNG, save/resume | done |
|
||||||
|
| Event selection, option gating | done |
|
||||||
|
| Turn loop, per-turn upkeep | done |
|
||||||
|
| Content validator + reachability tests | done |
|
||||||
|
| Corporate Ladder pack — 32 events, two tiers | done |
|
||||||
|
| Turn screen, retrospective, feedback widget | done |
|
||||||
|
| Local-storage save, file export/import | done |
|
||||||
|
| Single-file build | done |
|
||||||
|
| Calendar, scheduled wages and rent | done |
|
||||||
|
| Promotion from the mailroom to Dispatch | done |
|
||||||
|
| Repetition analytics (`npm run analyze`) | done |
|
||||||
|
| Version and build shown on screen | done |
|
||||||
|
| Keyboard play (Enter / Tab) | done |
|
||||||
|
| Second content pack — The Frontier, 37 events, two tiers | done |
|
||||||
|
| Setting picker, one save slot per pack | done |
|
||||||
|
| Pack conformance suite (every pack, every check) | done |
|
||||||
|
| Played in a real browser | Corporate Ladder since 2026-09-09; The Frontier 2026-09-15 (112 turns, 0.3.3) |
|
||||||
|
|
||||||
|
The first playtest confirmed the interface, saves and the exported log all work
|
||||||
|
in a real browser. Its findings — an unexplained daily drain, no sense of the
|
||||||
|
week, and an economy nobody could get ahead in — are what the calendar and the
|
||||||
|
retuned economy are for.
|
||||||
|
|
||||||
|
Note that the development machine has no browser that can render a page, so the
|
||||||
|
UI tests drive the real modules against a minimal fake DOM. That covers wiring,
|
||||||
|
not layout: **styling and dark mode are only ever verified by a human opening
|
||||||
|
it.**
|
||||||
|
|
||||||
|
## Playing it
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run serve # then open http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
The game plays from the keyboard: Enter takes the focused option, Enter again
|
||||||
|
moves to the next day, and Tab / Shift+Tab pick a different option. The top of
|
||||||
|
every screen names the game, the pack, your current title, and the build —
|
||||||
|
`0.3.4 · dev` when running from source, and the commit it came from when built.
|
||||||
|
|
||||||
|
A server is needed during development because browsers refuse ES module imports
|
||||||
|
over `file://`. If port 8080 is taken, run `python3 -m http.server <port>`
|
||||||
|
instead.
|
||||||
|
|
||||||
|
To hand the game to someone else:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run build # writes dist/theladder.html
|
||||||
|
```
|
||||||
|
|
||||||
|
That is one self-contained file with the stylesheet and every module inlined.
|
||||||
|
It plays by double-clicking it — no server, no network, nothing installed.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm test # 162 tests: engine, both packs, UI wiring, and the build
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests are in three layers. `test/conformance.test.js` runs over **every pack in
|
||||||
|
the registry** and asserts only what is true of a pack because it is a pack —
|
||||||
|
validity, no dead content, the declared economic baseline, variety, replay from
|
||||||
|
seed, a way out of every event. `test/content.test.js` and
|
||||||
|
`test/frontier.test.js` hold each pack to its own tuning. The engine suite runs
|
||||||
|
against a fixture pack, so tuning a setting can never break it.
|
||||||
|
|
||||||
|
To see what a playtest actually did — turns against distinct situations, how
|
||||||
|
often each recurred, which locked gates players kept meeting, and anything they
|
||||||
|
typed into the feedback box:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run analyze # the newest export in logs/
|
||||||
|
npm run analyze -- <file> # a specific one
|
||||||
|
```
|
||||||
|
|
||||||
|
Drop exports into `logs/`. They are gitignored — a log is a record of what a
|
||||||
|
real person did, including anything they typed into the feedback box.
|
||||||
|
|
||||||
|
There are no runtime dependencies and no build step for development. Node is
|
||||||
|
used only to run the tests, the single-file build, and the log analyser.
|
||||||
|
|
||||||
|
## Where things stand
|
||||||
|
|
||||||
|
The MVP is complete and has survived four playtests, and a second setting exists
|
||||||
|
to prove the engine/content split holds. Adding it required **no engine change**
|
||||||
|
— and three interface changes, two of which were latent defects a second pack
|
||||||
|
made visible (a single global save slot, and the one place the UI still named a
|
||||||
|
setting). See DECISIONS.md #34.
|
||||||
|
|
||||||
|
The Frontier had its first human session on 2026-09-15: 112 turns, promoted on
|
||||||
|
turn 44, both lifestyle purchases taken and the standing charge firing as
|
||||||
|
designed. What it found — an overcharging debt repayment in both packs, wages no
|
||||||
|
one could recognise, a sale barn offering what the player already owned, and an
|
||||||
|
office stage too thin — is what 0.3.4 fixes. The repayment fix needed one small
|
||||||
|
engine addition: an effect's amount may be read from state. See DECISIONS #38–40.
|
||||||
|
|
||||||
|
Open questions, in the order they are likely to matter:
|
||||||
|
|
||||||
|
- **0.3.4 has not been played.** Everything in it is asserted by tests and
|
||||||
|
archetype runs, which is the evidence that was not enough before. In simulation
|
||||||
|
promoted Frontier runs now end with about twice the money they did ($164 against
|
||||||
|
$88) and less standing, and why is not yet isolated. The next Frontier session
|
||||||
|
should watch whether money stops mattering at the top. See DECISIONS #40.
|
||||||
|
- **Dispatch is twelve events against the mailroom's eighteen.** It passes the
|
||||||
|
variety guard, but it has had far less play than the mailroom. The Frontier's
|
||||||
|
office had the same thinness in a real session; Dispatch may too.
|
||||||
|
- **A third tier in either pack.** Two rungs is what both settings have. The
|
||||||
|
split is now proven across settings; it is not proven across a long ladder. The
|
||||||
|
Frontier has now been played once, so this is the designer's call to open.
|
||||||
|
|
||||||
|
Settled, and not open: Corporate Ladder's negotiation gate stays as it is, and
|
||||||
|
the feedback widget stays as it is (DECISIONS #35). Money mattering late is
|
||||||
|
built rather than open (DECISIONS #36).
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
src/engine/ the game engine — imports nothing from content/, or from src/ui/
|
||||||
|
paths.js dotted-path get/set over state
|
||||||
|
conditions.js generic predicates: { path, op, value }, all/any/not
|
||||||
|
effects.js generic mutations: add/set/push/remove, with clamping
|
||||||
|
state.js pack -> schema and initial state
|
||||||
|
select.js which event fires this turn, which options are available
|
||||||
|
game.js the turn loop
|
||||||
|
rng.js seeded, serializable RNG
|
||||||
|
validate.js load-time content checking
|
||||||
|
src/ui/ screens, formatting and the app controller
|
||||||
|
app.js the only module that knows there is more than one pack
|
||||||
|
screens/ the turn screen, the retrospective, the setting picker
|
||||||
|
format.js state paths and conditions rendered as English
|
||||||
|
src/io/ local-storage save (one slot per pack), export/import, feedback log
|
||||||
|
content/
|
||||||
|
index.js the pack registry — the only list of settings
|
||||||
|
corporateladder/ a modern office and a mailroom
|
||||||
|
frontier/ a freight yard at the end of a stage line
|
||||||
|
tools/build.js flattens everything into one self-contained HTML file
|
||||||
|
test/ engine tests against a fixture pack; conformance, per-pack, UI
|
||||||
|
docs/ DECISIONS.md — why things are the way they are
|
||||||
|
Planning/ the original design documents
|
||||||
|
```
|
||||||
|
|
||||||
|
**The rule that keeps this honest: nothing in `src/engine/` may import from
|
||||||
|
`content/`.** The engine names no stat, no character and no setting of its own.
|
||||||
|
|
||||||
|
## How content works
|
||||||
|
|
||||||
|
A pack declares what state exists and what it starts at, the relationship stats
|
||||||
|
every character carries, the characters, and the events. Everything else falls
|
||||||
|
out of that.
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default {
|
||||||
|
id: 'corporateladder',
|
||||||
|
startingStage: 'mailroom',
|
||||||
|
|
||||||
|
state: {
|
||||||
|
'money': { initial: 1200, min: -5000, max: 1000000 },
|
||||||
|
'skills.negotiation': { initial: 1, min: 0, max: 100 },
|
||||||
|
'flags.took_loan': { initial: false },
|
||||||
|
},
|
||||||
|
|
||||||
|
// Expanded per character into relationships.<id>.<stat>, so adding a
|
||||||
|
// character adds its relationship state automatically.
|
||||||
|
relationshipStats: {
|
||||||
|
likes_you: { initial: 50, min: 0, max: 100 },
|
||||||
|
fears_you: { initial: 50, min: 0, max: 100 },
|
||||||
|
wants_to_help_you: { initial: 50, min: 0, max: 100 },
|
||||||
|
},
|
||||||
|
|
||||||
|
characters: [
|
||||||
|
{ id: 'boss', name: '...', role_type: 'boss', description: '...' },
|
||||||
|
],
|
||||||
|
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
id: 'mail_run',
|
||||||
|
stage: 'mailroom',
|
||||||
|
weight: 100, // relative likelihood among eligible events
|
||||||
|
description: 'A cart of mail and two hours to move it.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'hustle',
|
||||||
|
label: 'Get it done fast.',
|
||||||
|
requires: [{ path: 'skills.negotiation', op: '>=', value: 3 }],
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: 40 },
|
||||||
|
{ path: 'relationships.boss.likes_you', op: 'add', value: 3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
An event may also carry `once: true`, a `cooldown` in turns, and `requires`
|
||||||
|
conditions of its own. There is no separate notion of a random event: every
|
||||||
|
turn draws from the whole eligible pool by weight.
|
||||||
|
|
||||||
|
Conditions may read engine bookkeeping (`turn`, `stage`, `seen.<eventId>`,
|
||||||
|
`lastSeen.<eventId>`) as well as pack state. Effects may not write it.
|
||||||
|
|
||||||
|
A pack also declares `upkeep` — a list of `{ label, requires?, effects }`
|
||||||
|
applied at the end of a turn, after the choice. Adding `every` and `offset`
|
||||||
|
makes an entry periodic rather than daily: Corporate Ladder pays wages on
|
||||||
|
alternate Fridays (`{ every: 14, offset: 4 }`), charges rent every Monday, and
|
||||||
|
adds a loan service charge that steps up as the debt grows.
|
||||||
|
|
||||||
|
A pack may also declare a `calendar`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
calendar: {
|
||||||
|
cycleLength: 7,
|
||||||
|
cycleName: 'Week',
|
||||||
|
unitNames: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'],
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
The engine derives `calendar.index`, `calendar.cycle` and `calendar.name` from
|
||||||
|
the turn number before each event is drawn, so content can require a Friday and
|
||||||
|
the interface can title the turn. Declare no calendar and there is no week.
|
||||||
|
|
||||||
|
Run `validatePack(pack)` when loading in development — it catches undeclared
|
||||||
|
paths, unknown characters, unusable ids, unreachable events and stages with no
|
||||||
|
fallback event, all of which otherwise fail silently mid-playthrough.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// The mailroom, as staffed.
|
||||||
|
//
|
||||||
|
// `description` is pure colour — the engine never reads it. What the engine
|
||||||
|
// does read is `startingRelationship`, where a character departs from the
|
||||||
|
// pack's defaults: nobody fears a new hire, and Trevor started two weeks
|
||||||
|
// earlier and has priced that in.
|
||||||
|
|
||||||
|
export const characters = [
|
||||||
|
{
|
||||||
|
id: 'marlene',
|
||||||
|
name: 'Marlene Hobb',
|
||||||
|
title: 'Mailroom Supervisor',
|
||||||
|
role_type: 'boss',
|
||||||
|
description:
|
||||||
|
'Has worked here nineteen years. Refers to the building as "the plant." ' +
|
||||||
|
'Keeps a laminated card of the org chart, updated in pen.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'trevor',
|
||||||
|
name: 'Trevor Pike',
|
||||||
|
title: 'Mail Associate II',
|
||||||
|
role_type: 'rival',
|
||||||
|
description:
|
||||||
|
'Arrived two weeks before you and has never once let that go. ' +
|
||||||
|
'Photographs his lunch.',
|
||||||
|
startingRelationship: { likes_you: 35, wants_to_help_you: 25 },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default characters;
|
||||||
@@ -0,0 +1,549 @@
|
|||||||
|
// Dispatch: the second tier.
|
||||||
|
//
|
||||||
|
// Same mechanics, different pressure. The mailroom's problem was money; up here
|
||||||
|
// the money is fine and the problem is that other people's days now depend on
|
||||||
|
// yours — including the mailroom's, which you used to be.
|
||||||
|
//
|
||||||
|
// The first two events carry no conditions, no cooldown and no `once`, so this
|
||||||
|
// stage has a floor of its own. Trevor's two chains are mutually exclusive
|
||||||
|
// branches on whether you put him up for your old job.
|
||||||
|
|
||||||
|
export const dispatch = [
|
||||||
|
{
|
||||||
|
id: 'dispatch_first_day',
|
||||||
|
stage: 'dispatch',
|
||||||
|
weight: 99999,
|
||||||
|
once: true,
|
||||||
|
description:
|
||||||
|
'The Dispatch desk has three phones and a laminated card taped to it, ' +
|
||||||
|
'listing what to do when each one rings. The handwriting is Marlene\'s.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'read_card',
|
||||||
|
label: 'Read the card.',
|
||||||
|
result: 'It is nineteen years of somebody else\'s hard-won afternoons, in eleven lines.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.logistics', op: 'add', value: 3 },
|
||||||
|
{ path: 'relationships.marlene.likes_you', op: 'add', value: 2 },
|
||||||
|
{ path: 'notable', op: 'push', value: 'Found Marlene\'s handwriting taped to the Dispatch desk.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'answer_everything',
|
||||||
|
label: 'Answer all three phones and work it out.',
|
||||||
|
result: 'By four o\'clock you have worked out what six of the eleven lines meant.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.logistics', op: 'add', value: 2 },
|
||||||
|
{ path: 'skills.negotiation', op: 'add', value: 2 },
|
||||||
|
{ path: 'reputation', op: 'add', value: -2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'take_it_slow',
|
||||||
|
label: 'Let the phones ring and learn the board first.',
|
||||||
|
result: 'Two callers give up. The board, however, you now understand.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.logistics', op: 'add', value: 4 },
|
||||||
|
{ path: 'reputation', op: 'add', value: -3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'routing_board',
|
||||||
|
stage: 'dispatch',
|
||||||
|
weight: 70,
|
||||||
|
description:
|
||||||
|
'Two runs are booked against the same van at the same hour. Both floors ' +
|
||||||
|
'have written "URGENT" on the docket, one of them twice.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'triage',
|
||||||
|
label: 'Work out which one is actually urgent.',
|
||||||
|
result: 'Neither. You send the van to a third job nobody had flagged at all.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.logistics', op: 'add', value: 3 },
|
||||||
|
{ path: 'reputation', op: 'add', value: 3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'split_van',
|
||||||
|
label: 'Split the run and make both floors wait half as long.',
|
||||||
|
result: 'Both floors complain about the half they waited. Neither escalates it.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.logistics', op: 'add', value: 1 },
|
||||||
|
{ path: 'reputation', op: 'add', value: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'first_come',
|
||||||
|
label: 'Whoever wrote URGENT twice can go second.',
|
||||||
|
result: 'They notice. They are the sort of floor that notices.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'reputation', op: 'add', value: -2 },
|
||||||
|
{ path: 'skills.logistics', op: 'add', value: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'late_freight',
|
||||||
|
stage: 'dispatch',
|
||||||
|
weight: 55,
|
||||||
|
description:
|
||||||
|
'A freight job has come in past the cutoff. Taking it means somebody stays, ' +
|
||||||
|
'and the somebody is either you or a person you now have the standing to ask.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'stay_yourself',
|
||||||
|
label: 'Stay and do it yourself.',
|
||||||
|
result: 'It takes ninety minutes. Nobody sees it, which is most of the job now.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: 90 },
|
||||||
|
{ path: 'skills.logistics', op: 'add', value: 1 },
|
||||||
|
{ path: 'reputation', op: 'add', value: 2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ask_the_floor',
|
||||||
|
label: 'Ask the mailroom to cover it.',
|
||||||
|
requires: [{ path: 'skills.negotiation', op: '>=', value: 25 }],
|
||||||
|
result:
|
||||||
|
'They cover it. You remember precisely how this felt from the other end, ' +
|
||||||
|
'and ask anyway.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.negotiation', op: 'add', value: 2 },
|
||||||
|
{ path: 'reputation', op: 'add', value: -1 },
|
||||||
|
{ path: 'relationships.trevor.likes_you', op: 'add', value: -4 },
|
||||||
|
{ path: 'relationships.trevor.fears_you', op: 'add', value: 3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bounce_it',
|
||||||
|
label: 'Send it back as past cutoff.',
|
||||||
|
result: 'The cutoff exists. You have simply never seen anyone use it before.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'reputation', op: 'add', value: -3 },
|
||||||
|
{ path: 'skills.negotiation', op: 'add', value: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'mailroom_calls',
|
||||||
|
stage: 'dispatch',
|
||||||
|
weight: 60,
|
||||||
|
cooldown: 4,
|
||||||
|
description:
|
||||||
|
'The mailroom is short-handed and the bins are backing up. From up here it ' +
|
||||||
|
'is a number on a board. You know exactly what it is down there.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'go_down',
|
||||||
|
label: 'Go down and sort bins for an hour.',
|
||||||
|
result:
|
||||||
|
'You are a coordinator sorting bins. Two people find this funny and one ' +
|
||||||
|
'finds it reassuring.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.logistics', op: 'add', value: 2 },
|
||||||
|
{ path: 'reputation', op: 'add', value: 3 },
|
||||||
|
{ path: 'relationships.trevor.likes_you', op: 'add', value: 5 },
|
||||||
|
{ path: 'relationships.marlene.likes_you', op: 'add', value: 4 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'send_help',
|
||||||
|
label: 'Pull someone off a run to help them.',
|
||||||
|
result: 'The run is late. The bins are not. You will hear about the run.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.logistics', op: 'add', value: 1 },
|
||||||
|
{ path: 'reputation', op: 'add', value: -1 },
|
||||||
|
{ path: 'relationships.trevor.wants_to_help_you', op: 'add', value: 4 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'leave_them',
|
||||||
|
label: 'It is a number on a board.',
|
||||||
|
result: 'The number goes down by itself, eventually, the way it always did.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'relationships.trevor.likes_you', op: 'add', value: -5 },
|
||||||
|
{ path: 'relationships.marlene.likes_you', op: 'add', value: -3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'trevor_promoted',
|
||||||
|
stage: 'dispatch',
|
||||||
|
weight: 55,
|
||||||
|
cooldown: 5,
|
||||||
|
requires: [{ path: 'flags.recommended_trevor', op: '==', value: true }],
|
||||||
|
description:
|
||||||
|
'Trevor runs the mailroom now and has opinions about how it was run before. ' +
|
||||||
|
'Some of the opinions are about you. All of them are correct.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'concede',
|
||||||
|
label: 'Tell him he is right.',
|
||||||
|
result: 'He is so unprepared for this that he agrees with you about something else.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'relationships.trevor.likes_you', op: 'add', value: 6 },
|
||||||
|
{ path: 'relationships.trevor.wants_to_help_you', op: 'add', value: 4 },
|
||||||
|
{ path: 'skills.negotiation', op: 'add', value: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'back_him',
|
||||||
|
label: 'Back him upstairs when it matters.',
|
||||||
|
requires: [{ path: 'reputation', op: '>=', value: 55 }],
|
||||||
|
result: 'His changes go through. He knows exactly why they went through.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'relationships.trevor.wants_to_help_you', op: 'add', value: 10 },
|
||||||
|
{ path: 'reputation', op: 'add', value: 2 },
|
||||||
|
{ path: 'notable', op: 'push', value: 'Backed Trevor\'s changes to the mailroom.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pull_rank',
|
||||||
|
label: 'Remind him whose desk the board is on.',
|
||||||
|
result: 'It works. It works the way a door works when you take it off its hinges.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'relationships.trevor.fears_you', op: 'add', value: 8 },
|
||||||
|
{ path: 'relationships.trevor.likes_you', op: 'add', value: -8 },
|
||||||
|
{ path: 'reputation', op: 'add', value: -2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'trevor_passed_over',
|
||||||
|
stage: 'dispatch',
|
||||||
|
weight: 55,
|
||||||
|
cooldown: 5,
|
||||||
|
requires: [{ path: 'flags.recommended_trevor', op: '==', value: false }],
|
||||||
|
description:
|
||||||
|
'Trevor is still in the mailroom and has started addressing you by your ' +
|
||||||
|
'full title. He does it in front of people. He does it kindly, which is worse.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'address_it',
|
||||||
|
label: 'Say the thing out loud.',
|
||||||
|
requires: [{ path: 'skills.negotiation', op: '>=', value: 25 }],
|
||||||
|
result:
|
||||||
|
'He denies it, then stops doing it. Both of you agree never to refer ' +
|
||||||
|
'to the conversation again.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.negotiation', op: 'add', value: 3 },
|
||||||
|
{ path: 'relationships.trevor.likes_you', op: 'add', value: 6 },
|
||||||
|
{ path: 'relationships.trevor.fears_you', op: 'add', value: 2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'find_him_something',
|
||||||
|
label: 'Find him something worth doing.',
|
||||||
|
result:
|
||||||
|
'You give him the freight schedule, which is a real job. He is careful ' +
|
||||||
|
'with it, and the title stops.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'relationships.trevor.likes_you', op: 'add', value: 8 },
|
||||||
|
{ path: 'relationships.trevor.wants_to_help_you', op: 'add', value: 6 },
|
||||||
|
{ path: 'reputation', op: 'add', value: 2 },
|
||||||
|
{ path: 'notable', op: 'push', value: 'Found Trevor something worth doing.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'let_it_run',
|
||||||
|
label: 'Let him have it.',
|
||||||
|
result: 'He has it. It does not appear to be making him any happier.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'relationships.trevor.likes_you', op: 'add', value: -3 },
|
||||||
|
{ path: 'reputation', op: 'add', value: -1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'marlene_upstairs',
|
||||||
|
stage: 'dispatch',
|
||||||
|
weight: 45,
|
||||||
|
cooldown: 6,
|
||||||
|
description:
|
||||||
|
'Marlene comes up with a docket she could have sent. She looks at the three ' +
|
||||||
|
'phones for slightly too long before she says anything.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'ask_her',
|
||||||
|
label: 'Ask whether she ever wanted this desk.',
|
||||||
|
result:
|
||||||
|
'She says nobody offered, which is not the same as no. Then she asks ' +
|
||||||
|
'whether the third-floor bins have gone up.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'relationships.marlene.likes_you', op: 'add', value: 6 },
|
||||||
|
{ path: 'relationships.marlene.wants_to_help_you', op: 'add', value: 4 },
|
||||||
|
{ path: 'notable', op: 'push', value: 'Asked Marlene whether she ever wanted the Dispatch desk.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'take_the_docket',
|
||||||
|
label: 'Take the docket and get on with it.',
|
||||||
|
result: 'She goes back down. The card is still taped to the desk.',
|
||||||
|
effects: [{ path: 'skills.logistics', op: 'add', value: 1 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'put_her_up',
|
||||||
|
label: 'Put her name up for the next post that opens.',
|
||||||
|
requires: [{ path: 'reputation', op: '>=', value: 60 }],
|
||||||
|
result:
|
||||||
|
'She does not thank you. Two days later there is a second card taped ' +
|
||||||
|
'to the desk, with the four lines she had left out.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'relationships.marlene.likes_you', op: 'add', value: 10 },
|
||||||
|
{ path: 'relationships.marlene.wants_to_help_you', op: 'add', value: 10 },
|
||||||
|
{ path: 'reputation', op: 'add', value: 3 },
|
||||||
|
{ path: 'notable', op: 'push', value: 'Put Marlene up for the next post that opened.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'dispatch_overtime',
|
||||||
|
stage: 'dispatch',
|
||||||
|
weight: 40,
|
||||||
|
cooldown: 6,
|
||||||
|
description:
|
||||||
|
'The quarterly stocktake needs a coordinator on site for the weekend. It ' +
|
||||||
|
'pays coordinator rates, which are new to you.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'take_it',
|
||||||
|
label: 'Take the weekend.',
|
||||||
|
result: 'Two days, and the rate is genuinely different. You check the slip twice.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: 320 },
|
||||||
|
{ path: 'skills.logistics', op: 'add', value: 2 },
|
||||||
|
{ path: 'reputation', op: 'add', value: 2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'send_trevor',
|
||||||
|
label: 'Offer it to the mailroom instead.',
|
||||||
|
result:
|
||||||
|
'Trevor takes it. He has never been paid that for a weekend and it ' +
|
||||||
|
'shows on Monday.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'relationships.trevor.likes_you', op: 'add', value: 8 },
|
||||||
|
{ path: 'relationships.trevor.wants_to_help_you', op: 'add', value: 5 },
|
||||||
|
{ path: 'reputation', op: 'add', value: 2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pass',
|
||||||
|
label: 'Pass.',
|
||||||
|
result: 'Somebody from Finance does it and gets it wrong in an interesting way.',
|
||||||
|
effects: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'van_breakdown',
|
||||||
|
stage: 'dispatch',
|
||||||
|
weight: 55,
|
||||||
|
cooldown: 4,
|
||||||
|
description:
|
||||||
|
'The long-wheelbase van is off the road until Thursday. Everything booked ' +
|
||||||
|
'against it is still booked against it, and the board does not know that yet.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'rebuild_board',
|
||||||
|
label: 'Rebuild the day around what you have.',
|
||||||
|
result:
|
||||||
|
'Four jobs move, one is dropped, and nobody upstairs ever learns the ' +
|
||||||
|
'van was off the road.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.logistics', op: 'add', value: 4 },
|
||||||
|
{ path: 'reputation', op: 'add', value: 3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hire_one',
|
||||||
|
label: 'Hire one in for the day.',
|
||||||
|
requires: [{ path: 'money', op: '>=', value: 200 }],
|
||||||
|
result: 'It costs what it costs. The day runs as though nothing happened.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: -180 },
|
||||||
|
{ path: 'reputation', op: 'add', value: 2 },
|
||||||
|
{ path: 'skills.negotiation', op: 'add', value: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tell_them',
|
||||||
|
label: 'Tell every floor their job is late and why.',
|
||||||
|
result:
|
||||||
|
'Two floors are fine about it. The third writes an email with a ' +
|
||||||
|
'subject line that uses the word "again".',
|
||||||
|
effects: [
|
||||||
|
{ path: 'reputation', op: 'add', value: -2 },
|
||||||
|
{ path: 'skills.negotiation', op: 'add', value: 2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'new_starter',
|
||||||
|
stage: 'dispatch',
|
||||||
|
weight: 50,
|
||||||
|
cooldown: 5,
|
||||||
|
description:
|
||||||
|
'There is somebody new in the mailroom. They have been handed a laminated ' +
|
||||||
|
'card with the org chart on it, and their name is not on it either.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'walk_them_through',
|
||||||
|
label: 'Go down and walk them through it.',
|
||||||
|
result:
|
||||||
|
'You explain the card. Then you explain it again, because that is how ' +
|
||||||
|
'it works and you know that now.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'reputation', op: 'add', value: 3 },
|
||||||
|
{ path: 'relationships.marlene.likes_you', op: 'add', value: 4 },
|
||||||
|
{ path: 'skills.negotiation', op: 'add', value: 1 },
|
||||||
|
{ path: 'notable', op: 'push', value: 'Walked a new starter through the org chart.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'give_advice',
|
||||||
|
label: 'Tell them what you wish somebody had told you.',
|
||||||
|
requires: [{ path: 'skills.negotiation', op: '>=', value: 30 }],
|
||||||
|
result:
|
||||||
|
'They write it down, which nobody did for you. Whether it helps is ' +
|
||||||
|
'not something you will find out.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'reputation', op: 'add', value: 2 },
|
||||||
|
{ path: 'skills.negotiation', op: 'add', value: 2 },
|
||||||
|
{ path: 'notable', op: 'push', value: 'Told a new starter what nobody had told you.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'leave_them_to_it',
|
||||||
|
label: 'Leave them to work it out. Everyone else did.',
|
||||||
|
result: 'They work it out. It takes them eleven days.',
|
||||||
|
effects: [{ path: 'reputation', op: 'add', value: -1 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'finance_query',
|
||||||
|
stage: 'dispatch',
|
||||||
|
weight: 45,
|
||||||
|
cooldown: 6,
|
||||||
|
description:
|
||||||
|
'Finance has queried the courier spend. The query is three lines long and ' +
|
||||||
|
'took somebody a whole morning to write.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'answer_properly',
|
||||||
|
label: 'Answer it properly, with the numbers.',
|
||||||
|
result:
|
||||||
|
'The numbers are fine. Finance says so in one line, which took them ' +
|
||||||
|
'a whole afternoon.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.logistics', op: 'add', value: 2 },
|
||||||
|
{ path: 'reputation', op: 'add', value: 4 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'push_back',
|
||||||
|
label: 'Ask them what they think the alternative is.',
|
||||||
|
requires: [{ path: 'skills.negotiation', op: '>=', value: 35 }],
|
||||||
|
result:
|
||||||
|
'There is no alternative. Establishing this in writing turns out to ' +
|
||||||
|
'be worth more than the money was.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.negotiation', op: 'add', value: 3 },
|
||||||
|
{ path: 'reputation', op: 'add', value: 5 },
|
||||||
|
{ path: 'notable', op: 'push', value: 'Won an argument with Finance in writing.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cut_the_spend',
|
||||||
|
label: 'Cut the spend and let the service slip.',
|
||||||
|
result: 'Finance is delighted. Three floors are not, and one of them notices why.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: 60 },
|
||||||
|
{ path: 'reputation', op: 'add', value: -4 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
// The cost of standing, offered rather than imposed. Buying nothing is
|
||||||
|
// always playable and always cheaper; it is Dispatch's weekly "looking the
|
||||||
|
// part" charge that makes it a real decision instead of a shop.
|
||||||
|
id: 'the_car_lot',
|
||||||
|
stage: 'dispatch',
|
||||||
|
weight: 45,
|
||||||
|
cooldown: 6,
|
||||||
|
requires: [{ path: 'lifestyle', op: '<', value: 2 }],
|
||||||
|
description:
|
||||||
|
'Marlene mentions, not quite to you, that the coordinator before last ' +
|
||||||
|
'drove something with a roof rack, and that people noticed when he ' +
|
||||||
|
'stopped. There is a lot on Ninth with a sign about payments.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'buy_the_car',
|
||||||
|
label: 'Put money down on the car.',
|
||||||
|
requires: [
|
||||||
|
{ path: 'lifestyle', op: '==', value: 0 },
|
||||||
|
{ path: 'money', op: '>=', value: 450 },
|
||||||
|
],
|
||||||
|
result:
|
||||||
|
'It is four years old and the radio only gets one station, and on ' +
|
||||||
|
'Monday two people who have never spoken to you ask about it.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: -450 },
|
||||||
|
{ path: 'lifestyle', op: 'set', value: 1 },
|
||||||
|
{ path: 'reputation', op: 'add', value: 3 },
|
||||||
|
{ path: 'notable', op: 'push', value: 'Bought a car with a roof rack.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'trade_up',
|
||||||
|
label: 'Trade up, and take the lease on the better side of the park.',
|
||||||
|
requires: [
|
||||||
|
{ path: 'lifestyle', op: '==', value: 1 },
|
||||||
|
{ path: 'money', op: '>=', value: 900 },
|
||||||
|
],
|
||||||
|
result:
|
||||||
|
'The address is two stops closer and four hundred a month dearer. ' +
|
||||||
|
'Nobody says anything about it, which is the entire point of it.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: -900 },
|
||||||
|
{ path: 'lifestyle', op: 'set', value: 2 },
|
||||||
|
{ path: 'reputation', op: 'add', value: 5 },
|
||||||
|
{ path: 'notable', op: 'push', value: 'Moved to the better side of the park.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'keep_the_bus_pass',
|
||||||
|
label: 'Keep the bus pass. The bus is fine.',
|
||||||
|
result:
|
||||||
|
'The bus is fine. You read two chapters a day on it, which is two ' +
|
||||||
|
'more than anybody on Ninth manages.',
|
||||||
|
effects: [{ path: 'skills.logistics', op: 'add', value: 1 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default dispatch;
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
// Running out of money.
|
||||||
|
//
|
||||||
|
// There is no game over here and no separate failure mode in the engine — this
|
||||||
|
// is an ordinary event whose conditions happen to be "your money is negative",
|
||||||
|
// carrying enough weight to crowd out an ordinary day. Every route out costs
|
||||||
|
// something other than money, and every one of them leaves you still playing.
|
||||||
|
//
|
||||||
|
// Both events always offer at least one ungated option, so a player with no
|
||||||
|
// skills and no friends is never stuck.
|
||||||
|
|
||||||
|
export const hardTimes = [
|
||||||
|
{
|
||||||
|
id: 'rent_bounced',
|
||||||
|
// No stage: rent is due wherever you work, and the branch has to follow
|
||||||
|
// the player up rather than vanishing on promotion.
|
||||||
|
//
|
||||||
|
// Weighted heavily but not overwhelmingly, and on a cooldown: being broke
|
||||||
|
// should colour a run, not replace it. At weight 5000 with no cooldown a
|
||||||
|
// player who could not climb out saw this and almost nothing else, 45
|
||||||
|
// times in one session.
|
||||||
|
weight: 900,
|
||||||
|
cooldown: 3,
|
||||||
|
requires: [{ path: 'money', op: '<', value: 0 }],
|
||||||
|
description:
|
||||||
|
'Rent came out and did not clear. The letter uses the word ' +
|
||||||
|
'"unfortunately" four times and encloses a form.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'take_loan',
|
||||||
|
label: 'Take the loan the letter suggests.',
|
||||||
|
// They stop lending eventually, which is when the spiral gets real:
|
||||||
|
// past this, the only ways out cost standing rather than interest.
|
||||||
|
requires: [{ path: 'debt', op: '<', value: 2000 }],
|
||||||
|
result:
|
||||||
|
'The money arrives the same afternoon, which tells you something about ' +
|
||||||
|
'the rate.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: 500 },
|
||||||
|
{ path: 'debt', op: 'add', value: 500 },
|
||||||
|
{ path: 'flags.took_loan', op: 'set', value: true },
|
||||||
|
{ path: 'reputation', op: 'add', value: -2 },
|
||||||
|
{ path: 'notable', op: 'push', value: 'Took the loan.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ask_trevor',
|
||||||
|
label: 'Ask Trevor for a float.',
|
||||||
|
requires: [{ path: 'relationships.trevor.likes_you', op: '>=', value: 40 }],
|
||||||
|
result:
|
||||||
|
'He lends it without making a thing of it, and from then on he is a ' +
|
||||||
|
'man who lent you money.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: 150 },
|
||||||
|
{ path: 'reputation', op: 'add', value: -3 },
|
||||||
|
{ path: 'relationships.trevor.fears_you', op: 'add', value: -8 },
|
||||||
|
{ path: 'relationships.trevor.wants_to_help_you', op: 'add', value: -5 },
|
||||||
|
{ path: 'notable', op: 'push', value: 'Borrowed money from Trevor.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ask_marlene',
|
||||||
|
label: 'Ask Marlene for an advance.',
|
||||||
|
requires: [{ path: 'relationships.marlene.wants_to_help_you', op: '>=', value: 55 }],
|
||||||
|
result:
|
||||||
|
'She processes it herself and does not put it through the system. ' +
|
||||||
|
'Neither of you mentions it again.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: 200 },
|
||||||
|
{ path: 'relationships.marlene.wants_to_help_you', op: 'add', value: -10 },
|
||||||
|
{ path: 'relationships.marlene.likes_you', op: 'add', value: -3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'extra_shifts',
|
||||||
|
label: 'Take every shift going.',
|
||||||
|
result:
|
||||||
|
'You do eleven days without one off. The bins stop being individual ' +
|
||||||
|
'bins after a while.',
|
||||||
|
// Has to be worth more than a week of rent, or it is not a way out of
|
||||||
|
// the hole — it is the hole with extra steps.
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: 220 },
|
||||||
|
{ path: 'skills.logistics', op: 'add', value: 2 },
|
||||||
|
{ path: 'reputation', op: 'add', value: -1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'debt_collector',
|
||||||
|
// The chance to pay down has to come round often enough to matter; at
|
||||||
|
// weight 25 it appeared six times in 262 turns and debt became a ratchet.
|
||||||
|
weight: 45,
|
||||||
|
cooldown: 4,
|
||||||
|
requires: [{ path: 'debt', op: '>', value: 0 }],
|
||||||
|
description:
|
||||||
|
'A man from the lender calls the mailroom phone. Marlene answers it. She ' +
|
||||||
|
'hands it to you without expression and then, notably, without leaving.',
|
||||||
|
options: [
|
||||||
|
// Both take what is owed up to their size, never more — the same defect
|
||||||
|
// a real Frontier session found at the store, present here unplayed.
|
||||||
|
// Money first, because both effects read `debt` before it moves. See #38.
|
||||||
|
{
|
||||||
|
id: 'pay_chunk',
|
||||||
|
label: 'Pay a real chunk of it down, up to $350.',
|
||||||
|
requires: [{ path: 'money', op: '>=', value: 350 }],
|
||||||
|
result: 'He is audibly disappointed.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: { from: 'debt', times: -1, cap: 350 } },
|
||||||
|
{ path: 'debt', op: 'add', value: { from: 'debt', times: -1, cap: 350 } },
|
||||||
|
{ path: 'reputation', op: 'add', value: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pay_minimum',
|
||||||
|
label: 'Pay what you can.',
|
||||||
|
requires: [{ path: 'money', op: '>=', value: 60 }],
|
||||||
|
result: 'Up to fifty against the loan, and ten for the privilege of the call.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: -10 },
|
||||||
|
{ path: 'money', op: 'add', value: { from: 'debt', times: -1, cap: 50 } },
|
||||||
|
{ path: 'debt', op: 'add', value: { from: 'debt', times: -1, cap: 50 } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'stall',
|
||||||
|
label: 'Tell him next month.',
|
||||||
|
result: 'He says next month. Marlene goes back to her desk.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'debt', op: 'add', value: 25 },
|
||||||
|
{ path: 'reputation', op: 'add', value: -3 },
|
||||||
|
{ path: 'relationships.marlene.likes_you', op: 'add', value: -4 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default hardTimes;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { routine } from './routine.js';
|
||||||
|
import { people } from './people.js';
|
||||||
|
import { promotion } from './promotion.js';
|
||||||
|
import { dispatch } from './dispatch.js';
|
||||||
|
import { hardTimes } from './hard-times.js';
|
||||||
|
|
||||||
|
export const events = [...routine, ...people, ...promotion, ...dispatch, ...hardTimes];
|
||||||
|
export default events;
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
// Events that turn on where you stand with someone.
|
||||||
|
//
|
||||||
|
// These are gated on relationship state rather than skills, which is the point
|
||||||
|
// of tracking three separate numbers per character: a door can open because
|
||||||
|
// somebody likes you, or because they would rather you did not make a thing of
|
||||||
|
// it. The envelope chain is the pack's first taste of leverage — held, then
|
||||||
|
// spent or put back.
|
||||||
|
|
||||||
|
export const people = [
|
||||||
|
{
|
||||||
|
id: 'marlene_late',
|
||||||
|
stage: 'mailroom',
|
||||||
|
weight: 80,
|
||||||
|
cooldown: 3,
|
||||||
|
description:
|
||||||
|
'Marlene needs someone to stay until the six o\'clock courier. She is not ' +
|
||||||
|
'asking you specifically. She is looking at you specifically.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'stay',
|
||||||
|
label: 'Stay.',
|
||||||
|
result: 'The courier is twenty minutes late. Marlene waits with you.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: 45 },
|
||||||
|
{ path: 'relationships.marlene.likes_you', op: 'add', value: 5 },
|
||||||
|
{ path: 'relationships.marlene.wants_to_help_you', op: 'add', value: 4 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'decline',
|
||||||
|
label: 'Say you have somewhere to be.',
|
||||||
|
requires: [{ path: 'skills.negotiation', op: '>=', value: 15 }],
|
||||||
|
result: 'She says "of course" in a way that files it somewhere.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.negotiation', op: 'add', value: 2 },
|
||||||
|
{ path: 'relationships.marlene.likes_you', op: 'add', value: -3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'volunteer_trevor',
|
||||||
|
label: 'Suggest Trevor is free.',
|
||||||
|
result: 'Trevor is, as it turns out, free. He finds out the way you did.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'relationships.trevor.likes_you', op: 'add', value: -6 },
|
||||||
|
{ path: 'relationships.trevor.fears_you', op: 'add', value: 4 },
|
||||||
|
{ path: 'relationships.marlene.likes_you', op: 'add', value: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'trevor_credit',
|
||||||
|
stage: 'mailroom',
|
||||||
|
weight: 60,
|
||||||
|
cooldown: 6,
|
||||||
|
description:
|
||||||
|
'The floor manager thanks Trevor for the reorganised outbound shelf. ' +
|
||||||
|
'Trevor accepts this. You built the shelf on Tuesday.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'let_it_go',
|
||||||
|
label: 'Let it go.',
|
||||||
|
result: 'Trevor is warmer to you all week, which is somehow worse.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'reputation', op: 'add', value: -1 },
|
||||||
|
{ path: 'relationships.trevor.likes_you', op: 'add', value: 4 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'correct',
|
||||||
|
label: 'Correct the record, politely.',
|
||||||
|
requires: [{ path: 'skills.negotiation', op: '>=', value: 15 }],
|
||||||
|
result:
|
||||||
|
'You do it well enough that nobody is embarrassed except Trevor, who ' +
|
||||||
|
'is embarrassed thoroughly.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.negotiation', op: 'add', value: 2 },
|
||||||
|
{ path: 'reputation', op: 'add', value: 4 },
|
||||||
|
{ path: 'relationships.trevor.likes_you', op: 'add', value: -8 },
|
||||||
|
{ path: 'relationships.trevor.fears_you', op: 'add', value: 5 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'private',
|
||||||
|
label: 'Mention it to Trevor later, alone.',
|
||||||
|
result: 'He apologises twice, which is once more than he needed to.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.negotiation', op: 'add', value: 3 },
|
||||||
|
{ path: 'relationships.trevor.likes_you', op: 'add', value: -2 },
|
||||||
|
{ path: 'relationships.trevor.fears_you', op: 'add', value: 6 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'misrouted_envelope',
|
||||||
|
stage: 'mailroom',
|
||||||
|
weight: 35,
|
||||||
|
cooldown: 8,
|
||||||
|
requires: [{ path: 'flags.holds_the_envelope', op: '==', value: false }],
|
||||||
|
description:
|
||||||
|
'A sealed envelope, misrouted. Wrong floor, wrong name — and the name is ' +
|
||||||
|
'one you have seen on the quarterly. It is not sealed especially well.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'deliver',
|
||||||
|
label: 'Deliver it. Say nothing.',
|
||||||
|
result: 'It goes where it was going. You think about it on the way back.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'reputation', op: 'add', value: 2 },
|
||||||
|
{ path: 'notable', op: 'push', value: 'Delivered the misrouted envelope unopened.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hold',
|
||||||
|
label: 'Put it in your locker for now.',
|
||||||
|
result: 'For now.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'flags.holds_the_envelope', op: 'set', value: true },
|
||||||
|
{ path: 'notable', op: 'push', value: 'Kept an envelope that was not addressed to you.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'to_marlene',
|
||||||
|
label: 'Take it to Marlene.',
|
||||||
|
result: 'She reroutes it without looking at it, which took her years.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'relationships.marlene.likes_you', op: 'add', value: 4 },
|
||||||
|
{ path: 'relationships.marlene.wants_to_help_you', op: 'add', value: 3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'envelope_pressure',
|
||||||
|
stage: 'mailroom',
|
||||||
|
weight: 45,
|
||||||
|
cooldown: 6,
|
||||||
|
requires: [{ path: 'flags.holds_the_envelope', op: '==', value: true }],
|
||||||
|
description:
|
||||||
|
'The envelope is still in your locker. It has begun to feel heavier than paper.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'return_it',
|
||||||
|
label: 'Put it back into the system, late.',
|
||||||
|
result: 'Late mail is nobody\'s scandal. It clears.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'flags.holds_the_envelope', op: 'set', value: false },
|
||||||
|
{ path: 'reputation', op: 'add', value: -1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'let_it_be_known',
|
||||||
|
label: 'Let it be known, quietly, that you have it.',
|
||||||
|
requires: [{ path: 'skills.negotiation', op: '>=', value: 30 }],
|
||||||
|
result:
|
||||||
|
'You never say what is in it. You never have to. The mailroom gets ' +
|
||||||
|
'noticeably more punctual.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'relationships.marlene.fears_you', op: 'add', value: 10 },
|
||||||
|
{ path: 'relationships.trevor.fears_you', op: 'add', value: 10 },
|
||||||
|
{ path: 'reputation', op: 'add', value: -4 },
|
||||||
|
{ path: 'notable', op: 'push', value: 'Let it be known you had the envelope.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'leave_it',
|
||||||
|
label: 'Leave it where it is.',
|
||||||
|
result: 'You check the locker twice on your way out.',
|
||||||
|
effects: [{ path: 'reputation', op: 'add', value: -1 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'trevor_favor',
|
||||||
|
stage: 'mailroom',
|
||||||
|
weight: 55,
|
||||||
|
cooldown: 5,
|
||||||
|
requires: [
|
||||||
|
{ path: 'relationships.trevor.likes_you', op: '>=', value: 48 },
|
||||||
|
{ path: 'flags.covered_for_trevor', op: '==', value: false },
|
||||||
|
],
|
||||||
|
description:
|
||||||
|
'Trevor asks if you would cover the last hour on Thursday. He does not ' +
|
||||||
|
'say why. He does say it twice.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'cover',
|
||||||
|
label: 'Cover it.',
|
||||||
|
result: 'Thursday is quiet. Trevor is not there to see how quiet.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'flags.covered_for_trevor', op: 'set', value: true },
|
||||||
|
{ path: 'relationships.trevor.likes_you', op: 'add', value: 6 },
|
||||||
|
{ path: 'relationships.trevor.wants_to_help_you', op: 'add', value: 8 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'trade',
|
||||||
|
label: 'Cover it, and say what you want in return.',
|
||||||
|
requires: [{ path: 'skills.negotiation', op: '>=', value: 15 }],
|
||||||
|
result: 'He agrees quickly, and then thinks about how quickly he agreed.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'flags.covered_for_trevor', op: 'set', value: true },
|
||||||
|
{ path: 'skills.negotiation', op: 'add', value: 3 },
|
||||||
|
{ path: 'relationships.trevor.wants_to_help_you', op: 'add', value: 5 },
|
||||||
|
{ path: 'relationships.trevor.fears_you', op: 'add', value: 4 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'refuse',
|
||||||
|
label: 'Say no.',
|
||||||
|
result: 'He says it is fine. He asks Marlene, who says no as well.',
|
||||||
|
effects: [{ path: 'relationships.trevor.likes_you', op: 'add', value: -5 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'call_in_favor',
|
||||||
|
stage: 'mailroom',
|
||||||
|
weight: 50,
|
||||||
|
cooldown: 4,
|
||||||
|
requires: [{ path: 'flags.covered_for_trevor', op: '==', value: true }],
|
||||||
|
description:
|
||||||
|
'Thursday was a while ago now. Trevor has not brought it up, which is its ' +
|
||||||
|
'own kind of answer.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'call_it_in',
|
||||||
|
label: 'Ask him to take the afternoon bins.',
|
||||||
|
result: 'He takes them. The ledger is closed and both of you know it.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'flags.covered_for_trevor', op: 'set', value: false },
|
||||||
|
{ path: 'skills.logistics', op: 'add', value: 1 },
|
||||||
|
{ path: 'reputation', op: 'add', value: 1 },
|
||||||
|
{ path: 'relationships.trevor.wants_to_help_you', op: 'add', value: -4 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bank_it',
|
||||||
|
label: 'Leave it. It keeps.',
|
||||||
|
result: 'It keeps. He knows it keeps.',
|
||||||
|
effects: [{ path: 'relationships.trevor.fears_you', op: 'add', value: 2 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'forgive',
|
||||||
|
label: 'Tell him to forget it.',
|
||||||
|
result: 'He does not forget it, but he does stop bringing you coffee wrong.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'flags.covered_for_trevor', op: 'set', value: false },
|
||||||
|
{ path: 'relationships.trevor.likes_you', op: 'add', value: 7 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'marlene_confides',
|
||||||
|
stage: 'mailroom',
|
||||||
|
weight: 50,
|
||||||
|
cooldown: 8,
|
||||||
|
requires: [{ path: 'relationships.marlene.likes_you', op: '>=', value: 65 }],
|
||||||
|
description:
|
||||||
|
'Marlene tells you, without preamble, that she was told nineteen years ago ' +
|
||||||
|
'that the mailroom was a two-year posting. She then asks whether the ' +
|
||||||
|
'third-floor bins have gone up.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'ask_about_it',
|
||||||
|
label: 'Ask her about it.',
|
||||||
|
result: 'She talks for a while. She has not told this to Trevor.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.negotiation', op: 'add', value: 2 },
|
||||||
|
{ path: 'relationships.marlene.likes_you', op: 'add', value: 5 },
|
||||||
|
{ path: 'relationships.marlene.wants_to_help_you', op: 'add', value: 6 },
|
||||||
|
{
|
||||||
|
path: 'notable',
|
||||||
|
op: 'push',
|
||||||
|
value: 'Marlene told you about the two-year posting.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'answer_the_bins',
|
||||||
|
label: 'Tell her the bins have gone up.',
|
||||||
|
result: 'She writes it down. The subject is closed with visible relief.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.logistics', op: 'add', value: 1 },
|
||||||
|
{ path: 'relationships.marlene.likes_you', op: 'add', value: 2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ask_how_to_leave',
|
||||||
|
label: 'Ask her how to get out of the mailroom.',
|
||||||
|
requires: [{ path: 'skills.negotiation', op: '>=', value: 15 }],
|
||||||
|
result:
|
||||||
|
'She gives you three names and a piece of advice she says she wishes ' +
|
||||||
|
'someone had given her, which is how you know it is good.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.negotiation', op: 'add', value: 4 },
|
||||||
|
{ path: 'reputation', op: 'add', value: 2 },
|
||||||
|
{ path: 'relationships.marlene.wants_to_help_you', op: 'add', value: 5 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default people;
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// Leaving the mailroom.
|
||||||
|
//
|
||||||
|
// The offer is an ordinary event whose effects happen to include
|
||||||
|
// `{ path: 'stage', op: 'set', value: 'dispatch' }` — the engine has no notion
|
||||||
|
// of a promotion, only of a stage that determines which events are in the pool.
|
||||||
|
//
|
||||||
|
// Declining is a real option, not a soft no. It comes back after a cooldown,
|
||||||
|
// and the brainstorm is explicit that staying lower to build skills or
|
||||||
|
// relationships should be a strategy rather than a mistake.
|
||||||
|
|
||||||
|
export const promotion = [
|
||||||
|
{
|
||||||
|
id: 'dispatch_offer',
|
||||||
|
stage: 'mailroom',
|
||||||
|
weight: 800,
|
||||||
|
cooldown: 12,
|
||||||
|
// Deliberately a long way in. The mailroom is where most of this pack
|
||||||
|
// lives, and a promotion at turn 25 would skip it — the offer should read
|
||||||
|
// as the end of a career there, not an early exit from it.
|
||||||
|
requires: [
|
||||||
|
{ path: 'reputation', op: '>=', value: 72 },
|
||||||
|
{ path: 'turn', op: '>=', value: 40 },
|
||||||
|
// Two ways to be in the room: somebody upstairs wants to help you, or
|
||||||
|
// you have got good enough at asking.
|
||||||
|
{
|
||||||
|
any: [
|
||||||
|
{ path: 'relationships.marlene.wants_to_help_you', op: '>=', value: 68 },
|
||||||
|
{ path: 'skills.negotiation', op: '>=', value: 42 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
description:
|
||||||
|
'The Dispatch post is real, it is open, and Marlene has put your name on ' +
|
||||||
|
'a list of one. She says the coordinator job is mostly the same work with ' +
|
||||||
|
'a different phone. She does not say it is more money, because she assumes ' +
|
||||||
|
'you can read.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'accept',
|
||||||
|
label: 'Take it.',
|
||||||
|
result:
|
||||||
|
'You are given a desk with a window in it. Trevor helps you carry ' +
|
||||||
|
'your things up and does not say anything the whole way.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'stage', op: 'set', value: 'dispatch' },
|
||||||
|
{ path: 'flags.promoted', op: 'set', value: true },
|
||||||
|
{ path: 'reputation', op: 'add', value: 5 },
|
||||||
|
{ path: 'relationships.trevor.likes_you', op: 'add', value: -12 },
|
||||||
|
{ path: 'relationships.trevor.fears_you', op: 'add', value: 6 },
|
||||||
|
{ path: 'notable', op: 'push', value: 'Took the Dispatch post.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'accept_and_recommend',
|
||||||
|
label: 'Take it, and put Trevor up for the mailroom.',
|
||||||
|
requires: [{ path: 'relationships.trevor.likes_you', op: '>=', value: 50 }],
|
||||||
|
result:
|
||||||
|
'Marlene raises an eyebrow and writes his name down. Trevor finds out ' +
|
||||||
|
'from the notice board and is unbearable about it for a week.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'stage', op: 'set', value: 'dispatch' },
|
||||||
|
{ path: 'flags.promoted', op: 'set', value: true },
|
||||||
|
{ path: 'flags.recommended_trevor', op: 'set', value: true },
|
||||||
|
{ path: 'reputation', op: 'add', value: 3 },
|
||||||
|
{ path: 'relationships.trevor.likes_you', op: 'add', value: 12 },
|
||||||
|
{ path: 'relationships.trevor.wants_to_help_you', op: 'add', value: 8 },
|
||||||
|
{ path: 'notable', op: 'push', value: 'Took Dispatch, and put Trevor up for the mailroom.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'decline',
|
||||||
|
label: 'Not yet.',
|
||||||
|
result:
|
||||||
|
'Marlene takes the list back. She has been on a list of one before ' +
|
||||||
|
'and does not ask why.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.negotiation', op: 'add', value: 2 },
|
||||||
|
{ path: 'reputation', op: 'add', value: -2 },
|
||||||
|
{ path: 'relationships.marlene.wants_to_help_you', op: 'add', value: -6 },
|
||||||
|
{ path: 'notable', op: 'push', value: 'Turned down Dispatch.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default promotion;
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
// Ordinary days in the mailroom.
|
||||||
|
//
|
||||||
|
// The first three carry no conditions, no cooldown and no `once`, which makes
|
||||||
|
// them the stage's floor: whatever else is excluded, there is always a turn to
|
||||||
|
// play. Everything else in the pack leans on that.
|
||||||
|
|
||||||
|
export const routine = [
|
||||||
|
{
|
||||||
|
id: 'first_day',
|
||||||
|
stage: 'mailroom',
|
||||||
|
weight: 99999,
|
||||||
|
once: true,
|
||||||
|
description:
|
||||||
|
'Marlene Hobb hands you a laminated card with the org chart on it. Your ' +
|
||||||
|
'name is not on the card. She says it will be, eventually, in pen.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'listen',
|
||||||
|
label: 'Ask her to walk you through it.',
|
||||||
|
result: 'She walks you through it. Then she walks you through it again.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.negotiation', op: 'add', value: 2 },
|
||||||
|
{ path: 'relationships.marlene.likes_you', op: 'add', value: 4 },
|
||||||
|
{ path: 'notable', op: 'push', value: 'Let Marlene explain the org chart. Twice.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wing_it',
|
||||||
|
label: "Say you'll pick it up as you go.",
|
||||||
|
result: 'She puts the card back in her pocket without comment.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.logistics', op: 'add', value: 2 },
|
||||||
|
{ path: 'relationships.marlene.likes_you', op: 'add', value: -2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ask_trevor',
|
||||||
|
label: 'Ask Trevor where you actually sit.',
|
||||||
|
result:
|
||||||
|
'He shows you a desk, then mentions he has been here two weeks longer ' +
|
||||||
|
'than you. This will not be the last time.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'relationships.trevor.likes_you', op: 'add', value: 3 },
|
||||||
|
{ path: 'relationships.trevor.wants_to_help_you', op: 'add', value: 2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'mail_cart',
|
||||||
|
stage: 'mailroom',
|
||||||
|
weight: 100,
|
||||||
|
description:
|
||||||
|
"The mail cart's left front wheel has developed a shriek. Facilities has " +
|
||||||
|
'classified it as "cosmetic."',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'push',
|
||||||
|
label: "Push it anyway. Everyone will know you're coming.",
|
||||||
|
result: 'By Thursday, two floors can time their morning by you.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.logistics', op: 'add', value: 1 },
|
||||||
|
{ path: 'reputation', op: 'add', value: -2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'requisition',
|
||||||
|
label: 'Spend the morning on a requisition form.',
|
||||||
|
result:
|
||||||
|
'The form requires the cart\'s asset number. The asset number is on the ' +
|
||||||
|
'wheel. You fill it in from underneath.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.negotiation', op: 'add', value: 2 },
|
||||||
|
{ path: 'skills.logistics', op: 'add', value: 1 },
|
||||||
|
{ path: 'relationships.marlene.likes_you', op: 'add', value: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'swap',
|
||||||
|
label: 'Swap it with the good cart from the third floor.',
|
||||||
|
result: 'The third floor has no way of proving anything. They try anyway.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.logistics', op: 'add', value: 3 },
|
||||||
|
{ path: 'reputation', op: 'add', value: -3 },
|
||||||
|
{ path: 'relationships.trevor.fears_you', op: 'add', value: 3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'sorting_backlog',
|
||||||
|
stage: 'mailroom',
|
||||||
|
weight: 100,
|
||||||
|
description:
|
||||||
|
'Two bins came in overnight and neither is labelled. Trevor has decided ' +
|
||||||
|
'this is a good day to reorganise his desk.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'grind',
|
||||||
|
label: 'Sort both bins yourself.',
|
||||||
|
result: 'It takes until four. Marlene notices, which is the point.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.logistics', op: 'add', value: 3 },
|
||||||
|
{ path: 'reputation', op: 'add', value: 2 },
|
||||||
|
{ path: 'relationships.marlene.likes_you', op: 'add', value: 3 },
|
||||||
|
// Trevor's bin got sorted too, and he is not too proud to notice.
|
||||||
|
{ path: 'relationships.trevor.likes_you', op: 'add', value: 2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'split',
|
||||||
|
label: 'Ask Trevor to take one.',
|
||||||
|
requires: [{ path: 'skills.negotiation', op: '>=', value: 15 }],
|
||||||
|
result: 'He takes one. He takes the lighter one, but he takes one.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.negotiation', op: 'add', value: 2 },
|
||||||
|
{ path: 'skills.logistics', op: 'add', value: 1 },
|
||||||
|
{ path: 'relationships.trevor.likes_you', op: 'add', value: -2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'halfway',
|
||||||
|
label: 'Sort what fits before lunch and leave the rest.',
|
||||||
|
result: 'The rest is still there on Monday, and so are you.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'reputation', op: 'add', value: -2 },
|
||||||
|
{ path: 'relationships.marlene.likes_you', op: 'add', value: -2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'franking_machine',
|
||||||
|
stage: 'mailroom',
|
||||||
|
weight: 70,
|
||||||
|
description:
|
||||||
|
'The franking machine is making a noise it has not made before. The manual ' +
|
||||||
|
'is in a drawer that is locked, and the key is a subject nobody will be ' +
|
||||||
|
'drawn on.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'fix',
|
||||||
|
label: 'Take the panel off and have a look.',
|
||||||
|
requires: [{ path: 'skills.logistics', op: '>=', value: 20 }],
|
||||||
|
result: 'It was a spring. It is now a spring in the right place.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.logistics', op: 'add', value: 3 },
|
||||||
|
{ path: 'reputation', op: 'add', value: 4 },
|
||||||
|
{ path: 'relationships.marlene.likes_you', op: 'add', value: 3 },
|
||||||
|
{
|
||||||
|
path: 'notable',
|
||||||
|
op: 'push',
|
||||||
|
value: 'Fixed the franking machine, which nobody had managed since 2019.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cycle',
|
||||||
|
label: 'Turn it off and on.',
|
||||||
|
result: 'The noise stops. Nothing has been learned by anyone.',
|
||||||
|
effects: [{ path: 'skills.logistics', op: 'add', value: 1 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'escalate',
|
||||||
|
label: 'Log it with Facilities and wait.',
|
||||||
|
result: 'Facilities classifies it as cosmetic.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.negotiation', op: 'add', value: 1 },
|
||||||
|
{ path: 'relationships.marlene.likes_you', op: 'add', value: -1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'birthday_card',
|
||||||
|
stage: 'mailroom',
|
||||||
|
weight: 45,
|
||||||
|
cooldown: 5,
|
||||||
|
description:
|
||||||
|
'A card is going round for someone on the fourth floor. You have never met ' +
|
||||||
|
'them. The envelope already has two twenties in it and your name is next ' +
|
||||||
|
'on the list.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'contribute',
|
||||||
|
label: 'Put something in.',
|
||||||
|
result: 'You are now a person who puts something in.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: -20 },
|
||||||
|
{ path: 'reputation', op: 'add', value: 3 },
|
||||||
|
{ path: 'relationships.marlene.likes_you', op: 'add', value: 2 },
|
||||||
|
{ path: 'relationships.trevor.likes_you', op: 'add', value: 2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sign_only',
|
||||||
|
label: 'Sign it and pass it on.',
|
||||||
|
result: 'You write "all the best" and immediately regret the phrasing.',
|
||||||
|
effects: [{ path: 'reputation', op: 'add', value: -1 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pass',
|
||||||
|
label: 'Pass it on unsigned.',
|
||||||
|
result: 'Trevor checks the list. Trevor notices the list.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'reputation', op: 'add', value: -3 },
|
||||||
|
{ path: 'relationships.trevor.likes_you', op: 'add', value: -2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'elevator',
|
||||||
|
stage: 'mailroom',
|
||||||
|
weight: 30,
|
||||||
|
cooldown: 10,
|
||||||
|
description:
|
||||||
|
'You are alone in the elevator with a man whose name is on the quarterly. ' +
|
||||||
|
'He looks at your cart. He asks how long the mail takes to get to eleven.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'honest',
|
||||||
|
label: 'Tell him the truth: forty minutes, on a good day.',
|
||||||
|
result: 'He says "forty" once, to himself, and gets out on nine.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'reputation', op: 'add', value: 5 },
|
||||||
|
{ path: 'skills.negotiation', op: 'add', value: 2 },
|
||||||
|
{
|
||||||
|
path: 'notable',
|
||||||
|
op: 'push',
|
||||||
|
value: 'Told an executive the truth about the eleventh floor.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'optimistic',
|
||||||
|
label: 'Tell him twenty.',
|
||||||
|
result: 'He seems pleased. Somebody, eventually, will have to make it twenty.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'reputation', op: 'add', value: -3 },
|
||||||
|
{ path: 'skills.negotiation', op: 'add', value: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'unsure',
|
||||||
|
label: "Say you're not sure.",
|
||||||
|
result: 'The rest of the ride is four floors long.',
|
||||||
|
effects: [{ path: 'reputation', op: 'add', value: -1 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'overtime_week',
|
||||||
|
stage: 'mailroom',
|
||||||
|
weight: 40,
|
||||||
|
cooldown: 7,
|
||||||
|
description:
|
||||||
|
'There is a weekend inventory. It pays. Marlene mentions it in the tone of ' +
|
||||||
|
'someone who has been told to mention it.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'take',
|
||||||
|
label: 'Take the shift.',
|
||||||
|
result: 'Two days of counting things that were already counted.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: 180 },
|
||||||
|
{ path: 'skills.logistics', op: 'add', value: 2 },
|
||||||
|
{ path: 'reputation', op: 'add', value: 2 },
|
||||||
|
{ path: 'relationships.marlene.likes_you', op: 'add', value: 2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'negotiate',
|
||||||
|
label: 'Take it, for a bit more.',
|
||||||
|
requires: [{ path: 'skills.negotiation', op: '>=', value: 30 }],
|
||||||
|
result:
|
||||||
|
'Marlene does not enjoy the conversation, but she does have the ' +
|
||||||
|
'authority, and you both find that out at the same time.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: 260 },
|
||||||
|
{ path: 'skills.negotiation', op: 'add', value: 3 },
|
||||||
|
{ path: 'relationships.marlene.likes_you', op: 'add', value: -1 },
|
||||||
|
{
|
||||||
|
path: 'notable',
|
||||||
|
op: 'push',
|
||||||
|
value: 'Negotiated a better rate for the weekend inventory.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'decline',
|
||||||
|
label: 'Pass.',
|
||||||
|
result: 'You have a weekend. It is the only one anyone gets.',
|
||||||
|
effects: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'promotion_rumor',
|
||||||
|
stage: 'mailroom',
|
||||||
|
weight: 25,
|
||||||
|
cooldown: 12,
|
||||||
|
requires: [{ path: 'turn', op: '>=', value: 30 }],
|
||||||
|
description:
|
||||||
|
'There is a post going in Dispatch. Trevor has heard about it. Trevor ' +
|
||||||
|
'mentions that he has heard about it.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'ask_marlene',
|
||||||
|
label: 'Ask Marlene to put your name forward.',
|
||||||
|
requires: [{ path: 'relationships.marlene.wants_to_help_you', op: '>=', value: 60 }],
|
||||||
|
result: 'She says she will see. From Marlene this is close to a contract.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'reputation', op: 'add', value: 5 },
|
||||||
|
{ path: 'relationships.marlene.wants_to_help_you', op: 'add', value: -5 },
|
||||||
|
{
|
||||||
|
path: 'notable',
|
||||||
|
op: 'push',
|
||||||
|
value: 'Asked Marlene to put your name forward for Dispatch.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ask_around',
|
||||||
|
label: 'Ask around on your own.',
|
||||||
|
result: 'You learn the post has been open for eleven months.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.negotiation', op: 'add', value: 3 },
|
||||||
|
{ path: 'reputation', op: 'add', value: 2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ignore',
|
||||||
|
label: 'Stay where you are for now.',
|
||||||
|
result: 'Trevor mentions it again on Thursday.',
|
||||||
|
effects: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'late_run',
|
||||||
|
stage: 'mailroom',
|
||||||
|
weight: 35,
|
||||||
|
cooldown: 5,
|
||||||
|
description:
|
||||||
|
'The evening courier run needs covering. It is an hour, it is nobody\'s ' +
|
||||||
|
'favourite hour, and it pays.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'take_run',
|
||||||
|
label: 'Take the run.',
|
||||||
|
result: 'An hour in the loading bay. The city looks better from there than it has any right to.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: 70 },
|
||||||
|
{ path: 'skills.logistics', op: 'add', value: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'swap_it',
|
||||||
|
label: 'Take it, and trade it to Trevor for the Friday one.',
|
||||||
|
requires: [{ path: 'skills.negotiation', op: '>=', value: 15 }],
|
||||||
|
result: 'Friday pays the same and finishes earlier. Trevor works this out on Friday.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: 70 },
|
||||||
|
{ path: 'skills.negotiation', op: 'add', value: 2 },
|
||||||
|
{ path: 'relationships.trevor.likes_you', op: 'add', value: -3 },
|
||||||
|
{ path: 'relationships.trevor.fears_you', op: 'add', value: 2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'go_home',
|
||||||
|
label: 'Go home.',
|
||||||
|
result: 'You are home by six. It is a strange feeling and it does not last.',
|
||||||
|
effects: [{ path: 'relationships.marlene.likes_you', op: 'add', value: -1 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
// Deliberately surfaced by being broke: when the money runs low the board
|
||||||
|
// starts offering work. The way out of a hole should be visible from
|
||||||
|
// inside it, not something you had to have planned for.
|
||||||
|
id: 'spare_shift',
|
||||||
|
stage: 'mailroom',
|
||||||
|
weight: 300,
|
||||||
|
cooldown: 2,
|
||||||
|
requires: [{ path: 'money', op: '<', value: 350 }],
|
||||||
|
description:
|
||||||
|
'There is a shift going spare on the board — someone else\'s weekend, ' +
|
||||||
|
'going cheap because nobody wants it. Marlene has not taken it down.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'take_shift',
|
||||||
|
label: 'Take it.',
|
||||||
|
result: 'Two days of somebody else\'s work. The money is real.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: 140 },
|
||||||
|
{ path: 'skills.logistics', op: 'add', value: 1 },
|
||||||
|
{ path: 'relationships.marlene.likes_you', op: 'add', value: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'take_two',
|
||||||
|
label: 'Take it, and ask what else is going.',
|
||||||
|
requires: [{ path: 'skills.negotiation', op: '>=', value: 15 }],
|
||||||
|
result:
|
||||||
|
'There is more going than the board admits to. Marlene finds you ' +
|
||||||
|
'another two days without writing either of them down.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: 265 },
|
||||||
|
{ path: 'skills.negotiation', op: 'add', value: 2 },
|
||||||
|
{ path: 'reputation', op: 'add', value: -1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'leave_it',
|
||||||
|
label: 'Leave it for someone who needs it.',
|
||||||
|
result: 'Trevor takes it. Trevor mentions taking it.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'reputation', op: 'add', value: 2 },
|
||||||
|
{ path: 'relationships.trevor.likes_you', op: 'add', value: 2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default routine;
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
// Corporate Ladder — the default content pack for The Ladder.
|
||||||
|
//
|
||||||
|
// A modern office, the mailroom, and two people who have been there longer
|
||||||
|
// than you. Everything the engine needs to run this setting is in this file
|
||||||
|
// and the ones it imports; the engine itself knows nothing about mailrooms.
|
||||||
|
|
||||||
|
import { characters } from './characters.js';
|
||||||
|
import { events } from './events/index.js';
|
||||||
|
|
||||||
|
export const corporateLadder = {
|
||||||
|
id: 'corporateladder',
|
||||||
|
name: 'Corporate Ladder',
|
||||||
|
// One sentence for the setting picker. The interface reads it and knows
|
||||||
|
// nothing else about what this pack is.
|
||||||
|
blurb:
|
||||||
|
'A modern office and a mailroom, two people who have been here longer ' +
|
||||||
|
'than you, and a laminated org chart with your name not on it.',
|
||||||
|
version: '0.1.1',
|
||||||
|
turnUnit: 'day',
|
||||||
|
startingStage: 'mailroom',
|
||||||
|
// What the interface calls the panel of people. The UI used to hardcode
|
||||||
|
// "The mailroom" here, which was the one place it named a setting.
|
||||||
|
peopleHeading: 'The mailroom',
|
||||||
|
|
||||||
|
stages: {
|
||||||
|
mailroom: { label: 'Mailroom', title: 'Mail Associate I' },
|
||||||
|
dispatch: { label: 'Dispatch', title: 'Dispatch Coordinator' },
|
||||||
|
},
|
||||||
|
|
||||||
|
// Turn 1 is a Monday. Content can gate on the day — and, more importantly,
|
||||||
|
// money arrives and leaves on a rhythm the player can plan around instead of
|
||||||
|
// draining by an unexplained amount every day.
|
||||||
|
calendar: {
|
||||||
|
cycleLength: 7,
|
||||||
|
cycleName: 'Week',
|
||||||
|
unitNames: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Money is tight but not hopeless. The baseline bleeds about three dollars a
|
||||||
|
// day, so a careful player creeps forward and a careless one does not — the
|
||||||
|
// pressure comes from what choices cost, not from an unexplained drain.
|
||||||
|
state: {
|
||||||
|
'money': { initial: 520, min: -5000, max: 1000000 },
|
||||||
|
'debt': { initial: 0, min: 0, max: 1000000 },
|
||||||
|
'reputation': { initial: 50, min: 0, max: 100 },
|
||||||
|
'skills.negotiation': { initial: 5, min: 0, max: 100 },
|
||||||
|
'skills.logistics': { initial: 5, min: 0, max: 100 },
|
||||||
|
// What standing costs to keep. Nothing forces a player to climb this, but
|
||||||
|
// Dispatch charges standing for staying at the bottom of it — see the
|
||||||
|
// "Looking the part" entries in upkeep, and DECISIONS #36.
|
||||||
|
'lifestyle': { initial: 0, min: 0, max: 2 },
|
||||||
|
'flags.took_loan': { initial: false },
|
||||||
|
'flags.holds_the_envelope': { initial: false },
|
||||||
|
'flags.covered_for_trevor': { initial: false },
|
||||||
|
'flags.promoted': { initial: false },
|
||||||
|
'flags.recommended_trevor': { initial: false },
|
||||||
|
// Moments the retrospective reads back. Content decides what is memorable;
|
||||||
|
// the full turn-by-turn record lives in history regardless.
|
||||||
|
'notable': { initial: [] },
|
||||||
|
},
|
||||||
|
|
||||||
|
// Nobody fears a new hire. Trevor's defaults are lower still — see
|
||||||
|
// characters.js, where he starts two weeks ahead of you and knows it.
|
||||||
|
relationshipStats: {
|
||||||
|
likes_you: { label: 'Likes you', initial: 50, min: 0, max: 100 },
|
||||||
|
fears_you: { label: 'Wary of you', initial: 10, min: 0, max: 100 },
|
||||||
|
wants_to_help_you: { label: 'Will help you', initial: 40, min: 0, max: 100 },
|
||||||
|
},
|
||||||
|
|
||||||
|
// Money moves on a schedule the player can feel: paid on alternate Fridays,
|
||||||
|
// rent out every Monday, and only small change in between.
|
||||||
|
//
|
||||||
|
// Keep this arithmetic honest when tuning: wages, minus two rents, minus
|
||||||
|
// fourteen days of small change. Per week that is 560 - 455 - 126 = -21, so
|
||||||
|
// about forty dollars down over a fortnight — a drift, not a bleed. Rent was
|
||||||
|
// once raised without re-deriving it, which quietly made the baseline four
|
||||||
|
// times steeper and trapped a real playtester below zero for 30% of a
|
||||||
|
// 182-turn run. The range is asserted from `tuning` below.
|
||||||
|
upkeep: [
|
||||||
|
{ label: 'Coffee, transit, lunch', effects: [{ path: 'money', op: 'add', value: -18 }] },
|
||||||
|
// Turn 1 is a Monday, so offset 4 is the first Friday and every 14 makes it
|
||||||
|
// alternate Fridays. Rent lands on Mondays, starting the second week.
|
||||||
|
// Wages depend on the job. Two entries rather than one, gated on stage:
|
||||||
|
// the engine has no idea what a promotion is, and does not need to.
|
||||||
|
{
|
||||||
|
label: 'Wages',
|
||||||
|
every: 14,
|
||||||
|
offset: 4,
|
||||||
|
requires: [{ path: 'stage', op: '==', value: 'mailroom' }],
|
||||||
|
effects: [{ path: 'money', op: 'add', value: 1120 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Wages',
|
||||||
|
every: 14,
|
||||||
|
offset: 4,
|
||||||
|
// A real raise, but not a different life — the coordinator job is, as
|
||||||
|
// Marlene puts it, mostly the same work with a different phone. Net of
|
||||||
|
// rent it comes to a few dollars a day ahead instead of behind.
|
||||||
|
requires: [{ path: 'stage', op: '==', value: 'dispatch' }],
|
||||||
|
effects: [{ path: 'money', op: 'add', value: 1260 }],
|
||||||
|
},
|
||||||
|
{ label: 'Rent', every: 7, offset: 7, effects: [{ path: 'money', op: 'add', value: -455 }] },
|
||||||
|
// What the job expects of you, and what meeting that expectation costs.
|
||||||
|
// Dispatch nets +49 a week; a coordinator with the car is +19, and one
|
||||||
|
// with the car and the address is -21 and has to earn the difference out
|
||||||
|
// of the days themselves. That is the answer to "money stops mattering
|
||||||
|
// late": it stops mattering only if standing is free to keep.
|
||||||
|
{
|
||||||
|
label: 'Car payment and parking',
|
||||||
|
every: 7,
|
||||||
|
offset: 7,
|
||||||
|
requires: [{ path: 'lifestyle', op: '>=', value: 1 }],
|
||||||
|
effects: [{ path: 'money', op: 'add', value: -30 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'The better address',
|
||||||
|
every: 7,
|
||||||
|
offset: 7,
|
||||||
|
requires: [{ path: 'lifestyle', op: '>=', value: 2 }],
|
||||||
|
effects: [{ path: 'money', op: 'add', value: -40 }],
|
||||||
|
},
|
||||||
|
// The pressure that makes the spending a choice rather than a vanity. Both
|
||||||
|
// are gated on Dispatch: the mailroom pays too little for this to be
|
||||||
|
// anything but a trap, and "later in a career" is the point.
|
||||||
|
{
|
||||||
|
label: 'Looking the part',
|
||||||
|
every: 7,
|
||||||
|
offset: 7,
|
||||||
|
requires: [
|
||||||
|
{ path: 'stage', op: '==', value: 'dispatch' },
|
||||||
|
{ path: 'lifestyle', op: '<', value: 1 },
|
||||||
|
],
|
||||||
|
effects: [{ path: 'reputation', op: 'add', value: -4 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Standing above sixty-five is a thing you are seen to be, and being seen
|
||||||
|
// to be it runs on a better address than a coordinator's first one.
|
||||||
|
//
|
||||||
|
// Sized by measurement, not by feel. At -2 a week this was invisible:
|
||||||
|
// Dispatch hands out standing several points at a time, so a coordinator
|
||||||
|
// who bought nothing still finished pinned at 100 and the charge may as
|
||||||
|
// well not have existed. At -6, with the entry above, a non-buyer settles
|
||||||
|
// in the low seventies while somebody keeping up finishes at 100 — the
|
||||||
|
// ceiling is real and money is what lifts it.
|
||||||
|
label: 'Looking the part',
|
||||||
|
every: 7,
|
||||||
|
offset: 7,
|
||||||
|
requires: [
|
||||||
|
{ path: 'stage', op: '==', value: 'dispatch' },
|
||||||
|
{ path: 'reputation', op: '>=', value: 65 },
|
||||||
|
{ path: 'lifestyle', op: '<', value: 2 },
|
||||||
|
],
|
||||||
|
effects: [{ path: 'reputation', op: 'add', value: -6 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The top step, and the one that makes the whole thing a choice rather
|
||||||
|
// than a tax on carelessness. Without it, a player who spends every turn
|
||||||
|
// chasing standing simply out-earns the charge: measured, they held 99
|
||||||
|
// while never buying anything, and finished $2,245 richer than a player
|
||||||
|
// who kept up — so the lifestyle was a pure loss to anybody paying
|
||||||
|
// attention. With it they plateau in the low eighties. What money buys
|
||||||
|
// up here is the last fifteen points, which cannot be worked for.
|
||||||
|
label: 'Looking the part',
|
||||||
|
every: 7,
|
||||||
|
offset: 7,
|
||||||
|
requires: [
|
||||||
|
{ path: 'stage', op: '==', value: 'dispatch' },
|
||||||
|
{ path: 'reputation', op: '>=', value: 80 },
|
||||||
|
{ path: 'lifestyle', op: '<', value: 2 },
|
||||||
|
],
|
||||||
|
effects: [{ path: 'reputation', op: 'add', value: -14 }],
|
||||||
|
},
|
||||||
|
// The charge that never stops, and the only one with no purchase that ends
|
||||||
|
// it. Everything above is a ladder with a top: buy both tiers, reach 100
|
||||||
|
// standing, and every charge switches off. A real 96-turn session arrived
|
||||||
|
// there at turn 84 with nothing left to spend on and a balance that only
|
||||||
|
// climbed.
|
||||||
|
//
|
||||||
|
// It steps on money rather than being flat, and that is the whole design.
|
||||||
|
// A flat charge cannot flatten a slope: late income comes from choices and
|
||||||
|
// scales with how well the player is playing, so a charge big enough to
|
||||||
|
// stop an optimiser accumulating (-360 a week, measured) put ordinary
|
||||||
|
// archetypes below zero for 44% of a run and ended them $1,385 in debt.
|
||||||
|
// Stepping on money lands it on whoever has it. Measured: the balance
|
||||||
|
// settles around $4,250 and stops climbing (-3 a week), while ordinary play
|
||||||
|
// gets *better* than before it existed — 13% of a run below zero against
|
||||||
|
// 17%, and a tier-2 run ending +$175 rather than -$25.
|
||||||
|
{
|
||||||
|
label: 'What the standing costs',
|
||||||
|
every: 7,
|
||||||
|
offset: 7,
|
||||||
|
requires: [
|
||||||
|
{ path: 'stage', op: '==', value: 'dispatch' },
|
||||||
|
{ path: 'reputation', op: '>=', value: 85 },
|
||||||
|
{ path: 'money', op: '>=', value: 1200 },
|
||||||
|
],
|
||||||
|
effects: [{ path: 'money', op: 'add', value: -60 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'What the standing costs',
|
||||||
|
every: 7,
|
||||||
|
offset: 7,
|
||||||
|
requires: [
|
||||||
|
{ path: 'stage', op: '==', value: 'dispatch' },
|
||||||
|
{ path: 'reputation', op: '>=', value: 85 },
|
||||||
|
{ path: 'money', op: '>=', value: 2400 },
|
||||||
|
],
|
||||||
|
effects: [{ path: 'money', op: 'add', value: -120 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'What the standing costs',
|
||||||
|
every: 7,
|
||||||
|
offset: 7,
|
||||||
|
requires: [
|
||||||
|
{ path: 'stage', op: '==', value: 'dispatch' },
|
||||||
|
{ path: 'reputation', op: '>=', value: 85 },
|
||||||
|
{ path: 'money', op: '>=', value: 3600 },
|
||||||
|
],
|
||||||
|
effects: [{ path: 'money', op: 'add', value: -240 }],
|
||||||
|
},
|
||||||
|
// The service charge steps up with the size of the debt, so the spiral
|
||||||
|
// tightens instead of flattening out. A step function in data costs the
|
||||||
|
// engine nothing; a percentage would have needed a multiply operation.
|
||||||
|
{
|
||||||
|
label: 'Loan service charge',
|
||||||
|
every: 7,
|
||||||
|
offset: 7,
|
||||||
|
requires: [{ path: 'debt', op: '>', value: 0 }],
|
||||||
|
effects: [{ path: 'money', op: 'add', value: -70 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Loan service charge (arrears)',
|
||||||
|
every: 7,
|
||||||
|
offset: 7,
|
||||||
|
requires: [{ path: 'debt', op: '>=', value: 1000 }],
|
||||||
|
effects: [{ path: 'money', op: 'add', value: -40 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Loan service charge (collections)',
|
||||||
|
every: 7,
|
||||||
|
offset: 7,
|
||||||
|
requires: [{ path: 'debt', op: '>=', value: 2000 }],
|
||||||
|
effects: [{ path: 'money', op: 'add', value: -70 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
// What the conformance suite holds this pack to. The numbers a setting must
|
||||||
|
// hit are the setting's business, so they are declared here rather than
|
||||||
|
// written into a test that names this pack. See DECISIONS.md #31.
|
||||||
|
tuning: {
|
||||||
|
// Net movement of `money` from upkeep alone, per calendar cycle — here a
|
||||||
|
// week — at each stage, with no debt outstanding.
|
||||||
|
baselinePerCycle: {
|
||||||
|
mailroom: [-45, 5],
|
||||||
|
dispatch: [5, 125],
|
||||||
|
},
|
||||||
|
// Upkeep gated on these is excluded from that baseline: a debt taken on, a
|
||||||
|
// lifestyle bought, a standing earned. The baseline is the floor underneath
|
||||||
|
// everybody — what a player faces having none of the three.
|
||||||
|
baselineExcludes: ['debt', 'lifestyle', 'reputation'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// How the interface names and formats this pack's state. The UI reads only
|
||||||
|
// this map, so renaming a stat or reordering the header is content's job.
|
||||||
|
display: {
|
||||||
|
'money': { label: 'Money', format: 'money', order: 1 },
|
||||||
|
'debt': { label: 'Debt', format: 'money', order: 2, hideWhenZero: true },
|
||||||
|
'reputation': { label: 'Standing', format: 'meter', order: 3 },
|
||||||
|
'skills.negotiation': { label: 'Negotiation', format: 'meter', order: 4 },
|
||||||
|
'skills.logistics': { label: 'Logistics', format: 'meter', order: 5 },
|
||||||
|
// Hidden until it is bought: a mailroom associate has no lifestyle to
|
||||||
|
// speak of, and a row reading "The bus" from day one is noise.
|
||||||
|
'lifestyle': {
|
||||||
|
label: 'Lifestyle', format: 'tier', order: 6, hideWhenZero: true,
|
||||||
|
labels: ['The bus', 'A car', 'The address'],
|
||||||
|
},
|
||||||
|
// Kept out of the header, and out of the ledger too: `notable` is the
|
||||||
|
// retrospective's own list, and "Notable — changed" tells nobody anything.
|
||||||
|
'notable': { hidden: true, inLedger: false },
|
||||||
|
// Flags do belong in the ledger — a flag flipping is a fact about the
|
||||||
|
// world — but as a sentence rather than as "now true".
|
||||||
|
'flags.took_loan': {
|
||||||
|
label: 'The loan', hidden: true,
|
||||||
|
whenTrue: 'You are carrying a loan now.',
|
||||||
|
whenFalse: 'The loan is settled.',
|
||||||
|
},
|
||||||
|
'flags.holds_the_envelope': {
|
||||||
|
label: 'The envelope', hidden: true,
|
||||||
|
whenTrue: 'The envelope is in your locker.',
|
||||||
|
whenFalse: 'The envelope is back in the system.',
|
||||||
|
},
|
||||||
|
'flags.covered_for_trevor': {
|
||||||
|
label: "Trevor's Thursday", hidden: true,
|
||||||
|
whenTrue: 'Trevor owes you a Thursday.',
|
||||||
|
whenFalse: 'You and Trevor are square.',
|
||||||
|
},
|
||||||
|
'flags.promoted': {
|
||||||
|
label: 'Promotion', hidden: true,
|
||||||
|
whenTrue: 'You are out of the mailroom.',
|
||||||
|
whenFalse: 'Back in the mailroom.',
|
||||||
|
},
|
||||||
|
'flags.recommended_trevor': {
|
||||||
|
label: "Trevor's recommendation", hidden: true,
|
||||||
|
whenTrue: "Trevor's name is in for the mailroom.",
|
||||||
|
whenFalse: "Trevor's name is not in for the mailroom.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
characters,
|
||||||
|
events,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default corporateLadder;
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
// Harrow & Pike's counting house, as it might be staffed.
|
||||||
|
//
|
||||||
|
// Nobody here is fixed. Each role has several people who could fill it, and a
|
||||||
|
// run draws one partner, one chief clerk and two to four clerks from them
|
||||||
|
// (DECISIONS #41), so a second career in the same pack meets a different
|
||||||
|
// office. Every candidate carries the same four attributes the player does,
|
||||||
|
// and a description that should agree with them: a partner written as sharp
|
||||||
|
// has the wits to see through a bluff, and one written as easily flattered
|
||||||
|
// does not.
|
||||||
|
//
|
||||||
|
// Descriptions may name another role in braces, filled from whoever was drawn
|
||||||
|
// into it, so a nephew can be the nephew of whichever partner this run has.
|
||||||
|
|
||||||
|
export const characters = [
|
||||||
|
{
|
||||||
|
id: 'partner',
|
||||||
|
role_type: 'boss',
|
||||||
|
title: 'Senior Partner',
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
name: 'Josiah Crane',
|
||||||
|
description:
|
||||||
|
'Built the house from one room and a borrowed desk. Remembers every figure ' +
|
||||||
|
'he has ever been shown, and forgives very few of them.',
|
||||||
|
attributes: { nerve: 7, wits: 8, charm: 3, resolve: 8 },
|
||||||
|
startingRelationship: { trust: 40 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Harriet Voss',
|
||||||
|
description:
|
||||||
|
'Took over the firm when her husband died and has doubled it since. ' +
|
||||||
|
'Listens to everything and says about a fifth of it.',
|
||||||
|
attributes: { nerve: 6, wits: 9, charm: 5, resolve: 7 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Lionel Ashby',
|
||||||
|
description:
|
||||||
|
'Inherited the chair and would rather be at his club. Easily flattered, ' +
|
||||||
|
'easily bored, and sharper than he lets anyone see.',
|
||||||
|
attributes: { nerve: 4, wits: 6, charm: 7, resolve: 3 },
|
||||||
|
startingRelationship: { regard: 55 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'chief',
|
||||||
|
role_type: 'supervisor',
|
||||||
|
title: 'Chief Clerk',
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
name: 'Silas Pruett',
|
||||||
|
description:
|
||||||
|
'Thirty years at the same high desk. Counts the pen nibs on Fridays and ' +
|
||||||
|
'knows to the penny what the stationery cupboard should hold.',
|
||||||
|
attributes: { nerve: 5, wits: 6, charm: 2, resolve: 9 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Ada Kemble',
|
||||||
|
description:
|
||||||
|
'Runs the floor with a pencil behind her ear and a list, kept in the ' +
|
||||||
|
'same pencil, of everybody\'s lapses.',
|
||||||
|
attributes: { nerve: 6, wits: 7, charm: 4, resolve: 7 },
|
||||||
|
startingRelationship: { trust: 40 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Horace Lumb',
|
||||||
|
description:
|
||||||
|
'Wanted your desk for somebody else\'s nephew and has not forgotten it. ' +
|
||||||
|
'Polite to your face, in a way that takes some effort.',
|
||||||
|
attributes: { nerve: 4, wits: 5, charm: 5, resolve: 6 },
|
||||||
|
startingRelationship: { regard: 35 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'clerk',
|
||||||
|
role_type: 'peer',
|
||||||
|
title: 'Clerk',
|
||||||
|
count: [2, 4],
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
name: 'Tom Bassett',
|
||||||
|
description: 'Cheerful, generous with other people\'s time, and a borrower of small sums he means to repay.',
|
||||||
|
attributes: { nerve: 5, wits: 4, charm: 7, resolve: 3 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Nell Farrow',
|
||||||
|
description: 'The quickest pen on the floor and does not mind who knows it. Wants the next desk up, and soon.',
|
||||||
|
attributes: { nerve: 5, wits: 8, charm: 5, resolve: 6 },
|
||||||
|
startingRelationship: { regard: 40 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Arthur Quill',
|
||||||
|
description: 'Timid, exact, and never wrong about a column of figures. Apologises to doors.',
|
||||||
|
attributes: { nerve: 2, wits: 7, charm: 3, resolve: 7 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Joe Madden',
|
||||||
|
description: 'The loudest laugh in the building and most of the jokes, not all of them kind.',
|
||||||
|
attributes: { nerve: 7, wits: 4, charm: 6, resolve: 3 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Lottie Swain',
|
||||||
|
description: 'Types the partner\'s letters and so knows everything a week early. Tells almost none of it.',
|
||||||
|
attributes: { nerve: 4, wits: 7, charm: 7, resolve: 5 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Percy Dyer',
|
||||||
|
description: 'A nephew of {partner}\'s, sent to learn the business. Has so far learned where the kettle is.',
|
||||||
|
attributes: { nerve: 3, wits: 3, charm: 6, resolve: 2 },
|
||||||
|
startingRelationship: { trust: 40 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Walt Rudge',
|
||||||
|
description: 'Twenty years a clerk and content with it. The one everyone asks, and the last to be thanked.',
|
||||||
|
attributes: { nerve: 6, wits: 6, charm: 4, resolve: 8 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default characters;
|
||||||
@@ -0,0 +1,882 @@
|
|||||||
|
// A clerk's days at Harrow & Pike.
|
||||||
|
//
|
||||||
|
// Every situation here comes from the story-to-pack catalogue (tools/
|
||||||
|
// story-to-pack/probe/catalogue/v3), where it recurred across public-domain
|
||||||
|
// office fiction: Torchy's door, Keefe's pay talk, Pooter's cheeky juniors,
|
||||||
|
// Lorimer's hard boss. The id names the catalogue entry where one fits.
|
||||||
|
//
|
||||||
|
// This pack exists to try checks and a drawn cast (DECISIONS #41). Most options
|
||||||
|
// test one of the four attributes, against a named person or against a set
|
||||||
|
// difficulty, and say what happens either way; the player sees only the odds
|
||||||
|
// in words. Names are written as `{partner}`, `{chief}` and `{clerk}` and filled
|
||||||
|
// from whoever this run drew. An event about "a clerk" lists `clerk` in
|
||||||
|
// `involves`, and the engine picks one present clerk for the turn.
|
||||||
|
|
||||||
|
const moment = (text) => ({ path: 'notable', op: 'push', value: text });
|
||||||
|
|
||||||
|
export const events = [
|
||||||
|
{
|
||||||
|
id: 'first_day',
|
||||||
|
stage: 'desk',
|
||||||
|
weight: 1000,
|
||||||
|
once: true,
|
||||||
|
involves: ['clerk'],
|
||||||
|
description:
|
||||||
|
'Your first morning at Harrow & Pike. {chief} shows you a stool, a pen, a ' +
|
||||||
|
'blotter and the clock, in that order, and leaves you to draw your own ' +
|
||||||
|
'conclusions about which matters most.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'before_the_clock',
|
||||||
|
label: 'Be on the stool before the clock is.',
|
||||||
|
result: '{chief} notices, and writes something down. It may even be good.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'relationships.chief.regard', op: 'add', value: 4 },
|
||||||
|
moment('Began at Harrow & Pike, early.'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'meet_the_floor',
|
||||||
|
label: 'Go round and introduce yourself to {clerk}.',
|
||||||
|
result: '{clerk} shakes your hand and tells you which of the pens leak.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'relationships.{clerk}.regard', op: 'add', value: 5 },
|
||||||
|
moment('Began at Harrow & Pike, and met {clerk} first.'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// The floor: the one event nothing can exclude.
|
||||||
|
{
|
||||||
|
id: 'the_ledgers',
|
||||||
|
stage: 'desk',
|
||||||
|
weight: 40,
|
||||||
|
description:
|
||||||
|
'The ledgers are the same as last week\'s, only longer. {chief} has left a ' +
|
||||||
|
'note on your blotter about the width of your margins.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'head_down',
|
||||||
|
label: 'Keep your head down and work.',
|
||||||
|
result: 'The day goes into the columns, as days here do.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'standing', op: 'add', value: 1 },
|
||||||
|
{ path: 'relationships.chief.trust', op: 'add', value: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'stay_late',
|
||||||
|
label: 'Stay on after six to finish the day book.',
|
||||||
|
result: 'Overtime is paid, at the rate the firm thinks overtime is worth.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: 2 },
|
||||||
|
{ path: 'relationships.chief.regard', op: 'add', value: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ease_off',
|
||||||
|
label: 'Make the day last.',
|
||||||
|
result: 'Nobody notices, today.',
|
||||||
|
effects: [{ path: 'standing', op: 'add', value: -1 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'ask_for_more',
|
||||||
|
stage: 'desk',
|
||||||
|
weight: 25,
|
||||||
|
cooldown: 12,
|
||||||
|
requires: [
|
||||||
|
{ path: 'turn', op: '>=', value: 10 },
|
||||||
|
{ path: 'rises', op: '<', value: 3 },
|
||||||
|
],
|
||||||
|
description:
|
||||||
|
'You have been at the desk long enough to know what the work is worth, and ' +
|
||||||
|
'your lodgings have not got any cheaper. {partner} is in, and alone.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'ask',
|
||||||
|
label: 'Knock, and ask {partner} for a rise.',
|
||||||
|
check: { attribute: 'charm', against: 'partner', opposedBy: 'wits', relationship: 'trust' },
|
||||||
|
success: {
|
||||||
|
result: '{partner} looks at you for a long moment and says the firm can manage something.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'rises', op: 'add', value: 1 },
|
||||||
|
{ path: 'standing', op: 'add', value: 3 },
|
||||||
|
moment('Asked {partner} for a rise, and got it.'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
failure: {
|
||||||
|
result: '{partner} asks whether you have any idea what a clerk is worth. You find you do not.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'relationships.partner.trust', op: 'add', value: -5 },
|
||||||
|
{ path: 'standing', op: 'add', value: -2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hint',
|
||||||
|
label: 'Say nothing directly; mention the price of lodgings.',
|
||||||
|
check: { attribute: 'wits', difficulty: 5 },
|
||||||
|
success: {
|
||||||
|
result: '{partner} takes the point and, better, thinks it was a delicate one.',
|
||||||
|
effects: [{ path: 'relationships.partner.regard', op: 'add', value: 4 }],
|
||||||
|
},
|
||||||
|
failure: {
|
||||||
|
result: '{partner} agrees that lodgings are very dear and goes back to the post.',
|
||||||
|
effects: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'not_yet',
|
||||||
|
label: 'Wait for a better moment.',
|
||||||
|
result: 'There will be one. There usually is.',
|
||||||
|
effects: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'called_in',
|
||||||
|
stage: 'desk',
|
||||||
|
weight: 30,
|
||||||
|
cooldown: 8,
|
||||||
|
involves: ['clerk'],
|
||||||
|
description:
|
||||||
|
'{chief} has found a column that does not add up, and it is in your hand. You ' +
|
||||||
|
'are to explain it, standing, in front of the high desk.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'own_up',
|
||||||
|
label: 'Say plainly that it was your mistake.',
|
||||||
|
check: { attribute: 'resolve', difficulty: 4 },
|
||||||
|
success: {
|
||||||
|
result: 'It is noted, and then, which is rarer, it is let go.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'relationships.partner.trust', op: 'add', value: 4 },
|
||||||
|
{ path: 'attributes.resolve', op: 'add', value: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
failure: {
|
||||||
|
result: 'You start well and finish apologising for the weather.',
|
||||||
|
effects: [{ path: 'standing', op: 'add', value: -2 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'argue',
|
||||||
|
label: 'Argue that the figures were right.',
|
||||||
|
check: { attribute: 'wits', against: 'chief' },
|
||||||
|
success: {
|
||||||
|
result: 'They were. {chief} checks twice and hands the page back without a word.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'standing', op: 'add', value: 3 },
|
||||||
|
{ path: 'relationships.chief.regard', op: 'add', value: -4 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
failure: {
|
||||||
|
result: 'They were not, and now everybody knows you thought they were.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'standing', op: 'add', value: -4 },
|
||||||
|
{ path: 'relationships.partner.trust', op: 'add', value: -3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'blame',
|
||||||
|
label: 'Say the figures came from {clerk}\'s desk.',
|
||||||
|
check: { attribute: 'wits', against: 'chief' },
|
||||||
|
success: {
|
||||||
|
result: '{chief} goes to find {clerk}. You do not watch.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'standing', op: 'add', value: 1 },
|
||||||
|
{ path: 'relationships.{clerk}.trust', op: 'add', value: -8 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
failure: {
|
||||||
|
result: '{chief} has the day book open at your page before you finish the sentence.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'standing', op: 'add', value: -5 },
|
||||||
|
{ path: 'relationships.{clerk}.trust', op: 'add', value: -10 },
|
||||||
|
{ path: 'relationships.partner.trust', op: 'add', value: -5 },
|
||||||
|
moment('Tried to put a mistake on {clerk}, and was found out.'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'the_door',
|
||||||
|
stage: 'desk',
|
||||||
|
weight: 30,
|
||||||
|
cooldown: 6,
|
||||||
|
description:
|
||||||
|
'A caller in a good coat wants {partner} and will not give his name. You ' +
|
||||||
|
'were told the partner was not to be disturbed before three.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'turn_away',
|
||||||
|
label: 'Tell him the partner is out.',
|
||||||
|
check: { attribute: 'nerve', difficulty: 5 },
|
||||||
|
success: {
|
||||||
|
result: 'He goes. {partner} hears of it later and approves of the lie.',
|
||||||
|
effects: [{ path: 'relationships.partner.trust', op: 'add', value: 3 }],
|
||||||
|
},
|
||||||
|
failure: {
|
||||||
|
result: 'He sits down to wait, and {partner} comes out at a quarter past and finds him there.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'relationships.partner.regard', op: 'add', value: -3 },
|
||||||
|
{ path: 'standing', op: 'add', value: -1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'size_him_up',
|
||||||
|
label: 'Size him up and decide for yourself.',
|
||||||
|
check: { attribute: 'wits', difficulty: 6 },
|
||||||
|
success: {
|
||||||
|
result: 'You read him right. He mattered, and he got in, and {partner} knows who let him.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'relationships.partner.trust', op: 'add', value: 5 },
|
||||||
|
{ path: 'standing', op: 'add', value: 2 },
|
||||||
|
moment('Judged a caller at the door, and judged him right.'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
failure: {
|
||||||
|
result: 'He was selling encyclopaedias, and {partner} bought one to get rid of him.',
|
||||||
|
effects: [{ path: 'relationships.partner.trust', op: 'add', value: -4 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pass_it_on',
|
||||||
|
label: 'Fetch {chief} and let it be somebody else\'s decision.',
|
||||||
|
result: '{chief} deals with it, and with you, briefly.',
|
||||||
|
effects: [{ path: 'standing', op: 'add', value: -1 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'needled',
|
||||||
|
stage: 'desk',
|
||||||
|
weight: 40,
|
||||||
|
cooldown: 4,
|
||||||
|
involves: ['clerk'],
|
||||||
|
description:
|
||||||
|
'{clerk} reads your last letter aloud in a voice meant to be yours, and the ' +
|
||||||
|
'floor laughs, and keeps laughing longer than it deserves.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'let_it_go',
|
||||||
|
label: 'Let it pass.',
|
||||||
|
result: 'It passes. They will remember that you let it.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'relationships.{clerk}.regard', op: 'add', value: 1 },
|
||||||
|
{ path: 'standing', op: 'add', value: -1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'retort',
|
||||||
|
label: 'Answer {clerk} back, with the floor listening.',
|
||||||
|
check: { attribute: 'wits', against: '{clerk}', relationship: 'regard' },
|
||||||
|
success: {
|
||||||
|
result: 'It gets a bigger laugh than the letter did, and {clerk} laughs too.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'standing', op: 'add', value: 2 },
|
||||||
|
{ path: 'relationships.{clerk}.regard', op: 'add', value: 3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
failure: {
|
||||||
|
result: 'It comes out too sharp, and the laugh turns on you.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'standing', op: 'add', value: -2 },
|
||||||
|
{ path: 'relationships.{clerk}.regard', op: 'add', value: -3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'report',
|
||||||
|
label: 'Mention it to {chief}.',
|
||||||
|
result: '{chief} has a word. {clerk} knows where it came from.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'relationships.chief.trust', op: 'add', value: 2 },
|
||||||
|
{ path: 'relationships.{clerk}.trust', op: 'add', value: -6 },
|
||||||
|
{ path: 'standing', op: 'add', value: -1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'red_ink',
|
||||||
|
stage: 'desk',
|
||||||
|
weight: 30,
|
||||||
|
cooldown: 6,
|
||||||
|
description:
|
||||||
|
'{chief} has gone over your morning\'s work in red ink, and left the page ' +
|
||||||
|
'open on the high desk where the whole floor can see it.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'swallow_it',
|
||||||
|
label: 'Take it, and do the page again.',
|
||||||
|
result: 'You do it again. {chief} initials it without looking up.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'relationships.chief.regard', op: 'add', value: 2 },
|
||||||
|
{ path: 'standing', op: 'add', value: -1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'answer_back',
|
||||||
|
label: 'Point out that the red ink is wrong.',
|
||||||
|
check: { attribute: 'wits', against: 'chief' },
|
||||||
|
success: {
|
||||||
|
result: 'It is, in two places. {chief} says nothing, which from {chief.first} is a concession.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'standing', op: 'add', value: 2 },
|
||||||
|
{ path: 'relationships.chief.regard', op: 'add', value: 3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
failure: {
|
||||||
|
result: 'The red ink is right. It usually is.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'standing', op: 'add', value: -2 },
|
||||||
|
{ path: 'relationships.chief.regard', op: 'add', value: -5 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'over_the_head',
|
||||||
|
label: 'Take the page to {partner}.',
|
||||||
|
check: { attribute: 'charm', against: 'partner', opposedBy: 'resolve', relationship: 'trust' },
|
||||||
|
success: {
|
||||||
|
result: '{partner} reads it, and has a word with {chief}, and {chief} has one with you.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'relationships.partner.trust', op: 'add', value: 3 },
|
||||||
|
{ path: 'relationships.chief.regard', op: 'add', value: -8 },
|
||||||
|
{ path: 'standing', op: 'add', value: 2 },
|
||||||
|
moment('Went over {chief}\'s head, and it worked.'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
failure: {
|
||||||
|
result: '{partner} says the chief clerk runs the floor, and asks whether there was anything else.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'relationships.partner.trust', op: 'add', value: -4 },
|
||||||
|
{ path: 'relationships.chief.regard', op: 'add', value: -8 },
|
||||||
|
{ path: 'standing', op: 'add', value: -3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'sure_thing',
|
||||||
|
stage: 'desk',
|
||||||
|
weight: 15,
|
||||||
|
cooldown: 20,
|
||||||
|
description:
|
||||||
|
'A pleasant man at the chop-house has a share in a tramway in Brazil going ' +
|
||||||
|
'begging. Twelve dollars today, he says, will be forty by Christmas.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'buy_in',
|
||||||
|
label: 'Put in twelve dollars.',
|
||||||
|
effects: [{ path: 'money', op: 'add', value: -12 }],
|
||||||
|
check: { attribute: 'wits', difficulty: 7 },
|
||||||
|
success: {
|
||||||
|
result: 'Against every expectation, including his, it pays.',
|
||||||
|
effects: [{ path: 'money', op: 'add', value: 30 }, moment('Bought a share in a tramway, and it paid.')],
|
||||||
|
},
|
||||||
|
failure: {
|
||||||
|
result: 'There is no tramway. There is, briefly, a man.',
|
||||||
|
effects: [moment('Bought a share in a tramway that did not exist.')],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'see_through',
|
||||||
|
label: 'Ask him the one question he cannot answer.',
|
||||||
|
check: { attribute: 'wits', difficulty: 6 },
|
||||||
|
success: {
|
||||||
|
result: 'He finds he has an appointment. You find you are sharper than you were.',
|
||||||
|
effects: [{ path: 'attributes.wits', op: 'add', value: 1 }, moment('Saw through a tramway in Brazil.')],
|
||||||
|
},
|
||||||
|
failure: {
|
||||||
|
result: 'He answers it smoothly, and you are left not knowing.',
|
||||||
|
effects: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'decline',
|
||||||
|
label: 'Keep your money in your pocket.',
|
||||||
|
result: 'He is sorry for you, he says, and goes to find someone less careful.',
|
||||||
|
effects: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'the_round',
|
||||||
|
stage: 'desk',
|
||||||
|
weight: 30,
|
||||||
|
cooldown: 5,
|
||||||
|
involves: ['clerk'],
|
||||||
|
description:
|
||||||
|
'{clerk} and the others are going to the chop-house after work, and they ' +
|
||||||
|
'have kept you a place. The round there is not cheap.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'go',
|
||||||
|
label: 'Go, and stand your round.',
|
||||||
|
result: 'A good evening, and a dear one.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: -5 },
|
||||||
|
{ path: 'relationships.{clerk}.regard', op: 'add', value: 4 },
|
||||||
|
{ path: 'standing', op: 'add', value: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'talk_your_way',
|
||||||
|
label: 'Go, and talk your way out of the round.',
|
||||||
|
check: { attribute: 'charm', against: '{clerk}', opposedBy: 'wits' },
|
||||||
|
success: {
|
||||||
|
result: 'Nobody notices you never reached for your purse, and {clerk} laughs at all your jokes.',
|
||||||
|
effects: [{ path: 'relationships.{clerk}.regard', op: 'add', value: 3 }],
|
||||||
|
},
|
||||||
|
failure: {
|
||||||
|
result: '{clerk} notices, and says so, and you pay for the round and the remark.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: -5 },
|
||||||
|
{ path: 'relationships.{clerk}.regard', op: 'add', value: -2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'excuse',
|
||||||
|
label: 'Make an excuse and go home.',
|
||||||
|
result: 'They go without you. They will ask again, perhaps.',
|
||||||
|
effects: [{ path: 'relationships.{clerk}.regard', op: 'add', value: -2 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'night_school',
|
||||||
|
stage: 'desk',
|
||||||
|
weight: 25,
|
||||||
|
cooldown: 5,
|
||||||
|
requires: [{ path: 'money', op: '>=', value: 3 }],
|
||||||
|
description:
|
||||||
|
'A card in the tram window: evening classes in bookkeeping, in the art of ' +
|
||||||
|
'address, and at the boxing club behind the Institute. Three dollars a course.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'bookkeeping',
|
||||||
|
label: 'Take the bookkeeping course.',
|
||||||
|
result: 'Double entry, twice a week. Your columns begin to add up before you do.',
|
||||||
|
effects: [{ path: 'money', op: 'add', value: -3 }, { path: 'attributes.wits', op: 'add', value: 1 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'address',
|
||||||
|
label: 'Take the art of address.',
|
||||||
|
result: 'You learn when to say nothing, which is most of it.',
|
||||||
|
effects: [{ path: 'money', op: 'add', value: -3 }, { path: 'attributes.charm', op: 'add', value: 1 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'boxing',
|
||||||
|
label: 'Join the boxing club.',
|
||||||
|
result: 'You are hit in the face by a draper. It does you a world of good.',
|
||||||
|
effects: [{ path: 'money', op: 'add', value: -3 }, { path: 'attributes.nerve', op: 'add', value: 1 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'not_this_term',
|
||||||
|
label: 'Not this term.',
|
||||||
|
result: 'The card is still there next week.',
|
||||||
|
effects: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'stand_in',
|
||||||
|
stage: 'desk',
|
||||||
|
weight: 15,
|
||||||
|
cooldown: 10,
|
||||||
|
requires: [{ path: 'standing', op: '>=', value: 45 }],
|
||||||
|
description:
|
||||||
|
'{partner} is laid up and sends you, of all people, to sit in on the meeting ' +
|
||||||
|
'with the shipping agents. You are to listen, and to say as little as possible.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'listen',
|
||||||
|
label: 'Listen, and take good notes.',
|
||||||
|
result: 'Your notes are better than the minutes. {partner} keeps them.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'relationships.partner.trust', op: 'add', value: 2 },
|
||||||
|
{ path: 'standing', op: 'add', value: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'speak_up',
|
||||||
|
label: 'Say the thing you noticed in their figures.',
|
||||||
|
check: { attribute: 'wits', difficulty: 7 },
|
||||||
|
success: {
|
||||||
|
result: 'You were right, the agents know it, and so, by the afternoon, does {partner}.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'relationships.partner.trust', op: 'add', value: 6 },
|
||||||
|
{ path: 'standing', op: 'add', value: 5 },
|
||||||
|
moment('Spoke up at the agents\' meeting in {partner}\'s place, and was right.'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
failure: {
|
||||||
|
result: 'You were wrong in front of people who were paid to be right.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'relationships.partner.trust', op: 'add', value: -4 },
|
||||||
|
{ path: 'standing', op: 'add', value: -3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'charm_them',
|
||||||
|
label: 'Charm the agents over lunch.',
|
||||||
|
check: { attribute: 'charm', difficulty: 6 },
|
||||||
|
success: {
|
||||||
|
result: 'They like you. They say so to {partner}, which is the point.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'relationships.partner.regard', op: 'add', value: 5 },
|
||||||
|
{ path: 'standing', op: 'add', value: 3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
failure: {
|
||||||
|
result: 'The lunch is long, and you pay for it.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: -4 },
|
||||||
|
{ path: 'standing', op: 'add', value: -1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'lost_bill',
|
||||||
|
stage: 'desk',
|
||||||
|
weight: 20,
|
||||||
|
cooldown: 12,
|
||||||
|
involves: ['clerk'],
|
||||||
|
description:
|
||||||
|
'{clerk} has lost a bill of lading, and {chief} is talking about a letter to ' +
|
||||||
|
'the partner. {clerk} has not looked up from the desk all morning.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'speak_for',
|
||||||
|
label: 'Put {clerk}\'s case to {partner} yourself.',
|
||||||
|
check: { attribute: 'charm', against: 'partner', opposedBy: 'resolve', relationship: 'trust' },
|
||||||
|
success: {
|
||||||
|
result: '{partner} hears you out and lets it go. {clerk} does not forget who spoke.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'relationships.{clerk}.trust', op: 'add', value: 10 },
|
||||||
|
{ path: 'relationships.partner.regard', op: 'add', value: 2 },
|
||||||
|
moment('Spoke up for {clerk}, and it held.'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
failure: {
|
||||||
|
result: '{partner} asks whether you would like to share the letter.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'relationships.partner.trust', op: 'add', value: -3 },
|
||||||
|
{ path: 'relationships.{clerk}.trust', op: 'add', value: 4 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'help_look',
|
||||||
|
label: 'Stay late and help look for it.',
|
||||||
|
check: { attribute: 'resolve', difficulty: 5 },
|
||||||
|
success: {
|
||||||
|
result: 'It is in the wrong drawer, under the right one. You find it at nine.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'relationships.{clerk}.trust', op: 'add', value: 8 },
|
||||||
|
{ path: 'relationships.{clerk}.regard', op: 'add', value: 4 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
failure: {
|
||||||
|
result: 'It is not found, but {clerk} saw you look.',
|
||||||
|
effects: [{ path: 'relationships.{clerk}.regard', op: 'add', value: 2 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'keep_out',
|
||||||
|
label: 'Keep out of it.',
|
||||||
|
result: 'The letter goes. So, very nearly, does {clerk}.',
|
||||||
|
effects: [{ path: 'relationships.{clerk}.trust', op: 'add', value: -3 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'the_butt',
|
||||||
|
stage: 'desk',
|
||||||
|
weight: 20,
|
||||||
|
cooldown: 8,
|
||||||
|
involves: ['clerk'],
|
||||||
|
description:
|
||||||
|
'Somebody has pinned a card to your back, and you have walked the length of ' +
|
||||||
|
'the floor to {chief}\'s desk wearing it. {clerk} cannot keep a straight face.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'laugh_along',
|
||||||
|
label: 'Laugh louder than anybody.',
|
||||||
|
check: { attribute: 'charm', difficulty: 4 },
|
||||||
|
success: {
|
||||||
|
result: 'It becomes your joke instead of theirs.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'standing', op: 'add', value: 1 },
|
||||||
|
{ path: 'relationships.{clerk}.regard', op: 'add', value: 3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
failure: {
|
||||||
|
result: 'The laugh comes out wrong, and everyone hears that it did.',
|
||||||
|
effects: [{ path: 'standing', op: 'add', value: -2 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dignity',
|
||||||
|
label: 'Take it off and say nothing at all.',
|
||||||
|
check: { attribute: 'resolve', difficulty: 4 },
|
||||||
|
success: {
|
||||||
|
result: 'It is not funny for long, when nobody is laughing at the front.',
|
||||||
|
effects: [{ path: 'standing', op: 'add', value: 1 }],
|
||||||
|
},
|
||||||
|
failure: {
|
||||||
|
result: 'Your ears give you away.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'standing', op: 'add', value: -1 },
|
||||||
|
{ path: 'relationships.{clerk}.regard', op: 'add', value: -2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'get_even',
|
||||||
|
label: 'Resolve to do something about {clerk} tomorrow.',
|
||||||
|
result: 'You do. It is not as funny, and {clerk} knows exactly who it was.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'relationships.{clerk}.regard', op: 'add', value: -2 },
|
||||||
|
{ path: 'relationships.{clerk}.trust', op: 'add', value: -3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'saturday',
|
||||||
|
stage: 'desk',
|
||||||
|
weight: 60,
|
||||||
|
cooldown: 5,
|
||||||
|
requires: [{ path: 'calendar.name', op: '==', value: 'Saturday' }],
|
||||||
|
description:
|
||||||
|
'Saturday, and the office shuts at one. The pay envelope is in your inside ' +
|
||||||
|
'pocket, and the whole of the afternoon is in front of you.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'new_collar',
|
||||||
|
label: 'Buy the collar the senior clerks wear.',
|
||||||
|
result: 'It pinches, and it is noticed.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: -5 },
|
||||||
|
{ path: 'standing', op: 'add', value: 2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'good_dinner',
|
||||||
|
label: 'Dine somewhere with a tablecloth.',
|
||||||
|
result: 'A dinner you will describe, later, to people who were not there.',
|
||||||
|
effects: [{ path: 'money', op: 'add', value: -4 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pay_back',
|
||||||
|
label: 'Pay back what you owe the floor.',
|
||||||
|
requires: [{ path: 'owed', op: '>', value: 0 }],
|
||||||
|
whenLocked: 'hide',
|
||||||
|
result: 'Squared, or nearer it. It is a better feeling than the dinner would have been.',
|
||||||
|
// The money is read from the balance before either moves, so a small
|
||||||
|
// debt costs what it is and no more (DECISIONS #38).
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: { from: 'owed', times: -1, cap: 10 } },
|
||||||
|
{ path: 'owed', op: 'add', value: { from: 'owed', times: -1, cap: 10 } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'straight_home',
|
||||||
|
label: 'Straight home, envelope sealed.',
|
||||||
|
result: 'The envelope is lighter than it was in your imagination, but it is all there.',
|
||||||
|
effects: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'family_errand',
|
||||||
|
stage: 'desk',
|
||||||
|
weight: 20,
|
||||||
|
cooldown: 8,
|
||||||
|
involves: ['clerk'],
|
||||||
|
description:
|
||||||
|
'A parcel for {partner}\'s sister has to cross the city before four, in the ' +
|
||||||
|
'rain, and {partner} has asked for you by name. It is not in the job.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'take_it',
|
||||||
|
label: 'Take it yourself, and pay your own tram.',
|
||||||
|
result: 'The sister gives you tea, and a message for {partner} that is more about you than the parcel.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: -1 },
|
||||||
|
{ path: 'relationships.partner.regard', op: 'add', value: 4 },
|
||||||
|
{ path: 'standing', op: 'add', value: -1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'make_an_impression',
|
||||||
|
label: 'Take it, and make yourself agreeable when you get there.',
|
||||||
|
check: { attribute: 'charm', difficulty: 5 },
|
||||||
|
success: {
|
||||||
|
result: 'The sister writes to {partner} that evening. You are mentioned warmly.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'relationships.partner.regard', op: 'add', value: 6 },
|
||||||
|
{ path: 'relationships.partner.trust', op: 'add', value: 2 },
|
||||||
|
moment('Carried a parcel for {partner}\'s sister, and made a friend of her.'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
failure: {
|
||||||
|
result: 'You drip on the carpet and say something about the weather twice.',
|
||||||
|
effects: [{ path: 'relationships.partner.regard', op: 'add', value: 1 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hand_it_on',
|
||||||
|
label: 'Hand it on to {clerk}, who is going that way.',
|
||||||
|
result: '{clerk} goes, and gets the tea, and does not say whose errand it was.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'relationships.{clerk}.regard', op: 'add', value: -3 },
|
||||||
|
{ path: 'relationships.partner.regard', op: 'add', value: -2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'window_desk',
|
||||||
|
stage: 'desk',
|
||||||
|
weight: 20,
|
||||||
|
cooldown: 10,
|
||||||
|
involves: ['clerk'],
|
||||||
|
description:
|
||||||
|
'The desk by the window has come free. {chief} says it will go to whoever ' +
|
||||||
|
'deserves it, and {clerk} has been in before the clock all week.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'outwork',
|
||||||
|
label: 'Come in earlier than {clerk} does.',
|
||||||
|
check: { attribute: 'resolve', against: '{clerk}' },
|
||||||
|
success: {
|
||||||
|
result: 'You are there before the cleaners. The desk is yours, and so is the draught.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'standing', op: 'add', value: 4 },
|
||||||
|
{ path: 'relationships.{clerk}.regard', op: 'add', value: -3 },
|
||||||
|
moment('Won the desk by the window from {clerk}.'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
failure: {
|
||||||
|
result: '{clerk} was there before you, every morning, and has the desk.',
|
||||||
|
effects: [{ path: 'standing', op: 'add', value: -1 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'word_with_chief',
|
||||||
|
label: 'Have a quiet word with {chief}.',
|
||||||
|
check: { attribute: 'charm', against: 'chief', opposedBy: 'wits', relationship: 'regard' },
|
||||||
|
success: {
|
||||||
|
result: '{chief} decides the matter in your favour, and makes sure {clerk} knows why.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'standing', op: 'add', value: 3 },
|
||||||
|
{ path: 'relationships.{clerk}.trust', op: 'add', value: -6 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
failure: {
|
||||||
|
result: '{chief} writes your name down, next to the word "canvassing".',
|
||||||
|
effects: [
|
||||||
|
{ path: 'relationships.chief.trust', op: 'add', value: -4 },
|
||||||
|
{ path: 'standing', op: 'add', value: -2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'let_them_have_it',
|
||||||
|
label: 'Let {clerk} have it.',
|
||||||
|
result: '{clerk} takes the desk and, to your surprise, remembers who did not fight for it.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'relationships.{clerk}.regard', op: 'add', value: 5 },
|
||||||
|
{ path: 'relationships.{clerk}.trust', op: 'add', value: 3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'hard_times',
|
||||||
|
stage: 'desk',
|
||||||
|
weight: 400,
|
||||||
|
cooldown: 2,
|
||||||
|
requires: [{ path: 'money', op: '<', value: 0 }],
|
||||||
|
involves: ['clerk'],
|
||||||
|
description:
|
||||||
|
'The landlady has knocked twice about the week, and the purse under your ' +
|
||||||
|
'pillow is empty. It is a long walk to Saturday.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'copying',
|
||||||
|
label: 'Take in copying work to do at night.',
|
||||||
|
result: 'Law papers, by lamp, at a halfpenny a folio. It keeps the landlady quiet.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: 8 },
|
||||||
|
{ path: 'attributes.resolve', op: 'add', value: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'borrow',
|
||||||
|
label: 'Ask {clerk} for the loan of ten dollars.',
|
||||||
|
check: { attribute: 'charm', against: '{clerk}', opposedBy: 'resolve', relationship: 'trust' },
|
||||||
|
success: {
|
||||||
|
result: '{clerk} lends it, and tells you not to mention it, and you will not forget.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: 10 },
|
||||||
|
{ path: 'owed', op: 'add', value: 10 },
|
||||||
|
{ path: 'relationships.{clerk}.trust', op: 'add', value: -2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
failure: {
|
||||||
|
result: '{clerk} is short too, but finds you three, and makes a face about it.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: 3 },
|
||||||
|
{ path: 'owed', op: 'add', value: 3 },
|
||||||
|
{ path: 'relationships.{clerk}.regard', op: 'add', value: -3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pawn_the_watch',
|
||||||
|
label: 'Pawn your father\'s watch.',
|
||||||
|
requires: [{ path: 'flags.pawned_watch', op: '==', value: false }],
|
||||||
|
whenLocked: 'hide',
|
||||||
|
result: 'Twelve dollars and a ticket. You will get it back. Everybody says that.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: 12 },
|
||||||
|
{ path: 'flags.pawned_watch', op: 'set', value: true },
|
||||||
|
moment('Pawned your father\'s watch to pay the landlady.'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default events;
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
// The Counting House — a prototype pack for checks and a drawn cast.
|
||||||
|
//
|
||||||
|
// Harrow & Pike, a trading firm's counting house, somewhere about 1910. You
|
||||||
|
// are the newest clerk. The situations are the ones public-domain office
|
||||||
|
// fiction kept returning to (tools/story-to-pack, catalogue v3); the people are
|
||||||
|
// drawn fresh each run from several candidates per role, two to four clerks
|
||||||
|
// among them; and most choices are a check, shown to the player only as odds
|
||||||
|
// in words. See DECISIONS #41.
|
||||||
|
//
|
||||||
|
// One stage, deliberately: this pack exists to find out whether chance and a
|
||||||
|
// varying cast make the same situations worth replaying, before a whole career
|
||||||
|
// is built on them.
|
||||||
|
|
||||||
|
import { characters } from './characters.js';
|
||||||
|
import { events } from './events.js';
|
||||||
|
|
||||||
|
export const countingHouse = {
|
||||||
|
id: 'countinghouse',
|
||||||
|
name: 'The Counting House',
|
||||||
|
blurb:
|
||||||
|
'Harrow & Pike, about 1910: a stool, a pen, a chief clerk with red ink, and a ' +
|
||||||
|
'different office every time you start.',
|
||||||
|
version: '0.1.0',
|
||||||
|
turnUnit: 'day',
|
||||||
|
startingStage: 'desk',
|
||||||
|
peopleHeading: 'Harrow & Pike',
|
||||||
|
|
||||||
|
stages: {
|
||||||
|
desk: { label: 'The Desk', title: 'Junior Clerk' },
|
||||||
|
},
|
||||||
|
|
||||||
|
// A six-day week: the office shuts at one on Saturday, and that is pay day.
|
||||||
|
calendar: {
|
||||||
|
cycleLength: 6,
|
||||||
|
cycleName: 'Week',
|
||||||
|
unitNames: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// The four attributes, the same for the player and for everyone in the cast.
|
||||||
|
// A pack may call them what it likes; these are the prototype's names.
|
||||||
|
attributes: {
|
||||||
|
nerve: { label: 'Nerve' },
|
||||||
|
wits: { label: 'Wits' },
|
||||||
|
charm: { label: 'Charm' },
|
||||||
|
resolve: { label: 'Resolve' },
|
||||||
|
},
|
||||||
|
|
||||||
|
// How attributes turn into chances. The player sees only the words.
|
||||||
|
// chance = 50 + 8 x (yours - theirs) + 0.4 x (their regard - 50), from 5 to 95
|
||||||
|
// A new clerk (3) against a sharp partner (8) is 10%, a long shot; against an
|
||||||
|
// ordinary task (4) it is 42%. Each point of training is worth eight.
|
||||||
|
checks: {
|
||||||
|
base: 50,
|
||||||
|
perPoint: 8,
|
||||||
|
unopposed: 4,
|
||||||
|
relationshipPerPoint: 0.4,
|
||||||
|
relationshipCentre: 50,
|
||||||
|
floor: 5,
|
||||||
|
ceiling: 95,
|
||||||
|
odds: [
|
||||||
|
{ upTo: 20, words: 'a long shot' },
|
||||||
|
{ upTo: 40, words: 'unlikely' },
|
||||||
|
{ upTo: 60, words: 'an even chance' },
|
||||||
|
{ upTo: 80, words: 'likely' },
|
||||||
|
{ upTo: 95, words: 'a safe bet' },
|
||||||
|
],
|
||||||
|
said: { success: 'and it came off.', failure: 'and it did not come off.' },
|
||||||
|
},
|
||||||
|
|
||||||
|
state: {
|
||||||
|
'money': { initial: 20, min: -200, max: 100000 },
|
||||||
|
'standing': { initial: 40, min: 0, max: 100 },
|
||||||
|
'attributes.nerve': { initial: 3, min: 0, max: 10 },
|
||||||
|
'attributes.wits': { initial: 3, min: 0, max: 10 },
|
||||||
|
'attributes.charm': { initial: 3, min: 0, max: 10 },
|
||||||
|
'attributes.resolve': { initial: 3, min: 0, max: 10 },
|
||||||
|
// Borrowed from the other clerks when times are hard, repaid on a Saturday.
|
||||||
|
'owed': { initial: 0, min: 0, max: 100000 },
|
||||||
|
// Rises won from the partner: each is a permanent addition to the wage.
|
||||||
|
'rises': { initial: 0, min: 0, max: 3 },
|
||||||
|
'flags.pawned_watch': { initial: false },
|
||||||
|
'notable': { initial: [] },
|
||||||
|
},
|
||||||
|
|
||||||
|
// Two axes, not one: whether they believe you, and what they think of you.
|
||||||
|
relationshipStats: {
|
||||||
|
trust: { label: 'Trusts you', initial: 50, min: 0, max: 100 },
|
||||||
|
regard: { label: 'Thinks well of you', initial: 50, min: 0, max: 100 },
|
||||||
|
},
|
||||||
|
|
||||||
|
characters,
|
||||||
|
events,
|
||||||
|
|
||||||
|
// Per six-day week: -12 board and lodging +16 wages = +4.
|
||||||
|
// Each rise adds four; a rise is earned, so it is outside the baseline.
|
||||||
|
upkeep: [
|
||||||
|
{ label: 'Board and lodging', effects: [{ path: 'money', op: 'add', value: -2 }] },
|
||||||
|
{
|
||||||
|
label: 'Wages from Harrow & Pike',
|
||||||
|
every: 6,
|
||||||
|
offset: 5,
|
||||||
|
effects: [{ path: 'money', op: 'add', value: 16 }],
|
||||||
|
},
|
||||||
|
...[1, 2, 3].map((n) => ({
|
||||||
|
label: n === 1 ? 'Your rise' : `Your rise (${n})`,
|
||||||
|
every: 6,
|
||||||
|
offset: 5,
|
||||||
|
requires: [{ path: 'rises', op: '>=', value: n }],
|
||||||
|
effects: [{ path: 'money', op: 'add', value: 4 }],
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
|
||||||
|
tuning: {
|
||||||
|
baselinePerCycle: { desk: [0, 10] },
|
||||||
|
baselineExcludes: ['rises'],
|
||||||
|
},
|
||||||
|
|
||||||
|
display: {
|
||||||
|
'money': { label: 'Money', format: 'money', order: 1 },
|
||||||
|
'owed': { label: 'Owed to the floor', format: 'money', order: 2, hideWhenZero: true },
|
||||||
|
'standing': { label: 'Standing', format: 'meter', order: 2 },
|
||||||
|
'attributes.nerve': { label: 'Nerve', format: 'meter', order: 3 },
|
||||||
|
'attributes.wits': { label: 'Wits', format: 'meter', order: 4 },
|
||||||
|
'attributes.charm': { label: 'Charm', format: 'meter', order: 5 },
|
||||||
|
'attributes.resolve': { label: 'Resolve', format: 'meter', order: 6 },
|
||||||
|
'rises': { label: 'Rises', order: 7, hideWhenZero: true },
|
||||||
|
'notable': { hidden: true, inLedger: false },
|
||||||
|
'flags.pawned_watch': {
|
||||||
|
label: 'Your father\'s watch', hidden: true,
|
||||||
|
whenTrue: 'Your father\'s watch is at the pawnbroker\'s.',
|
||||||
|
whenFalse: 'You have your father\'s watch back.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default countingHouse;
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// Cold Fork, as staffed.
|
||||||
|
//
|
||||||
|
// `description` is pure colour — the engine never reads it. What it does read
|
||||||
|
// is `startingRelationship`, where a character departs from the pack defaults.
|
||||||
|
// Nobody in a freight yard trusts a new hand on sight, and Dutch has had the
|
||||||
|
// good runs to himself for two years and can count.
|
||||||
|
|
||||||
|
export const characters = [
|
||||||
|
{
|
||||||
|
id: 'hollis',
|
||||||
|
name: 'Hollis Vane',
|
||||||
|
title: 'Vane & Co. Freight',
|
||||||
|
role_type: 'boss',
|
||||||
|
description:
|
||||||
|
'Owns the outfit, the yard, and the note on both. Settles up in the ' +
|
||||||
|
'doorway of the office rather than inside it, which saves having to ' +
|
||||||
|
'offer anyone a chair.',
|
||||||
|
startingRelationship: { trusts_you: 35 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dutch',
|
||||||
|
name: 'Dutch Rennels',
|
||||||
|
title: 'Teamster',
|
||||||
|
role_type: 'rival',
|
||||||
|
description:
|
||||||
|
'Two years on the Ridge road and has driven it in every kind of weather ' +
|
||||||
|
'there is, as he will tell you. Keeps his own tally of who took which ' +
|
||||||
|
'run, in a book, in pencil.',
|
||||||
|
startingRelationship: { trusts_you: 30, wary_of_you: 25 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ward',
|
||||||
|
name: 'Marshal Ward',
|
||||||
|
title: 'Town Marshal',
|
||||||
|
role_type: 'authority',
|
||||||
|
description:
|
||||||
|
'Came to Cold Fork to retire and has been six years about it. Knows who ' +
|
||||||
|
'owes the store and how much, and considers that most of the job.',
|
||||||
|
startingRelationship: { trusts_you: 25, wary_of_you: 30 },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default characters;
|
||||||
@@ -0,0 +1,929 @@
|
|||||||
|
// 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 man who is fighting.',
|
||||||
|
result:
|
||||||
|
'It stops. Your sleeve does not survive it and neither does a ' +
|
||||||
|
'table, and the whole street knows by morning which it was.',
|
||||||
|
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 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Five more days in the office, added after the first real play of this
|
||||||
|
// stage: 69 turns saw 13 distinct situations, and `office_day` and
|
||||||
|
// `the_cells` were a sixth of the stage each. See DECISIONS #40.
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'stage_came_in_short',
|
||||||
|
stage: 'deputy',
|
||||||
|
weight: 50,
|
||||||
|
cooldown: 7,
|
||||||
|
description:
|
||||||
|
'The stage came in with the strongbox eleven pounds lighter than the ' +
|
||||||
|
'manifest, and the driver swears on his mother that he never once ' +
|
||||||
|
'stopped between the Wells and here.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'wire_the_county',
|
||||||
|
label: 'Wire the county and let them send a man.',
|
||||||
|
result:
|
||||||
|
'The county sends a man in nine days, by which time the driver has ' +
|
||||||
|
'told the story often enough to believe every word of it.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.trade', op: 'add', value: 2 },
|
||||||
|
{ path: 'relationships.ward.trusts_you', op: 'add', value: 2 },
|
||||||
|
{ path: 'standing', op: 'add', value: -1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ride_the_route',
|
||||||
|
label: 'Ride the route back yourself, looking at the ground.',
|
||||||
|
result:
|
||||||
|
'Wheel ruts off the road at the second creek, where the driver says ' +
|
||||||
|
'he never stopped. You do not find the eleven pounds. You find where ' +
|
||||||
|
'they got off.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: -3 },
|
||||||
|
{ path: 'skills.nerve', op: 'add', value: 3 },
|
||||||
|
{ path: 'standing', op: 'add', value: 3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lean_on_the_driver',
|
||||||
|
label: 'Lean on the driver until his story changes.',
|
||||||
|
requires: [{ path: 'skills.nerve', op: '>=', value: 30 }],
|
||||||
|
result:
|
||||||
|
'It changes on the fourth telling, at the second creek, and then ' +
|
||||||
|
'again at the part where somebody was expecting him.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.nerve', op: 'add', value: 3 },
|
||||||
|
{ path: 'standing', op: 'add', value: 4 },
|
||||||
|
{ path: 'relationships.ward.wary_of_you', op: 'add', value: 5 },
|
||||||
|
{ path: 'notable', op: 'push', value: 'Leaned on a stage driver until the story changed.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'the_county_fees',
|
||||||
|
stage: 'deputy',
|
||||||
|
weight: 45,
|
||||||
|
cooldown: 8,
|
||||||
|
description:
|
||||||
|
'The county wants its licence fees collected from every business on the ' +
|
||||||
|
'street, and the list it sent includes the woman who sells pies out of ' +
|
||||||
|
'her kitchen window on Sundays.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'collect_all',
|
||||||
|
label: 'Collect from everybody on the list.',
|
||||||
|
result:
|
||||||
|
'All of it, pies included. The county is pleased. The street is ' +
|
||||||
|
'polite to you in a way it was not last week.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'standing', op: 'add', value: -4 },
|
||||||
|
{ path: 'skills.trade', op: 'add', value: 1 },
|
||||||
|
{ path: 'relationships.ward.trusts_you', op: 'add', value: 4 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pay_hers',
|
||||||
|
label: 'Collect from the rest, and pay hers out of your own pocket.',
|
||||||
|
result:
|
||||||
|
'A dollar fifty. Nobody is told, and by Sunday everybody knows, which ' +
|
||||||
|
'is how the street keeps its accounts.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: -5 },
|
||||||
|
{ path: 'standing', op: 'add', value: 5 },
|
||||||
|
{ path: 'notable', op: 'push', value: 'Paid the pie licence out of your own pocket.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'strike_her_off',
|
||||||
|
label: 'Strike her off the list and let the county notice if it can.',
|
||||||
|
requires: [{ path: 'skills.trade', op: '>=', value: 28 }],
|
||||||
|
result:
|
||||||
|
'You find the statute that exempts a kitchen window, and cite it in ' +
|
||||||
|
'the return. The county never writes back, which is the county ' +
|
||||||
|
'agreeing.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.trade', op: 'add', value: 3 },
|
||||||
|
{ path: 'standing', op: 'add', value: 3 },
|
||||||
|
{ path: 'relationships.ward.wary_of_you', op: 'add', value: 4 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'the_bounty_man',
|
||||||
|
stage: 'deputy',
|
||||||
|
weight: 40,
|
||||||
|
cooldown: 9,
|
||||||
|
description:
|
||||||
|
'A man with a paper from Bitter Wells and a rifle he keeps too clean ' +
|
||||||
|
'wants to know whether a teamster called Pike is in Cold Fork. There is ' +
|
||||||
|
'a teamster called Pike in Cold Fork, and he has been decent to you.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'tell_him',
|
||||||
|
label: 'Tell him where Pike is.',
|
||||||
|
result:
|
||||||
|
'He thanks you and leaves money on the desk, which you did not ask ' +
|
||||||
|
'for and do not give back. The teamsters hear about the money first.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: 6 },
|
||||||
|
{ path: 'standing', op: 'add', value: -3 },
|
||||||
|
{ path: 'relationships.ward.trusts_you', op: 'add', value: 3 },
|
||||||
|
{ path: 'relationships.dutch.wary_of_you', op: 'add', value: 6 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'send_him_on',
|
||||||
|
label: 'Tell him Pike went west a month ago.',
|
||||||
|
result:
|
||||||
|
'He looks at you for long enough that you understand he knows. Then ' +
|
||||||
|
'he rides west, because the paper does not pay him to argue.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.nerve', op: 'add', value: 2 },
|
||||||
|
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 5 },
|
||||||
|
{ path: 'relationships.ward.wary_of_you', op: 'add', value: 5 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'check_the_paper',
|
||||||
|
label: 'Read his paper properly before you say anything at all.',
|
||||||
|
requires: [{ path: 'skills.trade', op: '>=', value: 30 }],
|
||||||
|
result:
|
||||||
|
'It names a Pike, a year and a county, and two of the three are ' +
|
||||||
|
'wrong. You tell him so, with the statute, and he goes home.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.trade', op: 'add', value: 3 },
|
||||||
|
{ path: 'standing', op: 'add', value: 4 },
|
||||||
|
{ path: 'relationships.ward.trusts_you', op: 'add', value: 4 },
|
||||||
|
{ path: 'notable', op: 'push', value: 'Read a bounty man’s paper closely enough to send him home.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'fire_at_the_store',
|
||||||
|
stage: 'deputy',
|
||||||
|
weight: 35,
|
||||||
|
cooldown: 12,
|
||||||
|
description:
|
||||||
|
'A lamp goes over in the back room of the store an hour after midnight, ' +
|
||||||
|
'and by the time you are in your boots the whole street is standing in ' +
|
||||||
|
'it in its nightclothes.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'bucket_line',
|
||||||
|
label: 'Get a bucket line going from the trough.',
|
||||||
|
result:
|
||||||
|
'It is out by two. The back room is gone and the front is black to ' +
|
||||||
|
'the ceiling, and the storekeeper shakes every hand on the line.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: -2 },
|
||||||
|
{ path: 'skills.nerve', op: 'add', value: 2 },
|
||||||
|
{ path: 'standing', op: 'add', value: 5 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'go_in_for_the_book',
|
||||||
|
label: 'Go in for the book on the counter.',
|
||||||
|
requires: [{ path: 'skills.nerve', op: '>=', value: 34 }],
|
||||||
|
result:
|
||||||
|
'You come out with it under your coat and no eyebrows. Half the town ' +
|
||||||
|
'is in that book, and every one of them knows who carried it out.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.nerve', op: 'add', value: 4 },
|
||||||
|
{ path: 'standing', op: 'add', value: 6 },
|
||||||
|
{ path: 'notable', op: 'push', value: 'Went into the burning store for the book.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hold_the_crowd',
|
||||||
|
label: 'Keep the crowd back and let it burn itself out.',
|
||||||
|
result:
|
||||||
|
'Nobody else is hurt, which Ward says is the only part of a fire that ' +
|
||||||
|
'is the office’s business. The storekeeper does not agree, loudly.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.trade', op: 'add', value: 2 },
|
||||||
|
{ path: 'standing', op: 'add', value: -2 },
|
||||||
|
{ path: 'relationships.ward.trusts_you', op: 'add', value: 3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'dutch_wants_the_ridge',
|
||||||
|
stage: 'deputy',
|
||||||
|
weight: 40,
|
||||||
|
cooldown: 10,
|
||||||
|
description:
|
||||||
|
'Dutch comes to the office sober and early, which has not happened ' +
|
||||||
|
'before, and asks whether a deputy has any say in who drives the Ridge ' +
|
||||||
|
'road now that Vane is short a man.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'put_him_forward',
|
||||||
|
label: 'Say you will put his name in with Vane.',
|
||||||
|
result:
|
||||||
|
'He nods once and goes, and Vane hears about it from Dutch before he ' +
|
||||||
|
'hears about it from you, which is not how you meant it to go.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 8 },
|
||||||
|
{ path: 'relationships.dutch.owes_you', op: 'add', value: 10 },
|
||||||
|
{ path: 'relationships.hollis.wary_of_you', op: 'add', value: 5 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'office_stays_out',
|
||||||
|
label: 'Say the office does not hire teamsters.',
|
||||||
|
result:
|
||||||
|
'He says that is true, and it is, and he takes his hat off the desk ' +
|
||||||
|
'and goes back down the street to wait for somebody else to decide.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'standing', op: 'add', value: 2 },
|
||||||
|
{ path: 'relationships.dutch.trusts_you', op: 'add', value: -5 },
|
||||||
|
{ path: 'relationships.ward.trusts_you', op: 'add', value: 2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ask_vane_for_him',
|
||||||
|
label: 'Walk across the street and ask Vane yourself.',
|
||||||
|
requires: [{ path: 'relationships.hollis.trusts_you', op: '>=', value: 40 }],
|
||||||
|
result:
|
||||||
|
'Vane hears you out in the doorway and says he will think about it, ' +
|
||||||
|
'and Dutch is on the Ridge road on Monday.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'standing', op: 'add', value: 3 },
|
||||||
|
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 10 },
|
||||||
|
{ path: 'relationships.hollis.trusts_you', op: 'add', value: -2 },
|
||||||
|
{ path: 'notable', op: 'push', value: 'Asked Vane to put Dutch on the Ridge road.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
// Corporate Ladder's car lot, in Cold Fork's terms and at its scale. Same
|
||||||
|
// structure deliberately: an offer, never an obligation, with the weekly
|
||||||
|
// charge for falling short of it sitting in upkeep instead.
|
||||||
|
//
|
||||||
|
// The horse and the room used to be one event, whose description was
|
||||||
|
// always about the horse. A real session was offered the room under that
|
||||||
|
// text, and shown "Buy the bay" locked after buying it — a door that could
|
||||||
|
// never open. Now each is its own event, gated on the tier it offers, so
|
||||||
|
// neither can appear once it is bought. See DECISIONS #40.
|
||||||
|
id: 'the_sale_barn',
|
||||||
|
stage: 'deputy',
|
||||||
|
weight: 45,
|
||||||
|
cooldown: 6,
|
||||||
|
requires: [{ path: 'lifestyle', op: '==', value: 0 }],
|
||||||
|
description:
|
||||||
|
'The sale barn has a bay with four good years in him and a price on ' +
|
||||||
|
'him. Ward says nothing about it, except that the county pays mileage ' +
|
||||||
|
'on a horse a man owns and nothing at all on a hired one.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'buy_the_bay',
|
||||||
|
label: 'Buy the bay.',
|
||||||
|
requires: [{ path: 'money', op: '>=', value: 70 }],
|
||||||
|
result:
|
||||||
|
'He is a better animal than the livery has ever put under you, and ' +
|
||||||
|
'the whole street watches you ride him down it once.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: -70 },
|
||||||
|
{ path: 'lifestyle', op: 'set', value: 1 },
|
||||||
|
{ path: 'standing', op: 'add', value: 3 },
|
||||||
|
{ path: 'notable', op: 'push', value: 'Bought the bay out of the sale barn.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ride_the_livery',
|
||||||
|
label: 'Ride what the livery gives you.',
|
||||||
|
result:
|
||||||
|
'A different animal every week, and you learn something about each ' +
|
||||||
|
'one of them in the first mile.',
|
||||||
|
effects: [{ path: 'skills.trade', op: 'add', value: 1 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'the_ellis_room',
|
||||||
|
stage: 'deputy',
|
||||||
|
weight: 45,
|
||||||
|
cooldown: 6,
|
||||||
|
requires: [{ path: 'lifestyle', op: '==', value: 1 }],
|
||||||
|
description:
|
||||||
|
'The room over the Ellis place is empty again: a stove, a door that ' +
|
||||||
|
'locks, and a window over the street. The Cordwood charges a deputy the ' +
|
||||||
|
'same as a teamster, and says so to both of them.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'take_the_room',
|
||||||
|
label: 'Take the room over the Ellis place, with the stove.',
|
||||||
|
requires: [{ path: 'money', op: '>=', value: 150 }],
|
||||||
|
result:
|
||||||
|
'A door that locks, a stove, and a window over the street. The ' +
|
||||||
|
'Cordwood does not hold it against you; the Cordwood holds nothing ' +
|
||||||
|
'against anybody who pays up.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: -150 },
|
||||||
|
{ path: 'lifestyle', op: 'set', value: 2 },
|
||||||
|
{ path: 'standing', op: 'add', value: 5 },
|
||||||
|
{ path: 'notable', op: 'push', value: 'Took the room with the stove over the Ellis place.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'stay_at_the_cordwood',
|
||||||
|
label: 'Stay at the Cordwood.',
|
||||||
|
result:
|
||||||
|
'The walls are thin and the talk comes through them, and some of what ' +
|
||||||
|
'comes through is worth a deputy hearing.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.trade', op: 'add', value: 1 },
|
||||||
|
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default deputy;
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
// 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: [
|
||||||
|
// Both payments take what is owed up to their size, never more — money
|
||||||
|
// first, because both effects read `owed` before it moves. See #38.
|
||||||
|
{
|
||||||
|
id: 'pay_chunk',
|
||||||
|
label: 'Pay a real piece of it down, up to forty-five dollars.',
|
||||||
|
requires: [{ path: 'money', op: '>=', value: 45 }],
|
||||||
|
result: 'He rules a line through most of it and is disappointed about it.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: { from: 'owed', times: -1, cap: 45 } },
|
||||||
|
{ path: 'owed', op: 'add', value: { from: 'owed', times: -1, cap: 45 } },
|
||||||
|
{ path: 'standing', op: 'add', value: 2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pay_minimum',
|
||||||
|
label: 'Pay what you have on you.',
|
||||||
|
requires: [{ path: 'money', op: '>=', value: 12 }],
|
||||||
|
result: 'Up to ten dollars against the line, and two dollars of coffee while you are in there.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: -2 },
|
||||||
|
{ path: 'money', op: 'add', value: { from: 'owed', times: -1, cap: 10 } },
|
||||||
|
{ path: 'owed', op: 'add', value: { from: 'owed', times: -1, cap: 10 } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'stall',
|
||||||
|
label: 'Tell him after the next stage.',
|
||||||
|
result:
|
||||||
|
'He says after the next stage. The two other people in the street ' +
|
||||||
|
'say nothing, and repeat all of it by evening.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'owed', op: 'add', value: 4 },
|
||||||
|
{ path: 'standing', op: 'add', value: -3 },
|
||||||
|
{ path: 'relationships.ward.wary_of_you', op: 'add', value: 4 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default hardTimes;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { routine } from './routine.js';
|
||||||
|
import { people } from './people.js';
|
||||||
|
import { promotion } from './promotion.js';
|
||||||
|
import { deputy } from './deputy.js';
|
||||||
|
import { hardTimes } from './hard-times.js';
|
||||||
|
|
||||||
|
export const events = [...routine, ...people, ...promotion, ...deputy, ...hardTimes];
|
||||||
|
export default events;
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
// The three people in Cold Fork who have an opinion about you.
|
||||||
|
//
|
||||||
|
// Relationship-gated content, and the counterweight to the yard: the runs pay
|
||||||
|
// the room, but Dutch's book, Vane's ledger and Ward's slow appraisal are what
|
||||||
|
// decide whether the star is ever offered.
|
||||||
|
|
||||||
|
export const people = [
|
||||||
|
{
|
||||||
|
id: 'dutch_book',
|
||||||
|
stage: 'hand',
|
||||||
|
weight: 70,
|
||||||
|
cooldown: 5,
|
||||||
|
description:
|
||||||
|
'Dutch has his tally book open on the tailgate and is working through ' +
|
||||||
|
'who took which run this month. He has arrived at a number he does not ' +
|
||||||
|
'like and is now looking for somewhere to put it.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'let_him_talk',
|
||||||
|
label: 'Let him get all the way to the end of it.',
|
||||||
|
result:
|
||||||
|
'It takes twenty minutes. By the end he has talked himself into ' +
|
||||||
|
'thinking it was about the weather, which suits both of you.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.trade', op: 'add', value: 2 },
|
||||||
|
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 6 },
|
||||||
|
{ path: 'relationships.dutch.wary_of_you', op: 'add', value: -4 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'check_the_book',
|
||||||
|
label: 'Ask to see the book.',
|
||||||
|
result:
|
||||||
|
'The tally is right and his arithmetic is wrong by two runs, in his ' +
|
||||||
|
'own favour. You say so quietly. He does not thank you and does not ' +
|
||||||
|
'forget it either.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.trade', op: 'add', value: 3 },
|
||||||
|
{ path: 'relationships.dutch.wary_of_you', op: 'add', value: 7 },
|
||||||
|
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'trade_a_run',
|
||||||
|
label: 'Offer him the next Ridge run to square it.',
|
||||||
|
requires: [{ path: 'relationships.dutch.trusts_you', op: '>=', value: 45 }],
|
||||||
|
result:
|
||||||
|
'He takes it, and the book gets a line in it on your side for the ' +
|
||||||
|
'first time. Dutch settles his lines.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: -6 },
|
||||||
|
{ path: 'skills.trade', op: 'add', value: 2 },
|
||||||
|
{ path: 'relationships.dutch.owes_you', op: 'add', value: 18 },
|
||||||
|
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 5 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'dutch_drunk',
|
||||||
|
stage: 'hand',
|
||||||
|
weight: 40,
|
||||||
|
cooldown: 9,
|
||||||
|
requires: [{ path: 'turn', op: '>=', value: 12 }],
|
||||||
|
description:
|
||||||
|
'Dutch did not come in for the morning run and the morning run is ' +
|
||||||
|
'loaded. He is at the Cordwood, upright, and in no condition to be ' +
|
||||||
|
'within thirty feet of a horse.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'cover',
|
||||||
|
label: 'Take his run and say nothing about it.',
|
||||||
|
result:
|
||||||
|
'Vane never learns. Dutch learns, in the afternoon, from the state ' +
|
||||||
|
'of the harness, and comes and stands near you for a while without ' +
|
||||||
|
'saying anything.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: 7 },
|
||||||
|
{ path: 'skills.nerve', op: 'add', value: 2 },
|
||||||
|
{ path: 'flags.covered_for_dutch', op: 'set', value: true },
|
||||||
|
{ path: 'relationships.dutch.owes_you', op: 'add', value: 25 },
|
||||||
|
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 8 },
|
||||||
|
{ path: 'notable', op: 'push', value: 'Covered Dutch’s run and never mentioned it.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tell_vane',
|
||||||
|
label: 'Tell Vane the run needs a driver.',
|
||||||
|
result:
|
||||||
|
'Vane takes it out himself at fifty-one years of age and says one ' +
|
||||||
|
'sentence to Dutch on his return. Dutch works the rest of the month ' +
|
||||||
|
'like a man being watched, because he is.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 7 },
|
||||||
|
{ path: 'relationships.dutch.trusts_you', op: 'add', value: -15 },
|
||||||
|
{ path: 'relationships.dutch.wary_of_you', op: 'add', value: 12 },
|
||||||
|
{ path: 'standing', op: 'add', value: -2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sober_him',
|
||||||
|
label: 'Walk him round the yard until he can stand up to it.',
|
||||||
|
requires: [{ path: 'skills.nerve', op: '>=', value: 12 }],
|
||||||
|
result:
|
||||||
|
'Four circuits and a bucket. He drives it badly but he drives it, ' +
|
||||||
|
'and that distinction is the entire point.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.nerve', op: 'add', value: 2 },
|
||||||
|
{ path: 'relationships.dutch.owes_you', op: 'add', value: 14 },
|
||||||
|
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 10 },
|
||||||
|
{ path: 'relationships.dutch.wary_of_you', op: 'add', value: -6 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'vane_ledger',
|
||||||
|
stage: 'hand',
|
||||||
|
weight: 50,
|
||||||
|
cooldown: 7,
|
||||||
|
description:
|
||||||
|
'The office door is open for once. Vane has the ledger out and the look ' +
|
||||||
|
'of a man who has added a column twice and got two answers.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'offer_help',
|
||||||
|
label: 'Offer to run the column.',
|
||||||
|
result:
|
||||||
|
'You find it in ten minutes: a freight bill entered twice in March. ' +
|
||||||
|
'He does not say thank you. He leaves the door open after that.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.trade', op: 'add', value: 4 },
|
||||||
|
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 9 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ask_for_more',
|
||||||
|
label: 'Ask him what the good runs pay.',
|
||||||
|
requires: [{ path: 'skills.trade', op: '>=', value: 22 }],
|
||||||
|
result:
|
||||||
|
'Twenty-two, and he has been giving them to Dutch for two years ' +
|
||||||
|
'because Dutch asked in his first month. Nobody had told you that ' +
|
||||||
|
'asking was the mechanism.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: 22 },
|
||||||
|
{ path: 'skills.trade', op: 'add', value: 3 },
|
||||||
|
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 3 },
|
||||||
|
{ path: 'relationships.dutch.wary_of_you', op: 'add', value: 6 },
|
||||||
|
{ path: 'notable', op: 'push', value: 'Asked Vane for the good runs.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'call_in_the_debt',
|
||||||
|
label: 'Mention the smith’s bill you stood for.',
|
||||||
|
requires: [{ path: 'relationships.hollis.owes_you', op: '>=', value: 20 }],
|
||||||
|
result:
|
||||||
|
'He pays it back to the cent, in front of Dutch, and is short with ' +
|
||||||
|
'you for two days afterwards for having had to.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: 24 },
|
||||||
|
{ path: 'relationships.hollis.owes_you', op: 'add', value: -20 },
|
||||||
|
{ path: 'relationships.hollis.trusts_you', op: 'add', value: -4 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'leave_him_to_it',
|
||||||
|
label: 'Leave him to it.',
|
||||||
|
result: 'The door is shut again within the hour.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.nerve', op: 'add', value: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'ward_asks',
|
||||||
|
stage: 'hand',
|
||||||
|
weight: 45,
|
||||||
|
cooldown: 6,
|
||||||
|
requires: [{ path: 'turn', op: '>=', value: 6 }],
|
||||||
|
description:
|
||||||
|
'Marshal Ward is leaning on the yard gate with no particular business ' +
|
||||||
|
'there. He asks, eventually and as though it has just occurred to him, ' +
|
||||||
|
'what came in on the stage.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'tell_him',
|
||||||
|
label: 'Tell him what came in on the stage.',
|
||||||
|
result:
|
||||||
|
'Hinges, nails, and a crate consigned to a name nobody in Cold Fork ' +
|
||||||
|
'has heard of. He writes none of it down and remembers all of it.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'relationships.ward.trusts_you', op: 'add', value: 8 },
|
||||||
|
{ path: 'relationships.ward.wary_of_you', op: 'add', value: -4 },
|
||||||
|
{ path: 'standing', op: 'add', value: -1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'say_nothing',
|
||||||
|
label: 'Say that is Vane’s business and not yours.',
|
||||||
|
result:
|
||||||
|
'He agrees that it is, pleasantly, and stays leaning on the gate for ' +
|
||||||
|
'another twenty minutes.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.nerve', op: 'add', value: 2 },
|
||||||
|
{ path: 'relationships.ward.wary_of_you', op: 'add', value: 6 },
|
||||||
|
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 5 },
|
||||||
|
{ path: 'standing', op: 'add', value: 2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ask_him_back',
|
||||||
|
label: 'Ask him what he is actually looking for.',
|
||||||
|
requires: [{ path: 'skills.trade', op: '>=', value: 12 }],
|
||||||
|
result:
|
||||||
|
'He tells you, which surprises both of you. A crate a month, six ' +
|
||||||
|
'months running, and no consignee that exists.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.trade', op: 'add', value: 3 },
|
||||||
|
{ path: 'skills.nerve', op: 'add', value: 2 },
|
||||||
|
{ path: 'relationships.ward.trusts_you', op: 'add', value: 10 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'ward_deputizes',
|
||||||
|
stage: 'hand',
|
||||||
|
weight: 35,
|
||||||
|
cooldown: 10,
|
||||||
|
requires: [
|
||||||
|
{ path: 'turn', op: '>=', value: 18 },
|
||||||
|
{ path: 'relationships.ward.trusts_you', op: '>=', value: 45 },
|
||||||
|
],
|
||||||
|
description:
|
||||||
|
'There is a man in the one cell who needs moving to the county seat and ' +
|
||||||
|
'Ward cannot leave the town without somebody in it. He is not offering ' +
|
||||||
|
'you a job. He is offering you two days.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'ride_along',
|
||||||
|
label: 'Ride along.',
|
||||||
|
result:
|
||||||
|
'Two days out and two back, and the prisoner talks for all four. ' +
|
||||||
|
'Ward does not, which turns out to be the instruction.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: 12 },
|
||||||
|
{ path: 'skills.nerve', op: 'add', value: 4 },
|
||||||
|
{ path: 'standing', op: 'add', value: 5 },
|
||||||
|
{ path: 'relationships.ward.trusts_you', op: 'add', value: 12 },
|
||||||
|
{ path: 'relationships.hollis.trusts_you', op: 'add', value: -4 },
|
||||||
|
{ path: 'notable', op: 'push', value: 'Rode to the county seat with Ward and a prisoner.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mind_the_town',
|
||||||
|
label: 'Offer to mind the town instead.',
|
||||||
|
requires: [{ path: 'skills.nerve', op: '>=', value: 22 }],
|
||||||
|
result:
|
||||||
|
'Two days with the keys and nothing whatever happens, which you ' +
|
||||||
|
'later understand was the test.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: 12 },
|
||||||
|
{ path: 'skills.nerve', op: 'add', value: 3 },
|
||||||
|
{ path: 'standing', op: 'add', value: 8 },
|
||||||
|
{ path: 'relationships.ward.trusts_you', op: 'add', value: 15 },
|
||||||
|
{ path: 'notable', op: 'push', value: 'Held the town for two days while Ward was gone.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'decline_ward',
|
||||||
|
label: 'Say the yard cannot spare you.',
|
||||||
|
result:
|
||||||
|
'It can. Both of you know it can. He takes the storekeeper instead ' +
|
||||||
|
'and the storekeeper tells the story for a year.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.trade', op: 'add', value: 2 },
|
||||||
|
{ path: 'relationships.ward.trusts_you', op: 'add', value: -8 },
|
||||||
|
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 6 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'saturday_night',
|
||||||
|
stage: 'hand',
|
||||||
|
weight: 55,
|
||||||
|
cooldown: 5,
|
||||||
|
description:
|
||||||
|
'The Cordwood on a Rest night: Dutch at the long table, half the yard ' +
|
||||||
|
'with him, and a game of cards that everybody present knows the ' +
|
||||||
|
'storekeeper’s nephew cannot afford to be in.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'sit_in',
|
||||||
|
label: 'Sit in.',
|
||||||
|
result:
|
||||||
|
'You come out four dollars ahead and the nephew comes out eleven ' +
|
||||||
|
'behind. Nobody says anything about it, then or afterwards.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: 4 },
|
||||||
|
{ path: 'skills.trade', op: 'add', value: 2 },
|
||||||
|
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 5 },
|
||||||
|
{ path: 'standing', op: 'add', value: -2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'buy_the_table',
|
||||||
|
label: 'Buy the table a round and stay out of the game.',
|
||||||
|
result:
|
||||||
|
'Six dollars, and for the rest of the month you are a man who bought ' +
|
||||||
|
'the table a round, which in Cold Fork is a kind of address.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: -6 },
|
||||||
|
{ path: 'standing', op: 'add', value: 5 },
|
||||||
|
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 4 },
|
||||||
|
{ path: 'relationships.dutch.wary_of_you', op: 'add', value: -3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'walk_the_nephew_out',
|
||||||
|
label: 'Get the nephew out of the game.',
|
||||||
|
requires: [{ path: 'skills.nerve', op: '>=', value: 12 }],
|
||||||
|
result:
|
||||||
|
'You do it badly and in front of everyone, and it works. The ' +
|
||||||
|
'storekeeper hears about it before you are back at the yard.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.nerve', op: 'add', value: 3 },
|
||||||
|
{ path: 'standing', op: 'add', value: 7 },
|
||||||
|
{ path: 'relationships.dutch.wary_of_you', op: 'add', value: 5 },
|
||||||
|
{ path: 'relationships.ward.trusts_you', op: 'add', value: 5 },
|
||||||
|
{ path: 'notable', op: 'push', value: 'Pulled the storekeeper’s nephew out of a card game.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'early_night',
|
||||||
|
label: 'Go up early.',
|
||||||
|
result: 'The ceiling of a room at the Cordwood, at nine at night.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.trade', op: 'add', value: 1 },
|
||||||
|
{ path: 'relationships.dutch.trusts_you', op: 'add', value: -2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'the_consignee',
|
||||||
|
stage: 'hand',
|
||||||
|
weight: 25,
|
||||||
|
cooldown: 12,
|
||||||
|
requires: [
|
||||||
|
{ path: 'turn', op: '>=', value: 24 },
|
||||||
|
{ path: 'relationships.ward.trusts_you', op: '>=', value: 50 },
|
||||||
|
],
|
||||||
|
description:
|
||||||
|
'The crate with no consignee is on the platform again, and this month ' +
|
||||||
|
'the manifest has Vane’s own hand on it. Ward is not at the gate today, ' +
|
||||||
|
'which is itself a decision somebody made.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'load_it',
|
||||||
|
label: 'Load it and drive it out to the Ridge like any other crate.',
|
||||||
|
result:
|
||||||
|
'It is heavier than hinges. Vane pays you eighteen dollars for a ' +
|
||||||
|
'nine dollar run and neither of you mentions the difference.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: 18 },
|
||||||
|
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 10 },
|
||||||
|
{ path: 'relationships.ward.trusts_you', op: 'add', value: -12 },
|
||||||
|
{ path: 'standing', op: 'add', value: -3 },
|
||||||
|
{ path: 'notable', op: 'push', value: 'Ran the unmarked crate out to the Ridge for Vane.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tell_ward',
|
||||||
|
label: 'Go and find Ward.',
|
||||||
|
result:
|
||||||
|
'He looks at the manifest for a long time and then puts it back on ' +
|
||||||
|
'the platform exactly as he found it. Nothing happens for eleven ' +
|
||||||
|
'days, and then a great deal happens at once.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.nerve', op: 'add', value: 4 },
|
||||||
|
{ path: 'standing', op: 'add', value: 6 },
|
||||||
|
{ path: 'relationships.ward.trusts_you', op: 'add', value: 20 },
|
||||||
|
{ path: 'relationships.hollis.trusts_you', op: 'add', value: -14 },
|
||||||
|
{ path: 'notable', op: 'push', value: 'Took Vane’s manifest to Marshal Ward.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ask_vane_direct',
|
||||||
|
label: 'Ask Vane what is in it.',
|
||||||
|
requires: [{ path: 'skills.trade', op: '>=', value: 22 }],
|
||||||
|
result:
|
||||||
|
'He tells you, standing in the doorway of the office as always, and ' +
|
||||||
|
'then asks what you intend to do about it. It is the first real ' +
|
||||||
|
'question anybody in Cold Fork has asked you.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.trade', op: 'add', value: 4 },
|
||||||
|
{ path: 'skills.nerve', op: 'add', value: 3 },
|
||||||
|
{ path: 'standing', op: 'add', value: 2 },
|
||||||
|
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 6 },
|
||||||
|
{ path: 'notable', op: 'push', value: 'Asked Vane what was in the crate, and got an answer.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default people;
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
// 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 = [
|
||||||
|
{
|
||||||
|
// The first real player asked, thirty turns in, how they would know they
|
||||||
|
// were getting close. Nothing said. This does, in the town's own terms: it
|
||||||
|
// names what the offer is waiting for without printing a threshold. It
|
||||||
|
// comes once, somewhat before the offer can, and only to a hand already
|
||||||
|
// heading that way. See DECISIONS #40.
|
||||||
|
id: 'ward_takes_notice',
|
||||||
|
stage: 'hand',
|
||||||
|
weight: 300,
|
||||||
|
once: true,
|
||||||
|
requires: [
|
||||||
|
{ path: 'turn', op: '>=', value: 24 },
|
||||||
|
{ path: 'standing', op: '>=', value: 55 },
|
||||||
|
],
|
||||||
|
description:
|
||||||
|
'Ward stops at the yard gate on his way past and says, to nobody in ' +
|
||||||
|
'particular, that the county will want a second man by winter. The man ' +
|
||||||
|
'will need a name the street says for the right reasons, and he will ' +
|
||||||
|
'need to be one Ward can take at his word — or one who has already ' +
|
||||||
|
'walked into trouble on the Ridge road and walked back out of it.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'ask_what_it_takes',
|
||||||
|
label: 'Ask him how long a man would have to wait.',
|
||||||
|
result:
|
||||||
|
'He says not before the fortieth day of anybody’s employment, and ' +
|
||||||
|
'that he is not talking about anybody in particular, and goes on up ' +
|
||||||
|
'the street.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'relationships.ward.trusts_you', op: 'add', value: 3 },
|
||||||
|
{ path: 'notable', op: 'push', value: 'Heard from Ward that the county would want a second man.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'happy_in_the_yard',
|
||||||
|
label: 'Say you are happy in the yard.',
|
||||||
|
result: 'Vane, in the office doorway, hears you say it.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'remember_it',
|
||||||
|
label: 'Say nothing, and remember it.',
|
||||||
|
result: 'He did not expect an answer, and seems to like not getting one.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.nerve', op: 'add', value: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'deputy_offer',
|
||||||
|
stage: 'hand',
|
||||||
|
weight: 800,
|
||||||
|
cooldown: 12,
|
||||||
|
// A long way in, deliberately. The yard is where most of this pack lives,
|
||||||
|
// and the star at turn twenty would skip it.
|
||||||
|
requires: [
|
||||||
|
{ path: 'standing', op: '>=', value: 70 },
|
||||||
|
{ path: 'turn', op: '>=', value: 40 },
|
||||||
|
// Two ways to be offered it: the marshal has decided about you, or the
|
||||||
|
// town has, on the evidence of what you have walked into.
|
||||||
|
{
|
||||||
|
any: [
|
||||||
|
{ path: 'relationships.ward.trusts_you', op: '>=', value: 68 },
|
||||||
|
{ path: 'skills.nerve', op: '>=', value: 40 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
description:
|
||||||
|
'Ward has the county paper that makes a deputy and has already written a ' +
|
||||||
|
'name on it. He says the pay is thirty-eight a run and the work is mostly ' +
|
||||||
|
'walking about, and that the second half of that is a lie he tells ' +
|
||||||
|
'everybody once.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'accept',
|
||||||
|
label: 'Take the star.',
|
||||||
|
result:
|
||||||
|
'Vane says nothing at all, which is worse than anything he could ' +
|
||||||
|
'have said. Dutch carries your kit up the street and talks the ' +
|
||||||
|
'entire way about the Ridge road.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'stage', op: 'set', value: 'deputy' },
|
||||||
|
{ path: 'flags.sworn_in', op: 'set', value: true },
|
||||||
|
{ path: 'standing', op: 'add', value: 6 },
|
||||||
|
{ path: 'relationships.ward.trusts_you', op: 'add', value: 5 },
|
||||||
|
{ path: 'relationships.hollis.trusts_you', op: 'add', value: -14 },
|
||||||
|
{ path: 'relationships.dutch.wary_of_you', op: 'add', value: 10 },
|
||||||
|
{ path: 'notable', op: 'push', value: 'Took the star.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'accept_and_speak',
|
||||||
|
label: 'Take it, and put Dutch up for the good runs.',
|
||||||
|
requires: [{ path: 'relationships.dutch.trusts_you', op: '>=', value: 55 }],
|
||||||
|
result:
|
||||||
|
'Vane writes the name down in front of you, which is as close as he ' +
|
||||||
|
'comes to a farewell. Dutch is insufferable about it for a month and ' +
|
||||||
|
'then, quietly, is not.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'stage', op: 'set', value: 'deputy' },
|
||||||
|
{ path: 'flags.sworn_in', op: 'set', value: true },
|
||||||
|
{ path: 'flags.spoke_for_dutch', op: 'set', value: true },
|
||||||
|
{ path: 'standing', op: 'add', value: 4 },
|
||||||
|
{ path: 'relationships.ward.trusts_you', op: 'add', value: 5 },
|
||||||
|
{ path: 'relationships.hollis.trusts_you', op: 'add', value: -8 },
|
||||||
|
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 15 },
|
||||||
|
{ path: 'relationships.dutch.owes_you', op: 'add', value: 20 },
|
||||||
|
{ path: 'notable', op: 'push', value: 'Took the star, and put Dutch up for the good runs.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'decline',
|
||||||
|
label: 'Not yet.',
|
||||||
|
result:
|
||||||
|
'He folds the paper back into his coat. He has carried it around ' +
|
||||||
|
'unsigned for six years and is in no hurry now.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.nerve', op: 'add', value: 3 },
|
||||||
|
{ path: 'standing', op: 'add', value: -3 },
|
||||||
|
{ path: 'relationships.ward.trusts_you', op: 'add', value: -6 },
|
||||||
|
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 8 },
|
||||||
|
{ path: 'notable', op: 'push', value: 'Turned down the star.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default promotion;
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
// 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: 'Stay out of it, and take neither side about the axle.',
|
||||||
|
result: 'Both of them decide, separately, that you are no use to them.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'relationships.dutch.trusts_you', op: 'add', value: -4 },
|
||||||
|
{ 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: 'Pay the book down, up to twenty dollars.',
|
||||||
|
// Hidden rather than greyed out when there is nothing owed. A locked
|
||||||
|
// door is worth showing when it is a door the player could open later
|
||||||
|
// by playing differently; "pay down what you owe" shown to a man who
|
||||||
|
// owes nothing is not that. It was the most-locked option in a real
|
||||||
|
// 181-turn session, six times, and told the player nothing each time.
|
||||||
|
whenLocked: 'hide',
|
||||||
|
requires: [
|
||||||
|
{ path: 'owed', op: '>', value: 0 },
|
||||||
|
{ path: 'money', op: '>=', value: 20 },
|
||||||
|
],
|
||||||
|
result: 'He crosses a line through it and turns the page back over.',
|
||||||
|
// What is owed, up to twenty, and never more. A flat -20 against a
|
||||||
|
// four-dollar balance took sixteen dollars for nothing in a real
|
||||||
|
// session. Money comes first: both effects read `owed` before it moves.
|
||||||
|
// See DECISIONS #38.
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: { from: 'owed', times: -1, cap: 20 } },
|
||||||
|
{ path: 'owed', op: 'add', value: { from: 'owed', times: -1, cap: 20 } },
|
||||||
|
{ path: 'standing', op: 'add', value: 2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'weather',
|
||||||
|
stage: 'hand',
|
||||||
|
weight: 45,
|
||||||
|
cooldown: 6,
|
||||||
|
description:
|
||||||
|
'It has been raining since the small hours and the creek crossing is ' +
|
||||||
|
'four feet up. There is a load for the camp that was wanted yesterday.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'wait_it_out',
|
||||||
|
label: 'Nothing moves today.',
|
||||||
|
result:
|
||||||
|
'The creek is down by evening and the camp waits a day. It is the ' +
|
||||||
|
'right call, which is not the same as a popular one.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.trade', op: 'add', value: 2 },
|
||||||
|
{ path: 'relationships.hollis.trusts_you', op: 'add', value: -2 },
|
||||||
|
{ path: 'standing', op: 'add', value: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ford_it',
|
||||||
|
label: 'Ford it.',
|
||||||
|
result:
|
||||||
|
'The wagon floats for about six feet, which is six feet more than a ' +
|
||||||
|
'wagon should. The load gets there wet and on time.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: 16 },
|
||||||
|
{ path: 'skills.nerve', op: 'add', value: 4 },
|
||||||
|
{ path: 'standing', op: 'add', value: 3 },
|
||||||
|
{ path: 'relationships.dutch.wary_of_you', op: 'add', value: 6 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'long_way',
|
||||||
|
label: 'Take the long road round the head of the creek.',
|
||||||
|
requires: [{ path: 'skills.trade', op: '>=', value: 12 }],
|
||||||
|
result:
|
||||||
|
'Eleven extra miles and a dry load. Dutch did not know that road was ' +
|
||||||
|
'passable, and now has to know that you did.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: 11 },
|
||||||
|
{ path: 'skills.trade', op: 'add', value: 2 },
|
||||||
|
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 4 },
|
||||||
|
{ path: 'relationships.dutch.wary_of_you', op: 'add', value: 4 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'road_agents',
|
||||||
|
stage: 'hand',
|
||||||
|
weight: 30,
|
||||||
|
cooldown: 8,
|
||||||
|
requires: [{ path: 'turn', op: '>=', value: 8 }],
|
||||||
|
description:
|
||||||
|
'Two riders come out of the rocks a mile short of the camp and sit their ' +
|
||||||
|
'horses across the road. Neither of them looks like he has done this ' +
|
||||||
|
'often enough to be calm about it.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'hand_it_over',
|
||||||
|
label: 'Give them the box and remember the horses.',
|
||||||
|
result:
|
||||||
|
'They take it and go. You give Marshal Ward a description of two ' +
|
||||||
|
'horses that is better than anything he has had in a year.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: -8 },
|
||||||
|
{ path: 'skills.nerve', op: 'add', value: 2 },
|
||||||
|
{ path: 'relationships.ward.trusts_you', op: 'add', value: 8 },
|
||||||
|
{ path: 'notable', op: 'push', value: 'Was stopped on the Ridge road, and gave Ward the horses.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'talk_them_down',
|
||||||
|
label: 'Tell them what is actually in the box.',
|
||||||
|
result:
|
||||||
|
'It is a crate of hinges and forty pounds of nails. The older one ' +
|
||||||
|
'laughs. They let you by, which is worse for them than it is for you.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.trade', op: 'add', value: 4 },
|
||||||
|
{ path: 'standing', op: 'add', value: 4 },
|
||||||
|
{ path: 'notable', op: 'push', value: 'Talked two road agents out of a crate of hinges.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bring_it_up',
|
||||||
|
label: 'Bring the scattergun up off the floorboards.',
|
||||||
|
requires: [{ path: 'flags.carries_iron', op: '==', value: true }],
|
||||||
|
result:
|
||||||
|
'Nobody fires. They go back into the rocks and the story is around ' +
|
||||||
|
'Cold Fork before the wagon is.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.nerve', op: 'add', value: 5 },
|
||||||
|
{ path: 'standing', op: 'add', value: 8 },
|
||||||
|
{ path: 'relationships.ward.trusts_you', op: 'add', value: 6 },
|
||||||
|
{ path: 'relationships.dutch.wary_of_you', op: 'add', value: 8 },
|
||||||
|
{ path: 'notable', op: 'push', value: 'Brought the scattergun up on the Ridge road.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default routine;
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
// 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.1',
|
||||||
|
turnUnit: 'day',
|
||||||
|
startingStage: 'hand',
|
||||||
|
peopleHeading: 'Cold Fork',
|
||||||
|
|
||||||
|
stages: {
|
||||||
|
hand: { label: 'The Yard', title: 'Hired Hand' },
|
||||||
|
deputy: { label: 'The Office', title: 'Deputy Marshal' },
|
||||||
|
},
|
||||||
|
|
||||||
|
// Not a week. The stage comes every fourth day, and the whole town runs on
|
||||||
|
// that: the outfit settles up when the stage comes in, the boarding house
|
||||||
|
// collects on the rest day before it. A pack that wants a seven-day week
|
||||||
|
// says seven here; the engine has no opinion either way, and Corporate
|
||||||
|
// Ladder's calendar is the same field with different numbers in it.
|
||||||
|
calendar: {
|
||||||
|
cycleLength: 4,
|
||||||
|
cycleName: 'Run',
|
||||||
|
unitNames: ['Stage day', 'Haul', 'Yard', 'Rest'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Money is smaller here than in Corporate Ladder by a factor of about
|
||||||
|
// fifteen, and it behaves differently: the settlement covers board and room
|
||||||
|
// and very little else, so the difference between a good run and a bad one
|
||||||
|
// is what you took from the work. See `tuning` below for the arithmetic that
|
||||||
|
// is actually asserted.
|
||||||
|
state: {
|
||||||
|
'money': { initial: 40, min: -400, max: 100000 },
|
||||||
|
// The store ledger. Named for what it is rather than for a loan: nobody in
|
||||||
|
// Cold Fork lends money, they let you carry a balance.
|
||||||
|
'owed': { initial: 0, min: 0, max: 100000 },
|
||||||
|
'standing': { initial: 50, min: 0, max: 100 },
|
||||||
|
'skills.nerve': { initial: 5, min: 0, max: 100 },
|
||||||
|
'skills.trade': { initial: 5, min: 0, max: 100 },
|
||||||
|
// The same shape as Corporate Ladder's lifestyle, in this setting's own
|
||||||
|
// terms and at this setting's own scale: what the office expects a man to
|
||||||
|
// turn up on, and what keeping that up costs. See DECISIONS #36.
|
||||||
|
'lifestyle': { initial: 0, min: 0, max: 2 },
|
||||||
|
'flags.carries_iron': { initial: false },
|
||||||
|
'flags.owes_the_store': { initial: false },
|
||||||
|
'flags.covered_for_dutch': { initial: false },
|
||||||
|
'flags.sworn_in': { initial: false },
|
||||||
|
'flags.spoke_for_dutch': { initial: false },
|
||||||
|
'notable': { initial: [] },
|
||||||
|
},
|
||||||
|
|
||||||
|
// A different vocabulary from Corporate Ladder's likes/fears/helps, and the
|
||||||
|
// reason the vocabulary is content's to choose. Out here the useful question
|
||||||
|
// about a person is not whether they like you: it is whether they will take
|
||||||
|
// your word, and whether they are carrying a debt to you they mean to pay.
|
||||||
|
relationshipStats: {
|
||||||
|
trusts_you: { label: 'Takes your word', initial: 45, min: 0, max: 100 },
|
||||||
|
owes_you: { label: 'Owes you', initial: 10, min: 0, max: 100 },
|
||||||
|
wary_of_you: { label: 'Wary of you', initial: 15, min: 0, max: 100 },
|
||||||
|
},
|
||||||
|
|
||||||
|
// The economic shape, and the thing that makes this pack a real test of the
|
||||||
|
// engine rather than Corporate Ladder in a hat: there is no wage worth
|
||||||
|
// living on. Vane settles up when the stage comes in and it covers the room
|
||||||
|
// and the feed and four dollars besides. Everything else you earn, you earn
|
||||||
|
// on a turn, by choosing to — and what the choices cost is where the
|
||||||
|
// pressure comes from, not an unexplained drain.
|
||||||
|
//
|
||||||
|
// Keep this arithmetic honest when tuning. Per four-day run:
|
||||||
|
// hand: -12 board -8 room +24 settlement = +4
|
||||||
|
// deputy: -12 board -8 room +38 pay = +18
|
||||||
|
// Both are asserted directly by the conformance suite against `tuning`
|
||||||
|
// below, so a number changed here without re-deriving it fails the build
|
||||||
|
// rather than trapping a player forty turns in.
|
||||||
|
upkeep: [
|
||||||
|
{ label: 'Board and feed', effects: [{ path: 'money', op: 'add', value: -3 }] },
|
||||||
|
// Turn 1 is a Stage day, so offset 3 is the first Rest day and offset 4
|
||||||
|
// the next Stage day — the room is collected the night before the outfit
|
||||||
|
// settles up, which is the boarding house's whole business model.
|
||||||
|
{
|
||||||
|
label: 'Room at the Cordwood',
|
||||||
|
every: 4,
|
||||||
|
offset: 3,
|
||||||
|
effects: [{ path: 'money', op: 'add', value: -8 }],
|
||||||
|
},
|
||||||
|
// Labelled as wages, because they are. "Settlement" read as jargon to the
|
||||||
|
// first real player, who could not tell it was the twenty-four a run the
|
||||||
|
// first morning quotes; the ledger is written for a player (DECISIONS #27).
|
||||||
|
{
|
||||||
|
label: 'Wages from Vane',
|
||||||
|
every: 4,
|
||||||
|
offset: 4,
|
||||||
|
requires: [{ path: 'stage', op: '==', value: 'hand' }],
|
||||||
|
effects: [{ path: 'money', op: 'add', value: 24 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Wages from the county',
|
||||||
|
every: 4,
|
||||||
|
offset: 4,
|
||||||
|
// The town pays a real wage, and it is still not much of a living. What
|
||||||
|
// changes with the star is not the money; it is what the days contain.
|
||||||
|
requires: [{ path: 'stage', op: '==', value: 'deputy' }],
|
||||||
|
effects: [{ path: 'money', op: 'add', value: 38 }],
|
||||||
|
},
|
||||||
|
// What the star is expected to arrive on, and what that expectation costs
|
||||||
|
// to meet. The office nets +18 a run; a deputy on his own horse is +11,
|
||||||
|
// and one with the horse and the room is +4 and makes the rest on the
|
||||||
|
// days. Money is meant to keep mattering after the promotion — but the
|
||||||
|
// first pass charged 9 for the room and left a keeper's median balance at
|
||||||
|
// $8 after 200 turns, which is not pressure, it is the debt trap this pack
|
||||||
|
// was tuned twice to avoid.
|
||||||
|
{
|
||||||
|
label: 'Feed and shoeing',
|
||||||
|
every: 4,
|
||||||
|
offset: 4,
|
||||||
|
requires: [{ path: 'lifestyle', op: '>=', value: 1 }],
|
||||||
|
effects: [{ path: 'money', op: 'add', value: -7 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'The room over the Ellis place',
|
||||||
|
every: 4,
|
||||||
|
offset: 4,
|
||||||
|
requires: [{ path: 'lifestyle', op: '>=', value: 2 }],
|
||||||
|
effects: [{ path: 'money', op: 'add', value: -7 }],
|
||||||
|
},
|
||||||
|
// Gated on the office for the same reason Corporate Ladder gates its
|
||||||
|
// equivalent on Dispatch: a hand cannot afford any of this, and being
|
||||||
|
// charged standing for that would be a trap rather than a choice.
|
||||||
|
{
|
||||||
|
label: 'What the star is expected to be',
|
||||||
|
every: 4,
|
||||||
|
offset: 4,
|
||||||
|
requires: [
|
||||||
|
{ path: 'stage', op: '==', value: 'deputy' },
|
||||||
|
{ path: 'lifestyle', op: '<', value: 1 },
|
||||||
|
],
|
||||||
|
effects: [{ path: 'standing', op: 'add', value: -2 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'What the star is expected to be',
|
||||||
|
every: 4,
|
||||||
|
offset: 4,
|
||||||
|
requires: [
|
||||||
|
{ path: 'stage', op: '==', value: 'deputy' },
|
||||||
|
{ path: 'standing', op: '>=', value: 65 },
|
||||||
|
{ path: 'lifestyle', op: '<', value: 2 },
|
||||||
|
],
|
||||||
|
effects: [{ path: 'standing', op: 'add', value: -2 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The top step, for the reason Corporate Ladder's has one: without it a
|
||||||
|
// man who plays for standing every single day holds a hundred of it on a
|
||||||
|
// livery horse, and the bay is money thrown away. With it he settles in
|
||||||
|
// the low eighties and the last stretch has to be bought.
|
||||||
|
label: 'What the star is expected to be',
|
||||||
|
every: 4,
|
||||||
|
offset: 4,
|
||||||
|
requires: [
|
||||||
|
{ path: 'stage', op: '==', value: 'deputy' },
|
||||||
|
{ path: 'standing', op: '>=', value: 80 },
|
||||||
|
{ path: 'lifestyle', op: '<', value: 2 },
|
||||||
|
],
|
||||||
|
effects: [{ path: 'standing', op: 'add', value: -14 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The charge that never stops, for the reason Corporate Ladder has one:
|
||||||
|
// without it a man who has bought everything and stands well with the
|
||||||
|
// whole town has nothing left that money is for. Being somebody in Cold
|
||||||
|
// Fork costs, every run, and nothing you can buy ends it.
|
||||||
|
label: 'What the standing costs',
|
||||||
|
every: 4,
|
||||||
|
offset: 4,
|
||||||
|
requires: [
|
||||||
|
{ path: 'stage', op: '==', value: 'deputy' },
|
||||||
|
{ path: 'standing', op: '>=', value: 85 },
|
||||||
|
],
|
||||||
|
// Four rather than six: this pack's late game is already flat — measured,
|
||||||
|
// a deputy who has bought everything drifts 0 a run either way — so the
|
||||||
|
// charge is here to keep money live, not to arrest an accumulation that
|
||||||
|
// does not happen. Six left an ordinary run at 55; four leaves 105.
|
||||||
|
effects: [{ path: 'money', op: 'add', value: -4 }],
|
||||||
|
},
|
||||||
|
// Store credit costs more the deeper in you are, the same step function
|
||||||
|
// Corporate Ladder uses for its loan — a shape, not a setting.
|
||||||
|
{
|
||||||
|
label: 'Store credit',
|
||||||
|
every: 4,
|
||||||
|
offset: 4,
|
||||||
|
requires: [{ path: 'owed', op: '>', value: 0 }],
|
||||||
|
effects: [{ path: 'money', op: 'add', value: -3 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Store credit (carried)',
|
||||||
|
every: 4,
|
||||||
|
offset: 4,
|
||||||
|
requires: [{ path: 'owed', op: '>=', value: 60 }],
|
||||||
|
effects: [{ path: 'money', op: 'add', value: -3 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Store credit (posted)',
|
||||||
|
every: 4,
|
||||||
|
offset: 4,
|
||||||
|
requires: [{ path: 'owed', op: '>=', value: 120 }],
|
||||||
|
effects: [{ path: 'money', op: 'add', value: -4 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
// What the conformance suite holds this pack to. Declaring it here rather
|
||||||
|
// than writing it into a test is the same principle as the rest of the pack:
|
||||||
|
// the numbers a setting must hit are the setting's business. See
|
||||||
|
// DECISIONS.md #31.
|
||||||
|
tuning: {
|
||||||
|
// Net movement of `money` from upkeep alone, per calendar cycle, at each
|
||||||
|
// stage. Upkeep is the floor a player plays against: barely above water in
|
||||||
|
// the yard, and a real if unremarkable living with the star.
|
||||||
|
baselinePerCycle: {
|
||||||
|
hand: [0, 12],
|
||||||
|
deputy: [10, 30],
|
||||||
|
},
|
||||||
|
// States a player opts into or earns, excluded from the baseline above:
|
||||||
|
// the floor is what a man faces owing nothing, keeping nothing, and being
|
||||||
|
// nobody in particular.
|
||||||
|
baselineExcludes: ['owed', 'lifestyle', 'standing'],
|
||||||
|
},
|
||||||
|
|
||||||
|
display: {
|
||||||
|
'money': { label: 'Money', format: 'money', order: 1 },
|
||||||
|
'owed': { label: 'Owed at the store', format: 'money', order: 2, hideWhenZero: true },
|
||||||
|
'standing': { label: 'Standing', format: 'meter', order: 3 },
|
||||||
|
'skills.nerve': { label: 'Nerve', format: 'meter', order: 4 },
|
||||||
|
'skills.trade': { label: 'Trade', format: 'meter', order: 5 },
|
||||||
|
'lifestyle': {
|
||||||
|
label: 'What you keep', format: 'tier', order: 6, hideWhenZero: true,
|
||||||
|
labels: ['The livery horse', 'Your own horse', 'Horse and a room'],
|
||||||
|
},
|
||||||
|
'notable': { hidden: true, inLedger: false },
|
||||||
|
'flags.carries_iron': {
|
||||||
|
label: 'The scattergun', hidden: true,
|
||||||
|
whenTrue: 'You are carrying Vane’s scattergun on the box.',
|
||||||
|
whenFalse: 'The scattergun is back under the office counter.',
|
||||||
|
},
|
||||||
|
'flags.owes_the_store': {
|
||||||
|
label: 'The store ledger', hidden: true,
|
||||||
|
whenTrue: 'Your name is in the book at the store.',
|
||||||
|
whenFalse: 'You are square with the store.',
|
||||||
|
},
|
||||||
|
'flags.covered_for_dutch': {
|
||||||
|
label: 'Dutch’s run', hidden: true,
|
||||||
|
whenTrue: 'Dutch owes you a run.',
|
||||||
|
whenFalse: 'You and Dutch are square.',
|
||||||
|
},
|
||||||
|
'flags.sworn_in': {
|
||||||
|
label: 'The star', hidden: true,
|
||||||
|
whenTrue: 'You are sworn in.',
|
||||||
|
whenFalse: 'You are back in the yard.',
|
||||||
|
},
|
||||||
|
'flags.spoke_for_dutch': {
|
||||||
|
label: 'Dutch’s name', hidden: true,
|
||||||
|
whenTrue: 'Dutch’s name is in for the good runs.',
|
||||||
|
whenFalse: 'Dutch’s name is not in for the good runs.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
characters,
|
||||||
|
events,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default frontier;
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// 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';
|
||||||
|
import { countingHouse } from './countinghouse/index.js';
|
||||||
|
|
||||||
|
export const packs = [corporateLadder, frontier, countingHouse];
|
||||||
|
|
||||||
|
/** 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;
|
||||||
@@ -0,0 +1,856 @@
|
|||||||
|
# Architecture decisions
|
||||||
|
|
||||||
|
Why the code is shaped the way it is. Append to this file as decisions are
|
||||||
|
made; the reasoning is worth more later than the conclusion.
|
||||||
|
|
||||||
|
## 1. All game state is addressable by path
|
||||||
|
|
||||||
|
Every value in a run — `money`, `skills.negotiation`,
|
||||||
|
`relationships.boss.likes_you`, `flags.took_loan` — is reachable by a dotted
|
||||||
|
path string, and conditions and effects are expressed against those paths.
|
||||||
|
|
||||||
|
The planning docs' schema had `requires: { skill, min }` and
|
||||||
|
`effects: { money, skills, relationships }`. Both hardcode the state shape into
|
||||||
|
the engine: the first gate that wants a relationship threshold, a money floor
|
||||||
|
or a flag, and the first effect that touches a fourth kind of state, means
|
||||||
|
editing engine code. Path addressing costs one `getPath`/`setPath` pair and
|
||||||
|
removes the whole class of change.
|
||||||
|
|
||||||
|
Everything else in the engine is built on this primitive: option gates, event
|
||||||
|
triggers, the soft-failure branch, and — later — promotion tiers.
|
||||||
|
|
||||||
|
## 2. Conditions and effects are data, evaluated generically
|
||||||
|
|
||||||
|
A condition is `{ path, op, value }` or a combinator (`all` / `any` / `not`).
|
||||||
|
An effect is `{ path, op, value }` with `add` / `set` / `push` / `remove`.
|
||||||
|
The engine knows the operators; it never knows what a path *means*.
|
||||||
|
|
||||||
|
Bounds live in the pack's state declaration (`min` / `max`) and are applied by
|
||||||
|
the effect layer, so clamping is content's decision, not the engine's.
|
||||||
|
|
||||||
|
## 3. There is no such thing as a "random event"
|
||||||
|
|
||||||
|
Every turn, the engine filters the entire event pool by stage and conditions,
|
||||||
|
then draws by weight. A routine turn is a high-weight event with loose
|
||||||
|
conditions; a rare interruption is a low-weight one; the hard-times branch is
|
||||||
|
an event whose conditions include a money threshold. The engine branches on
|
||||||
|
none of it, which is what the planning docs asked for in
|
||||||
|
`03-claude-code-build-prompt.md` §3 — but achieved by deleting the category
|
||||||
|
rather than by carefully not special-casing it.
|
||||||
|
|
||||||
|
Consequence: every stage needs at least one event that no exclusion rule can
|
||||||
|
remove (no `requires`, no `once`, no `cooldown`), or the pool can run dry
|
||||||
|
mid-run. The validator enforces this.
|
||||||
|
|
||||||
|
## 4. The engine owns some state; content may read it but not write it
|
||||||
|
|
||||||
|
`turn`, `stage`, `seen`, `lastSeen`, `history` and `meta` are engine
|
||||||
|
bookkeeping. Content can read them in conditions — "only after day 10", "only
|
||||||
|
if this event has never fired" — which is useful and safe. Writing them is a
|
||||||
|
validation error, so data cannot corrupt the loop.
|
||||||
|
|
||||||
|
`seen.<eventId>` is a count and `lastSeen.<eventId>` a turn number, which is
|
||||||
|
why event ids must be valid path segments.
|
||||||
|
|
||||||
|
## 5. Seeded, serializable RNG
|
||||||
|
|
||||||
|
Not in the planning docs; added because it is nearly free and pays three ways:
|
||||||
|
tests are deterministic, playtest reports are reproducible from seed plus
|
||||||
|
choice sequence, and the feedback log becomes replayable rather than merely
|
||||||
|
descriptive. mulberry32 was chosen because its entire state is one uint32, so
|
||||||
|
the generator's position round-trips through JSON with no special handling.
|
||||||
|
|
||||||
|
The seed and position live in `meta` inside the save file. Nothing about a run
|
||||||
|
exists outside its state object.
|
||||||
|
|
||||||
|
## 6. The turn loop is a pure function
|
||||||
|
|
||||||
|
`takeTurn(pack, state, optionId) -> { state, record }`. No mutable game object,
|
||||||
|
no hidden state. A saved game resumes into exactly the run it left — there is a
|
||||||
|
test that plays 30 turns straight, plays the same 30 turns with a JSON round
|
||||||
|
trip in the middle, and deep-equals the results.
|
||||||
|
|
||||||
|
## 7. Content is validated at load, not discovered at play
|
||||||
|
|
||||||
|
`validatePack` checks that every path referenced by a condition or effect is
|
||||||
|
declared, every character id resolves, ids are usable as path segments, no
|
||||||
|
event is unreachable, and every stage has a fallback. Without it, a typo in
|
||||||
|
content fails silently forty turns into a playthrough — the exact failure mode
|
||||||
|
that makes "just add data" unsafe for a non-programmer.
|
||||||
|
|
||||||
|
## 8. Distribution: ES modules in dev, one inlined file to ship
|
||||||
|
|
||||||
|
Chrome and Firefox block ES module imports and `fetch` over `file://`, so
|
||||||
|
"open the HTML file" and "modular JS with JSON content" are in direct conflict.
|
||||||
|
Resolved by serving during development (`npm run serve`) and inlining
|
||||||
|
everything into a single self-contained HTML file for distribution
|
||||||
|
(`npm run build`).
|
||||||
|
|
||||||
|
Related: content is authored as `.js` modules exporting plain objects rather
|
||||||
|
than `.json`. Same data, but comments and multiline strings are available —
|
||||||
|
which matters a great deal for narrative text — and it sidesteps `fetch`
|
||||||
|
entirely.
|
||||||
|
|
||||||
|
## 9. Tuning values live in the content pack
|
||||||
|
|
||||||
|
Relationship stats run 0–100 starting at 50, clamped; typical choice effects
|
||||||
|
are a few points, so one choice matters but several turns of consistent
|
||||||
|
behaviour are needed to move someone. Money is dollars. Turn is a day.
|
||||||
|
|
||||||
|
None of these are engine constants. They are declared in the pack manifest, so
|
||||||
|
retuning the game — or a pack that runs on weeks instead of days — touches no
|
||||||
|
code.
|
||||||
|
|
||||||
|
## 10. Naming
|
||||||
|
|
||||||
|
The product is **The Ladder**; the default content pack is **Corporate
|
||||||
|
Ladder**. The planning docs use "Corporate Ladder" as the working title for
|
||||||
|
both, which would have baked one setting into the product name, against the
|
||||||
|
explicit goal that corporate is one pack among many.
|
||||||
|
|
||||||
|
## 11. Per-turn upkeep is declared by the pack
|
||||||
|
|
||||||
|
The MVP spec wants "basic income and basic recurring costs", but effects only
|
||||||
|
run when a player chooses something. Baking wages and rent into every option
|
||||||
|
would be tedious and easy to get wrong.
|
||||||
|
|
||||||
|
`pack.upkeep` is a list of `{ label, requires?, effects }` applied at the end of
|
||||||
|
each turn, after the choice lands. It is still data — the engine reads it and
|
||||||
|
knows nothing about wages — and the conditions make it expressive enough to be
|
||||||
|
useful: Corporate Ladder's loan service charge is three conditional entries
|
||||||
|
forming a step function that escalates with the size of the debt, which needed
|
||||||
|
no multiply operation and no engine change.
|
||||||
|
|
||||||
|
Upkeep changes are recorded separately from choice changes in the turn record,
|
||||||
|
so the UI can show "you chose this" and "and then the month happened" as
|
||||||
|
different things.
|
||||||
|
|
||||||
|
## 12. Characters may start somewhere other than the pack default
|
||||||
|
|
||||||
|
`relationshipStats` sets the defaults; a character's `startingRelationship`
|
||||||
|
overrides individual stats. Trevor starts below the default on `likes_you`
|
||||||
|
because he arrived two weeks before you and has priced that in — which is
|
||||||
|
characterisation expressed as data rather than as an opening cutscene.
|
||||||
|
|
||||||
|
Nobody starts feared: `fears_you` defaults to 10 rather than 50, because a
|
||||||
|
midpoint default would mean every new hire arrives already intimidating.
|
||||||
|
|
||||||
|
## 13. Content is tested for reachability, not just validity
|
||||||
|
|
||||||
|
`validatePack` proves a pack *can* run. It does not prove any of it is ever
|
||||||
|
seen. The content suite plays 60 seeded runs and asserts that every event fires
|
||||||
|
and every option is taken at least once — dead content is otherwise invisible,
|
||||||
|
and two whole events plus a hard-times escape route were in fact unreachable on
|
||||||
|
the first pass because Trevor's gate sat above anything a player could
|
||||||
|
plausibly reach.
|
||||||
|
|
||||||
|
**A trap that cost a cycle:** the chooser's RNG must be seeded differently from
|
||||||
|
the game's. Both draw exactly one number per turn from the same generator, so a
|
||||||
|
shared seed correlates the choice index with the event draw and makes whole
|
||||||
|
option combinations structurally unreachable. It presents as a content bug and
|
||||||
|
is not one. Any headless playtest harness written later has to know this.
|
||||||
|
|
||||||
|
## 14. The interface is named by the pack, not by the UI
|
||||||
|
|
||||||
|
`pack.display` maps a state path to `{ label, format, order, hidden,
|
||||||
|
hideWhenZero }`. The header reads it to decide what to show, in what order, and
|
||||||
|
whether a value is money or a bar. Anything the pack does not describe falls
|
||||||
|
back to a name derived from the path, so content is never *required* to supply
|
||||||
|
a label just to be playable.
|
||||||
|
|
||||||
|
This is why the header says "Standing" for a path called `reputation`, and why
|
||||||
|
`debt` disappears while it is zero — both are content's decisions. A Wild West
|
||||||
|
pack renames the whole interface without touching a UI file.
|
||||||
|
|
||||||
|
## 15. A turn has two phases
|
||||||
|
|
||||||
|
Choosing, then outcome. The outcome phase shows what the choice did, as a list
|
||||||
|
of deltas, and separately what the day cost regardless — wages, rent, the loan.
|
||||||
|
Collapsing these into one screen would have made the numbers move without the
|
||||||
|
player seeing why, which is most of what makes them mean anything.
|
||||||
|
|
||||||
|
Locked options are rendered greyed with the requirement spelled out ("Needs
|
||||||
|
Negotiation 15+") rather than hidden. A player should be able to see the door
|
||||||
|
they cannot open yet; content can still opt into hiding one with
|
||||||
|
`whenLocked: 'hide'`.
|
||||||
|
|
||||||
|
The feedback widget lives in the outcome phase, because that is the moment a
|
||||||
|
player has an opinion — they have now seen both the options and the result.
|
||||||
|
|
||||||
|
## 16. Full re-render, with one deliberate exception
|
||||||
|
|
||||||
|
The root element is rebuilt from state on every change. At this size it is
|
||||||
|
instant and it removes a whole class of stale-view bug.
|
||||||
|
|
||||||
|
The exception is the feedback text fields, which update the model without
|
||||||
|
re-rendering: rebuilding the DOM under a cursor throws the player out of the
|
||||||
|
box they are typing in. Their contents are flushed to the log on a timer, when
|
||||||
|
the turn advances, and on `beforeunload`.
|
||||||
|
|
||||||
|
A related bug worth remembering: the flush originally skipped writing when the
|
||||||
|
feedback was empty, which meant *clearing* a rating did not clear it from the
|
||||||
|
log. Withdrawing feedback is itself feedback. The guard is now "has the player
|
||||||
|
touched this", not "is there content".
|
||||||
|
|
||||||
|
## 17. The single-file build is verified by running it
|
||||||
|
|
||||||
|
`tools/build.js` inlines the stylesheet and flattens the module graph into one
|
||||||
|
script. It is a deliberately small bundler that understands only the module
|
||||||
|
syntax this project uses and **throws on anything it does not recognise** — a
|
||||||
|
silent mis-bundle is far worse than a failed build.
|
||||||
|
|
||||||
|
The build is covered by a test that executes the bundled script against the
|
||||||
|
fake DOM and plays a turn through it. A build that merely produces a file
|
||||||
|
proves nothing; a broken bundle is otherwise discovered by whoever was handed
|
||||||
|
the file.
|
||||||
|
|
||||||
|
## 18. What the tests do not cover
|
||||||
|
|
||||||
|
There is no browser on this machine that can render the page — headless Firefox
|
||||||
|
cannot get a framebuffer, and no Chromium is installed. The UI tests drive the
|
||||||
|
real app modules against a minimal fake DOM (`test/fixtures/fake-dom.js`), which
|
||||||
|
covers wiring: imports resolve, handlers fire, screens render the right text,
|
||||||
|
choices reach the engine, saves and the log are written.
|
||||||
|
|
||||||
|
It does not cover layout, CSS, the dark-mode palette, file downloads or file
|
||||||
|
imports. Those need a human with a browser, and should be treated as unverified
|
||||||
|
until someone has clicked them.
|
||||||
|
|
||||||
|
## 19. The calendar is the pack's, derived by the engine
|
||||||
|
|
||||||
|
First playtest: *"wasn't clear what daily drain was for… should probably pay
|
||||||
|
rent weekly / get paid every other week… would be good to have day of week
|
||||||
|
shown and week # instead of just day #"*.
|
||||||
|
|
||||||
|
A pack may declare `calendar: { cycleLength, cycleName, unitNames }`. The
|
||||||
|
engine derives `calendar.index`, `calendar.cycle` and `calendar.name` from the
|
||||||
|
turn number and writes them into engine-owned state *before* the turn's event
|
||||||
|
is drawn — so content can require a Friday, and upkeep can charge rent on a
|
||||||
|
Monday. A pack that declares no calendar has none; the engine imposes no week
|
||||||
|
of its own, and `turnUnit` remains whatever the pack says.
|
||||||
|
|
||||||
|
## 20. Upkeep runs on a schedule
|
||||||
|
|
||||||
|
`every` and `offset` on an upkeep entry make it periodic: wages are
|
||||||
|
`{ every: 14, offset: 4 }` — alternate Fridays, given that turn 1 is a Monday —
|
||||||
|
and rent is `{ every: 7, offset: 7 }`. What was a flat $25/day of unexplained
|
||||||
|
drain is now $18 of small change, a fortnightly paycheque and a weekly rent
|
||||||
|
cheque, which is both easier to plan around and easier to feel.
|
||||||
|
|
||||||
|
Two display consequences, both of which were the real cause of the "unclear
|
||||||
|
drain" complaint. The ledger now labels a change with the upkeep entry that
|
||||||
|
caused it ("Rent −$455") rather than the path it moved ("Money −$455"). And
|
||||||
|
the turn screen carries a diary line — "Wages tomorrow · Rent in 4 days" —
|
||||||
|
computed generically from any pack's scheduled entries, so the player is told
|
||||||
|
what is coming before it happens rather than after.
|
||||||
|
|
||||||
|
## 21. Content coverage is measured across player archetypes
|
||||||
|
|
||||||
|
Playtest: *"always in debt — could never get ahead"*. The economy was retuned so
|
||||||
|
that a careful player climbs, a careless one sinks, and an unplanned one treads
|
||||||
|
water, with a `spare_shift` event that surfaces *because* you are broke — the
|
||||||
|
way out of a hole should be visible from inside it.
|
||||||
|
|
||||||
|
That retune broke the old coverage test, and usefully. Random play stopped
|
||||||
|
reaching hard times at all, which made the whole debt branch look dead. It was
|
||||||
|
not dead; random play is simply not a plausible player.
|
||||||
|
|
||||||
|
Coverage is now the union across five archetypes (`test/fixtures/strategies.js`)
|
||||||
|
— random, thrifty, spendthrift, sociable, and "desperate", which is broke *and*
|
||||||
|
well-liked. That last one exists because the "ask a friend for money" options
|
||||||
|
sit in a state no single-axis strategy ever reaches. The economy's shape is
|
||||||
|
asserted as an ordering between archetypes rather than as absolute figures, so
|
||||||
|
tuning does not churn the tests.
|
||||||
|
|
||||||
|
## 22. Stage is the one piece of engine state content may write
|
||||||
|
|
||||||
|
A promotion is `{ path: 'stage', op: 'set', value: 'dispatch' }` on an option's
|
||||||
|
effects. Nothing else about it exists in the engine — `stage` was already how
|
||||||
|
events are filtered, and moving the player between stages is a content
|
||||||
|
decision, so the reserved-write rule now allows it.
|
||||||
|
|
||||||
|
It allows it narrowly. Only `set`, and only to a stage that actually has events;
|
||||||
|
both are checked by the validator. A typo cannot strand the player somewhere
|
||||||
|
with an empty event pool.
|
||||||
|
|
||||||
|
Wages differ by rank through two upkeep entries gated on `stage`, not through
|
||||||
|
any notion of rank in the engine. The hard-times events dropped their `stage`
|
||||||
|
entirely and so apply everywhere — rent is due wherever you work, and that
|
||||||
|
branch has to follow the player up rather than vanishing on promotion.
|
||||||
|
|
||||||
|
## 23. What "reachable" means for an option
|
||||||
|
|
||||||
|
The coverage test used to ask whether an archetype ever *took* an option. That
|
||||||
|
is the wrong question: whether a bot picks something is an artefact of how the
|
||||||
|
bot scores, not a property of the pack. `rent_bounced.ask_trevor` was offered
|
||||||
|
and unlocked 419 times across a sweep and chosen once, and the test called it
|
||||||
|
dead content.
|
||||||
|
|
||||||
|
Events are still checked on firing, since the engine draws those. Options are
|
||||||
|
checked on *availability*: every option must, at some point, be offered to a
|
||||||
|
player unlocked. What they then do with it is theirs.
|
||||||
|
|
||||||
|
## 24. Variety is a measured property of the pack
|
||||||
|
|
||||||
|
Playtest: a 183-turn session, *"mailroom stayed mostly interesting despite the
|
||||||
|
number of repeats… what might be of interest is seeing how many turns are
|
||||||
|
played versus how many unique situations."*
|
||||||
|
|
||||||
|
So it is now measured, in three places.
|
||||||
|
|
||||||
|
`tools/analyze-log.js` reads an exported feedback log and reports turns against
|
||||||
|
distinct situations, how often each recurred, which options were most used,
|
||||||
|
which locked gates players kept meeting, and anything they typed. The
|
||||||
|
retrospective shows the player-facing version of the same thing.
|
||||||
|
|
||||||
|
And a test asserts no single event exceeds 25% of a 200-turn run, and that a run
|
||||||
|
sees more than 60% of what was reachable in the stages it visited — measured
|
||||||
|
against reachable content, since a run that never leaves the mailroom cannot see
|
||||||
|
Dispatch and counting that as thin would be measuring career progress instead.
|
||||||
|
|
||||||
|
That guardrail exists because the first version of Dispatch failed it badly:
|
||||||
|
eight events against the mailroom's nineteen meant its two floor events were 46%
|
||||||
|
of every promoted run, one every three turns. Promotion was moving the player
|
||||||
|
into *thinner* content. Three more events and a weight rebalance took the top
|
||||||
|
two to 24.5%, and the heaviest recurrence from every ~3 turns to every ~5.
|
||||||
|
|
||||||
|
## 25. The screen says which build it is
|
||||||
|
|
||||||
|
Playtest: *"need entry on the main screen showing what version we're running…
|
||||||
|
so it's easy to tell when new stuff is."*
|
||||||
|
|
||||||
|
A status line at the top of every screen names the game, the pack, the player's
|
||||||
|
current title, and the build.
|
||||||
|
|
||||||
|
`src/build-info.js` is tracked and permanently reads `commit: 'dev'`; it is
|
||||||
|
never rewritten. `tools/build.js` substitutes the real commit into the *bundled
|
||||||
|
output* only, so `dist/theladder.html` states exactly which commit produced it
|
||||||
|
while the working tree never churns. A build from a dirty tree gets a `+`
|
||||||
|
suffix — "5c1784d+" is honest in a way a bare SHA would not be, and the build
|
||||||
|
fails loudly if the substitution finds nothing to replace.
|
||||||
|
|
||||||
|
Running from source therefore reads "0.3.0 · dev", which is what it is.
|
||||||
|
|
||||||
|
The first attempt stamped the tracked file in place before every serve and
|
||||||
|
build. It was wrong in a way worth recording: after each commit the committed
|
||||||
|
stamp named the *previous* commit, and the next serve rewrote it and dirtied the
|
||||||
|
tree. A generated value does not belong in a tracked file if anything routinely
|
||||||
|
regenerates it.
|
||||||
|
|
||||||
|
## 26. The whole game is playable from the keyboard
|
||||||
|
|
||||||
|
Playtest: *"when I get the results, the focus should automatically be on the
|
||||||
|
next day, so I can just hit Enter."*
|
||||||
|
|
||||||
|
Every screen marks one control with `dataset.autofocus` and the app focuses it
|
||||||
|
after each render: the first *available* option while choosing, the next-turn
|
||||||
|
button once the result is in, "keep going" on the retrospective. A run is
|
||||||
|
therefore playable on Enter alone, with Tab and Shift+Tab to reach a different
|
||||||
|
option.
|
||||||
|
|
||||||
|
Locked options need no special handling — the browser's own tab order skips
|
||||||
|
disabled controls, so they stay visible without being in the way, which is
|
||||||
|
exactly the behaviour wanted.
|
||||||
|
|
||||||
|
## 27. The ledger is written for a player, not for the engine
|
||||||
|
|
||||||
|
Two fixes from the same playtest note, *"what is notable? under what changed?"*.
|
||||||
|
|
||||||
|
`notable` is the retrospective's own list of moments. Reading "Notable —
|
||||||
|
changed" in the turn ledger tells nobody anything, so a display rule may now
|
||||||
|
say `inLedger: false` and it is left out.
|
||||||
|
|
||||||
|
Flags are the opposite: a flag flipping is a fact about the world and worth
|
||||||
|
seeing. But "the envelope: now true" is engine talk. A display rule can supply
|
||||||
|
`whenTrue` and `whenFalse` sentences — "The envelope is in your locker." — and
|
||||||
|
the ledger prints those instead of a delta.
|
||||||
|
|
||||||
|
## 28. Exported logs live in logs/, and are not committed
|
||||||
|
|
||||||
|
`logs/` holds exported feedback logs and `npm run analyze` reads the newest one
|
||||||
|
there when given no argument. The logs are gitignored: they record what a real
|
||||||
|
person did turn by turn, including anything they typed into the feedback box.
|
||||||
|
That is not repository content, and making it so once would put it in the
|
||||||
|
history for good.
|
||||||
|
|
||||||
|
## 29. The economy needs a test on its arithmetic, not just on its outcomes
|
||||||
|
|
||||||
|
A real 182-turn session spent 30% of its turns below zero, went negative on turn
|
||||||
|
18 and never recovered, and chose the same hard-times option — "take every shift
|
||||||
|
going" — thirty-seven times.
|
||||||
|
|
||||||
|
The cause was a tuning change made without re-deriving what it meant. Rent went
|
||||||
|
from 385 to 455 while chasing a different problem, which took the mailroom
|
||||||
|
baseline from the designed −$3/day to −$13/day. Worse, at the debt that run
|
||||||
|
accumulated, the weekly service charge came to $180 against an escape option
|
||||||
|
worth $120: the hole was inescapable *by arithmetic*, whatever the player did.
|
||||||
|
|
||||||
|
Nothing caught it. Every archetype either optimised money or was promoted out of
|
||||||
|
the problem before it bit, and the 25%-dominance test passed because
|
||||||
|
`rent_bounced` was only 17% of turns.
|
||||||
|
|
||||||
|
Three things came out of it.
|
||||||
|
|
||||||
|
The baseline is now asserted directly — a test sums the pack's own upkeep over
|
||||||
|
twenty fortnights and requires the mailroom to net between −90 and +10, and
|
||||||
|
Dispatch to be better but under +250. That test would have failed the moment
|
||||||
|
rent changed. Outcome tests over simulated play are worth having, but they are a
|
||||||
|
slow and noisy way to detect a number that is simply wrong.
|
||||||
|
|
||||||
|
A `lifer` archetype was added: it refuses any option that would change stage —
|
||||||
|
generically, by looking for an effect on `stage` — and otherwise plays for
|
||||||
|
people. It reproduces the session that found this, and no other archetype can.
|
||||||
|
|
||||||
|
And a test now holds that no archetype spends more than a quarter of a run below
|
||||||
|
zero. Hard times is a state a player passes through. Living in it is the
|
||||||
|
failure mode.
|
||||||
|
|
||||||
|
Fixes: mailroom wages 980 → 1120, the escape option 120 → 220 so it is worth
|
||||||
|
more than a week's rent, `rent_bounced` from weight 5000 with no cooldown to 900
|
||||||
|
on a cooldown of 3 — being broke should colour a run, not replace it — and
|
||||||
|
`debt_collector` from weight 25 to 45, since at 25 it appeared six times in 262
|
||||||
|
turns and debt became a one-way ratchet.
|
||||||
|
|
||||||
|
## 30. Gaps are measured within a run, never across two
|
||||||
|
|
||||||
|
`tools/analyze-log.js` reported "every ~-5 turns". One export can hold several
|
||||||
|
playthroughs and turn numbers restart with each, so a first-seen in run two
|
||||||
|
compared against a last-seen in run one produces nonsense.
|
||||||
|
|
||||||
|
Spans and pair-counts are now accumulated per run and pooled, so the figure is a
|
||||||
|
proper weighted average of within-run gaps. It is worth stating the general
|
||||||
|
form: any statistic over a log has to respect the run boundary, because a log is
|
||||||
|
not one sequence.
|
||||||
|
|
||||||
|
## 31. A pack declares the numbers it must hit
|
||||||
|
|
||||||
|
Decision #29 added a test that asserts Corporate Ladder's economic baseline
|
||||||
|
directly, because a tuning change made without re-deriving what it meant had
|
||||||
|
trapped a real playtester. That test named the pack, named its stages and
|
||||||
|
hard-coded its ranges, which meant a second pack got none of it — the guard
|
||||||
|
that exists precisely because a number went unchecked would itself have gone
|
||||||
|
uncopied.
|
||||||
|
|
||||||
|
So the numbers moved into the pack, as `tuning.baselinePerCycle`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
tuning: {
|
||||||
|
baselinePerCycle: { hand: [0, 12], deputy: [10, 30] },
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
The conformance suite reads that and holds each pack to its own declared range.
|
||||||
|
A new setting is checked the moment it is in the registry, and what it is
|
||||||
|
checked against is its designer's stated intent rather than another setting's
|
||||||
|
numbers. The pack is required to declare it: a missing `tuning` block fails,
|
||||||
|
because the silent failure mode here is a pack that is never economically
|
||||||
|
tested at all.
|
||||||
|
|
||||||
|
The figure is **derived, not simulated**. An upkeep entry worth `v` every `n`
|
||||||
|
turns is worth `v * cycleLength / n` per cycle, exactly. Simulating it over a
|
||||||
|
fixed window reads differently depending on where the last rent lands: Corporate
|
||||||
|
Ladder measures −19 a fortnight over 280 turns and −42 over 560, and only the
|
||||||
|
second is the steady state. The original test used the 280-turn window and was
|
||||||
|
reading a number 23 dollars off the truth — inside its own tolerance, so it
|
||||||
|
never said so.
|
||||||
|
|
||||||
|
## 32. A skill gate is never gated behind itself
|
||||||
|
|
||||||
|
Corporate Ladder gates four options on `skills.negotiation >= 15`, and several
|
||||||
|
of its largest negotiation gains sit behind that same gate — `+4` on
|
||||||
|
`marlene_confides.ask_how_to_leave`, `+3` on `trevor_favor.trade`. Its logs show
|
||||||
|
players meeting the wall repeatedly through the first thirty turns.
|
||||||
|
|
||||||
|
Measured, the gate is not unreachable: a player steering directly for the skill
|
||||||
|
is through it by turn 11. What the logs actually show is ordinary players not
|
||||||
|
prioritising it and having no incidental route, which is a pacing failure rather
|
||||||
|
than a reachability one — and the reachability test could never have caught it,
|
||||||
|
because reachability is exactly what holds.
|
||||||
|
|
||||||
|
The Frontier is built to a rule instead, asserted in `test/frontier.test.js`:
|
||||||
|
for every skill threshold in the pack, the growth available *without* passing
|
||||||
|
that threshold must be able to reach it, from repeatable events — a single
|
||||||
|
one-off award is not a route. A second test measures it, requiring a player who
|
||||||
|
steers for a skill to be through its lowest gate inside twenty turns.
|
||||||
|
|
||||||
|
The rule is deliberately not in the conformance suite, because Corporate Ladder
|
||||||
|
does not satisfy it and retuning that pack is a separate decision from adding
|
||||||
|
this one. It is written down here so the choice is visible rather than implied.
|
||||||
|
|
||||||
|
## 33. One save slot per pack, and the picker is the front door
|
||||||
|
|
||||||
|
`theladder.save.v1` was a single global slot. The app guarded it by comparing
|
||||||
|
the packId inside the save against the pack it was about to run, and started a
|
||||||
|
new game when they differed — so with two settings, the first switch would have
|
||||||
|
silently destroyed the other career.
|
||||||
|
|
||||||
|
Slots are now `theladder.save.v1.<packId>`, one per setting, and switching is
|
||||||
|
non-destructive in both directions: each run is written to its own slot every
|
||||||
|
turn, and coming back resumes rather than restarts. `theladder.pack.v1` holds
|
||||||
|
the last setting played, so a returning player lands back where they were.
|
||||||
|
|
||||||
|
The pre-packs slot is still read once, for Corporate Ladder only, and only when
|
||||||
|
it really holds a Corporate Ladder save. It is never written. Someone mid-career
|
||||||
|
when this shipped finds their career.
|
||||||
|
|
||||||
|
A first-time player — nothing saved, nothing remembered — gets the setting
|
||||||
|
picker rather than being dropped into whichever pack is first in the registry.
|
||||||
|
A build shipping one pack skips it entirely.
|
||||||
|
|
||||||
|
## 34. What a second setting proved, and what it cost
|
||||||
|
|
||||||
|
The engine/content split was a claim until there were two settings. The
|
||||||
|
Frontier is 29 events across two stages, a four-day cycle instead of a
|
||||||
|
seven-day one, a different relationship vocabulary (`trusts_you`, `owes_you`,
|
||||||
|
`wary_of_you`), a different skill pair, and money about fifteen times smaller
|
||||||
|
that arrives from work rather than from a wage. `src/engine/` was not touched.
|
||||||
|
|
||||||
|
Three things outside the engine did have to change, and none of them was about
|
||||||
|
the setting:
|
||||||
|
|
||||||
|
- **`src/io/save.js`** — the single save slot, above.
|
||||||
|
- **`src/ui/app.js`** — it named one pack; it now holds the current one. The
|
||||||
|
screens, the engine and the rest of the io layer still take the pack as an
|
||||||
|
argument and are no less pack-agnostic than before.
|
||||||
|
- **`src/ui/screens/turn.js:40`** — hardcoded `'The mailroom'` as the heading
|
||||||
|
over the people panel. That was the interface naming a setting, against
|
||||||
|
decision #14, and it had gone unnoticed while only one setting existed. It is
|
||||||
|
now `pack.peopleHeading`.
|
||||||
|
|
||||||
|
`tools/build.js` needed one fix, unrelated to packs but exposed by them: its
|
||||||
|
module scan used `.*?` where `transform` used a pattern that spans lines, so a
|
||||||
|
destructured import wrapped across several lines was rewritten into a
|
||||||
|
`__require` for a module the scan had never collected. The bundle built clean
|
||||||
|
and failed only when run. Both now use `[\s\S]*?`.
|
||||||
|
|
||||||
|
The honest summary is that adding a setting required no engine change and three
|
||||||
|
interface changes, two of which were latent defects that a second pack merely
|
||||||
|
made visible.
|
||||||
|
|
||||||
|
## 35. Three open questions, closed by the designer
|
||||||
|
|
||||||
|
Recorded 2026-09-11, alongside 0.3.1.
|
||||||
|
|
||||||
|
**Corporate Ladder's negotiation gate stays.** A gate that takes deliberate work
|
||||||
|
to get past is acceptable, and so is an option that needs groundwork first. #32
|
||||||
|
remains the rule the Frontier is built to and tested against; it is a choice a
|
||||||
|
pack may make, not a standard every pack must meet, and Corporate Ladder is not
|
||||||
|
being retuned toward it.
|
||||||
|
|
||||||
|
**The feedback widget stays exactly as it is.** Zero ratings across three
|
||||||
|
sessions says nothing about the widget: those playtests were rapid, and feedback
|
||||||
|
came back directly rather than through it. It is not evidence either way until a
|
||||||
|
playtest runs without that direct channel.
|
||||||
|
|
||||||
|
**Money has to matter late, through the cost of standing.** A careful player
|
||||||
|
ends around $8,300 over 200 turns, and money stops being a constraint. The
|
||||||
|
direction is that rising standing brings rising expenses — the car, the place to
|
||||||
|
live, the clothes a position expects. Upkeep entries are already gated
|
||||||
|
conditions over state, so this is content work, not an engine change.
|
||||||
|
|
||||||
|
## 37. A playtest is only as good as its instruments
|
||||||
|
|
||||||
|
The second session — 181 turns of the Frontier, the first time that pack has
|
||||||
|
been played by a human at all — ran against a **stale bundle**. The page had
|
||||||
|
been loaded before the last rebuild, and switching settings inside an open tab
|
||||||
|
does not re-fetch the script, so the run exercised everything except the change
|
||||||
|
it was meant to test. The charge that never stops fired zero times in ninety
|
||||||
|
turns where its conditions held.
|
||||||
|
|
||||||
|
Working that out took file timestamps and a simulation. It should have taken one
|
||||||
|
line of the log, so three things changed:
|
||||||
|
|
||||||
|
- **Every logged turn now records `app_version` and `build`.** The log says
|
||||||
|
which bundle produced it, and a stale session is visible immediately rather
|
||||||
|
than looking exactly like a feature that does not work.
|
||||||
|
- **`src/version.js` was lying.** It hardcoded `0.3.0` through the whole of the
|
||||||
|
0.3.1 and 0.3.2 work, while playtesters were told to check the status bar to
|
||||||
|
know what they were on. A version string that lies is worse than none, because
|
||||||
|
it is trusted. A test now holds it to `package.json`.
|
||||||
|
- **A locked door is only worth showing when it is a door.** `pay_down` — "put
|
||||||
|
money against the book" — was the most-locked option in the session, shown six
|
||||||
|
times to a player who owed nothing. Options gated on a state the player is not
|
||||||
|
in and cannot choose to enter now carry `whenLocked: 'hide'`. Gates worth
|
||||||
|
bouncing off, like a skill threshold, stay visible: that is the signal the log
|
||||||
|
exists to collect.
|
||||||
|
|
||||||
|
## 36. Standing costs money to keep
|
||||||
|
|
||||||
|
Built in both packs, with no engine change, exactly as #35 predicted.
|
||||||
|
|
||||||
|
Each pack declares a `lifestyle` tier — Corporate Ladder's car and address, the
|
||||||
|
Frontier's own horse and the room over the Ellis place — bought at a second-rung
|
||||||
|
event and charged for every cycle thereafter. Dispatch nets +49 a week; with the
|
||||||
|
car +19; with both, **-21**, so a coordinator keeping up has to earn the
|
||||||
|
difference out of the days themselves. The Frontier runs the same shape at its
|
||||||
|
own scale: +18, +11, +4 a run.
|
||||||
|
|
||||||
|
**The charge that makes it a choice is levied in standing, not money.** A player
|
||||||
|
whose lifestyle lags behind the job loses standing every cycle, in steps that
|
||||||
|
steepen as standing rises. Both are gated on the second stage: the first rung
|
||||||
|
pays too little for any of this, and charging a mailroom associate for a car
|
||||||
|
would be a trap rather than a decision.
|
||||||
|
|
||||||
|
Three things were measured rather than guessed, and all three changed the design:
|
||||||
|
|
||||||
|
- **At -2 a week the charge was invisible.** Dispatch hands out standing several
|
||||||
|
points at a time, so a coordinator who bought nothing still finished pinned at
|
||||||
|
100. The step had to reach -4/-6 before ordinary play settled in the seventies.
|
||||||
|
- **A player who chased standing every turn beat it anyway** — 99 standing while
|
||||||
|
buying nothing, and $2,245 richer than one who kept up. The lifestyle was a
|
||||||
|
pure loss to anybody paying attention, which is the opposite of the intent.
|
||||||
|
The top step (-14 above 80 standing) puts that player's ceiling at 82, so the
|
||||||
|
last fifteen points are bought rather than worked for.
|
||||||
|
- **The Frontier's first numbers left a keeper on $8 after 200 turns.** That is
|
||||||
|
not pressure, it is the debt trap this pack was tuned twice to avoid; the room
|
||||||
|
went from 9 a run to 7.
|
||||||
|
|
||||||
|
**Which upkeep counts as the baseline is now the pack's to declare.** The
|
||||||
|
conformance suite used to exclude debt-gated entries by matching
|
||||||
|
`/debt|owed|loan/i` against path names — the generic suite guessing at content's
|
||||||
|
vocabulary, which #31 exists to prevent, and which would have silently counted
|
||||||
|
every lifestyle tier at once into a baseline that is supposed to mean "the floor
|
||||||
|
a player faces having opted into nothing." Packs now name those paths in
|
||||||
|
`tuning.baselineExcludes`, and the suite fails if one is not declared state.
|
||||||
|
|
||||||
|
**The top of the ladder was a plateau, so one charge never stops.** The first
|
||||||
|
human play of the lifestyle system — 96 turns, Corporate Ladder — found both
|
||||||
|
purchases (the car at turn 52, the address at 75) and showed the standing
|
||||||
|
pressure working exactly as intended: "Looking the part" took 56 reputation, and
|
||||||
|
standing sagged into the low seventies on the car alone before recovering to 100
|
||||||
|
once the address was bought. But from turn 84 the run had nothing left to spend
|
||||||
|
on: every charge switches off at the top tier, and the balance simply climbed.
|
||||||
|
Money stopped mattering again, one rung higher than before.
|
||||||
|
|
||||||
|
Both packs now carry a charge levied by standing itself, which no purchase ends.
|
||||||
|
They are shaped differently, and the difference is measured rather than stylistic:
|
||||||
|
|
||||||
|
- **Corporate Ladder steps on money.** A flat charge cannot flatten a slope —
|
||||||
|
late income comes from choices and scales with how well the player plays, so
|
||||||
|
the flat charge needed to stop an optimiser accumulating (-360 a week) put
|
||||||
|
ordinary archetypes below zero for 44% of a run and ended them $1,385 in debt.
|
||||||
|
Stepping on money ($1,200/-60, $2,400/-120, $3,600/-240) lands it on whoever
|
||||||
|
has it: the balance settles around $4,250 and stops climbing (-3 a week), and
|
||||||
|
ordinary play comes out *better* than before the charge existed — 13% of a run
|
||||||
|
below zero against 17%, a tier-2 run ending +$175 rather than -$25.
|
||||||
|
- **The Frontier is flat, at -4 a run.** Its late game never accumulated in the
|
||||||
|
first place — a deputy who owns everything drifts 0 a run at every charge
|
||||||
|
strength tested — so there is no slope to flatten. The charge is there to keep
|
||||||
|
money live, and break-even at the top is the intent.
|
||||||
|
|
||||||
|
**A save written before a stat existed silently loses the content gated on it.**
|
||||||
|
Found while working out how to playtest this: `loadLocal` returns the saved
|
||||||
|
state verbatim, and `conditions.js` compares numbers only — so for a career
|
||||||
|
saved before `lifestyle` existed, both `lifestyle < 2` and `lifestyle >= 1` are
|
||||||
|
false. The car lot never fires, the purchase never unlocks, the charge never
|
||||||
|
lands, and nothing says so. Resuming a save would have shown a playtester none
|
||||||
|
of this feature while reporting nothing wrong. Loading now fills in any path the
|
||||||
|
pack has gained since (`hydrate`, in `state.js`), writes the repair back, and
|
||||||
|
does the same for an imported file. This is the price of "adding content is just
|
||||||
|
adding data", and it applies to every stat any pack adds from here on.
|
||||||
|
|
||||||
|
**And a test that had passed for two releases was measuring the wrong thing.**
|
||||||
|
"The promotion leaves a player better off" compared the end balances of promoted
|
||||||
|
and unpromoted players pooled across archetypes — but which archetypes get
|
||||||
|
promoted is not independent of how they spend, so the comparison mostly measured
|
||||||
|
the mix. It inverted the moment there was something to spend money on. It is now
|
||||||
|
derived from upkeep arithmetic, and the lifestyle's effect is tested with two
|
||||||
|
players who differ in exactly one habit.
|
||||||
|
|
||||||
|
## 38. A repayment pays what is owed, never more
|
||||||
|
|
||||||
|
Recorded 2026-09-15, for 0.3.4. Designer's call: a small engine addition rather
|
||||||
|
than a content workaround.
|
||||||
|
|
||||||
|
The first human session of the Frontier on a current build — 112 turns — owed the
|
||||||
|
store four dollars on turn 27 and chose "Put money against the book". It took
|
||||||
|
twenty dollars and cleared four: the option was a flat `money -20, owed -20`, and
|
||||||
|
`owed` clamped at zero while `money` did not. The ledger showed "−$4 at its
|
||||||
|
limit", which the player read, reasonably, as a debt still standing.
|
||||||
|
|
||||||
|
The same shape was in five options across both packs — the Frontier's store and
|
||||||
|
both of its hard-times repayments, and both of Corporate Ladder's loan
|
||||||
|
repayments — and none had been caught, because every archetype that pays debt
|
||||||
|
pays large debts.
|
||||||
|
|
||||||
|
**Content could not fix it.** Effects add or set a number the pack wrote, and a
|
||||||
|
balance built from +14, +32, +4, −45 and −10 can be anything, so no fixed payment
|
||||||
|
can equal it. The alternatives were to gate each instalment on the balance
|
||||||
|
covering it (leaving small balances to cost more than they are) or to retune
|
||||||
|
every credit amount to a common step (changing tuned numbers to suit a missing
|
||||||
|
primitive).
|
||||||
|
|
||||||
|
**So an `add` effect's value may now be read from state:**
|
||||||
|
`{ path: 'money', op: 'add', value: { from: 'owed', times: -1, cap: 20 } }` is
|
||||||
|
"take away whatever is owed, but no more than 20". The amount is resolved at the
|
||||||
|
moment the effect applies, so a repayment lists the money effect before the
|
||||||
|
balance effect — both read the balance before it moves. The change record keeps
|
||||||
|
the number actually applied, with the reference in `source`.
|
||||||
|
|
||||||
|
This is not the engine learning about debt. It reads a number from a path without
|
||||||
|
knowing what the path means, exactly as a condition does, and #1 and #2 still
|
||||||
|
hold: it is data, validated at load. The validator requires the referenced path to
|
||||||
|
be declared and `times` and `cap` to be numbers. The test archetypes score such an
|
||||||
|
effect at its cap, the most a player reading the label could expect.
|
||||||
|
|
||||||
|
A conformance test now holds every pack to it: an option that spends money and
|
||||||
|
reduces a balance — any money-formatted stat other than money — must read the
|
||||||
|
amount from the balance, or be gated on the balance covering the whole instalment.
|
||||||
|
|
||||||
|
## 39. A hidden option is not a locked door in the log either
|
||||||
|
|
||||||
|
`whenLocked: 'hide'` (#37) kept "Put money against the book" off the screen of a
|
||||||
|
player who owed nothing, and it worked. But the turn record listed offered options
|
||||||
|
without saying which were hidden, so `npm run analyze` reported the option as a
|
||||||
|
locked door shown three times to a player who never saw it. The instrument was
|
||||||
|
counting the very thing the fix removed.
|
||||||
|
|
||||||
|
The turn record now carries `hidden` for every offered option, and the analyser
|
||||||
|
skips hidden options when counting locked doors. Logs exported before 0.3.4 do not
|
||||||
|
have the field and still over-count.
|
||||||
|
|
||||||
|
The same session's exports also put a save file next to the feedback log in
|
||||||
|
`logs/`. The analyser read "the newest `.json`", so whichever of the two was
|
||||||
|
exported last would have been read as the log. It now reads only
|
||||||
|
`theladder-feedback-*.json` when no file is named.
|
||||||
|
|
||||||
|
## 40. What the first Frontier playtest changed
|
||||||
|
|
||||||
|
The session (112 turns, 0.3.3, promoted on turn 44) confirmed the systems #36 and
|
||||||
|
#37 were built for: both lifestyle purchases were found and taken, the standing
|
||||||
|
charge fired as designed, money never went below zero, and the run ended flat —
|
||||||
|
the break-even the Frontier intends at the top. Everything it found wrong was
|
||||||
|
legibility, plus the defect in #38. All eight of the player's comments were
|
||||||
|
questions about what a number meant, and both thumbs-down were on events that
|
||||||
|
confused them.
|
||||||
|
|
||||||
|
- **Wages are called wages.** "Settlement" and "Deputy pay" became "Wages from
|
||||||
|
Vane" and "Wages from the county". The player could not connect "Settlement"
|
||||||
|
to the twenty-four a run the first morning quotes, and read a run's own +$9 as
|
||||||
|
the pay going missing (#27: the ledger is written for a player).
|
||||||
|
- **A clamped change says where it stopped** — "stopped at $0", "stopped at 100" —
|
||||||
|
instead of "at its limit", which was read as a balance still outstanding.
|
||||||
|
- **Two option labels were rewritten** that the player could not parse: "Have no
|
||||||
|
view on the axle" and "take hold of the nearest one".
|
||||||
|
- **The horse and the room are separate events.** One event offered both, with a
|
||||||
|
description that was always about the horse, so the room was offered under
|
||||||
|
horse text and "Buy the bay" was shown locked to a player who had bought it — a
|
||||||
|
door that could never open. Each is now gated on the tier it sells, and a test
|
||||||
|
holds that neither is offered once bought.
|
||||||
|
- **The promotion gets a warning.** "How do I find out how close I am to
|
||||||
|
promotion?" had no answer anywhere. `ward_takes_notice` comes once, to a hand
|
||||||
|
past turn 24 with standing 55, and says in the town's terms what the offer waits
|
||||||
|
for — a name the street says for the right reasons, and a man Ward takes at his
|
||||||
|
word or one with the Ridge road behind him — without printing a threshold. Across
|
||||||
|
72 archetype runs it fired in 67, at a median of turn 29, always before the offer.
|
||||||
|
A numeric progress display was not built: it would need the interface to know
|
||||||
|
what a promotion is, which #22 keeps in content.
|
||||||
|
- **The office got five more days.** 69 deputy turns saw 13 distinct situations,
|
||||||
|
and `office_day` and `the_cells` were 17% of the stage each — the same thinness
|
||||||
|
#24 caught in Dispatch. Five events were added (the stage in short, the county's
|
||||||
|
fees, a bounty hunter, a fire at the store, Dutch wanting the Ridge road) and the
|
||||||
|
horse/room split adds a sixth. Measured across 72 archetype runs of 250 turns,
|
||||||
|
the three floor events fell from about 17.5% of deputy turns each to about
|
||||||
|
13.5%. Every new event has an ungated way through, and its skill gates (nerve 30
|
||||||
|
and 34, trade 28 and 30) sit under growth available without them (#32).
|
||||||
|
|
||||||
|
**Measured and not yet understood.** In the same simulation, promoted runs now end
|
||||||
|
with a median of $164 rather than $88, and a median standing of 89 rather than 98.
|
||||||
|
Two changes plausibly account for it — repayments no longer overcharge, and lower
|
||||||
|
standing triggers the $4 standing charge less often — but it has not been
|
||||||
|
isolated. The declared baselines and every tuning test still pass. The Frontier is
|
||||||
|
meant to be roughly level at the top, so the next Frontier session should watch
|
||||||
|
whether money stops mattering again, as it did in Corporate Ladder before #36.
|
||||||
|
|
||||||
|
"Owed at the store" was asked for as "a major field at the top". It already is,
|
||||||
|
second after Money whenever anything is owed. Whether it is prominent enough can
|
||||||
|
only be judged in a browser, which this machine cannot render (#18).
|
||||||
|
|
||||||
|
## 41. Chance, and a cast that changes between runs
|
||||||
|
|
||||||
|
Recorded 2026-09-23. Designer's call: a change to the base engine, generic, which
|
||||||
|
every pack may use and none has to. Corporate Ladder and the Frontier use none of
|
||||||
|
it and replay exactly as before.
|
||||||
|
|
||||||
|
Until now an option did exactly what it said. Skills only decided whether an
|
||||||
|
option was on the menu (#15, #32). The story-to-pack catalogue of recurring
|
||||||
|
situations (asking for a rise, the door to the powerful, a colleague's needling)
|
||||||
|
is made of moments whose outcome depends on who you are and who you are dealing
|
||||||
|
with, so three things were added. `content/countinghouse` is the pack that exercises them.
|
||||||
|
|
||||||
|
**Attributes.** A pack names them in `pack.attributes`. The player's live in
|
||||||
|
ordinary declared state (`attributes.<id>`), so they grow by effects like anything
|
||||||
|
else. The prototype's four, agreed with the designer, are Nerve, Wits, Charm and
|
||||||
|
Resolve. Another pack may rename them, as #14 lets it rename the interface.
|
||||||
|
|
||||||
|
**Checks.** An option may carry `check: { attribute, against?, opposedBy?,
|
||||||
|
relationship?, difficulty?, modifier? }` and then must carry `success` and
|
||||||
|
`failure`, each with its own `result` and `effects`. Its plain `effects` apply
|
||||||
|
either way. The chance is
|
||||||
|
|
||||||
|
base + perPoint × (yours − theirs) + relationshipPerPoint × (their regard − centre) + modifier,
|
||||||
|
clamped to [floor, ceiling]
|
||||||
|
|
||||||
|
where "theirs" is the opposing character's attribute, or the check's `difficulty`,
|
||||||
|
or the pack's `unopposed`. Every number is the pack's, in `pack.checks` (#9).
|
||||||
|
**The player never sees a number.** Options show the odds in words ("a long shot",
|
||||||
|
"likely"), from bands the pack may rename, and the outcome says whether it came
|
||||||
|
off. The roll is one draw from the run's own generator, so a run with checks still
|
||||||
|
replays exactly from its seed and choices (#5, #6). A pack that never checks draws
|
||||||
|
nothing extra.
|
||||||
|
|
||||||
|
**A cast drawn per run.** A character may be a *role* with `candidates`, several
|
||||||
|
authored people who could fill it, and one is drawn when a run starts. A role may
|
||||||
|
be a *group* with `count: [min, max]` (two to four clerks), which draws that many
|
||||||
|
different people into numbered slots (`clerk_1` .. `clerk_4`). Every candidate
|
||||||
|
carries a name, a description and the same attributes the player has, so a
|
||||||
|
replayed pack meets a different office with different strengths. Candidates are
|
||||||
|
authored rather than generated from random parts: a character written as sharp
|
||||||
|
has the wits to see through a bluff, which a random attribute roll pasted onto a
|
||||||
|
random description would not guarantee.
|
||||||
|
|
||||||
|
- **The draw is saved in engine-owned state** (`cast`, now a reserved root, #4)
|
||||||
|
and made on its own stream derived from the seed, so it neither moves the event
|
||||||
|
generator nor depends on it. `hydrate` (#36) draws one for a save that predates
|
||||||
|
it, and draws the same one every time.
|
||||||
|
- **Every slot up to a group's maximum is declared in the schema from the start.**
|
||||||
|
Relationship state for `clerk_3` exists whether or not anyone was drawn into it,
|
||||||
|
so it validates and saves like any other path. An empty slot is recorded as
|
||||||
|
`null` and is simply absent from the run.
|
||||||
|
- **Text names people by role.** `{partner}`, `{chief.first}` and `{clerk.title}`
|
||||||
|
are filled in from whoever was drawn. An event about "a clerk" says
|
||||||
|
`involves: ['clerk']`. When it is drawn, one present clerk is bound to it for
|
||||||
|
the turn, and `{clerk}` in its text, `relationships.{clerk}.trust` in its effects
|
||||||
|
and `against: '{clerk}'` in a check all mean that person. The binding is saved
|
||||||
|
with the run. An event cannot fire in a run with nobody to bind. Text kept in
|
||||||
|
state, such as a notable moment, has its placeholders pinned to the slot
|
||||||
|
(`{clerk_2}`), so the retrospective names the right person later.
|
||||||
|
- **The validator holds all of it:**
|
||||||
|
- attributes declared, and every drawn person carrying all of them;
|
||||||
|
- enough candidates for a group's maximum, and unique names;
|
||||||
|
- checks naming real attributes and people, with both branches;
|
||||||
|
- `{placeholders}` that name real characters, where a group is only named inside an event that binds it;
|
||||||
|
- no placeholder in an event's own `requires`, which is evaluated before anyone is bound.
|
||||||
|
|
||||||
|
**What the test harness had to learn.** The archetypes score an option by its
|
||||||
|
effects (#21). A checked option is now scored at its expected value: plain effects
|
||||||
|
in full, and each branch weighted by its chance, the way a player reading the odds
|
||||||
|
would. The conformance check that no repayment takes more than is owed (#38) now
|
||||||
|
reads both branches, since a check can pay or spend on either.
|
||||||
|
|
||||||
|
**The prototype pack was tuned by measurement, not by eye.** Its first draft
|
||||||
|
failed three conformance tests:
|
||||||
|
- the routine ledgers were a third of every player's turns;
|
||||||
|
- a careless player was broke 40% of a run;
|
||||||
|
- it had no balance a borrower could owe.
|
||||||
|
|
||||||
|
Two more catalogue situations (an errand for the partner's family, and a desk you
|
||||||
|
and a clerk both want), a lighter floor event, a loan that is refused but still
|
||||||
|
helps a little, and an "owed to the floor" balance repaid on Saturdays brought
|
||||||
|
those to 22% at worst, 12% at worst, and a debt that is repaid by reading the
|
||||||
|
balance (#38).
|
||||||
|
|
||||||
|
**Not yet known.** Whether chance makes the same situations worth replaying, and
|
||||||
|
whether the odds in words are legible, can only be judged by someone playing it
|
||||||
|
(#18). The pack has one stage on purpose, so that question can be answered before
|
||||||
|
a career is built on it.
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>The Ladder</title>
|
||||||
|
<meta name="description" content="A turn-based career simulation. Start in the mailroom.">
|
||||||
|
<link rel="stylesheet" href="src/ui/styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app" class="app">
|
||||||
|
<p class="booting">Loading…</p>
|
||||||
|
</div>
|
||||||
|
<noscript>
|
||||||
|
<p class="booting">The Ladder needs JavaScript. It runs entirely in your browser — nothing is sent anywhere.</p>
|
||||||
|
</noscript>
|
||||||
|
<script type="module">
|
||||||
|
import { boot } from './src/ui/app.js';
|
||||||
|
boot(document.getElementById('app'));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Exported playtest logs
|
||||||
|
|
||||||
|
Drop exported feedback logs here — the browser downloads them wherever it
|
||||||
|
normally does, and this is where they go to be read.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run analyze # the newest log in this folder
|
||||||
|
npm run analyze -- <file> # a specific one
|
||||||
|
```
|
||||||
|
|
||||||
|
The logs themselves are gitignored. They record what a real person did, turn by
|
||||||
|
turn, including anything they typed into the feedback box, so they are not
|
||||||
|
committed: keep them local, or share them deliberately.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "theladder",
|
||||||
|
"version": "0.4.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "A turn-based career simulation game. Engine is theme-agnostic; settings are content packs.",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"test": "node --test \"test/**/*.test.js\"",
|
||||||
|
"serve": "python3 -m http.server 8080",
|
||||||
|
"build": "node tools/build.js",
|
||||||
|
"analyze": "node tools/analyze-log.js"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// The build identity shown in the status bar.
|
||||||
|
//
|
||||||
|
// This file is never rewritten. Running from source is honestly "dev";
|
||||||
|
// tools/build.js substitutes the real commit into the bundled output only, so
|
||||||
|
// dist/theladder.html names the commit that produced it and the working tree
|
||||||
|
// never churns.
|
||||||
|
export const BUILD = {
|
||||||
|
commit: 'dev',
|
||||||
|
builtAt: null,
|
||||||
|
};
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
// Who is in this run.
|
||||||
|
//
|
||||||
|
// A pack's characters are fixed people by default: a name and a description,
|
||||||
|
// written once. A character may instead be a *role* with `candidates` — several
|
||||||
|
// authored people who could fill it — and one is drawn when a run starts. A role
|
||||||
|
// may also be a *group* with a `count` range, like two to four co-workers, in
|
||||||
|
// which case that many different candidates are drawn into numbered slots.
|
||||||
|
// Replaying a pack therefore meets a different cast, which is the point.
|
||||||
|
//
|
||||||
|
// Slots are what state is keyed by: a single role is its own id (`boss`), a
|
||||||
|
// group's members are `coworker_1` .. `coworker_<max>`. Every slot up to the
|
||||||
|
// group's maximum exists in the schema from the start, so relationship state
|
||||||
|
// is declared, validated and saved like any other; a slot nobody was drawn into
|
||||||
|
// is simply not present in the run. See DECISIONS #41.
|
||||||
|
//
|
||||||
|
// The draw uses its own stream derived from the run's seed, so it neither moves
|
||||||
|
// the event generator nor depends on it: a pack that gains a cast does not
|
||||||
|
// change which events any other pack draws, and a save that predates the cast
|
||||||
|
// can have one drawn for it on load, reproducibly.
|
||||||
|
|
||||||
|
import { createRng } from './rng.js';
|
||||||
|
import { getPath } from './paths.js';
|
||||||
|
|
||||||
|
/** The count range of a group role, or null for a single character. */
|
||||||
|
export function groupRange(character) {
|
||||||
|
if (character.count === undefined) return null;
|
||||||
|
return Array.isArray(character.count) ? character.count : [character.count, character.count];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every state slot a character occupies: its own id, or one per possible group member. */
|
||||||
|
export function slotsOf(character) {
|
||||||
|
const range = groupRange(character);
|
||||||
|
if (!range) return [character.id];
|
||||||
|
return Array.from({ length: range[1] }, (_, i) => `${character.id}_${i + 1}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a pack draws any part of its cast per run. */
|
||||||
|
export function hasDrawnCast(pack) {
|
||||||
|
return (pack.characters ?? []).some((c) => c.candidates?.length || groupRange(c));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The authored person behind a candidate entry, in the shape the state keeps. */
|
||||||
|
function personOf(character, candidate, index) {
|
||||||
|
return {
|
||||||
|
role: character.id,
|
||||||
|
candidate: index,
|
||||||
|
name: candidate.name,
|
||||||
|
title: candidate.title ?? character.title ?? null,
|
||||||
|
description: candidate.description ?? '',
|
||||||
|
attributes: { ...(candidate.attributes ?? {}) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw the cast for a run. Returns `{ <slot>: person | null }` for every slot of
|
||||||
|
* every drawn role; a group slot left empty is `null`, which is how "not in this
|
||||||
|
* run" is recorded. Fixed characters are not in it: they need no drawing.
|
||||||
|
*/
|
||||||
|
export function drawCast(pack, seed) {
|
||||||
|
const rng = createRng(`cast:${seed}`);
|
||||||
|
const cast = {};
|
||||||
|
for (const character of pack.characters ?? []) {
|
||||||
|
const pool = character.candidates ?? [];
|
||||||
|
const range = groupRange(character);
|
||||||
|
if (!range && !pool.length) continue;
|
||||||
|
|
||||||
|
const slots = slotsOf(character);
|
||||||
|
const wanted = range ? range[0] + rng.int(range[1] - range[0] + 1) : 1;
|
||||||
|
const remaining = pool.map((candidate, index) => ({ candidate, index }));
|
||||||
|
for (let i = 0; i < slots.length; i++) {
|
||||||
|
if (i >= wanted || !remaining.length) {
|
||||||
|
cast[slots[i]] = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const [picked] = remaining.splice(rng.int(remaining.length), 1);
|
||||||
|
cast[slots[i]] = personOf(character, picked.candidate, picked.index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cast;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Starting relationship overrides a drawn person brings with them. */
|
||||||
|
export function drawnRelationships(pack, cast) {
|
||||||
|
const overrides = [];
|
||||||
|
for (const character of pack.characters ?? []) {
|
||||||
|
for (const slot of slotsOf(character)) {
|
||||||
|
const person = cast?.[slot];
|
||||||
|
if (!person) continue;
|
||||||
|
const candidate = character.candidates?.[person.candidate];
|
||||||
|
for (const [stat, value] of Object.entries(candidate?.startingRelationship ?? {})) {
|
||||||
|
overrides.push({ path: `relationships.${slot}.${stat}`, value });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return overrides;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The character definition that owns a slot. */
|
||||||
|
export function characterForSlot(pack, slot) {
|
||||||
|
for (const character of pack.characters ?? []) {
|
||||||
|
if (slotsOf(character).includes(slot)) return character;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The person in a slot as the interface and the checks see them: name, title,
|
||||||
|
* description and attributes, whether drawn for this run or written into the
|
||||||
|
* pack. Undefined if the slot is empty this run or unknown.
|
||||||
|
*/
|
||||||
|
export function personIn(pack, state, slot) {
|
||||||
|
const character = characterForSlot(pack, slot);
|
||||||
|
if (!character) return undefined;
|
||||||
|
const drawn = getPath(state, `cast.${slot}`);
|
||||||
|
if (drawn === null) return undefined;
|
||||||
|
if (drawn) return { id: slot, ...drawn };
|
||||||
|
if (character.candidates?.length || groupRange(character)) return undefined;
|
||||||
|
return {
|
||||||
|
id: slot,
|
||||||
|
role: character.id,
|
||||||
|
name: character.name,
|
||||||
|
title: character.title ?? null,
|
||||||
|
description: character.description ?? '',
|
||||||
|
attributes: { ...(character.attributes ?? {}) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everyone present in this run, in pack order. */
|
||||||
|
export function presentPeople(pack, state) {
|
||||||
|
return (pack.characters ?? []).flatMap((character) =>
|
||||||
|
slotsOf(character).map((slot) => personIn(pack, state, slot)).filter(Boolean));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The slots of a role that are filled in this run. */
|
||||||
|
export function presentSlots(pack, state, roleId) {
|
||||||
|
const character = (pack.characters ?? []).find((c) => c.id === roleId);
|
||||||
|
if (!character) return [];
|
||||||
|
return slotsOf(character).filter((slot) => personIn(pack, state, slot));
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// Options whose outcome is not certain.
|
||||||
|
//
|
||||||
|
// An option may carry a `check`: which of the player's attributes it tests,
|
||||||
|
// optionally *against* a character who opposes it with their own attribute, and
|
||||||
|
// optionally leaning on how that character regards the player. It then has a
|
||||||
|
// `success` and a `failure`, each with its own result text and effects; the
|
||||||
|
// option's plain `effects` apply either way. The engine works out the chance,
|
||||||
|
// rolls on the run's own generator, and records which way it went.
|
||||||
|
//
|
||||||
|
// The engine knows the arithmetic and nothing about what any attribute means:
|
||||||
|
// the numbers are the pack's (`pack.checks`), the attribute names are the
|
||||||
|
// pack's, and so are the words the player sees in place of a percentage. A
|
||||||
|
// player is shown "a long shot" or "likely", never a number. See DECISIONS #41.
|
||||||
|
//
|
||||||
|
// chance = base + perPoint × (yours − theirs)
|
||||||
|
// + relationshipPerPoint × (their regard − relationshipCentre)
|
||||||
|
// + modifier, clamped to [floor, ceiling]
|
||||||
|
//
|
||||||
|
// where "theirs" is the opposing character's attribute when the check is
|
||||||
|
// `against` someone, and the check's `difficulty` (or the pack's `unopposed`)
|
||||||
|
// when it is not.
|
||||||
|
|
||||||
|
import { getPath } from './paths.js';
|
||||||
|
import { personIn } from './cast.js';
|
||||||
|
import { resolveSlot } from './text.js';
|
||||||
|
|
||||||
|
/** Odds words used when a pack does not name its own. Upper bounds, in percent. */
|
||||||
|
export const DEFAULT_ODDS = [
|
||||||
|
{ upTo: 20, words: 'a long shot' },
|
||||||
|
{ upTo: 40, words: 'unlikely' },
|
||||||
|
{ upTo: 60, words: 'an even chance' },
|
||||||
|
{ upTo: 80, words: 'likely' },
|
||||||
|
{ upTo: 100, words: 'a safe bet' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** The words for a chance, from the pack's bands. */
|
||||||
|
export function oddsWords(pack, chance) {
|
||||||
|
const bands = pack.checks?.odds ?? DEFAULT_ODDS;
|
||||||
|
return (bands.find((band) => chance <= band.upTo) ?? bands[bands.length - 1]).words;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The slot a check is made against this turn, if any. */
|
||||||
|
function opponentSlot(check, bindings) {
|
||||||
|
if (!check.against) return undefined;
|
||||||
|
const id = check.against.replace(/^\{|\}$/g, '');
|
||||||
|
return resolveSlot(id, bindings);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The chance of an option's check succeeding in this state, as a whole
|
||||||
|
* percentage, with the parts it was built from. Undefined for an option with
|
||||||
|
* no check.
|
||||||
|
*/
|
||||||
|
export function chanceOf(pack, state, option, bindings = state?.meta?.bindings ?? {}) {
|
||||||
|
const check = option.check;
|
||||||
|
if (!check) return undefined;
|
||||||
|
const rules = pack.checks ?? {};
|
||||||
|
const yours = getPath(state, `attributes.${check.attribute}`) ?? 0;
|
||||||
|
|
||||||
|
const slot = opponentSlot(check, bindings);
|
||||||
|
const opponent = slot ? personIn(pack, state, slot) : undefined;
|
||||||
|
const theirs = opponent
|
||||||
|
? opponent.attributes?.[check.opposedBy ?? check.attribute] ?? 0
|
||||||
|
: check.difficulty ?? rules.unopposed ?? 0;
|
||||||
|
|
||||||
|
let regard = 0;
|
||||||
|
if (check.relationship && slot) {
|
||||||
|
const value = getPath(state, `relationships.${slot}.${check.relationship}`);
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
regard = (rules.relationshipPerPoint ?? 0) * (value - (rules.relationshipCentre ?? 50));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = (rules.base ?? 50) + (rules.perPoint ?? 0) * (yours - theirs) + regard + (check.modifier ?? 0);
|
||||||
|
const chance = Math.round(Math.min(rules.ceiling ?? 95, Math.max(rules.floor ?? 5, raw)));
|
||||||
|
return { chance, words: oddsWords(pack, chance), yours, theirs, against: slot ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Roll a check. `rng` is the run's generator; exactly one number is drawn, so
|
||||||
|
* a replay from the same seed and choices rolls the same way.
|
||||||
|
*/
|
||||||
|
export function rollCheck(pack, state, option, rng, bindings) {
|
||||||
|
const odds = chanceOf(pack, state, option, bindings);
|
||||||
|
const roll = rng.next() * 100;
|
||||||
|
return { ...odds, succeeded: roll < odds.chance };
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// Generic predicates over state paths.
|
||||||
|
//
|
||||||
|
// A condition is either a comparison — { path, op, value } — or a combinator:
|
||||||
|
// { all: [...] }, { any: [...] }, { not: {...} }. Content uses these for
|
||||||
|
// option gates, event triggers and anything else that needs to ask a question
|
||||||
|
// about state. The engine never learns what the paths mean.
|
||||||
|
|
||||||
|
import { getPath } from './paths.js';
|
||||||
|
|
||||||
|
const COMPARATORS = {
|
||||||
|
'==': (a, b) => a === b,
|
||||||
|
'!=': (a, b) => a !== b,
|
||||||
|
'>': (a, b) => typeof a === 'number' && typeof b === 'number' && a > b,
|
||||||
|
'>=': (a, b) => typeof a === 'number' && typeof b === 'number' && a >= b,
|
||||||
|
'<': (a, b) => typeof a === 'number' && typeof b === 'number' && a < b,
|
||||||
|
'<=': (a, b) => typeof a === 'number' && typeof b === 'number' && a <= b,
|
||||||
|
'in': (a, b) => Array.isArray(b) && b.includes(a),
|
||||||
|
'not-in': (a, b) => Array.isArray(b) && !b.includes(a),
|
||||||
|
'has': (a, b) => Array.isArray(a) && a.includes(b),
|
||||||
|
'not-has': (a, b) => Array.isArray(a) && !a.includes(b),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OPERATORS = Object.keys(COMPARATORS);
|
||||||
|
|
||||||
|
/** True if `condition` holds against `state`. */
|
||||||
|
export function evaluate(state, condition) {
|
||||||
|
if (condition === null || typeof condition !== 'object') {
|
||||||
|
throw new TypeError(`condition must be an object, got ${JSON.stringify(condition)}`);
|
||||||
|
}
|
||||||
|
if (Array.isArray(condition)) return condition.every((c) => evaluate(state, c));
|
||||||
|
if ('all' in condition) return condition.all.every((c) => evaluate(state, c));
|
||||||
|
if ('any' in condition) return condition.any.some((c) => evaluate(state, c));
|
||||||
|
if ('not' in condition) return !evaluate(state, condition.not);
|
||||||
|
|
||||||
|
const { path, op, value } = condition;
|
||||||
|
const compare = COMPARATORS[op];
|
||||||
|
if (!compare) throw new TypeError(`unknown condition operator ${JSON.stringify(op)}`);
|
||||||
|
return compare(getPath(state, path), value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate a list of conditions, returning the ones that failed. An empty or
|
||||||
|
* absent list passes. The failures are what the UI greys out an option with
|
||||||
|
* and what the feedback log records as "the player could not pick this".
|
||||||
|
*/
|
||||||
|
export function check(state, conditions) {
|
||||||
|
if (!conditions) return { passed: true, failed: [] };
|
||||||
|
const list = Array.isArray(conditions) ? conditions : [conditions];
|
||||||
|
const failed = list.filter((condition) => !evaluate(state, condition));
|
||||||
|
return { passed: failed.length === 0, failed };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every path a condition tree reads. Used by the validator. */
|
||||||
|
export function pathsUsed(condition, out = []) {
|
||||||
|
if (condition === null || typeof condition !== 'object') return out;
|
||||||
|
if (Array.isArray(condition)) {
|
||||||
|
for (const c of condition) pathsUsed(c, out);
|
||||||
|
} else if ('all' in condition) {
|
||||||
|
for (const c of condition.all) pathsUsed(c, out);
|
||||||
|
} else if ('any' in condition) {
|
||||||
|
for (const c of condition.any) pathsUsed(c, out);
|
||||||
|
} else if ('not' in condition) {
|
||||||
|
pathsUsed(condition.not, out);
|
||||||
|
} else if (typeof condition.path === 'string') {
|
||||||
|
out.push(condition.path);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
Vendored
+89
@@ -0,0 +1,89 @@
|
|||||||
|
// Generic mutations over state paths.
|
||||||
|
//
|
||||||
|
// An effect is { path, op, value }. Applying a list returns a new state plus
|
||||||
|
// a change record — the before/after of every path touched. That record is
|
||||||
|
// what the feedback log stores, so "what did this choice actually do" is
|
||||||
|
// answerable after the fact without replaying the run.
|
||||||
|
|
||||||
|
import { getPath, setPath } from './paths.js';
|
||||||
|
|
||||||
|
const OPS = {
|
||||||
|
add: (current, value) => (typeof current === 'number' ? current : 0) + value,
|
||||||
|
set: (_current, value) => value,
|
||||||
|
push: (current, value) => [...(Array.isArray(current) ? current : []), value],
|
||||||
|
remove: (current, value) => (Array.isArray(current) ? current.filter((v) => v !== value) : []),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OPERATIONS = Object.keys(OPS);
|
||||||
|
|
||||||
|
/** Constrain a value to the bounds its schema entry declares, if any. */
|
||||||
|
function clamp(value, rule) {
|
||||||
|
if (!rule || typeof value !== 'number') return value;
|
||||||
|
let out = value;
|
||||||
|
if (typeof rule.min === 'number') out = Math.max(rule.min, out);
|
||||||
|
if (typeof rule.max === 'number') out = Math.min(rule.max, out);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether an effect's value is a reference to state rather than a literal. */
|
||||||
|
export function isAmountFrom(value) {
|
||||||
|
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
&& typeof value.from === 'string';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The amount an `add` effect applies. Usually the number the pack wrote. It may
|
||||||
|
* instead be read from state at the moment the effect applies:
|
||||||
|
*
|
||||||
|
* { path: 'money', op: 'add', value: { from: 'owed', times: -1, cap: 20 } }
|
||||||
|
*
|
||||||
|
* is "take away whatever is owed, but no more than 20". A fixed instalment
|
||||||
|
* cannot pay off a balance that could be anything, and charging the whole
|
||||||
|
* instalment against a smaller balance took a playtester's money for a debt
|
||||||
|
* that did not exist (DECISIONS #38). It is still data, and still generic: the
|
||||||
|
* engine reads a number from a path without knowing what the path means.
|
||||||
|
*/
|
||||||
|
export function resolveAmount(state, value) {
|
||||||
|
if (!isAmountFrom(value)) return value;
|
||||||
|
const current = getPath(state, value.from);
|
||||||
|
let amount = typeof current === 'number' ? current : 0;
|
||||||
|
if (typeof value.cap === 'number') amount = Math.min(amount, value.cap);
|
||||||
|
return amount * (typeof value.times === 'number' ? value.times : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply one effect. `schema` is the resolved state schema, consulted only for
|
||||||
|
* clamping — the engine still has no idea what any given path means.
|
||||||
|
*/
|
||||||
|
export function applyEffect(state, effect, schema = {}) {
|
||||||
|
const { path, op } = effect;
|
||||||
|
const operate = OPS[op];
|
||||||
|
if (!operate) throw new TypeError(`unknown effect operation ${JSON.stringify(op)}`);
|
||||||
|
|
||||||
|
const value = op === 'add' ? resolveAmount(state, effect.value) : effect.value;
|
||||||
|
const from = getPath(state, path);
|
||||||
|
const raw = operate(from, value);
|
||||||
|
const to = clamp(raw, schema[path]);
|
||||||
|
|
||||||
|
const change = { path, op, value, from, to, clamped: raw !== to };
|
||||||
|
// The log keeps the number actually applied, and says where it came from.
|
||||||
|
if (value !== effect.value) change.source = effect.value;
|
||||||
|
return { state: setPath(state, path, to), change };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply a list of effects in order. Returns the new state and every change. */
|
||||||
|
export function applyEffects(state, effects, schema = {}) {
|
||||||
|
let next = state;
|
||||||
|
const changes = [];
|
||||||
|
for (const effect of effects ?? []) {
|
||||||
|
const result = applyEffect(next, effect, schema);
|
||||||
|
next = result.state;
|
||||||
|
changes.push(result.change);
|
||||||
|
}
|
||||||
|
return { state: next, changes };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every path a list of effects writes. Used by the validator. */
|
||||||
|
export function pathsUsed(effects) {
|
||||||
|
return (effects ?? []).map((effect) => effect.path).filter((p) => typeof p === 'string');
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
// The turn loop.
|
||||||
|
//
|
||||||
|
// Purely functional: a turn is (pack, state, optionId) -> new state. The RNG
|
||||||
|
// is rebuilt from the seed and position carried in state, so a saved game
|
||||||
|
// resumes into exactly the run it left, and a test can replay a whole career
|
||||||
|
// with no hidden state anywhere.
|
||||||
|
|
||||||
|
import { createRng } from './rng.js';
|
||||||
|
import { applyEffects } from './effects.js';
|
||||||
|
import { check } from './conditions.js';
|
||||||
|
import { resolveSchema, createState } from './state.js';
|
||||||
|
import { selectEvent, resolveOptions, findOption, bindRoles } from './select.js';
|
||||||
|
import { setPath, getPath } from './paths.js';
|
||||||
|
import { chanceOf, rollCheck } from './checks.js';
|
||||||
|
import { bindEffects, fillText } from './text.js';
|
||||||
|
|
||||||
|
export class ContentError extends Error {}
|
||||||
|
export class MoveError extends Error {}
|
||||||
|
|
||||||
|
/** Draw the next event and record where the generator ended up. */
|
||||||
|
function advance(pack, state) {
|
||||||
|
// The calendar is settled before the draw, so an event can require a Friday.
|
||||||
|
const dated = applyCalendar(pack, state);
|
||||||
|
const rng = createRng(dated.meta.seed, dated.meta.rngPosition ?? undefined);
|
||||||
|
const event = selectEvent(pack, dated, rng);
|
||||||
|
if (!event) {
|
||||||
|
throw new ContentError(
|
||||||
|
`no event is eligible at stage "${dated.stage}" on turn ${dated.turn}; ` +
|
||||||
|
'every stage needs at least one event with no conditions',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let next = setPath(dated, 'meta.currentEventId', event.id);
|
||||||
|
// Which co-worker this event is about, drawn after the event itself. A pack
|
||||||
|
// with no `involves` draws nothing here, so its runs are unchanged.
|
||||||
|
const bindings = bindRoles(pack, next, event, rng);
|
||||||
|
if (Object.keys(bindings).length || next.meta.bindings) next = setPath(next, 'meta.bindings', bindings);
|
||||||
|
next = setPath(next, 'meta.rngPosition', rng.getPosition());
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the calendar from the turn number.
|
||||||
|
*
|
||||||
|
* A pack that declares `calendar` gets `calendar.index` (position in the
|
||||||
|
* cycle), `calendar.cycle` (which cycle, 1-based) and `calendar.name` written
|
||||||
|
* into engine-owned state before the turn's event is chosen — so content can
|
||||||
|
* gate on the day of the week, and upkeep can charge rent on one of them. A
|
||||||
|
* pack that declares no calendar simply has none; the engine imposes no week.
|
||||||
|
*/
|
||||||
|
function applyCalendar(pack, state) {
|
||||||
|
const calendar = pack.calendar;
|
||||||
|
if (!calendar?.cycleLength) return state;
|
||||||
|
|
||||||
|
const elapsed = state.turn - 1;
|
||||||
|
const index = ((elapsed % calendar.cycleLength) + calendar.cycleLength) % calendar.cycleLength;
|
||||||
|
let next = setPath(state, 'calendar.index', index);
|
||||||
|
next = setPath(next, 'calendar.cycle', Math.floor(elapsed / calendar.cycleLength) + 1);
|
||||||
|
return setPath(next, 'calendar.name', calendar.unitNames?.[index] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a scheduled upkeep entry falls due this turn. `every` is a period in
|
||||||
|
* turns and `offset` the first turn it lands on (zero-based), so wages every
|
||||||
|
* fortnight starting on the seventh day are `{ every: 14, offset: 6 }`. An
|
||||||
|
* entry with no `every` runs every turn.
|
||||||
|
*/
|
||||||
|
function isDue(entry, state) {
|
||||||
|
if (!entry.every) return true;
|
||||||
|
const elapsed = state.turn - 1 - (entry.offset ?? 0);
|
||||||
|
return elapsed >= 0 && elapsed % entry.every === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the pack's per-turn upkeep — wages in, rent out, whatever a loan
|
||||||
|
* costs to carry. Each entry may carry its own conditions, evaluated after the
|
||||||
|
* player's choice has landed, so upkeep is content's business and not a
|
||||||
|
* special case in the loop.
|
||||||
|
*/
|
||||||
|
function applyUpkeep(pack, state, schema) {
|
||||||
|
let next = state;
|
||||||
|
const changes = [];
|
||||||
|
for (const entry of pack.upkeep ?? []) {
|
||||||
|
if (!isDue(entry, next)) continue;
|
||||||
|
if (!check(next, entry.requires).passed) continue;
|
||||||
|
const applied = applyEffects(next, entry.effects, schema);
|
||||||
|
next = applied.state;
|
||||||
|
changes.push(...applied.changes.map((change) => ({ ...change, label: entry.label ?? null })));
|
||||||
|
}
|
||||||
|
return { state: next, changes };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Begin a run. `seed` is any string or number; it is stored in the save. */
|
||||||
|
export function startGame(pack, { seed = Date.now(), schema = resolveSchema(pack), now = null } = {}) {
|
||||||
|
let state = createState(pack, { seed, schema });
|
||||||
|
state = setPath(state, 'meta.startedAt', now);
|
||||||
|
return advance(pack, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The event the player is looking at. */
|
||||||
|
export function currentEvent(pack, state) {
|
||||||
|
const id = getPath(state, 'meta.currentEventId');
|
||||||
|
const event = (pack.events ?? []).find((e) => e.id === id);
|
||||||
|
if (!event) throw new ContentError(`current event "${id}" is not in pack "${pack.id}"`);
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The current event together with each option's availability. */
|
||||||
|
export function currentTurn(pack, state) {
|
||||||
|
const event = currentEvent(pack, state);
|
||||||
|
const options = resolveOptions(state, event).map((resolved) => (resolved.option.check
|
||||||
|
? { ...resolved, odds: chanceOf(pack, state, resolved.option) }
|
||||||
|
: resolved));
|
||||||
|
return { event, options, bindings: state.meta.bindings ?? {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play one option. Returns the new state and a record of what happened —
|
||||||
|
* the record is what the feedback log stores, and it deliberately includes the
|
||||||
|
* options that were locked, not just the one that was taken.
|
||||||
|
*/
|
||||||
|
export function takeTurn(pack, state, optionId, { schema = resolveSchema(pack), now = null } = {}) {
|
||||||
|
const event = currentEvent(pack, state);
|
||||||
|
const option = findOption(event, optionId);
|
||||||
|
if (!option) throw new MoveError(`event "${event.id}" has no option "${optionId}"`);
|
||||||
|
|
||||||
|
const resolved = resolveOptions(state, event);
|
||||||
|
const chosen = resolved.find((r) => r.option.id === optionId);
|
||||||
|
if (!chosen.available) {
|
||||||
|
throw new MoveError(`option "${optionId}" is locked: ${JSON.stringify(chosen.failed)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bindings = state.meta.bindings ?? {};
|
||||||
|
let effects = bindEffects(option.effects, bindings);
|
||||||
|
let check = null;
|
||||||
|
let rngState = state;
|
||||||
|
if (option.check) {
|
||||||
|
// One number from the run's own generator, so the roll replays with the run.
|
||||||
|
const rng = createRng(state.meta.seed, state.meta.rngPosition ?? undefined);
|
||||||
|
const rolled = rollCheck(pack, state, option, rng, bindings);
|
||||||
|
const outcome = rolled.succeeded ? option.success : option.failure;
|
||||||
|
effects = [...effects, ...bindEffects(outcome?.effects, bindings)];
|
||||||
|
check = {
|
||||||
|
attribute: option.check.attribute,
|
||||||
|
against: rolled.against,
|
||||||
|
chance: rolled.chance,
|
||||||
|
odds: rolled.words,
|
||||||
|
outcome: rolled.succeeded ? 'success' : 'failure',
|
||||||
|
};
|
||||||
|
rngState = setPath(state, 'meta.rngPosition', rng.getPosition());
|
||||||
|
}
|
||||||
|
|
||||||
|
const applied = applyEffects(rngState, effects, schema);
|
||||||
|
let next = applied.state;
|
||||||
|
|
||||||
|
const seenCount = getPath(next, `seen.${event.id}`) ?? 0;
|
||||||
|
next = setPath(next, `seen.${event.id}`, seenCount + 1);
|
||||||
|
next = setPath(next, `lastSeen.${event.id}`, next.turn);
|
||||||
|
|
||||||
|
const upkeep = applyUpkeep(pack, next, schema);
|
||||||
|
next = upkeep.state;
|
||||||
|
|
||||||
|
const record = {
|
||||||
|
turn: next.turn,
|
||||||
|
stage: next.stage,
|
||||||
|
eventId: event.id,
|
||||||
|
optionId: option.id,
|
||||||
|
// Names filled in, so the log and the retrospective read as the player saw it.
|
||||||
|
optionLabel: fillText(pack, state, option.label, bindings),
|
||||||
|
...(check ? { check } : {}),
|
||||||
|
...(Object.keys(bindings).length ? { bindings } : {}),
|
||||||
|
changes: applied.changes,
|
||||||
|
upkeep: upkeep.changes,
|
||||||
|
offered: resolved.map((r) => ({
|
||||||
|
id: r.option.id,
|
||||||
|
label: r.option.label,
|
||||||
|
available: r.available,
|
||||||
|
failed: r.failed,
|
||||||
|
// A hidden option was never on the player's screen. Without this the log
|
||||||
|
// cannot tell it from a locked door they saw, and the analyser counted
|
||||||
|
// one the first real Frontier player never met. See DECISIONS #39.
|
||||||
|
hidden: r.hidden,
|
||||||
|
})),
|
||||||
|
at: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
next = setPath(next, 'history', [...next.history, record]);
|
||||||
|
next = setPath(next, 'turn', next.turn + 1);
|
||||||
|
next = advance(pack, next);
|
||||||
|
|
||||||
|
return { state: next, record };
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// The engine's public surface. Nothing in src/engine may import from content/.
|
||||||
|
export { getPath, setPath, hasPath, parsePath, listPaths } from './paths.js';
|
||||||
|
export { createRng } from './rng.js';
|
||||||
|
export { evaluate, check, OPERATORS } from './conditions.js';
|
||||||
|
export { applyEffect, applyEffects, OPERATIONS } from './effects.js';
|
||||||
|
export { createState, resolveSchema, indexCharacters, RESERVED_ROOTS } from './state.js';
|
||||||
|
export { eligibleEvents, selectEvent, resolveOptions, findOption } from './select.js';
|
||||||
|
export { startGame, currentEvent, currentTurn, takeTurn, ContentError, MoveError } from './game.js';
|
||||||
|
export { validatePack, assertValidPack } from './validate.js';
|
||||||
|
export { drawCast, presentPeople, presentSlots, personIn, slotsOf, hasDrawnCast } from './cast.js';
|
||||||
|
export { fillText, placeholders, bindEffects, bindConditions } from './text.js';
|
||||||
|
export { chanceOf, rollCheck, oddsWords, DEFAULT_ODDS } from './checks.js';
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// Path addressing for game state.
|
||||||
|
//
|
||||||
|
// Every piece of game state is reachable by a dotted path string: `money`,
|
||||||
|
// `skills.negotiation`, `relationships.boss.likes_you`, `flags.took_loan`.
|
||||||
|
// Conditions, effects, gates and event triggers are all expressed against
|
||||||
|
// these paths, which is what lets content reach any part of state without the
|
||||||
|
// engine knowing what that part means.
|
||||||
|
|
||||||
|
const SEGMENT = /^[A-Za-z0-9_]+$/;
|
||||||
|
const FORBIDDEN = new Set(['__proto__', 'prototype', 'constructor']);
|
||||||
|
|
||||||
|
/** Split a path into segments, throwing on anything malformed or unsafe. */
|
||||||
|
export function parsePath(path) {
|
||||||
|
if (typeof path !== 'string' || path.length === 0) {
|
||||||
|
throw new TypeError(`path must be a non-empty string, got ${JSON.stringify(path)}`);
|
||||||
|
}
|
||||||
|
const segments = path.split('.');
|
||||||
|
for (const segment of segments) {
|
||||||
|
if (!SEGMENT.test(segment)) {
|
||||||
|
throw new TypeError(`invalid path segment ${JSON.stringify(segment)} in "${path}"`);
|
||||||
|
}
|
||||||
|
// Content is data, and data can be authored by anyone; never let a path
|
||||||
|
// walk into the prototype chain.
|
||||||
|
if (FORBIDDEN.has(segment)) {
|
||||||
|
throw new TypeError(`forbidden path segment ${JSON.stringify(segment)} in "${path}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read a path. Returns undefined if any link in the chain is missing. */
|
||||||
|
export function getPath(state, path) {
|
||||||
|
let cursor = state;
|
||||||
|
for (const segment of parsePath(path)) {
|
||||||
|
if (cursor === null || typeof cursor !== 'object') return undefined;
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(cursor, segment)) return undefined;
|
||||||
|
cursor = cursor[segment];
|
||||||
|
}
|
||||||
|
return cursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if the path exists (distinct from resolving to undefined). */
|
||||||
|
export function hasPath(state, path) {
|
||||||
|
let cursor = state;
|
||||||
|
for (const segment of parsePath(path)) {
|
||||||
|
if (cursor === null || typeof cursor !== 'object') return false;
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(cursor, segment)) return false;
|
||||||
|
cursor = cursor[segment];
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write a path, returning a new state. Ancestors along the path are copied;
|
||||||
|
* everything else is shared, so this is cheap to call in a loop.
|
||||||
|
*/
|
||||||
|
export function setPath(state, path, value) {
|
||||||
|
const segments = parsePath(path);
|
||||||
|
const root = { ...state };
|
||||||
|
let cursor = root;
|
||||||
|
for (let i = 0; i < segments.length - 1; i++) {
|
||||||
|
const segment = segments[i];
|
||||||
|
const child = cursor[segment];
|
||||||
|
cursor[segment] = (child !== null && typeof child === 'object') ? { ...child } : {};
|
||||||
|
cursor = cursor[segment];
|
||||||
|
}
|
||||||
|
cursor[segments[segments.length - 1]] = value;
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every leaf path in an object, in depth-first order. Used by the validator. */
|
||||||
|
export function listPaths(object, prefix = '') {
|
||||||
|
const out = [];
|
||||||
|
for (const [key, value] of Object.entries(object)) {
|
||||||
|
const path = prefix ? `${prefix}.${key}` : key;
|
||||||
|
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
||||||
|
out.push(...listPaths(value, path));
|
||||||
|
} else {
|
||||||
|
out.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// Seeded, serializable random number generator.
|
||||||
|
//
|
||||||
|
// Every draw the game makes comes from here, so a run is fully reproducible
|
||||||
|
// from its seed plus the player's choice sequence. That makes tests
|
||||||
|
// deterministic and playtest reports replayable: the seed and the generator's
|
||||||
|
// position both live in the save file and the feedback log.
|
||||||
|
|
||||||
|
/** Hash an arbitrary string seed down to a uint32. */
|
||||||
|
function hashSeed(seed) {
|
||||||
|
let h = 1779033703 ^ String(seed).length;
|
||||||
|
for (let i = 0; i < String(seed).length; i++) {
|
||||||
|
h = Math.imul(h ^ String(seed).charCodeAt(i), 3432918353);
|
||||||
|
h = (h << 13) | (h >>> 19);
|
||||||
|
}
|
||||||
|
h = Math.imul(h ^ (h >>> 16), 2246822507);
|
||||||
|
h = Math.imul(h ^ (h >>> 13), 3266489909);
|
||||||
|
return (h ^ (h >>> 16)) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* mulberry32: one uint32 of state, which is the whole point — the generator's
|
||||||
|
* position round-trips through JSON with no special handling.
|
||||||
|
*/
|
||||||
|
export function createRng(seed, position) {
|
||||||
|
let state = position === undefined ? hashSeed(seed) : position >>> 0;
|
||||||
|
|
||||||
|
const next = () => {
|
||||||
|
state = (state + 0x6d2b79f5) >>> 0;
|
||||||
|
let t = state;
|
||||||
|
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||||
|
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
seed,
|
||||||
|
/** Float in [0, 1). */
|
||||||
|
next,
|
||||||
|
/** Integer in [0, n). */
|
||||||
|
int: (n) => Math.floor(next() * n),
|
||||||
|
/** Uniform pick from a non-empty array. */
|
||||||
|
pick(items) {
|
||||||
|
if (!items.length) throw new RangeError('cannot pick from an empty array');
|
||||||
|
return items[Math.floor(next() * items.length)];
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Weighted pick. `weightOf` must return a non-negative number; entries
|
||||||
|
* weighing zero are never drawn. Returns undefined if nothing has weight.
|
||||||
|
*/
|
||||||
|
weighted(items, weightOf) {
|
||||||
|
let total = 0;
|
||||||
|
for (const item of items) total += Math.max(0, weightOf(item));
|
||||||
|
if (total <= 0) return undefined;
|
||||||
|
let roll = next() * total;
|
||||||
|
for (const item of items) {
|
||||||
|
roll -= Math.max(0, weightOf(item));
|
||||||
|
if (roll < 0) return item;
|
||||||
|
}
|
||||||
|
return items[items.length - 1];
|
||||||
|
},
|
||||||
|
/** Current position, for saving mid-run. */
|
||||||
|
getPosition: () => state,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// Choosing what happens this turn.
|
||||||
|
//
|
||||||
|
// There is deliberately no such thing as "a scripted turn" versus "a random
|
||||||
|
// event" in here. Every turn, the whole event pool is filtered by stage and
|
||||||
|
// conditions, then drawn from by weight. A routine turn is an event with a
|
||||||
|
// high weight and loose conditions; a rare interruption is one with a low
|
||||||
|
// weight and tight conditions; the "hard times" branch is one whose
|
||||||
|
// conditions include a money threshold. The engine branches on none of it.
|
||||||
|
|
||||||
|
import { check } from './conditions.js';
|
||||||
|
import { getPath } from './paths.js';
|
||||||
|
import { presentSlots } from './cast.js';
|
||||||
|
import { bindConditions } from './text.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick, for each role an event `involves`, one person present in this run. An
|
||||||
|
* event about "a co-worker" is about one particular co-worker for the turn.
|
||||||
|
*/
|
||||||
|
export function bindRoles(pack, state, event, rng) {
|
||||||
|
const bindings = {};
|
||||||
|
for (const role of event.involves ?? []) {
|
||||||
|
const slots = presentSlots(pack, state, role);
|
||||||
|
if (slots.length) bindings[role] = slots.length === 1 ? slots[0] : rng.pick(slots);
|
||||||
|
}
|
||||||
|
return bindings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Events that could fire right now, with the reason excluded ones could not. */
|
||||||
|
export function eligibleEvents(pack, state) {
|
||||||
|
const eligible = [];
|
||||||
|
const excluded = [];
|
||||||
|
|
||||||
|
for (const event of pack.events ?? []) {
|
||||||
|
if (event.stage !== undefined && event.stage !== state.stage) {
|
||||||
|
excluded.push({ event, reason: 'stage' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const seenCount = getPath(state, `seen.${event.id}`) ?? 0;
|
||||||
|
if (event.once && seenCount > 0) {
|
||||||
|
excluded.push({ event, reason: 'once' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (event.cooldown) {
|
||||||
|
const lastTurn = getPath(state, `lastSeen.${event.id}`);
|
||||||
|
if (lastTurn !== undefined && state.turn - lastTurn < event.cooldown) {
|
||||||
|
excluded.push({ event, reason: 'cooldown' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const gate = check(state, event.requires);
|
||||||
|
if (!gate.passed) {
|
||||||
|
excluded.push({ event, reason: 'requires', failed: gate.failed });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// An event about a co-worker cannot fire in a run that drew none.
|
||||||
|
if ((event.involves ?? []).some((role) => !presentSlots(pack, state, role).length)) {
|
||||||
|
excluded.push({ event, reason: 'involves' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
eligible.push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { eligible, excluded };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw this turn's event. Returns undefined when nothing is eligible, which is
|
||||||
|
* a content bug rather than a game state — the validator checks that at least
|
||||||
|
* one unconditional event exists per stage so this cannot happen in practice.
|
||||||
|
*/
|
||||||
|
export function selectEvent(pack, state, rng) {
|
||||||
|
const { eligible } = eligibleEvents(pack, state);
|
||||||
|
return rng.weighted(eligible, (event) => event.weight ?? 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every option on an event, in authored order, marked available or locked and
|
||||||
|
* carrying the conditions that locked it. The UI greys locked options out; the
|
||||||
|
* feedback log records them, because what a player *could not* choose is the
|
||||||
|
* strongest signal that the choice set is too narrow.
|
||||||
|
*/
|
||||||
|
export function resolveOptions(state, event) {
|
||||||
|
const bindings = state?.meta?.bindings ?? {};
|
||||||
|
return (event.options ?? []).map((option) => {
|
||||||
|
const gate = check(state, bindConditions(option.requires, bindings));
|
||||||
|
return {
|
||||||
|
option,
|
||||||
|
available: gate.passed,
|
||||||
|
failed: gate.failed,
|
||||||
|
hidden: !gate.passed && option.whenLocked === 'hide',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Look up one option on an event by id. */
|
||||||
|
export function findOption(event, optionId) {
|
||||||
|
return (event.options ?? []).find((option) => option.id === optionId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
// Turning a content pack into a running game state.
|
||||||
|
//
|
||||||
|
// The pack declares what state exists and what it starts at; the engine
|
||||||
|
// expands that into a flat schema and an initial state object. The engine
|
||||||
|
// names no stat of its own beyond its own bookkeeping.
|
||||||
|
|
||||||
|
import { getPath, setPath } from './paths.js';
|
||||||
|
import { slotsOf, drawCast, drawnRelationships, hasDrawnCast } from './cast.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paths the engine owns. Content may *read* these in conditions — "only after
|
||||||
|
* day 10", "only if this event hasn't fired" — but may not declare or write
|
||||||
|
* them, which is what keeps engine bookkeeping from being corrupted by data.
|
||||||
|
*/
|
||||||
|
export const RESERVED_ROOTS =
|
||||||
|
new Set(['turn', 'stage', 'seen', 'lastSeen', 'history', 'meta', 'calendar', 'cast']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flatten a pack's state declarations into one path -> rule map, expanding the
|
||||||
|
* per-character relationship stats. `relationships.<characterId>.<stat>` is
|
||||||
|
* generated from `relationshipStats` x `characters`, so adding a character
|
||||||
|
* adds its relationship state automatically and naming a stat is the pack's
|
||||||
|
* business, never the engine's.
|
||||||
|
*/
|
||||||
|
export function resolveSchema(pack) {
|
||||||
|
const schema = {};
|
||||||
|
for (const [path, rule] of Object.entries(pack.state ?? {})) {
|
||||||
|
schema[path] = { ...rule };
|
||||||
|
}
|
||||||
|
for (const character of pack.characters ?? []) {
|
||||||
|
// A group role has one slot per possible member (coworker_1 .. coworker_4),
|
||||||
|
// all declared up front so every one is validated and saved. See cast.js.
|
||||||
|
for (const slot of slotsOf(character)) {
|
||||||
|
for (const [stat, rule] of Object.entries(pack.relationshipStats ?? {})) {
|
||||||
|
// A character may start somewhere other than the pack default — a rival
|
||||||
|
// who already resents you, a boss who has not formed an opinion.
|
||||||
|
const override = character.startingRelationship?.[stat];
|
||||||
|
schema[`relationships.${slot}.${stat}`] =
|
||||||
|
override === undefined ? { ...rule } : { ...rule, initial: override };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Draw a run's cast into state, with the starting relationships the drawn people bring. */
|
||||||
|
function withCast(pack, state) {
|
||||||
|
if (!hasDrawnCast(pack)) return state;
|
||||||
|
const cast = drawCast(pack, state.meta.seed);
|
||||||
|
let next = setPath(state, 'cast', cast);
|
||||||
|
for (const { path, value } of drawnRelationships(pack, cast)) next = setPath(next, path, value);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fresh game state for `pack`, seeded for reproducibility. */
|
||||||
|
export function createState(pack, { seed, schema = resolveSchema(pack) } = {}) {
|
||||||
|
let state = {
|
||||||
|
meta: {
|
||||||
|
packId: pack.id,
|
||||||
|
packVersion: pack.version ?? null,
|
||||||
|
seed: String(seed),
|
||||||
|
rngPosition: null,
|
||||||
|
startedAt: null,
|
||||||
|
},
|
||||||
|
turn: 1,
|
||||||
|
stage: pack.startingStage,
|
||||||
|
seen: {},
|
||||||
|
lastSeen: {},
|
||||||
|
calendar: {},
|
||||||
|
history: [],
|
||||||
|
};
|
||||||
|
for (const [path, rule] of Object.entries(schema)) {
|
||||||
|
state = setPath(state, path, structuredClone(rule.initial));
|
||||||
|
}
|
||||||
|
return withCast(pack, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Give a loaded save any state the pack has gained since it was written.
|
||||||
|
*
|
||||||
|
* Adding a stat to a pack is meant to be data, but a run saved before that stat
|
||||||
|
* existed has no path for it, and `undefined` fails every numeric comparison in
|
||||||
|
* conditions.js — both `x >= 1` and `x < 1` are false. So content gated on a new
|
||||||
|
* stat does not misbehave in a saved run, it *vanishes* from it: the event never
|
||||||
|
* fires, the option never unlocks, the upkeep never charges, and nothing
|
||||||
|
* anywhere says so. Filling the gaps in on load is what makes "adding content is
|
||||||
|
* just adding data" true for players already mid-career.
|
||||||
|
*
|
||||||
|
* Returns the paths it filled, so a caller can say what it repaired.
|
||||||
|
*/
|
||||||
|
export function hydrate(pack, state, schema = resolveSchema(pack)) {
|
||||||
|
let next = state;
|
||||||
|
const added = [];
|
||||||
|
for (const [path, rule] of Object.entries(schema)) {
|
||||||
|
if (getPath(next, path) !== undefined) continue;
|
||||||
|
next = setPath(next, path, structuredClone(rule.initial));
|
||||||
|
added.push(path);
|
||||||
|
}
|
||||||
|
// A run saved before its pack drew a cast gets one now, from its own seed, so
|
||||||
|
// the same save always wakes up with the same people.
|
||||||
|
if (hasDrawnCast(pack) && next.cast === undefined) {
|
||||||
|
next = withCast(pack, next);
|
||||||
|
added.push('cast');
|
||||||
|
}
|
||||||
|
return { state: next, added };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The characters a pack defines, keyed by id. */
|
||||||
|
export function indexCharacters(pack) {
|
||||||
|
return Object.fromEntries((pack.characters ?? []).map((c) => [c.id, c]));
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// Names in text, and which person an event is about.
|
||||||
|
//
|
||||||
|
// Content that meets a different cast each run cannot write "Dutch" into its
|
||||||
|
// prose. It writes `{rival}`, and the name is filled in from whoever was drawn.
|
||||||
|
// `{rival.first}` is the first name, `{rival.title}` the title.
|
||||||
|
//
|
||||||
|
// A group role (two to four co-workers) cannot be named by its role alone —
|
||||||
|
// which one? An event says so with `involves: ['coworker']`, and when the event
|
||||||
|
// is drawn one present member is bound to it for the turn. `{coworker}` in its
|
||||||
|
// text, `relationships.{coworker}.trust` in its effects and `against:
|
||||||
|
// '{coworker}'` in a check all mean that person. The binding is saved with the
|
||||||
|
// run, so a replay binds the same person. See DECISIONS #41.
|
||||||
|
|
||||||
|
import { personIn } from './cast.js';
|
||||||
|
|
||||||
|
const PLACEHOLDER = /\{([A-Za-z0-9_]+)(?:\.(name|first|title))?\}/g;
|
||||||
|
|
||||||
|
/** Every `{id}` / `{id.part}` in a piece of text, as `{ id, part }`. */
|
||||||
|
export function placeholders(text) {
|
||||||
|
if (typeof text !== 'string') return [];
|
||||||
|
return [...text.matchAll(PLACEHOLDER)].map((m) => ({ id: m[1], part: m[2] ?? 'name' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The slot a placeholder id means this turn: a bound group member, or the id itself. */
|
||||||
|
export function resolveSlot(id, bindings = {}) {
|
||||||
|
return bindings[id] ?? id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replace `{id}` in a path or a string with the slot bound to it this turn. */
|
||||||
|
export function bindPath(path, bindings = {}) {
|
||||||
|
if (typeof path !== 'string') return path;
|
||||||
|
return path.replace(/\{([A-Za-z0-9_]+)\}/g, (whole, id) => resolveSlot(id, bindings));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fill names into text. Anything it cannot resolve is left as written. */
|
||||||
|
export function fillText(pack, state, text, bindings = state?.meta?.bindings ?? {}) {
|
||||||
|
if (typeof text !== 'string') return text;
|
||||||
|
return text.replace(PLACEHOLDER, (whole, id, part = 'name') => {
|
||||||
|
const person = personIn(pack, state, resolveSlot(id, bindings));
|
||||||
|
if (!person) return whole;
|
||||||
|
if (part === 'first') return person.name.split(' ')[0];
|
||||||
|
if (part === 'title') return person.title ?? '';
|
||||||
|
return person.name;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Conditions with any bound placeholders in their paths replaced by the slot. */
|
||||||
|
export function bindConditions(conditions, bindings = {}) {
|
||||||
|
if (!conditions || !Object.keys(bindings).length) return conditions;
|
||||||
|
const walk = (c) => {
|
||||||
|
if (!c || typeof c !== 'object') return c;
|
||||||
|
if (Array.isArray(c)) return c.map(walk);
|
||||||
|
if ('all' in c) return { ...c, all: c.all.map(walk) };
|
||||||
|
if ('any' in c) return { ...c, any: c.any.map(walk) };
|
||||||
|
if ('not' in c) return { ...c, not: walk(c.not) };
|
||||||
|
return { ...c, path: bindPath(c.path, bindings) };
|
||||||
|
};
|
||||||
|
return walk(conditions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Effects with any bound placeholders in their paths replaced by the slot. */
|
||||||
|
export function bindEffects(effects, bindings = {}) {
|
||||||
|
return (effects ?? []).map((effect) => {
|
||||||
|
const path = bindPath(effect.path, bindings);
|
||||||
|
let value = effect.value;
|
||||||
|
if (value && typeof value === 'object' && typeof value.from === 'string') {
|
||||||
|
value = { ...value, from: bindPath(value.from, bindings) };
|
||||||
|
} else if (typeof value === 'string') {
|
||||||
|
// Text kept in state (a notable moment) outlives the turn's bindings, so
|
||||||
|
// `{clerk}` is pinned to the slot it meant: `{clerk_2}` still names the
|
||||||
|
// same person when the retrospective reads it back.
|
||||||
|
value = value.replace(PLACEHOLDER, (whole, id, part) =>
|
||||||
|
(bindings[id] ? `{${bindings[id]}${part ? `.${part}` : ''}}` : whole));
|
||||||
|
}
|
||||||
|
return path === effect.path && value === effect.value ? effect : { ...effect, path, value };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,416 @@
|
|||||||
|
// Load-time validation of a content pack.
|
||||||
|
//
|
||||||
|
// This is what makes "adding content is just adding data" safe. Without it, a
|
||||||
|
// typo in a path or a character id fails silently forty turns into a
|
||||||
|
// playthrough. Run it whenever a pack is loaded in development, and in tests.
|
||||||
|
|
||||||
|
import { RESERVED_ROOTS, resolveSchema, indexCharacters } from './state.js';
|
||||||
|
import { OPERATORS, pathsUsed as conditionPaths } from './conditions.js';
|
||||||
|
import { OPERATIONS, isAmountFrom } from './effects.js';
|
||||||
|
import { parsePath } from './paths.js';
|
||||||
|
import { groupRange } from './cast.js';
|
||||||
|
import { placeholders } from './text.js';
|
||||||
|
|
||||||
|
const ID = /^[A-Za-z0-9_]+$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check a pack. Returns { errors, warnings, schema }; errors mean the pack
|
||||||
|
* cannot be played, warnings mean it probably does not do what was intended.
|
||||||
|
*/
|
||||||
|
export function validatePack(pack) {
|
||||||
|
const errors = [];
|
||||||
|
const warnings = [];
|
||||||
|
const schema = resolveSchema(pack);
|
||||||
|
const characters = indexCharacters(pack);
|
||||||
|
const stateRoots = new Set(Object.keys(schema).map((p) => p.split('.')[0]));
|
||||||
|
|
||||||
|
const fail = (where, message) => errors.push(`${where}: ${message}`);
|
||||||
|
const warn = (where, message) => warnings.push(`${where}: ${message}`);
|
||||||
|
|
||||||
|
if (!pack.id || !ID.test(pack.id)) fail('pack', `id ${JSON.stringify(pack.id)} must match ${ID}`);
|
||||||
|
if (!pack.startingStage) fail('pack', 'startingStage is required');
|
||||||
|
|
||||||
|
// Content may read engine bookkeeping but must never declare or write it.
|
||||||
|
for (const path of Object.keys(schema)) {
|
||||||
|
try {
|
||||||
|
const root = parsePath(path)[0];
|
||||||
|
if (RESERVED_ROOTS.has(root)) {
|
||||||
|
fail('state', `"${path}" declares reserved root "${root}", which the engine owns`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
fail('state', `"${path}" is not a usable path: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attributes are ordinary declared state (`attributes.<id>` for the player);
|
||||||
|
// pack.attributes names them, so checks and characters can be held to the list.
|
||||||
|
const attributeIds = new Set(Object.keys(pack.attributes ?? {}));
|
||||||
|
for (const id of attributeIds) {
|
||||||
|
if (!ID.test(id)) fail(`attribute "${id}"`, `id must match ${ID}`);
|
||||||
|
if (!(`attributes.${id}` in schema)) fail(`attribute "${id}"`, `the player needs "attributes.${id}" in pack.state`);
|
||||||
|
}
|
||||||
|
const checkAttributes = (where, attributes, { complete }) => {
|
||||||
|
for (const key of Object.keys(attributes ?? {})) {
|
||||||
|
if (!attributeIds.has(key)) fail(where, `attribute "${key}" is not in pack.attributes`);
|
||||||
|
else if (typeof attributes[key] !== 'number') fail(where, `attribute "${key}" must be a number`);
|
||||||
|
}
|
||||||
|
if (complete) {
|
||||||
|
for (const id of attributeIds) {
|
||||||
|
if (typeof attributes?.[id] !== 'number') fail(where, `has no "${id}", which every drawn person needs`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const seenCharacters = new Set();
|
||||||
|
const names = new Map();
|
||||||
|
const nameOnce = (where, name) => {
|
||||||
|
if (names.has(name)) fail(where, `name "${name}" is already used by ${names.get(name)}`);
|
||||||
|
else names.set(name, where);
|
||||||
|
};
|
||||||
|
for (const character of pack.characters ?? []) {
|
||||||
|
const where = `character "${character.id}"`;
|
||||||
|
if (!character.id || !ID.test(character.id)) fail(where, `id must match ${ID}`);
|
||||||
|
if (seenCharacters.has(character.id)) fail(where, 'duplicate id');
|
||||||
|
seenCharacters.add(character.id);
|
||||||
|
if (!character.role_type) warn(where, 'no role_type tag; content cannot filter on it');
|
||||||
|
|
||||||
|
const range = groupRange(character);
|
||||||
|
const pool = character.candidates ?? [];
|
||||||
|
if (range) {
|
||||||
|
const [min, max] = range;
|
||||||
|
if (!Number.isInteger(min) || !Number.isInteger(max) || min < 0 || max < 1 || min > max) {
|
||||||
|
fail(where, `count ${JSON.stringify(character.count)} must be [min, max] whole numbers with 0 <= min <= max and max >= 1`);
|
||||||
|
} else if (pool.length < max) {
|
||||||
|
fail(where, `can hold up to ${max} people but has only ${pool.length} candidates`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (range || pool.length) {
|
||||||
|
if (character.name) warn(where, 'has a name as well as candidates; the drawn candidate\'s name is used');
|
||||||
|
pool.forEach((candidate, index) => {
|
||||||
|
const at = `${where} candidate ${index}`;
|
||||||
|
if (!candidate.name) fail(at, 'name is required');
|
||||||
|
else nameOnce(at, candidate.name);
|
||||||
|
if (!candidate.description) warn(at, 'no description');
|
||||||
|
checkAttributes(at, candidate.attributes, { complete: attributeIds.size > 0 });
|
||||||
|
for (const stat of Object.keys(candidate.startingRelationship ?? {})) {
|
||||||
|
if (!(stat in (pack.relationshipStats ?? {}))) fail(at, `startingRelationship names unknown stat "${stat}"`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (!character.name) fail(where, 'name is required');
|
||||||
|
else nameOnce(where, character.name);
|
||||||
|
checkAttributes(where, character.attributes, { complete: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const characterById = Object.fromEntries((pack.characters ?? []).map((c) => [c.id, c]));
|
||||||
|
const isGroup = (id) => Boolean(characterById[id] && groupRange(characterById[id]));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A path or text may name a character as `{id}`. A single character is always
|
||||||
|
* there to name; a group member only when the event binds one with `involves`.
|
||||||
|
* Returns the path with each placeholder replaced by a representative slot, so
|
||||||
|
* it can be checked against the schema like any written path.
|
||||||
|
*/
|
||||||
|
const bindForCheck = (where, text, involves) => {
|
||||||
|
for (const { id } of placeholders(text)) {
|
||||||
|
if (!characterById[id]) fail(where, `{${id}} names no character`);
|
||||||
|
else if (isGroup(id) && !involves.includes(id)) {
|
||||||
|
fail(where, `{${id}} is a group; the event must list "${id}" in involves to say which one`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return typeof text === 'string'
|
||||||
|
? text.replace(/\{([A-Za-z0-9_]+)\}/g, (w, id) => (isGroup(id) ? `${id}_1` : id))
|
||||||
|
: text;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Chances are the pack's numbers, like every other tuning value (#9).
|
||||||
|
const usesChecks = (pack.events ?? []).some((e) => (e.options ?? []).some((o) => o.check));
|
||||||
|
if (usesChecks) {
|
||||||
|
const rules = pack.checks;
|
||||||
|
if (!rules) fail('checks', 'options carry checks but the pack declares no checks block');
|
||||||
|
else {
|
||||||
|
for (const key of ['base', 'perPoint', 'floor', 'ceiling']) {
|
||||||
|
if (typeof rules[key] !== 'number') fail('checks', `${key} must be a number`);
|
||||||
|
}
|
||||||
|
if (rules.floor > rules.ceiling) fail('checks', 'floor is above ceiling');
|
||||||
|
if (rules.odds) {
|
||||||
|
let last = -Infinity;
|
||||||
|
for (const band of rules.odds) {
|
||||||
|
if (typeof band.upTo !== 'number' || band.upTo <= last) fail('checks', 'odds bands must rise by upTo');
|
||||||
|
if (!band.words) fail('checks', 'every odds band needs words');
|
||||||
|
last = band.upTo;
|
||||||
|
}
|
||||||
|
if (last < (rules.ceiling ?? 100)) fail('checks', `odds bands stop at ${last}, below the ceiling`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const readable = (path) => {
|
||||||
|
let root;
|
||||||
|
try {
|
||||||
|
root = parsePath(path)[0];
|
||||||
|
} catch (error) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
if (RESERVED_ROOTS.has(root)) return null;
|
||||||
|
if (path in schema) return null;
|
||||||
|
if (stateRoots.has(root)) return `"${path}" is not declared in pack.state or relationshipStats`;
|
||||||
|
return `"${path}" is not declared anywhere in this pack`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stages are the one piece of engine bookkeeping content may write: moving
|
||||||
|
// the player between them is a content decision (a promotion), and the
|
||||||
|
// engine only ever uses `stage` to filter events. The value is checked
|
||||||
|
// against the stages that actually have events, once those are known.
|
||||||
|
const stageWrites = [];
|
||||||
|
|
||||||
|
const writable = (path, effect, where) => {
|
||||||
|
let root;
|
||||||
|
try {
|
||||||
|
root = parsePath(path)[0];
|
||||||
|
} catch (error) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
if (path === 'stage') {
|
||||||
|
if (effect.op !== 'set') return `"stage" may only be written with set, not ${effect.op}`;
|
||||||
|
stageWrites.push({ where, value: effect.value });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (RESERVED_ROOTS.has(root)) return `"${path}" writes engine-owned state`;
|
||||||
|
if (!(path in schema)) return `"${path}" is not declared in pack.state or relationshipStats`;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkConditions = (where, conditions) => {
|
||||||
|
if (!conditions) return;
|
||||||
|
const list = Array.isArray(conditions) ? conditions : [conditions];
|
||||||
|
for (const path of list.flatMap((c) => conditionPaths(c))) {
|
||||||
|
const problem = readable(path);
|
||||||
|
if (problem) fail(where, `condition reads ${problem}`);
|
||||||
|
}
|
||||||
|
const walk = (condition) => {
|
||||||
|
if (!condition || typeof condition !== 'object') return;
|
||||||
|
if (Array.isArray(condition)) return condition.forEach(walk);
|
||||||
|
if ('all' in condition) return condition.all.forEach(walk);
|
||||||
|
if ('any' in condition) return condition.any.forEach(walk);
|
||||||
|
if ('not' in condition) return walk(condition.not);
|
||||||
|
if (!OPERATORS.includes(condition.op)) {
|
||||||
|
fail(where, `unknown condition operator ${JSON.stringify(condition.op)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
list.forEach(walk);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (pack.calendar) {
|
||||||
|
const { cycleLength, unitNames } = pack.calendar;
|
||||||
|
if (!Number.isInteger(cycleLength) || cycleLength < 1) {
|
||||||
|
fail('calendar', `cycleLength ${cycleLength} must be a positive whole number`);
|
||||||
|
}
|
||||||
|
if (unitNames && unitNames.length !== cycleLength) {
|
||||||
|
fail('calendar', `unitNames has ${unitNames.length} entries but cycleLength is ${cycleLength}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(pack.upkeep ?? []).forEach((entry, index) => {
|
||||||
|
const where = `upkeep[${index}]${entry.label ? ` "${entry.label}"` : ''}`;
|
||||||
|
if (entry.every !== undefined && (!Number.isInteger(entry.every) || entry.every < 1)) {
|
||||||
|
fail(where, `every ${entry.every} must be a positive whole number of turns`);
|
||||||
|
}
|
||||||
|
if (entry.offset !== undefined && (!Number.isInteger(entry.offset) || entry.offset < 0)) {
|
||||||
|
fail(where, `offset ${entry.offset} must be zero or a positive whole number`);
|
||||||
|
}
|
||||||
|
if (entry.offset !== undefined && entry.every === undefined) {
|
||||||
|
warn(where, 'offset has no effect without every');
|
||||||
|
}
|
||||||
|
checkConditions(where, entry.requires);
|
||||||
|
if (!entry.effects?.length) fail(where, 'has no effects');
|
||||||
|
for (const effect of entry.effects ?? []) {
|
||||||
|
if (!OPERATIONS.includes(effect.op)) {
|
||||||
|
fail(where, `unknown effect operation ${JSON.stringify(effect.op)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const effect of entry.effects ?? []) {
|
||||||
|
if (typeof effect.path !== 'string') continue;
|
||||||
|
const problem = writable(effect.path, effect, where);
|
||||||
|
if (problem) fail(where, `effect writes ${problem}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const character of pack.characters ?? []) {
|
||||||
|
for (const stat of Object.keys(character.startingRelationship ?? {})) {
|
||||||
|
if (!(stat in (pack.relationshipStats ?? {}))) {
|
||||||
|
fail(`character "${character.id}"`, `startingRelationship names unknown stat "${stat}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stages = new Set();
|
||||||
|
const unconditional = new Set();
|
||||||
|
const seenEvents = new Set();
|
||||||
|
|
||||||
|
for (const event of pack.events ?? []) {
|
||||||
|
const where = `event "${event.id}"`;
|
||||||
|
if (!event.id || !ID.test(event.id)) fail(where, `id must match ${ID} (it is used as a state path)`);
|
||||||
|
if (seenEvents.has(event.id)) fail(where, 'duplicate id');
|
||||||
|
seenEvents.add(event.id);
|
||||||
|
if (!event.description) warn(where, 'no description; the player will see an empty scenario');
|
||||||
|
if (event.stage !== undefined) stages.add(event.stage);
|
||||||
|
if (event.weight !== undefined && !(event.weight > 0)) {
|
||||||
|
fail(where, `weight ${event.weight} must be greater than zero, or the event can never fire`);
|
||||||
|
}
|
||||||
|
checkConditions(where, event.requires);
|
||||||
|
const involves = event.involves ?? [];
|
||||||
|
for (const role of involves) {
|
||||||
|
if (!characterById[role]) fail(where, `involves "${role}", which is no character`);
|
||||||
|
}
|
||||||
|
// Bindings are drawn after the event is chosen, so the event's own gate
|
||||||
|
// cannot depend on who they are.
|
||||||
|
const gatePaths = event.requires
|
||||||
|
? (Array.isArray(event.requires) ? event.requires : [event.requires]).flatMap((c) => conditionPaths(c))
|
||||||
|
: [];
|
||||||
|
if (gatePaths.some((p) => p.includes('{'))) {
|
||||||
|
fail(where, 'requires names a {character}; only options and effects can, once the event is drawn');
|
||||||
|
}
|
||||||
|
bindForCheck(where, event.description, involves);
|
||||||
|
const bound = (conditions) => {
|
||||||
|
if (!conditions) return conditions;
|
||||||
|
const walk = (c) => {
|
||||||
|
if (!c || typeof c !== 'object') return c;
|
||||||
|
if (Array.isArray(c)) return c.map(walk);
|
||||||
|
if ('all' in c) return { ...c, all: c.all.map(walk) };
|
||||||
|
if ('any' in c) return { ...c, any: c.any.map(walk) };
|
||||||
|
if ('not' in c) return { ...c, not: walk(c.not) };
|
||||||
|
return { ...c, path: bindForCheck(where, c.path, involves) };
|
||||||
|
};
|
||||||
|
return walk(conditions);
|
||||||
|
};
|
||||||
|
// A fallback must be one that *no* exclusion rule can remove: requires,
|
||||||
|
// once and cooldown are exactly the three reasons eligibleEvents drops an
|
||||||
|
// event, so a floor event may carry none of them.
|
||||||
|
if (!event.requires && !event.once && !event.cooldown && event.stage !== undefined) {
|
||||||
|
unconditional.add(event.stage);
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = event.options ?? [];
|
||||||
|
if (options.length === 0) fail(where, 'has no options; the turn would be unplayable');
|
||||||
|
const seenOptions = new Set();
|
||||||
|
for (const option of options) {
|
||||||
|
const optionWhere = `${where} option "${option.id}"`;
|
||||||
|
if (!option.id || !ID.test(option.id)) fail(optionWhere, `id must match ${ID}`);
|
||||||
|
if (seenOptions.has(option.id)) fail(optionWhere, 'duplicate id');
|
||||||
|
seenOptions.add(option.id);
|
||||||
|
if (!option.label) fail(optionWhere, 'label is required');
|
||||||
|
bindForCheck(optionWhere, option.label, involves);
|
||||||
|
bindForCheck(optionWhere, option.result, involves);
|
||||||
|
checkConditions(optionWhere, bound(option.requires));
|
||||||
|
|
||||||
|
const checkEffects = (at, effects) => {
|
||||||
|
for (const effect of effects ?? []) {
|
||||||
|
if (!OPERATIONS.includes(effect.op)) {
|
||||||
|
fail(at, `unknown effect operation ${JSON.stringify(effect.op)}`);
|
||||||
|
}
|
||||||
|
if (effect.op === 'add' && typeof effect.value !== 'number') {
|
||||||
|
if (isAmountFrom(effect.value)) {
|
||||||
|
// An amount read from state must read something the pack declares,
|
||||||
|
// and scale and cap it by numbers. See DECISIONS #38.
|
||||||
|
const problem = readable(bindForCheck(at, effect.value.from, involves));
|
||||||
|
if (problem) fail(at, `effect "${effect.path}" reads its amount from ${problem}`);
|
||||||
|
for (const key of ['times', 'cap']) {
|
||||||
|
if (effect.value[key] !== undefined && typeof effect.value[key] !== 'number') {
|
||||||
|
fail(at, `effect "${effect.path}" has a non-numeric ${key}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fail(at, `effect "${effect.path}" uses ${effect.op} with a non-numeric value`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const effect of effects ?? []) {
|
||||||
|
if (typeof effect.path !== 'string') continue;
|
||||||
|
const problem = writable(bindForCheck(at, effect.path, involves), effect, at);
|
||||||
|
if (problem) fail(at, `effect writes ${problem}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
checkEffects(optionWhere, option.effects);
|
||||||
|
|
||||||
|
if (option.check) {
|
||||||
|
const c = option.check;
|
||||||
|
const at = `${optionWhere} check`;
|
||||||
|
if (!attributeIds.has(c.attribute)) fail(at, `attribute "${c.attribute}" is not in pack.attributes`);
|
||||||
|
if (c.opposedBy !== undefined && !attributeIds.has(c.opposedBy)) {
|
||||||
|
fail(at, `opposedBy "${c.opposedBy}" is not in pack.attributes`);
|
||||||
|
}
|
||||||
|
if (c.against !== undefined) {
|
||||||
|
const id = String(c.against).replace(/^\{|\}$/g, '');
|
||||||
|
const character = characterById[id];
|
||||||
|
if (!character) fail(at, `against "${c.against}" is no character`);
|
||||||
|
else {
|
||||||
|
if (isGroup(id) && !involves.includes(id)) {
|
||||||
|
fail(at, `against "${c.against}" is a group; the event must list "${id}" in involves`);
|
||||||
|
}
|
||||||
|
const attribute = c.opposedBy ?? c.attribute;
|
||||||
|
const people = character.candidates?.length ? character.candidates : [character];
|
||||||
|
if (people.some((p) => typeof p.attributes?.[attribute] !== 'number')) {
|
||||||
|
fail(at, `not everyone who can be "${id}" has "${attribute}" to oppose with`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (c.relationship !== undefined) {
|
||||||
|
fail(at, 'relationship needs against: whose regard is it?');
|
||||||
|
}
|
||||||
|
if (c.relationship !== undefined && !(c.relationship in (pack.relationshipStats ?? {}))) {
|
||||||
|
fail(at, `relationship "${c.relationship}" is not in relationshipStats`);
|
||||||
|
}
|
||||||
|
for (const key of ['difficulty', 'modifier']) {
|
||||||
|
if (c[key] !== undefined && typeof c[key] !== 'number') fail(at, `${key} must be a number`);
|
||||||
|
}
|
||||||
|
for (const branch of ['success', 'failure']) {
|
||||||
|
const outcome = option[branch];
|
||||||
|
if (!outcome) { fail(at, `has no ${branch}; a check needs both ways it can go`); continue; }
|
||||||
|
if (!outcome.result) warn(`${optionWhere} ${branch}`, 'no result text; the player will not be told');
|
||||||
|
bindForCheck(`${optionWhere} ${branch}`, outcome.result, involves);
|
||||||
|
checkEffects(`${optionWhere} ${branch}`, outcome.effects);
|
||||||
|
}
|
||||||
|
} else if (option.success || option.failure) {
|
||||||
|
fail(optionWhere, 'has success or failure but no check to decide between them');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every reachable stage needs a floor: one event with no conditions, or the
|
||||||
|
// pool can empty out mid-run and the turn loop has nothing to show.
|
||||||
|
if (pack.startingStage && !stages.has(pack.startingStage)) {
|
||||||
|
fail('pack', `startingStage "${pack.startingStage}" has no events`);
|
||||||
|
}
|
||||||
|
for (const { where, value } of stageWrites) {
|
||||||
|
if (typeof value !== 'string' || !stages.has(value)) {
|
||||||
|
fail(where, `moves the player to stage ${JSON.stringify(value)}, which has no events`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const stage of stages) {
|
||||||
|
if (!unconditional.has(stage)) {
|
||||||
|
fail('events', `stage "${stage}" has no unconditional event; the turn pool can run dry`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [path, rule] of Object.entries(schema)) {
|
||||||
|
if (rule.initial === undefined) fail('state', `"${path}" has no initial value`);
|
||||||
|
if (typeof rule.initial === 'number' && typeof rule.min === 'number' && rule.initial < rule.min) {
|
||||||
|
warn('state', `"${path}" starts below its own minimum`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(characters).length === 0) warn('pack', 'no characters defined');
|
||||||
|
|
||||||
|
return { errors, warnings, schema, valid: errors.length === 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Throw if the pack is unplayable. Call this at load time in development. */
|
||||||
|
export function assertValidPack(pack) {
|
||||||
|
const result = validatePack(pack);
|
||||||
|
if (!result.valid) {
|
||||||
|
throw new Error(`content pack "${pack.id}" is invalid:\n ${result.errors.join('\n ')}`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// Handing the player a file.
|
||||||
|
//
|
||||||
|
// Object URLs are revoked on the next frame rather than immediately: Safari
|
||||||
|
// has historically cancelled the download if the URL goes away too soon.
|
||||||
|
|
||||||
|
export function downloadText(filename, text, type = 'application/json') {
|
||||||
|
const url = URL.createObjectURL(new Blob([text], { type }));
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.append(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
requestAnimationFrame(() => URL.revokeObjectURL(url));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read a File the player picked, as text. */
|
||||||
|
export function readFile(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(String(reader.result));
|
||||||
|
reader.onerror = () => reject(new Error('Could not read that file.'));
|
||||||
|
reader.readAsText(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A timestamp suffix that sorts, for filenames. */
|
||||||
|
export function stamp(date = new Date()) {
|
||||||
|
return date.toISOString().slice(0, 19).replace(/[:T]/g, '-');
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
// The feedback and instrumentation log.
|
||||||
|
//
|
||||||
|
// Deliberately separate from the save: this is research data for the designer,
|
||||||
|
// not part of anyone's career. It is exported on its own and never merged into
|
||||||
|
// a save file.
|
||||||
|
//
|
||||||
|
// Each entry records more than the spec's minimum, for two reasons. The full
|
||||||
|
// before/after diff makes "what did that choice actually do" answerable months
|
||||||
|
// later without replaying anything. The list of options that were *offered but
|
||||||
|
// locked* is the strongest available signal that a choice set is too narrow —
|
||||||
|
// players rarely type into a comment box, but the gates they keep bouncing off
|
||||||
|
// are recorded whether they say anything or not.
|
||||||
|
|
||||||
|
import { APP, BUILD } from '../version.js';
|
||||||
|
|
||||||
|
const KEY = 'theladder.feedback.v1';
|
||||||
|
const FORMAT = 1;
|
||||||
|
|
||||||
|
function safely(action, fallback = null) {
|
||||||
|
try {
|
||||||
|
return action();
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRaw() {
|
||||||
|
return safely(() => {
|
||||||
|
const raw = localStorage.getItem(KEY);
|
||||||
|
return raw ? JSON.parse(raw) : [];
|
||||||
|
}, []) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeRaw(entries) {
|
||||||
|
return safely(() => {
|
||||||
|
localStorage.setItem(KEY, JSON.stringify(entries));
|
||||||
|
return true;
|
||||||
|
}, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a turn. `record` is what takeTurn returned; `state` is the state it
|
||||||
|
* produced, carried for the seed and the running totals at that moment.
|
||||||
|
*/
|
||||||
|
export function logTurn(record, state, pack) {
|
||||||
|
const entries = readRaw();
|
||||||
|
entries.push({
|
||||||
|
logged_at: new Date().toISOString(),
|
||||||
|
// Which build produced this turn. A session played on a page loaded before
|
||||||
|
// the last rebuild looks exactly like a feature that does not work: a real
|
||||||
|
// playtest ran 181 turns against a stale bundle and the charge it was meant
|
||||||
|
// to exercise never fired, which took file timestamps and a simulation to
|
||||||
|
// work out. The log should say so itself.
|
||||||
|
app_version: APP.version,
|
||||||
|
build: BUILD.commit,
|
||||||
|
pack_id: pack.id,
|
||||||
|
pack_version: pack.version ?? null,
|
||||||
|
seed: state.meta.seed,
|
||||||
|
turn_id: `${state.meta.seed}:${record.turn}`,
|
||||||
|
turn: record.turn,
|
||||||
|
stage: record.stage,
|
||||||
|
event_id: record.eventId,
|
||||||
|
action_taken: record.optionId,
|
||||||
|
action_label: record.optionLabel,
|
||||||
|
changes: record.changes,
|
||||||
|
upkeep: record.upkeep,
|
||||||
|
offered: record.offered,
|
||||||
|
rating: null,
|
||||||
|
comment: null,
|
||||||
|
desired_but_unavailable_action: null,
|
||||||
|
});
|
||||||
|
writeRaw(entries);
|
||||||
|
return entries.at(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach the player's feedback to a turn already logged. Feedback arrives
|
||||||
|
* after the choice — often several seconds after — so it is a second write
|
||||||
|
* rather than part of the first.
|
||||||
|
*/
|
||||||
|
export function attachFeedback(turnId, { rating, comment, desiredAction }) {
|
||||||
|
const entries = readRaw();
|
||||||
|
const entry = entries.findLast((e) => e.turn_id === turnId);
|
||||||
|
if (!entry) return false;
|
||||||
|
if (rating !== undefined) entry.rating = rating;
|
||||||
|
if (comment !== undefined) entry.comment = comment || null;
|
||||||
|
if (desiredAction !== undefined) entry.desired_but_unavailable_action = desiredAction || null;
|
||||||
|
return writeRaw(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readLog() {
|
||||||
|
return readRaw();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearLog() {
|
||||||
|
return safely(() => { localStorage.removeItem(KEY); return true; }, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many entries carry actual player feedback, for the export summary. */
|
||||||
|
export function feedbackCount(entries = readRaw()) {
|
||||||
|
return entries.filter((e) => e.rating || e.comment || e.desired_but_unavailable_action).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeLog(entries = readRaw()) {
|
||||||
|
return JSON.stringify({
|
||||||
|
format: FORMAT,
|
||||||
|
exported_at: new Date().toISOString(),
|
||||||
|
turns_logged: entries.length,
|
||||||
|
turns_with_feedback: feedbackCount(entries),
|
||||||
|
entries,
|
||||||
|
}, null, 2);
|
||||||
|
}
|
||||||
+115
@@ -0,0 +1,115 @@
|
|||||||
|
// Saving and loading.
|
||||||
|
//
|
||||||
|
// Local storage is the live save; the file export exists so a cleared cache or
|
||||||
|
// a crashed browser does not take a career with it. A save is the whole game
|
||||||
|
// state and nothing else — the engine keeps no state outside it, so this is a
|
||||||
|
// complete record by construction.
|
||||||
|
//
|
||||||
|
// There is one slot per pack. A single global slot was fine while there was
|
||||||
|
// one setting; the moment a second existed, switching settings would have
|
||||||
|
// silently thrown away the other run — the guard in the app read the packId
|
||||||
|
// off whatever was in the slot and started a new game when it did not match.
|
||||||
|
|
||||||
|
const PREFIX = 'theladder.save.v1';
|
||||||
|
// The slot as it was before packs were selectable: one save, no pack in the
|
||||||
|
// key. Read once, for Corporate Ladder only, so a run in progress survives
|
||||||
|
// this change. Never written.
|
||||||
|
const LEGACY_KEY = PREFIX;
|
||||||
|
const LEGACY_PACK = 'corporateladder';
|
||||||
|
const LAST_PACK_KEY = 'theladder.pack.v1';
|
||||||
|
const FORMAT = 1;
|
||||||
|
|
||||||
|
const slotFor = (packId) => `${PREFIX}.${packId}`;
|
||||||
|
|
||||||
|
/** Local storage throws in private windows and when site data is blocked. */
|
||||||
|
function safely(action, fallback = null) {
|
||||||
|
try {
|
||||||
|
return action();
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveLocal(packId, state) {
|
||||||
|
return safely(() => {
|
||||||
|
localStorage.setItem(
|
||||||
|
slotFor(packId),
|
||||||
|
JSON.stringify({ format: FORMAT, savedAt: new Date().toISOString(), state }),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The saved run for one pack, or null. Falls back to the pre-packs slot for
|
||||||
|
* Corporate Ladder, and only when that slot really holds a Corporate Ladder
|
||||||
|
* save — a stale slot from some other pack is not a reason to load a run into
|
||||||
|
* a game whose events do not match it.
|
||||||
|
*/
|
||||||
|
export function loadLocal(packId) {
|
||||||
|
return safely(() => {
|
||||||
|
const raw = localStorage.getItem(slotFor(packId));
|
||||||
|
if (raw) return JSON.parse(raw)?.state ?? null;
|
||||||
|
if (packId !== LEGACY_PACK) return null;
|
||||||
|
const legacy = localStorage.getItem(LEGACY_KEY);
|
||||||
|
if (!legacy) return null;
|
||||||
|
const state = JSON.parse(legacy)?.state ?? null;
|
||||||
|
return state?.meta?.packId === LEGACY_PACK ? state : null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearLocal(packId) {
|
||||||
|
return safely(() => {
|
||||||
|
localStorage.removeItem(slotFor(packId));
|
||||||
|
if (packId === LEGACY_PACK) localStorage.removeItem(LEGACY_KEY);
|
||||||
|
return true;
|
||||||
|
}, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which pack was last played, so a returning player lands back in it. */
|
||||||
|
export function lastPlayed() {
|
||||||
|
return safely(() => localStorage.getItem(LAST_PACK_KEY));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rememberPack(packId) {
|
||||||
|
return safely(() => { localStorage.setItem(LAST_PACK_KEY, packId); return true; }, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if the browser will actually remember anything. */
|
||||||
|
export function storageAvailable() {
|
||||||
|
return safely(() => {
|
||||||
|
const probe = '__theladder_probe__';
|
||||||
|
localStorage.setItem(probe, '1');
|
||||||
|
localStorage.removeItem(probe);
|
||||||
|
return true;
|
||||||
|
}, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A save file's contents, as a string ready to be downloaded. */
|
||||||
|
export function serializeSave(state) {
|
||||||
|
return JSON.stringify({ format: FORMAT, savedAt: new Date().toISOString(), state }, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse an imported save, refusing anything that is not one. A file from a
|
||||||
|
* different pack is rejected by name rather than loaded into a game whose
|
||||||
|
* events and characters do not match it.
|
||||||
|
*/
|
||||||
|
export function parseSave(text, expectedPackId) {
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
throw new Error('That file is not valid JSON.');
|
||||||
|
}
|
||||||
|
const state = parsed?.state;
|
||||||
|
if (!state || typeof state !== 'object' || !state.meta || typeof state.turn !== 'number') {
|
||||||
|
throw new Error('That does not look like a saved game.');
|
||||||
|
}
|
||||||
|
if (expectedPackId && state.meta.packId !== expectedPackId) {
|
||||||
|
throw new Error(
|
||||||
|
`That save is from "${state.meta.packId}", but this is "${expectedPackId}".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
+335
@@ -0,0 +1,335 @@
|
|||||||
|
// The application: wiring the engine to the screens.
|
||||||
|
//
|
||||||
|
// This is the one module that knows there is more than one content pack. It
|
||||||
|
// holds the current pack and its schema, and everything below it still takes
|
||||||
|
// the pack as an argument — the screens, the engine and the io layer are all
|
||||||
|
// as pack-agnostic as they were when there was only one.
|
||||||
|
//
|
||||||
|
// Each pack has its own save slot, so switching settings never destroys a run.
|
||||||
|
//
|
||||||
|
// Rendering is a full rebuild of the root element. At this size it is fast and
|
||||||
|
// removes a whole class of stale-view bug — with one deliberate exception: the
|
||||||
|
// feedback text fields update the model without re-rendering, because
|
||||||
|
// rebuilding the DOM under a cursor would throw the player out of the box they
|
||||||
|
// are typing in.
|
||||||
|
|
||||||
|
import { packs, packById, defaultPack } from '../../content/index.js';
|
||||||
|
import { validatePack } from '../engine/validate.js';
|
||||||
|
import { resolveSchema, hydrate } from '../engine/state.js';
|
||||||
|
import { startGame, currentTurn, takeTurn, findOptionById } from './engine-bridge.js';
|
||||||
|
import { turnScreen } from './screens/turn.js';
|
||||||
|
import { retrospectiveScreen } from './screens/retrospective.js';
|
||||||
|
import { packsScreen } from './screens/packs.js';
|
||||||
|
import { fill, focusMarked } from './dom.js';
|
||||||
|
import {
|
||||||
|
saveLocal, loadLocal, clearLocal, serializeSave, parseSave, storageAvailable,
|
||||||
|
lastPlayed, rememberPack,
|
||||||
|
} from '../io/save.js';
|
||||||
|
import { logTurn, attachFeedback, readLog, serializeLog, clearLog, feedbackCount } from '../io/feedback-log.js';
|
||||||
|
import { downloadText, readFile, stamp } from '../io/download.js';
|
||||||
|
|
||||||
|
const app = {
|
||||||
|
pack: null,
|
||||||
|
schema: null,
|
||||||
|
state: null,
|
||||||
|
view: null,
|
||||||
|
root: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Reset the per-turn view, reading the current turn out of the game state. */
|
||||||
|
function choosingView() {
|
||||||
|
return {
|
||||||
|
screen: 'turn',
|
||||||
|
phase: 'choosing',
|
||||||
|
turn: currentTurn(app.pack, app.state),
|
||||||
|
feedback: emptyFeedback(),
|
||||||
|
feedbackOpen: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyFeedback = () =>
|
||||||
|
({ rating: null, comment: '', desiredAction: '', saved: false, touched: false, turnId: null });
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const screen = app.view.screen === 'packs'
|
||||||
|
? packsScreen(packs, packsView(), actions)
|
||||||
|
: app.view.screen === 'retrospective'
|
||||||
|
? retrospectiveScreen(app.pack, app.state, app.schema, actions)
|
||||||
|
: turnScreen(app.pack, app.state, app.schema, app.view, actions);
|
||||||
|
fill(app.root, screen);
|
||||||
|
// After the screen is in the document, put the caret where the player most
|
||||||
|
// likely wants it. This is what makes the game playable on Enter alone.
|
||||||
|
focusMarked(app.root);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The picker's view of the world: which pack is current, and what is saved in
|
||||||
|
* each of the others. Reading every slot is cheap and means the screen can say
|
||||||
|
* "day 41, Dispatch Coordinator" rather than just "saved".
|
||||||
|
*/
|
||||||
|
function packsView() {
|
||||||
|
const runs = {};
|
||||||
|
for (const candidate of packs) {
|
||||||
|
const saved = candidate.id === app.pack?.id
|
||||||
|
? app.state
|
||||||
|
: loadLocal(candidate.id);
|
||||||
|
if (saved) runs[candidate.id] = { turn: saved.turn, stage: saved.stage };
|
||||||
|
}
|
||||||
|
return { currentPackId: app.pack?.id ?? null, runs, canGoBack: app.state !== null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions = {
|
||||||
|
onChoose(optionId) {
|
||||||
|
const option = findOptionById(app.view.turn.event, optionId);
|
||||||
|
const { state, record } = takeTurn(app.pack, app.state, optionId);
|
||||||
|
app.state = state;
|
||||||
|
saveLocal(app.pack.id, state);
|
||||||
|
|
||||||
|
const entry = logTurn(record, state, app.pack);
|
||||||
|
app.view = {
|
||||||
|
screen: 'turn',
|
||||||
|
phase: 'outcome',
|
||||||
|
turn: app.view.turn,
|
||||||
|
record,
|
||||||
|
option,
|
||||||
|
feedback: { ...emptyFeedback(), turnId: entry?.turn_id ?? null },
|
||||||
|
feedbackOpen: false,
|
||||||
|
};
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
|
||||||
|
onNext() {
|
||||||
|
flushFeedback();
|
||||||
|
app.view = choosingView();
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
|
||||||
|
onRate(rating) {
|
||||||
|
app.view.feedback.rating = app.view.feedback.rating === rating ? null : rating;
|
||||||
|
app.view.feedback.saved = app.view.feedback.rating !== null;
|
||||||
|
app.view.feedback.touched = true;
|
||||||
|
flushFeedback();
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
|
||||||
|
// No render: rebuilding the DOM would move the cursor out of the field.
|
||||||
|
onComment(text) {
|
||||||
|
app.view.feedback.comment = text;
|
||||||
|
app.view.feedback.touched = true;
|
||||||
|
scheduleFlush();
|
||||||
|
},
|
||||||
|
|
||||||
|
onDesiredAction(text) {
|
||||||
|
app.view.feedback.desiredAction = text;
|
||||||
|
app.view.feedback.touched = true;
|
||||||
|
scheduleFlush();
|
||||||
|
},
|
||||||
|
|
||||||
|
onToggleFeedbackDetail() {
|
||||||
|
app.view.feedbackOpen = !app.view.feedbackOpen;
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
|
||||||
|
onRetrospective() {
|
||||||
|
flushFeedback();
|
||||||
|
app.view = { ...app.view, screen: 'retrospective' };
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
|
||||||
|
onResume() {
|
||||||
|
app.view = choosingView();
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
|
||||||
|
onNewGame() {
|
||||||
|
const played = app.state?.history?.length ?? 0;
|
||||||
|
if (played > 0 && !confirm(
|
||||||
|
`This ends the current run at ${app.pack.turnUnit} ${app.state.turn}. ` +
|
||||||
|
'Export a save first if you want to keep it. Start a new game?',
|
||||||
|
)) return;
|
||||||
|
flushFeedback();
|
||||||
|
clearLocal(app.pack.id);
|
||||||
|
beginRun(app.pack);
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Open the setting picker. */
|
||||||
|
onPacks() {
|
||||||
|
flushFeedback();
|
||||||
|
app.view = { ...(app.view ?? {}), screen: 'packs' };
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch settings. The run you are leaving has already been written to its
|
||||||
|
* own slot every turn, so this is not destructive in either direction — and
|
||||||
|
* coming back to a pack resumes it rather than restarting it.
|
||||||
|
*/
|
||||||
|
onChoosePack(packId) {
|
||||||
|
const chosen = packById(packId);
|
||||||
|
if (!chosen) return;
|
||||||
|
flushFeedback();
|
||||||
|
if (chosen.id === app.pack?.id) {
|
||||||
|
app.view = choosingView();
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!enterPack(chosen)) return;
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
|
||||||
|
onExportSave() {
|
||||||
|
downloadText(`theladder-save-${stamp()}.json`, serializeSave(app.state));
|
||||||
|
},
|
||||||
|
|
||||||
|
onImportSave() {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.accept = 'application/json,.json';
|
||||||
|
input.addEventListener('change', async () => {
|
||||||
|
const file = input.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
// Same repair as a local load: an exported file is exactly as old as
|
||||||
|
// the run it came from.
|
||||||
|
const imported = parseSave(await readFile(file), app.pack.id);
|
||||||
|
const { state } = hydrate(app.pack, imported, app.schema);
|
||||||
|
app.state = state;
|
||||||
|
saveLocal(app.pack.id, state);
|
||||||
|
app.view = choosingView();
|
||||||
|
render();
|
||||||
|
} catch (error) {
|
||||||
|
alert(`That save could not be loaded.\n\n${error.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
input.click();
|
||||||
|
},
|
||||||
|
|
||||||
|
onExportFeedback() {
|
||||||
|
flushFeedback();
|
||||||
|
const entries = readLog();
|
||||||
|
if (entries.length === 0) {
|
||||||
|
alert('Nothing logged yet — play a turn or two first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
downloadText(`theladder-feedback-${stamp()}.json`, serializeLog(entries));
|
||||||
|
const withFeedback = feedbackCount(entries);
|
||||||
|
if (confirm(
|
||||||
|
`Exported ${entries.length} logged turns (${withFeedback} carry feedback).\n\n` +
|
||||||
|
'Clear the log now that it is saved?',
|
||||||
|
)) clearLog();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Text fields write through on a delay so a long comment is not one storage
|
||||||
|
// write per keystroke, and always flush before anything that could lose it.
|
||||||
|
let flushTimer = null;
|
||||||
|
function scheduleFlush() {
|
||||||
|
clearTimeout(flushTimer);
|
||||||
|
flushTimer = setTimeout(flushFeedback, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushFeedback() {
|
||||||
|
clearTimeout(flushTimer);
|
||||||
|
const feedback = app.view?.feedback;
|
||||||
|
if (!feedback?.turnId) return;
|
||||||
|
// `touched` rather than "has content": a player who clears a rating or
|
||||||
|
// deletes a comment is withdrawing it, and that has to be written through
|
||||||
|
// too. Turns the player simply ignored are never written at all.
|
||||||
|
if (!feedback.touched) return;
|
||||||
|
attachFeedback(feedback.turnId, {
|
||||||
|
rating: feedback.rating,
|
||||||
|
comment: feedback.comment,
|
||||||
|
desiredAction: feedback.desiredAction,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make `pack` the current setting: validate it, adopt its schema, and either
|
||||||
|
* resume its saved run or start one. Returns false when the pack will not
|
||||||
|
* load, having already put the error on screen.
|
||||||
|
*/
|
||||||
|
function enterPack(pack) {
|
||||||
|
const validation = validatePack(pack);
|
||||||
|
if (!validation.valid) {
|
||||||
|
fill(app.root, errorScreen(pack, validation.errors));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (const warning of validation.warnings) console.warn(`[content ${pack.id}] ${warning}`);
|
||||||
|
|
||||||
|
app.pack = pack;
|
||||||
|
app.schema = resolveSchema(pack);
|
||||||
|
rememberPack(pack.id);
|
||||||
|
|
||||||
|
const restored = loadLocal(pack.id);
|
||||||
|
// The packId check is belt and braces now that slots are per pack, but an
|
||||||
|
// imported or hand-edited save can still carry the wrong name.
|
||||||
|
if (restored && restored.meta?.packId === pack.id) {
|
||||||
|
// A career saved before the pack gained a stat has no path for it, and
|
||||||
|
// every numeric comparison against `undefined` is false — so the events,
|
||||||
|
// options and upkeep gated on a new stat would quietly not exist for a
|
||||||
|
// player already mid-run. Fill them in, and write the repair back.
|
||||||
|
const repaired = hydrate(pack, restored, app.schema);
|
||||||
|
if (repaired.added.length > 0) {
|
||||||
|
console.info(`[save ${pack.id}] filled in new state: ${repaired.added.join(', ')}`);
|
||||||
|
saveLocal(pack.id, repaired.state);
|
||||||
|
}
|
||||||
|
app.state = repaired.state;
|
||||||
|
app.view = choosingView();
|
||||||
|
} else {
|
||||||
|
beginRun(pack);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fresh run in `pack`, written to that pack's slot. */
|
||||||
|
function beginRun(pack) {
|
||||||
|
app.state = startGame(pack, {
|
||||||
|
seed: String(Date.now()),
|
||||||
|
schema: app.schema,
|
||||||
|
now: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
saveLocal(pack.id, app.state);
|
||||||
|
app.view = choosingView();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function boot(rootElement) {
|
||||||
|
app.root = rootElement;
|
||||||
|
|
||||||
|
if (!storageAvailable()) {
|
||||||
|
console.warn('[save] local storage is unavailable; progress will not persist. Use Export save.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Land a returning player back where they were. A first-time player with
|
||||||
|
// more than one setting to choose from gets the picker as the front door
|
||||||
|
// rather than being dropped into whichever pack happens to be first.
|
||||||
|
const remembered = packById(lastPlayed() ?? '');
|
||||||
|
const anySaved = packs.some((pack) => loadLocal(pack.id) !== null);
|
||||||
|
|
||||||
|
if (!remembered && !anySaved && packs.length > 1) {
|
||||||
|
app.view = { screen: 'packs' };
|
||||||
|
render();
|
||||||
|
} else if (!enterPack(remembered ?? defaultPack)) {
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anything half-typed survives a tab close.
|
||||||
|
window.addEventListener('beforeunload', flushFeedback);
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorScreen(pack, errors) {
|
||||||
|
const list = document.createElement('ul');
|
||||||
|
for (const error of errors) {
|
||||||
|
const item = document.createElement('li');
|
||||||
|
item.textContent = error;
|
||||||
|
list.append(item);
|
||||||
|
}
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.className = 'screen screen--error';
|
||||||
|
const heading = document.createElement('h1');
|
||||||
|
heading.textContent = `"${pack.name ?? pack.id}" will not load.`;
|
||||||
|
wrapper.append(heading, list);
|
||||||
|
return wrapper;
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
// Pieces shared between the turn screen and the retrospective.
|
||||||
|
|
||||||
|
import { h } from './dom.js';
|
||||||
|
import { formatValue, labelFor, headerStats } from './format.js';
|
||||||
|
import { APP, versionLine } from '../version.js';
|
||||||
|
import { getPath } from '../engine/paths.js';
|
||||||
|
import { fillText } from '../engine/text.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What you are playing, which pack, and who you currently are — plus the build,
|
||||||
|
* so a playtester can say which version they were on.
|
||||||
|
*/
|
||||||
|
export function statusBar(pack, state) {
|
||||||
|
const title = pack.stages?.[state.stage]?.title ?? state.stage;
|
||||||
|
return h('div', { class: 'statusbar' },
|
||||||
|
h('div', { class: 'statusbar__what' },
|
||||||
|
h('span', { class: 'statusbar__game' }, APP.name),
|
||||||
|
h('span', { class: 'statusbar__sep' }, '·'),
|
||||||
|
h('span', { class: 'statusbar__pack' }, pack.name ?? pack.id),
|
||||||
|
h('span', { class: 'statusbar__sep' }, '·'),
|
||||||
|
h('span', { class: 'statusbar__title' }, title),
|
||||||
|
),
|
||||||
|
h('span', { class: 'statusbar__version', title: 'app version · build' }, versionLine()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A labelled bar for a bounded stat. */
|
||||||
|
export function meter(label, value, bounds, { compact = false } = {}) {
|
||||||
|
const min = bounds?.min ?? 0;
|
||||||
|
const max = bounds?.max ?? 100;
|
||||||
|
const fraction = max === min ? 0 : Math.max(0, Math.min(1, (value - min) / (max - min)));
|
||||||
|
return h('div', { class: compact ? 'meter meter--compact' : 'meter' },
|
||||||
|
h('div', { class: 'meter__head' },
|
||||||
|
h('span', { class: 'meter__label' }, label),
|
||||||
|
h('span', { class: 'meter__value' }, String(value)),
|
||||||
|
),
|
||||||
|
h('div', { class: 'meter__track', role: 'img', 'aria-label': `${label}: ${value} of ${max}` },
|
||||||
|
h('div', { class: 'meter__fill', style: `width: ${(fraction * 100).toFixed(1)}%` }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The stat strip: money and debt as figures, bounded stats as bars. */
|
||||||
|
export function statPanel(pack, state, schema) {
|
||||||
|
return h('div', { class: 'stats' },
|
||||||
|
headerStats(pack, state, schema).map((stat) => (
|
||||||
|
stat.rule.format === 'meter'
|
||||||
|
? meter(stat.label, stat.value, stat.bounds, { compact: true })
|
||||||
|
: h('div', { class: `figure figure--${stat.path.replace(/\./g, '-')}` },
|
||||||
|
h('span', { class: 'figure__label' }, stat.label),
|
||||||
|
h('span', {
|
||||||
|
class: `figure__value${stat.value < 0 ? ' figure__value--negative' : ''}`,
|
||||||
|
}, formatValue(pack, stat.path, stat.value)),
|
||||||
|
)
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One character, with how they currently regard the player. */
|
||||||
|
export function characterCard(pack, state, schema, character) {
|
||||||
|
// `character` is a person as presentPeople() returns them: drawn for this run
|
||||||
|
// or written into the pack, keyed by the slot their relationships live under.
|
||||||
|
return h('article', { class: 'person' },
|
||||||
|
h('header', { class: 'person__head' },
|
||||||
|
h('h3', { class: 'person__name' }, character.name),
|
||||||
|
character.title && h('p', { class: 'person__title' }, character.title),
|
||||||
|
),
|
||||||
|
h('p', { class: 'person__description' }, fillText(pack, state, character.description)),
|
||||||
|
h('div', { class: 'person__meters' },
|
||||||
|
Object.keys(pack.relationshipStats ?? {}).map((stat) => {
|
||||||
|
const path = `relationships.${character.id}.${stat}`;
|
||||||
|
return meter(
|
||||||
|
pack.relationshipStats[stat].label ?? stat,
|
||||||
|
getPath(state, path),
|
||||||
|
schema[path],
|
||||||
|
{ compact: true },
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The change list shown after a choice: what moved, and by how much. */
|
||||||
|
export function changeList(pack, changes, { className = 'changes', state } = {}) {
|
||||||
|
const meaningful = changes.filter((change) => {
|
||||||
|
if (change.from === change.to) return false;
|
||||||
|
// Some state is bookkeeping the player has no use for — `notable` is the
|
||||||
|
// retrospective's own list, and reading "Notable — changed" in the ledger
|
||||||
|
// tells nobody anything.
|
||||||
|
return pack.display?.[change.path]?.inLedger !== false;
|
||||||
|
});
|
||||||
|
if (!meaningful.length) return h('p', { class: 'changes changes--none' }, 'Nothing measurable changed.');
|
||||||
|
|
||||||
|
return h('ul', { class: className },
|
||||||
|
meaningful.map((change) => {
|
||||||
|
// A flag flipping is a fact about the world, not a quantity. Say it as a
|
||||||
|
// sentence the pack supplies rather than as "now true".
|
||||||
|
if (typeof change.to === 'boolean') {
|
||||||
|
const rule = pack.display?.[change.path];
|
||||||
|
const phrase = (change.to ? rule?.whenTrue : rule?.whenFalse)
|
||||||
|
?? `${labelFor(pack, change.path, state)}: ${change.to ? 'yes' : 'no longer'}`;
|
||||||
|
return h('li', { class: `change change--note change--${change.to ? 'up' : 'down'}` },
|
||||||
|
h('span', { class: 'change__phrase' }, phrase),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rising = change.to > change.from;
|
||||||
|
return h('li', { class: `change change--${rising ? 'up' : 'down'}` },
|
||||||
|
// Upkeep changes carry the label of the entry that caused them —
|
||||||
|
// "Rent", "Wages" — which is far more use than the path's own name.
|
||||||
|
h('span', { class: 'change__label' }, change.label ?? labelFor(pack, change.path, state)),
|
||||||
|
h('span', { class: 'change__delta' }, deltaText(pack, change)),
|
||||||
|
// Say where it stopped. "−$4 at its limit" was read by a real player as
|
||||||
|
// a debt of four still standing, when it meant the balance hit zero.
|
||||||
|
change.clamped && h('span', { class: 'change__note' },
|
||||||
|
`stopped at ${formatValue(pack, change.path, change.to)}`),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deltaText(pack, change) {
|
||||||
|
if (typeof change.to !== 'number' || typeof change.from !== 'number') {
|
||||||
|
return typeof change.to === 'boolean' ? (change.to ? 'now true' : 'no longer true') : 'changed';
|
||||||
|
}
|
||||||
|
// A tier moved: "+1" says nothing, the name of the step says everything.
|
||||||
|
const rule = pack.display?.[change.path];
|
||||||
|
if (rule?.format === 'tier') return rule.labels?.[change.to] ?? String(change.to);
|
||||||
|
|
||||||
|
const delta = change.to - change.from;
|
||||||
|
const money = pack.display?.[change.path]?.format === 'money';
|
||||||
|
const magnitude = money
|
||||||
|
? `$${Math.abs(Math.round(delta)).toLocaleString('en-US')}`
|
||||||
|
: String(Math.abs(delta));
|
||||||
|
return `${delta > 0 ? '+' : '−'}${magnitude}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// A very small DOM helper. Not a framework — just enough to build elements
|
||||||
|
// without a wall of createElement calls.
|
||||||
|
|
||||||
|
export function h(tag, props = {}, ...children) {
|
||||||
|
const el = document.createElement(tag);
|
||||||
|
for (const [key, value] of Object.entries(props ?? {})) {
|
||||||
|
if (value === null || value === undefined || value === false) continue;
|
||||||
|
if (key === 'class') el.className = value;
|
||||||
|
else if (key === 'dataset') Object.assign(el.dataset, value);
|
||||||
|
else if (key.startsWith('on') && typeof value === 'function') {
|
||||||
|
el.addEventListener(key.slice(2).toLowerCase(), value);
|
||||||
|
} else if (key in el && key !== 'list') el[key] = value;
|
||||||
|
else el.setAttribute(key, value);
|
||||||
|
}
|
||||||
|
for (const child of children.flat(Infinity)) {
|
||||||
|
if (child === null || child === undefined || child === false) continue;
|
||||||
|
el.append(child instanceof Node ? child : document.createTextNode(String(child)));
|
||||||
|
}
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replace an element's contents. */
|
||||||
|
export function fill(container, ...children) {
|
||||||
|
container.replaceChildren(...children.flat(Infinity).filter((c) => c !== null && c !== undefined && c !== false));
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Focus whatever the screen marked with `dataset.autofocus`.
|
||||||
|
*
|
||||||
|
* Every screen names the one control a player most likely wants next — the
|
||||||
|
* first available option while choosing, "next day" once the result is in — so
|
||||||
|
* a whole run can be played on Enter alone, with Tab and Shift+Tab to pick a
|
||||||
|
* different option. Disabled buttons are skipped by the browser's own tab
|
||||||
|
* order, so locked options are visible without being in the way.
|
||||||
|
*/
|
||||||
|
export function focusMarked(root) {
|
||||||
|
const queue = [root];
|
||||||
|
while (queue.length) {
|
||||||
|
const node = queue.shift();
|
||||||
|
if (node?.dataset?.autofocus) {
|
||||||
|
node.focus?.();
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
queue.push(...(node?.childNodes ?? []));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// The UI's view of the engine, with the pack's schema bound in.
|
||||||
|
//
|
||||||
|
// Re-exported here rather than imported directly so the screens never have to
|
||||||
|
// thread a schema argument through, and so there is one obvious place to look
|
||||||
|
// for everything the interface is allowed to do to a game.
|
||||||
|
|
||||||
|
import { resolveSchema } from '../engine/state.js';
|
||||||
|
import * as game from '../engine/game.js';
|
||||||
|
import { findOption } from '../engine/select.js';
|
||||||
|
|
||||||
|
const schemaCache = new WeakMap();
|
||||||
|
|
||||||
|
function schemaFor(pack) {
|
||||||
|
if (!schemaCache.has(pack)) schemaCache.set(pack, resolveSchema(pack));
|
||||||
|
return schemaCache.get(pack);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startGame(pack, options = {}) {
|
||||||
|
return game.startGame(pack, { schema: schemaFor(pack), ...options });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function currentTurn(pack, state) {
|
||||||
|
return game.currentTurn(pack, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function takeTurn(pack, state, optionId, options = {}) {
|
||||||
|
return game.takeTurn(pack, state, optionId, {
|
||||||
|
schema: schemaFor(pack),
|
||||||
|
now: new Date().toISOString(),
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const findOptionById = findOption;
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
// Turning state paths and conditions into English.
|
||||||
|
//
|
||||||
|
// Display names, number formats and which stats appear in the header all come
|
||||||
|
// from the pack's `display` map, so a new setting renames and re-orders the
|
||||||
|
// interface without touching the UI. Anything the pack does not describe falls
|
||||||
|
// back to a derivation from the path itself, so content is never *required* to
|
||||||
|
// supply a label just to be playable.
|
||||||
|
|
||||||
|
import { getPath } from '../engine/paths.js';
|
||||||
|
import { check } from '../engine/conditions.js';
|
||||||
|
import { personIn } from '../engine/cast.js';
|
||||||
|
|
||||||
|
const titleCase = (text) =>
|
||||||
|
text.replace(/_/g, ' ').replace(/^\w/, (c) => c.toUpperCase());
|
||||||
|
|
||||||
|
/** The pack's display rules for a path, if it has any. */
|
||||||
|
export function displayFor(pack, path) {
|
||||||
|
return pack.display?.[path] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A human name for a path, derived from the path when the pack is silent.
|
||||||
|
* Given the run's state, a relationship path names whoever is in that slot this
|
||||||
|
* run — a drawn boss, the second of three co-workers — not the role.
|
||||||
|
*/
|
||||||
|
export function labelFor(pack, path, state) {
|
||||||
|
const declared = displayFor(pack, path)?.label;
|
||||||
|
if (declared) return declared;
|
||||||
|
|
||||||
|
const segments = path.split('.');
|
||||||
|
if (segments[0] === 'relationships' && segments.length === 3) {
|
||||||
|
const name = (state && personIn(pack, state, segments[1])?.name)
|
||||||
|
?? pack.characters?.find((c) => c.id === segments[1])?.name;
|
||||||
|
const stat = pack.relationshipStats?.[segments[2]]?.label ?? titleCase(segments[2]);
|
||||||
|
return `${name ?? titleCase(segments[1])} — ${stat.toLowerCase()}`;
|
||||||
|
}
|
||||||
|
return titleCase(segments.at(-1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Money is written as money; everything else is a plain number. */
|
||||||
|
export function formatValue(pack, path, value) {
|
||||||
|
if (typeof value === 'boolean') return value ? 'yes' : 'no';
|
||||||
|
const rule = displayFor(pack, path);
|
||||||
|
if (rule?.format === 'money') return formatMoney(value);
|
||||||
|
// A tier is a number the player should never see as one: a pack declares
|
||||||
|
// what each step is called and the interface reads the name off the list.
|
||||||
|
if (rule?.format === 'tier') return rule.labels?.[value] ?? String(value);
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMoney(amount) {
|
||||||
|
const rounded = Math.round(amount);
|
||||||
|
const body = `$${Math.abs(rounded).toLocaleString('en-US')}`;
|
||||||
|
return rounded < 0 ? `−${body}` : body;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A signed change, for the outcome list: "+$45", "−3". */
|
||||||
|
export function formatDelta(pack, path, from, to) {
|
||||||
|
const delta = to - from;
|
||||||
|
if (displayFor(pack, path)?.format === 'money') {
|
||||||
|
return `${delta > 0 ? '+' : '−'}$${Math.abs(Math.round(delta)).toLocaleString('en-US')}`;
|
||||||
|
}
|
||||||
|
return `${delta > 0 ? '+' : '−'}${Math.abs(delta)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One condition as a requirement the player can read: "Negotiation 15+",
|
||||||
|
* "Trevor — likes you 48+", "not in debt".
|
||||||
|
*/
|
||||||
|
export function describeCondition(pack, condition, state) {
|
||||||
|
if (!condition || typeof condition !== 'object') return '';
|
||||||
|
if (Array.isArray(condition)) return condition.map((c) => describeCondition(pack, c, state)).join(', ');
|
||||||
|
if (condition.all) return condition.all.map((c) => describeCondition(pack, c, state)).join(', ');
|
||||||
|
if (condition.any) return condition.any.map((c) => describeCondition(pack, c, state)).join(' or ');
|
||||||
|
if (condition.not) return `not ${describeCondition(pack, condition.not, state)}`;
|
||||||
|
|
||||||
|
const { path, op, value } = condition;
|
||||||
|
const label = labelFor(pack, path, state);
|
||||||
|
|
||||||
|
if (typeof value === 'boolean') return value ? label.toLowerCase() : `no ${label.toLowerCase()}`;
|
||||||
|
|
||||||
|
const amount = formatValue(pack, path, value);
|
||||||
|
switch (op) {
|
||||||
|
case '>=': return `${label} ${amount}+`;
|
||||||
|
case '>': return `${label} over ${amount}`;
|
||||||
|
case '<=': return `${label} ${amount} or less`;
|
||||||
|
case '<': return `${label} under ${amount}`;
|
||||||
|
case '==': return `${label} exactly ${amount}`;
|
||||||
|
case '!=': return `${label} other than ${amount}`;
|
||||||
|
default: return `${label} ${op} ${amount}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a locked option is waiting for. */
|
||||||
|
export function requirementText(pack, failedConditions, state) {
|
||||||
|
const parts = failedConditions.map((c) => describeCondition(pack, c, state)).filter(Boolean);
|
||||||
|
return parts.length ? `Needs ${parts.join(', ')}` : 'Not available yet';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stats the header shows, in the pack's declared order, skipping anything
|
||||||
|
* the pack marks hidden or asks to hide while it is zero.
|
||||||
|
*/
|
||||||
|
export function headerStats(pack, state, schema) {
|
||||||
|
return Object.entries(pack.display ?? {})
|
||||||
|
.filter(([, rule]) => !rule.hidden)
|
||||||
|
.map(([path, rule]) => ({
|
||||||
|
path,
|
||||||
|
rule,
|
||||||
|
label: labelFor(pack, path),
|
||||||
|
value: getPath(state, path),
|
||||||
|
bounds: schema[path],
|
||||||
|
}))
|
||||||
|
.filter((stat) => !(stat.rule.hideWhenZero && !stat.value))
|
||||||
|
.sort((a, b) => (a.rule.order ?? 99) - (b.rule.order ?? 99));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How the current turn is titled. A pack with a calendar gets its day name and
|
||||||
|
* cycle ("Thursday", "Week 2 · Day 12"); one without falls back to the turn
|
||||||
|
* number alone, which is what the engine guarantees.
|
||||||
|
*/
|
||||||
|
export function turnHeading(pack, state) {
|
||||||
|
const unit = capitalize(pack.turnUnit ?? 'turn');
|
||||||
|
const name = state.calendar?.name;
|
||||||
|
if (!name) return { title: `${unit} ${state.turn}`, detail: null };
|
||||||
|
|
||||||
|
const cycleName = pack.calendar?.cycleName ?? 'Cycle';
|
||||||
|
return {
|
||||||
|
title: name,
|
||||||
|
detail: `${cycleName} ${state.calendar.cycle} · ${unit} ${state.turn}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pack's scheduled upkeep, as "what is coming and when". Answers the
|
||||||
|
* question a player actually has — when am I next paid, when is rent due —
|
||||||
|
* without them having to reverse-engineer it from the ledger.
|
||||||
|
*/
|
||||||
|
export function upcomingUpkeep(pack, state, { limit = 3 } = {}) {
|
||||||
|
const upcoming = [];
|
||||||
|
for (const entry of pack.upkeep ?? []) {
|
||||||
|
if (!entry.every || !entry.label) continue;
|
||||||
|
if (!check(state, entry.requires).passed) continue;
|
||||||
|
|
||||||
|
const offset = entry.offset ?? 0;
|
||||||
|
const elapsed = state.turn - 1 - offset;
|
||||||
|
const turnsAway = elapsed < 0 ? -elapsed : (entry.every - (elapsed % entry.every)) % entry.every;
|
||||||
|
const amount = (entry.effects ?? [])
|
||||||
|
.filter((e) => e.op === 'add' && typeof e.value === 'number')
|
||||||
|
.reduce((sum, e) => sum + e.value, 0);
|
||||||
|
|
||||||
|
upcoming.push({ label: entry.label, turnsAway, incoming: amount > 0 });
|
||||||
|
}
|
||||||
|
return upcoming
|
||||||
|
.sort((a, b) => a.turnsAway - b.turnsAway)
|
||||||
|
.filter((entry, index, all) => all.findIndex((e) => e.label === entry.label) === index)
|
||||||
|
.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
const capitalize = (text) => text.charAt(0).toUpperCase() + text.slice(1);
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
// The career retrospective.
|
||||||
|
//
|
||||||
|
// Structurally identical however the run went — this is a summary, not a
|
||||||
|
// scoreboard, and there is no win state to congratulate anyone on. It is also
|
||||||
|
// not an ending: the player can close it and carry on from the same turn.
|
||||||
|
|
||||||
|
import { h } from '../dom.js';
|
||||||
|
import { statPanel, characterCard, statusBar } from '../components.js';
|
||||||
|
import { presentPeople } from '../../engine/cast.js';
|
||||||
|
import { fillText } from '../../engine/text.js';
|
||||||
|
import { formatMoney, labelFor } from '../format.js';
|
||||||
|
import { summarize } from '../summary.js';
|
||||||
|
|
||||||
|
export function retrospectiveScreen(pack, state, schema, actions) {
|
||||||
|
const summary = summarize(pack, state);
|
||||||
|
const unit = pack.turnUnit ?? 'turn';
|
||||||
|
|
||||||
|
return h('div', { class: 'screen screen--retrospective' },
|
||||||
|
statusBar(pack, state),
|
||||||
|
|
||||||
|
h('header', { class: 'retro__head' },
|
||||||
|
h('p', { class: 'retro__eyebrow' }, pack.name),
|
||||||
|
h('h1', { class: 'retro__title' }, 'Career retrospective'),
|
||||||
|
h('p', { class: 'retro__subtitle' }, careerLine(pack, summary, unit)),
|
||||||
|
// Said out loud because it did not read that way to a real player, who
|
||||||
|
// arrived here by keyboard at day 31 and took it for the end of the run.
|
||||||
|
h('p', { class: 'retro__note' },
|
||||||
|
`This is a summary, not an ending — "Keep going" picks up where you left `
|
||||||
|
+ `off, on ${unit} ${state.turn}.`),
|
||||||
|
),
|
||||||
|
|
||||||
|
h('section', { class: 'retro__section' },
|
||||||
|
h('h2', { class: 'retro__heading' }, 'Where you ended up'),
|
||||||
|
statPanel(pack, state, schema),
|
||||||
|
),
|
||||||
|
|
||||||
|
h('section', { class: 'retro__section' },
|
||||||
|
h('h2', { class: 'retro__heading' }, 'How it went'),
|
||||||
|
h('dl', { class: 'facts' },
|
||||||
|
fact(`Highest your ${primaryLabel(pack, summary)} got`,
|
||||||
|
summary.peak === null ? '—' : formatMoney(summary.peak)),
|
||||||
|
fact('Lowest it got', summary.trough === null ? '—' : formatMoney(summary.trough)),
|
||||||
|
summary.daysInTheRed > 0 && fact(`${capitalize(unit)}s in the red`, String(summary.daysInTheRed)),
|
||||||
|
summary.bestDay && fact(`Best ${unit}`,
|
||||||
|
`${formatMoney(summary.bestDay.net)} — ${summary.bestDay.label}`),
|
||||||
|
summary.worstDay && fact(`Worst ${unit}`,
|
||||||
|
`${formatMoney(summary.worstDay.net)} — ${summary.worstDay.label}`),
|
||||||
|
summary.favouriteAction && fact('What you did most',
|
||||||
|
`${summary.favouriteAction.label} (${summary.favouriteAction.count}×)`),
|
||||||
|
fact('Situations you saw', `${summary.eventsSeen} of ${summary.eventsTotal}`),
|
||||||
|
summary.eventsSeen > 0 && fact('Times you saw each, on average',
|
||||||
|
summary.averageTimesSeen.toFixed(1)),
|
||||||
|
summary.mostSeen && summary.mostSeen.count > 1 && fact('Most familiar of them',
|
||||||
|
`came up ${summary.mostSeen.count} times`),
|
||||||
|
summary.lockedEncounters > 0 && fact('Doors you could not open yet',
|
||||||
|
String(summary.lockedEncounters)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
h('section', { class: 'retro__section' },
|
||||||
|
h('h2', { class: 'retro__heading' }, 'Where you stand with people'),
|
||||||
|
h('div', { class: 'retro__people' },
|
||||||
|
presentPeople(pack, state).map((person) => characterCard(pack, state, schema, person)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
h('section', { class: 'retro__section' },
|
||||||
|
h('h2', { class: 'retro__heading' }, 'Notable moments'),
|
||||||
|
summary.notable.length
|
||||||
|
? h('ol', { class: 'moments' }, summary.notable.map((moment) => h('li', { class: 'moment' }, fillText(pack, state, moment))))
|
||||||
|
: h('p', { class: 'moments moments--none' },
|
||||||
|
'Nothing that anybody would write down. That happens.'),
|
||||||
|
),
|
||||||
|
|
||||||
|
h('footer', { class: 'controls' },
|
||||||
|
h('button', {
|
||||||
|
class: 'button button--primary',
|
||||||
|
dataset: { autofocus: 'true' },
|
||||||
|
onClick: actions.onResume,
|
||||||
|
}, 'Keep going'),
|
||||||
|
h('button', { class: 'button button--quiet', onClick: actions.onExportSave }, 'Export save'),
|
||||||
|
h('button', { class: 'button button--quiet', onClick: actions.onExportFeedback },
|
||||||
|
'Export feedback log'),
|
||||||
|
h('button', { class: 'button button--quiet', onClick: actions.onNewGame }, 'Start again'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One sentence for where the career went. A run that changed stage says so —
|
||||||
|
* that is the whole shape of it — and one that did not reads as it always did.
|
||||||
|
*/
|
||||||
|
function careerLine(pack, summary, unit) {
|
||||||
|
const label = (stage) => (pack.stages?.[stage]?.label ?? stage).toLowerCase();
|
||||||
|
const length = `${summary.turns} ${unit}${summary.turns === 1 ? '' : 's'}`;
|
||||||
|
if (summary.stagesVisited.length > 1) {
|
||||||
|
return `${length}, ${summary.stagesVisited.map(label).join(', then ')}.`;
|
||||||
|
}
|
||||||
|
return `${length} in the ${label(summary.stage)}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The pack's own word for its main resource, lowercased for mid-sentence use. */
|
||||||
|
function primaryLabel(pack, summary) {
|
||||||
|
return summary.primaryPath ? labelFor(pack, summary.primaryPath).toLowerCase() : 'balance';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fact(label, value) {
|
||||||
|
return h('div', { class: 'facts__item' },
|
||||||
|
h('dt', { class: 'facts__label' }, label),
|
||||||
|
h('dd', { class: 'facts__value' }, value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const capitalize = (text) => text.charAt(0).toUpperCase() + text.slice(1);
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
// The turn screen, in two phases.
|
||||||
|
//
|
||||||
|
// Choosing: the scenario and its options, with locked ones shown greyed and
|
||||||
|
// labelled with what they are waiting for — a player should be able to see the
|
||||||
|
// door they cannot open yet, because that is most of what makes the numbers
|
||||||
|
// mean anything.
|
||||||
|
//
|
||||||
|
// Outcome: what happened, what moved, and what the day cost regardless of the
|
||||||
|
// choice. The feedback widget lives here rather than in the choosing phase,
|
||||||
|
// because this is the moment a player has an opinion.
|
||||||
|
|
||||||
|
import { h } from '../dom.js';
|
||||||
|
import { statPanel, characterCard, changeList, statusBar } from '../components.js';
|
||||||
|
import { requirementText, turnHeading, upcomingUpkeep } from '../format.js';
|
||||||
|
import { presentPeople } from '../../engine/cast.js';
|
||||||
|
import { fillText } from '../../engine/text.js';
|
||||||
|
|
||||||
|
export function turnScreen(pack, state, schema, view, actions) {
|
||||||
|
const heading = turnHeading(pack, state);
|
||||||
|
|
||||||
|
return h('div', { class: 'screen screen--turn' },
|
||||||
|
statusBar(pack, state),
|
||||||
|
|
||||||
|
h('header', { class: 'hud' },
|
||||||
|
h('div', { class: 'hud__where' },
|
||||||
|
h('p', { class: 'hud__stage' }, pack.stages?.[state.stage]?.title ?? state.stage),
|
||||||
|
h('h1', { class: 'hud__day' }, heading.title),
|
||||||
|
heading.detail && h('p', { class: 'hud__detail' }, heading.detail),
|
||||||
|
),
|
||||||
|
statPanel(pack, state, schema),
|
||||||
|
),
|
||||||
|
|
||||||
|
diary(pack, state),
|
||||||
|
|
||||||
|
h('div', { class: 'layout' },
|
||||||
|
h('main', { class: 'scene' },
|
||||||
|
view.phase === 'outcome'
|
||||||
|
? outcomeView(pack, state, view, actions)
|
||||||
|
: choosingView(pack, state, view, actions),
|
||||||
|
),
|
||||||
|
h('aside', { class: 'people' },
|
||||||
|
h('h2', { class: 'people__heading' }, pack.peopleHeading ?? 'The people'),
|
||||||
|
presentPeople(pack, state).map((person) => characterCard(pack, state, schema, person)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Order here is tab order. "Wrap up my career" used to sit first, directly
|
||||||
|
// after the last option — so a player tabbing to reach an option, going one
|
||||||
|
// past it, and pressing Enter got the retrospective instead of a turn. That
|
||||||
|
// happened in a real session at day 31 and read as the game ending. It goes
|
||||||
|
// last now, behind buttons that do nothing frightening if pressed by
|
||||||
|
// accident, and Import save is next to Export save where it belongs.
|
||||||
|
h('footer', { class: 'controls' },
|
||||||
|
h('button', { class: 'button button--quiet', onClick: actions.onExportSave }, 'Export save'),
|
||||||
|
h('button', { class: 'button button--quiet', onClick: actions.onImportSave }, 'Import save'),
|
||||||
|
h('button', { class: 'button button--quiet', onClick: actions.onExportFeedback },
|
||||||
|
'Export feedback log'),
|
||||||
|
h('button', { class: 'button button--quiet', onClick: actions.onPacks }, 'Change setting'),
|
||||||
|
h('button', { class: 'button button--quiet', onClick: actions.onNewGame }, 'New game'),
|
||||||
|
h('button', { class: 'button', onClick: actions.onRetrospective },
|
||||||
|
'Wrap up my career'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What is coming and when. A player asked what the daily drain was for; the
|
||||||
|
* answer is that most of it is not daily, and this says so before the money
|
||||||
|
* moves rather than after.
|
||||||
|
*/
|
||||||
|
function diary(pack, state) {
|
||||||
|
const upcoming = upcomingUpkeep(pack, state);
|
||||||
|
if (!upcoming.length) return null;
|
||||||
|
|
||||||
|
const unit = pack.turnUnit ?? 'turn';
|
||||||
|
return h('p', { class: 'diary' },
|
||||||
|
upcoming.map((entry, index) => h('span', {
|
||||||
|
class: `diary__entry diary__entry--${entry.incoming ? 'in' : 'out'}`,
|
||||||
|
},
|
||||||
|
index > 0 ? ' · ' : '',
|
||||||
|
entry.label,
|
||||||
|
entry.turnsAway === 0
|
||||||
|
? ' today'
|
||||||
|
: entry.turnsAway === 1 ? ` tomorrow` : ` in ${entry.turnsAway} ${unit}s`,
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function choosingView(pack, state, view, actions) {
|
||||||
|
const { event, options } = view.turn;
|
||||||
|
const text = (t) => fillText(pack, state, t, view.turn.bindings);
|
||||||
|
const visible = options.filter((o) => !o.hidden);
|
||||||
|
// The first option a player can actually take gets the focus, so Enter plays
|
||||||
|
// it and Tab walks to the others.
|
||||||
|
const firstAvailable = visible.find((o) => o.available)?.option.id;
|
||||||
|
|
||||||
|
return h('section', { class: 'scenario' },
|
||||||
|
h('p', { class: 'scenario__text' }, text(event.description)),
|
||||||
|
h('ul', { class: 'options' },
|
||||||
|
visible.map(({ option, available, failed, odds }) => h('li', { class: 'options__item' },
|
||||||
|
h('button', {
|
||||||
|
class: `option${available ? '' : ' option--locked'}`,
|
||||||
|
disabled: !available,
|
||||||
|
dataset: option.id === firstAvailable ? { autofocus: 'true' } : {},
|
||||||
|
onClick: available ? () => actions.onChoose(option.id) : null,
|
||||||
|
},
|
||||||
|
h('span', { class: 'option__label' }, text(option.label)),
|
||||||
|
// Odds are shown in words, never as a number: "a long shot", "likely".
|
||||||
|
available && odds && h('span', { class: 'option__odds' }, odds.words),
|
||||||
|
!available && h('span', { class: 'option__requirement' }, requirementText(pack, failed, state)),
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function outcomeView(pack, state, view, actions) {
|
||||||
|
const { record, option } = view;
|
||||||
|
// The state has already moved on to the next event, so the names in this
|
||||||
|
// outcome come from the bindings the turn was played with, not the new ones.
|
||||||
|
const text = (t) => fillText(pack, state, t, record.bindings ?? {});
|
||||||
|
const branch = record.check ? option[record.check.outcome] : null;
|
||||||
|
const result = branch ? branch.result : option.result;
|
||||||
|
const said = pack.checks?.said ?? { success: 'It came off.', failure: 'It did not come off.' };
|
||||||
|
|
||||||
|
return h('section', { class: 'outcome' },
|
||||||
|
h('p', { class: 'outcome__chose' }, record.optionLabel ?? text(option.label)),
|
||||||
|
record.check && h('p', { class: `outcome__check outcome__check--${record.check.outcome}` },
|
||||||
|
`${capitalise(record.check.odds)} — ${said[record.check.outcome]}`),
|
||||||
|
result && h('p', { class: 'outcome__text' }, text(result)),
|
||||||
|
|
||||||
|
h('div', { class: 'outcome__ledger' },
|
||||||
|
h('h3', { class: 'outcome__heading' }, 'What that changed'),
|
||||||
|
changeList(pack, record.changes, { state }),
|
||||||
|
record.upkeep.length > 0 && h('div', { class: 'outcome__upkeep' },
|
||||||
|
h('h3', { class: 'outcome__heading' }, 'And then the day happened'),
|
||||||
|
changeList(pack, record.upkeep, { state }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
feedbackWidget(view, actions),
|
||||||
|
|
||||||
|
h('button', {
|
||||||
|
class: 'button button--primary button--next',
|
||||||
|
dataset: { autofocus: 'true' },
|
||||||
|
onClick: actions.onNext,
|
||||||
|
}, `Next ${pack.turnUnit ?? 'turn'} →`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entirely optional: ignoring it does nothing at all, which is the point.
|
||||||
|
function feedbackWidget(view, actions) {
|
||||||
|
const { feedback } = view;
|
||||||
|
const ratingButton = (value, label) => h('button', {
|
||||||
|
class: `rating${feedback.rating === value ? ' rating--on' : ''}`,
|
||||||
|
type: 'button',
|
||||||
|
'aria-pressed': String(feedback.rating === value),
|
||||||
|
onClick: () => actions.onRate(value),
|
||||||
|
}, label);
|
||||||
|
|
||||||
|
return h('section', { class: 'feedback' },
|
||||||
|
h('div', { class: 'feedback__row' },
|
||||||
|
h('span', { class: 'feedback__prompt' }, 'How was that?'),
|
||||||
|
h('div', { class: 'feedback__ratings' },
|
||||||
|
ratingButton('up', '👍'),
|
||||||
|
ratingButton('neutral', '😐'),
|
||||||
|
ratingButton('down', '👎'),
|
||||||
|
),
|
||||||
|
h('button', {
|
||||||
|
class: 'feedback__toggle',
|
||||||
|
type: 'button',
|
||||||
|
onClick: actions.onToggleFeedbackDetail,
|
||||||
|
}, view.feedbackOpen ? 'Fewer options' : 'Say more'),
|
||||||
|
),
|
||||||
|
|
||||||
|
view.feedbackOpen && h('div', { class: 'feedback__detail' },
|
||||||
|
h('label', { class: 'field' },
|
||||||
|
h('span', { class: 'field__label' }, 'Anything you want to tell the designer?'),
|
||||||
|
h('textarea', {
|
||||||
|
class: 'field__input',
|
||||||
|
rows: 2,
|
||||||
|
value: feedback.comment ?? '',
|
||||||
|
onInput: (e) => actions.onComment(e.target.value),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
h('label', { class: 'field' },
|
||||||
|
h('span', { class: 'field__label' },
|
||||||
|
"Something you wanted to do that wasn't offered?"),
|
||||||
|
h('textarea', {
|
||||||
|
class: 'field__input',
|
||||||
|
rows: 2,
|
||||||
|
value: feedback.desiredAction ?? '',
|
||||||
|
onInput: (e) => actions.onDesiredAction(e.target.value),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
feedback.saved && h('p', { class: 'feedback__saved' }, 'Noted — thank you.'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const capitalise = (words) => words.charAt(0).toUpperCase() + words.slice(1);
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
/* The Ladder — one stylesheet, no framework.
|
||||||
|
The look is office stationery: manila, rules, stamped figures. Muted enough
|
||||||
|
that seventeen scenarios in a row do not tire the eye. */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--paper: #f2eee4;
|
||||||
|
--card: #fffdf7;
|
||||||
|
--ink: #26231d;
|
||||||
|
--ink-soft: #6b6659;
|
||||||
|
--ink-faint: #938c7c;
|
||||||
|
--rule: #ded6c4;
|
||||||
|
--rule-strong: #c9bfa8;
|
||||||
|
--accent: #7c5a2c;
|
||||||
|
--accent-soft: #efe3cd;
|
||||||
|
--up: #4a6b3d;
|
||||||
|
--down: #a4402f;
|
||||||
|
--focus: #2f6f9e;
|
||||||
|
|
||||||
|
--radius: 3px;
|
||||||
|
--sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
--serif: Georgia, "Iowan Old Style", "Palatino Linotype", Palatino, serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--paper: #1c1a17;
|
||||||
|
--card: #24211d;
|
||||||
|
--ink: #e8e3d8;
|
||||||
|
--ink-soft: #a8a294;
|
||||||
|
--ink-faint: #7d776a;
|
||||||
|
--rule: #38342d;
|
||||||
|
--rule-strong: #4a453b;
|
||||||
|
--accent: #c9a05e;
|
||||||
|
--accent-soft: #3a3227;
|
||||||
|
--up: #85ad72;
|
||||||
|
--down: #d17a68;
|
||||||
|
--focus: #6fa8d4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--paper);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.5;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app { max-width: 1080px; margin: 0 auto; padding: 24px 20px 48px; }
|
||||||
|
|
||||||
|
/* ---------- status bar ---------- */
|
||||||
|
|
||||||
|
.statusbar {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 8px 16px;
|
||||||
|
align-items: baseline; justify-content: space-between;
|
||||||
|
margin-bottom: 18px; padding-bottom: 10px;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
font-size: 12px; letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.statusbar__what { display: flex; flex-wrap: wrap; gap: 8px; align-items: baseline; }
|
||||||
|
.statusbar__game { font-weight: 600; color: var(--ink); }
|
||||||
|
.statusbar__sep { color: var(--rule-strong); }
|
||||||
|
.statusbar__pack { color: var(--ink-soft); }
|
||||||
|
.statusbar__title { color: var(--accent); }
|
||||||
|
.statusbar__version {
|
||||||
|
font-variant-numeric: tabular-nums; color: var(--ink-faint); font-size: 11px;
|
||||||
|
}
|
||||||
|
.booting { color: var(--ink-faint); padding: 48px 20px; text-align: center; }
|
||||||
|
|
||||||
|
/* ---------- head-up display ---------- */
|
||||||
|
|
||||||
|
.hud {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 20px 32px;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
border-bottom: 2px solid var(--rule-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hud__stage {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hud__day { margin: 2px 0 0; font-size: 26px; font-weight: 600; letter-spacing: -0.01em; }
|
||||||
|
.hud__detail { margin: 1px 0 0; font-size: 13px; color: var(--ink-soft); }
|
||||||
|
|
||||||
|
.diary {
|
||||||
|
margin: 14px 0 0; padding: 8px 12px;
|
||||||
|
font-size: 12.5px; color: var(--ink-soft);
|
||||||
|
background: var(--card); border: 1px solid var(--rule); border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
.diary__entry--in { color: var(--up); }
|
||||||
|
.diary__entry--out { color: var(--ink-soft); }
|
||||||
|
|
||||||
|
.stats { display: flex; flex-wrap: wrap; gap: 10px 26px; align-items: flex-end; }
|
||||||
|
|
||||||
|
.figure { display: flex; flex-direction: column; gap: 1px; min-width: 92px; }
|
||||||
|
.figure__label {
|
||||||
|
font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
.figure__value { font-family: var(--serif); font-size: 21px; font-variant-numeric: tabular-nums; }
|
||||||
|
.figure__value--negative { color: var(--down); }
|
||||||
|
|
||||||
|
/* ---------- meters ---------- */
|
||||||
|
|
||||||
|
.meter { min-width: 150px; }
|
||||||
|
.meter--compact { min-width: 132px; }
|
||||||
|
.meter__head {
|
||||||
|
display: flex; justify-content: space-between; gap: 10px; align-items: baseline;
|
||||||
|
font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
.meter__value { font-variant-numeric: tabular-nums; color: var(--ink-soft); }
|
||||||
|
.meter__track {
|
||||||
|
margin-top: 4px; height: 5px; background: var(--rule); border-radius: 99px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.meter__fill {
|
||||||
|
height: 100%; background: var(--accent); border-radius: 99px;
|
||||||
|
transition: width 280ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- layout ---------- */
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 280px;
|
||||||
|
gap: 36px;
|
||||||
|
margin-top: 28px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.layout { grid-template-columns: minmax(0, 1fr); gap: 28px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- the scenario ---------- */
|
||||||
|
|
||||||
|
.scenario__text, .outcome__text {
|
||||||
|
font-family: var(--serif);
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1.55;
|
||||||
|
margin: 0 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.options { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
|
||||||
|
|
||||||
|
.option {
|
||||||
|
display: flex; flex-direction: column; gap: 3px; width: 100%;
|
||||||
|
padding: 14px 16px; text-align: left; cursor: pointer;
|
||||||
|
background: var(--card); color: inherit; font: inherit;
|
||||||
|
border: 1px solid var(--rule-strong); border-radius: var(--radius);
|
||||||
|
transition: border-color 120ms ease, transform 120ms ease, background 120ms ease;
|
||||||
|
}
|
||||||
|
.option:hover:not(:disabled) { border-color: var(--accent); transform: translateX(2px); }
|
||||||
|
.option:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
|
||||||
|
.option__label { font-size: 16px; }
|
||||||
|
|
||||||
|
.option--locked {
|
||||||
|
cursor: not-allowed; background: transparent; border-style: dashed;
|
||||||
|
border-color: var(--rule); color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
.option__requirement {
|
||||||
|
font-size: 12px; letter-spacing: 0.04em; color: var(--ink-faint); font-style: italic;
|
||||||
|
}
|
||||||
|
/* The odds, in words: "a long shot", "likely". Never a number. */
|
||||||
|
.option__odds {
|
||||||
|
font-size: 12px; letter-spacing: 0.04em; color: var(--accent); font-style: italic;
|
||||||
|
}
|
||||||
|
.outcome__check { margin: 0 0 8px; font-size: 14px; font-style: italic; }
|
||||||
|
.outcome__check--success { color: var(--up); }
|
||||||
|
.outcome__check--failure { color: var(--down); }
|
||||||
|
|
||||||
|
/* ---------- outcome ---------- */
|
||||||
|
|
||||||
|
.outcome__chose {
|
||||||
|
margin: 0 0 10px; font-size: 12px; letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase; color: var(--accent);
|
||||||
|
}
|
||||||
|
.outcome__ledger {
|
||||||
|
margin: 26px 0; padding: 18px 20px;
|
||||||
|
background: var(--card); border: 1px solid var(--rule); border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
.outcome__heading {
|
||||||
|
margin: 0 0 10px; font-size: 11px; letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase; color: var(--ink-faint); font-weight: 600;
|
||||||
|
}
|
||||||
|
.outcome__upkeep { margin-top: 18px; padding-top: 16px; border-top: 1px dashed var(--rule); }
|
||||||
|
|
||||||
|
.changes { list-style: none; margin: 0; padding: 0; display: grid; gap: 5px; }
|
||||||
|
.changes--none { color: var(--ink-faint); font-style: italic; margin: 0; }
|
||||||
|
.change { display: flex; gap: 10px; align-items: baseline; font-size: 14px; }
|
||||||
|
.change__label { color: var(--ink-soft); }
|
||||||
|
.change__delta { font-variant-numeric: tabular-nums; font-weight: 600; margin-left: auto; }
|
||||||
|
.change--up .change__delta { color: var(--up); }
|
||||||
|
.change--down .change__delta { color: var(--down); }
|
||||||
|
.change__note { font-size: 11px; color: var(--ink-faint); font-style: italic; }
|
||||||
|
.change--note .change__phrase { font-style: italic; }
|
||||||
|
.change--note.change--up .change__phrase { color: var(--up); }
|
||||||
|
.change--note.change--down .change__phrase { color: var(--ink-soft); }
|
||||||
|
|
||||||
|
.button--next { margin-top: 8px; }
|
||||||
|
|
||||||
|
/* ---------- people ---------- */
|
||||||
|
|
||||||
|
.people__heading, .retro__heading {
|
||||||
|
margin: 0 0 12px; font-size: 11px; letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase; color: var(--ink-faint); font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.person {
|
||||||
|
padding: 16px; margin-bottom: 14px;
|
||||||
|
background: var(--card); border: 1px solid var(--rule); border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
.person__name { margin: 0; font-size: 16px; font-weight: 600; }
|
||||||
|
.person__title {
|
||||||
|
margin: 1px 0 0; font-size: 11px; letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase; color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
.person__description {
|
||||||
|
margin: 10px 0 14px; font-size: 13px; line-height: 1.5; color: var(--ink-soft);
|
||||||
|
font-family: var(--serif);
|
||||||
|
}
|
||||||
|
.person__meters { display: grid; gap: 8px; }
|
||||||
|
|
||||||
|
/* ---------- feedback ---------- */
|
||||||
|
|
||||||
|
.feedback {
|
||||||
|
margin: 24px 0; padding: 14px 16px;
|
||||||
|
border: 1px dashed var(--rule-strong); border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
.feedback__row { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }
|
||||||
|
.feedback__prompt { font-size: 13px; color: var(--ink-soft); }
|
||||||
|
.feedback__ratings { display: flex; gap: 6px; }
|
||||||
|
|
||||||
|
.rating {
|
||||||
|
padding: 4px 10px; font-size: 16px; line-height: 1.2; cursor: pointer;
|
||||||
|
background: transparent; border: 1px solid var(--rule); border-radius: var(--radius);
|
||||||
|
filter: grayscale(1); opacity: 0.65; transition: all 120ms ease;
|
||||||
|
}
|
||||||
|
.rating:hover { opacity: 1; filter: none; }
|
||||||
|
.rating--on { filter: none; opacity: 1; border-color: var(--accent); background: var(--accent-soft); }
|
||||||
|
.rating:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
|
||||||
|
|
||||||
|
.feedback__toggle {
|
||||||
|
margin-left: auto; padding: 0; font: inherit; font-size: 12px; cursor: pointer;
|
||||||
|
background: none; border: none; color: var(--ink-faint); text-decoration: underline;
|
||||||
|
}
|
||||||
|
.feedback__detail { display: grid; gap: 12px; margin-top: 14px; }
|
||||||
|
.field { display: grid; gap: 4px; }
|
||||||
|
.field__label { font-size: 12px; color: var(--ink-faint); }
|
||||||
|
.field__input {
|
||||||
|
width: 100%; padding: 8px 10px; font: inherit; font-size: 14px; resize: vertical;
|
||||||
|
background: var(--card); color: inherit;
|
||||||
|
border: 1px solid var(--rule); border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
.field__input:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
|
||||||
|
.feedback__saved { margin: 10px 0 0; font-size: 12px; color: var(--up); }
|
||||||
|
|
||||||
|
/* ---------- buttons and controls ---------- */
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
|
||||||
|
margin-top: 36px; padding-top: 18px; border-top: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
padding: 9px 15px; font: inherit; font-size: 14px; cursor: pointer;
|
||||||
|
background: var(--card); color: inherit;
|
||||||
|
border: 1px solid var(--rule-strong); border-radius: var(--radius);
|
||||||
|
transition: border-color 120ms ease, background 120ms ease;
|
||||||
|
}
|
||||||
|
.button:hover { border-color: var(--accent); }
|
||||||
|
.button:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
|
||||||
|
.button--primary { background: var(--accent); border-color: var(--accent); color: #fffdf7; }
|
||||||
|
.button--primary:hover { filter: brightness(1.08); }
|
||||||
|
.button--quiet { border-color: transparent; background: transparent; color: var(--ink-faint); }
|
||||||
|
.button--quiet:hover { color: var(--ink); border-color: var(--rule); }
|
||||||
|
|
||||||
|
/* ---------- retrospective ---------- */
|
||||||
|
|
||||||
|
.retro__head { padding-bottom: 20px; border-bottom: 2px solid var(--rule-strong); }
|
||||||
|
.retro__eyebrow {
|
||||||
|
margin: 0; font-size: 11px; letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase; color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
.retro__title { margin: 6px 0 4px; font-size: 32px; font-weight: 600; letter-spacing: -0.02em; }
|
||||||
|
.retro__subtitle { margin: 0; color: var(--ink-soft); font-family: var(--serif); font-size: 17px; }
|
||||||
|
.retro__note { margin: 8px 0 0; color: var(--ink-soft); font-size: 13px; }
|
||||||
|
|
||||||
|
.retro__section { margin-top: 34px; }
|
||||||
|
.retro__people { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px; }
|
||||||
|
.retro__people .person { margin-bottom: 0; }
|
||||||
|
|
||||||
|
.facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 14px; margin: 0; }
|
||||||
|
.facts__item {
|
||||||
|
padding: 12px 14px; background: var(--card);
|
||||||
|
border: 1px solid var(--rule); border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
.facts__label {
|
||||||
|
font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
.facts__value { margin: 3px 0 0; font-family: var(--serif); font-size: 16px; }
|
||||||
|
|
||||||
|
.moments { margin: 0; padding-left: 22px; display: grid; gap: 7px; }
|
||||||
|
.moment { font-family: var(--serif); font-size: 16px; }
|
||||||
|
.moments--none { padding: 0; color: var(--ink-faint); font-style: italic; }
|
||||||
|
|
||||||
|
.screen--error { padding: 40px 0; }
|
||||||
|
.screen--error h1 { font-size: 20px; color: var(--down); }
|
||||||
|
|
||||||
|
/* The setting picker. One card per content pack; the pack you are in is
|
||||||
|
marked rather than hidden, because "keep playing" is the common case. */
|
||||||
|
.screen--packs { padding: 40px 0; }
|
||||||
|
.packs__eyebrow {
|
||||||
|
margin: 0; font-size: 11px; letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase; color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
.packs__title { margin: 6px 0 6px; font-size: 32px; font-weight: 600; letter-spacing: -0.02em; }
|
||||||
|
.packs__subtitle {
|
||||||
|
margin: 0 0 28px; max-width: 52ch;
|
||||||
|
color: var(--ink-soft); font-family: var(--serif); font-size: 17px;
|
||||||
|
}
|
||||||
|
.packs__list {
|
||||||
|
list-style: none; margin: 0; padding: 0;
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px;
|
||||||
|
}
|
||||||
|
.packs__item {
|
||||||
|
padding: 18px 18px 16px; background: var(--card);
|
||||||
|
border: 1px solid var(--rule); border-radius: var(--radius);
|
||||||
|
display: flex; flex-direction: column; gap: 8px;
|
||||||
|
}
|
||||||
|
.packs__item--current { border-color: var(--accent); }
|
||||||
|
.packs__name { margin: 0; font-size: 19px; font-weight: 600; letter-spacing: -0.01em; }
|
||||||
|
.packs__blurb { margin: 0; color: var(--ink-soft); font-family: var(--serif); font-size: 16px; }
|
||||||
|
.packs__state {
|
||||||
|
margin: 0 0 4px; font-size: 12px; letter-spacing: 0.04em; color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
.packs__item .button { margin-top: auto; align-self: flex-start; }
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// Reading a career back out of its history.
|
||||||
|
//
|
||||||
|
// Everything here is derived generically from the turn record and the pack's
|
||||||
|
// display map — no path is named. A pack with different stats, or none of them
|
||||||
|
// called money, gets the same retrospective for free.
|
||||||
|
|
||||||
|
/** Paths the pack formats as money, in display order. */
|
||||||
|
function moneyPaths(pack) {
|
||||||
|
return Object.entries(pack.display ?? {})
|
||||||
|
.filter(([, rule]) => rule.format === 'money')
|
||||||
|
.sort((a, b) => (a[1].order ?? 99) - (b[1].order ?? 99))
|
||||||
|
.map(([path]) => path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every value a path passed through, turn by turn. */
|
||||||
|
function trace(history, path) {
|
||||||
|
const values = [];
|
||||||
|
for (const record of history) {
|
||||||
|
for (const change of [...record.changes, ...record.upkeep]) {
|
||||||
|
if (change.path === path && typeof change.to === 'number') values.push(change.to);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarize(pack, state) {
|
||||||
|
const history = state.history ?? [];
|
||||||
|
const primary = moneyPaths(pack)[0] ?? null;
|
||||||
|
const primaryTrace = primary ? trace(history, primary) : [];
|
||||||
|
|
||||||
|
const byLabel = new Map();
|
||||||
|
for (const record of history) {
|
||||||
|
byLabel.set(record.optionLabel, (byLabel.get(record.optionLabel) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
const favourite = [...byLabel.entries()].sort((a, b) => b[1] - a[1])[0] ?? null;
|
||||||
|
|
||||||
|
const netByTurn = history.map((record) => ({
|
||||||
|
turn: record.turn,
|
||||||
|
label: record.optionLabel,
|
||||||
|
net: [...record.changes, ...record.upkeep]
|
||||||
|
.filter((c) => c.path === primary && typeof c.to === 'number')
|
||||||
|
.reduce((sum, c) => sum + (c.to - c.from), 0),
|
||||||
|
}));
|
||||||
|
const ranked = [...netByTurn].sort((a, b) => b.net - a.net);
|
||||||
|
|
||||||
|
const lockedEncounters = history.reduce(
|
||||||
|
(sum, record) => sum + record.offered.filter((o) => !o.available).length, 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
// How much of the run was new ground and how much was familiar. The ratio of
|
||||||
|
// turns to distinct situations is the honest measure of whether a pack has
|
||||||
|
// enough content for the length of run it invites.
|
||||||
|
const eventCounts = new Map();
|
||||||
|
for (const record of history) {
|
||||||
|
eventCounts.set(record.eventId, (eventCounts.get(record.eventId) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
const counts = [...eventCounts.values()];
|
||||||
|
const mostSeen = [...eventCounts.entries()].sort((a, b) => b[1] - a[1])[0] ?? null;
|
||||||
|
|
||||||
|
const stagesVisited = [];
|
||||||
|
for (const record of history) {
|
||||||
|
if (stagesVisited.at(-1) !== record.stage) stagesVisited.push(record.stage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
turns: history.length,
|
||||||
|
stage: state.stage,
|
||||||
|
notable: state.notable ?? [],
|
||||||
|
primaryPath: primary,
|
||||||
|
peak: primaryTrace.length ? Math.max(...primaryTrace) : null,
|
||||||
|
trough: primaryTrace.length ? Math.min(...primaryTrace) : null,
|
||||||
|
daysInTheRed: primaryTrace.filter((value) => value < 0).length,
|
||||||
|
favouriteAction: favourite ? { label: favourite[0], count: favourite[1] } : null,
|
||||||
|
bestDay: ranked[0]?.net > 0 ? ranked[0] : null,
|
||||||
|
worstDay: ranked.at(-1)?.net < 0 ? ranked.at(-1) : null,
|
||||||
|
lockedEncounters,
|
||||||
|
eventsSeen: eventCounts.size,
|
||||||
|
eventsTotal: (pack.events ?? []).length,
|
||||||
|
eventCounts,
|
||||||
|
repeatedSituations: counts.filter((n) => n > 1).length,
|
||||||
|
averageTimesSeen: eventCounts.size ? history.length / eventCounts.size : 0,
|
||||||
|
mostSeen: mostSeen ? { eventId: mostSeen[0], count: mostSeen[1] } : null,
|
||||||
|
stagesVisited,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// What is running. Shown on the main screen so a playtester can say which
|
||||||
|
// build they were on, and so it is obvious when new work has landed.
|
||||||
|
|
||||||
|
import { BUILD } from './build-info.js';
|
||||||
|
|
||||||
|
export const APP = {
|
||||||
|
name: 'The Ladder',
|
||||||
|
// Kept in step with package.json by a test, because it silently was not: the
|
||||||
|
// status bar read 0.3.0 through the 0.3.1 and 0.3.2 work, which is exactly
|
||||||
|
// the thing a playtester is told to check to know what they are playing.
|
||||||
|
version: '0.4.0',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** "0.3.0 · a1b2c3d" — or "0.3.0 · dev" when running from source unstamped. */
|
||||||
|
export function versionLine() {
|
||||||
|
return `${APP.version} · ${BUILD.commit}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { BUILD };
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// The single-file build.
|
||||||
|
//
|
||||||
|
// A build that produces a file proves nothing; this one runs the bundled
|
||||||
|
// script against the fake DOM and plays a turn through it. That is the closest
|
||||||
|
// thing to opening dist/theladder.html that is available without a browser —
|
||||||
|
// it catches a broken bundle, which is otherwise only discovered by whoever
|
||||||
|
// was handed the file.
|
||||||
|
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { installFakeDom, byClass, firstByClass } from './fixtures/fake-dom.js';
|
||||||
|
|
||||||
|
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
|
||||||
|
test('the version the app reports is the version in package.json', async () => {
|
||||||
|
// It silently was not. The status bar read 0.3.0 through the whole of the
|
||||||
|
// 0.3.1 and 0.3.2 work, while playtesters were being told to check it to know
|
||||||
|
// which build they were on — a version string that lies is worse than none,
|
||||||
|
// because it is trusted.
|
||||||
|
const { APP } = await import('../src/version.js');
|
||||||
|
const pkg = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8'));
|
||||||
|
assert.equal(APP.version, pkg.version,
|
||||||
|
'src/version.js and package.json disagree about what this is');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the bundle builds, and the bundled game boots and plays', () => {
|
||||||
|
execFileSync('node', ['tools/build.js'], { cwd: root, stdio: 'pipe' });
|
||||||
|
const html = readFileSync(resolve(root, 'dist/theladder.html'), 'utf8');
|
||||||
|
|
||||||
|
assert.doesNotMatch(html, /<link\s+rel="stylesheet"/, 'the stylesheet is inlined');
|
||||||
|
assert.doesNotMatch(html, /type="module"/, 'no module scripts survive; file:// would block them');
|
||||||
|
assert.doesNotMatch(html, /^[ \t]*import\s/m, 'no bare import statements remain');
|
||||||
|
assert.match(html, /--paper:/, 'the CSS made it in');
|
||||||
|
// A distributed file has to be able to say which commit made it.
|
||||||
|
assert.match(html, /commit: '[^']+'/, 'the build stamp is present');
|
||||||
|
assert.doesNotMatch(html, /commit: 'dev'/, 'a built file is never stamped "dev"');
|
||||||
|
|
||||||
|
const script = html.match(/<script>([\s\S]*?)<\/script>/)?.[1];
|
||||||
|
assert.ok(script, 'the bundled script is present');
|
||||||
|
|
||||||
|
const dom = installFakeDom();
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line no-new-func -- running the build output is the point
|
||||||
|
new Function(script)();
|
||||||
|
|
||||||
|
// Nothing is saved and nothing is remembered, so a build shipping more
|
||||||
|
// than one setting opens on the picker. Click into the first one and carry
|
||||||
|
// on — this exercises the front door in the real bundle, which is the one
|
||||||
|
// place the pack registry, the picker screen and the save slots all meet.
|
||||||
|
assert.ok(firstByClass(dom.root, 'screen--packs'), 'the bundled game rendered the picker');
|
||||||
|
assert.match(dom.root.textContent, /Corporate Ladder/, 'the packs are named');
|
||||||
|
assert.match(dom.root.textContent, /The Frontier/);
|
||||||
|
|
||||||
|
const start = byClass(dom.root, 'button').find((b) => /Start here|Keep playing/.test(b.textContent));
|
||||||
|
assert.ok(start, 'the picker offers a way in');
|
||||||
|
start.click();
|
||||||
|
|
||||||
|
assert.ok(firstByClass(dom.root, 'screen--turn'), 'the bundled game rendered a turn');
|
||||||
|
assert.match(dom.root.textContent, /Day 1/);
|
||||||
|
assert.match(dom.root.textContent, /Marlene Hobb/);
|
||||||
|
assert.match(dom.root.textContent, /The Ladder/, 'the status bar is in the bundle');
|
||||||
|
|
||||||
|
const playable = byClass(dom.root, 'option').filter((b) => !b.disabled);
|
||||||
|
assert.ok(playable.length > 0, 'the bundled game offers choices');
|
||||||
|
playable[0].click();
|
||||||
|
assert.ok(firstByClass(dom.root, 'outcome'), 'a turn can be played in the bundle');
|
||||||
|
} finally {
|
||||||
|
dom.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
// The cast drawn per run, names in text, and options whose outcome is a check.
|
||||||
|
// See DECISIONS #41. Runs against test/fixtures/cast-pack.js, never real content.
|
||||||
|
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { startGame, currentTurn, takeTurn } from '../src/engine/game.js';
|
||||||
|
import { validatePack } from '../src/engine/validate.js';
|
||||||
|
import { hydrate, createState, resolveSchema } from '../src/engine/state.js';
|
||||||
|
import { presentPeople, presentSlots, personIn } from '../src/engine/cast.js';
|
||||||
|
import { fillText } from '../src/engine/text.js';
|
||||||
|
import { chanceOf, oddsWords } from '../src/engine/checks.js';
|
||||||
|
import { getPath, setPath } from '../src/engine/paths.js';
|
||||||
|
import { castPack } from './fixtures/cast-pack.js';
|
||||||
|
import { testPack } from './fixtures/test-pack.js';
|
||||||
|
|
||||||
|
const clone = (value) => structuredClone(value);
|
||||||
|
|
||||||
|
/** Advance until the current event is `eventId`, taking `work` otherwise. */
|
||||||
|
function until(state, eventId, pack = castPack) {
|
||||||
|
let next = state;
|
||||||
|
for (let i = 0; i < 200 && currentTurn(pack, next).event.id !== eventId; i++) {
|
||||||
|
next = takeTurn(pack, next, currentTurn(pack, next).options[0].option.id).state;
|
||||||
|
}
|
||||||
|
assert.equal(currentTurn(pack, next).event.id, eventId);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the cast pack is valid, with no warnings', () => {
|
||||||
|
const { errors, warnings } = validatePack(castPack);
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
assert.deepEqual(warnings, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a run draws one boss and two to four different co-workers', () => {
|
||||||
|
const counts = new Set();
|
||||||
|
for (let seed = 0; seed < 40; seed++) {
|
||||||
|
const state = startGame(castPack, { seed });
|
||||||
|
const people = presentPeople(castPack, state);
|
||||||
|
assert.equal(people.filter((p) => p.role === 'boss').length, 1);
|
||||||
|
const coworkers = presentSlots(castPack, state, 'coworker');
|
||||||
|
assert.ok(coworkers.length >= 2 && coworkers.length <= 4, `drew ${coworkers.length}`);
|
||||||
|
counts.add(coworkers.length);
|
||||||
|
const names = people.map((p) => p.name);
|
||||||
|
assert.equal(new Set(names).size, names.length, 'nobody is drawn twice');
|
||||||
|
assert.ok(names.includes('Old Sam'), 'a fixed character is always present');
|
||||||
|
}
|
||||||
|
assert.deepEqual([...counts].sort(), [2, 3, 4], 'every head count turns up across seeds');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the cast is the seed\'s: the same seed draws the same people, others differ', () => {
|
||||||
|
const names = (seed) => presentPeople(castPack, startGame(castPack, { seed })).map((p) => p.name).join('|');
|
||||||
|
assert.equal(names('a'), names('a'));
|
||||||
|
const distinct = new Set(Array.from({ length: 20 }, (_, i) => names(`s${i}`)));
|
||||||
|
assert.ok(distinct.size > 10, `only ${distinct.size} different casts in 20 runs`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an empty group slot is not in the run, and a drawn person brings their own starting regard', () => {
|
||||||
|
let seed = 0;
|
||||||
|
let state;
|
||||||
|
do state = startGame(castPack, { seed: seed++ });
|
||||||
|
while (presentSlots(castPack, state, 'coworker').length !== 2 || personIn(castPack, state, 'boss').name !== 'Walter Grose');
|
||||||
|
assert.equal(getPath(state, 'cast.coworker_3'), null);
|
||||||
|
assert.equal(personIn(castPack, state, 'coworker_3'), undefined);
|
||||||
|
assert.equal(getPath(state, 'relationships.boss.trust'), 30);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('names are filled into text from whoever was drawn', () => {
|
||||||
|
const state = startGame(castPack, { seed: 'names' });
|
||||||
|
const boss = personIn(castPack, state, 'boss');
|
||||||
|
assert.equal(fillText(castPack, state, 'Ask {boss.first}, the {boss.title}.'),
|
||||||
|
`Ask ${boss.name.split(' ')[0]}, the Manager.`);
|
||||||
|
assert.equal(fillText(castPack, state, '{porter} nods.'), 'Old Sam nods.');
|
||||||
|
assert.equal(fillText(castPack, state, '{nobody} stays as written.'), '{nobody} stays as written.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an event about a co-worker binds one who is present, and its effects land on them', () => {
|
||||||
|
const state = until(startGame(castPack, { seed: 'bind' }), 'needled');
|
||||||
|
const turn = currentTurn(castPack, state);
|
||||||
|
const slot = turn.bindings.coworker;
|
||||||
|
assert.ok(presentSlots(castPack, state, 'coworker').includes(slot));
|
||||||
|
assert.match(fillText(castPack, state, turn.event.description), new RegExp(personIn(castPack, state, slot).name));
|
||||||
|
|
||||||
|
const before = getPath(state, `relationships.${slot}.regard`);
|
||||||
|
const { state: after, record } = takeTurn(castPack, state, 'ignore');
|
||||||
|
assert.equal(getPath(after, `relationships.${slot}.regard`), before - 2);
|
||||||
|
assert.deepEqual(record.bindings, { coworker: slot });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the chance follows the formula, and the odds are words', () => {
|
||||||
|
let state = startGame(castPack, { seed: 'odds' });
|
||||||
|
const option = castPack.events[0].options[2]; // wits against difficulty 5
|
||||||
|
state = setPath(state, 'attributes.wits', 5);
|
||||||
|
assert.equal(chanceOf(castPack, state, option).chance, 50);
|
||||||
|
state = setPath(state, 'attributes.wits', 7);
|
||||||
|
assert.equal(chanceOf(castPack, state, option).chance, 70);
|
||||||
|
state = setPath(state, 'attributes.wits', 10);
|
||||||
|
assert.equal(chanceOf(castPack, state, option).chance, 100, 'clamped at the ceiling');
|
||||||
|
assert.equal(oddsWords(castPack, 15), 'a long shot');
|
||||||
|
assert.equal(oddsWords(castPack, 50), 'an even chance');
|
||||||
|
assert.equal(oddsWords(castPack, 90), 'a safe bet');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an opposed check reads the other person\'s attribute, and their regard moves it', () => {
|
||||||
|
let state = startGame(castPack, { seed: 'opposed' });
|
||||||
|
const option = castPack.events[0].options[1]; // charm against the boss's wits, leaning on trust
|
||||||
|
const boss = personIn(castPack, state, 'boss');
|
||||||
|
state = setPath(state, 'attributes.charm', 6);
|
||||||
|
state = setPath(state, 'relationships.boss.trust', 50);
|
||||||
|
const even = chanceOf(castPack, state, option);
|
||||||
|
assert.equal(even.theirs, boss.attributes.wits);
|
||||||
|
assert.equal(even.chance, Math.min(100, Math.max(0, 50 + 10 * (6 - boss.attributes.wits))));
|
||||||
|
state = setPath(state, 'relationships.boss.trust', 70);
|
||||||
|
assert.equal(chanceOf(castPack, state, option).chance, Math.min(100, even.chance + 10));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a check applies the branch it rolled, plus the option\'s own effects', () => {
|
||||||
|
let state = until(startGame(castPack, { seed: 'roll' }), 'routine');
|
||||||
|
const sure = setPath(setPath(state, 'attributes.charm', 10), 'relationships.boss.trust', 100);
|
||||||
|
const won = takeTurn(castPack, sure, 'ask_for_more');
|
||||||
|
assert.equal(won.record.check.outcome, 'success');
|
||||||
|
assert.equal(getPath(won.state, 'money'), getPath(sure, 'money') + 50);
|
||||||
|
assert.equal(getPath(won.state, 'relationships.boss.regard'), getPath(sure, 'relationships.boss.regard') - 1);
|
||||||
|
|
||||||
|
const hopeless = setPath(setPath(state, 'attributes.charm', 0), 'relationships.boss.trust', 0);
|
||||||
|
const lost = takeTurn(castPack, hopeless, 'ask_for_more');
|
||||||
|
assert.equal(lost.record.check.outcome, 'failure');
|
||||||
|
assert.equal(getPath(lost.state, 'money'), getPath(hopeless, 'money'));
|
||||||
|
assert.equal(getPath(lost.state, 'relationships.boss.trust'), 0, 'clamped');
|
||||||
|
assert.match(lost.record.check.odds, /long shot/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a run with checks and bindings replays identically from its seed and choices', () => {
|
||||||
|
const play = () => {
|
||||||
|
let state = startGame(castPack, { seed: 'replay' });
|
||||||
|
const records = [];
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
const turn = currentTurn(castPack, state);
|
||||||
|
const pick = turn.options[i % turn.options.length].option.id;
|
||||||
|
const result = takeTurn(castPack, state, pick);
|
||||||
|
records.push(result.record);
|
||||||
|
state = i === 30 ? JSON.parse(JSON.stringify(result.state)) : result.state;
|
||||||
|
}
|
||||||
|
return { state, records };
|
||||||
|
};
|
||||||
|
const a = play();
|
||||||
|
const b = play();
|
||||||
|
assert.deepEqual(a.records.map((r) => r.check?.outcome ?? '-'), b.records.map((r) => r.check?.outcome ?? '-'));
|
||||||
|
assert.deepEqual(a.state, b.state);
|
||||||
|
assert.ok(a.records.some((r) => r.check?.outcome === 'success') && a.records.some((r) => r.check?.outcome === 'failure'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a save from before the pack drew a cast gets one on load, the same one every time', () => {
|
||||||
|
const schema = resolveSchema(castPack);
|
||||||
|
const fresh = createState(castPack, { seed: 'old', schema });
|
||||||
|
const old = clone(fresh);
|
||||||
|
delete old.cast;
|
||||||
|
const once = hydrate(castPack, clone(old), schema);
|
||||||
|
const twice = hydrate(castPack, clone(old), schema);
|
||||||
|
assert.ok(once.added.includes('cast'));
|
||||||
|
assert.deepEqual(once.state.cast, twice.state.cast);
|
||||||
|
assert.deepEqual(once.state.cast, fresh.cast);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a pack with no drawn cast gets no cast and no bindings: its runs are unchanged', () => {
|
||||||
|
const state = startGame(testPack, { seed: 'plain' });
|
||||||
|
assert.equal(state.cast, undefined);
|
||||||
|
assert.equal(state.meta.bindings, undefined);
|
||||||
|
assert.equal(presentPeople(testPack, state).map((p) => p.name).join(), 'The Chief,Pat');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the validator catches cast and check mistakes', () => {
|
||||||
|
const broken = clone(castPack);
|
||||||
|
broken.events[0].options[1].check.attribute = 'strength';
|
||||||
|
broken.events[0].options[2].failure = undefined;
|
||||||
|
broken.events[0].description = 'Blame {coworker}.';
|
||||||
|
broken.events.push({ id: 'ghost', stage: 'desk', description: '{ghost} appears.', options: [{ id: 'x', label: 'x', effects: [] }] });
|
||||||
|
broken.characters[1].count = [2, 6];
|
||||||
|
const { errors } = validatePack(broken);
|
||||||
|
const has = (fragment) => assert.ok(errors.some((e) => e.includes(fragment)), `expected an error mentioning "${fragment}":\n${errors.join('\n')}`);
|
||||||
|
has('attribute "strength" is not in pack.attributes');
|
||||||
|
has('has no failure');
|
||||||
|
has('{coworker} is a group');
|
||||||
|
has('{ghost} names no character');
|
||||||
|
has('has only 5 candidates');
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { evaluate, check, pathsUsed } from '../src/engine/conditions.js';
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
money: 100,
|
||||||
|
turn: 12,
|
||||||
|
skills: { talking: 3 },
|
||||||
|
flags: { in_debt: false },
|
||||||
|
notable: ['promoted'],
|
||||||
|
seen: { first_day: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
test('compares numbers', () => {
|
||||||
|
assert.equal(evaluate(state, { path: 'money', op: '>=', value: 100 }), true);
|
||||||
|
assert.equal(evaluate(state, { path: 'money', op: '>', value: 100 }), false);
|
||||||
|
assert.equal(evaluate(state, { path: 'skills.talking', op: '<', value: 5 }), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('compares booleans and equality', () => {
|
||||||
|
assert.equal(evaluate(state, { path: 'flags.in_debt', op: '==', value: false }), true);
|
||||||
|
assert.equal(evaluate(state, { path: 'flags.in_debt', op: '!=', value: true }), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('array membership works both directions', () => {
|
||||||
|
assert.equal(evaluate(state, { path: 'notable', op: 'has', value: 'promoted' }), true);
|
||||||
|
assert.equal(evaluate(state, { path: 'notable', op: 'not-has', value: 'fired' }), true);
|
||||||
|
assert.equal(evaluate(state, { path: 'skills.talking', op: 'in', value: [1, 2, 3] }), true);
|
||||||
|
assert.equal(evaluate(state, { path: 'skills.talking', op: 'not-in', value: [9] }), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ordering against a missing path is false, not a throw', () => {
|
||||||
|
assert.equal(evaluate(state, { path: 'skills.welding', op: '>=', value: 1 }), false);
|
||||||
|
assert.equal(evaluate(state, { path: 'skills.welding', op: '<', value: 1 }), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('content can read engine bookkeeping', () => {
|
||||||
|
assert.equal(evaluate(state, { path: 'turn', op: '>=', value: 10 }), true);
|
||||||
|
assert.equal(evaluate(state, { path: 'seen.first_day', op: '>=', value: 1 }), true);
|
||||||
|
assert.equal(evaluate(state, { path: 'seen.never_fired', op: '>=', value: 1 }), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('combinators nest', () => {
|
||||||
|
const condition = {
|
||||||
|
all: [
|
||||||
|
{ path: 'money', op: '>', value: 0 },
|
||||||
|
{ any: [{ path: 'skills.talking', op: '>=', value: 3 }, { path: 'flags.in_debt', op: '==', value: true }] },
|
||||||
|
{ not: { path: 'notable', op: 'has', value: 'fired' } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
assert.equal(evaluate(state, condition), true);
|
||||||
|
assert.equal(evaluate({ ...state, money: -1 }, condition), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('check reports which conditions failed', () => {
|
||||||
|
const result = check(state, [
|
||||||
|
{ path: 'money', op: '>=', value: 100 },
|
||||||
|
{ path: 'skills.talking', op: '>=', value: 9 },
|
||||||
|
]);
|
||||||
|
assert.equal(result.passed, false);
|
||||||
|
assert.equal(result.failed.length, 1);
|
||||||
|
assert.equal(result.failed[0].path, 'skills.talking');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an absent gate passes', () => {
|
||||||
|
assert.equal(check(state, undefined).passed, true);
|
||||||
|
assert.equal(check(state, []).passed, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unknown operator is a loud failure', () => {
|
||||||
|
assert.throws(() => evaluate(state, { path: 'money', op: '=~', value: 1 }), /unknown condition operator/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pathsUsed walks the whole tree', () => {
|
||||||
|
const paths = pathsUsed({
|
||||||
|
all: [{ path: 'money', op: '>', value: 0 }, { not: { path: 'flags.in_debt', op: '==', value: true } }],
|
||||||
|
});
|
||||||
|
assert.deepEqual(paths.sort(), ['flags.in_debt', 'money']);
|
||||||
|
});
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
// What every content pack must do, whatever setting it is.
|
||||||
|
//
|
||||||
|
// This suite runs over `content/index.js` — every pack the build ships — and
|
||||||
|
// asserts only things that are true of a pack because it is a pack, never
|
||||||
|
// because it is about a mailroom or a freight yard. A new setting is therefore
|
||||||
|
// tested the moment it appears in the registry, rather than by copying this
|
||||||
|
// file and changing the nouns.
|
||||||
|
//
|
||||||
|
// Per-pack tuning lives with the pack: content.test.js and frontier.test.js
|
||||||
|
// hold each one to its own economy, its own promotion and its own characters.
|
||||||
|
// Anything a pack must declare for this suite to check it — the economic
|
||||||
|
// baseline, for one — is a field on the pack, not a constant in here.
|
||||||
|
//
|
||||||
|
// One trap, learned the hard way: the chooser's RNG must be seeded
|
||||||
|
// *differently* from the game's. Both draw one number per turn from the same
|
||||||
|
// generator, so sharing a seed correlates the choice index with the event draw
|
||||||
|
// and silently makes whole option combinations unreachable.
|
||||||
|
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { packs } from '../content/index.js';
|
||||||
|
import { validatePack } from '../src/engine/validate.js';
|
||||||
|
import { startGame, currentTurn, takeTurn } from '../src/engine/game.js';
|
||||||
|
import { createRng } from '../src/engine/rng.js';
|
||||||
|
import { play, sweep, median, strategies } from './fixtures/strategies.js';
|
||||||
|
import { summarize } from '../src/ui/summary.js';
|
||||||
|
|
||||||
|
/** Play `turns` turns of `pack` with independently-seeded random choices. */
|
||||||
|
function randomRun(pack, runSeed, turns) {
|
||||||
|
const rng = createRng(`chooser-${runSeed}`);
|
||||||
|
let state = startGame(pack, { seed: `run-${runSeed}` });
|
||||||
|
for (let t = 0; t < turns; t++) {
|
||||||
|
const turn = currentTurn(pack, state);
|
||||||
|
const available = turn.options.filter((o) => o.available);
|
||||||
|
assert.ok(available.length > 0,
|
||||||
|
`${pack.id}: no available option on event "${turn.event.id}"`);
|
||||||
|
state = takeTurn(pack, state, rng.pick(available).option.id).state;
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Net movement of `money` from upkeep alone, per calendar cycle, at one stage.
|
||||||
|
*
|
||||||
|
* Derived rather than simulated: an entry worth `v` every `n` turns is worth
|
||||||
|
* `v * cycleLength / n` per cycle, exactly, with no window to fall off the end
|
||||||
|
* of. Simulating this over a fixed number of turns reads differently depending
|
||||||
|
* on where the last rent lands — Corporate Ladder measures -19 a fortnight over
|
||||||
|
* 280 turns and -42 over 560, and only one of those is the steady state.
|
||||||
|
*
|
||||||
|
* Entries gated on a path the pack calls elective are excluded: a debt it took
|
||||||
|
* on, a lifestyle it bought. The baseline is what a player faces having opted
|
||||||
|
* into none of them, which is the floor the pack is designed around.
|
||||||
|
*
|
||||||
|
* Which paths those are is the pack's to name, in `tuning.baselineExcludes`.
|
||||||
|
* This used to be a regex over path names — /debt|owed|loan/i — which is the
|
||||||
|
* generic suite guessing at content's vocabulary, exactly what DECISIONS #31
|
||||||
|
* says it must not do. It also silently missed anything named otherwise.
|
||||||
|
*/
|
||||||
|
function baselinePerCycle(pack, stage) {
|
||||||
|
const cycle = pack.calendar?.cycleLength ?? 1;
|
||||||
|
const elective = new Set(pack.tuning?.baselineExcludes ?? []);
|
||||||
|
return (pack.upkeep ?? [])
|
||||||
|
.filter((entry) => {
|
||||||
|
const gate = entry.requires ?? [];
|
||||||
|
if (gate.some((c) => elective.has(c.path))) return false;
|
||||||
|
return !gate.some((c) => c.path === 'stage' && c.value !== stage);
|
||||||
|
})
|
||||||
|
.reduce((total, entry) => {
|
||||||
|
const money = entry.effects
|
||||||
|
.filter((e) => e.path === 'money' && e.op === 'add')
|
||||||
|
.reduce((sum, e) => sum + e.value, 0);
|
||||||
|
return total + money * (cycle / (entry.every ?? 1));
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const pack of packs) {
|
||||||
|
const where = `[${pack.id}]`;
|
||||||
|
|
||||||
|
test(`${where} the pack is valid, with no warnings`, () => {
|
||||||
|
const result = validatePack(pack);
|
||||||
|
assert.deepEqual(result.errors, []);
|
||||||
|
assert.deepEqual(result.warnings, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`${where} declares what the interface and this suite need from it`, () => {
|
||||||
|
// Everything the picker shows and everything asserted below. A pack that
|
||||||
|
// omits one of these is not a broken game, but it is a pack that will
|
||||||
|
// render badly or go unchecked, and both should fail loudly at build time.
|
||||||
|
assert.ok(pack.name, 'a display name');
|
||||||
|
assert.ok(pack.blurb, 'a one-line blurb for the setting picker');
|
||||||
|
assert.ok(pack.peopleHeading, 'a heading for the panel of characters');
|
||||||
|
assert.ok(pack.stages && Object.keys(pack.stages).length > 0, 'named stages');
|
||||||
|
assert.ok(pack.tuning?.baselinePerCycle, 'a declared economic baseline');
|
||||||
|
for (const stage of Object.keys(pack.stages)) {
|
||||||
|
assert.ok(pack.stages[stage].title, `a title for stage "${stage}"`);
|
||||||
|
assert.ok(pack.tuning.baselinePerCycle[stage], `a baseline for stage "${stage}"`);
|
||||||
|
}
|
||||||
|
// A typo here would not fail: the entry simply would not be excluded, and
|
||||||
|
// the baseline would quietly become a number nobody meant.
|
||||||
|
for (const path of pack.tuning.baselineExcludes ?? []) {
|
||||||
|
assert.ok(path in pack.state,
|
||||||
|
`baselineExcludes names "${path}", which the pack does not declare`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`${where} no content is dead: every event fires and every option becomes available`, () => {
|
||||||
|
// Across archetypes, not just random play: several things in a pack exist
|
||||||
|
// for a player pursuing something, and "broke but well-liked" in
|
||||||
|
// particular is a state no single-axis strategy ever reaches.
|
||||||
|
//
|
||||||
|
// Options are checked on *availability*, not on being taken. Whether a
|
||||||
|
// particular bot picks an option is an artefact of how the bot scores;
|
||||||
|
// what matters is whether a player is ever offered it unlocked.
|
||||||
|
const { firedEvents, offeredOptions } = sweep(pack, { seeds: 12, turns: 250 });
|
||||||
|
|
||||||
|
const allEvents = pack.events.map((e) => e.id);
|
||||||
|
const allOptions = pack.events.flatMap((e) => e.options.map((o) => `${e.id}.${o.id}`));
|
||||||
|
assert.deepEqual(allEvents.filter((e) => !firedEvents.has(e)), [],
|
||||||
|
'events that can never fire');
|
||||||
|
assert.deepEqual(allOptions.filter((o) => !offeredOptions.has(o)), [],
|
||||||
|
'options no player can ever be offered');
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`${where} every event leaves a player with nothing at least one way out`, () => {
|
||||||
|
// A player with no skills, no money and no friends must still be able to
|
||||||
|
// take a turn. The engine guarantees an event will be drawn; only content
|
||||||
|
// can guarantee it is playable when everything is gated.
|
||||||
|
const trapped = pack.events
|
||||||
|
.filter((event) => !(event.options ?? []).some((option) => !option.requires))
|
||||||
|
.map((event) => event.id);
|
||||||
|
assert.deepEqual(trapped, [], 'events whose every option is gated');
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`${where} no repayment can take more than is owed`, () => {
|
||||||
|
// A fixed instalment against a smaller balance takes money for a debt that
|
||||||
|
// does not exist; the Frontier's store did it to a real player. A balance
|
||||||
|
// is any money-formatted stat other than money itself. An option that pays
|
||||||
|
// one down with money must read the amount from the balance, or be gated
|
||||||
|
// on the balance covering the whole instalment. See DECISIONS #38.
|
||||||
|
const balances = Object.entries(pack.display ?? {})
|
||||||
|
.filter(([path, rule]) => rule.format === 'money' && path !== 'money')
|
||||||
|
.map(([path]) => path);
|
||||||
|
const overcharging = [];
|
||||||
|
for (const event of pack.events) {
|
||||||
|
for (const option of event.options ?? []) {
|
||||||
|
// A check's branches pay and spend too (DECISIONS #41): each is judged
|
||||||
|
// together with the effects that apply either way.
|
||||||
|
const branches = option.check
|
||||||
|
? [option.success, option.failure].map((b) => [...(option.effects ?? []), ...(b?.effects ?? [])])
|
||||||
|
: [option.effects ?? []];
|
||||||
|
for (const effects of branches) {
|
||||||
|
const spends = effects.some((e) => e.path === 'money' && e.op === 'add'
|
||||||
|
&& (typeof e.value === 'number' ? e.value < 0 : (e.value?.times ?? 1) < 0));
|
||||||
|
if (!spends) continue;
|
||||||
|
for (const e of effects) {
|
||||||
|
if (!balances.includes(e.path) || e.op !== 'add') continue;
|
||||||
|
if (typeof e.value !== 'number' || e.value >= 0) continue;
|
||||||
|
const covered = (option.requires ?? [])
|
||||||
|
.some((r) => r.path === e.path && r.op === '>=' && r.value >= -e.value);
|
||||||
|
if (!covered) overcharging.push(`${event.id}.${option.id} (${e.path} ${e.value})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.ok(balances.length > 0 || pack.display === undefined, 'a pack with a balance should declare it as money');
|
||||||
|
assert.deepEqual(overcharging, [], 'options that can charge for more than is owed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`${where} every turn always offers something to do, however badly it is going`, () => {
|
||||||
|
for (let seed = 100; seed < 130; seed++) randomRun(pack, seed, 150);
|
||||||
|
});
|
||||||
|
|
||||||
|
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],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
// Corporate Ladder's own tuning.
|
||||||
|
//
|
||||||
|
// What is true of this pack because it is *this* pack: the shape of its week,
|
||||||
|
// when wages and rent land, how the loan behaves, when Dispatch opens and how
|
||||||
|
// deep the Trevor chain is. Everything true of a pack because it is a pack —
|
||||||
|
// validity, dead content, the economic baseline, variety, replay — is in
|
||||||
|
// conformance.test.js, which runs over every pack in the registry.
|
||||||
|
//
|
||||||
|
// Engine behaviour is covered against the fixture pack instead, so tuning the
|
||||||
|
// game cannot break the engine suite and vice versa.
|
||||||
|
//
|
||||||
|
// One trap, learned the hard way: the chooser's RNG must be seeded
|
||||||
|
// *differently* from the game's. Both draw one number per turn from the same
|
||||||
|
// generator, so sharing a seed correlates the choice index with the event
|
||||||
|
// draw and silently makes whole option combinations unreachable — which looks
|
||||||
|
// exactly like a content bug.
|
||||||
|
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { corporateLadder as pack } from '../content/corporateladder/index.js';
|
||||||
|
import { startGame, currentTurn, takeTurn } from '../src/engine/game.js';
|
||||||
|
import { check } from '../src/engine/conditions.js';
|
||||||
|
import { play, sweep, median } from './fixtures/strategies.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Net money from upkeep per week for a player in a given position: at a stage,
|
||||||
|
* at a lifestyle tier, owing nothing.
|
||||||
|
*
|
||||||
|
* Derived rather than simulated. Comparing simulated end-balances between
|
||||||
|
* promoted and unpromoted players looks like the obvious test and is
|
||||||
|
* confounded: which archetypes get promoted is not independent of how they
|
||||||
|
* spend, so the pooled medians mostly measure the archetype mix. It passed for
|
||||||
|
* two releases and then inverted the moment there was something to spend on.
|
||||||
|
*/
|
||||||
|
function netPerCycle(position) {
|
||||||
|
const state = { debt: 0, lifestyle: 0, reputation: 0, ...position };
|
||||||
|
const cycle = pack.calendar?.cycleLength ?? 1;
|
||||||
|
return (pack.upkeep ?? [])
|
||||||
|
.filter((entry) => check(state, entry.requires).passed)
|
||||||
|
.reduce((total, entry) => total + entry.effects
|
||||||
|
.filter((e) => e.path === 'money' && e.op === 'add')
|
||||||
|
.reduce((sum, e) => sum + e.value, 0) * (cycle / (entry.every ?? 1)), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the promotion is worth taking, and reachable by a deliberate player', () => {
|
||||||
|
const { byStrategy } = sweep(pack, { seeds: 12, turns: 200 });
|
||||||
|
|
||||||
|
const promoted = Object.values(byStrategy).flat().filter((s) => s.stage === 'dispatch');
|
||||||
|
const stayed = Object.values(byStrategy).flat().filter((s) => s.stage === 'mailroom');
|
||||||
|
assert.ok(promoted.length > 0 && stayed.length > 0, 'both outcomes should occur');
|
||||||
|
|
||||||
|
const mailroom = netPerCycle({ stage: 'mailroom' });
|
||||||
|
const coordinator = netPerCycle({ stage: 'dispatch' });
|
||||||
|
assert.ok(coordinator > mailroom + 40,
|
||||||
|
`dispatch nets ${coordinator} a week against the mailroom's ${mailroom}`);
|
||||||
|
|
||||||
|
// A player actually pursuing it should get there mid-run, not at the very
|
||||||
|
// end — the mailroom is a career, not a tutorial.
|
||||||
|
const timings = [];
|
||||||
|
for (let seed = 0; seed < 12; seed++) {
|
||||||
|
let at = null;
|
||||||
|
play(pack, { strategy: 'sociable', seed, turns: 200,
|
||||||
|
visit: ({ state }) => { if (state.stage === 'dispatch' && at === null) at = state.turn; } });
|
||||||
|
if (at) timings.push(at);
|
||||||
|
}
|
||||||
|
assert.equal(timings.length, 12, 'a sociable player should always get the offer');
|
||||||
|
const typical = median(timings);
|
||||||
|
assert.ok(typical > 40 && typical < 90, `promotion landed at turn ${typical}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play to `turns` chasing standing, buying the lifestyle or refusing to.
|
||||||
|
*
|
||||||
|
* Two players differing in exactly one habit. The archetype sweep cannot
|
||||||
|
* answer this question: which archetypes get promoted is not independent of
|
||||||
|
* how they spend, so its pooled medians compare populations, not policies.
|
||||||
|
*/
|
||||||
|
function chaseStanding({ seed, keepUp, turns = 200 }) {
|
||||||
|
const buys = (r) => (r.option.effects ?? []).some((e) => e.path === 'lifestyle');
|
||||||
|
const gain = (r) => (r.option.effects ?? [])
|
||||||
|
.filter((e) => e.path === 'reputation' && e.op === 'add')
|
||||||
|
.reduce((sum, e) => sum + e.value, 0);
|
||||||
|
|
||||||
|
let state = startGame(pack, { seed: `standing-${seed}` });
|
||||||
|
for (let t = 0; t < turns; t++) {
|
||||||
|
const turn = currentTurn(pack, state);
|
||||||
|
const available = turn.options.filter((o) => o.available);
|
||||||
|
const buy = available.find(buys);
|
||||||
|
const chosen = (keepUp && buy)
|
||||||
|
? buy
|
||||||
|
: [...available.filter((o) => keepUp || !buys(o))].sort((a, b) => gain(b) - gain(a))[0]
|
||||||
|
?? available[0];
|
||||||
|
state = takeTurn(pack, state, chosen.option.id).state;
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('standing costs money to keep, and the top of it cannot be worked for', () => {
|
||||||
|
// Derived: each tier is a real weekly charge, and a coordinator keeping up
|
||||||
|
// fully is running at a loss on upkeep alone. That is the answer to "money
|
||||||
|
// stops mattering late" — it stopped mattering because standing was free.
|
||||||
|
const plain = netPerCycle({ stage: 'dispatch' });
|
||||||
|
const car = netPerCycle({ stage: 'dispatch', lifestyle: 1 });
|
||||||
|
const address = netPerCycle({ stage: 'dispatch', lifestyle: 2 });
|
||||||
|
assert.ok(car < plain - 25, `the car costs ${plain - car} a week`);
|
||||||
|
assert.ok(address < car - 35, `the address costs ${car - address} a week`);
|
||||||
|
assert.ok(address < 0,
|
||||||
|
`a coordinator keeping up nets ${address} a week and has to earn the difference`);
|
||||||
|
|
||||||
|
// And the top of the ladder is not a plateau. A 96-turn session bought both
|
||||||
|
// tiers, reached 100 standing by turn 84, and from there had nothing left to
|
||||||
|
// spend on: every charge was switched off and the balance only climbed.
|
||||||
|
// Standing costs money to carry at every tier, with nothing to buy that ends
|
||||||
|
// it, so there is no position in this pack where money stops mattering.
|
||||||
|
const arrived = netPerCycle({ stage: 'dispatch', lifestyle: 2, reputation: 90, money: 2500 });
|
||||||
|
assert.ok(arrived < address,
|
||||||
|
`standing itself should cost something: ${arrived} against ${address} a week`);
|
||||||
|
|
||||||
|
// And it steepens with what there is to keep up, which is what stops the
|
||||||
|
// balance climbing forever. A flat charge could not: late income scales with
|
||||||
|
// how well the player plays, so flattening an optimiser's slope with one
|
||||||
|
// meant putting ordinary play 44% of a run below zero.
|
||||||
|
const wealthy = netPerCycle({ stage: 'dispatch', lifestyle: 2, reputation: 90, money: 6000 });
|
||||||
|
assert.ok(wealthy < arrived,
|
||||||
|
`the charge should steepen with money: ${wealthy} against ${arrived} a week`);
|
||||||
|
assert.ok(wealthy < 0, `and take a rich coordinator backwards: ${wealthy} a week`);
|
||||||
|
|
||||||
|
// At every tier, not only the top one: a coordinator who bought nothing is
|
||||||
|
// already paying for standing in standing, and pays in money besides.
|
||||||
|
const plainSomebody = netPerCycle({ stage: 'dispatch', lifestyle: 0, reputation: 90, money: 6000 });
|
||||||
|
assert.ok(plainSomebody < plain,
|
||||||
|
`standing should cost at tier 0 too: ${plainSomebody} against ${plain} a week`);
|
||||||
|
|
||||||
|
// Simulated, controlled: the ceiling. Without the top step a player who
|
||||||
|
// chased standing every turn held 99 while buying nothing, which made the
|
||||||
|
// whole system a tax on inattention rather than a choice.
|
||||||
|
const never = [];
|
||||||
|
const keeps = [];
|
||||||
|
for (let seed = 0; seed < 8; seed++) {
|
||||||
|
never.push(chaseStanding({ seed, keepUp: false }));
|
||||||
|
keeps.push(chaseStanding({ seed, keepUp: true }));
|
||||||
|
}
|
||||||
|
const standing = (runs) => median(runs.map((s) => s.reputation));
|
||||||
|
assert.ok(standing(never) <= 90,
|
||||||
|
`a coordinator who buys nothing reached ${standing(never)} standing`);
|
||||||
|
assert.ok(standing(keeps) >= 95,
|
||||||
|
`a coordinator who keeps up reached only ${standing(keeps)} standing`);
|
||||||
|
assert.ok(median(keeps.map((s) => s.money)) < median(never.map((s) => s.money)),
|
||||||
|
'and the difference is paid for out of the balance');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hard times is uncommon for a careful player and real for a careless one', () => {
|
||||||
|
const countHardTimes = (strategy) => {
|
||||||
|
let days = 0;
|
||||||
|
play(pack, { strategy, seed: 'ht', turns: 200,
|
||||||
|
visit: ({ turn }) => { if (turn.event.id === 'rent_bounced') days++; } });
|
||||||
|
return days;
|
||||||
|
};
|
||||||
|
assert.equal(countHardTimes('thrifty'), 0, 'minding the money should keep you out of trouble');
|
||||||
|
assert.ok(countHardTimes('spendthrift') > 0, 'ignoring it should land you in it');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('money arrives and leaves on a rhythm the player can plan around', () => {
|
||||||
|
const wages = [];
|
||||||
|
const rent = [];
|
||||||
|
let state = startGame(pack, { seed: 'rhythm' });
|
||||||
|
for (let t = 0; t < 30; t++) {
|
||||||
|
const turn = currentTurn(pack, state);
|
||||||
|
const result = takeTurn(pack, state, turn.options.find((o) => o.available).option.id);
|
||||||
|
for (const change of result.record.upkeep) {
|
||||||
|
if (change.label === 'Wages') wages.push(state.turn);
|
||||||
|
if (change.label === 'Rent') rent.push(state.turn);
|
||||||
|
}
|
||||||
|
state = result.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.deepEqual(wages, [5, 19], 'paid on alternate Fridays');
|
||||||
|
assert.deepEqual(rent, [8, 15, 22, 29], 'rent every Monday from the second week');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the calendar names the day and counts the week', () => {
|
||||||
|
let state = startGame(pack, { seed: 'calendar' });
|
||||||
|
assert.deepEqual(
|
||||||
|
[state.calendar.name, state.calendar.cycle, state.calendar.index],
|
||||||
|
['Monday', 1, 0],
|
||||||
|
);
|
||||||
|
for (let t = 0; t < 11; t++) {
|
||||||
|
const turn = currentTurn(pack, state);
|
||||||
|
state = takeTurn(pack, state, turn.options.find((o) => o.available).option.id).state;
|
||||||
|
}
|
||||||
|
assert.equal(state.turn, 12);
|
||||||
|
assert.deepEqual([state.calendar.name, state.calendar.cycle], ['Friday', 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('debt cannot spiral without limit: lending stops and the charge escalates', () => {
|
||||||
|
let peakDebt = 0;
|
||||||
|
for (const strategy of ['spendthrift', 'desperate', 'random']) {
|
||||||
|
for (let seed = 0; seed < 12; seed++) {
|
||||||
|
play(pack, { strategy, seed, turns: 200,
|
||||||
|
visit: ({ state }) => { peakDebt = Math.max(peakDebt, state.debt); } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The loan is gated at debt < 2000 and lends 500, so 2500 plus stalled
|
||||||
|
// interest is the ceiling. Well above that means the gate has stopped working.
|
||||||
|
assert.ok(peakDebt <= 3000, `debt reached ${peakDebt}; the borrowing ceiling is not holding`);
|
||||||
|
|
||||||
|
const charges = pack.upkeep.filter((u) => u.label?.startsWith('Loan service charge'));
|
||||||
|
assert.ok(charges.length >= 3, 'the service charge should step up as debt grows');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a deliberate player can reach relationship-gated content in a reasonable run', () => {
|
||||||
|
// The Trevor chain is the deepest gate in the pack. A player cultivating him
|
||||||
|
// should get there well inside a normal session, not at turn 100.
|
||||||
|
const regard = (o) => (o.option.effects ?? [])
|
||||||
|
.filter((e) => e.path === 'relationships.trevor.likes_you')
|
||||||
|
.reduce((sum, e) => sum + e.value, 0);
|
||||||
|
|
||||||
|
let state = startGame(pack, { seed: 'befriend-trevor' });
|
||||||
|
let reached = null;
|
||||||
|
for (let t = 0; t < 60 && reached === null; t++) {
|
||||||
|
const turn = currentTurn(pack, state);
|
||||||
|
if (turn.event.id === 'trevor_favor') { reached = state.turn; break; }
|
||||||
|
const best = turn.options.filter((o) => o.available).sort((a, b) => regard(b) - regard(a))[0];
|
||||||
|
state = takeTurn(pack, state, best.option.id).state;
|
||||||
|
}
|
||||||
|
assert.ok(reached && reached < 45, `Trevor's chain opened at turn ${reached ?? 'never'}`);
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { applyEffect, applyEffects } from '../src/engine/effects.js';
|
||||||
|
|
||||||
|
const schema = {
|
||||||
|
'money': { initial: 0, min: -500, max: 1000 },
|
||||||
|
'skills.talking': { initial: 1, min: 0, max: 10 },
|
||||||
|
'flags.in_debt': { initial: false },
|
||||||
|
'notable': { initial: [] },
|
||||||
|
};
|
||||||
|
const state = { money: 100, skills: { talking: 1 }, flags: { in_debt: false }, notable: [] };
|
||||||
|
|
||||||
|
test('add and set produce a new state', () => {
|
||||||
|
const result = applyEffects(state, [
|
||||||
|
{ path: 'money', op: 'add', value: -30 },
|
||||||
|
{ path: 'flags.in_debt', op: 'set', value: true },
|
||||||
|
], schema);
|
||||||
|
assert.equal(result.state.money, 70);
|
||||||
|
assert.equal(result.state.flags.in_debt, true);
|
||||||
|
assert.equal(state.money, 100, 'the input state is never mutated');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('push appends to a list', () => {
|
||||||
|
const result = applyEffects(state, [{ path: 'notable', op: 'push', value: 'first day' }], schema);
|
||||||
|
assert.deepEqual(result.state.notable, ['first day']);
|
||||||
|
assert.deepEqual(state.notable, [], 'the input list is never mutated');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('remove drops matching entries', () => {
|
||||||
|
const seeded = { ...state, notable: ['a', 'b', 'a'] };
|
||||||
|
const result = applyEffects(seeded, [{ path: 'notable', op: 'remove', value: 'a' }], schema);
|
||||||
|
assert.deepEqual(result.state.notable, ['b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('values clamp to their declared bounds', () => {
|
||||||
|
const high = applyEffect(state, { path: 'skills.talking', op: 'add', value: 99 }, schema);
|
||||||
|
assert.equal(high.state.skills.talking, 10);
|
||||||
|
assert.equal(high.change.clamped, true);
|
||||||
|
|
||||||
|
const low = applyEffect(state, { path: 'money', op: 'add', value: -9999 }, schema);
|
||||||
|
assert.equal(low.state.money, -500);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unbounded path is not clamped', () => {
|
||||||
|
const result = applyEffect(state, { path: 'money', op: 'add', value: 10 }, {});
|
||||||
|
assert.equal(result.state.money, 110);
|
||||||
|
assert.equal(result.change.clamped, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the change record carries before and after for every path touched', () => {
|
||||||
|
const result = applyEffects(state, [
|
||||||
|
{ path: 'money', op: 'add', value: 25 },
|
||||||
|
{ path: 'skills.talking', op: 'add', value: 1 },
|
||||||
|
], schema);
|
||||||
|
assert.deepEqual(result.changes.map((c) => [c.path, c.from, c.to]), [
|
||||||
|
['money', 100, 125],
|
||||||
|
['skills.talking', 1, 2],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adding to a path that has never been set treats it as zero', () => {
|
||||||
|
const result = applyEffect({}, { path: 'money', op: 'add', value: 5 }, {});
|
||||||
|
assert.equal(result.state.money, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unknown operation is a loud failure', () => {
|
||||||
|
assert.throws(() => applyEffect(state, { path: 'money', op: 'multiply', value: 2 }), /unknown effect operation/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an amount can be read from state, capped and signed', () => {
|
||||||
|
// The Frontier's store, turn 27 of a real session: four owed, twenty taken.
|
||||||
|
const ledger = { owed: { initial: 0, min: 0, max: 1000 } };
|
||||||
|
const pay = [
|
||||||
|
{ path: 'money', op: 'add', value: { from: 'owed', times: -1, cap: 20 } },
|
||||||
|
{ path: 'owed', op: 'add', value: { from: 'owed', times: -1, cap: 20 } },
|
||||||
|
];
|
||||||
|
|
||||||
|
const small = applyEffects({ money: 110, owed: 4 }, pay, ledger);
|
||||||
|
assert.equal(small.state.money, 106, 'pays what is owed, not the instalment');
|
||||||
|
assert.equal(small.state.owed, 0);
|
||||||
|
assert.equal(small.changes[1].clamped, false, 'nothing is lost to a clamp');
|
||||||
|
assert.equal(small.changes[0].value, -4, 'the change records the amount applied');
|
||||||
|
assert.deepEqual(small.changes[0].source, { from: 'owed', times: -1, cap: 20 });
|
||||||
|
|
||||||
|
const large = applyEffects({ money: 110, owed: 50 }, pay, ledger);
|
||||||
|
assert.equal(large.state.money, 90, 'the cap holds when more is owed');
|
||||||
|
assert.equal(large.state.owed, 30);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an amount read from a path that is not a number is zero', () => {
|
||||||
|
const result = applyEffect({ money: 10 }, { path: 'money', op: 'add', value: { from: 'owed', times: -1 } }, {});
|
||||||
|
assert.equal(result.state.money, 10);
|
||||||
|
assert.equal(result.change.value === 0 || Object.is(result.change.value, -0), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a literal amount records no source', () => {
|
||||||
|
const result = applyEffect(state, { path: 'money', op: 'add', value: 5 }, schema);
|
||||||
|
assert.equal('source' in result.change, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an empty effect list is a no-op', () => {
|
||||||
|
const result = applyEffects(state, [], schema);
|
||||||
|
assert.deepEqual(result.changes, []);
|
||||||
|
assert.equal(result.state, state);
|
||||||
|
});
|
||||||
Vendored
+114
@@ -0,0 +1,114 @@
|
|||||||
|
// A small pack for the cast and checks (DECISIONS #41): a drawn single role, a
|
||||||
|
// group role of two to four, a fixed character with attributes, opposed and
|
||||||
|
// unopposed checks, and an event about "one of the co-workers". Its checks run
|
||||||
|
// from 0 to 100 so tests can force an outcome; a real pack keeps a floor and a
|
||||||
|
// ceiling so nothing is ever certain.
|
||||||
|
|
||||||
|
export const castPack = {
|
||||||
|
id: 'castpack',
|
||||||
|
name: 'Cast Pack',
|
||||||
|
version: '1.0.0',
|
||||||
|
turnUnit: 'day',
|
||||||
|
startingStage: 'desk',
|
||||||
|
|
||||||
|
attributes: {
|
||||||
|
nerve: { label: 'Nerve' },
|
||||||
|
wits: { label: 'Wits' },
|
||||||
|
charm: { label: 'Charm' },
|
||||||
|
resolve: { label: 'Resolve' },
|
||||||
|
},
|
||||||
|
|
||||||
|
checks: {
|
||||||
|
base: 50, perPoint: 10, unopposed: 5,
|
||||||
|
relationshipPerPoint: 0.5, relationshipCentre: 50,
|
||||||
|
floor: 0, ceiling: 100,
|
||||||
|
},
|
||||||
|
|
||||||
|
state: {
|
||||||
|
'money': { initial: 100, min: -500, max: 10000 },
|
||||||
|
'attributes.nerve': { initial: 5, min: 0, max: 10 },
|
||||||
|
'attributes.wits': { initial: 5, min: 0, max: 10 },
|
||||||
|
'attributes.charm': { initial: 5, min: 0, max: 10 },
|
||||||
|
'attributes.resolve': { initial: 5, min: 0, max: 10 },
|
||||||
|
},
|
||||||
|
|
||||||
|
relationshipStats: {
|
||||||
|
trust: { label: 'Trusts you', initial: 50, min: 0, max: 100 },
|
||||||
|
regard: { label: 'Thinks of you', initial: 50, min: 0, max: 100 },
|
||||||
|
},
|
||||||
|
|
||||||
|
characters: [
|
||||||
|
{
|
||||||
|
id: 'boss',
|
||||||
|
role_type: 'boss',
|
||||||
|
title: 'Manager',
|
||||||
|
candidates: [
|
||||||
|
{ name: 'Agnes Pell', description: 'Reads every memo twice.', attributes: { nerve: 6, wits: 8, charm: 3, resolve: 7 } },
|
||||||
|
{ name: 'Walter Grose', description: 'Loud, and quicker than he looks.', attributes: { nerve: 7, wits: 6, charm: 6, resolve: 4 },
|
||||||
|
startingRelationship: { trust: 30 } },
|
||||||
|
{ name: 'Mina Hart', description: 'Has never raised her voice.', attributes: { nerve: 4, wits: 7, charm: 8, resolve: 6 } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'coworker',
|
||||||
|
role_type: 'peer',
|
||||||
|
title: 'Clerk',
|
||||||
|
count: [2, 4],
|
||||||
|
candidates: [
|
||||||
|
{ name: 'Ben Aske', description: 'Owes {boss} a favour.', attributes: { nerve: 3, wits: 4, charm: 6, resolve: 5 } },
|
||||||
|
{ name: 'Cora Dunn', description: 'First in, last out.', attributes: { nerve: 5, wits: 5, charm: 4, resolve: 8 } },
|
||||||
|
{ name: 'Del Fane', description: 'Knows everyone\'s business.', attributes: { nerve: 4, wits: 7, charm: 5, resolve: 3 } },
|
||||||
|
{ name: 'Edie Gow', description: 'Quiet until she is not.', attributes: { nerve: 7, wits: 5, charm: 3, resolve: 6 } },
|
||||||
|
{ name: 'Fitz Hale', description: 'Charming, unreliable.', attributes: { nerve: 5, wits: 3, charm: 8, resolve: 2 } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ id: 'porter', name: 'Old Sam', role_type: 'staff', description: 'Has seen everyone come and go.',
|
||||||
|
attributes: { nerve: 2, wits: 9, charm: 4, resolve: 9 } },
|
||||||
|
],
|
||||||
|
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
id: 'routine',
|
||||||
|
stage: 'desk',
|
||||||
|
weight: 100,
|
||||||
|
description: 'A day at the desk under {boss}.',
|
||||||
|
options: [
|
||||||
|
{ id: 'work', label: 'Work.', effects: [{ path: 'money', op: 'add', value: 10 }] },
|
||||||
|
{
|
||||||
|
id: 'ask_for_more',
|
||||||
|
label: 'Ask {boss.first} for more money.',
|
||||||
|
check: { attribute: 'charm', against: 'boss', opposedBy: 'wits', relationship: 'trust' },
|
||||||
|
effects: [{ path: 'relationships.boss.regard', op: 'add', value: -1 }],
|
||||||
|
success: { result: '{boss} agrees.', effects: [{ path: 'money', op: 'add', value: 50 }] },
|
||||||
|
failure: { result: '{boss} laughs.', effects: [{ path: 'relationships.boss.trust', op: 'add', value: -5 }] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hard_sum',
|
||||||
|
label: 'Try the hard sum.',
|
||||||
|
check: { attribute: 'wits', difficulty: 5 },
|
||||||
|
success: { result: 'It adds up.', effects: [{ path: 'attributes.wits', op: 'add', value: 1 }] },
|
||||||
|
failure: { result: 'It does not.', effects: [] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'needled',
|
||||||
|
stage: 'desk',
|
||||||
|
weight: 100,
|
||||||
|
involves: ['coworker'],
|
||||||
|
description: '{coworker} makes a remark about your tie.',
|
||||||
|
options: [
|
||||||
|
{ id: 'ignore', label: 'Let it go.', effects: [{ path: 'relationships.{coworker}.regard', op: 'add', value: -2 }] },
|
||||||
|
{
|
||||||
|
id: 'retort',
|
||||||
|
label: 'Answer {coworker.first} back.',
|
||||||
|
check: { attribute: 'nerve', against: '{coworker}', relationship: 'regard' },
|
||||||
|
success: { result: '{coworker} laughs, and means it.', effects: [{ path: 'relationships.{coworker}.regard', op: 'add', value: 5 }] },
|
||||||
|
failure: { result: 'It lands badly.', effects: [{ path: 'relationships.{coworker}.trust', op: 'add', value: -5 }] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default castPack;
|
||||||
Vendored
+167
@@ -0,0 +1,167 @@
|
|||||||
|
// A minimal DOM, enough to drive the UI headlessly.
|
||||||
|
//
|
||||||
|
// Not a browser and not a substitute for clicking the real thing — it exists
|
||||||
|
// so that the wiring (imports, handlers, the render/choose/log path) is
|
||||||
|
// exercised by the test suite rather than discovered by a player. Anything
|
||||||
|
// involving real files or real downloads is out of its scope and has to be
|
||||||
|
// checked by hand.
|
||||||
|
|
||||||
|
class FakeNode {
|
||||||
|
constructor() {
|
||||||
|
this.childNodes = [];
|
||||||
|
this.parentNode = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
get textContent() {
|
||||||
|
return this.childNodes.map((child) => child.textContent).join('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeText extends FakeNode {
|
||||||
|
constructor(text) {
|
||||||
|
super();
|
||||||
|
this.nodeValue = String(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
get textContent() {
|
||||||
|
return this.nodeValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeElement extends FakeNode {
|
||||||
|
constructor(tagName) {
|
||||||
|
super();
|
||||||
|
this.tagName = tagName.toUpperCase();
|
||||||
|
this.className = '';
|
||||||
|
this.id = '';
|
||||||
|
this.attributes = {};
|
||||||
|
this.listeners = {};
|
||||||
|
this.dataset = {};
|
||||||
|
this.style = '';
|
||||||
|
// Predefined so `key in el` behaves the way it does in a real element:
|
||||||
|
// these are set as properties, everything else falls through to attributes.
|
||||||
|
this.disabled = false;
|
||||||
|
this.value = '';
|
||||||
|
this.rows = 0;
|
||||||
|
this.type = '';
|
||||||
|
this.accept = '';
|
||||||
|
this.href = '';
|
||||||
|
this.download = '';
|
||||||
|
this.files = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
append(...nodes) {
|
||||||
|
for (const node of nodes) {
|
||||||
|
node.parentNode = this;
|
||||||
|
this.childNodes.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
replaceChildren(...nodes) {
|
||||||
|
this.childNodes = [];
|
||||||
|
this.append(...nodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
remove() {
|
||||||
|
const siblings = this.parentNode?.childNodes;
|
||||||
|
if (siblings) siblings.splice(siblings.indexOf(this), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
setAttribute(name, value) { this.attributes[name] = String(value); }
|
||||||
|
getAttribute(name) { return this.attributes[name] ?? null; }
|
||||||
|
|
||||||
|
addEventListener(type, handler) {
|
||||||
|
(this.listeners[type] ??= []).push(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(type, event = {}) {
|
||||||
|
for (const handler of this.listeners[type] ?? []) handler({ target: this, ...event });
|
||||||
|
}
|
||||||
|
|
||||||
|
click() { this.dispatch('click'); }
|
||||||
|
|
||||||
|
focus() {
|
||||||
|
if (globalThis.document) globalThis.document.activeElement = this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function walk(node, out = []) {
|
||||||
|
out.push(node);
|
||||||
|
for (const child of node.childNodes) if (child instanceof FakeElement) walk(child, out);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every element under `root` whose class list contains `className`. */
|
||||||
|
export function byClass(root, className) {
|
||||||
|
return walk(root).filter((el) => el.className.split(/\s+/).includes(className));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The first such element, or undefined. */
|
||||||
|
export function firstByClass(root, className) {
|
||||||
|
return byClass(root, className)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every element of a tag. */
|
||||||
|
export function byTag(root, tagName) {
|
||||||
|
return walk(root).filter((el) => el.tagName === tagName.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeStorage {
|
||||||
|
constructor() { this.map = new Map(); }
|
||||||
|
getItem(key) { return this.map.has(key) ? this.map.get(key) : null; }
|
||||||
|
setItem(key, value) { this.map.set(key, String(value)); }
|
||||||
|
removeItem(key) { this.map.delete(key); }
|
||||||
|
clear() { this.map.clear(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install the fake DOM as globals and return a handle for the test. Call the
|
||||||
|
* returned `restore` afterwards so suites do not leak into each other.
|
||||||
|
*/
|
||||||
|
export function installFakeDom() {
|
||||||
|
const root = new FakeElement('div');
|
||||||
|
root.id = 'app';
|
||||||
|
const document = {
|
||||||
|
createElement: (tag) => new FakeElement(tag),
|
||||||
|
createTextNode: (text) => new FakeText(text),
|
||||||
|
getElementById: (id) => (id === 'app' ? root : null),
|
||||||
|
body: new FakeElement('body'),
|
||||||
|
activeElement: null,
|
||||||
|
};
|
||||||
|
const listeners = {};
|
||||||
|
const window = {
|
||||||
|
addEventListener: (type, handler) => { (listeners[type] ??= []).push(handler); },
|
||||||
|
};
|
||||||
|
|
||||||
|
const previous = {};
|
||||||
|
const globals = {
|
||||||
|
document,
|
||||||
|
window,
|
||||||
|
Node: FakeNode,
|
||||||
|
localStorage: new FakeStorage(),
|
||||||
|
requestAnimationFrame: (fn) => setTimeout(fn, 0),
|
||||||
|
confirm: () => true,
|
||||||
|
alert: () => {},
|
||||||
|
};
|
||||||
|
for (const [key, value] of Object.entries(globals)) {
|
||||||
|
previous[key] = globalThis[key];
|
||||||
|
globalThis[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
document,
|
||||||
|
window,
|
||||||
|
root,
|
||||||
|
storage: globals.localStorage,
|
||||||
|
/** Fire a window event, e.g. beforeunload. */
|
||||||
|
fireWindow: (type) => { for (const handler of listeners[type] ?? []) handler({}); },
|
||||||
|
restore() {
|
||||||
|
for (const [key, value] of Object.entries(previous)) {
|
||||||
|
if (value === undefined) delete globalThis[key];
|
||||||
|
else globalThis[key] = value;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { FakeElement, FakeText, FakeNode };
|
||||||
Vendored
+135
@@ -0,0 +1,135 @@
|
|||||||
|
// Player archetypes for content probing.
|
||||||
|
//
|
||||||
|
// "Is this content reachable?" is not the same question as "does random play
|
||||||
|
// reach it?" — a random player is not a plausible player, and several things
|
||||||
|
// in a pack exist precisely for a player pursuing something. Coverage is
|
||||||
|
// therefore the union across archetypes, and each one is a rough caricature of
|
||||||
|
// a way somebody actually plays.
|
||||||
|
//
|
||||||
|
// NOTE: seed the chooser differently from the game. Both draw one number per
|
||||||
|
// turn from the same generator, so a shared seed correlates the choice with
|
||||||
|
// the event draw and silently makes combinations unreachable.
|
||||||
|
|
||||||
|
import { startGame, currentTurn, takeTurn } from '../../src/engine/game.js';
|
||||||
|
import { createRng } from '../../src/engine/rng.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What an effect is worth to a bot. An amount read from state (DECISIONS #38)
|
||||||
|
* is scored at its cap: the most it could be, which is what a player weighing
|
||||||
|
* the option sees on the label.
|
||||||
|
*/
|
||||||
|
const amountOf = (effect) => {
|
||||||
|
if (typeof effect.value === 'number') return effect.value;
|
||||||
|
if (typeof effect.value?.cap === 'number') return effect.value.cap * (effect.value.times ?? 1);
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every effect an option can have, with the weight a player reading the odds
|
||||||
|
* would give it: plain effects count in full, a check's success and failure by
|
||||||
|
* their chance (DECISIONS #41). An option with no check is just its effects.
|
||||||
|
*/
|
||||||
|
export const weightedEffects = (resolved) => {
|
||||||
|
const { option, odds } = resolved;
|
||||||
|
const plain = (option.effects ?? []).map((effect) => ({ effect, weight: 1 }));
|
||||||
|
if (!option.check) return plain;
|
||||||
|
const p = (odds?.chance ?? 50) / 100;
|
||||||
|
return [
|
||||||
|
...plain,
|
||||||
|
...(option.success?.effects ?? []).map((effect) => ({ effect, weight: p })),
|
||||||
|
...(option.failure?.effects ?? []).map((effect) => ({ effect, weight: 1 - p })),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const moneyGain = (resolved) => weightedEffects(resolved)
|
||||||
|
.filter(({ effect }) => effect.path === 'money' && effect.op === 'add')
|
||||||
|
.reduce((sum, { effect, weight }) => sum + amountOf(effect) * weight, 0);
|
||||||
|
|
||||||
|
const relationshipGain = (resolved) => weightedEffects(resolved)
|
||||||
|
.filter(({ effect }) => effect.path.startsWith('relationships.'))
|
||||||
|
.reduce((sum, { effect, weight }) => sum + amountOf(effect) * weight, 0);
|
||||||
|
|
||||||
|
const best = (options, score) => [...options].sort((a, b) => score(b) - score(a))[0];
|
||||||
|
|
||||||
|
export const strategies = {
|
||||||
|
/** No plan at all. */
|
||||||
|
random: (options, rng) => rng.pick(options),
|
||||||
|
/** Takes the money every time. */
|
||||||
|
thrifty: (options) => best(options, moneyGain),
|
||||||
|
/** Spends it every time. */
|
||||||
|
spendthrift: (options) => best(options, (o) => -moneyGain(o)),
|
||||||
|
/** Chases standing with people. */
|
||||||
|
sociable: (options) => best(options, relationshipGain),
|
||||||
|
/**
|
||||||
|
* Broke but well-liked — the state the "ask a friend for money" options were
|
||||||
|
* written for, and one no single-axis strategy ever reaches.
|
||||||
|
*/
|
||||||
|
desperate: (options, rng) => (rng.next() < 0.25
|
||||||
|
? rng.pick(options)
|
||||||
|
: best(options, (o) => relationshipGain(o) - moneyGain(o) / 40)),
|
||||||
|
/**
|
||||||
|
* Never leaves the first rung — refuses anything that would change stage,
|
||||||
|
* and otherwise plays for people. Added after a real 182-turn session that
|
||||||
|
* stayed in the mailroom the whole way and spent 30% of it below zero: every
|
||||||
|
* other archetype either optimised money or got promoted out of the problem,
|
||||||
|
* so none of them could see it.
|
||||||
|
*/
|
||||||
|
lifer: (options) => {
|
||||||
|
const stays = options.filter(
|
||||||
|
(o) => !weightedEffects(o).some(({ effect }) => effect.path === 'stage'),
|
||||||
|
);
|
||||||
|
return best(stays.length ? stays : options, relationshipGain);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Play one run to `turns`, calling `visit` before each choice. */
|
||||||
|
export function play(pack, { strategy = 'random', seed = 'run', turns = 200, visit } = {}) {
|
||||||
|
const choose = strategies[strategy];
|
||||||
|
const rng = createRng(`chooser-${strategy}-${seed}`);
|
||||||
|
let state = startGame(pack, { seed: `game-${strategy}-${seed}` });
|
||||||
|
|
||||||
|
for (let t = 0; t < turns; t++) {
|
||||||
|
const turn = currentTurn(pack, state);
|
||||||
|
const available = turn.options.filter((o) => o.available);
|
||||||
|
if (available.length === 0) throw new Error(`no available option on "${turn.event.id}"`);
|
||||||
|
const chosen = choose(available, rng);
|
||||||
|
visit?.({ turn, state, chosen });
|
||||||
|
state = takeTurn(pack, state, chosen.option.id).state;
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Play every archetype across several seeds, reporting what was reached. */
|
||||||
|
export function sweep(pack, { seeds = 12, turns = 200 } = {}) {
|
||||||
|
const firedEvents = new Set();
|
||||||
|
const takenOptions = new Set();
|
||||||
|
// What a player *could* have picked. This, not what a bot happened to pick,
|
||||||
|
// is what "is this content reachable" means — a real player chooses freely,
|
||||||
|
// and an archetype's preferences are an artefact of the harness.
|
||||||
|
const offeredOptions = new Set();
|
||||||
|
const byStrategy = {};
|
||||||
|
|
||||||
|
for (const strategy of Object.keys(strategies)) {
|
||||||
|
const finals = [];
|
||||||
|
for (let seed = 0; seed < seeds; seed++) {
|
||||||
|
const state = play(pack, {
|
||||||
|
strategy,
|
||||||
|
seed,
|
||||||
|
turns,
|
||||||
|
visit: ({ turn, chosen }) => {
|
||||||
|
firedEvents.add(turn.event.id);
|
||||||
|
takenOptions.add(`${turn.event.id}.${chosen.option.id}`);
|
||||||
|
for (const resolved of turn.options) {
|
||||||
|
if (resolved.available) offeredOptions.add(`${turn.event.id}.${resolved.option.id}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
finals.push(state);
|
||||||
|
}
|
||||||
|
byStrategy[strategy] = finals;
|
||||||
|
}
|
||||||
|
return { firedEvents, takenOptions, offeredOptions, byStrategy };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const median = (values) =>
|
||||||
|
[...values].sort((a, b) => a - b)[Math.floor(values.length / 2)];
|
||||||
Vendored
+112
@@ -0,0 +1,112 @@
|
|||||||
|
// A deliberately small pack that exercises every engine feature: gates,
|
||||||
|
// once-only events, cooldowns, weighted draws, a conditional branch and a
|
||||||
|
// clamped stat. Engine tests run against this, never against real content, so
|
||||||
|
// tuning the game cannot break the test suite.
|
||||||
|
|
||||||
|
export const testPack = {
|
||||||
|
id: 'testpack',
|
||||||
|
name: 'Test Pack',
|
||||||
|
version: '1.0.0',
|
||||||
|
turnUnit: 'day',
|
||||||
|
startingStage: 'floor',
|
||||||
|
|
||||||
|
state: {
|
||||||
|
'money': { initial: 100, min: -500, max: 10000 },
|
||||||
|
'skills.talking': { initial: 1, min: 0, max: 10 },
|
||||||
|
'flags.in_debt': { initial: false },
|
||||||
|
'notable': { initial: [] },
|
||||||
|
},
|
||||||
|
|
||||||
|
relationshipStats: {
|
||||||
|
likes_you: { initial: 50, min: 0, max: 100 },
|
||||||
|
fears_you: { initial: 50, min: 0, max: 100 },
|
||||||
|
},
|
||||||
|
|
||||||
|
characters: [
|
||||||
|
{ id: 'chief', name: 'The Chief', role_type: 'boss', description: 'Tall, tired.' },
|
||||||
|
{ id: 'pat', name: 'Pat', role_type: 'rival', description: 'Smiles too much.' },
|
||||||
|
],
|
||||||
|
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
id: 'routine',
|
||||||
|
stage: 'floor',
|
||||||
|
weight: 100,
|
||||||
|
description: 'Another ordinary shift.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'work',
|
||||||
|
label: 'Put your head down and work.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: 10 },
|
||||||
|
{ path: 'relationships.chief.likes_you', op: 'add', value: 2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'charm',
|
||||||
|
label: 'Talk your way into an easier job.',
|
||||||
|
requires: [{ path: 'skills.talking', op: '>=', value: 3 }],
|
||||||
|
effects: [{ path: 'skills.talking', op: 'add', value: 1 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'practice',
|
||||||
|
label: 'Chat with Pat all afternoon.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'skills.talking', op: 'add', value: 1 },
|
||||||
|
{ path: 'money', op: 'add', value: -5 },
|
||||||
|
{ path: 'relationships.pat.likes_you', op: 'add', value: 3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'first_day',
|
||||||
|
stage: 'floor',
|
||||||
|
weight: 1000,
|
||||||
|
once: true,
|
||||||
|
description: 'Your first day.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'arrive',
|
||||||
|
label: 'Arrive on time.',
|
||||||
|
effects: [{ path: 'notable', op: 'push', value: 'Showed up on time, once.' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hard_times',
|
||||||
|
stage: 'floor',
|
||||||
|
weight: 1000,
|
||||||
|
requires: [{ path: 'money', op: '<', value: 0 }],
|
||||||
|
description: 'Rent is due and you do not have it.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'loan',
|
||||||
|
label: 'Take the loan.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: 200 },
|
||||||
|
{ path: 'flags.in_debt', op: 'set', value: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'beg',
|
||||||
|
label: 'Ask Pat for help.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: 50 },
|
||||||
|
{ path: 'relationships.pat.fears_you', op: 'add', value: -10 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'audit',
|
||||||
|
stage: 'floor',
|
||||||
|
weight: 5,
|
||||||
|
cooldown: 10,
|
||||||
|
description: 'An auditor is wandering the floor.',
|
||||||
|
options: [{ id: 'nod', label: 'Nod politely.', effects: [] }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default testPack;
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
// The Frontier's own tuning.
|
||||||
|
//
|
||||||
|
// What is true of this pack because it is *this* pack: a four-day run instead
|
||||||
|
// of a week, a settlement that barely covers the room, a store ledger rather
|
||||||
|
// than a loan, and when the star is offered. Everything true of a pack because
|
||||||
|
// it is a pack is in conformance.test.js, which runs over every pack in the
|
||||||
|
// registry — including this one.
|
||||||
|
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { frontier as pack } from '../content/frontier/index.js';
|
||||||
|
import { startGame, currentTurn, takeTurn } from '../src/engine/game.js';
|
||||||
|
import { check } from '../src/engine/conditions.js';
|
||||||
|
import { applyEffects } from '../src/engine/effects.js';
|
||||||
|
import { resolveSchema } from '../src/engine/state.js';
|
||||||
|
import { play, sweep, median } from './fixtures/strategies.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Net money from upkeep per run for a player in a given position: at a stage,
|
||||||
|
* at a lifestyle tier, owing the store nothing.
|
||||||
|
*
|
||||||
|
* Derived rather than simulated, and for a reason worth keeping: comparing the
|
||||||
|
* end-balances of promoted and unpromoted players pools archetypes that do not
|
||||||
|
* spend alike, so it measures the mix rather than the wage.
|
||||||
|
*/
|
||||||
|
function netPerCycle(position) {
|
||||||
|
const state = { owed: 0, lifestyle: 0, standing: 0, ...position };
|
||||||
|
const cycle = pack.calendar?.cycleLength ?? 1;
|
||||||
|
return (pack.upkeep ?? [])
|
||||||
|
.filter((entry) => check(state, entry.requires).passed)
|
||||||
|
.reduce((total, entry) => total + entry.effects
|
||||||
|
.filter((e) => e.path === 'money' && e.op === 'add')
|
||||||
|
.reduce((sum, e) => sum + e.value, 0) * (cycle / (entry.every ?? 1)), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the calendar is a four-day run, not a week', () => {
|
||||||
|
// The point of this pack, structurally: the engine derives a cycle from
|
||||||
|
// whatever length the pack declares, and nothing anywhere assumes seven.
|
||||||
|
let state = startGame(pack, { seed: 'calendar' });
|
||||||
|
assert.deepEqual(
|
||||||
|
[state.calendar.name, state.calendar.cycle, state.calendar.index],
|
||||||
|
['Stage day', 1, 0],
|
||||||
|
);
|
||||||
|
const names = [];
|
||||||
|
for (let t = 0; t < 7; t++) {
|
||||||
|
names.push(state.calendar.name);
|
||||||
|
const turn = currentTurn(pack, state);
|
||||||
|
state = takeTurn(pack, state, turn.options.find((o) => o.available).option.id).state;
|
||||||
|
}
|
||||||
|
assert.deepEqual(names,
|
||||||
|
['Stage day', 'Haul', 'Yard', 'Rest', 'Stage day', 'Haul', 'Yard']);
|
||||||
|
assert.equal(state.calendar.cycle, 2, 'the second run has begun');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the settlement and the room land on the days the pack says', () => {
|
||||||
|
const settlement = [];
|
||||||
|
const room = [];
|
||||||
|
let state = startGame(pack, { seed: 'rhythm' });
|
||||||
|
for (let t = 0; t < 16; t++) {
|
||||||
|
const turn = currentTurn(pack, state);
|
||||||
|
const result = takeTurn(pack, state, turn.options.find((o) => o.available).option.id);
|
||||||
|
for (const change of result.record.upkeep) {
|
||||||
|
if (change.label === 'Wages from Vane') settlement.push(state.turn);
|
||||||
|
if (change.label === 'Room at the Cordwood') room.push(state.turn);
|
||||||
|
}
|
||||||
|
state = result.state;
|
||||||
|
}
|
||||||
|
// The boarding house collects on the Rest day, the night before the outfit
|
||||||
|
// settles up on the Stage day. That ordering is the whole reason a hand is
|
||||||
|
// never quite ahead.
|
||||||
|
assert.deepEqual(room, [4, 8, 12, 16], 'the room is collected every Rest day');
|
||||||
|
assert.deepEqual(settlement, [5, 9, 13], 'the outfit settles up every Stage day');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the star is worth taking, and reachable by a deliberate player', () => {
|
||||||
|
const { byStrategy } = sweep(pack, { seeds: 12, turns: 200 });
|
||||||
|
|
||||||
|
const sworn = Object.values(byStrategy).flat().filter((s) => s.stage === 'deputy');
|
||||||
|
const stayed = Object.values(byStrategy).flat().filter((s) => s.stage === 'hand');
|
||||||
|
assert.ok(sworn.length > 0 && stayed.length > 0, 'both outcomes should occur');
|
||||||
|
|
||||||
|
const yard = netPerCycle({ stage: 'hand' });
|
||||||
|
const office = netPerCycle({ stage: 'deputy' });
|
||||||
|
assert.ok(office > yard + 10,
|
||||||
|
`the office nets ${office} a run against the yard's ${yard}`);
|
||||||
|
|
||||||
|
// The yard is a career, not a tutorial: the offer is gated at turn 40 and
|
||||||
|
// should land somewhere in the middle of a run rather than at the very end.
|
||||||
|
const timings = [];
|
||||||
|
for (let seed = 0; seed < 12; seed++) {
|
||||||
|
let at = null;
|
||||||
|
play(pack, { strategy: 'sociable', seed, turns: 200,
|
||||||
|
visit: ({ state }) => { if (state.stage === 'deputy' && at === null) at = state.turn; } });
|
||||||
|
if (at) timings.push(at);
|
||||||
|
}
|
||||||
|
assert.equal(timings.length, 12, 'a sociable player should always get the offer');
|
||||||
|
const typical = median(timings);
|
||||||
|
assert.ok(typical >= 41 && typical < 90, `the star landed at turn ${typical}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Play to `turns` playing for standing, buying what the star expects or not. */
|
||||||
|
function chaseStanding({ seed, keepUp, turns = 200 }) {
|
||||||
|
const buys = (r) => (r.option.effects ?? []).some((e) => e.path === 'lifestyle');
|
||||||
|
const gain = (r) => (r.option.effects ?? [])
|
||||||
|
.filter((e) => e.path === 'standing' && e.op === 'add')
|
||||||
|
.reduce((sum, e) => sum + e.value, 0);
|
||||||
|
|
||||||
|
let state = startGame(pack, { seed: `standing-${seed}` });
|
||||||
|
for (let t = 0; t < turns; t++) {
|
||||||
|
const turn = currentTurn(pack, state);
|
||||||
|
const available = turn.options.filter((o) => o.available);
|
||||||
|
const buy = available.find(buys);
|
||||||
|
const chosen = (keepUp && buy)
|
||||||
|
? buy
|
||||||
|
: [...available.filter((o) => keepUp || !buys(o))].sort((a, b) => gain(b) - gain(a))[0]
|
||||||
|
?? available[0];
|
||||||
|
state = takeTurn(pack, state, chosen.option.id).state;
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the star costs money to wear, and the top of standing cannot be worked for', () => {
|
||||||
|
// The same mechanism as Corporate Ladder's, at this pack's scale and in its
|
||||||
|
// own vocabulary — which is the whole claim the second setting exists to
|
||||||
|
// make. No engine code is involved in either.
|
||||||
|
const plain = netPerCycle({ stage: 'deputy' });
|
||||||
|
const horse = netPerCycle({ stage: 'deputy', lifestyle: 1 });
|
||||||
|
const room = netPerCycle({ stage: 'deputy', lifestyle: 2 });
|
||||||
|
assert.ok(horse < plain - 5, `the bay costs ${plain - horse} a run`);
|
||||||
|
assert.ok(room < horse - 5, `the room costs ${horse - room} a run`);
|
||||||
|
assert.ok(room > 0,
|
||||||
|
`a deputy keeping up nets ${room} a run; the yard's own floor is barely above water`);
|
||||||
|
|
||||||
|
// The top of the ladder is not a plateau here either: standing costs money
|
||||||
|
// to carry at every tier, and nothing you can buy switches it off.
|
||||||
|
// Flat here rather than stepped on money, as Corporate Ladder's is: this
|
||||||
|
// pack's late game does not accumulate in the first place — measured, a
|
||||||
|
// deputy who owns everything drifts 0 a run — so the charge exists to keep
|
||||||
|
// money live, and break-even at the top is the intent rather than a shortfall.
|
||||||
|
const somebody = netPerCycle({ stage: 'deputy', lifestyle: 2, standing: 90 });
|
||||||
|
assert.ok(somebody < room, `standing itself should cost: ${somebody} against ${room} a run`);
|
||||||
|
assert.ok(somebody <= 0,
|
||||||
|
`and leave a deputy who has everything no better than level: ${somebody} a run`);
|
||||||
|
|
||||||
|
const never = [];
|
||||||
|
const keeps = [];
|
||||||
|
for (let seed = 0; seed < 8; seed++) {
|
||||||
|
never.push(chaseStanding({ seed, keepUp: false }));
|
||||||
|
keeps.push(chaseStanding({ seed, keepUp: true }));
|
||||||
|
}
|
||||||
|
const standing = (runs) => median(runs.map((s) => s.standing));
|
||||||
|
assert.ok(standing(never) <= 90, `a deputy on a livery horse reached ${standing(never)} standing`);
|
||||||
|
assert.ok(standing(keeps) >= 95, `a deputy keeping up reached only ${standing(keeps)} standing`);
|
||||||
|
assert.ok(median(keeps.map((s) => s.money)) < median(never.map((s) => s.money)),
|
||||||
|
'and it is paid for in money');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hard times is uncommon for a careful player and real for a careless one', () => {
|
||||||
|
const countHardTimes = (strategy) => {
|
||||||
|
let days = 0;
|
||||||
|
play(pack, { strategy, seed: 'ht', turns: 200,
|
||||||
|
visit: ({ turn }) => { if (turn.event.id === 'store_cut_off') days++; } });
|
||||||
|
return days;
|
||||||
|
};
|
||||||
|
assert.equal(countHardTimes('thrifty'), 0, 'minding the money should keep you out of the book');
|
||||||
|
assert.ok(countHardTimes('spendthrift') > 0, 'ignoring it should land you in it');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the store ledger cannot spiral without limit', () => {
|
||||||
|
let peak = 0;
|
||||||
|
for (const strategy of ['spendthrift', 'desperate', 'random']) {
|
||||||
|
for (let seed = 0; seed < 12; seed++) {
|
||||||
|
play(pack, { strategy, seed, turns: 200,
|
||||||
|
visit: ({ state }) => { peak = Math.max(peak, state.owed); } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// He stops carrying you at 120 and the largest single line is 32, so 152
|
||||||
|
// plus a few stalled charges is the ceiling. Well above that means the gate
|
||||||
|
// has stopped working.
|
||||||
|
assert.ok(peak <= 220, `the ledger reached ${peak}; the credit ceiling is not holding`);
|
||||||
|
|
||||||
|
const charges = pack.upkeep.filter((u) => u.label?.startsWith('Store credit'));
|
||||||
|
assert.ok(charges.length >= 3, 'the charge should step up as the ledger grows');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a skill gate is never gated behind itself', () => {
|
||||||
|
// The lesson Corporate Ladder's logs taught, applied here as a rule rather
|
||||||
|
// than as a hope. Four of its options need negotiation 15 and several of its
|
||||||
|
// largest negotiation gains sit behind that same gate, so a player who is
|
||||||
|
// not deliberately chasing the skill bounces off the wall for thirty turns.
|
||||||
|
//
|
||||||
|
// The structural property that prevents it: for every skill threshold in the
|
||||||
|
// pack, the growth available *without* passing that threshold must be able
|
||||||
|
// to reach it, and repeatably — a single one-off award is not a route.
|
||||||
|
const gates = new Map();
|
||||||
|
for (const event of pack.events) {
|
||||||
|
for (const option of event.options ?? []) {
|
||||||
|
for (const requirement of option.requires ?? []) {
|
||||||
|
if (!requirement.path.startsWith('skills.')) continue;
|
||||||
|
const highest = gates.get(requirement.path) ?? 0;
|
||||||
|
gates.set(requirement.path, Math.max(highest, requirement.value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.ok(gates.size > 0, 'the pack should gate something on a skill');
|
||||||
|
|
||||||
|
for (const [path, highest] of gates) {
|
||||||
|
const start = pack.state[path].initial;
|
||||||
|
let repeatable = 0;
|
||||||
|
for (const event of pack.events) {
|
||||||
|
if (event.once) continue;
|
||||||
|
for (const option of event.options ?? []) {
|
||||||
|
// Ungated on this skill: a route a player has open from turn one.
|
||||||
|
if ((option.requires ?? []).some((r) => r.path === path)) continue;
|
||||||
|
const gain = (option.effects ?? [])
|
||||||
|
.filter((e) => e.path === path && e.op === 'add')
|
||||||
|
.reduce((sum, e) => sum + e.value, 0);
|
||||||
|
if (gain > 0) repeatable += gain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.ok(start + repeatable > highest,
|
||||||
|
`${path} tops out at ${highest}, but only ${repeatable} is reachable without it`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a player who wants a skill does not wait long for the first door', () => {
|
||||||
|
// Measured rather than assumed: a player steering for a skill should be
|
||||||
|
// through its lowest gate inside the first two runs of the stage.
|
||||||
|
const firstGate = 12;
|
||||||
|
for (const path of ['skills.nerve', 'skills.trade']) {
|
||||||
|
const gain = (o) => (o.option.effects ?? [])
|
||||||
|
.filter((e) => e.path === path && e.op === 'add')
|
||||||
|
.reduce((sum, e) => sum + e.value, 0);
|
||||||
|
|
||||||
|
const times = [];
|
||||||
|
for (let seed = 0; seed < 12; seed++) {
|
||||||
|
let state = startGame(pack, { seed: `gate-${seed}` });
|
||||||
|
let at = null;
|
||||||
|
for (let t = 0; t < 120 && at === null; t++) {
|
||||||
|
const turn = currentTurn(pack, state);
|
||||||
|
const available = turn.options.filter((o) => o.available);
|
||||||
|
const best = [...available].sort((a, b) => gain(b) - gain(a))[0];
|
||||||
|
state = takeTurn(pack, state, best.option.id).state;
|
||||||
|
const value = path.split('.').reduce((o, k) => o?.[k], state);
|
||||||
|
if (value >= firstGate) at = state.turn;
|
||||||
|
}
|
||||||
|
times.push(at ?? Infinity);
|
||||||
|
}
|
||||||
|
const typical = median(times);
|
||||||
|
assert.ok(typical <= 20,
|
||||||
|
`${path} reached ${firstGate} at turn ${typical}; the first door is too far off`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the horse and the room are each offered only while they can still be bought', () => {
|
||||||
|
// A real session was shown "Buy the bay" locked after buying it, and offered
|
||||||
|
// the room under a description about the horse. See DECISIONS #40.
|
||||||
|
const offered = (lifestyle) => pack.events
|
||||||
|
.filter((e) => ['the_sale_barn', 'the_ellis_room'].includes(e.id))
|
||||||
|
.filter((e) => check({ stage: 'deputy', lifestyle }, e.requires).passed)
|
||||||
|
.map((e) => e.id);
|
||||||
|
assert.deepEqual(offered(0), ['the_sale_barn']);
|
||||||
|
assert.deepEqual(offered(1), ['the_ellis_room']);
|
||||||
|
assert.deepEqual(offered(2), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('paying the book down never takes more than is owed', () => {
|
||||||
|
// Turn 27 of that session: four dollars owed, twenty taken. See DECISIONS #38.
|
||||||
|
const schema = resolveSchema(pack);
|
||||||
|
const payDown = pack.events.find((e) => e.id === 'the_store')
|
||||||
|
.options.find((o) => o.id === 'pay_down');
|
||||||
|
const base = startGame(pack, { seed: 'the-book' });
|
||||||
|
|
||||||
|
const small = applyEffects({ ...base, money: 110, owed: 4 }, payDown.effects, schema).state;
|
||||||
|
assert.equal(small.money, 106, 'four owed costs four');
|
||||||
|
assert.equal(small.owed, 0);
|
||||||
|
|
||||||
|
const large = applyEffects({ ...base, money: 110, owed: 50 }, payDown.effects, schema).state;
|
||||||
|
assert.equal(large.money, 90, 'more than twenty owed costs twenty');
|
||||||
|
assert.equal(large.owed, 30);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a deliberate player can put Dutch in their debt', () => {
|
||||||
|
// The deepest relationship gate in the pack: `send_for_dutch` in the
|
||||||
|
// marshal's office needs a favour banked two rungs earlier.
|
||||||
|
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'}`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { startGame, currentTurn, takeTurn, MoveError } from '../src/engine/game.js';
|
||||||
|
import { getPath, setPath } from '../src/engine/paths.js';
|
||||||
|
import { testPack } from './fixtures/test-pack.js';
|
||||||
|
|
||||||
|
/** Play `turns` turns, always taking the first available option. */
|
||||||
|
function playThrough(seed, turns, chooser = (options) => options.find((o) => o.available).option.id) {
|
||||||
|
let state = startGame(testPack, { seed });
|
||||||
|
const records = [];
|
||||||
|
for (let i = 0; i < turns; i++) {
|
||||||
|
const turn = currentTurn(testPack, state);
|
||||||
|
const result = takeTurn(testPack, state, chooser(turn.options, turn.event, state));
|
||||||
|
state = result.state;
|
||||||
|
records.push(result.record);
|
||||||
|
}
|
||||||
|
return { state, records };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a new game starts on turn one with the pack defaults', () => {
|
||||||
|
const state = startGame(testPack, { seed: 'start' });
|
||||||
|
assert.equal(state.turn, 1);
|
||||||
|
assert.equal(state.stage, 'floor');
|
||||||
|
assert.equal(state.money, 100);
|
||||||
|
assert.equal(state.skills.talking, 1);
|
||||||
|
assert.equal(state.relationships.chief.likes_you, 50);
|
||||||
|
assert.equal(state.relationships.pat.fears_you, 50);
|
||||||
|
assert.ok(state.meta.currentEventId, 'an event is waiting before the first turn');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('relationship state is generated per character, so adding one adds its stats', () => {
|
||||||
|
const state = startGame(testPack, { seed: 'chars' });
|
||||||
|
assert.deepEqual(Object.keys(state.relationships).sort(), ['chief', 'pat']);
|
||||||
|
assert.deepEqual(Object.keys(state.relationships.chief).sort(), ['fears_you', 'likes_you']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('taking a turn applies effects and advances the clock', () => {
|
||||||
|
let state = startGame(testPack, { seed: 'turn' });
|
||||||
|
const before = state.money;
|
||||||
|
const result = takeTurn(testPack, state, currentTurn(testPack, state).options[0].option.id);
|
||||||
|
assert.equal(result.state.turn, 2);
|
||||||
|
assert.notEqual(result.state.meta.currentEventId, undefined);
|
||||||
|
assert.ok(result.state.money !== before || result.record.changes.length >= 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the same seed and the same choices replay identically', () => {
|
||||||
|
const a = playThrough('replay', 40);
|
||||||
|
const b = playThrough('replay', 40);
|
||||||
|
assert.deepEqual(a.state, b.state);
|
||||||
|
assert.deepEqual(a.records, b.records);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('different seeds produce different runs', () => {
|
||||||
|
const a = playThrough('seed-a', 40);
|
||||||
|
const b = playThrough('seed-b', 40);
|
||||||
|
assert.notDeepEqual(a.records.map((r) => r.eventId), b.records.map((r) => r.eventId));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a saved game resumes into exactly the run it left', () => {
|
||||||
|
const uninterrupted = playThrough('save', 30);
|
||||||
|
|
||||||
|
let state = startGame(testPack, { seed: 'save' });
|
||||||
|
for (let i = 0; i < 15; i++) {
|
||||||
|
const turn = currentTurn(testPack, state);
|
||||||
|
state = takeTurn(testPack, state, turn.options.find((o) => o.available).option.id).state;
|
||||||
|
}
|
||||||
|
// The whole save is one JSON round trip; nothing lives outside it.
|
||||||
|
state = JSON.parse(JSON.stringify(state));
|
||||||
|
for (let i = 0; i < 15; i++) {
|
||||||
|
const turn = currentTurn(testPack, state);
|
||||||
|
state = takeTurn(testPack, state, turn.options.find((o) => o.available).option.id).state;
|
||||||
|
}
|
||||||
|
assert.deepEqual(state, uninterrupted.state);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a once-only event fires exactly once across a long run', () => {
|
||||||
|
const { records } = playThrough('once', 60);
|
||||||
|
assert.equal(records.filter((r) => r.eventId === 'first_day').length, 1);
|
||||||
|
assert.equal(records[0].eventId, 'first_day', 'its weight makes it the opener');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a locked option cannot be played', () => {
|
||||||
|
const state = startGame(testPack, { seed: 'locked' });
|
||||||
|
let probe = state;
|
||||||
|
// Walk to a turn that offers the gated option.
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const turn = currentTurn(testPack, probe);
|
||||||
|
const charm = turn.options.find((o) => o.option.id === 'charm');
|
||||||
|
if (charm) {
|
||||||
|
assert.equal(charm.available, false);
|
||||||
|
assert.throws(() => takeTurn(testPack, probe, 'charm'), MoveError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
probe = takeTurn(testPack, probe, turn.options.find((o) => o.available).option.id).state;
|
||||||
|
}
|
||||||
|
assert.fail('the gated option never came up');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unknown option id is rejected', () => {
|
||||||
|
const state = startGame(testPack, { seed: 'bogus' });
|
||||||
|
assert.throws(() => takeTurn(testPack, state, 'no_such_option'), /has no option/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a skill grows from choices and eventually unlocks its gate', () => {
|
||||||
|
const talkative = (options) => {
|
||||||
|
const practice = options.find((o) => o.option.id === 'practice' && o.available);
|
||||||
|
return (practice ?? options.find((o) => o.available)).option.id;
|
||||||
|
};
|
||||||
|
const { state } = playThrough('skill', 20, talkative);
|
||||||
|
assert.ok(state.skills.talking >= 3, `expected talking to grow, got ${state.skills.talking}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('money going negative brings the hard-times branch into the pool', () => {
|
||||||
|
const spendy = (options) => {
|
||||||
|
const practice = options.find((o) => o.option.id === 'practice' && o.available);
|
||||||
|
return (practice ?? options.find((o) => o.available)).option.id;
|
||||||
|
};
|
||||||
|
let state = startGame(testPack, { seed: 'broke' });
|
||||||
|
let sawHardTimes = false;
|
||||||
|
for (let i = 0; i < 120; i++) {
|
||||||
|
const turn = currentTurn(testPack, state);
|
||||||
|
if (turn.event.id === 'hard_times') { sawHardTimes = true; break; }
|
||||||
|
state = takeTurn(testPack, state, spendy(turn.options, turn.event, state)).state;
|
||||||
|
}
|
||||||
|
assert.ok(state.money < 0 || sawHardTimes, 'the run should have gone broke');
|
||||||
|
assert.ok(sawHardTimes, 'hard times should surface once money is negative — the game never ends');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the turn record carries the diff and the options the player could not take', () => {
|
||||||
|
let state = startGame(testPack, { seed: 'record' });
|
||||||
|
state = takeTurn(testPack, state, currentTurn(testPack, state).options[0].option.id).state;
|
||||||
|
|
||||||
|
const turn = currentTurn(testPack, state);
|
||||||
|
const { record } = takeTurn(testPack, state, 'work');
|
||||||
|
|
||||||
|
assert.equal(record.eventId, turn.event.id);
|
||||||
|
assert.equal(record.optionId, 'work');
|
||||||
|
assert.deepEqual(record.changes.map((c) => c.path), ['money', 'relationships.chief.likes_you']);
|
||||||
|
assert.equal(record.changes[0].from + 10, record.changes[0].to);
|
||||||
|
|
||||||
|
const locked = record.offered.find((o) => o.id === 'charm');
|
||||||
|
assert.equal(locked.available, false);
|
||||||
|
assert.equal(locked.failed[0].path, 'skills.talking');
|
||||||
|
assert.equal(locked.hidden, false, 'the record says whether a locked option was on screen');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('history accumulates every turn and is what the retrospective reads', () => {
|
||||||
|
const { state, records } = playThrough('history', 12);
|
||||||
|
assert.equal(state.history.length, 12);
|
||||||
|
assert.deepEqual(state.history, records);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a stat cannot be pushed past its declared bound', () => {
|
||||||
|
const { state } = playThrough('clamp', 80, (options) => {
|
||||||
|
const practice = options.find((o) => o.option.id === 'practice' && o.available);
|
||||||
|
return (practice ?? options.find((o) => o.available)).option.id;
|
||||||
|
});
|
||||||
|
assert.ok(state.skills.talking <= 10, 'talking is capped at 10');
|
||||||
|
assert.ok(state.relationships.pat.likes_you <= 100, 'relationship stats are capped at 100');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the run never ends on its own', () => {
|
||||||
|
const { state } = playThrough('endless', 200);
|
||||||
|
assert.equal(state.turn, 201);
|
||||||
|
assert.ok(currentTurn(testPack, state).options.some((o) => o.available), 'there is always something to do');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('upkeep runs every turn and is recorded separately from the choice', () => {
|
||||||
|
const upkeepPack = structuredClone(testPack);
|
||||||
|
upkeepPack.upkeep = [
|
||||||
|
{ label: 'wage', effects: [{ path: 'money', op: 'add', value: 20 }] },
|
||||||
|
{
|
||||||
|
label: 'debt service',
|
||||||
|
requires: [{ path: 'flags.in_debt', op: '==', value: true }],
|
||||||
|
effects: [{ path: 'money', op: 'add', value: -5 }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let state = startGame(upkeepPack, { seed: 'upkeep' });
|
||||||
|
const before = state.money;
|
||||||
|
const first = takeTurn(upkeepPack, state, currentTurn(upkeepPack, state).options[0].option.id);
|
||||||
|
|
||||||
|
assert.equal(first.record.upkeep.length, 1, 'the conditional entry is skipped while it does not apply');
|
||||||
|
assert.equal(first.record.upkeep[0].label, 'wage');
|
||||||
|
const fromChoice = first.record.changes
|
||||||
|
.filter((c) => c.path === 'money')
|
||||||
|
.reduce((sum, c) => sum + (c.to - c.from), 0);
|
||||||
|
assert.equal(first.state.money, before + fromChoice + 20);
|
||||||
|
|
||||||
|
// Once the flag is set, the conditional entry starts applying.
|
||||||
|
const nowIndebted = setPath(first.state, 'flags.in_debt', true);
|
||||||
|
const nextOption = currentTurn(upkeepPack, nowIndebted).options.find((o) => o.available).option.id;
|
||||||
|
const indebted = takeTurn(upkeepPack, nowIndebted, nextOption);
|
||||||
|
assert.deepEqual(indebted.record.upkeep.map((c) => c.label), ['wage', 'debt service']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a character can start somewhere other than the pack default', () => {
|
||||||
|
const pack = structuredClone(testPack);
|
||||||
|
pack.characters.find((c) => c.id === 'pat').startingRelationship = { likes_you: 20 };
|
||||||
|
|
||||||
|
const state = startGame(pack, { seed: 'starts' });
|
||||||
|
assert.equal(state.relationships.pat.likes_you, 20, 'the override applies');
|
||||||
|
assert.equal(state.relationships.pat.fears_you, 50, 'unnamed stats keep the pack default');
|
||||||
|
assert.equal(state.relationships.chief.likes_you, 50, 'other characters are unaffected');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a pack with a calendar gets day-of-cycle state it can gate on', () => {
|
||||||
|
const dated = structuredClone(testPack);
|
||||||
|
dated.calendar = { cycleLength: 7, cycleName: 'Week', unitNames: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] };
|
||||||
|
|
||||||
|
let state = startGame(dated, { seed: 'calendar' });
|
||||||
|
assert.deepEqual(
|
||||||
|
{ index: state.calendar.index, cycle: state.calendar.cycle, name: state.calendar.name },
|
||||||
|
{ index: 0, cycle: 1, name: 'Mon' },
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
state = takeTurn(dated, state, currentTurn(dated, state).options.find((o) => o.available).option.id).state;
|
||||||
|
}
|
||||||
|
assert.equal(state.turn, 8);
|
||||||
|
assert.deepEqual(
|
||||||
|
{ index: state.calendar.index, cycle: state.calendar.cycle, name: state.calendar.name },
|
||||||
|
{ index: 0, cycle: 2, name: 'Mon' },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a pack with no calendar has none: the engine imposes no week', () => {
|
||||||
|
const state = startGame(testPack, { seed: 'no-calendar' });
|
||||||
|
assert.deepEqual(state.calendar, {});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scheduled upkeep falls due on its own period, not every turn', () => {
|
||||||
|
const scheduled = structuredClone(testPack);
|
||||||
|
scheduled.upkeep = [
|
||||||
|
{ label: 'daily', effects: [{ path: 'money', op: 'add', value: -1 }] },
|
||||||
|
{ label: 'weekly rent', every: 7, offset: 6, effects: [{ path: 'money', op: 'add', value: -50 }] },
|
||||||
|
{ label: 'fortnightly pay', every: 14, offset: 6, effects: [{ path: 'money', op: 'add', value: 200 }] },
|
||||||
|
];
|
||||||
|
|
||||||
|
let state = startGame(scheduled, { seed: 'schedule' });
|
||||||
|
const dueOn = [];
|
||||||
|
for (let turn = 1; turn <= 22; turn++) {
|
||||||
|
const result = takeTurn(scheduled, state, currentTurn(scheduled, state).options.find((o) => o.available).option.id);
|
||||||
|
dueOn.push(result.record.upkeep.map((c) => c.label));
|
||||||
|
state = result.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.deepEqual(dueOn[0], ['daily'], 'turn 1 is an ordinary day');
|
||||||
|
assert.deepEqual(dueOn[6], ['daily', 'weekly rent', 'fortnightly pay'], 'turn 7 is rent and payday');
|
||||||
|
assert.deepEqual(dueOn[13], ['daily', 'weekly rent'], 'turn 14 is rent only');
|
||||||
|
assert.deepEqual(dueOn[20], ['daily', 'weekly rent', 'fortnightly pay'], 'turn 21 is both again');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('moving stage changes which events are drawn', () => {
|
||||||
|
const twoStage = structuredClone(testPack);
|
||||||
|
twoStage.events.push({
|
||||||
|
id: 'upstairs_only',
|
||||||
|
stage: 'attic',
|
||||||
|
weight: 1,
|
||||||
|
description: 'It is quieter up here.',
|
||||||
|
options: [{ id: 'wait', label: 'Wait.', effects: [] }],
|
||||||
|
});
|
||||||
|
twoStage.events.find((e) => e.id === 'routine').options.push({
|
||||||
|
id: 'climb',
|
||||||
|
label: 'Go up to the attic.',
|
||||||
|
effects: [{ path: 'stage', op: 'set', value: 'attic' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
let state = startGame(twoStage, { seed: 'stages' });
|
||||||
|
// Walk to a turn offering the climb, then take it.
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const turn = currentTurn(twoStage, state);
|
||||||
|
if (turn.event.id === 'routine') {
|
||||||
|
state = takeTurn(twoStage, state, 'climb').state;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
state = takeTurn(twoStage, state, turn.options.find((o) => o.available).option.id).state;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(state.stage, 'attic');
|
||||||
|
assert.equal(currentTurn(twoStage, state).event.id, 'upstairs_only',
|
||||||
|
'only the new stage\'s events are reachable now');
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { getPath, setPath, hasPath, parsePath, listPaths } from '../src/engine/paths.js';
|
||||||
|
|
||||||
|
const state = { money: 100, skills: { talking: 2 }, relationships: { chief: { likes_you: 50 } } };
|
||||||
|
|
||||||
|
test('reads nested paths', () => {
|
||||||
|
assert.equal(getPath(state, 'money'), 100);
|
||||||
|
assert.equal(getPath(state, 'skills.talking'), 2);
|
||||||
|
assert.equal(getPath(state, 'relationships.chief.likes_you'), 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing paths read as undefined, not as a throw', () => {
|
||||||
|
assert.equal(getPath(state, 'skills.welding'), undefined);
|
||||||
|
assert.equal(getPath(state, 'nothing.at.all'), undefined);
|
||||||
|
assert.equal(hasPath(state, 'skills.welding'), false);
|
||||||
|
assert.equal(hasPath(state, 'skills.talking'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('writes without mutating the original', () => {
|
||||||
|
const next = setPath(state, 'skills.talking', 5);
|
||||||
|
assert.equal(next.skills.talking, 5);
|
||||||
|
assert.equal(state.skills.talking, 2, 'original state must be untouched');
|
||||||
|
assert.equal(next.relationships, state.relationships, 'untouched branches are shared');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creates missing intermediate objects', () => {
|
||||||
|
const next = setPath({}, 'flags.deep.nested', true);
|
||||||
|
assert.equal(next.flags.deep.nested, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects prototype-polluting paths', () => {
|
||||||
|
assert.throws(() => parsePath('__proto__.polluted'), /forbidden/);
|
||||||
|
assert.throws(() => parsePath('constructor'), /forbidden/);
|
||||||
|
assert.throws(() => setPath({}, 'a.prototype.b', 1), /forbidden/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects malformed paths', () => {
|
||||||
|
assert.throws(() => parsePath(''), /non-empty/);
|
||||||
|
assert.throws(() => parsePath('money.'), /invalid path segment/);
|
||||||
|
assert.throws(() => parsePath('a b'), /invalid path segment/);
|
||||||
|
assert.throws(() => parsePath(42), /non-empty string/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lists leaf paths', () => {
|
||||||
|
assert.deepEqual(listPaths(state), ['money', 'skills.talking', 'relationships.chief.likes_you']);
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createRng } from '../src/engine/rng.js';
|
||||||
|
|
||||||
|
test('the same seed produces the same sequence', () => {
|
||||||
|
const a = createRng('seed-one');
|
||||||
|
const b = createRng('seed-one');
|
||||||
|
const draws = Array.from({ length: 20 }, () => [a.next(), b.next()]);
|
||||||
|
for (const [x, y] of draws) assert.equal(x, y);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('different seeds diverge', () => {
|
||||||
|
const a = Array.from({ length: 10 }, (_, i) => createRng('one').next());
|
||||||
|
const b = Array.from({ length: 10 }, (_, i) => createRng('two').next());
|
||||||
|
assert.notDeepEqual(a, b);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a generator resumes exactly from a saved position', () => {
|
||||||
|
const original = createRng('resume-me');
|
||||||
|
for (let i = 0; i < 7; i++) original.next();
|
||||||
|
const position = original.getPosition();
|
||||||
|
|
||||||
|
const resumed = createRng('resume-me', position);
|
||||||
|
const expected = Array.from({ length: 5 }, () => original.next());
|
||||||
|
const actual = Array.from({ length: 5 }, () => resumed.next());
|
||||||
|
assert.deepEqual(actual, expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a saved position survives a JSON round trip', () => {
|
||||||
|
const rng = createRng('json');
|
||||||
|
rng.next();
|
||||||
|
const revived = createRng('json', JSON.parse(JSON.stringify(rng.getPosition())));
|
||||||
|
assert.equal(revived.next(), createRng('json', rng.getPosition()).next());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('draws stay in range', () => {
|
||||||
|
const rng = createRng('range');
|
||||||
|
for (let i = 0; i < 500; i++) {
|
||||||
|
const value = rng.next();
|
||||||
|
assert.ok(value >= 0 && value < 1);
|
||||||
|
assert.ok(rng.int(10) >= 0 && rng.int(10) < 10);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('weighted picks respect weight and skip zero-weight entries', () => {
|
||||||
|
const rng = createRng('weighted');
|
||||||
|
const items = [{ id: 'common', w: 99 }, { id: 'rare', w: 1 }, { id: 'never', w: 0 }];
|
||||||
|
const counts = { common: 0, rare: 0, never: 0 };
|
||||||
|
for (let i = 0; i < 2000; i++) counts[rng.weighted(items, (i2) => i2.w).id]++;
|
||||||
|
assert.equal(counts.never, 0, 'zero weight must never be drawn');
|
||||||
|
assert.ok(counts.common > counts.rare * 10, `expected common to dominate, got ${JSON.stringify(counts)}`);
|
||||||
|
assert.ok(counts.rare > 0, 'a weight of 1 in 100 should still appear over 2000 draws');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('weighted returns undefined when nothing has weight', () => {
|
||||||
|
const rng = createRng('empty');
|
||||||
|
assert.equal(rng.weighted([{ w: 0 }], (i) => i.w), undefined);
|
||||||
|
assert.equal(rng.weighted([], () => 1), undefined);
|
||||||
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { eligibleEvents, selectEvent, resolveOptions } from '../src/engine/select.js';
|
||||||
|
import { createState, resolveSchema } from '../src/engine/state.js';
|
||||||
|
import { createRng } from '../src/engine/rng.js';
|
||||||
|
import { setPath } from '../src/engine/paths.js';
|
||||||
|
import { testPack } from './fixtures/test-pack.js';
|
||||||
|
|
||||||
|
const schema = resolveSchema(testPack);
|
||||||
|
const fresh = () => createState(testPack, { seed: 'select', schema });
|
||||||
|
|
||||||
|
test('a conditional event is out of the pool until its condition holds', () => {
|
||||||
|
const rich = fresh();
|
||||||
|
assert.ok(!eligibleEvents(testPack, rich).eligible.some((e) => e.id === 'hard_times'));
|
||||||
|
|
||||||
|
const broke = setPath(rich, 'money', -20);
|
||||||
|
assert.ok(eligibleEvents(testPack, broke).eligible.some((e) => e.id === 'hard_times'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exclusions say why', () => {
|
||||||
|
const { excluded } = eligibleEvents(testPack, fresh());
|
||||||
|
const hardTimes = excluded.find((x) => x.event.id === 'hard_times');
|
||||||
|
assert.equal(hardTimes.reason, 'requires');
|
||||||
|
assert.equal(hardTimes.failed[0].path, 'money');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a once-only event leaves the pool after it fires', () => {
|
||||||
|
const state = setPath(fresh(), 'seen.first_day', 1);
|
||||||
|
const { eligible, excluded } = eligibleEvents(testPack, state);
|
||||||
|
assert.ok(!eligible.some((e) => e.id === 'first_day'));
|
||||||
|
assert.equal(excluded.find((x) => x.event.id === 'first_day').reason, 'once');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a cooldown holds an event back for the declared number of turns', () => {
|
||||||
|
let state = fresh();
|
||||||
|
state = setPath(state, 'lastSeen.audit', 5);
|
||||||
|
|
||||||
|
state = setPath(state, 'turn', 9);
|
||||||
|
assert.equal(eligibleEvents(testPack, state).excluded.find((x) => x.event.id === 'audit')?.reason, 'cooldown');
|
||||||
|
|
||||||
|
state = setPath(state, 'turn', 15);
|
||||||
|
assert.ok(eligibleEvents(testPack, state).eligible.some((e) => e.id === 'audit'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('events belonging to another stage are never drawn', () => {
|
||||||
|
const elsewhere = setPath(fresh(), 'stage', 'penthouse');
|
||||||
|
const { eligible, excluded } = eligibleEvents(testPack, elsewhere);
|
||||||
|
assert.equal(eligible.length, 0);
|
||||||
|
assert.ok(excluded.every((x) => x.reason === 'stage'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selection is a weighted draw, so the heavy event dominates', () => {
|
||||||
|
const rng = createRng('draw');
|
||||||
|
const state = setPath(fresh(), 'seen.first_day', 1); // take the opener out of the way
|
||||||
|
const counts = {};
|
||||||
|
for (let i = 0; i < 1000; i++) {
|
||||||
|
const event = selectEvent(testPack, state, rng);
|
||||||
|
counts[event.id] = (counts[event.id] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
assert.ok(counts.routine > 900, `routine (weight 100) should dominate audit (weight 5): ${JSON.stringify(counts)}`);
|
||||||
|
assert.ok(counts.audit > 0, 'a low weight is still reachable');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an option below its skill gate resolves as locked, with the reason', () => {
|
||||||
|
const event = testPack.events.find((e) => e.id === 'routine');
|
||||||
|
const resolved = resolveOptions(fresh(), event);
|
||||||
|
|
||||||
|
const charm = resolved.find((r) => r.option.id === 'charm');
|
||||||
|
assert.equal(charm.available, false);
|
||||||
|
assert.equal(charm.failed[0].path, 'skills.talking');
|
||||||
|
assert.equal(charm.hidden, false, 'locked options are shown greyed out unless the content says otherwise');
|
||||||
|
|
||||||
|
assert.equal(resolved.find((r) => r.option.id === 'work').available, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an option unlocks once the gate is met', () => {
|
||||||
|
const skilled = setPath(fresh(), 'skills.talking', 5);
|
||||||
|
const event = testPack.events.find((e) => e.id === 'routine');
|
||||||
|
assert.equal(resolveOptions(skilled, event).find((r) => r.option.id === 'charm').available, true);
|
||||||
|
});
|
||||||
+578
@@ -0,0 +1,578 @@
|
|||||||
|
// UI wiring tests.
|
||||||
|
//
|
||||||
|
// These drive the real app module against a fake DOM (test/fixtures/fake-dom.js).
|
||||||
|
// They prove the wiring runs — imports resolve, handlers fire, the screens
|
||||||
|
// render the right text, choices reach the engine, saves and the feedback log
|
||||||
|
// are written. They are not a substitute for playing it in a browser: layout,
|
||||||
|
// styling, file downloads and file imports are outside their reach.
|
||||||
|
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { installFakeDom, byClass, firstByClass, byTag } from './fixtures/fake-dom.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boot the app fresh against a clean fake DOM.
|
||||||
|
*
|
||||||
|
* `pack` is written to the remembered-pack slot before booting, which is how a
|
||||||
|
* returning player lands back in a setting. Pass null to boot with nothing
|
||||||
|
* remembered and nothing saved, which is the first-time player who gets the
|
||||||
|
* setting picker instead of a turn.
|
||||||
|
*/
|
||||||
|
async function boot({ pack = 'corporateladder' } = {}) {
|
||||||
|
const dom = installFakeDom();
|
||||||
|
if (pack) dom.storage.setItem('theladder.pack.v1', pack);
|
||||||
|
// A fresh module instance per test: the app holds module-level state.
|
||||||
|
const { boot: bootApp } = await import(`../src/ui/app.js?t=${Math.random()}`);
|
||||||
|
bootApp(dom.root);
|
||||||
|
return dom;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = (root) => root.textContent;
|
||||||
|
const optionButtons = (root) => byClass(root, 'option');
|
||||||
|
const enabledOptions = (root) => optionButtons(root).filter((b) => !b.disabled);
|
||||||
|
const wrapUp = (root) => byTag(root, 'button').find((b) => /Wrap up/.test(b.textContent));
|
||||||
|
|
||||||
|
test('tabbing past the last option does not land on the button that ends the run', async () => {
|
||||||
|
// Document order is tab order. "Wrap up my career" sat immediately after the
|
||||||
|
// last option, so one Tab too many and Enter showed the retrospective — which
|
||||||
|
// a real player, on the keyboard, read as the game ending at day 31.
|
||||||
|
const dom = await boot();
|
||||||
|
try {
|
||||||
|
const buttons = byTag(dom.root, 'button');
|
||||||
|
const lastOption = buttons.map((b) => /option/.test(b.className ?? '')).lastIndexOf(true);
|
||||||
|
const next = buttons[lastOption + 1];
|
||||||
|
assert.ok(next, 'there is something after the options');
|
||||||
|
assert.doesNotMatch(next.textContent, /Wrap up/,
|
||||||
|
'the next thing Tab reaches should not be the one that leaves the turn');
|
||||||
|
assert.equal(wrapUp(dom.root), buttons[buttons.length - 1],
|
||||||
|
'it belongs last, behind everything harmless');
|
||||||
|
} finally {
|
||||||
|
dom.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the app boots to a playable turn screen', async () => {
|
||||||
|
const dom = await boot();
|
||||||
|
try {
|
||||||
|
assert.ok(firstByClass(dom.root, 'screen--turn'), 'the turn screen rendered');
|
||||||
|
assert.match(text(dom.root), /Day 1/, 'the day counter is showing');
|
||||||
|
assert.match(text(dom.root), /Marlene Hobb/, 'the characters are listed');
|
||||||
|
assert.match(text(dom.root), /Trevor Pike/);
|
||||||
|
assert.ok(enabledOptions(dom.root).length > 0, 'there is something to click');
|
||||||
|
} finally {
|
||||||
|
dom.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the header shows the pack\'s declared stats, and hides what it asks to hide', async () => {
|
||||||
|
const dom = await boot();
|
||||||
|
try {
|
||||||
|
const stats = text(firstByClass(dom.root, 'stats'));
|
||||||
|
assert.match(stats, /Money/);
|
||||||
|
assert.match(stats, /\$520/, 'money is formatted as money');
|
||||||
|
assert.match(stats, /Negotiation/);
|
||||||
|
assert.match(stats, /Standing/, 'reputation is renamed by the pack');
|
||||||
|
assert.doesNotMatch(stats, /Debt/, 'debt is hidden while it is zero');
|
||||||
|
assert.doesNotMatch(stats, /Notable/, 'hidden state stays out of the header');
|
||||||
|
} finally {
|
||||||
|
dom.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('choosing an option shows the outcome, the deltas and the upkeep', async () => {
|
||||||
|
const dom = await boot();
|
||||||
|
try {
|
||||||
|
enabledOptions(dom.root)[0].click();
|
||||||
|
|
||||||
|
assert.ok(firstByClass(dom.root, 'outcome'), 'the outcome view rendered');
|
||||||
|
assert.match(text(dom.root), /What that changed/);
|
||||||
|
assert.match(text(dom.root), /And then the day happened/, 'upkeep is shown separately');
|
||||||
|
|
||||||
|
const upkeep = text(firstByClass(dom.root, 'outcome__upkeep'));
|
||||||
|
// Labelled by what caused it, not by the path it moved — this is the
|
||||||
|
// answer to "what was that daily drain for?".
|
||||||
|
assert.match(upkeep, /Coffee, transit, lunch/);
|
||||||
|
assert.doesNotMatch(upkeep, /Money/, 'the bare path name is not useful here');
|
||||||
|
assert.ok(byClass(dom.root, 'change').length > 0, 'individual changes are listed');
|
||||||
|
} finally {
|
||||||
|
dom.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('advancing moves to the next turn', async () => {
|
||||||
|
const dom = await boot();
|
||||||
|
try {
|
||||||
|
enabledOptions(dom.root)[0].click();
|
||||||
|
firstByClass(dom.root, 'button--next').click();
|
||||||
|
|
||||||
|
assert.ok(firstByClass(dom.root, 'scenario'), 'back to choosing');
|
||||||
|
assert.match(text(dom.root), /Day 2/);
|
||||||
|
} finally {
|
||||||
|
dom.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a locked option is shown, disabled, and says what it needs', async () => {
|
||||||
|
const dom = await boot();
|
||||||
|
try {
|
||||||
|
// Play until a locked option appears — the pack gates several.
|
||||||
|
let locked = null;
|
||||||
|
for (let i = 0; i < 40 && !locked; i++) {
|
||||||
|
locked = optionButtons(dom.root).find((b) => b.disabled);
|
||||||
|
if (locked) break;
|
||||||
|
enabledOptions(dom.root)[0].click();
|
||||||
|
firstByClass(dom.root, 'button--next').click();
|
||||||
|
}
|
||||||
|
assert.ok(locked, 'a gated option should turn up within 40 turns');
|
||||||
|
assert.match(locked.textContent, /Needs /, 'the requirement is spelled out');
|
||||||
|
locked.click(); // must be inert
|
||||||
|
assert.ok(firstByClass(dom.root, 'scenario'), 'clicking a locked option does nothing');
|
||||||
|
} finally {
|
||||||
|
dom.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('progress is written to local storage and restored on the next boot', async () => {
|
||||||
|
const dom = await boot();
|
||||||
|
try {
|
||||||
|
enabledOptions(dom.root)[0].click();
|
||||||
|
firstByClass(dom.root, 'button--next').click();
|
||||||
|
enabledOptions(dom.root)[0].click();
|
||||||
|
firstByClass(dom.root, 'button--next').click();
|
||||||
|
|
||||||
|
const saved = JSON.parse(dom.storage.getItem('theladder.save.v1.corporateladder'));
|
||||||
|
assert.equal(saved.state.turn, 3);
|
||||||
|
|
||||||
|
// Boot again against the same storage: the run should resume, not restart.
|
||||||
|
const { boot: bootAgain } = await import(`../src/ui/app.js?t=${Math.random()}`);
|
||||||
|
const secondRoot = dom.document.createElement('div');
|
||||||
|
bootAgain(secondRoot);
|
||||||
|
assert.match(text(secondRoot), /Day 3/, 'the saved run resumed');
|
||||||
|
} finally {
|
||||||
|
dom.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every played turn is logged, separately from the save', async () => {
|
||||||
|
const dom = await boot();
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
enabledOptions(dom.root)[0].click();
|
||||||
|
firstByClass(dom.root, 'button--next').click();
|
||||||
|
}
|
||||||
|
|
||||||
|
const log = JSON.parse(dom.storage.getItem('theladder.feedback.v1'));
|
||||||
|
assert.equal(log.length, 3);
|
||||||
|
|
||||||
|
const entry = log[0];
|
||||||
|
assert.ok(entry.event_id && entry.action_taken, 'the choice is recorded');
|
||||||
|
assert.ok(Array.isArray(entry.changes) && entry.changes.length > 0, 'the diff is recorded');
|
||||||
|
assert.ok(Array.isArray(entry.offered), 'the options offered are recorded');
|
||||||
|
assert.equal(entry.rating, null, 'feedback starts empty');
|
||||||
|
|
||||||
|
const save = JSON.parse(dom.storage.getItem('theladder.save.v1.corporateladder'));
|
||||||
|
assert.equal(save.state.history.length, 3);
|
||||||
|
assert.ok(!JSON.stringify(save).includes('rating'), 'feedback is not mixed into the save');
|
||||||
|
} finally {
|
||||||
|
dom.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the feedback widget is optional, and records a rating when used', async () => {
|
||||||
|
const dom = await boot();
|
||||||
|
try {
|
||||||
|
enabledOptions(dom.root)[0].click();
|
||||||
|
|
||||||
|
const ratings = byClass(dom.root, 'rating');
|
||||||
|
assert.equal(ratings.length, 3, 'up, neutral and down');
|
||||||
|
|
||||||
|
ratings[0].click();
|
||||||
|
const log = JSON.parse(dom.storage.getItem('theladder.feedback.v1'));
|
||||||
|
assert.equal(log[0].rating, 'up');
|
||||||
|
|
||||||
|
// Clicking the same rating again clears it.
|
||||||
|
byClass(dom.root, 'rating')[0].click();
|
||||||
|
assert.equal(JSON.parse(dom.storage.getItem('theladder.feedback.v1'))[0].rating, null);
|
||||||
|
} finally {
|
||||||
|
dom.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the comment fields write through without re-rendering under the cursor', async () => {
|
||||||
|
const dom = await boot();
|
||||||
|
try {
|
||||||
|
enabledOptions(dom.root)[0].click();
|
||||||
|
firstByClass(dom.root, 'feedback__toggle').click();
|
||||||
|
|
||||||
|
const fields = byTag(firstByClass(dom.root, 'feedback__detail'), 'textarea');
|
||||||
|
assert.equal(fields.length, 2, 'a comment box and a "what did you want to do" box');
|
||||||
|
|
||||||
|
fields[0].value = 'the wheel bit made me laugh';
|
||||||
|
fields[0].dispatch('input');
|
||||||
|
fields[1].value = 'I wanted to just hide in the stairwell';
|
||||||
|
fields[1].dispatch('input');
|
||||||
|
|
||||||
|
// The same element objects must still be live — a re-render would have
|
||||||
|
// replaced them and thrown the player out of the box they are typing in.
|
||||||
|
assert.equal(byTag(firstByClass(dom.root, 'feedback__detail'), 'textarea')[0], fields[0]);
|
||||||
|
|
||||||
|
// Written through on the way to the next turn.
|
||||||
|
firstByClass(dom.root, 'button--next').click();
|
||||||
|
const entry = JSON.parse(dom.storage.getItem('theladder.feedback.v1'))[0];
|
||||||
|
assert.equal(entry.comment, 'the wheel bit made me laugh');
|
||||||
|
assert.equal(entry.desired_but_unavailable_action, 'I wanted to just hide in the stairwell');
|
||||||
|
} finally {
|
||||||
|
dom.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the retrospective summarises the run and is not an ending', async () => {
|
||||||
|
const dom = await boot();
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
enabledOptions(dom.root)[0].click();
|
||||||
|
firstByClass(dom.root, 'button--next').click();
|
||||||
|
}
|
||||||
|
wrapUp(dom.root).click();
|
||||||
|
|
||||||
|
assert.ok(firstByClass(dom.root, 'screen--retrospective'));
|
||||||
|
assert.match(text(dom.root), /not an ending/, 'it says so, rather than implying it');
|
||||||
|
const body = text(dom.root);
|
||||||
|
assert.match(body, /Career retrospective/);
|
||||||
|
assert.match(body, /6 days in the mailroom/);
|
||||||
|
assert.match(body, /Where you stand with people/);
|
||||||
|
assert.match(body, /Marlene Hobb/);
|
||||||
|
assert.match(body, /Situations you saw/);
|
||||||
|
|
||||||
|
firstByClass(dom.root, 'button--primary').click(); // "Keep going"
|
||||||
|
assert.ok(firstByClass(dom.root, 'screen--turn'), 'the player can carry on');
|
||||||
|
assert.match(text(dom.root), /Day 7/);
|
||||||
|
} finally {
|
||||||
|
dom.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a run with nothing notable still gets a retrospective', async () => {
|
||||||
|
const dom = await boot();
|
||||||
|
try {
|
||||||
|
wrapUp(dom.root).click(); // wrap up on turn one
|
||||||
|
assert.ok(firstByClass(dom.root, 'screen--retrospective'));
|
||||||
|
assert.match(text(dom.root), /Nothing that anybody would write down/);
|
||||||
|
} finally {
|
||||||
|
dom.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the header names the day and counts the week', async () => {
|
||||||
|
const dom = await boot();
|
||||||
|
try {
|
||||||
|
const hud = text(firstByClass(dom.root, 'hud'));
|
||||||
|
assert.match(hud, /Monday/, 'the day is named');
|
||||||
|
assert.match(hud, /Week 1 · Day 1/, 'the week and the day number are both shown');
|
||||||
|
} finally {
|
||||||
|
dom.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the diary says what is coming, so the drain is not a mystery', async () => {
|
||||||
|
const dom = await boot();
|
||||||
|
try {
|
||||||
|
const diary = text(firstByClass(dom.root, 'diary'));
|
||||||
|
assert.match(diary, /Wages in 4 days/, 'payday is announced before it lands');
|
||||||
|
assert.match(diary, /Rent in 7 days/);
|
||||||
|
assert.doesNotMatch(diary, /Loan service/, 'charges that do not apply are not listed');
|
||||||
|
} finally {
|
||||||
|
dom.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wages and rent appear in the ledger on the days they fall due', async () => {
|
||||||
|
const dom = await boot();
|
||||||
|
try {
|
||||||
|
const ledgerOn = [];
|
||||||
|
for (let day = 1; day <= 8; day++) {
|
||||||
|
enabledOptions(dom.root)[0].click();
|
||||||
|
const upkeep = firstByClass(dom.root, 'outcome__upkeep');
|
||||||
|
ledgerOn.push(upkeep ? upkeep.textContent : '');
|
||||||
|
firstByClass(dom.root, 'button--next').click();
|
||||||
|
}
|
||||||
|
assert.match(ledgerOn[0], /Coffee, transit, lunch/, 'small change every day');
|
||||||
|
assert.doesNotMatch(ledgerOn[0], /Wages/, 'no wages on day 1');
|
||||||
|
assert.match(ledgerOn[4], /Wages/, 'paid on day 5, the first Friday');
|
||||||
|
assert.match(ledgerOn[7], /Rent/, 'rent on day 8, the second Monday');
|
||||||
|
} finally {
|
||||||
|
dom.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the status bar says what you are playing, which pack, and who you are', async () => {
|
||||||
|
const dom = await boot();
|
||||||
|
try {
|
||||||
|
const status = text(firstByClass(dom.root, 'statusbar'));
|
||||||
|
assert.match(status, /The Ladder/, 'the game');
|
||||||
|
assert.match(status, /Corporate Ladder/, 'the pack');
|
||||||
|
assert.match(status, /Mail Associate I/, 'the current title');
|
||||||
|
assert.match(status, /\d+\.\d+\.\d+ · /, 'the version and build');
|
||||||
|
} finally {
|
||||||
|
dom.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the status bar title follows the player up a tier', async () => {
|
||||||
|
const { statusBar } = await import('../src/ui/components.js');
|
||||||
|
const { corporateLadder } = await import('../content/corporateladder/index.js');
|
||||||
|
const dom = installFakeDom();
|
||||||
|
try {
|
||||||
|
const promoted = statusBar(corporateLadder, { stage: 'dispatch' });
|
||||||
|
assert.match(promoted.textContent, /Dispatch Coordinator/);
|
||||||
|
} finally {
|
||||||
|
dom.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the keyboard alone can play a turn: focus lands where Enter should go', async () => {
|
||||||
|
const dom = await boot();
|
||||||
|
try {
|
||||||
|
// Choosing: the first option a player can actually take.
|
||||||
|
const firstPlayable = enabledOptions(dom.root)[0];
|
||||||
|
assert.equal(dom.document.activeElement, firstPlayable,
|
||||||
|
'the first available option is focused, so Enter takes it');
|
||||||
|
|
||||||
|
dom.document.activeElement.click();
|
||||||
|
|
||||||
|
// Outcome: the button that moves the run on.
|
||||||
|
const next = firstByClass(dom.root, 'button--next');
|
||||||
|
assert.equal(dom.document.activeElement, next,
|
||||||
|
'the next-turn button is focused, so Enter advances');
|
||||||
|
|
||||||
|
dom.document.activeElement.click();
|
||||||
|
assert.ok(firstByClass(dom.root, 'scenario'), 'and we are choosing again');
|
||||||
|
assert.equal(dom.document.activeElement, enabledOptions(dom.root)[0]);
|
||||||
|
} finally {
|
||||||
|
dom.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('locked options are not in the tab order', async () => {
|
||||||
|
const dom = await boot();
|
||||||
|
try {
|
||||||
|
// Disabled buttons are skipped by the browser's own tab handling; the
|
||||||
|
// guarantee this test protects is that locked options stay disabled.
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
const locked = optionButtons(dom.root).filter((b) => b.disabled);
|
||||||
|
if (locked.length) {
|
||||||
|
assert.ok(locked.every((b) => b.disabled === true));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
enabledOptions(dom.root)[0].click();
|
||||||
|
firstByClass(dom.root, 'button--next').click();
|
||||||
|
}
|
||||||
|
assert.fail('no locked option appeared in 40 turns');
|
||||||
|
} finally {
|
||||||
|
dom.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the ledger reads as English, and leaves bookkeeping out of it', async () => {
|
||||||
|
const { changeList } = await import('../src/ui/components.js');
|
||||||
|
const { corporateLadder: pack } = await import('../content/corporateladder/index.js');
|
||||||
|
const dom = installFakeDom();
|
||||||
|
try {
|
||||||
|
const rendered = changeList(pack, [
|
||||||
|
{ path: 'money', op: 'add', value: -20, from: 520, to: 500, clamped: false },
|
||||||
|
{ path: 'flags.holds_the_envelope', op: 'set', value: true, from: false, to: true, clamped: false },
|
||||||
|
{ path: 'notable', op: 'push', value: 'Kept an envelope.', from: [], to: ['Kept an envelope.'], clamped: false },
|
||||||
|
]);
|
||||||
|
const body = rendered.textContent;
|
||||||
|
|
||||||
|
assert.match(body, /Money/);
|
||||||
|
assert.match(body, /−\$20/);
|
||||||
|
assert.match(body, /The envelope is in your locker\./, 'a flag reads as a sentence');
|
||||||
|
assert.doesNotMatch(body, /now true/, 'not as a boolean');
|
||||||
|
assert.doesNotMatch(body, /Notable/, 'the retrospective\'s own list stays out of the ledger');
|
||||||
|
assert.doesNotMatch(body, /Kept an envelope\./);
|
||||||
|
} finally {
|
||||||
|
dom.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a first-time player is asked which setting to play', async () => {
|
||||||
|
// Nothing saved and nothing remembered: the front door is the picker rather
|
||||||
|
// than whichever pack happens to be first in the registry.
|
||||||
|
const dom = await boot({ pack: null });
|
||||||
|
try {
|
||||||
|
assert.ok(firstByClass(dom.root, 'screen--packs'), 'the picker rendered');
|
||||||
|
assert.match(text(dom.root), /Corporate Ladder/);
|
||||||
|
assert.match(text(dom.root), /The Frontier/);
|
||||||
|
assert.match(text(dom.root), /Not started/, 'a pack with no run says so');
|
||||||
|
|
||||||
|
const start = byClass(dom.root, 'button').find((b) => /Start here/.test(b.textContent));
|
||||||
|
assert.ok(start, 'the picker offers a way in');
|
||||||
|
start.click();
|
||||||
|
assert.ok(firstByClass(dom.root, 'screen--turn'), 'choosing a setting starts it');
|
||||||
|
} finally {
|
||||||
|
dom.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('switching settings keeps both runs', async () => {
|
||||||
|
// The defect this guards against: one global save slot, so the first switch
|
||||||
|
// silently threw the other career away.
|
||||||
|
const dom = await boot({ pack: 'corporateladder' });
|
||||||
|
try {
|
||||||
|
enabledOptions(dom.root)[0].click();
|
||||||
|
byClass(dom.root, 'button').find((b) => /Next/.test(b.textContent))?.click();
|
||||||
|
assert.match(text(dom.root), /Day 2/, 'a turn was played in the first setting');
|
||||||
|
|
||||||
|
byClass(dom.root, 'button').find((b) => /Change setting/.test(b.textContent)).click();
|
||||||
|
const toFrontier = byClass(dom.root, 'button')
|
||||||
|
.find((b) => /Start here|Go back to this run/.test(b.textContent));
|
||||||
|
toFrontier.click();
|
||||||
|
|
||||||
|
assert.match(text(dom.root), /Hollis Vane/, 'the second setting is running');
|
||||||
|
assert.match(text(dom.root), /Day 1/, 'and it started fresh');
|
||||||
|
|
||||||
|
// Both slots are on disk, independently.
|
||||||
|
const corporate = JSON.parse(dom.storage.getItem('theladder.save.v1.corporateladder'));
|
||||||
|
const frontier = JSON.parse(dom.storage.getItem('theladder.save.v1.frontier'));
|
||||||
|
assert.equal(corporate.state.turn, 2, 'the first run is still where it was');
|
||||||
|
assert.equal(frontier.state.turn, 1);
|
||||||
|
assert.equal(corporate.state.meta.packId, 'corporateladder');
|
||||||
|
assert.equal(frontier.state.meta.packId, 'frontier');
|
||||||
|
|
||||||
|
// And going back resumes rather than restarting.
|
||||||
|
byClass(dom.root, 'button').find((b) => /Change setting/.test(b.textContent)).click();
|
||||||
|
assert.match(text(dom.root), /Day 2, Mail Associate I, saved/,
|
||||||
|
'the picker reports where the other run is');
|
||||||
|
byClass(dom.root, 'button').find((b) => /Go back to this run/.test(b.textContent)).click();
|
||||||
|
assert.match(text(dom.root), /Day 2/, 'the first career resumed where it was left');
|
||||||
|
assert.match(text(dom.root), /Marlene Hobb/);
|
||||||
|
} finally {
|
||||||
|
dom.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a run saved before settings were selectable is not lost', async () => {
|
||||||
|
// The old single slot, migrated on read. Someone mid-career when this
|
||||||
|
// shipped should find their career, not a new one.
|
||||||
|
const dom = installFakeDom();
|
||||||
|
try {
|
||||||
|
const { startGame } = await import('../src/engine/game.js');
|
||||||
|
const { corporateLadder } = await import('../content/corporateladder/index.js');
|
||||||
|
const legacy = startGame(corporateLadder, { seed: 'legacy' });
|
||||||
|
legacy.turn = 61;
|
||||||
|
dom.storage.setItem('theladder.save.v1',
|
||||||
|
JSON.stringify({ format: 1, savedAt: null, state: legacy }));
|
||||||
|
|
||||||
|
const { boot: bootApp } = await import(`../src/ui/app.js?t=${Math.random()}`);
|
||||||
|
bootApp(dom.root);
|
||||||
|
|
||||||
|
assert.ok(firstByClass(dom.root, 'screen--turn'), 'it booted into the run, not the picker');
|
||||||
|
assert.match(text(dom.root), /Day 61/, 'the old career came back');
|
||||||
|
} finally {
|
||||||
|
dom.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a save written before a stat existed gains it, rather than losing the content', async () => {
|
||||||
|
// The failure this guards against is silent, which is what makes it worth a
|
||||||
|
// test: conditions.js compares numbers only, so with no `lifestyle` path both
|
||||||
|
// `lifestyle < 2` and `lifestyle >= 1` are false. Every event, option and
|
||||||
|
// upkeep entry gated on a stat added since the save was written simply does
|
||||||
|
// not exist for that career, and nothing anywhere says so.
|
||||||
|
const dom = installFakeDom();
|
||||||
|
try {
|
||||||
|
const { startGame } = await import('../src/engine/game.js');
|
||||||
|
const { corporateLadder } = await import('../content/corporateladder/index.js');
|
||||||
|
const before = startGame(corporateLadder, { seed: 'pre-lifestyle' });
|
||||||
|
before.turn = 40;
|
||||||
|
delete before.lifestyle;
|
||||||
|
dom.storage.setItem('theladder.save.v1.corporateladder',
|
||||||
|
JSON.stringify({ format: 1, savedAt: null, state: before }));
|
||||||
|
dom.storage.setItem('theladder.pack.v1', 'corporateladder');
|
||||||
|
|
||||||
|
const { boot: bootApp } = await import(`../src/ui/app.js?t=${Math.random()}`);
|
||||||
|
bootApp(dom.root);
|
||||||
|
|
||||||
|
assert.match(text(dom.root), /Day 40/, 'the career resumed where it was');
|
||||||
|
const saved = JSON.parse(dom.storage.getItem('theladder.save.v1.corporateladder'));
|
||||||
|
assert.equal(saved.state.lifestyle, 0,
|
||||||
|
'the stat the run predates was filled in, and the repair written back');
|
||||||
|
} finally {
|
||||||
|
dom.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the header reads the second pack\'s display map, not the first\'s', async () => {
|
||||||
|
// `display` is a per-pack contract with the interface, and a typo in it is
|
||||||
|
// only visible at runtime. Corporate Ladder's version is covered above; this
|
||||||
|
// is the same assertion against a pack with entirely different stat names.
|
||||||
|
const dom = await boot({ pack: 'frontier' });
|
||||||
|
try {
|
||||||
|
const stats = text(firstByClass(dom.root, 'stats'));
|
||||||
|
assert.match(stats, /Money/);
|
||||||
|
assert.match(stats, /\$40/, 'money is formatted as money');
|
||||||
|
assert.match(stats, /Nerve/);
|
||||||
|
assert.match(stats, /Trade/);
|
||||||
|
assert.match(stats, /Standing/);
|
||||||
|
assert.doesNotMatch(stats, /Negotiation|Logistics/, 'no leakage from the other pack');
|
||||||
|
assert.doesNotMatch(stats, /Owed at the store/, 'the ledger is hidden while it is zero');
|
||||||
|
|
||||||
|
assert.match(text(dom.root), /Cold Fork/, 'the people panel is named by the pack');
|
||||||
|
assert.match(text(dom.root), /Hollis Vane/);
|
||||||
|
assert.match(text(dom.root), /Marshal Ward/);
|
||||||
|
assert.match(text(dom.root), /Hired Hand/, 'the stage title comes from the pack');
|
||||||
|
assert.match(text(dom.root), /Stage day/, 'and so does the name of the day');
|
||||||
|
} finally {
|
||||||
|
dom.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- a drawn cast, and checks (DECISIONS #41) ----------------------------------
|
||||||
|
|
||||||
|
test('a pack with a drawn cast shows whoever this run drew, and never a placeholder', async () => {
|
||||||
|
const dom = await boot({ pack: 'countinghouse' });
|
||||||
|
try {
|
||||||
|
const people = byClass(dom.root, 'person');
|
||||||
|
// One partner, one chief clerk, and two to four clerks.
|
||||||
|
assert.ok(people.length >= 4 && people.length <= 6, `${people.length} people in the panel`);
|
||||||
|
const titles = byClass(dom.root, 'person__title').map((t) => t.textContent);
|
||||||
|
assert.equal(titles.filter((t) => t === 'Senior Partner').length, 1);
|
||||||
|
assert.equal(titles.filter((t) => t === 'Chief Clerk').length, 1);
|
||||||
|
const clerks = titles.filter((t) => t === 'Clerk').length;
|
||||||
|
assert.ok(clerks >= 2 && clerks <= 4, `${clerks} clerks`);
|
||||||
|
for (let turn = 0; turn < 25; turn++) {
|
||||||
|
assert.doesNotMatch(text(dom.root), /\{[a-z_0-9.]+\}/, 'a {placeholder} reached the screen');
|
||||||
|
enabledOptions(dom.root)[0].click();
|
||||||
|
assert.doesNotMatch(text(dom.root), /\{[a-z_0-9.]+\}/, 'a {placeholder} reached the outcome');
|
||||||
|
firstByClass(dom.root, 'button--next').click();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
dom.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a checked option shows its odds in words, and the outcome says whether it came off', async () => {
|
||||||
|
const dom = await boot({ pack: 'countinghouse' });
|
||||||
|
try {
|
||||||
|
let checked;
|
||||||
|
for (let turn = 0; turn < 40 && !checked; turn++) {
|
||||||
|
checked = optionButtons(dom.root).find((b) => !b.disabled && firstByClass(b, 'option__odds'));
|
||||||
|
if (checked) break;
|
||||||
|
enabledOptions(dom.root)[0].click();
|
||||||
|
firstByClass(dom.root, 'button--next').click();
|
||||||
|
}
|
||||||
|
assert.ok(checked, 'a checked option turned up within forty days');
|
||||||
|
const odds = firstByClass(checked, 'option__odds').textContent;
|
||||||
|
assert.match(odds, /^(a long shot|unlikely|an even chance|likely|a safe bet)$/);
|
||||||
|
assert.doesNotMatch(text(dom.root), /\d+%/, 'no percentage is ever shown');
|
||||||
|
|
||||||
|
checked.click();
|
||||||
|
const said = text(firstByClass(dom.root, 'outcome__check'));
|
||||||
|
assert.match(said, /and it (came|did not come) off\./);
|
||||||
|
assert.ok(said.toLowerCase().startsWith(odds), `the outcome repeats the odds: "${said}"`);
|
||||||
|
assert.ok(firstByClass(dom.root, 'outcome__text'), 'and says what happened');
|
||||||
|
} finally {
|
||||||
|
dom.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { validatePack, assertValidPack } from '../src/engine/validate.js';
|
||||||
|
import { testPack } from './fixtures/test-pack.js';
|
||||||
|
|
||||||
|
/** A copy of the fixture with one thing broken. */
|
||||||
|
function broken(mutate) {
|
||||||
|
const pack = structuredClone(testPack);
|
||||||
|
mutate(pack);
|
||||||
|
return validatePack(pack);
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = (result) => result.errors.join('\n');
|
||||||
|
|
||||||
|
test('the fixture pack is valid and warning-free', () => {
|
||||||
|
const result = validatePack(testPack);
|
||||||
|
assert.deepEqual(result.errors, []);
|
||||||
|
assert.deepEqual(result.warnings, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an effect writing an undeclared path is caught', () => {
|
||||||
|
const result = broken((pack) => {
|
||||||
|
pack.events[0].options[0].effects.push({ path: 'skills.wleding', op: 'add', value: 1 });
|
||||||
|
});
|
||||||
|
assert.match(messages(result), /skills\.wleding.*not declared/s);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an amount read from state must read a declared path, with numeric scale and cap', () => {
|
||||||
|
const unknown = broken((pack) => {
|
||||||
|
pack.events[0].options[0].effects.push({ path: 'money', op: 'add', value: { from: 'skills.wleding', times: -1 } });
|
||||||
|
});
|
||||||
|
assert.match(messages(unknown), /reads its amount from "skills\.wleding".*not declared/s);
|
||||||
|
|
||||||
|
const badCap = broken((pack) => {
|
||||||
|
pack.events[0].options[0].effects.push({ path: 'money', op: 'add', value: { from: 'money', cap: 'lots' } });
|
||||||
|
});
|
||||||
|
assert.match(messages(badCap), /non-numeric cap/);
|
||||||
|
|
||||||
|
const fine = broken((pack) => {
|
||||||
|
pack.events[0].options[0].effects.push({ path: 'money', op: 'add', value: { from: 'money', times: -1, cap: 5 } });
|
||||||
|
});
|
||||||
|
assert.deepEqual(fine.errors, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a relationship effect naming a character who does not exist is caught', () => {
|
||||||
|
const result = broken((pack) => {
|
||||||
|
pack.events[0].options[0].effects.push({ path: 'relationships.dave.likes_you', op: 'add', value: 1 });
|
||||||
|
});
|
||||||
|
assert.match(messages(result), /relationships\.dave\.likes_you.*not declared/s);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a condition reading an undeclared path is caught', () => {
|
||||||
|
const result = broken((pack) => {
|
||||||
|
pack.events[0].options[1].requires = [{ path: 'skills.charisma', op: '>=', value: 1 }];
|
||||||
|
});
|
||||||
|
assert.match(messages(result), /skills\.charisma.*not declared/s);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('content may read engine bookkeeping but not write it', () => {
|
||||||
|
const readsTurn = broken((pack) => {
|
||||||
|
// Not the floor event — see the fallback rule below.
|
||||||
|
pack.events.find((e) => e.id === 'audit').requires = [{ path: 'turn', op: '>=', value: 3 }];
|
||||||
|
});
|
||||||
|
assert.deepEqual(readsTurn.errors, []);
|
||||||
|
|
||||||
|
const writesTurn = broken((pack) => {
|
||||||
|
pack.events[0].options[0].effects.push({ path: 'turn', op: 'add', value: 5 });
|
||||||
|
});
|
||||||
|
assert.match(messages(writesTurn), /engine-owned state/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('declaring a reserved root as pack state is caught', () => {
|
||||||
|
const result = broken((pack) => { pack.state['history'] = { initial: [] }; });
|
||||||
|
assert.match(messages(result), /reserved root/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown operators are caught before they reach the engine', () => {
|
||||||
|
const badCondition = broken((pack) => {
|
||||||
|
pack.events[0].options[0].requires = [{ path: 'money', op: 'exceeds', value: 1 }];
|
||||||
|
});
|
||||||
|
assert.match(messages(badCondition), /unknown condition operator/);
|
||||||
|
|
||||||
|
const badEffect = broken((pack) => {
|
||||||
|
pack.events[0].options[0].effects[0].op = 'multiply';
|
||||||
|
});
|
||||||
|
assert.match(messages(badEffect), /unknown effect operation/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('add with a non-numeric value is caught', () => {
|
||||||
|
const result = broken((pack) => { pack.events[0].options[0].effects[0].value = 'ten'; });
|
||||||
|
assert.match(messages(result), /non-numeric/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('duplicate ids are caught', () => {
|
||||||
|
assert.match(messages(broken((pack) => { pack.events[1].id = 'routine'; })), /duplicate id/);
|
||||||
|
assert.match(messages(broken((pack) => { pack.characters[1].id = 'chief'; })), /duplicate id/);
|
||||||
|
assert.match(messages(broken((pack) => { pack.events[0].options[1].id = 'work'; })), /duplicate id/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an id that cannot be a path segment is caught, because ids become state paths', () => {
|
||||||
|
const result = broken((pack) => { pack.events[0].id = 'routine day'; });
|
||||||
|
assert.match(messages(result), /must match/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an event with no options is caught', () => {
|
||||||
|
const result = broken((pack) => { pack.events[0].options = []; });
|
||||||
|
assert.match(messages(result), /no options/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a zero weight is caught, since the event could never fire', () => {
|
||||||
|
const result = broken((pack) => { pack.events[0].weight = 0; });
|
||||||
|
assert.match(messages(result), /greater than zero/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a stage with no unconditional event is caught, since the pool can run dry', () => {
|
||||||
|
const result = broken((pack) => {
|
||||||
|
pack.events[0].requires = [{ path: 'money', op: '>', value: 0 }];
|
||||||
|
});
|
||||||
|
assert.match(messages(result), /no unconditional event/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a starting stage with no events at all is caught', () => {
|
||||||
|
const result = broken((pack) => { pack.startingStage = 'penthouse'; });
|
||||||
|
assert.match(messages(result), /startingStage "penthouse" has no events/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a character with no role_type warns but still plays', () => {
|
||||||
|
const result = broken((pack) => { delete pack.characters[0].role_type; });
|
||||||
|
assert.deepEqual(result.errors, []);
|
||||||
|
assert.match(result.warnings.join('\n'), /role_type/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('assertValidPack throws with every error listed', () => {
|
||||||
|
const pack = structuredClone(testPack);
|
||||||
|
pack.events[0].options[0].effects.push({ path: 'nope', op: 'add', value: 1 });
|
||||||
|
assert.throws(() => assertValidPack(pack), /is invalid/);
|
||||||
|
assert.doesNotThrow(() => assertValidPack(testPack));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('upkeep is validated like any other effect list', () => {
|
||||||
|
const badPath = broken((pack) => {
|
||||||
|
pack.upkeep = [{ label: 'wage', effects: [{ path: 'monie', op: 'add', value: 20 }] }];
|
||||||
|
});
|
||||||
|
assert.match(messages(badPath), /monie.*not declared/s);
|
||||||
|
|
||||||
|
const empty = broken((pack) => { pack.upkeep = [{ label: 'wage', effects: [] }]; });
|
||||||
|
assert.match(messages(empty), /has no effects/);
|
||||||
|
|
||||||
|
const writesReserved = broken((pack) => {
|
||||||
|
pack.upkeep = [{ effects: [{ path: 'turn', op: 'add', value: 1 }] }];
|
||||||
|
});
|
||||||
|
assert.match(messages(writesReserved), /engine-owned state/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a starting relationship for a stat that does not exist is caught', () => {
|
||||||
|
const result = broken((pack) => {
|
||||||
|
pack.characters[0].startingRelationship = { respects_you: 10 };
|
||||||
|
});
|
||||||
|
assert.match(messages(result), /unknown stat "respects_you"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a malformed calendar is caught', () => {
|
||||||
|
const badLength = broken((pack) => { pack.calendar = { cycleLength: 0 }; });
|
||||||
|
assert.match(messages(badLength), /cycleLength 0 must be a positive/);
|
||||||
|
|
||||||
|
const mismatched = broken((pack) => {
|
||||||
|
pack.calendar = { cycleLength: 7, unitNames: ['Mon', 'Tue'] };
|
||||||
|
});
|
||||||
|
assert.match(messages(mismatched), /unitNames has 2 entries but cycleLength is 7/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a malformed upkeep schedule is caught', () => {
|
||||||
|
const badEvery = broken((pack) => {
|
||||||
|
pack.upkeep = [{ label: 'rent', every: 0, effects: [{ path: 'money', op: 'add', value: -1 }] }];
|
||||||
|
});
|
||||||
|
assert.match(messages(badEvery), /every 0 must be a positive/);
|
||||||
|
|
||||||
|
const orphanOffset = broken((pack) => {
|
||||||
|
pack.upkeep = [{ label: 'rent', offset: 3, effects: [{ path: 'money', op: 'add', value: -1 }] }];
|
||||||
|
});
|
||||||
|
assert.deepEqual(orphanOffset.errors, []);
|
||||||
|
assert.match(orphanOffset.warnings.join('\n'), /offset has no effect without every/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('content may move the player between stages, but only to a real one', () => {
|
||||||
|
const good = broken((pack) => {
|
||||||
|
pack.events.push({
|
||||||
|
id: 'upstairs', stage: 'attic', weight: 1, description: 'Up you go.',
|
||||||
|
options: [{ id: 'go', label: 'Go up.', effects: [] }],
|
||||||
|
});
|
||||||
|
pack.events[0].options[0].effects.push({ path: 'stage', op: 'set', value: 'attic' });
|
||||||
|
});
|
||||||
|
assert.deepEqual(good.errors, []);
|
||||||
|
|
||||||
|
const nowhere = broken((pack) => {
|
||||||
|
pack.events[0].options[0].effects.push({ path: 'stage', op: 'set', value: 'penthouse' });
|
||||||
|
});
|
||||||
|
assert.match(messages(nowhere), /stage "penthouse", which has no events/);
|
||||||
|
|
||||||
|
const wrongOp = broken((pack) => {
|
||||||
|
pack.events[0].options[0].effects.push({ path: 'stage', op: 'add', value: 1 });
|
||||||
|
});
|
||||||
|
assert.match(messages(wrongOp), /may only be written with set/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
// Read an exported feedback log and report what actually happened.
|
||||||
|
//
|
||||||
|
// node tools/analyze-log.js theladder-feedback-2026-09-10-11-04-22.json
|
||||||
|
//
|
||||||
|
// The question this exists to answer: how many turns were played against how
|
||||||
|
// many distinct situations, and how often each one came round again. A pack
|
||||||
|
// that invites a 180-turn run needs to be judged on what turn 180 looked like,
|
||||||
|
// not on what turn 5 did.
|
||||||
|
//
|
||||||
|
// It also surfaces the two things worth more than any rating — the gates
|
||||||
|
// players kept bouncing off, and anything they said they wanted to do.
|
||||||
|
|
||||||
|
import { readFile, readdir, stat } from 'node:fs/promises';
|
||||||
|
import { resolve, dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const logsDir = resolve(root, 'logs');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The most recently modified feedback log in logs/, when none was named.
|
||||||
|
*
|
||||||
|
* Feedback logs only. A save export is a .json too, and a playtester who
|
||||||
|
* exports both — as the first one to do so did — would otherwise have the save
|
||||||
|
* read as a log whenever it was the later of the two.
|
||||||
|
*/
|
||||||
|
async function newestLog() {
|
||||||
|
let names;
|
||||||
|
try {
|
||||||
|
names = (await readdir(logsDir))
|
||||||
|
.filter((name) => name.startsWith('theladder-feedback-') && name.endsWith('.json'));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!names.length) return null;
|
||||||
|
|
||||||
|
const timed = await Promise.all(names.map(async (name) => {
|
||||||
|
const path = join(logsDir, name);
|
||||||
|
return { path, at: (await stat(path)).mtimeMs };
|
||||||
|
}));
|
||||||
|
return timed.sort((a, b) => b.at - a.at)[0].path;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bar = (fraction, width = 24) =>
|
||||||
|
'█'.repeat(Math.max(1, Math.round(fraction * width))).padEnd(width, '·');
|
||||||
|
|
||||||
|
const pct = (n, total) => `${((n / total) * 100).toFixed(1)}%`;
|
||||||
|
|
||||||
|
function section(title) {
|
||||||
|
console.log(`\n${title}`);
|
||||||
|
console.log('─'.repeat(title.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split the log into playthroughs. One export can hold several. */
|
||||||
|
function byRun(entries) {
|
||||||
|
const runs = new Map();
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!runs.has(entry.seed)) runs.set(entry.seed, []);
|
||||||
|
runs.get(entry.seed).push(entry);
|
||||||
|
}
|
||||||
|
return runs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How often each situation came round.
|
||||||
|
*
|
||||||
|
* Gaps are pooled *within* runs and never across them. An export can hold
|
||||||
|
* several playthroughs, and turn numbers restart with each — measuring a gap
|
||||||
|
* across that seam produced "every ~-5 turns", which is how this was caught.
|
||||||
|
*/
|
||||||
|
function reportRepetition(runs) {
|
||||||
|
const counts = new Map();
|
||||||
|
const spans = new Map(); // summed (last - first) within each run
|
||||||
|
const pairs = new Map(); // summed (appearances - 1) within each run
|
||||||
|
const onlyTurn = new Map();
|
||||||
|
let turns = 0;
|
||||||
|
|
||||||
|
for (const run of runs.values()) {
|
||||||
|
const firstSeen = new Map();
|
||||||
|
const lastSeen = new Map();
|
||||||
|
const inRun = new Map();
|
||||||
|
for (const entry of run) {
|
||||||
|
turns++;
|
||||||
|
counts.set(entry.event_id, (counts.get(entry.event_id) ?? 0) + 1);
|
||||||
|
inRun.set(entry.event_id, (inRun.get(entry.event_id) ?? 0) + 1);
|
||||||
|
if (!firstSeen.has(entry.event_id)) firstSeen.set(entry.event_id, entry.turn);
|
||||||
|
lastSeen.set(entry.event_id, entry.turn);
|
||||||
|
}
|
||||||
|
for (const [eventId, appearances] of inRun) {
|
||||||
|
if (appearances > 1) {
|
||||||
|
spans.set(eventId, (spans.get(eventId) ?? 0) + (lastSeen.get(eventId) - firstSeen.get(eventId)));
|
||||||
|
pairs.set(eventId, (pairs.get(eventId) ?? 0) + (appearances - 1));
|
||||||
|
} else if (!onlyTurn.has(eventId)) {
|
||||||
|
onlyTurn.set(eventId, firstSeen.get(eventId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const distinct = counts.size;
|
||||||
|
section('Repetition');
|
||||||
|
console.log(`${turns} turns across ${distinct} distinct situations ` +
|
||||||
|
`— each seen ${(turns / distinct).toFixed(1)} times on average.`);
|
||||||
|
const repeated = [...counts.values()].filter((n) => n > 1).length;
|
||||||
|
console.log(`${repeated} of ${distinct} came up more than once; ` +
|
||||||
|
`${distinct - repeated} were one-offs.`);
|
||||||
|
|
||||||
|
const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1]);
|
||||||
|
const most = ranked[0][1];
|
||||||
|
console.log('');
|
||||||
|
for (const [eventId, count] of ranked) {
|
||||||
|
const pairCount = pairs.get(eventId) ?? 0;
|
||||||
|
const gap = pairCount > 0
|
||||||
|
? ` every ~${Math.max(1, Math.round(spans.get(eventId) / pairCount))} turns`
|
||||||
|
: ` once${runs.size > 1 ? ' per run' : `, turn ${onlyTurn.get(eventId)}`}`;
|
||||||
|
console.log(` ${eventId.padEnd(24)} ${String(count).padStart(3)} ` +
|
||||||
|
`${bar(count / most)} ${pct(count, turns).padStart(6)}${gap}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportChoices(entries) {
|
||||||
|
const counts = new Map();
|
||||||
|
for (const entry of entries) {
|
||||||
|
const key = `${entry.event_id}.${entry.action_taken}`;
|
||||||
|
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
section('Choices');
|
||||||
|
const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1]);
|
||||||
|
console.log(`${counts.size} distinct options taken. Most used:`);
|
||||||
|
for (const [key, count] of ranked.slice(0, 8)) {
|
||||||
|
console.log(` ${String(count).padStart(3)}× ${key}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportGates(entries) {
|
||||||
|
// What players were shown but could not take. The strongest signal that a
|
||||||
|
// choice set is too narrow, and it arrives whether or not anyone types.
|
||||||
|
const blocked = new Map();
|
||||||
|
for (const entry of entries) {
|
||||||
|
for (const option of entry.offered ?? []) {
|
||||||
|
// A hidden option was never on screen, so it was never a door anybody
|
||||||
|
// met. Logs from before 0.3.4 do not record `hidden`, and still count
|
||||||
|
// such options here. See DECISIONS #39.
|
||||||
|
if (option.available || option.hidden) continue;
|
||||||
|
const reasons = (option.failed ?? [])
|
||||||
|
.map((c) => `${c.path} ${c.op} ${JSON.stringify(c.value)}`)
|
||||||
|
.join(', ') || 'unstated';
|
||||||
|
const key = `${entry.event_id}.${option.id} — needs ${reasons}`;
|
||||||
|
blocked.set(key, (blocked.get(key) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
section('Doors that stayed shut');
|
||||||
|
if (blocked.size === 0) {
|
||||||
|
console.log('Nothing was ever locked.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ranked = [...blocked.entries()].sort((a, b) => b[1] - a[1]);
|
||||||
|
console.log(`${ranked.reduce((s, [, n]) => s + n, 0)} locked options shown, ` +
|
||||||
|
`${blocked.size} distinct. Most often:`);
|
||||||
|
for (const [key, count] of ranked.slice(0, 10)) {
|
||||||
|
console.log(` ${String(count).padStart(3)}× ${key}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportFeedback(entries) {
|
||||||
|
section('What the player said');
|
||||||
|
const rated = entries.filter((e) => e.rating);
|
||||||
|
const tally = { up: 0, neutral: 0, down: 0 };
|
||||||
|
for (const entry of rated) tally[entry.rating] = (tally[entry.rating] ?? 0) + 1;
|
||||||
|
|
||||||
|
console.log(rated.length
|
||||||
|
? `${rated.length} of ${entries.length} turns rated — ` +
|
||||||
|
`👍 ${tally.up} 😐 ${tally.neutral} 👎 ${tally.down}`
|
||||||
|
: 'No turns were rated.');
|
||||||
|
|
||||||
|
const rateFor = (id) => {
|
||||||
|
const seen = entries.filter((e) => e.event_id === id && e.rating);
|
||||||
|
if (!seen.length) return null;
|
||||||
|
const score = seen.reduce((s, e) => s + (e.rating === 'up' ? 1 : e.rating === 'down' ? -1 : 0), 0);
|
||||||
|
return { id, score, n: seen.length };
|
||||||
|
};
|
||||||
|
const scored = [...new Set(entries.map((e) => e.event_id))]
|
||||||
|
.map(rateFor).filter(Boolean).sort((a, b) => b.score - a.score);
|
||||||
|
if (scored.length > 1) {
|
||||||
|
console.log(`\n best received: ${scored[0].id} (${scored[0].score >= 0 ? '+' : ''}${scored[0].score} over ${scored[0].n})`);
|
||||||
|
const worst = scored.at(-1);
|
||||||
|
console.log(` worst received: ${worst.id} (${worst.score >= 0 ? '+' : ''}${worst.score} over ${worst.n})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const comments = entries.filter((e) => e.comment);
|
||||||
|
if (comments.length) {
|
||||||
|
console.log('\n Comments:');
|
||||||
|
for (const entry of comments) {
|
||||||
|
console.log(` turn ${entry.turn} (${entry.event_id}): ${entry.comment}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const wanted = entries.filter((e) => e.desired_but_unavailable_action);
|
||||||
|
if (wanted.length) {
|
||||||
|
console.log('\n Wanted to do, but could not:');
|
||||||
|
for (const entry of wanted) {
|
||||||
|
console.log(` turn ${entry.turn} (${entry.event_id}): ${entry.desired_but_unavailable_action}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const path = process.argv[2] ?? await newestLog();
|
||||||
|
if (!path) {
|
||||||
|
console.error('usage: npm run analyze -- <exported-feedback-log.json>');
|
||||||
|
console.error(' or: drop the export into logs/ and run npm run analyze');
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!process.argv[2]) console.log(`reading ${path.replace(`${root}/`, '')}\n`);
|
||||||
|
|
||||||
|
const log = JSON.parse(await readFile(path, 'utf8'));
|
||||||
|
const entries = log.entries ?? [];
|
||||||
|
if (!entries.length) {
|
||||||
|
console.log('The log has no entries.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One log can hold turns from more than one setting, and pooling them would
|
||||||
|
// put a mailroom and a freight yard in the same repetition table. The same
|
||||||
|
// rule as the run boundary in DECISIONS #30, a level up: every statistic
|
||||||
|
// here is per pack, and a log is not one sequence.
|
||||||
|
const byPack = new Map();
|
||||||
|
for (const entry of entries) {
|
||||||
|
const id = entry.pack_id ?? 'unknown';
|
||||||
|
if (!byPack.has(id)) byPack.set(id, []);
|
||||||
|
byPack.get(id).push(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (byPack.size > 1) {
|
||||||
|
console.log(`${entries.length} turns logged across ${byPack.size} settings.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [packId, packEntries] of byPack) {
|
||||||
|
const runs = byRun(packEntries);
|
||||||
|
console.log(
|
||||||
|
`${packEntries.length} turns logged across ${runs.size} run${runs.size === 1 ? '' : 's'} ` +
|
||||||
|
`(pack ${packId} ${packEntries[0].pack_version ?? ''})`.trimEnd(),
|
||||||
|
);
|
||||||
|
for (const [seed, run] of runs) {
|
||||||
|
const stages = [...new Set(run.map((e) => e.stage))];
|
||||||
|
console.log(` seed ${seed}: ${run.length} turns, ${stages.join(' → ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
reportRepetition(runs);
|
||||||
|
reportChoices(packEntries);
|
||||||
|
reportGates(packEntries);
|
||||||
|
reportFeedback(packEntries);
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(`could not analyse that log: ${error.message}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
+175
@@ -0,0 +1,175 @@
|
|||||||
|
// Build a single self-contained HTML file.
|
||||||
|
//
|
||||||
|
// Chrome and Firefox refuse ES module imports over file://, so the modular
|
||||||
|
// source that is pleasant to develop cannot be the thing you hand someone.
|
||||||
|
// This inlines the stylesheet and flattens the module graph into one script,
|
||||||
|
// producing a file that plays by double-clicking it.
|
||||||
|
//
|
||||||
|
// It is a deliberately small bundler that understands only the module syntax
|
||||||
|
// this project actually uses, and throws on anything it does not recognise —
|
||||||
|
// a silent mis-bundle would be far worse than a failed build.
|
||||||
|
|
||||||
|
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { dirname, resolve, relative } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const entryHtml = resolve(root, 'index.html');
|
||||||
|
const outDir = resolve(root, 'dist');
|
||||||
|
const outFile = resolve(outDir, 'theladder.html');
|
||||||
|
|
||||||
|
const id = (path) => relative(root, path).replaceAll('\\', '/');
|
||||||
|
|
||||||
|
/** Rewrite one module's source into a registry factory. */
|
||||||
|
function transform(source, path) {
|
||||||
|
const exports = [];
|
||||||
|
let body = source;
|
||||||
|
|
||||||
|
// import { a, b as c } from './x.js'
|
||||||
|
body = body.replace(
|
||||||
|
/^[ \t]*import\s*\{([^}]*)\}\s*from\s*['"]([^'"]+)['"];?[ \t]*$/gm,
|
||||||
|
(_m, names, from) => `const {${names.replaceAll(' as ', ': ')}} = __require(${JSON.stringify(id(resolve(dirname(path), from)))});`,
|
||||||
|
);
|
||||||
|
// import * as ns from './x.js'
|
||||||
|
body = body.replace(
|
||||||
|
/^[ \t]*import\s*\*\s*as\s+(\w+)\s+from\s*['"]([^'"]+)['"];?[ \t]*$/gm,
|
||||||
|
(_m, name, from) => `const ${name} = __require(${JSON.stringify(id(resolve(dirname(path), from)))});`,
|
||||||
|
);
|
||||||
|
// import name from './x.js'
|
||||||
|
body = body.replace(
|
||||||
|
/^[ \t]*import\s+(\w+)\s+from\s*['"]([^'"]+)['"];?[ \t]*$/gm,
|
||||||
|
(_m, name, from) => `const ${name} = __require(${JSON.stringify(id(resolve(dirname(path), from)))}).default;`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// export { a, b } (no `from` — re-exports are not used in this project)
|
||||||
|
body = body.replace(/^[ \t]*export\s*\{([^}]*)\};?[ \t]*$/gm, (_m, names) => {
|
||||||
|
for (const entry of names.split(',').map((n) => n.trim()).filter(Boolean)) {
|
||||||
|
const [local, exported = local] = entry.split(/\s+as\s+/).map((n) => n.trim());
|
||||||
|
exports.push([exported, local]);
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
// export function / const / let / class
|
||||||
|
body = body.replace(/^[ \t]*export\s+(async\s+)?(function|const|let|class)\s+(\w+)/gm, (_m, async_, kind, name) => {
|
||||||
|
exports.push([name, name]);
|
||||||
|
return `${async_ ?? ''}${kind} ${name}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// export default <expression>
|
||||||
|
body = body.replace(/^[ \t]*export\s+default\s+/gm, () => {
|
||||||
|
exports.push(['default', '__default']);
|
||||||
|
return 'const __default = ';
|
||||||
|
});
|
||||||
|
|
||||||
|
const leftover = body.match(/^\s*(import|export)\s/m);
|
||||||
|
if (leftover) {
|
||||||
|
throw new Error(`${id(path)}: unsupported module syntax near "${leftover[0].trim()}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignments = exports.map(([exported, local]) =>
|
||||||
|
` __exports[${JSON.stringify(exported)}] = ${local};`).join('\n');
|
||||||
|
|
||||||
|
return `__define(${JSON.stringify(id(path))}, (__exports, __require) => {\n${body}\n${assignments}\n});`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collect(entryPath, seen = new Map()) {
|
||||||
|
if (seen.has(entryPath)) return seen;
|
||||||
|
const source = await readFile(entryPath, 'utf8');
|
||||||
|
seen.set(entryPath, null); // reserve the slot before recursing
|
||||||
|
|
||||||
|
// `[\s\S]*?` rather than `.*?`: a destructured import may be wrapped across
|
||||||
|
// several lines, and `transform` below already rewrites those — a scan that
|
||||||
|
// missed them produced a bundle with a __require for a module that was never
|
||||||
|
// collected, which only failed when the bundled game was run.
|
||||||
|
const specifiers = [...source.matchAll(/^[ \t]*import\s[\s\S]*?from\s*['"]([^'"]+)['"];?[ \t]*$/gm)]
|
||||||
|
.map((match) => match[1])
|
||||||
|
.filter((specifier) => specifier.startsWith('.'));
|
||||||
|
|
||||||
|
for (const specifier of specifiers) {
|
||||||
|
await collect(resolve(dirname(entryPath), specifier), seen);
|
||||||
|
}
|
||||||
|
seen.set(entryPath, transform(source, entryPath));
|
||||||
|
return seen;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The commit this build came from, marked `+` when the tree had uncommitted
|
||||||
|
* changes. Substituted into the output only — src/build-info.js stays as it is
|
||||||
|
* in the repository, so building never dirties the working tree.
|
||||||
|
*/
|
||||||
|
function buildIdentity() {
|
||||||
|
try {
|
||||||
|
const sha = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: root, stdio: 'pipe' })
|
||||||
|
.toString().trim();
|
||||||
|
const dirty = execFileSync('git', ['status', '--porcelain'], { cwd: root, stdio: 'pipe' })
|
||||||
|
.toString().trim().length > 0;
|
||||||
|
return dirty ? `${sha}+` : sha;
|
||||||
|
} catch {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function build() {
|
||||||
|
const html = await readFile(entryHtml, 'utf8');
|
||||||
|
|
||||||
|
const styleMatch = html.match(/<link\s+rel="stylesheet"\s+href="([^"]+)"\s*\/?>/);
|
||||||
|
if (!styleMatch) throw new Error('index.html has no stylesheet link to inline');
|
||||||
|
const css = await readFile(resolve(root, styleMatch[1]), 'utf8');
|
||||||
|
|
||||||
|
const scriptMatch = html.match(/<script type="module">([\s\S]*?)<\/script>/);
|
||||||
|
if (!scriptMatch) throw new Error('index.html has no module script');
|
||||||
|
const bootstrapSource = scriptMatch[1];
|
||||||
|
|
||||||
|
const entrySpecifier = bootstrapSource.match(/from\s*['"]([^'"]+)['"]/)?.[1];
|
||||||
|
if (!entrySpecifier) throw new Error('could not find the entry module in index.html');
|
||||||
|
const entryPath = resolve(root, entrySpecifier);
|
||||||
|
|
||||||
|
const modules = await collect(entryPath);
|
||||||
|
const bootstrap = transform(bootstrapSource, entryHtml).replace(
|
||||||
|
/__define\("index\.html"/, '__define("__bootstrap__"',
|
||||||
|
);
|
||||||
|
|
||||||
|
const script = [
|
||||||
|
'(() => {',
|
||||||
|
' "use strict";',
|
||||||
|
' const __factories = new Map();',
|
||||||
|
' const __cache = new Map();',
|
||||||
|
' const __define = (id, factory) => __factories.set(id, factory);',
|
||||||
|
' const __require = (id) => {',
|
||||||
|
' if (__cache.has(id)) return __cache.get(id);',
|
||||||
|
' const factory = __factories.get(id);',
|
||||||
|
' if (!factory) throw new Error(`module not bundled: ${id}`);',
|
||||||
|
' const exports = {};',
|
||||||
|
' __cache.set(id, exports);',
|
||||||
|
' factory(exports, __require);',
|
||||||
|
' return exports;',
|
||||||
|
' };',
|
||||||
|
...[...modules.values()].map((code) => code.replace(/^/gm, ' ')),
|
||||||
|
bootstrap.replace(/^/gm, ' '),
|
||||||
|
' __require("__bootstrap__");',
|
||||||
|
'})();',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const commit = buildIdentity();
|
||||||
|
const stamped = script
|
||||||
|
.replace("commit: 'dev'", `commit: '${commit}'`)
|
||||||
|
.replace('builtAt: null', `builtAt: '${new Date().toISOString().slice(0, 10)}'`);
|
||||||
|
if (stamped === script) throw new Error('could not stamp the build identity into the bundle');
|
||||||
|
|
||||||
|
const output = html
|
||||||
|
.replace(styleMatch[0], `<style>\n${css}\n </style>`)
|
||||||
|
.replace(scriptMatch[0], `<script>\n${stamped}\n </script>`);
|
||||||
|
|
||||||
|
await mkdir(outDir, { recursive: true });
|
||||||
|
await writeFile(outFile, output, 'utf8');
|
||||||
|
|
||||||
|
const kb = (Buffer.byteLength(output) / 1024).toFixed(1);
|
||||||
|
console.log(`built ${id(outFile)} — ${modules.size} modules, ${kb} kB, commit ${commit}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
build().catch((error) => {
|
||||||
|
console.error(`build failed: ${error.message}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user