diff --git a/README.md b/README.md index 0f54f31..7db737e 100644 --- a/README.md +++ b/README.md @@ -24,11 +24,13 @@ currently playable only from a test harness. | Event selection, option gating | done | | Turn loop, per-turn upkeep | 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 | | Local-storage save, file export/import | done | | Single-file build | done | | Calendar, scheduled wages and rent | done | +| Promotion from the mailroom to Dispatch | done | +| Repetition analytics (`npm run analyze`) | done | | Played in a real browser | done — first playtest 2026-09-09 | 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. ```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 -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 diff --git a/content/corporateladder/events/dispatch.js b/content/corporateladder/events/dispatch.js new file mode 100644 index 0000000..1e7cef4 --- /dev/null +++ b/content/corporateladder/events/dispatch.js @@ -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; diff --git a/content/corporateladder/events/hard-times.js b/content/corporateladder/events/hard-times.js index b1e2c17..362c788 100644 --- a/content/corporateladder/events/hard-times.js +++ b/content/corporateladder/events/hard-times.js @@ -11,7 +11,8 @@ export const hardTimes = [ { 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, requires: [{ path: 'money', op: '<', value: 0 }], description: @@ -80,7 +81,6 @@ export const hardTimes = [ { id: 'debt_collector', - stage: 'mailroom', weight: 25, cooldown: 4, requires: [{ path: 'debt', op: '>', value: 0 }], diff --git a/content/corporateladder/events/index.js b/content/corporateladder/events/index.js index 0b28497..00047ed 100644 --- a/content/corporateladder/events/index.js +++ b/content/corporateladder/events/index.js @@ -1,6 +1,8 @@ import { routine } from './routine.js'; import { people } from './people.js'; +import { promotion } from './promotion.js'; +import { dispatch } from './dispatch.js'; import { hardTimes } from './hard-times.js'; -export const events = [...routine, ...people, ...hardTimes]; +export const events = [...routine, ...people, ...promotion, ...dispatch, ...hardTimes]; export default events; diff --git a/content/corporateladder/events/promotion.js b/content/corporateladder/events/promotion.js new file mode 100644 index 0000000..cfbad9c --- /dev/null +++ b/content/corporateladder/events/promotion.js @@ -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; diff --git a/content/corporateladder/index.js b/content/corporateladder/index.js index cc45e97..ef46b32 100644 --- a/content/corporateladder/index.js +++ b/content/corporateladder/index.js @@ -16,6 +16,7 @@ export const corporateLadder = { stages: { mailroom: { label: 'Mailroom', title: 'Mail Associate I' }, + dispatch: { label: 'Dispatch', title: 'Dispatch Coordinator' }, }, // Turn 1 is a Monday. Content can gate on the day — and, more importantly, @@ -39,6 +40,8 @@ export const corporateLadder = { 'flags.took_loan': { initial: false }, 'flags.holds_the_envelope': { initial: false }, 'flags.covered_for_trevor': { initial: false }, + 'flags.promoted': { initial: false }, + 'flags.recommended_trevor': { initial: false }, // Moments the retrospective reads back. Content decides what is memorable; // the full turn-by-turn record lives in history regardless. 'notable': { initial: [] }, @@ -59,7 +62,25 @@ export const corporateLadder = { { label: 'Coffee, transit, lunch', effects: [{ path: 'money', op: 'add', value: -18 }] }, // Turn 1 is a Monday, so offset 4 is the first Friday and every 14 makes it // alternate Fridays. Rent lands on Mondays, starting the second week. - { 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 }] }, // 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 @@ -99,6 +120,8 @@ export const corporateLadder = { 'flags.took_loan': { label: 'a loan on the books', hidden: true }, 'flags.holds_the_envelope': { label: 'the envelope', 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, diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 529c554..01f40a7 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -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 asserted as an ordering between archetypes rather than as absolute figures, so tuning does not churn the tests. + +## 22. Stage is the one piece of engine state content may write + +A promotion is `{ path: 'stage', op: 'set', value: 'dispatch' }` on an option's +effects. Nothing else about it exists in the engine — `stage` was already how +events are filtered, and moving the player between stages is a content +decision, so the reserved-write rule now allows it. + +It allows it narrowly. Only `set`, and only to a stage that actually has events; +both are checked by the validator. A typo cannot strand the player somewhere +with an empty event pool. + +Wages differ by rank through two upkeep entries gated on `stage`, not through +any notion of rank in the engine. The hard-times events dropped their `stage` +entirely and so apply everywhere — rent is due wherever you work, and that +branch has to follow the player up rather than vanishing on promotion. + +## 23. What "reachable" means for an option + +The coverage test used to ask whether an archetype ever *took* an option. That +is the wrong question: whether a bot picks something is an artefact of how the +bot scores, not a property of the pack. `rent_bounced.ask_trevor` was offered +and unlocked 419 times across a sweep and chosen once, and the test called it +dead content. + +Events are still checked on firing, since the engine draws those. Options are +checked on *availability*: every option must, at some point, be offered to a +player unlocked. What they then do with it is theirs. + +## 24. Variety is a measured property of the pack + +Playtest: a 183-turn session, *"mailroom stayed mostly interesting despite the +number of repeats… what might be of interest is seeing how many turns are +played versus how many unique situations."* + +So it is now measured, in three places. + +`tools/analyze-log.js` reads an exported feedback log and reports turns against +distinct situations, how often each recurred, which options were most used, +which locked gates players kept meeting, and anything they typed. The +retrospective shows the player-facing version of the same thing. + +And a test asserts no single event exceeds 25% of a 200-turn run, and that a run +sees more than 60% of what was reachable in the stages it visited — measured +against reachable content, since a run that never leaves the mailroom cannot see +Dispatch and counting that as thin would be measuring career progress instead. + +That guardrail exists because the first version of Dispatch failed it badly: +eight events against the mailroom's nineteen meant its two floor events were 46% +of every promoted run, one every three turns. Promotion was moving the player +into *thinner* content. Three more events and a weight rebalance took the top +two to 24.5%, and the heaviest recurrence from every ~3 turns to every ~5. diff --git a/package.json b/package.json index ac20434..edc9cba 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "scripts": { "test": "node --test \"test/**/*.test.js\"", "serve": "python3 -m http.server 8080", - "build": "node tools/build.js" + "build": "node tools/build.js", + "analyze": "node tools/analyze-log.js" } } diff --git a/src/engine/validate.js b/src/engine/validate.js index d8c3a97..8d12a1d 100644 --- a/src/engine/validate.js +++ b/src/engine/validate.js @@ -6,7 +6,7 @@ import { RESERVED_ROOTS, resolveSchema, indexCharacters } from './state.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'; const ID = /^[A-Za-z0-9_]+$/; @@ -63,13 +63,24 @@ export function validatePack(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; try { root = parsePath(path)[0]; } catch (error) { return error.message; } + if (path === 'stage') { + if (effect.op !== 'set') return `"stage" may only be written with set, not ${effect.op}`; + stageWrites.push({ where, value: effect.value }); + return null; + } if (RESERVED_ROOTS.has(root)) return `"${path}" writes engine-owned state`; if (!(path in schema)) return `"${path}" is not declared in pack.state or relationshipStats`; return null; @@ -123,8 +134,9 @@ export function validatePack(pack) { fail(where, `unknown effect operation ${JSON.stringify(effect.op)}`); } } - for (const path of effectPaths(entry.effects)) { - const problem = writable(path); + for (const effect of entry.effects ?? []) { + if (typeof effect.path !== 'string') continue; + const problem = writable(effect.path, effect, where); if (problem) fail(where, `effect writes ${problem}`); } }); @@ -178,8 +190,9 @@ export function validatePack(pack) { fail(optionWhere, `effect "${effect.path}" uses ${effect.op} with a non-numeric value`); } } - for (const path of effectPaths(option.effects)) { - const problem = writable(path); + for (const effect of option.effects ?? []) { + if (typeof effect.path !== 'string') continue; + const problem = writable(effect.path, effect, optionWhere); if (problem) fail(optionWhere, `effect writes ${problem}`); } } @@ -190,6 +203,12 @@ export function validatePack(pack) { if (pack.startingStage && !stages.has(pack.startingStage)) { fail('pack', `startingStage "${pack.startingStage}" has no events`); } + for (const { where, value } of stageWrites) { + if (typeof value !== 'string' || !stages.has(value)) { + fail(where, `moves the player to stage ${JSON.stringify(value)}, which has no events`); + } + } + for (const stage of stages) { if (!unconditional.has(stage)) { fail('events', `stage "${stage}" has no unconditional event; the turn pool can run dry`); diff --git a/src/ui/screens/retrospective.js b/src/ui/screens/retrospective.js index b5a7f04..4d8eeee 100644 --- a/src/ui/screens/retrospective.js +++ b/src/ui/screens/retrospective.js @@ -17,9 +17,7 @@ export function retrospectiveScreen(pack, state, schema, actions) { h('header', { class: 'retro__head' }, h('p', { class: 'retro__eyebrow' }, pack.name), h('h1', { class: 'retro__title' }, 'Career retrospective'), - h('p', { class: 'retro__subtitle' }, - `${summary.turns} ${unit}${summary.turns === 1 ? '' : 's'} in the ` + - `${(pack.stages?.[summary.stage]?.label ?? summary.stage).toLowerCase()}.`), + h('p', { class: 'retro__subtitle' }, careerLine(pack, summary, unit)), ), 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.label} (${summary.favouriteAction.count}×)`), fact('Situations you saw', `${summary.eventsSeen} of ${summary.eventsTotal}`), + summary.eventsSeen > 0 && fact('Times you saw each, on average', + summary.averageTimesSeen.toFixed(1)), + summary.mostSeen && summary.mostSeen.count > 1 && fact('Most familiar of them', + `came up ${summary.mostSeen.count} times`), summary.lockedEncounters > 0 && fact('Doors you could not open yet', String(summary.lockedEncounters)), ), @@ -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. */ function primaryLabel(pack, summary) { return summary.primaryPath ? labelFor(pack, summary.primaryPath).toLowerCase() : 'balance'; diff --git a/src/ui/summary.js b/src/ui/summary.js index f2f9ed7..be0c514 100644 --- a/src/ui/summary.js +++ b/src/ui/summary.js @@ -47,7 +47,20 @@ export function summarize(pack, state) { (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 { turns: history.length, @@ -61,7 +74,12 @@ export function summarize(pack, state) { bestDay: ranked[0]?.net > 0 ? ranked[0] : null, worstDay: ranked.at(-1)?.net < 0 ? ranked.at(-1) : null, lockedEncounters, - eventsSeen, + eventsSeen: eventCounts.size, eventsTotal: (pack.events ?? []).length, + eventCounts, + repeatedSituations: counts.filter((n) => n > 1).length, + averageTimesSeen: eventCounts.size ? history.length / eventCounts.size : 0, + mostSeen: mostSeen ? { eventId: mostSeen[0], count: mostSeen[1] } : null, + stagesVisited, }; } diff --git a/test/content.test.js b/test/content.test.js index e222422..7b58a04 100644 --- a/test/content.test.js +++ b/test/content.test.js @@ -18,6 +18,7 @@ import { validatePack } from '../src/engine/validate.js'; import { startGame, currentTurn, takeTurn } from '../src/engine/game.js'; import { createRng } from '../src/engine/rng.js'; import { play, sweep, median } from './fixtures/strategies.js'; +import { summarize } from '../src/ui/summary.js'; /** Play `turns` turns with independently-seeded random choices. */ function randomRun(runSeed, turns, visit = () => {}) { @@ -40,31 +41,60 @@ test('the pack is valid, with no 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 // for a player pursuing something, and "broke but well-liked" in particular // 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 allOptions = pack.events.flatMap((e) => e.options.map((o) => `${e.id}.${o.id}`)); assert.deepEqual(allEvents.filter((e) => !firedEvents.has(e)), [], 'events that can never fire'); - assert.deepEqual(allOptions.filter((o) => !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', () => { - 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)); + assert.ok(byStrategy.thrifty.every((s) => s.stage === 'mailroom'), + 'nobody should be promoted this early'); // The shape that matters: minding the money climbs, ignoring it sinks, and - // playing without a plan roughly treads water. Exact figures will move with + // playing without a plan roughly treads water. Exact figures move with // tuning; the ordering is the design. - assert.ok(finalMoney('thrifty') > finalMoney('random'), - 'a careful player should get ahead'); - assert.ok(finalMoney('random') > finalMoney('spendthrift'), - 'a careless player should struggle'); - assert.ok(finalMoney('thrifty') > 800, - `a careful player should be comfortably ahead, was ${finalMoney('thrifty')}`); + assert.ok(finalMoney('thrifty') > finalMoney('random'), 'a careful player should get ahead'); + assert.ok(finalMoney('random') > finalMoney('spendthrift'), 'a careless player should struggle'); +}); + +test('the promotion is worth taking, and reachable by a deliberate player', () => { + const { byStrategy } = sweep(pack, { seeds: 12, turns: 200 }); + + const promoted = Object.values(byStrategy).flat().filter((s) => s.stage === 'dispatch'); + const stayed = Object.values(byStrategy).flat().filter((s) => s.stage === 'mailroom'); + assert.ok(promoted.length > 0 && stayed.length > 0, 'both outcomes should occur'); + 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', () => { @@ -179,3 +209,53 @@ test('the retrospective has something to read back', () => { assert.equal(state.history.length, 60, 'every turn is recorded'); assert.ok(state.history.every((h) => Array.isArray(h.upkeep)), 'upkeep is recorded per turn'); }); + +test('a long run does not collapse onto a handful of situations', () => { + // Motivated by a real 183-turn session. Before Dispatch was fleshed out, its + // two floor events were 46% of every promoted run — one every three turns. + // This is the guardrail on that: variety is a property of the pack, and it + // should fail loudly when a stage is too thin for the run it invites. + for (const strategy of ['sociable', 'thrifty', 'random']) { + const counts = new Map(); + let turns = 0; + play(pack, { strategy, seed: 'variety', turns: 200, + visit: ({ turn }) => { + counts.set(turn.event.id, (counts.get(turn.event.id) ?? 0) + 1); + turns++; + } }); + + const [topEvent, topCount] = [...counts.entries()].sort((a, b) => b[1] - a[1])[0]; + const share = topCount / turns; + assert.ok(share < 0.25, + `${strategy}: "${topEvent}" was ${(share * 100).toFixed(0)}% of turns`); + + // Measured against what was actually reachable: a run that never leaves + // the mailroom cannot see Dispatch, and counting that as thin content + // would be measuring career progress rather than variety. + const visited = new Set([...counts.keys()] + .map((id) => pack.events.find((e) => e.id === id).stage) + .filter(Boolean)); + const reachable = pack.events + .filter((e) => e.stage === undefined || visited.has(e.stage)).length; + assert.ok(counts.size / reachable > 0.6, + `${strategy}: saw ${counts.size} of ${reachable} reachable situations in ${turns} turns`); + } +}); + +test('the retrospective reports how repetitive the run was', () => { + const state = play(pack, { strategy: 'sociable', seed: 'repeats', turns: 120 }); + const summary = summarize(pack, state); + + assert.equal(summary.turns, 120); + assert.ok(summary.eventsSeen > 0 && summary.eventsSeen <= summary.eventsTotal); + assert.ok(summary.averageTimesSeen > 1, 'a long run repeats situations'); + assert.equal( + summary.averageTimesSeen.toFixed(2), + (summary.turns / summary.eventsSeen).toFixed(2), + 'the average is turns over distinct situations', + ); + assert.ok(summary.mostSeen.count >= summary.averageTimesSeen); + assert.ok(summary.repeatedSituations <= summary.eventsSeen); + assert.deepEqual(summary.stagesVisited, ['mailroom', 'dispatch'], + 'the retrospective knows the career moved'); +}); diff --git a/test/fixtures/strategies.js b/test/fixtures/strategies.js index 14d2a4e..f00f21b 100644 --- a/test/fixtures/strategies.js +++ b/test/fixtures/strategies.js @@ -62,6 +62,10 @@ export function play(pack, { strategy = 'random', seed = 'run', turns = 200, vis export function sweep(pack, { seeds = 12, turns = 200 } = {}) { const firedEvents = new Set(); const takenOptions = new Set(); + // What a player *could* have picked. This, not what a bot happened to pick, + // is what "is this content reachable" means — a real player chooses freely, + // and an archetype's preferences are an artefact of the harness. + const offeredOptions = new Set(); const byStrategy = {}; for (const strategy of Object.keys(strategies)) { @@ -74,13 +78,16 @@ export function sweep(pack, { seeds = 12, turns = 200 } = {}) { visit: ({ turn, chosen }) => { firedEvents.add(turn.event.id); takenOptions.add(`${turn.event.id}.${chosen.option.id}`); + for (const resolved of turn.options) { + if (resolved.available) offeredOptions.add(`${turn.event.id}.${resolved.option.id}`); + } }, }); finals.push(state); } byStrategy[strategy] = finals; } - return { firedEvents, takenOptions, byStrategy }; + return { firedEvents, takenOptions, offeredOptions, byStrategy }; } export const median = (values) => diff --git a/test/game.test.js b/test/game.test.js index acd79d8..c5ed5f9 100644 --- a/test/game.test.js +++ b/test/game.test.js @@ -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[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'); +}); diff --git a/test/validate.test.js b/test/validate.test.js index e102708..d327662 100644 --- a/test/validate.test.js +++ b/test/validate.test.js @@ -164,3 +164,24 @@ test('a malformed upkeep schedule is caught', () => { assert.deepEqual(orphanOffset.errors, []); assert.match(orphanOffset.warnings.join('\n'), /offset has no effect without every/); }); + +test('content may move the player between stages, but only to a real one', () => { + const good = broken((pack) => { + pack.events.push({ + id: 'upstairs', stage: 'attic', weight: 1, description: 'Up you go.', + options: [{ id: 'go', label: 'Go up.', effects: [] }], + }); + pack.events[0].options[0].effects.push({ path: 'stage', op: 'set', value: 'attic' }); + }); + assert.deepEqual(good.errors, []); + + const nowhere = broken((pack) => { + pack.events[0].options[0].effects.push({ path: 'stage', op: 'set', value: 'penthouse' }); + }); + assert.match(messages(nowhere), /stage "penthouse", which has no events/); + + const wrongOp = broken((pack) => { + pack.events[0].options[0].effects.push({ path: 'stage', op: 'add', value: 1 }); + }); + assert.match(messages(wrongOp), /may only be written with set/); +}); diff --git a/tools/analyze-log.js b/tools/analyze-log.js new file mode 100644 index 0000000..896fb56 --- /dev/null +++ b/tools/analyze-log.js @@ -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 '); + 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; +});