Fix repayments that overcharged, and act on the first Frontier playtest
A debt repayment took its whole instalment even when less was owed: four dollars owed at the store cost twenty. Content could not express "pay what is owed", so an add effect's amount may now be read from state, with a cap. Five repayments across both packs use it, and a conformance test holds every pack to it. The turn record now says which options were hidden, so the analyser stops counting doors nobody saw, and it reads only feedback logs. From the same session: wages are labelled as wages, a clamped change says where it stopped, two unreadable option labels are rewritten, the sale barn no longer offers what the player already owns, Ward hints at what the promotion waits for, and the office has five more events. DECISIONS #38-40. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C6UDQ9o6L6Ey173U7XVou6
This commit is contained in:
co-authored by
Claude Opus 5
parent
d84ae495f4
commit
112e558dbb
@@ -25,7 +25,7 @@ real browser.
|
||||
| Event selection, option gating | done |
|
||||
| Turn loop, per-turn upkeep | done |
|
||||
| Content validator + reachability tests | done |
|
||||
| Corporate Ladder pack — 31 events, two tiers | done |
|
||||
| Corporate Ladder pack — 32 events, two tiers | done |
|
||||
| Turn screen, retrospective, feedback widget | done |
|
||||
| Local-storage save, file export/import | done |
|
||||
| Single-file build | done |
|
||||
@@ -34,10 +34,10 @@ real browser.
|
||||
| Repetition analytics (`npm run analyze`) | done |
|
||||
| Version and build shown on screen | done |
|
||||
| Keyboard play (Enter / Tab) | done |
|
||||
| Second content pack — The Frontier, 29 events, two tiers | done |
|
||||
| Second content pack — The Frontier, 37 events, two tiers | done |
|
||||
| Setting picker, one save slot per pack | done |
|
||||
| Pack conformance suite (every pack, every check) | done |
|
||||
| Played in a real browser | Corporate Ladder yes, first playtest 2026-09-09; **The Frontier not yet** |
|
||||
| Played in a real browser | Corporate Ladder since 2026-09-09; The Frontier 2026-09-15 (112 turns, 0.3.3) |
|
||||
|
||||
The first playtest confirmed the interface, saves and the exported log all work
|
||||
in a real browser. Its findings — an unexplained daily drain, no sense of the
|
||||
@@ -58,7 +58,7 @@ npm run serve # then open http://localhost:8080
|
||||
The game plays from the keyboard: Enter takes the focused option, Enter again
|
||||
moves to the next day, and Tab / Shift+Tab pick a different option. The top of
|
||||
every screen names the game, the pack, your current title, and the build —
|
||||
`0.3.0 · dev` when running from source, and the commit it came from when built.
|
||||
`0.3.4 · dev` when running from source, and the commit it came from when built.
|
||||
|
||||
A server is needed during development because browsers refuse ES module imports
|
||||
over `file://`. If port 8080 is taken, run `python3 -m http.server <port>`
|
||||
@@ -74,7 +74,7 @@ That is one self-contained file with the stylesheet and every module inlined.
|
||||
It plays by double-clicking it — no server, no network, nothing installed.
|
||||
|
||||
```sh
|
||||
npm test # 149 tests: engine, both packs, UI wiring, and the build
|
||||
npm test # 162 tests: engine, both packs, UI wiring, and the build
|
||||
```
|
||||
|
||||
Tests are in three layers. `test/conformance.test.js` runs over **every pack in
|
||||
@@ -101,34 +101,32 @@ used only to run the tests, the single-file build, and the log analyser.
|
||||
|
||||
## Where things stand
|
||||
|
||||
The MVP is complete and has survived three playtests, and a second setting now
|
||||
exists to prove the engine/content split holds. Adding it required **no engine
|
||||
change** — and three interface changes, two of which were latent defects a
|
||||
second pack made visible (a single global save slot, and the one place the UI
|
||||
still named a setting). See DECISIONS.md #34.
|
||||
The MVP is complete and has survived four playtests, and a second setting exists
|
||||
to prove the engine/content split holds. Adding it required **no engine change**
|
||||
— and three interface changes, two of which were latent defects a second pack
|
||||
made visible (a single global save slot, and the one place the UI still named a
|
||||
setting). See DECISIONS.md #34.
|
||||
|
||||
The Frontier had its first human session on 2026-09-15: 112 turns, promoted on
|
||||
turn 44, both lifestyle purchases taken and the standing charge firing as
|
||||
designed. What it found — an overcharging debt repayment in both packs, wages no
|
||||
one could recognise, a sale barn offering what the player already owned, and an
|
||||
office stage too thin — is what 0.3.4 fixes. The repayment fix needed one small
|
||||
engine addition: an effect's amount may be read from state. See DECISIONS #38–40.
|
||||
|
||||
Open questions, in the order they are likely to matter:
|
||||
|
||||
- **The Frontier has never been played by a human.** Its economy is asserted by
|
||||
the conformance suite and exercised by six archetypes, and that is exactly the
|
||||
evidence that was not enough the last two times. It needs a session before its
|
||||
numbers are believed.
|
||||
|
||||
- **Standing costs money to keep, and the Frontier's version is unplayed.**
|
||||
Each setting has a `lifestyle` tier bought on the second rung and charged for
|
||||
every cycle after, a standing penalty for lagging behind what the job expects,
|
||||
and a charge levied by standing itself that no purchase ever ends. Corporate
|
||||
Ladder's has had one 96-turn session: both purchases were found and taken, and
|
||||
the standing pressure worked, but the run arrived at the top with nothing left
|
||||
to spend on — which is where the never-stopping charge came from. The Frontier
|
||||
carries the same system at its own scale and has never been in front of anyone.
|
||||
See DECISIONS #36.
|
||||
- **Dispatch is eleven events against the mailroom's nineteen.** It passes the
|
||||
variety guard, but it has had far less play than the mailroom. Waits on more
|
||||
playtesting.
|
||||
- **0.3.4 has not been played.** Everything in it is asserted by tests and
|
||||
archetype runs, which is the evidence that was not enough before. In simulation
|
||||
promoted Frontier runs now end with about twice the money they did ($164 against
|
||||
$88) and less standing, and why is not yet isolated. The next Frontier session
|
||||
should watch whether money stops mattering at the top. See DECISIONS #40.
|
||||
- **Dispatch is twelve events against the mailroom's eighteen.** It passes the
|
||||
variety guard, but it has had far less play than the mailroom. The Frontier's
|
||||
office had the same thinness in a real session; Dispatch may too.
|
||||
- **A third tier in either pack.** Two rungs is what both settings have. The
|
||||
split is now proven across settings; it is not proven across a long ladder.
|
||||
Waits until the Frontier has been played.
|
||||
split is now proven across settings; it is not proven across a long ladder. The
|
||||
Frontier has now been played once, so this is the designer's call to open.
|
||||
|
||||
Settled, and not open: Corporate Ladder's negotiation gate stays as it is, and
|
||||
the feedback widget stays as it is (DECISIONS #35). Money mattering late is
|
||||
|
||||
@@ -98,14 +98,17 @@ export const hardTimes = [
|
||||
'A man from the lender calls the mailroom phone. Marlene answers it. She ' +
|
||||
'hands it to you without expression and then, notably, without leaving.',
|
||||
options: [
|
||||
// Both take what is owed up to their size, never more — the same defect
|
||||
// a real Frontier session found at the store, present here unplayed.
|
||||
// Money first, because both effects read `debt` before it moves. See #38.
|
||||
{
|
||||
id: 'pay_chunk',
|
||||
label: 'Pay a real chunk of it down.',
|
||||
label: 'Pay a real chunk of it down, up to $350.',
|
||||
requires: [{ path: 'money', op: '>=', value: 350 }],
|
||||
result: 'He is audibly disappointed.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: -350 },
|
||||
{ path: 'debt', op: 'add', value: -350 },
|
||||
{ path: 'money', op: 'add', value: { from: 'debt', times: -1, cap: 350 } },
|
||||
{ path: 'debt', op: 'add', value: { from: 'debt', times: -1, cap: 350 } },
|
||||
{ path: 'reputation', op: 'add', value: 1 },
|
||||
],
|
||||
},
|
||||
@@ -113,10 +116,11 @@ export const hardTimes = [
|
||||
id: 'pay_minimum',
|
||||
label: 'Pay what you can.',
|
||||
requires: [{ path: 'money', op: '>=', value: 60 }],
|
||||
result: 'Sixty dollars, fifty of which is the loan.',
|
||||
result: 'Up to fifty against the loan, and ten for the privilege of the call.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: -60 },
|
||||
{ path: 'debt', op: 'add', value: -50 },
|
||||
{ path: 'money', op: 'add', value: -10 },
|
||||
{ path: 'money', op: 'add', value: { from: 'debt', times: -1, cap: 50 } },
|
||||
{ path: 'debt', op: 'add', value: { from: 'debt', times: -1, cap: 50 } },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -15,7 +15,7 @@ export const corporateLadder = {
|
||||
blurb:
|
||||
'A modern office and a mailroom, two people who have been here longer ' +
|
||||
'than you, and a laminated org chart with your name not on it.',
|
||||
version: '0.1.0',
|
||||
version: '0.1.1',
|
||||
turnUnit: 'day',
|
||||
startingStage: 'mailroom',
|
||||
// What the interface calls the panel of people. The UI used to hardcode
|
||||
|
||||
@@ -160,7 +160,7 @@ export const deputy = [
|
||||
options: [
|
||||
{
|
||||
id: 'wade_in',
|
||||
label: 'Go in and take hold of the nearest one.',
|
||||
label: 'Go in and take hold of the nearest man who is fighting.',
|
||||
result:
|
||||
'It stops. Your sleeve does not survive it and neither does a ' +
|
||||
'table, and the whole street knows by morning which it was.',
|
||||
@@ -580,15 +580,281 @@ export const deputy = [
|
||||
],
|
||||
},
|
||||
|
||||
// Five more days in the office, added after the first real play of this
|
||||
// stage: 69 turns saw 13 distinct situations, and `office_day` and
|
||||
// `the_cells` were a sixth of the stage each. See DECISIONS #40.
|
||||
|
||||
{
|
||||
id: 'stage_came_in_short',
|
||||
stage: 'deputy',
|
||||
weight: 50,
|
||||
cooldown: 7,
|
||||
description:
|
||||
'The stage came in with the strongbox eleven pounds lighter than the ' +
|
||||
'manifest, and the driver swears on his mother that he never once ' +
|
||||
'stopped between the Wells and here.',
|
||||
options: [
|
||||
{
|
||||
id: 'wire_the_county',
|
||||
label: 'Wire the county and let them send a man.',
|
||||
result:
|
||||
'The county sends a man in nine days, by which time the driver has ' +
|
||||
'told the story often enough to believe every word of it.',
|
||||
effects: [
|
||||
{ path: 'skills.trade', op: 'add', value: 2 },
|
||||
{ path: 'relationships.ward.trusts_you', op: 'add', value: 2 },
|
||||
{ path: 'standing', op: 'add', value: -1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ride_the_route',
|
||||
label: 'Ride the route back yourself, looking at the ground.',
|
||||
result:
|
||||
'Wheel ruts off the road at the second creek, where the driver says ' +
|
||||
'he never stopped. You do not find the eleven pounds. You find where ' +
|
||||
'they got off.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: -3 },
|
||||
{ path: 'skills.nerve', op: 'add', value: 3 },
|
||||
{ path: 'standing', op: 'add', value: 3 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'lean_on_the_driver',
|
||||
label: 'Lean on the driver until his story changes.',
|
||||
requires: [{ path: 'skills.nerve', op: '>=', value: 30 }],
|
||||
result:
|
||||
'It changes on the fourth telling, at the second creek, and then ' +
|
||||
'again at the part where somebody was expecting him.',
|
||||
effects: [
|
||||
{ path: 'skills.nerve', op: 'add', value: 3 },
|
||||
{ path: 'standing', op: 'add', value: 4 },
|
||||
{ path: 'relationships.ward.wary_of_you', op: 'add', value: 5 },
|
||||
{ path: 'notable', op: 'push', value: 'Leaned on a stage driver until the story changed.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'the_county_fees',
|
||||
stage: 'deputy',
|
||||
weight: 45,
|
||||
cooldown: 8,
|
||||
description:
|
||||
'The county wants its licence fees collected from every business on the ' +
|
||||
'street, and the list it sent includes the woman who sells pies out of ' +
|
||||
'her kitchen window on Sundays.',
|
||||
options: [
|
||||
{
|
||||
id: 'collect_all',
|
||||
label: 'Collect from everybody on the list.',
|
||||
result:
|
||||
'All of it, pies included. The county is pleased. The street is ' +
|
||||
'polite to you in a way it was not last week.',
|
||||
effects: [
|
||||
{ path: 'standing', op: 'add', value: -4 },
|
||||
{ path: 'skills.trade', op: 'add', value: 1 },
|
||||
{ path: 'relationships.ward.trusts_you', op: 'add', value: 4 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'pay_hers',
|
||||
label: 'Collect from the rest, and pay hers out of your own pocket.',
|
||||
result:
|
||||
'A dollar fifty. Nobody is told, and by Sunday everybody knows, which ' +
|
||||
'is how the street keeps its accounts.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: -5 },
|
||||
{ path: 'standing', op: 'add', value: 5 },
|
||||
{ path: 'notable', op: 'push', value: 'Paid the pie licence out of your own pocket.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'strike_her_off',
|
||||
label: 'Strike her off the list and let the county notice if it can.',
|
||||
requires: [{ path: 'skills.trade', op: '>=', value: 28 }],
|
||||
result:
|
||||
'You find the statute that exempts a kitchen window, and cite it in ' +
|
||||
'the return. The county never writes back, which is the county ' +
|
||||
'agreeing.',
|
||||
effects: [
|
||||
{ path: 'skills.trade', op: 'add', value: 3 },
|
||||
{ path: 'standing', op: 'add', value: 3 },
|
||||
{ path: 'relationships.ward.wary_of_you', op: 'add', value: 4 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'the_bounty_man',
|
||||
stage: 'deputy',
|
||||
weight: 40,
|
||||
cooldown: 9,
|
||||
description:
|
||||
'A man with a paper from Bitter Wells and a rifle he keeps too clean ' +
|
||||
'wants to know whether a teamster called Pike is in Cold Fork. There is ' +
|
||||
'a teamster called Pike in Cold Fork, and he has been decent to you.',
|
||||
options: [
|
||||
{
|
||||
id: 'tell_him',
|
||||
label: 'Tell him where Pike is.',
|
||||
result:
|
||||
'He thanks you and leaves money on the desk, which you did not ask ' +
|
||||
'for and do not give back. The teamsters hear about the money first.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: 6 },
|
||||
{ path: 'standing', op: 'add', value: -3 },
|
||||
{ path: 'relationships.ward.trusts_you', op: 'add', value: 3 },
|
||||
{ path: 'relationships.dutch.wary_of_you', op: 'add', value: 6 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'send_him_on',
|
||||
label: 'Tell him Pike went west a month ago.',
|
||||
result:
|
||||
'He looks at you for long enough that you understand he knows. Then ' +
|
||||
'he rides west, because the paper does not pay him to argue.',
|
||||
effects: [
|
||||
{ path: 'skills.nerve', op: 'add', value: 2 },
|
||||
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 5 },
|
||||
{ path: 'relationships.ward.wary_of_you', op: 'add', value: 5 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'check_the_paper',
|
||||
label: 'Read his paper properly before you say anything at all.',
|
||||
requires: [{ path: 'skills.trade', op: '>=', value: 30 }],
|
||||
result:
|
||||
'It names a Pike, a year and a county, and two of the three are ' +
|
||||
'wrong. You tell him so, with the statute, and he goes home.',
|
||||
effects: [
|
||||
{ path: 'skills.trade', op: 'add', value: 3 },
|
||||
{ path: 'standing', op: 'add', value: 4 },
|
||||
{ path: 'relationships.ward.trusts_you', op: 'add', value: 4 },
|
||||
{ path: 'notable', op: 'push', value: 'Read a bounty man’s paper closely enough to send him home.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'fire_at_the_store',
|
||||
stage: 'deputy',
|
||||
weight: 35,
|
||||
cooldown: 12,
|
||||
description:
|
||||
'A lamp goes over in the back room of the store an hour after midnight, ' +
|
||||
'and by the time you are in your boots the whole street is standing in ' +
|
||||
'it in its nightclothes.',
|
||||
options: [
|
||||
{
|
||||
id: 'bucket_line',
|
||||
label: 'Get a bucket line going from the trough.',
|
||||
result:
|
||||
'It is out by two. The back room is gone and the front is black to ' +
|
||||
'the ceiling, and the storekeeper shakes every hand on the line.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: -2 },
|
||||
{ path: 'skills.nerve', op: 'add', value: 2 },
|
||||
{ path: 'standing', op: 'add', value: 5 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'go_in_for_the_book',
|
||||
label: 'Go in for the book on the counter.',
|
||||
requires: [{ path: 'skills.nerve', op: '>=', value: 34 }],
|
||||
result:
|
||||
'You come out with it under your coat and no eyebrows. Half the town ' +
|
||||
'is in that book, and every one of them knows who carried it out.',
|
||||
effects: [
|
||||
{ path: 'skills.nerve', op: 'add', value: 4 },
|
||||
{ path: 'standing', op: 'add', value: 6 },
|
||||
{ path: 'notable', op: 'push', value: 'Went into the burning store for the book.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'hold_the_crowd',
|
||||
label: 'Keep the crowd back and let it burn itself out.',
|
||||
result:
|
||||
'Nobody else is hurt, which Ward says is the only part of a fire that ' +
|
||||
'is the office’s business. The storekeeper does not agree, loudly.',
|
||||
effects: [
|
||||
{ path: 'skills.trade', op: 'add', value: 2 },
|
||||
{ path: 'standing', op: 'add', value: -2 },
|
||||
{ path: 'relationships.ward.trusts_you', op: 'add', value: 3 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'dutch_wants_the_ridge',
|
||||
stage: 'deputy',
|
||||
weight: 40,
|
||||
cooldown: 10,
|
||||
description:
|
||||
'Dutch comes to the office sober and early, which has not happened ' +
|
||||
'before, and asks whether a deputy has any say in who drives the Ridge ' +
|
||||
'road now that Vane is short a man.',
|
||||
options: [
|
||||
{
|
||||
id: 'put_him_forward',
|
||||
label: 'Say you will put his name in with Vane.',
|
||||
result:
|
||||
'He nods once and goes, and Vane hears about it from Dutch before he ' +
|
||||
'hears about it from you, which is not how you meant it to go.',
|
||||
effects: [
|
||||
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 8 },
|
||||
{ path: 'relationships.dutch.owes_you', op: 'add', value: 10 },
|
||||
{ path: 'relationships.hollis.wary_of_you', op: 'add', value: 5 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'office_stays_out',
|
||||
label: 'Say the office does not hire teamsters.',
|
||||
result:
|
||||
'He says that is true, and it is, and he takes his hat off the desk ' +
|
||||
'and goes back down the street to wait for somebody else to decide.',
|
||||
effects: [
|
||||
{ path: 'standing', op: 'add', value: 2 },
|
||||
{ path: 'relationships.dutch.trusts_you', op: 'add', value: -5 },
|
||||
{ path: 'relationships.ward.trusts_you', op: 'add', value: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ask_vane_for_him',
|
||||
label: 'Walk across the street and ask Vane yourself.',
|
||||
requires: [{ path: 'relationships.hollis.trusts_you', op: '>=', value: 40 }],
|
||||
result:
|
||||
'Vane hears you out in the doorway and says he will think about it, ' +
|
||||
'and Dutch is on the Ridge road on Monday.',
|
||||
effects: [
|
||||
{ path: 'standing', op: 'add', value: 3 },
|
||||
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 10 },
|
||||
{ path: 'relationships.hollis.trusts_you', op: 'add', value: -2 },
|
||||
{ path: 'notable', op: 'push', value: 'Asked Vane to put Dutch on the Ridge road.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
// Corporate Ladder's car lot, in Cold Fork's terms and at its scale. Same
|
||||
// structure deliberately: an offer, never an obligation, with the weekly
|
||||
// charge for falling short of it sitting in upkeep instead.
|
||||
//
|
||||
// The horse and the room used to be one event, whose description was
|
||||
// always about the horse. A real session was offered the room under that
|
||||
// text, and shown "Buy the bay" locked after buying it — a door that could
|
||||
// never open. Now each is its own event, gated on the tier it offers, so
|
||||
// neither can appear once it is bought. See DECISIONS #40.
|
||||
id: 'the_sale_barn',
|
||||
stage: 'deputy',
|
||||
weight: 45,
|
||||
cooldown: 6,
|
||||
requires: [{ path: 'lifestyle', op: '<', value: 2 }],
|
||||
requires: [{ path: 'lifestyle', op: '==', value: 0 }],
|
||||
description:
|
||||
'The sale barn has a bay with four good years in him and a price on ' +
|
||||
'him. Ward says nothing about it, except that the county pays mileage ' +
|
||||
@@ -597,10 +863,7 @@ export const deputy = [
|
||||
{
|
||||
id: 'buy_the_bay',
|
||||
label: 'Buy the bay.',
|
||||
requires: [
|
||||
{ path: 'lifestyle', op: '==', value: 0 },
|
||||
{ path: 'money', op: '>=', value: 70 },
|
||||
],
|
||||
requires: [{ path: 'money', op: '>=', value: 70 }],
|
||||
result:
|
||||
'He is a better animal than the livery has ever put under you, and ' +
|
||||
'the whole street watches you ride him down it once.',
|
||||
@@ -611,24 +874,6 @@ export const deputy = [
|
||||
{ path: 'notable', op: 'push', value: 'Bought the bay out of the sale barn.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'take_the_room',
|
||||
label: 'Take the room over the Ellis place, with the stove.',
|
||||
requires: [
|
||||
{ path: 'lifestyle', op: '==', value: 1 },
|
||||
{ path: 'money', op: '>=', value: 150 },
|
||||
],
|
||||
result:
|
||||
'A door that locks, a stove, and a window over the street. The ' +
|
||||
'Cordwood does not hold it against you; the Cordwood holds nothing ' +
|
||||
'against anybody who pays up.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: -150 },
|
||||
{ path: 'lifestyle', op: 'set', value: 2 },
|
||||
{ path: 'standing', op: 'add', value: 5 },
|
||||
{ path: 'notable', op: 'push', value: 'Took the room with the stove over the Ellis place.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ride_the_livery',
|
||||
label: 'Ride what the livery gives you.',
|
||||
@@ -639,6 +884,46 @@ export const deputy = [
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'the_ellis_room',
|
||||
stage: 'deputy',
|
||||
weight: 45,
|
||||
cooldown: 6,
|
||||
requires: [{ path: 'lifestyle', op: '==', value: 1 }],
|
||||
description:
|
||||
'The room over the Ellis place is empty again: a stove, a door that ' +
|
||||
'locks, and a window over the street. The Cordwood charges a deputy the ' +
|
||||
'same as a teamster, and says so to both of them.',
|
||||
options: [
|
||||
{
|
||||
id: 'take_the_room',
|
||||
label: 'Take the room over the Ellis place, with the stove.',
|
||||
requires: [{ path: 'money', op: '>=', value: 150 }],
|
||||
result:
|
||||
'A door that locks, a stove, and a window over the street. The ' +
|
||||
'Cordwood does not hold it against you; the Cordwood holds nothing ' +
|
||||
'against anybody who pays up.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: -150 },
|
||||
{ path: 'lifestyle', op: 'set', value: 2 },
|
||||
{ path: 'standing', op: 'add', value: 5 },
|
||||
{ path: 'notable', op: 'push', value: 'Took the room with the stove over the Ellis place.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'stay_at_the_cordwood',
|
||||
label: 'Stay at the Cordwood.',
|
||||
result:
|
||||
'The walls are thin and the talk comes through them, and some of what ' +
|
||||
'comes through is worth a deputy hearing.',
|
||||
effects: [
|
||||
{ path: 'skills.trade', op: 'add', value: 1 },
|
||||
{ path: 'relationships.dutch.trusts_you', op: 'add', value: 2 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default deputy;
|
||||
|
||||
@@ -99,14 +99,16 @@ export const hardTimes = [
|
||||
'book and yours is one of them. He mentions this to you in the street, ' +
|
||||
'in daylight, in front of two other people.',
|
||||
options: [
|
||||
// Both payments take what is owed up to their size, never more — money
|
||||
// first, because both effects read `owed` before it moves. See #38.
|
||||
{
|
||||
id: 'pay_chunk',
|
||||
label: 'Pay a real piece of it down.',
|
||||
label: 'Pay a real piece of it down, up to forty-five dollars.',
|
||||
requires: [{ path: 'money', op: '>=', value: 45 }],
|
||||
result: 'He rules a line through most of it and is disappointed about it.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: -45 },
|
||||
{ path: 'owed', op: 'add', value: -45 },
|
||||
{ path: 'money', op: 'add', value: { from: 'owed', times: -1, cap: 45 } },
|
||||
{ path: 'owed', op: 'add', value: { from: 'owed', times: -1, cap: 45 } },
|
||||
{ path: 'standing', op: 'add', value: 2 },
|
||||
],
|
||||
},
|
||||
@@ -114,10 +116,11 @@ export const hardTimes = [
|
||||
id: 'pay_minimum',
|
||||
label: 'Pay what you have on you.',
|
||||
requires: [{ path: 'money', op: '>=', value: 12 }],
|
||||
result: 'Twelve dollars, ten of which is the line and two of which is coffee.',
|
||||
result: 'Up to ten dollars against the line, and two dollars of coffee while you are in there.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: -12 },
|
||||
{ path: 'owed', op: 'add', value: -10 },
|
||||
{ path: 'money', op: 'add', value: -2 },
|
||||
{ path: 'money', op: 'add', value: { from: 'owed', times: -1, cap: 10 } },
|
||||
{ path: 'owed', op: 'add', value: { from: 'owed', times: -1, cap: 10 } },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -10,6 +10,58 @@
|
||||
// than a mistake.
|
||||
|
||||
export const promotion = [
|
||||
{
|
||||
// The first real player asked, thirty turns in, how they would know they
|
||||
// were getting close. Nothing said. This does, in the town's own terms: it
|
||||
// names what the offer is waiting for without printing a threshold. It
|
||||
// comes once, somewhat before the offer can, and only to a hand already
|
||||
// heading that way. See DECISIONS #40.
|
||||
id: 'ward_takes_notice',
|
||||
stage: 'hand',
|
||||
weight: 300,
|
||||
once: true,
|
||||
requires: [
|
||||
{ path: 'turn', op: '>=', value: 24 },
|
||||
{ path: 'standing', op: '>=', value: 55 },
|
||||
],
|
||||
description:
|
||||
'Ward stops at the yard gate on his way past and says, to nobody in ' +
|
||||
'particular, that the county will want a second man by winter. The man ' +
|
||||
'will need a name the street says for the right reasons, and he will ' +
|
||||
'need to be one Ward can take at his word — or one who has already ' +
|
||||
'walked into trouble on the Ridge road and walked back out of it.',
|
||||
options: [
|
||||
{
|
||||
id: 'ask_what_it_takes',
|
||||
label: 'Ask him how long a man would have to wait.',
|
||||
result:
|
||||
'He says not before the fortieth day of anybody’s employment, and ' +
|
||||
'that he is not talking about anybody in particular, and goes on up ' +
|
||||
'the street.',
|
||||
effects: [
|
||||
{ path: 'relationships.ward.trusts_you', op: 'add', value: 3 },
|
||||
{ path: 'notable', op: 'push', value: 'Heard from Ward that the county would want a second man.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'happy_in_the_yard',
|
||||
label: 'Say you are happy in the yard.',
|
||||
result: 'Vane, in the office doorway, hears you say it.',
|
||||
effects: [
|
||||
{ path: 'relationships.hollis.trusts_you', op: 'add', value: 3 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'remember_it',
|
||||
label: 'Say nothing, and remember it.',
|
||||
result: 'He did not expect an answer, and seems to like not getting one.',
|
||||
effects: [
|
||||
{ path: 'skills.nerve', op: 'add', value: 1 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'deputy_offer',
|
||||
stage: 'hand',
|
||||
|
||||
@@ -243,7 +243,7 @@ export const routine = [
|
||||
},
|
||||
{
|
||||
id: 'stay_out',
|
||||
label: 'Have no view on the axle.',
|
||||
label: 'Stay out of it, and take neither side about the axle.',
|
||||
result: 'Both of them decide, separately, that you are no use to them.',
|
||||
effects: [
|
||||
{ path: 'relationships.dutch.trusts_you', op: 'add', value: -4 },
|
||||
@@ -298,7 +298,7 @@ export const routine = [
|
||||
},
|
||||
{
|
||||
id: 'pay_down',
|
||||
label: 'Put money against the book.',
|
||||
label: 'Pay the book down, up to twenty dollars.',
|
||||
// Hidden rather than greyed out when there is nothing owed. A locked
|
||||
// door is worth showing when it is a door the player could open later
|
||||
// by playing differently; "pay down what you owe" shown to a man who
|
||||
@@ -310,9 +310,13 @@ export const routine = [
|
||||
{ path: 'money', op: '>=', value: 20 },
|
||||
],
|
||||
result: 'He crosses a line through it and turns the page back over.',
|
||||
// What is owed, up to twenty, and never more. A flat -20 against a
|
||||
// four-dollar balance took sixteen dollars for nothing in a real
|
||||
// session. Money comes first: both effects read `owed` before it moves.
|
||||
// See DECISIONS #38.
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: -20 },
|
||||
{ path: 'owed', op: 'add', value: -20 },
|
||||
{ path: 'money', op: 'add', value: { from: 'owed', times: -1, cap: 20 } },
|
||||
{ path: 'owed', op: 'add', value: { from: 'owed', times: -1, cap: 20 } },
|
||||
{ path: 'standing', op: 'add', value: 2 },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -20,7 +20,7 @@ export const frontier = {
|
||||
blurb:
|
||||
'Cold Fork, at the end of a stage line: a freight yard, a road that is ' +
|
||||
'not safe, and a marshal who has been six years retiring.',
|
||||
version: '0.1.0',
|
||||
version: '0.1.1',
|
||||
turnUnit: 'day',
|
||||
startingStage: 'hand',
|
||||
peopleHeading: 'Cold Fork',
|
||||
@@ -100,15 +100,18 @@ export const frontier = {
|
||||
offset: 3,
|
||||
effects: [{ path: 'money', op: 'add', value: -8 }],
|
||||
},
|
||||
// Labelled as wages, because they are. "Settlement" read as jargon to the
|
||||
// first real player, who could not tell it was the twenty-four a run the
|
||||
// first morning quotes; the ledger is written for a player (DECISIONS #27).
|
||||
{
|
||||
label: 'Settlement',
|
||||
label: 'Wages from Vane',
|
||||
every: 4,
|
||||
offset: 4,
|
||||
requires: [{ path: 'stage', op: '==', value: 'hand' }],
|
||||
effects: [{ path: 'money', op: 'add', value: 24 }],
|
||||
},
|
||||
{
|
||||
label: 'Deputy pay',
|
||||
label: 'Wages from the county',
|
||||
every: 4,
|
||||
offset: 4,
|
||||
// The town pays a real wage, and it is still not much of a living. What
|
||||
|
||||
@@ -656,3 +656,112 @@ 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).
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "theladder",
|
||||
"version": "0.3.3",
|
||||
"version": "0.3.4",
|
||||
"private": true,
|
||||
"description": "A turn-based career simulation game. Engine is theme-agnostic; settings are content packs.",
|
||||
"type": "module",
|
||||
|
||||
Vendored
+32
-5
@@ -25,23 +25,50 @@ function clamp(value, rule) {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Whether an effect's value is a reference to state rather than a literal. */
|
||||
export function isAmountFrom(value) {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
&& typeof value.from === 'string';
|
||||
}
|
||||
|
||||
/**
|
||||
* The amount an `add` effect applies. Usually the number the pack wrote. It may
|
||||
* instead be read from state at the moment the effect applies:
|
||||
*
|
||||
* { path: 'money', op: 'add', value: { from: 'owed', times: -1, cap: 20 } }
|
||||
*
|
||||
* is "take away whatever is owed, but no more than 20". A fixed instalment
|
||||
* cannot pay off a balance that could be anything, and charging the whole
|
||||
* instalment against a smaller balance took a playtester's money for a debt
|
||||
* that did not exist (DECISIONS #38). It is still data, and still generic: the
|
||||
* engine reads a number from a path without knowing what the path means.
|
||||
*/
|
||||
export function resolveAmount(state, value) {
|
||||
if (!isAmountFrom(value)) return value;
|
||||
const current = getPath(state, value.from);
|
||||
let amount = typeof current === 'number' ? current : 0;
|
||||
if (typeof value.cap === 'number') amount = Math.min(amount, value.cap);
|
||||
return amount * (typeof value.times === 'number' ? value.times : 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one effect. `schema` is the resolved state schema, consulted only for
|
||||
* clamping — the engine still has no idea what any given path means.
|
||||
*/
|
||||
export function applyEffect(state, effect, schema = {}) {
|
||||
const { path, op, value } = effect;
|
||||
const { path, op } = effect;
|
||||
const operate = OPS[op];
|
||||
if (!operate) throw new TypeError(`unknown effect operation ${JSON.stringify(op)}`);
|
||||
|
||||
const value = op === 'add' ? resolveAmount(state, effect.value) : effect.value;
|
||||
const from = getPath(state, path);
|
||||
const raw = operate(from, value);
|
||||
const to = clamp(raw, schema[path]);
|
||||
|
||||
return {
|
||||
state: setPath(state, path, to),
|
||||
change: { path, op, value, from, to, clamped: raw !== to },
|
||||
};
|
||||
const change = { path, op, value, from, to, clamped: raw !== to };
|
||||
// The log keeps the number actually applied, and says where it came from.
|
||||
if (value !== effect.value) change.source = effect.value;
|
||||
return { state: setPath(state, path, to), change };
|
||||
}
|
||||
|
||||
/** Apply a list of effects in order. Returns the new state and every change. */
|
||||
|
||||
@@ -143,6 +143,10 @@ export function takeTurn(pack, state, optionId, { schema = resolveSchema(pack),
|
||||
label: r.option.label,
|
||||
available: r.available,
|
||||
failed: r.failed,
|
||||
// A hidden option was never on the player's screen. Without this the log
|
||||
// cannot tell it from a locked door they saw, and the analyser counted
|
||||
// one the first real Frontier player never met. See DECISIONS #39.
|
||||
hidden: r.hidden,
|
||||
})),
|
||||
at: now,
|
||||
};
|
||||
|
||||
+14
-2
@@ -6,7 +6,7 @@
|
||||
|
||||
import { RESERVED_ROOTS, resolveSchema, indexCharacters } from './state.js';
|
||||
import { OPERATORS, pathsUsed as conditionPaths } from './conditions.js';
|
||||
import { OPERATIONS } from './effects.js';
|
||||
import { OPERATIONS, isAmountFrom } from './effects.js';
|
||||
import { parsePath } from './paths.js';
|
||||
|
||||
const ID = /^[A-Za-z0-9_]+$/;
|
||||
@@ -186,10 +186,22 @@ export function validatePack(pack) {
|
||||
if (!OPERATIONS.includes(effect.op)) {
|
||||
fail(optionWhere, `unknown effect operation ${JSON.stringify(effect.op)}`);
|
||||
}
|
||||
if (['add'].includes(effect.op) && typeof effect.value !== 'number') {
|
||||
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(effect.value.from);
|
||||
if (problem) fail(optionWhere, `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(optionWhere, `effect "${effect.path}" has a non-numeric ${key}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fail(optionWhere, `effect "${effect.path}" uses ${effect.op} with a non-numeric value`);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const effect of option.effects ?? []) {
|
||||
if (typeof effect.path !== 'string') continue;
|
||||
const problem = writable(effect.path, effect, optionWhere);
|
||||
|
||||
@@ -107,7 +107,10 @@ export function changeList(pack, changes, { className = 'changes' } = {}) {
|
||||
// "Rent", "Wages" — which is far more use than the path's own name.
|
||||
h('span', { class: 'change__label' }, change.label ?? labelFor(pack, change.path)),
|
||||
h('span', { class: 'change__delta' }, deltaText(pack, change)),
|
||||
change.clamped && h('span', { class: 'change__note' }, 'at its limit'),
|
||||
// Say where it stopped. "−$4 at its limit" was read by a real player as
|
||||
// a debt of four still standing, when it meant the balance hit zero.
|
||||
change.clamped && h('span', { class: 'change__note' },
|
||||
`stopped at ${formatValue(pack, change.path, change.to)}`),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ export const APP = {
|
||||
// Kept in step with package.json by a test, because it silently was not: the
|
||||
// status bar read 0.3.0 through the 0.3.1 and 0.3.2 work, which is exactly
|
||||
// the thing a playtester is told to check to know what they are playing.
|
||||
version: '0.3.3',
|
||||
version: '0.3.4',
|
||||
};
|
||||
|
||||
/** "0.3.0 · a1b2c3d" — or "0.3.0 · dev" when running from source unstamped. */
|
||||
|
||||
@@ -132,6 +132,35 @@ for (const pack of packs) {
|
||||
assert.deepEqual(trapped, [], 'events whose every option is gated');
|
||||
});
|
||||
|
||||
test(`${where} no repayment can take more than is owed`, () => {
|
||||
// A fixed instalment against a smaller balance takes money for a debt that
|
||||
// does not exist; the Frontier's store did it to a real player. A balance
|
||||
// is any money-formatted stat other than money itself. An option that pays
|
||||
// one down with money must read the amount from the balance, or be gated
|
||||
// on the balance covering the whole instalment. See DECISIONS #38.
|
||||
const balances = Object.entries(pack.display ?? {})
|
||||
.filter(([path, rule]) => rule.format === 'money' && path !== 'money')
|
||||
.map(([path]) => path);
|
||||
const overcharging = [];
|
||||
for (const event of pack.events) {
|
||||
for (const option of event.options ?? []) {
|
||||
const effects = option.effects ?? [];
|
||||
const spends = effects.some((e) => e.path === 'money' && e.op === 'add'
|
||||
&& (typeof e.value === 'number' ? e.value < 0 : (e.value?.times ?? 1) < 0));
|
||||
if (!spends) continue;
|
||||
for (const e of effects) {
|
||||
if (!balances.includes(e.path) || e.op !== 'add') continue;
|
||||
if (typeof e.value !== 'number' || e.value >= 0) continue;
|
||||
const covered = (option.requires ?? [])
|
||||
.some((r) => r.path === e.path && r.op === '>=' && r.value >= -e.value);
|
||||
if (!covered) overcharging.push(`${event.id}.${option.id} (${e.path} ${e.value})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(balances.length > 0 || pack.display === undefined, 'a pack with a balance should declare it as money');
|
||||
assert.deepEqual(overcharging, [], 'options that can charge for more than is owed');
|
||||
});
|
||||
|
||||
test(`${where} every turn always offers something to do, however badly it is going`, () => {
|
||||
for (let seed = 100; seed < 130; seed++) randomRun(pack, seed, 150);
|
||||
});
|
||||
|
||||
@@ -67,6 +67,37 @@ test('an unknown operation is a loud failure', () => {
|
||||
assert.throws(() => applyEffect(state, { path: 'money', op: 'multiply', value: 2 }), /unknown effect operation/);
|
||||
});
|
||||
|
||||
test('an amount can be read from state, capped and signed', () => {
|
||||
// The Frontier's store, turn 27 of a real session: four owed, twenty taken.
|
||||
const ledger = { owed: { initial: 0, min: 0, max: 1000 } };
|
||||
const pay = [
|
||||
{ path: 'money', op: 'add', value: { from: 'owed', times: -1, cap: 20 } },
|
||||
{ path: 'owed', op: 'add', value: { from: 'owed', times: -1, cap: 20 } },
|
||||
];
|
||||
|
||||
const small = applyEffects({ money: 110, owed: 4 }, pay, ledger);
|
||||
assert.equal(small.state.money, 106, 'pays what is owed, not the instalment');
|
||||
assert.equal(small.state.owed, 0);
|
||||
assert.equal(small.changes[1].clamped, false, 'nothing is lost to a clamp');
|
||||
assert.equal(small.changes[0].value, -4, 'the change records the amount applied');
|
||||
assert.deepEqual(small.changes[0].source, { from: 'owed', times: -1, cap: 20 });
|
||||
|
||||
const large = applyEffects({ money: 110, owed: 50 }, pay, ledger);
|
||||
assert.equal(large.state.money, 90, 'the cap holds when more is owed');
|
||||
assert.equal(large.state.owed, 30);
|
||||
});
|
||||
|
||||
test('an amount read from a path that is not a number is zero', () => {
|
||||
const result = applyEffect({ money: 10 }, { path: 'money', op: 'add', value: { from: 'owed', times: -1 } }, {});
|
||||
assert.equal(result.state.money, 10);
|
||||
assert.equal(result.change.value === 0 || Object.is(result.change.value, -0), true);
|
||||
});
|
||||
|
||||
test('a literal amount records no source', () => {
|
||||
const result = applyEffect(state, { path: 'money', op: 'add', value: 5 }, schema);
|
||||
assert.equal('source' in result.change, false);
|
||||
});
|
||||
|
||||
test('an empty effect list is a no-op', () => {
|
||||
const result = applyEffects(state, [], schema);
|
||||
assert.deepEqual(result.changes, []);
|
||||
|
||||
Vendored
+13
-2
@@ -13,13 +13,24 @@
|
||||
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;
|
||||
};
|
||||
|
||||
const moneyGain = (resolved) => (resolved.option.effects ?? [])
|
||||
.filter((e) => e.path === 'money' && e.op === 'add')
|
||||
.reduce((sum, e) => sum + e.value, 0);
|
||||
.reduce((sum, e) => sum + amountOf(e), 0);
|
||||
|
||||
const relationshipGain = (resolved) => (resolved.option.effects ?? [])
|
||||
.filter((e) => e.path.startsWith('relationships.'))
|
||||
.reduce((sum, e) => sum + e.value, 0);
|
||||
.reduce((sum, e) => sum + amountOf(e), 0);
|
||||
|
||||
const best = (options, score) => [...options].sort((a, b) => score(b) - score(a))[0];
|
||||
|
||||
|
||||
+31
-1
@@ -11,6 +11,8 @@ import assert from 'node:assert/strict';
|
||||
import { frontier as pack } from '../content/frontier/index.js';
|
||||
import { startGame, currentTurn, takeTurn } from '../src/engine/game.js';
|
||||
import { check } from '../src/engine/conditions.js';
|
||||
import { applyEffects } from '../src/engine/effects.js';
|
||||
import { resolveSchema } from '../src/engine/state.js';
|
||||
import { play, sweep, median } from './fixtures/strategies.js';
|
||||
|
||||
/**
|
||||
@@ -58,7 +60,7 @@ test('the settlement and the room land on the days the pack says', () => {
|
||||
const turn = currentTurn(pack, state);
|
||||
const result = takeTurn(pack, state, turn.options.find((o) => o.available).option.id);
|
||||
for (const change of result.record.upkeep) {
|
||||
if (change.label === 'Settlement') settlement.push(state.turn);
|
||||
if (change.label === 'Wages from Vane') settlement.push(state.turn);
|
||||
if (change.label === 'Room at the Cordwood') room.push(state.turn);
|
||||
}
|
||||
state = result.state;
|
||||
@@ -250,6 +252,34 @@ test('a player who wants a skill does not wait long for the first door', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('the horse and the room are each offered only while they can still be bought', () => {
|
||||
// A real session was shown "Buy the bay" locked after buying it, and offered
|
||||
// the room under a description about the horse. See DECISIONS #40.
|
||||
const offered = (lifestyle) => pack.events
|
||||
.filter((e) => ['the_sale_barn', 'the_ellis_room'].includes(e.id))
|
||||
.filter((e) => check({ stage: 'deputy', lifestyle }, e.requires).passed)
|
||||
.map((e) => e.id);
|
||||
assert.deepEqual(offered(0), ['the_sale_barn']);
|
||||
assert.deepEqual(offered(1), ['the_ellis_room']);
|
||||
assert.deepEqual(offered(2), []);
|
||||
});
|
||||
|
||||
test('paying the book down never takes more than is owed', () => {
|
||||
// Turn 27 of that session: four dollars owed, twenty taken. See DECISIONS #38.
|
||||
const schema = resolveSchema(pack);
|
||||
const payDown = pack.events.find((e) => e.id === 'the_store')
|
||||
.options.find((o) => o.id === 'pay_down');
|
||||
const base = startGame(pack, { seed: 'the-book' });
|
||||
|
||||
const small = applyEffects({ ...base, money: 110, owed: 4 }, payDown.effects, schema).state;
|
||||
assert.equal(small.money, 106, 'four owed costs four');
|
||||
assert.equal(small.owed, 0);
|
||||
|
||||
const large = applyEffects({ ...base, money: 110, owed: 50 }, payDown.effects, schema).state;
|
||||
assert.equal(large.money, 90, 'more than twenty owed costs twenty');
|
||||
assert.equal(large.owed, 30);
|
||||
});
|
||||
|
||||
test('a deliberate player can put Dutch in their debt', () => {
|
||||
// The deepest relationship gate in the pack: `send_for_dutch` in the
|
||||
// marshal's office needs a favour banked two rungs earlier.
|
||||
|
||||
@@ -141,6 +141,7 @@ test('the turn record carries the diff and the options the player could not take
|
||||
const locked = record.offered.find((o) => o.id === 'charm');
|
||||
assert.equal(locked.available, false);
|
||||
assert.equal(locked.failed[0].path, 'skills.talking');
|
||||
assert.equal(locked.hidden, false, 'the record says whether a locked option was on screen');
|
||||
});
|
||||
|
||||
test('history accumulates every turn and is what the retrospective reads', () => {
|
||||
|
||||
@@ -25,6 +25,23 @@ test('an effect writing an undeclared path is caught', () => {
|
||||
assert.match(messages(result), /skills\.wleding.*not declared/s);
|
||||
});
|
||||
|
||||
test('an amount read from state must read a declared path, with numeric scale and cap', () => {
|
||||
const unknown = broken((pack) => {
|
||||
pack.events[0].options[0].effects.push({ path: 'money', op: 'add', value: { from: 'skills.wleding', times: -1 } });
|
||||
});
|
||||
assert.match(messages(unknown), /reads its amount from "skills\.wleding".*not declared/s);
|
||||
|
||||
const badCap = broken((pack) => {
|
||||
pack.events[0].options[0].effects.push({ path: 'money', op: 'add', value: { from: 'money', cap: 'lots' } });
|
||||
});
|
||||
assert.match(messages(badCap), /non-numeric cap/);
|
||||
|
||||
const fine = broken((pack) => {
|
||||
pack.events[0].options[0].effects.push({ path: 'money', op: 'add', value: { from: 'money', times: -1, cap: 5 } });
|
||||
});
|
||||
assert.deepEqual(fine.errors, []);
|
||||
});
|
||||
|
||||
test('a relationship effect naming a character who does not exist is caught', () => {
|
||||
const result = broken((pack) => {
|
||||
pack.events[0].options[0].effects.push({ path: 'relationships.dave.likes_you', op: 'add', value: 1 });
|
||||
|
||||
+13
-3
@@ -17,11 +17,18 @@ import { fileURLToPath } from 'node:url';
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const logsDir = resolve(root, 'logs');
|
||||
|
||||
/** The most recently modified log in logs/, when none was named. */
|
||||
/**
|
||||
* The most recently modified feedback log in logs/, when none was named.
|
||||
*
|
||||
* Feedback logs only. A save export is a .json too, and a playtester who
|
||||
* exports both — as the first one to do so did — would otherwise have the save
|
||||
* read as a log whenever it was the later of the two.
|
||||
*/
|
||||
async function newestLog() {
|
||||
let names;
|
||||
try {
|
||||
names = (await readdir(logsDir)).filter((name) => name.endsWith('.json'));
|
||||
names = (await readdir(logsDir))
|
||||
.filter((name) => name.startsWith('theladder-feedback-') && name.endsWith('.json'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -130,7 +137,10 @@ function reportGates(entries) {
|
||||
const blocked = new Map();
|
||||
for (const entry of entries) {
|
||||
for (const option of entry.offered ?? []) {
|
||||
if (option.available) continue;
|
||||
// A hidden option was never on screen, so it was never a door anybody
|
||||
// met. Logs from before 0.3.4 do not record `hidden`, and still count
|
||||
// such options here. See DECISIONS #39.
|
||||
if (option.available || option.hidden) continue;
|
||||
const reasons = (option.failed ?? [])
|
||||
.map((c) => `${c.path} ${c.op} ${JSON.stringify(c.value)}`)
|
||||
.join(', ') || 'unstated';
|
||||
|
||||
Reference in New Issue
Block a user