Add promotion to Dispatch, and measure how repetitive a run is
The mailroom is no longer the whole game. Around turn 50, a player who has
built standing and either Marlene's goodwill or the nerve to ask gets offered
the Dispatch post — and can take it, take it and put Trevor up for the mailroom,
or turn it down, which is a real strategy rather than a mistake and comes back
after a cooldown.
Dispatch is eleven events of its own. The money problem is largely solved up
there and replaced by other people's days depending on yours, including the
mailroom's — which you used to be. Trevor branches on whether you recommended
him: he either runs the mailroom and has opinions about how it used to be run,
or he stays put and starts addressing you by your full title, kindly, in front
of people.
None of this is a promotion mechanic in the engine. It is
`{ path: 'stage', op: 'set', value: 'dispatch' }` on an option, since `stage`
was already how events are filtered. The reserved-write rule now allows it
narrowly — `set` only, and only to a stage that has events, both checked by the
validator, so a typo cannot strand the player in an empty pool. Wages differ by
rank through two upkeep entries gated on `stage`. The hard-times events dropped
their stage entirely: rent is due wherever you work.
The second half of this is the thing the last playtest asked for. A 183-turn
session, and no way to see how much of it was new ground.
`npm run analyze <exported-log.json>` now 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.
It found a regression in this very change on its first run. Dispatch started
with eight events against the mailroom's nineteen, so 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.
A test now holds that line.
Also corrected: the coverage test was asking whether an archetype ever *took* an
option, which is a property of the bot, not the pack. It had flagged
`rent_bounced.ask_trevor` as dead content when it had been offered, unlocked,
419 times across a sweep and simply never chosen. Options are now checked on
availability; events are still checked on firing.
114 tests. Reasoning in docs/DECISIONS.md §22-24.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VMSFHyVPitUoosW5wyEADj
This commit is contained in:
co-authored by
Claude Opus 5
parent
e482f07157
commit
4cab9fc41e
@@ -24,11 +24,13 @@ currently playable only from a test harness.
|
|||||||
| Event selection, option gating | done |
|
| Event selection, option gating | done |
|
||||||
| Turn loop, per-turn upkeep | done |
|
| Turn loop, per-turn upkeep | done |
|
||||||
| Content validator + reachability tests | done |
|
| Content validator + reachability tests | done |
|
||||||
| Corporate Ladder pack — 17 events, 2 characters | done |
|
| Corporate Ladder pack — 31 events, two tiers | done |
|
||||||
| Turn screen, retrospective, feedback widget | done |
|
| Turn screen, retrospective, feedback widget | done |
|
||||||
| Local-storage save, file export/import | done |
|
| Local-storage save, file export/import | done |
|
||||||
| Single-file build | done |
|
| Single-file build | done |
|
||||||
| Calendar, scheduled wages and rent | done |
|
| Calendar, scheduled wages and rent | done |
|
||||||
|
| Promotion from the mailroom to Dispatch | done |
|
||||||
|
| Repetition analytics (`npm run analyze`) | done |
|
||||||
| Played in a real browser | done — first playtest 2026-09-09 |
|
| Played in a real browser | done — first playtest 2026-09-09 |
|
||||||
|
|
||||||
The first playtest confirmed the interface, saves and the exported log all work
|
The first playtest confirmed the interface, saves and the exported log all work
|
||||||
@@ -61,11 +63,19 @@ 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.
|
It plays by double-clicking it — no server, no network, nothing installed.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm test # 109 tests: engine, content, UI wiring, and the build
|
npm test # 114 tests: engine, content, UI wiring, and the build
|
||||||
|
```
|
||||||
|
|
||||||
|
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 -- theladder-feedback-2026-09-10-11-04-22.json
|
||||||
```
|
```
|
||||||
|
|
||||||
There are no runtime dependencies and no build step for development. Node is
|
There are no runtime dependencies and no build step for development. Node is
|
||||||
used only to run the tests and the single-file build.
|
used only to run the tests, the single-file build, and the log analyser.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,490 @@
|
|||||||
|
// 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 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default dispatch;
|
||||||
@@ -11,7 +11,8 @@
|
|||||||
export const hardTimes = [
|
export const hardTimes = [
|
||||||
{
|
{
|
||||||
id: 'rent_bounced',
|
id: 'rent_bounced',
|
||||||
stage: 'mailroom',
|
// No stage: rent is due wherever you work, and the branch has to follow
|
||||||
|
// the player up rather than vanishing on promotion.
|
||||||
weight: 5000,
|
weight: 5000,
|
||||||
requires: [{ path: 'money', op: '<', value: 0 }],
|
requires: [{ path: 'money', op: '<', value: 0 }],
|
||||||
description:
|
description:
|
||||||
@@ -80,7 +81,6 @@ export const hardTimes = [
|
|||||||
|
|
||||||
{
|
{
|
||||||
id: 'debt_collector',
|
id: 'debt_collector',
|
||||||
stage: 'mailroom',
|
|
||||||
weight: 25,
|
weight: 25,
|
||||||
cooldown: 4,
|
cooldown: 4,
|
||||||
requires: [{ path: 'debt', op: '>', value: 0 }],
|
requires: [{ path: 'debt', op: '>', value: 0 }],
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { routine } from './routine.js';
|
import { routine } from './routine.js';
|
||||||
import { people } from './people.js';
|
import { people } from './people.js';
|
||||||
|
import { promotion } from './promotion.js';
|
||||||
|
import { dispatch } from './dispatch.js';
|
||||||
import { hardTimes } from './hard-times.js';
|
import { hardTimes } from './hard-times.js';
|
||||||
|
|
||||||
export const events = [...routine, ...people, ...hardTimes];
|
export const events = [...routine, ...people, ...promotion, ...dispatch, ...hardTimes];
|
||||||
export default events;
|
export default events;
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -16,6 +16,7 @@ export const corporateLadder = {
|
|||||||
|
|
||||||
stages: {
|
stages: {
|
||||||
mailroom: { label: 'Mailroom', title: 'Mail Associate I' },
|
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,
|
// Turn 1 is a Monday. Content can gate on the day — and, more importantly,
|
||||||
@@ -39,6 +40,8 @@ export const corporateLadder = {
|
|||||||
'flags.took_loan': { initial: false },
|
'flags.took_loan': { initial: false },
|
||||||
'flags.holds_the_envelope': { initial: false },
|
'flags.holds_the_envelope': { initial: false },
|
||||||
'flags.covered_for_trevor': { 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;
|
// Moments the retrospective reads back. Content decides what is memorable;
|
||||||
// the full turn-by-turn record lives in history regardless.
|
// the full turn-by-turn record lives in history regardless.
|
||||||
'notable': { initial: [] },
|
'notable': { initial: [] },
|
||||||
@@ -59,7 +62,25 @@ export const corporateLadder = {
|
|||||||
{ label: 'Coffee, transit, lunch', effects: [{ path: 'money', op: 'add', value: -18 }] },
|
{ 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
|
// 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.
|
// alternate Fridays. Rent lands on Mondays, starting the second week.
|
||||||
{ label: 'Wages', every: 14, offset: 4, effects: [{ path: 'money', op: 'add', value: 980 }] },
|
// 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: 980 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 }] },
|
{ label: 'Rent', every: 7, offset: 7, effects: [{ path: 'money', op: 'add', value: -455 }] },
|
||||||
// The service charge steps up with the size of the debt, so the spiral
|
// 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
|
// tightens instead of flattening out. A step function in data costs the
|
||||||
@@ -99,6 +120,8 @@ export const corporateLadder = {
|
|||||||
'flags.took_loan': { label: 'a loan on the books', hidden: true },
|
'flags.took_loan': { label: 'a loan on the books', hidden: true },
|
||||||
'flags.holds_the_envelope': { label: 'the envelope', hidden: true },
|
'flags.holds_the_envelope': { label: 'the envelope', hidden: true },
|
||||||
'flags.covered_for_trevor': { label: "Trevor's Thursday", hidden: true },
|
'flags.covered_for_trevor': { label: "Trevor's Thursday", hidden: true },
|
||||||
|
'flags.promoted': { label: 'a promotion', hidden: true },
|
||||||
|
'flags.recommended_trevor': { label: "Trevor's recommendation", hidden: true },
|
||||||
},
|
},
|
||||||
|
|
||||||
characters,
|
characters,
|
||||||
|
|||||||
@@ -261,3 +261,55 @@ 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
|
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
|
asserted as an ordering between archetypes rather than as absolute figures, so
|
||||||
tuning does not churn the tests.
|
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.
|
||||||
|
|||||||
+2
-1
@@ -7,6 +7,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "node --test \"test/**/*.test.js\"",
|
"test": "node --test \"test/**/*.test.js\"",
|
||||||
"serve": "python3 -m http.server 8080",
|
"serve": "python3 -m http.server 8080",
|
||||||
"build": "node tools/build.js"
|
"build": "node tools/build.js",
|
||||||
|
"analyze": "node tools/analyze-log.js"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-6
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
import { RESERVED_ROOTS, resolveSchema, indexCharacters } from './state.js';
|
import { RESERVED_ROOTS, resolveSchema, indexCharacters } from './state.js';
|
||||||
import { OPERATORS, pathsUsed as conditionPaths } from './conditions.js';
|
import { OPERATORS, pathsUsed as conditionPaths } from './conditions.js';
|
||||||
import { OPERATIONS, pathsUsed as effectPaths } from './effects.js';
|
import { OPERATIONS } from './effects.js';
|
||||||
import { parsePath } from './paths.js';
|
import { parsePath } from './paths.js';
|
||||||
|
|
||||||
const ID = /^[A-Za-z0-9_]+$/;
|
const ID = /^[A-Za-z0-9_]+$/;
|
||||||
@@ -63,13 +63,24 @@ export function validatePack(pack) {
|
|||||||
return `"${path}" is not declared anywhere in this pack`;
|
return `"${path}" is not declared anywhere in this pack`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const writable = (path) => {
|
// 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;
|
let root;
|
||||||
try {
|
try {
|
||||||
root = parsePath(path)[0];
|
root = parsePath(path)[0];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return error.message;
|
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 (RESERVED_ROOTS.has(root)) return `"${path}" writes engine-owned state`;
|
||||||
if (!(path in schema)) return `"${path}" is not declared in pack.state or relationshipStats`;
|
if (!(path in schema)) return `"${path}" is not declared in pack.state or relationshipStats`;
|
||||||
return null;
|
return null;
|
||||||
@@ -123,8 +134,9 @@ export function validatePack(pack) {
|
|||||||
fail(where, `unknown effect operation ${JSON.stringify(effect.op)}`);
|
fail(where, `unknown effect operation ${JSON.stringify(effect.op)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const path of effectPaths(entry.effects)) {
|
for (const effect of entry.effects ?? []) {
|
||||||
const problem = writable(path);
|
if (typeof effect.path !== 'string') continue;
|
||||||
|
const problem = writable(effect.path, effect, where);
|
||||||
if (problem) fail(where, `effect writes ${problem}`);
|
if (problem) fail(where, `effect writes ${problem}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -178,8 +190,9 @@ export function validatePack(pack) {
|
|||||||
fail(optionWhere, `effect "${effect.path}" uses ${effect.op} with a non-numeric value`);
|
fail(optionWhere, `effect "${effect.path}" uses ${effect.op} with a non-numeric value`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const path of effectPaths(option.effects)) {
|
for (const effect of option.effects ?? []) {
|
||||||
const problem = writable(path);
|
if (typeof effect.path !== 'string') continue;
|
||||||
|
const problem = writable(effect.path, effect, optionWhere);
|
||||||
if (problem) fail(optionWhere, `effect writes ${problem}`);
|
if (problem) fail(optionWhere, `effect writes ${problem}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,6 +203,12 @@ export function validatePack(pack) {
|
|||||||
if (pack.startingStage && !stages.has(pack.startingStage)) {
|
if (pack.startingStage && !stages.has(pack.startingStage)) {
|
||||||
fail('pack', `startingStage "${pack.startingStage}" has no events`);
|
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) {
|
for (const stage of stages) {
|
||||||
if (!unconditional.has(stage)) {
|
if (!unconditional.has(stage)) {
|
||||||
fail('events', `stage "${stage}" has no unconditional event; the turn pool can run dry`);
|
fail('events', `stage "${stage}" has no unconditional event; the turn pool can run dry`);
|
||||||
|
|||||||
@@ -17,9 +17,7 @@ export function retrospectiveScreen(pack, state, schema, actions) {
|
|||||||
h('header', { class: 'retro__head' },
|
h('header', { class: 'retro__head' },
|
||||||
h('p', { class: 'retro__eyebrow' }, pack.name),
|
h('p', { class: 'retro__eyebrow' }, pack.name),
|
||||||
h('h1', { class: 'retro__title' }, 'Career retrospective'),
|
h('h1', { class: 'retro__title' }, 'Career retrospective'),
|
||||||
h('p', { class: 'retro__subtitle' },
|
h('p', { class: 'retro__subtitle' }, careerLine(pack, summary, unit)),
|
||||||
`${summary.turns} ${unit}${summary.turns === 1 ? '' : 's'} in the ` +
|
|
||||||
`${(pack.stages?.[summary.stage]?.label ?? summary.stage).toLowerCase()}.`),
|
|
||||||
),
|
),
|
||||||
|
|
||||||
h('section', { class: 'retro__section' },
|
h('section', { class: 'retro__section' },
|
||||||
@@ -41,6 +39,10 @@ export function retrospectiveScreen(pack, state, schema, actions) {
|
|||||||
summary.favouriteAction && fact('What you did most',
|
summary.favouriteAction && fact('What you did most',
|
||||||
`${summary.favouriteAction.label} (${summary.favouriteAction.count}×)`),
|
`${summary.favouriteAction.label} (${summary.favouriteAction.count}×)`),
|
||||||
fact('Situations you saw', `${summary.eventsSeen} of ${summary.eventsTotal}`),
|
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',
|
summary.lockedEncounters > 0 && fact('Doors you could not open yet',
|
||||||
String(summary.lockedEncounters)),
|
String(summary.lockedEncounters)),
|
||||||
),
|
),
|
||||||
@@ -72,6 +74,19 @@ export function retrospectiveScreen(pack, state, schema, actions) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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. */
|
/** The pack's own word for its main resource, lowercased for mid-sentence use. */
|
||||||
function primaryLabel(pack, summary) {
|
function primaryLabel(pack, summary) {
|
||||||
return summary.primaryPath ? labelFor(pack, summary.primaryPath).toLowerCase() : 'balance';
|
return summary.primaryPath ? labelFor(pack, summary.primaryPath).toLowerCase() : 'balance';
|
||||||
|
|||||||
+20
-2
@@ -47,7 +47,20 @@ export function summarize(pack, state) {
|
|||||||
(sum, record) => sum + record.offered.filter((o) => !o.available).length, 0,
|
(sum, record) => sum + record.offered.filter((o) => !o.available).length, 0,
|
||||||
);
|
);
|
||||||
|
|
||||||
const eventsSeen = new Set(history.map((r) => r.eventId)).size;
|
// 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 {
|
return {
|
||||||
turns: history.length,
|
turns: history.length,
|
||||||
@@ -61,7 +74,12 @@ export function summarize(pack, state) {
|
|||||||
bestDay: ranked[0]?.net > 0 ? ranked[0] : null,
|
bestDay: ranked[0]?.net > 0 ? ranked[0] : null,
|
||||||
worstDay: ranked.at(-1)?.net < 0 ? ranked.at(-1) : null,
|
worstDay: ranked.at(-1)?.net < 0 ? ranked.at(-1) : null,
|
||||||
lockedEncounters,
|
lockedEncounters,
|
||||||
eventsSeen,
|
eventsSeen: eventCounts.size,
|
||||||
eventsTotal: (pack.events ?? []).length,
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+91
-11
@@ -18,6 +18,7 @@ import { validatePack } from '../src/engine/validate.js';
|
|||||||
import { startGame, currentTurn, takeTurn } from '../src/engine/game.js';
|
import { startGame, currentTurn, takeTurn } from '../src/engine/game.js';
|
||||||
import { createRng } from '../src/engine/rng.js';
|
import { createRng } from '../src/engine/rng.js';
|
||||||
import { play, sweep, median } from './fixtures/strategies.js';
|
import { play, sweep, median } from './fixtures/strategies.js';
|
||||||
|
import { summarize } from '../src/ui/summary.js';
|
||||||
|
|
||||||
/** Play `turns` turns with independently-seeded random choices. */
|
/** Play `turns` turns with independently-seeded random choices. */
|
||||||
function randomRun(runSeed, turns, visit = () => {}) {
|
function randomRun(runSeed, turns, visit = () => {}) {
|
||||||
@@ -40,31 +41,60 @@ test('the pack is valid, with no warnings', () => {
|
|||||||
assert.deepEqual(result.warnings, []);
|
assert.deepEqual(result.warnings, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('no content is dead: every event and option is reachable by some player', () => {
|
test('no content is dead: every event fires and every option becomes available', () => {
|
||||||
// Across archetypes, not just random play: several things in the pack exist
|
// Across archetypes, not just random play: several things in the pack exist
|
||||||
// for a player pursuing something, and "broke but well-liked" in particular
|
// for a player pursuing something, and "broke but well-liked" in particular
|
||||||
// is a state no single-axis strategy ever reaches.
|
// is a state no single-axis strategy ever reaches.
|
||||||
const { firedEvents, takenOptions } = sweep(pack, { seeds: 12, turns: 200 });
|
//
|
||||||
|
// 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 allEvents = pack.events.map((e) => e.id);
|
||||||
const allOptions = pack.events.flatMap((e) => e.options.map((o) => `${e.id}.${o.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(allEvents.filter((e) => !firedEvents.has(e)), [], 'events that can never fire');
|
||||||
assert.deepEqual(allOptions.filter((o) => !takenOptions.has(o)), [], 'options that can never be taken');
|
assert.deepEqual(allOptions.filter((o) => !offeredOptions.has(o)), [],
|
||||||
|
'options no player can ever be offered');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('how you play changes where you end up', () => {
|
test('how you play changes where you end up', () => {
|
||||||
const { byStrategy } = sweep(pack, { seeds: 12, turns: 200 });
|
// Measured before promotions land, so this is about how a player handles
|
||||||
|
// money rather than about how fast they got out of the mailroom. Mixing the
|
||||||
|
// two hides the economy behind the career.
|
||||||
|
const { byStrategy } = sweep(pack, { seeds: 12, turns: 35 });
|
||||||
const finalMoney = (name) => median(byStrategy[name].map((s) => s.money));
|
const finalMoney = (name) => median(byStrategy[name].map((s) => s.money));
|
||||||
|
assert.ok(byStrategy.thrifty.every((s) => s.stage === 'mailroom'),
|
||||||
|
'nobody should be promoted this early');
|
||||||
|
|
||||||
// The shape that matters: minding the money climbs, ignoring it sinks, and
|
// The shape that matters: minding the money climbs, ignoring it sinks, and
|
||||||
// playing without a plan roughly treads water. Exact figures will move with
|
// playing without a plan roughly treads water. Exact figures move with
|
||||||
// tuning; the ordering is the design.
|
// tuning; the ordering is the design.
|
||||||
assert.ok(finalMoney('thrifty') > finalMoney('random'),
|
assert.ok(finalMoney('thrifty') > finalMoney('random'), 'a careful player should get ahead');
|
||||||
'a careful player should get ahead');
|
assert.ok(finalMoney('random') > finalMoney('spendthrift'), 'a careless player should struggle');
|
||||||
assert.ok(finalMoney('random') > finalMoney('spendthrift'),
|
});
|
||||||
'a careless player should struggle');
|
|
||||||
assert.ok(finalMoney('thrifty') > 800,
|
test('the promotion is worth taking, and reachable by a deliberate player', () => {
|
||||||
`a careful player should be comfortably ahead, was ${finalMoney('thrifty')}`);
|
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');
|
||||||
|
assert.ok(median(promoted.map((s) => s.money)) > median(stayed.map((s) => s.money)),
|
||||||
|
'the coordinator wage should leave a player better off');
|
||||||
|
|
||||||
|
// 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}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('hard times is uncommon for a careful player and real for a careless one', () => {
|
test('hard times is uncommon for a careful player and real for a careless one', () => {
|
||||||
@@ -179,3 +209,53 @@ test('the retrospective has something to read back', () => {
|
|||||||
assert.equal(state.history.length, 60, 'every turn is recorded');
|
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');
|
assert.ok(state.history.every((h) => Array.isArray(h.upkeep)), 'upkeep is recorded per turn');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a long run does not collapse onto a handful of situations', () => {
|
||||||
|
// Motivated by a real 183-turn session. Before Dispatch was fleshed out, its
|
||||||
|
// two floor events were 46% of every promoted run — one every three turns.
|
||||||
|
// This is the guardrail on that: variety is a property of the pack, and it
|
||||||
|
// should fail loudly when a stage is too thin for the run it invites.
|
||||||
|
for (const strategy of ['sociable', 'thrifty', 'random']) {
|
||||||
|
const counts = new Map();
|
||||||
|
let turns = 0;
|
||||||
|
play(pack, { strategy, seed: 'variety', turns: 200,
|
||||||
|
visit: ({ turn }) => {
|
||||||
|
counts.set(turn.event.id, (counts.get(turn.event.id) ?? 0) + 1);
|
||||||
|
turns++;
|
||||||
|
} });
|
||||||
|
|
||||||
|
const [topEvent, topCount] = [...counts.entries()].sort((a, b) => b[1] - a[1])[0];
|
||||||
|
const share = topCount / turns;
|
||||||
|
assert.ok(share < 0.25,
|
||||||
|
`${strategy}: "${topEvent}" was ${(share * 100).toFixed(0)}% of turns`);
|
||||||
|
|
||||||
|
// Measured against what was actually reachable: a run that never leaves
|
||||||
|
// the mailroom cannot see Dispatch, and counting that as thin content
|
||||||
|
// would be measuring career progress rather than variety.
|
||||||
|
const visited = new Set([...counts.keys()]
|
||||||
|
.map((id) => pack.events.find((e) => e.id === id).stage)
|
||||||
|
.filter(Boolean));
|
||||||
|
const reachable = pack.events
|
||||||
|
.filter((e) => e.stage === undefined || visited.has(e.stage)).length;
|
||||||
|
assert.ok(counts.size / reachable > 0.6,
|
||||||
|
`${strategy}: saw ${counts.size} of ${reachable} reachable situations in ${turns} turns`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the retrospective reports how repetitive the run was', () => {
|
||||||
|
const state = play(pack, { strategy: 'sociable', seed: 'repeats', turns: 120 });
|
||||||
|
const summary = summarize(pack, state);
|
||||||
|
|
||||||
|
assert.equal(summary.turns, 120);
|
||||||
|
assert.ok(summary.eventsSeen > 0 && summary.eventsSeen <= summary.eventsTotal);
|
||||||
|
assert.ok(summary.averageTimesSeen > 1, 'a long run repeats situations');
|
||||||
|
assert.equal(
|
||||||
|
summary.averageTimesSeen.toFixed(2),
|
||||||
|
(summary.turns / summary.eventsSeen).toFixed(2),
|
||||||
|
'the average is turns over distinct situations',
|
||||||
|
);
|
||||||
|
assert.ok(summary.mostSeen.count >= summary.averageTimesSeen);
|
||||||
|
assert.ok(summary.repeatedSituations <= summary.eventsSeen);
|
||||||
|
assert.deepEqual(summary.stagesVisited, ['mailroom', 'dispatch'],
|
||||||
|
'the retrospective knows the career moved');
|
||||||
|
});
|
||||||
|
|||||||
Vendored
+8
-1
@@ -62,6 +62,10 @@ export function play(pack, { strategy = 'random', seed = 'run', turns = 200, vis
|
|||||||
export function sweep(pack, { seeds = 12, turns = 200 } = {}) {
|
export function sweep(pack, { seeds = 12, turns = 200 } = {}) {
|
||||||
const firedEvents = new Set();
|
const firedEvents = new Set();
|
||||||
const takenOptions = 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 = {};
|
const byStrategy = {};
|
||||||
|
|
||||||
for (const strategy of Object.keys(strategies)) {
|
for (const strategy of Object.keys(strategies)) {
|
||||||
@@ -74,13 +78,16 @@ export function sweep(pack, { seeds = 12, turns = 200 } = {}) {
|
|||||||
visit: ({ turn, chosen }) => {
|
visit: ({ turn, chosen }) => {
|
||||||
firedEvents.add(turn.event.id);
|
firedEvents.add(turn.event.id);
|
||||||
takenOptions.add(`${turn.event.id}.${chosen.option.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);
|
finals.push(state);
|
||||||
}
|
}
|
||||||
byStrategy[strategy] = finals;
|
byStrategy[strategy] = finals;
|
||||||
}
|
}
|
||||||
return { firedEvents, takenOptions, byStrategy };
|
return { firedEvents, takenOptions, offeredOptions, byStrategy };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const median = (values) =>
|
export const median = (values) =>
|
||||||
|
|||||||
@@ -249,3 +249,34 @@ test('scheduled upkeep falls due on its own period, not every turn', () => {
|
|||||||
assert.deepEqual(dueOn[13], ['daily', 'weekly rent'], 'turn 14 is rent only');
|
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');
|
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');
|
||||||
|
});
|
||||||
|
|||||||
@@ -164,3 +164,24 @@ test('a malformed upkeep schedule is caught', () => {
|
|||||||
assert.deepEqual(orphanOffset.errors, []);
|
assert.deepEqual(orphanOffset.errors, []);
|
||||||
assert.match(orphanOffset.warnings.join('\n'), /offset has no effect without every/);
|
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,182 @@
|
|||||||
|
// 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 } from 'node:fs/promises';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportRepetition(entries) {
|
||||||
|
const counts = new Map();
|
||||||
|
const firstSeen = new Map();
|
||||||
|
const lastSeen = new Map();
|
||||||
|
for (const entry of entries) {
|
||||||
|
counts.set(entry.event_id, (counts.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
const distinct = counts.size;
|
||||||
|
const turns = entries.length;
|
||||||
|
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 gap = count > 1
|
||||||
|
? ` every ~${Math.round((lastSeen.get(eventId) - firstSeen.get(eventId)) / (count - 1))} turns`
|
||||||
|
: ` once, turn ${firstSeen.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 ?? []) {
|
||||||
|
if (option.available) 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];
|
||||||
|
if (!path) {
|
||||||
|
console.error('usage: node tools/analyze-log.js <exported-feedback-log.json>');
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const log = JSON.parse(await readFile(path, 'utf8'));
|
||||||
|
const entries = log.entries ?? [];
|
||||||
|
if (!entries.length) {
|
||||||
|
console.log('The log has no entries.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const runs = byRun(entries);
|
||||||
|
console.log(`${entries.length} turns logged across ${runs.size} run${runs.size === 1 ? '' : 's'} ` +
|
||||||
|
`(pack ${entries[0].pack_id} ${entries[0].pack_version ?? ''})`.trimEnd());
|
||||||
|
for (const [seed, run] of runs) {
|
||||||
|
const stages = [...new Set(run.map((e) => e.stage))];
|
||||||
|
console.log(` seed ${seed}: ${run.length} turns, ${stages.join(' → ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
reportRepetition(entries);
|
||||||
|
reportChoices(entries);
|
||||||
|
reportGates(entries);
|
||||||
|
reportFeedback(entries);
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(`could not analyse that log: ${error.message}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user