Add chance and a cast that changes between runs, and a pack to try them

An option used to do exactly what it said. Now it may carry a check: one of
four attributes (Nerve, Wits, Charm, Resolve) against a set difficulty or
against a named person's own attribute, leaning on how they regard you, with a
success and a failure. The player sees only the odds in words ("a long shot",
"likely"), never a number. The roll is one draw from the run's generator, so a
run still replays exactly from its seed and choices.

A character may be a role with several authored candidates, one drawn per run,
or a group of two to four drawn into numbered slots. Everyone carries a name, a
description and the same four attributes as the player, so a replayed pack
meets a different office. Text names people as {partner} or {clerk}, and an
event about "a clerk" binds one present clerk for the turn. The draw and the
binding are saved with the run, and a save from before a pack drew a cast gets
the same one every time it is loaded.

All of it is generic and optional: Corporate Ladder and the Frontier use none
of it and play as before. The Counting House, a one-stage office about 1910
built from the story-to-pack catalogue, exercises it, and was tuned against the
conformance suite by measurement. DECISIONS #41. Version 0.4.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018MhABSrqkwTdcybXmheDPr
This commit is contained in:
JesseMarkowitz
2026-09-23 22:12:26 -04:00
co-authored by Claude Opus 5.5
parent 112e558dbb
commit 3108319a2b
25 changed files with 2257 additions and 77 deletions
+122
View File
@@ -0,0 +1,122 @@
// Harrow & Pike's counting house, as it might be staffed.
//
// Nobody here is fixed. Each role has several people who could fill it, and a
// run draws one partner, one chief clerk and two to four clerks from them
// (DECISIONS #41), so a second career in the same pack meets a different
// office. Every candidate carries the same four attributes the player does,
// and a description that should agree with them: a partner written as sharp
// has the wits to see through a bluff, and one written as easily flattered
// does not.
//
// Descriptions may name another role in braces, filled from whoever was drawn
// into it, so a nephew can be the nephew of whichever partner this run has.
export const characters = [
{
id: 'partner',
role_type: 'boss',
title: 'Senior Partner',
candidates: [
{
name: 'Josiah Crane',
description:
'Built the house from one room and a borrowed desk. Remembers every figure ' +
'he has ever been shown, and forgives very few of them.',
attributes: { nerve: 7, wits: 8, charm: 3, resolve: 8 },
startingRelationship: { trust: 40 },
},
{
name: 'Harriet Voss',
description:
'Took over the firm when her husband died and has doubled it since. ' +
'Listens to everything and says about a fifth of it.',
attributes: { nerve: 6, wits: 9, charm: 5, resolve: 7 },
},
{
name: 'Lionel Ashby',
description:
'Inherited the chair and would rather be at his club. Easily flattered, ' +
'easily bored, and sharper than he lets anyone see.',
attributes: { nerve: 4, wits: 6, charm: 7, resolve: 3 },
startingRelationship: { regard: 55 },
},
],
},
{
id: 'chief',
role_type: 'supervisor',
title: 'Chief Clerk',
candidates: [
{
name: 'Silas Pruett',
description:
'Thirty years at the same high desk. Counts the pen nibs on Fridays and ' +
'knows to the penny what the stationery cupboard should hold.',
attributes: { nerve: 5, wits: 6, charm: 2, resolve: 9 },
},
{
name: 'Ada Kemble',
description:
'Runs the floor with a pencil behind her ear and a list, kept in the ' +
'same pencil, of everybody\'s lapses.',
attributes: { nerve: 6, wits: 7, charm: 4, resolve: 7 },
startingRelationship: { trust: 40 },
},
{
name: 'Horace Lumb',
description:
'Wanted your desk for somebody else\'s nephew and has not forgotten it. ' +
'Polite to your face, in a way that takes some effort.',
attributes: { nerve: 4, wits: 5, charm: 5, resolve: 6 },
startingRelationship: { regard: 35 },
},
],
},
{
id: 'clerk',
role_type: 'peer',
title: 'Clerk',
count: [2, 4],
candidates: [
{
name: 'Tom Bassett',
description: 'Cheerful, generous with other people\'s time, and a borrower of small sums he means to repay.',
attributes: { nerve: 5, wits: 4, charm: 7, resolve: 3 },
},
{
name: 'Nell Farrow',
description: 'The quickest pen on the floor and does not mind who knows it. Wants the next desk up, and soon.',
attributes: { nerve: 5, wits: 8, charm: 5, resolve: 6 },
startingRelationship: { regard: 40 },
},
{
name: 'Arthur Quill',
description: 'Timid, exact, and never wrong about a column of figures. Apologises to doors.',
attributes: { nerve: 2, wits: 7, charm: 3, resolve: 7 },
},
{
name: 'Joe Madden',
description: 'The loudest laugh in the building and most of the jokes, not all of them kind.',
attributes: { nerve: 7, wits: 4, charm: 6, resolve: 3 },
},
{
name: 'Lottie Swain',
description: 'Types the partner\'s letters and so knows everything a week early. Tells almost none of it.',
attributes: { nerve: 4, wits: 7, charm: 7, resolve: 5 },
},
{
name: 'Percy Dyer',
description: 'A nephew of {partner}\'s, sent to learn the business. Has so far learned where the kettle is.',
attributes: { nerve: 3, wits: 3, charm: 6, resolve: 2 },
startingRelationship: { trust: 40 },
},
{
name: 'Walt Rudge',
description: 'Twenty years a clerk and content with it. The one everyone asks, and the last to be thanked.',
attributes: { nerve: 6, wits: 6, charm: 4, resolve: 8 },
},
],
},
];
export default characters;
+882
View File
@@ -0,0 +1,882 @@
// A clerk's days at Harrow & Pike.
//
// Every situation here comes from the story-to-pack catalogue (tools/
// story-to-pack/probe/catalogue/v3), where it recurred across public-domain
// office fiction: Torchy's door, Keefe's pay talk, Pooter's cheeky juniors,
// Lorimer's hard boss. The id names the catalogue entry where one fits.
//
// This pack exists to try checks and a drawn cast (DECISIONS #41). Most options
// test one of the four attributes, against a named person or against a set
// difficulty, and say what happens either way; the player sees only the odds
// in words. Names are written as `{partner}`, `{chief}` and `{clerk}` and filled
// from whoever this run drew. An event about "a clerk" lists `clerk` in
// `involves`, and the engine picks one present clerk for the turn.
const moment = (text) => ({ path: 'notable', op: 'push', value: text });
export const events = [
{
id: 'first_day',
stage: 'desk',
weight: 1000,
once: true,
involves: ['clerk'],
description:
'Your first morning at Harrow & Pike. {chief} shows you a stool, a pen, a ' +
'blotter and the clock, in that order, and leaves you to draw your own ' +
'conclusions about which matters most.',
options: [
{
id: 'before_the_clock',
label: 'Be on the stool before the clock is.',
result: '{chief} notices, and writes something down. It may even be good.',
effects: [
{ path: 'relationships.chief.regard', op: 'add', value: 4 },
moment('Began at Harrow & Pike, early.'),
],
},
{
id: 'meet_the_floor',
label: 'Go round and introduce yourself to {clerk}.',
result: '{clerk} shakes your hand and tells you which of the pens leak.',
effects: [
{ path: 'relationships.{clerk}.regard', op: 'add', value: 5 },
moment('Began at Harrow & Pike, and met {clerk} first.'),
],
},
],
},
// The floor: the one event nothing can exclude.
{
id: 'the_ledgers',
stage: 'desk',
weight: 40,
description:
'The ledgers are the same as last week\'s, only longer. {chief} has left a ' +
'note on your blotter about the width of your margins.',
options: [
{
id: 'head_down',
label: 'Keep your head down and work.',
result: 'The day goes into the columns, as days here do.',
effects: [
{ path: 'standing', op: 'add', value: 1 },
{ path: 'relationships.chief.trust', op: 'add', value: 1 },
],
},
{
id: 'stay_late',
label: 'Stay on after six to finish the day book.',
result: 'Overtime is paid, at the rate the firm thinks overtime is worth.',
effects: [
{ path: 'money', op: 'add', value: 2 },
{ path: 'relationships.chief.regard', op: 'add', value: 1 },
],
},
{
id: 'ease_off',
label: 'Make the day last.',
result: 'Nobody notices, today.',
effects: [{ path: 'standing', op: 'add', value: -1 }],
},
],
},
{
id: 'ask_for_more',
stage: 'desk',
weight: 25,
cooldown: 12,
requires: [
{ path: 'turn', op: '>=', value: 10 },
{ path: 'rises', op: '<', value: 3 },
],
description:
'You have been at the desk long enough to know what the work is worth, and ' +
'your lodgings have not got any cheaper. {partner} is in, and alone.',
options: [
{
id: 'ask',
label: 'Knock, and ask {partner} for a rise.',
check: { attribute: 'charm', against: 'partner', opposedBy: 'wits', relationship: 'trust' },
success: {
result: '{partner} looks at you for a long moment and says the firm can manage something.',
effects: [
{ path: 'rises', op: 'add', value: 1 },
{ path: 'standing', op: 'add', value: 3 },
moment('Asked {partner} for a rise, and got it.'),
],
},
failure: {
result: '{partner} asks whether you have any idea what a clerk is worth. You find you do not.',
effects: [
{ path: 'relationships.partner.trust', op: 'add', value: -5 },
{ path: 'standing', op: 'add', value: -2 },
],
},
},
{
id: 'hint',
label: 'Say nothing directly; mention the price of lodgings.',
check: { attribute: 'wits', difficulty: 5 },
success: {
result: '{partner} takes the point and, better, thinks it was a delicate one.',
effects: [{ path: 'relationships.partner.regard', op: 'add', value: 4 }],
},
failure: {
result: '{partner} agrees that lodgings are very dear and goes back to the post.',
effects: [],
},
},
{
id: 'not_yet',
label: 'Wait for a better moment.',
result: 'There will be one. There usually is.',
effects: [],
},
],
},
{
id: 'called_in',
stage: 'desk',
weight: 30,
cooldown: 8,
involves: ['clerk'],
description:
'{chief} has found a column that does not add up, and it is in your hand. You ' +
'are to explain it, standing, in front of the high desk.',
options: [
{
id: 'own_up',
label: 'Say plainly that it was your mistake.',
check: { attribute: 'resolve', difficulty: 4 },
success: {
result: 'It is noted, and then, which is rarer, it is let go.',
effects: [
{ path: 'relationships.partner.trust', op: 'add', value: 4 },
{ path: 'attributes.resolve', op: 'add', value: 1 },
],
},
failure: {
result: 'You start well and finish apologising for the weather.',
effects: [{ path: 'standing', op: 'add', value: -2 }],
},
},
{
id: 'argue',
label: 'Argue that the figures were right.',
check: { attribute: 'wits', against: 'chief' },
success: {
result: 'They were. {chief} checks twice and hands the page back without a word.',
effects: [
{ path: 'standing', op: 'add', value: 3 },
{ path: 'relationships.chief.regard', op: 'add', value: -4 },
],
},
failure: {
result: 'They were not, and now everybody knows you thought they were.',
effects: [
{ path: 'standing', op: 'add', value: -4 },
{ path: 'relationships.partner.trust', op: 'add', value: -3 },
],
},
},
{
id: 'blame',
label: 'Say the figures came from {clerk}\'s desk.',
check: { attribute: 'wits', against: 'chief' },
success: {
result: '{chief} goes to find {clerk}. You do not watch.',
effects: [
{ path: 'standing', op: 'add', value: 1 },
{ path: 'relationships.{clerk}.trust', op: 'add', value: -8 },
],
},
failure: {
result: '{chief} has the day book open at your page before you finish the sentence.',
effects: [
{ path: 'standing', op: 'add', value: -5 },
{ path: 'relationships.{clerk}.trust', op: 'add', value: -10 },
{ path: 'relationships.partner.trust', op: 'add', value: -5 },
moment('Tried to put a mistake on {clerk}, and was found out.'),
],
},
},
],
},
{
id: 'the_door',
stage: 'desk',
weight: 30,
cooldown: 6,
description:
'A caller in a good coat wants {partner} and will not give his name. You ' +
'were told the partner was not to be disturbed before three.',
options: [
{
id: 'turn_away',
label: 'Tell him the partner is out.',
check: { attribute: 'nerve', difficulty: 5 },
success: {
result: 'He goes. {partner} hears of it later and approves of the lie.',
effects: [{ path: 'relationships.partner.trust', op: 'add', value: 3 }],
},
failure: {
result: 'He sits down to wait, and {partner} comes out at a quarter past and finds him there.',
effects: [
{ path: 'relationships.partner.regard', op: 'add', value: -3 },
{ path: 'standing', op: 'add', value: -1 },
],
},
},
{
id: 'size_him_up',
label: 'Size him up and decide for yourself.',
check: { attribute: 'wits', difficulty: 6 },
success: {
result: 'You read him right. He mattered, and he got in, and {partner} knows who let him.',
effects: [
{ path: 'relationships.partner.trust', op: 'add', value: 5 },
{ path: 'standing', op: 'add', value: 2 },
moment('Judged a caller at the door, and judged him right.'),
],
},
failure: {
result: 'He was selling encyclopaedias, and {partner} bought one to get rid of him.',
effects: [{ path: 'relationships.partner.trust', op: 'add', value: -4 }],
},
},
{
id: 'pass_it_on',
label: 'Fetch {chief} and let it be somebody else\'s decision.',
result: '{chief} deals with it, and with you, briefly.',
effects: [{ path: 'standing', op: 'add', value: -1 }],
},
],
},
{
id: 'needled',
stage: 'desk',
weight: 40,
cooldown: 4,
involves: ['clerk'],
description:
'{clerk} reads your last letter aloud in a voice meant to be yours, and the ' +
'floor laughs, and keeps laughing longer than it deserves.',
options: [
{
id: 'let_it_go',
label: 'Let it pass.',
result: 'It passes. They will remember that you let it.',
effects: [
{ path: 'relationships.{clerk}.regard', op: 'add', value: 1 },
{ path: 'standing', op: 'add', value: -1 },
],
},
{
id: 'retort',
label: 'Answer {clerk} back, with the floor listening.',
check: { attribute: 'wits', against: '{clerk}', relationship: 'regard' },
success: {
result: 'It gets a bigger laugh than the letter did, and {clerk} laughs too.',
effects: [
{ path: 'standing', op: 'add', value: 2 },
{ path: 'relationships.{clerk}.regard', op: 'add', value: 3 },
],
},
failure: {
result: 'It comes out too sharp, and the laugh turns on you.',
effects: [
{ path: 'standing', op: 'add', value: -2 },
{ path: 'relationships.{clerk}.regard', op: 'add', value: -3 },
],
},
},
{
id: 'report',
label: 'Mention it to {chief}.',
result: '{chief} has a word. {clerk} knows where it came from.',
effects: [
{ path: 'relationships.chief.trust', op: 'add', value: 2 },
{ path: 'relationships.{clerk}.trust', op: 'add', value: -6 },
{ path: 'standing', op: 'add', value: -1 },
],
},
],
},
{
id: 'red_ink',
stage: 'desk',
weight: 30,
cooldown: 6,
description:
'{chief} has gone over your morning\'s work in red ink, and left the page ' +
'open on the high desk where the whole floor can see it.',
options: [
{
id: 'swallow_it',
label: 'Take it, and do the page again.',
result: 'You do it again. {chief} initials it without looking up.',
effects: [
{ path: 'relationships.chief.regard', op: 'add', value: 2 },
{ path: 'standing', op: 'add', value: -1 },
],
},
{
id: 'answer_back',
label: 'Point out that the red ink is wrong.',
check: { attribute: 'wits', against: 'chief' },
success: {
result: 'It is, in two places. {chief} says nothing, which from {chief.first} is a concession.',
effects: [
{ path: 'standing', op: 'add', value: 2 },
{ path: 'relationships.chief.regard', op: 'add', value: 3 },
],
},
failure: {
result: 'The red ink is right. It usually is.',
effects: [
{ path: 'standing', op: 'add', value: -2 },
{ path: 'relationships.chief.regard', op: 'add', value: -5 },
],
},
},
{
id: 'over_the_head',
label: 'Take the page to {partner}.',
check: { attribute: 'charm', against: 'partner', opposedBy: 'resolve', relationship: 'trust' },
success: {
result: '{partner} reads it, and has a word with {chief}, and {chief} has one with you.',
effects: [
{ path: 'relationships.partner.trust', op: 'add', value: 3 },
{ path: 'relationships.chief.regard', op: 'add', value: -8 },
{ path: 'standing', op: 'add', value: 2 },
moment('Went over {chief}\'s head, and it worked.'),
],
},
failure: {
result: '{partner} says the chief clerk runs the floor, and asks whether there was anything else.',
effects: [
{ path: 'relationships.partner.trust', op: 'add', value: -4 },
{ path: 'relationships.chief.regard', op: 'add', value: -8 },
{ path: 'standing', op: 'add', value: -3 },
],
},
},
],
},
{
id: 'sure_thing',
stage: 'desk',
weight: 15,
cooldown: 20,
description:
'A pleasant man at the chop-house has a share in a tramway in Brazil going ' +
'begging. Twelve dollars today, he says, will be forty by Christmas.',
options: [
{
id: 'buy_in',
label: 'Put in twelve dollars.',
effects: [{ path: 'money', op: 'add', value: -12 }],
check: { attribute: 'wits', difficulty: 7 },
success: {
result: 'Against every expectation, including his, it pays.',
effects: [{ path: 'money', op: 'add', value: 30 }, moment('Bought a share in a tramway, and it paid.')],
},
failure: {
result: 'There is no tramway. There is, briefly, a man.',
effects: [moment('Bought a share in a tramway that did not exist.')],
},
},
{
id: 'see_through',
label: 'Ask him the one question he cannot answer.',
check: { attribute: 'wits', difficulty: 6 },
success: {
result: 'He finds he has an appointment. You find you are sharper than you were.',
effects: [{ path: 'attributes.wits', op: 'add', value: 1 }, moment('Saw through a tramway in Brazil.')],
},
failure: {
result: 'He answers it smoothly, and you are left not knowing.',
effects: [],
},
},
{
id: 'decline',
label: 'Keep your money in your pocket.',
result: 'He is sorry for you, he says, and goes to find someone less careful.',
effects: [],
},
],
},
{
id: 'the_round',
stage: 'desk',
weight: 30,
cooldown: 5,
involves: ['clerk'],
description:
'{clerk} and the others are going to the chop-house after work, and they ' +
'have kept you a place. The round there is not cheap.',
options: [
{
id: 'go',
label: 'Go, and stand your round.',
result: 'A good evening, and a dear one.',
effects: [
{ path: 'money', op: 'add', value: -5 },
{ path: 'relationships.{clerk}.regard', op: 'add', value: 4 },
{ path: 'standing', op: 'add', value: 1 },
],
},
{
id: 'talk_your_way',
label: 'Go, and talk your way out of the round.',
check: { attribute: 'charm', against: '{clerk}', opposedBy: 'wits' },
success: {
result: 'Nobody notices you never reached for your purse, and {clerk} laughs at all your jokes.',
effects: [{ path: 'relationships.{clerk}.regard', op: 'add', value: 3 }],
},
failure: {
result: '{clerk} notices, and says so, and you pay for the round and the remark.',
effects: [
{ path: 'money', op: 'add', value: -5 },
{ path: 'relationships.{clerk}.regard', op: 'add', value: -2 },
],
},
},
{
id: 'excuse',
label: 'Make an excuse and go home.',
result: 'They go without you. They will ask again, perhaps.',
effects: [{ path: 'relationships.{clerk}.regard', op: 'add', value: -2 }],
},
],
},
{
id: 'night_school',
stage: 'desk',
weight: 25,
cooldown: 5,
requires: [{ path: 'money', op: '>=', value: 3 }],
description:
'A card in the tram window: evening classes in bookkeeping, in the art of ' +
'address, and at the boxing club behind the Institute. Three dollars a course.',
options: [
{
id: 'bookkeeping',
label: 'Take the bookkeeping course.',
result: 'Double entry, twice a week. Your columns begin to add up before you do.',
effects: [{ path: 'money', op: 'add', value: -3 }, { path: 'attributes.wits', op: 'add', value: 1 }],
},
{
id: 'address',
label: 'Take the art of address.',
result: 'You learn when to say nothing, which is most of it.',
effects: [{ path: 'money', op: 'add', value: -3 }, { path: 'attributes.charm', op: 'add', value: 1 }],
},
{
id: 'boxing',
label: 'Join the boxing club.',
result: 'You are hit in the face by a draper. It does you a world of good.',
effects: [{ path: 'money', op: 'add', value: -3 }, { path: 'attributes.nerve', op: 'add', value: 1 }],
},
{
id: 'not_this_term',
label: 'Not this term.',
result: 'The card is still there next week.',
effects: [],
},
],
},
{
id: 'stand_in',
stage: 'desk',
weight: 15,
cooldown: 10,
requires: [{ path: 'standing', op: '>=', value: 45 }],
description:
'{partner} is laid up and sends you, of all people, to sit in on the meeting ' +
'with the shipping agents. You are to listen, and to say as little as possible.',
options: [
{
id: 'listen',
label: 'Listen, and take good notes.',
result: 'Your notes are better than the minutes. {partner} keeps them.',
effects: [
{ path: 'relationships.partner.trust', op: 'add', value: 2 },
{ path: 'standing', op: 'add', value: 1 },
],
},
{
id: 'speak_up',
label: 'Say the thing you noticed in their figures.',
check: { attribute: 'wits', difficulty: 7 },
success: {
result: 'You were right, the agents know it, and so, by the afternoon, does {partner}.',
effects: [
{ path: 'relationships.partner.trust', op: 'add', value: 6 },
{ path: 'standing', op: 'add', value: 5 },
moment('Spoke up at the agents\' meeting in {partner}\'s place, and was right.'),
],
},
failure: {
result: 'You were wrong in front of people who were paid to be right.',
effects: [
{ path: 'relationships.partner.trust', op: 'add', value: -4 },
{ path: 'standing', op: 'add', value: -3 },
],
},
},
{
id: 'charm_them',
label: 'Charm the agents over lunch.',
check: { attribute: 'charm', difficulty: 6 },
success: {
result: 'They like you. They say so to {partner}, which is the point.',
effects: [
{ path: 'relationships.partner.regard', op: 'add', value: 5 },
{ path: 'standing', op: 'add', value: 3 },
],
},
failure: {
result: 'The lunch is long, and you pay for it.',
effects: [
{ path: 'money', op: 'add', value: -4 },
{ path: 'standing', op: 'add', value: -1 },
],
},
},
],
},
{
id: 'lost_bill',
stage: 'desk',
weight: 20,
cooldown: 12,
involves: ['clerk'],
description:
'{clerk} has lost a bill of lading, and {chief} is talking about a letter to ' +
'the partner. {clerk} has not looked up from the desk all morning.',
options: [
{
id: 'speak_for',
label: 'Put {clerk}\'s case to {partner} yourself.',
check: { attribute: 'charm', against: 'partner', opposedBy: 'resolve', relationship: 'trust' },
success: {
result: '{partner} hears you out and lets it go. {clerk} does not forget who spoke.',
effects: [
{ path: 'relationships.{clerk}.trust', op: 'add', value: 10 },
{ path: 'relationships.partner.regard', op: 'add', value: 2 },
moment('Spoke up for {clerk}, and it held.'),
],
},
failure: {
result: '{partner} asks whether you would like to share the letter.',
effects: [
{ path: 'relationships.partner.trust', op: 'add', value: -3 },
{ path: 'relationships.{clerk}.trust', op: 'add', value: 4 },
],
},
},
{
id: 'help_look',
label: 'Stay late and help look for it.',
check: { attribute: 'resolve', difficulty: 5 },
success: {
result: 'It is in the wrong drawer, under the right one. You find it at nine.',
effects: [
{ path: 'relationships.{clerk}.trust', op: 'add', value: 8 },
{ path: 'relationships.{clerk}.regard', op: 'add', value: 4 },
],
},
failure: {
result: 'It is not found, but {clerk} saw you look.',
effects: [{ path: 'relationships.{clerk}.regard', op: 'add', value: 2 }],
},
},
{
id: 'keep_out',
label: 'Keep out of it.',
result: 'The letter goes. So, very nearly, does {clerk}.',
effects: [{ path: 'relationships.{clerk}.trust', op: 'add', value: -3 }],
},
],
},
{
id: 'the_butt',
stage: 'desk',
weight: 20,
cooldown: 8,
involves: ['clerk'],
description:
'Somebody has pinned a card to your back, and you have walked the length of ' +
'the floor to {chief}\'s desk wearing it. {clerk} cannot keep a straight face.',
options: [
{
id: 'laugh_along',
label: 'Laugh louder than anybody.',
check: { attribute: 'charm', difficulty: 4 },
success: {
result: 'It becomes your joke instead of theirs.',
effects: [
{ path: 'standing', op: 'add', value: 1 },
{ path: 'relationships.{clerk}.regard', op: 'add', value: 3 },
],
},
failure: {
result: 'The laugh comes out wrong, and everyone hears that it did.',
effects: [{ path: 'standing', op: 'add', value: -2 }],
},
},
{
id: 'dignity',
label: 'Take it off and say nothing at all.',
check: { attribute: 'resolve', difficulty: 4 },
success: {
result: 'It is not funny for long, when nobody is laughing at the front.',
effects: [{ path: 'standing', op: 'add', value: 1 }],
},
failure: {
result: 'Your ears give you away.',
effects: [
{ path: 'standing', op: 'add', value: -1 },
{ path: 'relationships.{clerk}.regard', op: 'add', value: -2 },
],
},
},
{
id: 'get_even',
label: 'Resolve to do something about {clerk} tomorrow.',
result: 'You do. It is not as funny, and {clerk} knows exactly who it was.',
effects: [
{ path: 'relationships.{clerk}.regard', op: 'add', value: -2 },
{ path: 'relationships.{clerk}.trust', op: 'add', value: -3 },
],
},
],
},
{
id: 'saturday',
stage: 'desk',
weight: 60,
cooldown: 5,
requires: [{ path: 'calendar.name', op: '==', value: 'Saturday' }],
description:
'Saturday, and the office shuts at one. The pay envelope is in your inside ' +
'pocket, and the whole of the afternoon is in front of you.',
options: [
{
id: 'new_collar',
label: 'Buy the collar the senior clerks wear.',
result: 'It pinches, and it is noticed.',
effects: [
{ path: 'money', op: 'add', value: -5 },
{ path: 'standing', op: 'add', value: 2 },
],
},
{
id: 'good_dinner',
label: 'Dine somewhere with a tablecloth.',
result: 'A dinner you will describe, later, to people who were not there.',
effects: [{ path: 'money', op: 'add', value: -4 }],
},
{
id: 'pay_back',
label: 'Pay back what you owe the floor.',
requires: [{ path: 'owed', op: '>', value: 0 }],
whenLocked: 'hide',
result: 'Squared, or nearer it. It is a better feeling than the dinner would have been.',
// The money is read from the balance before either moves, so a small
// debt costs what it is and no more (DECISIONS #38).
effects: [
{ path: 'money', op: 'add', value: { from: 'owed', times: -1, cap: 10 } },
{ path: 'owed', op: 'add', value: { from: 'owed', times: -1, cap: 10 } },
],
},
{
id: 'straight_home',
label: 'Straight home, envelope sealed.',
result: 'The envelope is lighter than it was in your imagination, but it is all there.',
effects: [],
},
],
},
{
id: 'family_errand',
stage: 'desk',
weight: 20,
cooldown: 8,
involves: ['clerk'],
description:
'A parcel for {partner}\'s sister has to cross the city before four, in the ' +
'rain, and {partner} has asked for you by name. It is not in the job.',
options: [
{
id: 'take_it',
label: 'Take it yourself, and pay your own tram.',
result: 'The sister gives you tea, and a message for {partner} that is more about you than the parcel.',
effects: [
{ path: 'money', op: 'add', value: -1 },
{ path: 'relationships.partner.regard', op: 'add', value: 4 },
{ path: 'standing', op: 'add', value: -1 },
],
},
{
id: 'make_an_impression',
label: 'Take it, and make yourself agreeable when you get there.',
check: { attribute: 'charm', difficulty: 5 },
success: {
result: 'The sister writes to {partner} that evening. You are mentioned warmly.',
effects: [
{ path: 'relationships.partner.regard', op: 'add', value: 6 },
{ path: 'relationships.partner.trust', op: 'add', value: 2 },
moment('Carried a parcel for {partner}\'s sister, and made a friend of her.'),
],
},
failure: {
result: 'You drip on the carpet and say something about the weather twice.',
effects: [{ path: 'relationships.partner.regard', op: 'add', value: 1 }],
},
},
{
id: 'hand_it_on',
label: 'Hand it on to {clerk}, who is going that way.',
result: '{clerk} goes, and gets the tea, and does not say whose errand it was.',
effects: [
{ path: 'relationships.{clerk}.regard', op: 'add', value: -3 },
{ path: 'relationships.partner.regard', op: 'add', value: -2 },
],
},
],
},
{
id: 'window_desk',
stage: 'desk',
weight: 20,
cooldown: 10,
involves: ['clerk'],
description:
'The desk by the window has come free. {chief} says it will go to whoever ' +
'deserves it, and {clerk} has been in before the clock all week.',
options: [
{
id: 'outwork',
label: 'Come in earlier than {clerk} does.',
check: { attribute: 'resolve', against: '{clerk}' },
success: {
result: 'You are there before the cleaners. The desk is yours, and so is the draught.',
effects: [
{ path: 'standing', op: 'add', value: 4 },
{ path: 'relationships.{clerk}.regard', op: 'add', value: -3 },
moment('Won the desk by the window from {clerk}.'),
],
},
failure: {
result: '{clerk} was there before you, every morning, and has the desk.',
effects: [{ path: 'standing', op: 'add', value: -1 }],
},
},
{
id: 'word_with_chief',
label: 'Have a quiet word with {chief}.',
check: { attribute: 'charm', against: 'chief', opposedBy: 'wits', relationship: 'regard' },
success: {
result: '{chief} decides the matter in your favour, and makes sure {clerk} knows why.',
effects: [
{ path: 'standing', op: 'add', value: 3 },
{ path: 'relationships.{clerk}.trust', op: 'add', value: -6 },
],
},
failure: {
result: '{chief} writes your name down, next to the word "canvassing".',
effects: [
{ path: 'relationships.chief.trust', op: 'add', value: -4 },
{ path: 'standing', op: 'add', value: -2 },
],
},
},
{
id: 'let_them_have_it',
label: 'Let {clerk} have it.',
result: '{clerk} takes the desk and, to your surprise, remembers who did not fight for it.',
effects: [
{ path: 'relationships.{clerk}.regard', op: 'add', value: 5 },
{ path: 'relationships.{clerk}.trust', op: 'add', value: 3 },
],
},
],
},
{
id: 'hard_times',
stage: 'desk',
weight: 400,
cooldown: 2,
requires: [{ path: 'money', op: '<', value: 0 }],
involves: ['clerk'],
description:
'The landlady has knocked twice about the week, and the purse under your ' +
'pillow is empty. It is a long walk to Saturday.',
options: [
{
id: 'copying',
label: 'Take in copying work to do at night.',
result: 'Law papers, by lamp, at a halfpenny a folio. It keeps the landlady quiet.',
effects: [
{ path: 'money', op: 'add', value: 8 },
{ path: 'attributes.resolve', op: 'add', value: 1 },
],
},
{
id: 'borrow',
label: 'Ask {clerk} for the loan of ten dollars.',
check: { attribute: 'charm', against: '{clerk}', opposedBy: 'resolve', relationship: 'trust' },
success: {
result: '{clerk} lends it, and tells you not to mention it, and you will not forget.',
effects: [
{ path: 'money', op: 'add', value: 10 },
{ path: 'owed', op: 'add', value: 10 },
{ path: 'relationships.{clerk}.trust', op: 'add', value: -2 },
],
},
failure: {
result: '{clerk} is short too, but finds you three, and makes a face about it.',
effects: [
{ path: 'money', op: 'add', value: 3 },
{ path: 'owed', op: 'add', value: 3 },
{ path: 'relationships.{clerk}.regard', op: 'add', value: -3 },
],
},
},
{
id: 'pawn_the_watch',
label: 'Pawn your father\'s watch.',
requires: [{ path: 'flags.pawned_watch', op: '==', value: false }],
whenLocked: 'hide',
result: 'Twelve dollars and a ticket. You will get it back. Everybody says that.',
effects: [
{ path: 'money', op: 'add', value: 12 },
{ path: 'flags.pawned_watch', op: 'set', value: true },
moment('Pawned your father\'s watch to pay the landlady.'),
],
},
],
},
];
export default events;
+136
View File
@@ -0,0 +1,136 @@
// The Counting House — a prototype pack for checks and a drawn cast.
//
// Harrow & Pike, a trading firm's counting house, somewhere about 1910. You
// are the newest clerk. The situations are the ones public-domain office
// fiction kept returning to (tools/story-to-pack, catalogue v3); the people are
// drawn fresh each run from several candidates per role, two to four clerks
// among them; and most choices are a check, shown to the player only as odds
// in words. See DECISIONS #41.
//
// One stage, deliberately: this pack exists to find out whether chance and a
// varying cast make the same situations worth replaying, before a whole career
// is built on them.
import { characters } from './characters.js';
import { events } from './events.js';
export const countingHouse = {
id: 'countinghouse',
name: 'The Counting House',
blurb:
'Harrow & Pike, about 1910: a stool, a pen, a chief clerk with red ink, and a ' +
'different office every time you start.',
version: '0.1.0',
turnUnit: 'day',
startingStage: 'desk',
peopleHeading: 'Harrow & Pike',
stages: {
desk: { label: 'The Desk', title: 'Junior Clerk' },
},
// A six-day week: the office shuts at one on Saturday, and that is pay day.
calendar: {
cycleLength: 6,
cycleName: 'Week',
unitNames: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'],
},
// The four attributes, the same for the player and for everyone in the cast.
// A pack may call them what it likes; these are the prototype's names.
attributes: {
nerve: { label: 'Nerve' },
wits: { label: 'Wits' },
charm: { label: 'Charm' },
resolve: { label: 'Resolve' },
},
// How attributes turn into chances. The player sees only the words.
// chance = 50 + 8 x (yours - theirs) + 0.4 x (their regard - 50), from 5 to 95
// A new clerk (3) against a sharp partner (8) is 10%, a long shot; against an
// ordinary task (4) it is 42%. Each point of training is worth eight.
checks: {
base: 50,
perPoint: 8,
unopposed: 4,
relationshipPerPoint: 0.4,
relationshipCentre: 50,
floor: 5,
ceiling: 95,
odds: [
{ upTo: 20, words: 'a long shot' },
{ upTo: 40, words: 'unlikely' },
{ upTo: 60, words: 'an even chance' },
{ upTo: 80, words: 'likely' },
{ upTo: 95, words: 'a safe bet' },
],
said: { success: 'and it came off.', failure: 'and it did not come off.' },
},
state: {
'money': { initial: 20, min: -200, max: 100000 },
'standing': { initial: 40, min: 0, max: 100 },
'attributes.nerve': { initial: 3, min: 0, max: 10 },
'attributes.wits': { initial: 3, min: 0, max: 10 },
'attributes.charm': { initial: 3, min: 0, max: 10 },
'attributes.resolve': { initial: 3, min: 0, max: 10 },
// Borrowed from the other clerks when times are hard, repaid on a Saturday.
'owed': { initial: 0, min: 0, max: 100000 },
// Rises won from the partner: each is a permanent addition to the wage.
'rises': { initial: 0, min: 0, max: 3 },
'flags.pawned_watch': { initial: false },
'notable': { initial: [] },
},
// Two axes, not one: whether they believe you, and what they think of you.
relationshipStats: {
trust: { label: 'Trusts you', initial: 50, min: 0, max: 100 },
regard: { label: 'Thinks well of you', initial: 50, min: 0, max: 100 },
},
characters,
events,
// Per six-day week: -12 board and lodging +16 wages = +4.
// Each rise adds four; a rise is earned, so it is outside the baseline.
upkeep: [
{ label: 'Board and lodging', effects: [{ path: 'money', op: 'add', value: -2 }] },
{
label: 'Wages from Harrow & Pike',
every: 6,
offset: 5,
effects: [{ path: 'money', op: 'add', value: 16 }],
},
...[1, 2, 3].map((n) => ({
label: n === 1 ? 'Your rise' : `Your rise (${n})`,
every: 6,
offset: 5,
requires: [{ path: 'rises', op: '>=', value: n }],
effects: [{ path: 'money', op: 'add', value: 4 }],
})),
],
tuning: {
baselinePerCycle: { desk: [0, 10] },
baselineExcludes: ['rises'],
},
display: {
'money': { label: 'Money', format: 'money', order: 1 },
'owed': { label: 'Owed to the floor', format: 'money', order: 2, hideWhenZero: true },
'standing': { label: 'Standing', format: 'meter', order: 2 },
'attributes.nerve': { label: 'Nerve', format: 'meter', order: 3 },
'attributes.wits': { label: 'Wits', format: 'meter', order: 4 },
'attributes.charm': { label: 'Charm', format: 'meter', order: 5 },
'attributes.resolve': { label: 'Resolve', format: 'meter', order: 6 },
'rises': { label: 'Rises', order: 7, hideWhenZero: true },
'notable': { hidden: true, inLedger: false },
'flags.pawned_watch': {
label: 'Your father\'s watch', hidden: true,
whenTrue: 'Your father\'s watch is at the pawnbroker\'s.',
whenFalse: 'You have your father\'s watch back.',
},
},
};
export default countingHouse;
+2 -1
View File
@@ -10,8 +10,9 @@
import { corporateLadder } from './corporateladder/index.js';
import { frontier } from './frontier/index.js';
import { countingHouse } from './countinghouse/index.js';
export const packs = [corporateLadder, frontier];
export const packs = [corporateLadder, frontier, countingHouse];
/** A pack by id, or undefined. Used to resume into whatever was last played. */
export function packById(id) {
+89
View File
@@ -765,3 +765,92 @@ whether money stops mattering again, as it did in Corporate Ladder before #36.
"Owed at the store" was asked for as "a major field at the top". It already is,
second after Money whenever anything is owed. Whether it is prominent enough can
only be judged in a browser, which this machine cannot render (#18).
## 41. Chance, and a cast that changes between runs
Recorded 2026-09-23. Designer's call: a change to the base engine, generic, which
every pack may use and none has to. Corporate Ladder and the Frontier use none of
it and replay exactly as before.
Until now an option did exactly what it said. Skills only decided whether an
option was on the menu (#15, #32). The story-to-pack catalogue of recurring
situations (asking for a rise, the door to the powerful, a colleague's needling)
is made of moments whose outcome depends on who you are and who you are dealing
with, so three things were added. `content/countinghouse` is the pack that exercises them.
**Attributes.** A pack names them in `pack.attributes`. The player's live in
ordinary declared state (`attributes.<id>`), so they grow by effects like anything
else. The prototype's four, agreed with the designer, are Nerve, Wits, Charm and
Resolve. Another pack may rename them, as #14 lets it rename the interface.
**Checks.** An option may carry `check: { attribute, against?, opposedBy?,
relationship?, difficulty?, modifier? }` and then must carry `success` and
`failure`, each with its own `result` and `effects`. Its plain `effects` apply
either way. The chance is
base + perPoint × (yours − theirs) + relationshipPerPoint × (their regard − centre) + modifier,
clamped to [floor, ceiling]
where "theirs" is the opposing character's attribute, or the check's `difficulty`,
or the pack's `unopposed`. Every number is the pack's, in `pack.checks` (#9).
**The player never sees a number.** Options show the odds in words ("a long shot",
"likely"), from bands the pack may rename, and the outcome says whether it came
off. The roll is one draw from the run's own generator, so a run with checks still
replays exactly from its seed and choices (#5, #6). A pack that never checks draws
nothing extra.
**A cast drawn per run.** A character may be a *role* with `candidates`, several
authored people who could fill it, and one is drawn when a run starts. A role may
be a *group* with `count: [min, max]` (two to four clerks), which draws that many
different people into numbered slots (`clerk_1` .. `clerk_4`). Every candidate
carries a name, a description and the same attributes the player has, so a
replayed pack meets a different office with different strengths. Candidates are
authored rather than generated from random parts: a character written as sharp
has the wits to see through a bluff, which a random attribute roll pasted onto a
random description would not guarantee.
- **The draw is saved in engine-owned state** (`cast`, now a reserved root, #4)
and made on its own stream derived from the seed, so it neither moves the event
generator nor depends on it. `hydrate` (#36) draws one for a save that predates
it, and draws the same one every time.
- **Every slot up to a group's maximum is declared in the schema from the start.**
Relationship state for `clerk_3` exists whether or not anyone was drawn into it,
so it validates and saves like any other path. An empty slot is recorded as
`null` and is simply absent from the run.
- **Text names people by role.** `{partner}`, `{chief.first}` and `{clerk.title}`
are filled in from whoever was drawn. An event about "a clerk" says
`involves: ['clerk']`. When it is drawn, one present clerk is bound to it for
the turn, and `{clerk}` in its text, `relationships.{clerk}.trust` in its effects
and `against: '{clerk}'` in a check all mean that person. The binding is saved
with the run. An event cannot fire in a run with nobody to bind. Text kept in
state, such as a notable moment, has its placeholders pinned to the slot
(`{clerk_2}`), so the retrospective names the right person later.
- **The validator holds all of it:**
- attributes declared, and every drawn person carrying all of them;
- enough candidates for a group's maximum, and unique names;
- checks naming real attributes and people, with both branches;
- `{placeholders}` that name real characters, where a group is only named inside an event that binds it;
- no placeholder in an event's own `requires`, which is evaluated before anyone is bound.
**What the test harness had to learn.** The archetypes score an option by its
effects (#21). A checked option is now scored at its expected value: plain effects
in full, and each branch weighted by its chance, the way a player reading the odds
would. The conformance check that no repayment takes more than is owed (#38) now
reads both branches, since a check can pay or spend on either.
**The prototype pack was tuned by measurement, not by eye.** Its first draft
failed three conformance tests:
- the routine ledgers were a third of every player's turns;
- a careless player was broke 40% of a run;
- it had no balance a borrower could owe.
Two more catalogue situations (an errand for the partner's family, and a desk you
and a clerk both want), a lighter floor event, a loan that is refused but still
helps a little, and an "owed to the floor" balance repaid on Saturdays brought
those to 22% at worst, 12% at worst, and a debt that is repaid by reading the
balance (#38).
**Not yet known.** Whether chance makes the same situations worth replaying, and
whether the odds in words are legible, can only be judged by someone playing it
(#18). The pack has one stage on purpose, so that question can be answered before
a career is built on it.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "theladder",
"version": "0.3.4",
"version": "0.4.0",
"private": true,
"description": "A turn-based career simulation game. Engine is theme-agnostic; settings are content packs.",
"type": "module",
+139
View File
@@ -0,0 +1,139 @@
// Who is in this run.
//
// A pack's characters are fixed people by default: a name and a description,
// written once. A character may instead be a *role* with `candidates` — several
// authored people who could fill it — and one is drawn when a run starts. A role
// may also be a *group* with a `count` range, like two to four co-workers, in
// which case that many different candidates are drawn into numbered slots.
// Replaying a pack therefore meets a different cast, which is the point.
//
// Slots are what state is keyed by: a single role is its own id (`boss`), a
// group's members are `coworker_1` .. `coworker_<max>`. Every slot up to the
// group's maximum exists in the schema from the start, so relationship state
// is declared, validated and saved like any other; a slot nobody was drawn into
// is simply not present in the run. See DECISIONS #41.
//
// The draw uses its own stream derived from the run's seed, so it neither moves
// the event generator nor depends on it: a pack that gains a cast does not
// change which events any other pack draws, and a save that predates the cast
// can have one drawn for it on load, reproducibly.
import { createRng } from './rng.js';
import { getPath } from './paths.js';
/** The count range of a group role, or null for a single character. */
export function groupRange(character) {
if (character.count === undefined) return null;
return Array.isArray(character.count) ? character.count : [character.count, character.count];
}
/** Every state slot a character occupies: its own id, or one per possible group member. */
export function slotsOf(character) {
const range = groupRange(character);
if (!range) return [character.id];
return Array.from({ length: range[1] }, (_, i) => `${character.id}_${i + 1}`);
}
/** Whether a pack draws any part of its cast per run. */
export function hasDrawnCast(pack) {
return (pack.characters ?? []).some((c) => c.candidates?.length || groupRange(c));
}
/** The authored person behind a candidate entry, in the shape the state keeps. */
function personOf(character, candidate, index) {
return {
role: character.id,
candidate: index,
name: candidate.name,
title: candidate.title ?? character.title ?? null,
description: candidate.description ?? '',
attributes: { ...(candidate.attributes ?? {}) },
};
}
/**
* Draw the cast for a run. Returns `{ <slot>: person | null }` for every slot of
* every drawn role; a group slot left empty is `null`, which is how "not in this
* run" is recorded. Fixed characters are not in it: they need no drawing.
*/
export function drawCast(pack, seed) {
const rng = createRng(`cast:${seed}`);
const cast = {};
for (const character of pack.characters ?? []) {
const pool = character.candidates ?? [];
const range = groupRange(character);
if (!range && !pool.length) continue;
const slots = slotsOf(character);
const wanted = range ? range[0] + rng.int(range[1] - range[0] + 1) : 1;
const remaining = pool.map((candidate, index) => ({ candidate, index }));
for (let i = 0; i < slots.length; i++) {
if (i >= wanted || !remaining.length) {
cast[slots[i]] = null;
continue;
}
const [picked] = remaining.splice(rng.int(remaining.length), 1);
cast[slots[i]] = personOf(character, picked.candidate, picked.index);
}
}
return cast;
}
/** Starting relationship overrides a drawn person brings with them. */
export function drawnRelationships(pack, cast) {
const overrides = [];
for (const character of pack.characters ?? []) {
for (const slot of slotsOf(character)) {
const person = cast?.[slot];
if (!person) continue;
const candidate = character.candidates?.[person.candidate];
for (const [stat, value] of Object.entries(candidate?.startingRelationship ?? {})) {
overrides.push({ path: `relationships.${slot}.${stat}`, value });
}
}
}
return overrides;
}
/** The character definition that owns a slot. */
export function characterForSlot(pack, slot) {
for (const character of pack.characters ?? []) {
if (slotsOf(character).includes(slot)) return character;
}
return undefined;
}
/**
* The person in a slot as the interface and the checks see them: name, title,
* description and attributes, whether drawn for this run or written into the
* pack. Undefined if the slot is empty this run or unknown.
*/
export function personIn(pack, state, slot) {
const character = characterForSlot(pack, slot);
if (!character) return undefined;
const drawn = getPath(state, `cast.${slot}`);
if (drawn === null) return undefined;
if (drawn) return { id: slot, ...drawn };
if (character.candidates?.length || groupRange(character)) return undefined;
return {
id: slot,
role: character.id,
name: character.name,
title: character.title ?? null,
description: character.description ?? '',
attributes: { ...(character.attributes ?? {}) },
};
}
/** Everyone present in this run, in pack order. */
export function presentPeople(pack, state) {
return (pack.characters ?? []).flatMap((character) =>
slotsOf(character).map((slot) => personIn(pack, state, slot)).filter(Boolean));
}
/** The slots of a role that are filled in this run. */
export function presentSlots(pack, state, roleId) {
const character = (pack.characters ?? []).find((c) => c.id === roleId);
if (!character) return [];
return slotsOf(character).filter((slot) => personIn(pack, state, slot));
}
+87
View File
@@ -0,0 +1,87 @@
// Options whose outcome is not certain.
//
// An option may carry a `check`: which of the player's attributes it tests,
// optionally *against* a character who opposes it with their own attribute, and
// optionally leaning on how that character regards the player. It then has a
// `success` and a `failure`, each with its own result text and effects; the
// option's plain `effects` apply either way. The engine works out the chance,
// rolls on the run's own generator, and records which way it went.
//
// The engine knows the arithmetic and nothing about what any attribute means:
// the numbers are the pack's (`pack.checks`), the attribute names are the
// pack's, and so are the words the player sees in place of a percentage. A
// player is shown "a long shot" or "likely", never a number. See DECISIONS #41.
//
// chance = base + perPoint × (yours − theirs)
// + relationshipPerPoint × (their regard − relationshipCentre)
// + modifier, clamped to [floor, ceiling]
//
// where "theirs" is the opposing character's attribute when the check is
// `against` someone, and the check's `difficulty` (or the pack's `unopposed`)
// when it is not.
import { getPath } from './paths.js';
import { personIn } from './cast.js';
import { resolveSlot } from './text.js';
/** Odds words used when a pack does not name its own. Upper bounds, in percent. */
export const DEFAULT_ODDS = [
{ upTo: 20, words: 'a long shot' },
{ upTo: 40, words: 'unlikely' },
{ upTo: 60, words: 'an even chance' },
{ upTo: 80, words: 'likely' },
{ upTo: 100, words: 'a safe bet' },
];
/** The words for a chance, from the pack's bands. */
export function oddsWords(pack, chance) {
const bands = pack.checks?.odds ?? DEFAULT_ODDS;
return (bands.find((band) => chance <= band.upTo) ?? bands[bands.length - 1]).words;
}
/** The slot a check is made against this turn, if any. */
function opponentSlot(check, bindings) {
if (!check.against) return undefined;
const id = check.against.replace(/^\{|\}$/g, '');
return resolveSlot(id, bindings);
}
/**
* The chance of an option's check succeeding in this state, as a whole
* percentage, with the parts it was built from. Undefined for an option with
* no check.
*/
export function chanceOf(pack, state, option, bindings = state?.meta?.bindings ?? {}) {
const check = option.check;
if (!check) return undefined;
const rules = pack.checks ?? {};
const yours = getPath(state, `attributes.${check.attribute}`) ?? 0;
const slot = opponentSlot(check, bindings);
const opponent = slot ? personIn(pack, state, slot) : undefined;
const theirs = opponent
? opponent.attributes?.[check.opposedBy ?? check.attribute] ?? 0
: check.difficulty ?? rules.unopposed ?? 0;
let regard = 0;
if (check.relationship && slot) {
const value = getPath(state, `relationships.${slot}.${check.relationship}`);
if (typeof value === 'number') {
regard = (rules.relationshipPerPoint ?? 0) * (value - (rules.relationshipCentre ?? 50));
}
}
const raw = (rules.base ?? 50) + (rules.perPoint ?? 0) * (yours - theirs) + regard + (check.modifier ?? 0);
const chance = Math.round(Math.min(rules.ceiling ?? 95, Math.max(rules.floor ?? 5, raw)));
return { chance, words: oddsWords(pack, chance), yours, theirs, against: slot ?? null };
}
/**
* Roll a check. `rng` is the run's generator; exactly one number is drawn, so
* a replay from the same seed and choices rolls the same way.
*/
export function rollCheck(pack, state, option, rng, bindings) {
const odds = chanceOf(pack, state, option, bindings);
const roll = rng.next() * 100;
return { ...odds, succeeded: roll < odds.chance };
}
+36 -4
View File
@@ -9,8 +9,10 @@ import { createRng } from './rng.js';
import { applyEffects } from './effects.js';
import { check } from './conditions.js';
import { resolveSchema, createState } from './state.js';
import { selectEvent, resolveOptions, findOption } from './select.js';
import { selectEvent, resolveOptions, findOption, bindRoles } from './select.js';
import { setPath, getPath } from './paths.js';
import { chanceOf, rollCheck } from './checks.js';
import { bindEffects, fillText } from './text.js';
export class ContentError extends Error {}
export class MoveError extends Error {}
@@ -28,6 +30,10 @@ function advance(pack, state) {
);
}
let next = setPath(dated, 'meta.currentEventId', event.id);
// Which co-worker this event is about, drawn after the event itself. A pack
// with no `involves` draws nothing here, so its runs are unchanged.
const bindings = bindRoles(pack, next, event, rng);
if (Object.keys(bindings).length || next.meta.bindings) next = setPath(next, 'meta.bindings', bindings);
next = setPath(next, 'meta.rngPosition', rng.getPosition());
return next;
}
@@ -101,7 +107,10 @@ export function currentEvent(pack, state) {
/** The current event together with each option's availability. */
export function currentTurn(pack, state) {
const event = currentEvent(pack, state);
return { event, options: resolveOptions(state, event) };
const options = resolveOptions(state, event).map((resolved) => (resolved.option.check
? { ...resolved, odds: chanceOf(pack, state, resolved.option) }
: resolved));
return { event, options, bindings: state.meta.bindings ?? {} };
}
/**
@@ -120,7 +129,27 @@ export function takeTurn(pack, state, optionId, { schema = resolveSchema(pack),
throw new MoveError(`option "${optionId}" is locked: ${JSON.stringify(chosen.failed)}`);
}
const applied = applyEffects(state, option.effects, schema);
const bindings = state.meta.bindings ?? {};
let effects = bindEffects(option.effects, bindings);
let check = null;
let rngState = state;
if (option.check) {
// One number from the run's own generator, so the roll replays with the run.
const rng = createRng(state.meta.seed, state.meta.rngPosition ?? undefined);
const rolled = rollCheck(pack, state, option, rng, bindings);
const outcome = rolled.succeeded ? option.success : option.failure;
effects = [...effects, ...bindEffects(outcome?.effects, bindings)];
check = {
attribute: option.check.attribute,
against: rolled.against,
chance: rolled.chance,
odds: rolled.words,
outcome: rolled.succeeded ? 'success' : 'failure',
};
rngState = setPath(state, 'meta.rngPosition', rng.getPosition());
}
const applied = applyEffects(rngState, effects, schema);
let next = applied.state;
const seenCount = getPath(next, `seen.${event.id}`) ?? 0;
@@ -135,7 +164,10 @@ export function takeTurn(pack, state, optionId, { schema = resolveSchema(pack),
stage: next.stage,
eventId: event.id,
optionId: option.id,
optionLabel: option.label,
// Names filled in, so the log and the retrospective read as the player saw it.
optionLabel: fillText(pack, state, option.label, bindings),
...(check ? { check } : {}),
...(Object.keys(bindings).length ? { bindings } : {}),
changes: applied.changes,
upkeep: upkeep.changes,
offered: resolved.map((r) => ({
+3
View File
@@ -7,3 +7,6 @@ export { createState, resolveSchema, indexCharacters, RESERVED_ROOTS } from './s
export { eligibleEvents, selectEvent, resolveOptions, findOption } from './select.js';
export { startGame, currentEvent, currentTurn, takeTurn, ContentError, MoveError } from './game.js';
export { validatePack, assertValidPack } from './validate.js';
export { drawCast, presentPeople, presentSlots, personIn, slotsOf, hasDrawnCast } from './cast.js';
export { fillText, placeholders, bindEffects, bindConditions } from './text.js';
export { chanceOf, rollCheck, oddsWords, DEFAULT_ODDS } from './checks.js';
+22 -1
View File
@@ -9,6 +9,21 @@
import { check } from './conditions.js';
import { getPath } from './paths.js';
import { presentSlots } from './cast.js';
import { bindConditions } from './text.js';
/**
* Pick, for each role an event `involves`, one person present in this run. An
* event about "a co-worker" is about one particular co-worker for the turn.
*/
export function bindRoles(pack, state, event, rng) {
const bindings = {};
for (const role of event.involves ?? []) {
const slots = presentSlots(pack, state, role);
if (slots.length) bindings[role] = slots.length === 1 ? slots[0] : rng.pick(slots);
}
return bindings;
}
/** Events that could fire right now, with the reason excluded ones could not. */
export function eligibleEvents(pack, state) {
@@ -37,6 +52,11 @@ export function eligibleEvents(pack, state) {
excluded.push({ event, reason: 'requires', failed: gate.failed });
continue;
}
// An event about a co-worker cannot fire in a run that drew none.
if ((event.involves ?? []).some((role) => !presentSlots(pack, state, role).length)) {
excluded.push({ event, reason: 'involves' });
continue;
}
eligible.push(event);
}
@@ -60,8 +80,9 @@ export function selectEvent(pack, state, rng) {
* strongest signal that the choice set is too narrow.
*/
export function resolveOptions(state, event) {
const bindings = state?.meta?.bindings ?? {};
return (event.options ?? []).map((option) => {
const gate = check(state, option.requires);
const gate = check(state, bindConditions(option.requires, bindings));
return {
option,
available: gate.passed,
+28 -8
View File
@@ -5,6 +5,7 @@
// names no stat of its own beyond its own bookkeeping.
import { getPath, setPath } from './paths.js';
import { slotsOf, drawCast, drawnRelationships, hasDrawnCast } from './cast.js';
/**
* Paths the engine owns. Content may *read* these in conditions — "only after
@@ -12,7 +13,7 @@ import { getPath, setPath } from './paths.js';
* them, which is what keeps engine bookkeeping from being corrupted by data.
*/
export const RESERVED_ROOTS =
new Set(['turn', 'stage', 'seen', 'lastSeen', 'history', 'meta', 'calendar']);
new Set(['turn', 'stage', 'seen', 'lastSeen', 'history', 'meta', 'calendar', 'cast']);
/**
* Flatten a pack's state declarations into one path -> rule map, expanding the
@@ -27,17 +28,30 @@ export function resolveSchema(pack) {
schema[path] = { ...rule };
}
for (const character of pack.characters ?? []) {
for (const [stat, rule] of Object.entries(pack.relationshipStats ?? {})) {
// A character may start somewhere other than the pack default — a rival
// who already resents you, a boss who has not formed an opinion.
const override = character.startingRelationship?.[stat];
schema[`relationships.${character.id}.${stat}`] =
override === undefined ? { ...rule } : { ...rule, initial: override };
// A group role has one slot per possible member (coworker_1 .. coworker_4),
// all declared up front so every one is validated and saved. See cast.js.
for (const slot of slotsOf(character)) {
for (const [stat, rule] of Object.entries(pack.relationshipStats ?? {})) {
// A character may start somewhere other than the pack default — a rival
// who already resents you, a boss who has not formed an opinion.
const override = character.startingRelationship?.[stat];
schema[`relationships.${slot}.${stat}`] =
override === undefined ? { ...rule } : { ...rule, initial: override };
}
}
}
return schema;
}
/** Draw a run's cast into state, with the starting relationships the drawn people bring. */
function withCast(pack, state) {
if (!hasDrawnCast(pack)) return state;
const cast = drawCast(pack, state.meta.seed);
let next = setPath(state, 'cast', cast);
for (const { path, value } of drawnRelationships(pack, cast)) next = setPath(next, path, value);
return next;
}
/** A fresh game state for `pack`, seeded for reproducibility. */
export function createState(pack, { seed, schema = resolveSchema(pack) } = {}) {
let state = {
@@ -58,7 +72,7 @@ export function createState(pack, { seed, schema = resolveSchema(pack) } = {}) {
for (const [path, rule] of Object.entries(schema)) {
state = setPath(state, path, structuredClone(rule.initial));
}
return state;
return withCast(pack, state);
}
/**
@@ -82,6 +96,12 @@ export function hydrate(pack, state, schema = resolveSchema(pack)) {
next = setPath(next, path, structuredClone(rule.initial));
added.push(path);
}
// A run saved before its pack drew a cast gets one now, from its own seed, so
// the same save always wakes up with the same people.
if (hasDrawnCast(pack) && next.cast === undefined) {
next = withCast(pack, next);
added.push('cast');
}
return { state: next, added };
}
+77
View File
@@ -0,0 +1,77 @@
// Names in text, and which person an event is about.
//
// Content that meets a different cast each run cannot write "Dutch" into its
// prose. It writes `{rival}`, and the name is filled in from whoever was drawn.
// `{rival.first}` is the first name, `{rival.title}` the title.
//
// A group role (two to four co-workers) cannot be named by its role alone —
// which one? An event says so with `involves: ['coworker']`, and when the event
// is drawn one present member is bound to it for the turn. `{coworker}` in its
// text, `relationships.{coworker}.trust` in its effects and `against:
// '{coworker}'` in a check all mean that person. The binding is saved with the
// run, so a replay binds the same person. See DECISIONS #41.
import { personIn } from './cast.js';
const PLACEHOLDER = /\{([A-Za-z0-9_]+)(?:\.(name|first|title))?\}/g;
/** Every `{id}` / `{id.part}` in a piece of text, as `{ id, part }`. */
export function placeholders(text) {
if (typeof text !== 'string') return [];
return [...text.matchAll(PLACEHOLDER)].map((m) => ({ id: m[1], part: m[2] ?? 'name' }));
}
/** The slot a placeholder id means this turn: a bound group member, or the id itself. */
export function resolveSlot(id, bindings = {}) {
return bindings[id] ?? id;
}
/** Replace `{id}` in a path or a string with the slot bound to it this turn. */
export function bindPath(path, bindings = {}) {
if (typeof path !== 'string') return path;
return path.replace(/\{([A-Za-z0-9_]+)\}/g, (whole, id) => resolveSlot(id, bindings));
}
/** Fill names into text. Anything it cannot resolve is left as written. */
export function fillText(pack, state, text, bindings = state?.meta?.bindings ?? {}) {
if (typeof text !== 'string') return text;
return text.replace(PLACEHOLDER, (whole, id, part = 'name') => {
const person = personIn(pack, state, resolveSlot(id, bindings));
if (!person) return whole;
if (part === 'first') return person.name.split(' ')[0];
if (part === 'title') return person.title ?? '';
return person.name;
});
}
/** Conditions with any bound placeholders in their paths replaced by the slot. */
export function bindConditions(conditions, bindings = {}) {
if (!conditions || !Object.keys(bindings).length) return conditions;
const walk = (c) => {
if (!c || typeof c !== 'object') return c;
if (Array.isArray(c)) return c.map(walk);
if ('all' in c) return { ...c, all: c.all.map(walk) };
if ('any' in c) return { ...c, any: c.any.map(walk) };
if ('not' in c) return { ...c, not: walk(c.not) };
return { ...c, path: bindPath(c.path, bindings) };
};
return walk(conditions);
}
/** Effects with any bound placeholders in their paths replaced by the slot. */
export function bindEffects(effects, bindings = {}) {
return (effects ?? []).map((effect) => {
const path = bindPath(effect.path, bindings);
let value = effect.value;
if (value && typeof value === 'object' && typeof value.from === 'string') {
value = { ...value, from: bindPath(value.from, bindings) };
} else if (typeof value === 'string') {
// Text kept in state (a notable moment) outlives the turn's bindings, so
// `{clerk}` is pinned to the slot it meant: `{clerk_2}` still names the
// same person when the retrospective reads it back.
value = value.replace(PLACEHOLDER, (whole, id, part) =>
(bindings[id] ? `{${bindings[id]}${part ? `.${part}` : ''}}` : whole));
}
return path === effect.path && value === effect.value ? effect : { ...effect, path, value };
});
}
+189 -22
View File
@@ -8,6 +8,8 @@ import { RESERVED_ROOTS, resolveSchema, indexCharacters } from './state.js';
import { OPERATORS, pathsUsed as conditionPaths } from './conditions.js';
import { OPERATIONS, isAmountFrom } from './effects.js';
import { parsePath } from './paths.js';
import { groupRange } from './cast.js';
import { placeholders } from './text.js';
const ID = /^[A-Za-z0-9_]+$/;
@@ -40,14 +42,108 @@ export function validatePack(pack) {
}
}
// Attributes are ordinary declared state (`attributes.<id>` for the player);
// pack.attributes names them, so checks and characters can be held to the list.
const attributeIds = new Set(Object.keys(pack.attributes ?? {}));
for (const id of attributeIds) {
if (!ID.test(id)) fail(`attribute "${id}"`, `id must match ${ID}`);
if (!(`attributes.${id}` in schema)) fail(`attribute "${id}"`, `the player needs "attributes.${id}" in pack.state`);
}
const checkAttributes = (where, attributes, { complete }) => {
for (const key of Object.keys(attributes ?? {})) {
if (!attributeIds.has(key)) fail(where, `attribute "${key}" is not in pack.attributes`);
else if (typeof attributes[key] !== 'number') fail(where, `attribute "${key}" must be a number`);
}
if (complete) {
for (const id of attributeIds) {
if (typeof attributes?.[id] !== 'number') fail(where, `has no "${id}", which every drawn person needs`);
}
}
};
const seenCharacters = new Set();
const names = new Map();
const nameOnce = (where, name) => {
if (names.has(name)) fail(where, `name "${name}" is already used by ${names.get(name)}`);
else names.set(name, where);
};
for (const character of pack.characters ?? []) {
const where = `character "${character.id}"`;
if (!character.id || !ID.test(character.id)) fail(where, `id must match ${ID}`);
if (seenCharacters.has(character.id)) fail(where, 'duplicate id');
seenCharacters.add(character.id);
if (!character.name) fail(where, 'name is required');
if (!character.role_type) warn(where, 'no role_type tag; content cannot filter on it');
const range = groupRange(character);
const pool = character.candidates ?? [];
if (range) {
const [min, max] = range;
if (!Number.isInteger(min) || !Number.isInteger(max) || min < 0 || max < 1 || min > max) {
fail(where, `count ${JSON.stringify(character.count)} must be [min, max] whole numbers with 0 <= min <= max and max >= 1`);
} else if (pool.length < max) {
fail(where, `can hold up to ${max} people but has only ${pool.length} candidates`);
}
}
if (range || pool.length) {
if (character.name) warn(where, 'has a name as well as candidates; the drawn candidate\'s name is used');
pool.forEach((candidate, index) => {
const at = `${where} candidate ${index}`;
if (!candidate.name) fail(at, 'name is required');
else nameOnce(at, candidate.name);
if (!candidate.description) warn(at, 'no description');
checkAttributes(at, candidate.attributes, { complete: attributeIds.size > 0 });
for (const stat of Object.keys(candidate.startingRelationship ?? {})) {
if (!(stat in (pack.relationshipStats ?? {}))) fail(at, `startingRelationship names unknown stat "${stat}"`);
}
});
} else {
if (!character.name) fail(where, 'name is required');
else nameOnce(where, character.name);
checkAttributes(where, character.attributes, { complete: false });
}
}
const characterById = Object.fromEntries((pack.characters ?? []).map((c) => [c.id, c]));
const isGroup = (id) => Boolean(characterById[id] && groupRange(characterById[id]));
/**
* A path or text may name a character as `{id}`. A single character is always
* there to name; a group member only when the event binds one with `involves`.
* Returns the path with each placeholder replaced by a representative slot, so
* it can be checked against the schema like any written path.
*/
const bindForCheck = (where, text, involves) => {
for (const { id } of placeholders(text)) {
if (!characterById[id]) fail(where, `{${id}} names no character`);
else if (isGroup(id) && !involves.includes(id)) {
fail(where, `{${id}} is a group; the event must list "${id}" in involves to say which one`);
}
}
return typeof text === 'string'
? text.replace(/\{([A-Za-z0-9_]+)\}/g, (w, id) => (isGroup(id) ? `${id}_1` : id))
: text;
};
// Chances are the pack's numbers, like every other tuning value (#9).
const usesChecks = (pack.events ?? []).some((e) => (e.options ?? []).some((o) => o.check));
if (usesChecks) {
const rules = pack.checks;
if (!rules) fail('checks', 'options carry checks but the pack declares no checks block');
else {
for (const key of ['base', 'perPoint', 'floor', 'ceiling']) {
if (typeof rules[key] !== 'number') fail('checks', `${key} must be a number`);
}
if (rules.floor > rules.ceiling) fail('checks', 'floor is above ceiling');
if (rules.odds) {
let last = -Infinity;
for (const band of rules.odds) {
if (typeof band.upTo !== 'number' || band.upTo <= last) fail('checks', 'odds bands must rise by upTo');
if (!band.words) fail('checks', 'every odds band needs words');
last = band.upTo;
}
if (last < (rules.ceiling ?? 100)) fail('checks', `odds bands stop at ${last}, below the ceiling`);
}
}
}
const readable = (path) => {
@@ -164,6 +260,31 @@ export function validatePack(pack) {
fail(where, `weight ${event.weight} must be greater than zero, or the event can never fire`);
}
checkConditions(where, event.requires);
const involves = event.involves ?? [];
for (const role of involves) {
if (!characterById[role]) fail(where, `involves "${role}", which is no character`);
}
// Bindings are drawn after the event is chosen, so the event's own gate
// cannot depend on who they are.
const gatePaths = event.requires
? (Array.isArray(event.requires) ? event.requires : [event.requires]).flatMap((c) => conditionPaths(c))
: [];
if (gatePaths.some((p) => p.includes('{'))) {
fail(where, 'requires names a {character}; only options and effects can, once the event is drawn');
}
bindForCheck(where, event.description, involves);
const bound = (conditions) => {
if (!conditions) return conditions;
const walk = (c) => {
if (!c || typeof c !== 'object') return c;
if (Array.isArray(c)) return c.map(walk);
if ('all' in c) return { ...c, all: c.all.map(walk) };
if ('any' in c) return { ...c, any: c.any.map(walk) };
if ('not' in c) return { ...c, not: walk(c.not) };
return { ...c, path: bindForCheck(where, c.path, involves) };
};
return walk(conditions);
};
// A fallback must be one that *no* exclusion rule can remove: requires,
// once and cooldown are exactly the three reasons eligibleEvents drops an
// event, so a floor event may carry none of them.
@@ -180,32 +301,78 @@ export function validatePack(pack) {
if (seenOptions.has(option.id)) fail(optionWhere, 'duplicate id');
seenOptions.add(option.id);
if (!option.label) fail(optionWhere, 'label is required');
checkConditions(optionWhere, option.requires);
bindForCheck(optionWhere, option.label, involves);
bindForCheck(optionWhere, option.result, involves);
checkConditions(optionWhere, bound(option.requires));
for (const effect of option.effects ?? []) {
if (!OPERATIONS.includes(effect.op)) {
fail(optionWhere, `unknown effect operation ${JSON.stringify(effect.op)}`);
}
if (effect.op === 'add' && typeof effect.value !== 'number') {
if (isAmountFrom(effect.value)) {
// An amount read from state must read something the pack declares,
// and scale and cap it by numbers. See DECISIONS #38.
const problem = readable(effect.value.from);
if (problem) fail(optionWhere, `effect "${effect.path}" reads its amount from ${problem}`);
for (const key of ['times', 'cap']) {
if (effect.value[key] !== undefined && typeof effect.value[key] !== 'number') {
fail(optionWhere, `effect "${effect.path}" has a non-numeric ${key}`);
const checkEffects = (at, effects) => {
for (const effect of effects ?? []) {
if (!OPERATIONS.includes(effect.op)) {
fail(at, `unknown effect operation ${JSON.stringify(effect.op)}`);
}
if (effect.op === 'add' && typeof effect.value !== 'number') {
if (isAmountFrom(effect.value)) {
// An amount read from state must read something the pack declares,
// and scale and cap it by numbers. See DECISIONS #38.
const problem = readable(bindForCheck(at, effect.value.from, involves));
if (problem) fail(at, `effect "${effect.path}" reads its amount from ${problem}`);
for (const key of ['times', 'cap']) {
if (effect.value[key] !== undefined && typeof effect.value[key] !== 'number') {
fail(at, `effect "${effect.path}" has a non-numeric ${key}`);
}
}
} else {
fail(at, `effect "${effect.path}" uses ${effect.op} with a non-numeric value`);
}
} else {
fail(optionWhere, `effect "${effect.path}" uses ${effect.op} with a non-numeric value`);
}
}
}
for (const effect of option.effects ?? []) {
if (typeof effect.path !== 'string') continue;
const problem = writable(effect.path, effect, optionWhere);
if (problem) fail(optionWhere, `effect writes ${problem}`);
for (const effect of effects ?? []) {
if (typeof effect.path !== 'string') continue;
const problem = writable(bindForCheck(at, effect.path, involves), effect, at);
if (problem) fail(at, `effect writes ${problem}`);
}
};
checkEffects(optionWhere, option.effects);
if (option.check) {
const c = option.check;
const at = `${optionWhere} check`;
if (!attributeIds.has(c.attribute)) fail(at, `attribute "${c.attribute}" is not in pack.attributes`);
if (c.opposedBy !== undefined && !attributeIds.has(c.opposedBy)) {
fail(at, `opposedBy "${c.opposedBy}" is not in pack.attributes`);
}
if (c.against !== undefined) {
const id = String(c.against).replace(/^\{|\}$/g, '');
const character = characterById[id];
if (!character) fail(at, `against "${c.against}" is no character`);
else {
if (isGroup(id) && !involves.includes(id)) {
fail(at, `against "${c.against}" is a group; the event must list "${id}" in involves`);
}
const attribute = c.opposedBy ?? c.attribute;
const people = character.candidates?.length ? character.candidates : [character];
if (people.some((p) => typeof p.attributes?.[attribute] !== 'number')) {
fail(at, `not everyone who can be "${id}" has "${attribute}" to oppose with`);
}
}
} else if (c.relationship !== undefined) {
fail(at, 'relationship needs against: whose regard is it?');
}
if (c.relationship !== undefined && !(c.relationship in (pack.relationshipStats ?? {}))) {
fail(at, `relationship "${c.relationship}" is not in relationshipStats`);
}
for (const key of ['difficulty', 'modifier']) {
if (c[key] !== undefined && typeof c[key] !== 'number') fail(at, `${key} must be a number`);
}
for (const branch of ['success', 'failure']) {
const outcome = option[branch];
if (!outcome) { fail(at, `has no ${branch}; a check needs both ways it can go`); continue; }
if (!outcome.result) warn(`${optionWhere} ${branch}`, 'no result text; the player will not be told');
bindForCheck(`${optionWhere} ${branch}`, outcome.result, involves);
checkEffects(`${optionWhere} ${branch}`, outcome.effects);
}
} else if (option.success || option.failure) {
fail(optionWhere, 'has success or failure but no check to decide between them');
}
}
}
+7 -4
View File
@@ -4,6 +4,7 @@ import { h } from './dom.js';
import { formatValue, labelFor, headerStats } from './format.js';
import { APP, versionLine } from '../version.js';
import { getPath } from '../engine/paths.js';
import { fillText } from '../engine/text.js';
/**
* What you are playing, which pack, and who you currently are — plus the build,
@@ -57,12 +58,14 @@ export function statPanel(pack, state, schema) {
/** One character, with how they currently regard the player. */
export function characterCard(pack, state, schema, character) {
// `character` is a person as presentPeople() returns them: drawn for this run
// or written into the pack, keyed by the slot their relationships live under.
return h('article', { class: 'person' },
h('header', { class: 'person__head' },
h('h3', { class: 'person__name' }, character.name),
character.title && h('p', { class: 'person__title' }, character.title),
),
h('p', { class: 'person__description' }, character.description),
h('p', { class: 'person__description' }, fillText(pack, state, character.description)),
h('div', { class: 'person__meters' },
Object.keys(pack.relationshipStats ?? {}).map((stat) => {
const path = `relationships.${character.id}.${stat}`;
@@ -78,7 +81,7 @@ export function characterCard(pack, state, schema, character) {
}
/** The change list shown after a choice: what moved, and by how much. */
export function changeList(pack, changes, { className = 'changes' } = {}) {
export function changeList(pack, changes, { className = 'changes', state } = {}) {
const meaningful = changes.filter((change) => {
if (change.from === change.to) return false;
// Some state is bookkeeping the player has no use for — `notable` is the
@@ -95,7 +98,7 @@ export function changeList(pack, changes, { className = 'changes' } = {}) {
if (typeof change.to === 'boolean') {
const rule = pack.display?.[change.path];
const phrase = (change.to ? rule?.whenTrue : rule?.whenFalse)
?? `${labelFor(pack, change.path)}: ${change.to ? 'yes' : 'no longer'}`;
?? `${labelFor(pack, change.path, state)}: ${change.to ? 'yes' : 'no longer'}`;
return h('li', { class: `change change--note change--${change.to ? 'up' : 'down'}` },
h('span', { class: 'change__phrase' }, phrase),
);
@@ -105,7 +108,7 @@ export function changeList(pack, changes, { className = 'changes' } = {}) {
return h('li', { class: `change change--${rising ? 'up' : 'down'}` },
// Upkeep changes carry the label of the entry that caused them —
// "Rent", "Wages" — which is far more use than the path's own name.
h('span', { class: 'change__label' }, change.label ?? labelFor(pack, change.path)),
h('span', { class: 'change__label' }, change.label ?? labelFor(pack, change.path, state)),
h('span', { class: 'change__delta' }, deltaText(pack, change)),
// Say where it stopped. "−$4 at its limit" was read by a real player as
// a debt of four still standing, when it meant the balance hit zero.
+18 -12
View File
@@ -8,6 +8,7 @@
import { getPath } from '../engine/paths.js';
import { check } from '../engine/conditions.js';
import { personIn } from '../engine/cast.js';
const titleCase = (text) =>
text.replace(/_/g, ' ').replace(/^\w/, (c) => c.toUpperCase());
@@ -17,16 +18,21 @@ export function displayFor(pack, path) {
return pack.display?.[path] ?? null;
}
/** A human name for a path, derived from the path when the pack is silent. */
export function labelFor(pack, path) {
/**
* A human name for a path, derived from the path when the pack is silent.
* Given the run's state, a relationship path names whoever is in that slot this
* run — a drawn boss, the second of three co-workers — not the role.
*/
export function labelFor(pack, path, state) {
const declared = displayFor(pack, path)?.label;
if (declared) return declared;
const segments = path.split('.');
if (segments[0] === 'relationships' && segments.length === 3) {
const character = pack.characters?.find((c) => c.id === segments[1]);
const name = (state && personIn(pack, state, segments[1])?.name)
?? pack.characters?.find((c) => c.id === segments[1])?.name;
const stat = pack.relationshipStats?.[segments[2]]?.label ?? titleCase(segments[2]);
return `${character?.name ?? titleCase(segments[1])} — ${stat.toLowerCase()}`;
return `${name ?? titleCase(segments[1])} — ${stat.toLowerCase()}`;
}
return titleCase(segments.at(-1));
}
@@ -61,15 +67,15 @@ export function formatDelta(pack, path, from, to) {
* One condition as a requirement the player can read: "Negotiation 15+",
* "Trevor — likes you 48+", "not in debt".
*/
export function describeCondition(pack, condition) {
export function describeCondition(pack, condition, state) {
if (!condition || typeof condition !== 'object') return '';
if (Array.isArray(condition)) return condition.map((c) => describeCondition(pack, c)).join(', ');
if (condition.all) return condition.all.map((c) => describeCondition(pack, c)).join(', ');
if (condition.any) return condition.any.map((c) => describeCondition(pack, c)).join(' or ');
if (condition.not) return `not ${describeCondition(pack, condition.not)}`;
if (Array.isArray(condition)) return condition.map((c) => describeCondition(pack, c, state)).join(', ');
if (condition.all) return condition.all.map((c) => describeCondition(pack, c, state)).join(', ');
if (condition.any) return condition.any.map((c) => describeCondition(pack, c, state)).join(' or ');
if (condition.not) return `not ${describeCondition(pack, condition.not, state)}`;
const { path, op, value } = condition;
const label = labelFor(pack, path);
const label = labelFor(pack, path, state);
if (typeof value === 'boolean') return value ? label.toLowerCase() : `no ${label.toLowerCase()}`;
@@ -86,8 +92,8 @@ export function describeCondition(pack, condition) {
}
/** What a locked option is waiting for. */
export function requirementText(pack, failedConditions) {
const parts = failedConditions.map((c) => describeCondition(pack, c)).filter(Boolean);
export function requirementText(pack, failedConditions, state) {
const parts = failedConditions.map((c) => describeCondition(pack, c, state)).filter(Boolean);
return parts.length ? `Needs ${parts.join(', ')}` : 'Not available yet';
}
+4 -2
View File
@@ -6,6 +6,8 @@
import { h } from '../dom.js';
import { statPanel, characterCard, statusBar } from '../components.js';
import { presentPeople } from '../../engine/cast.js';
import { fillText } from '../../engine/text.js';
import { formatMoney, labelFor } from '../format.js';
import { summarize } from '../summary.js';
@@ -58,14 +60,14 @@ export function retrospectiveScreen(pack, state, schema, actions) {
h('section', { class: 'retro__section' },
h('h2', { class: 'retro__heading' }, 'Where you stand with people'),
h('div', { class: 'retro__people' },
(pack.characters ?? []).map((character) => characterCard(pack, state, schema, character)),
presentPeople(pack, state).map((person) => characterCard(pack, state, schema, person)),
),
),
h('section', { class: 'retro__section' },
h('h2', { class: 'retro__heading' }, 'Notable moments'),
summary.notable.length
? h('ol', { class: 'moments' }, summary.notable.map((moment) => h('li', { class: 'moment' }, moment)))
? h('ol', { class: 'moments' }, summary.notable.map((moment) => h('li', { class: 'moment' }, fillText(pack, state, moment))))
: h('p', { class: 'moments moments--none' },
'Nothing that anybody would write down. That happens.'),
),
+28 -13
View File
@@ -12,6 +12,8 @@
import { h } from '../dom.js';
import { statPanel, characterCard, changeList, statusBar } from '../components.js';
import { requirementText, turnHeading, upcomingUpkeep } from '../format.js';
import { presentPeople } from '../../engine/cast.js';
import { fillText } from '../../engine/text.js';
export function turnScreen(pack, state, schema, view, actions) {
const heading = turnHeading(pack, state);
@@ -33,12 +35,12 @@ export function turnScreen(pack, state, schema, view, actions) {
h('div', { class: 'layout' },
h('main', { class: 'scene' },
view.phase === 'outcome'
? outcomeView(pack, view, actions)
: choosingView(pack, view, actions),
? outcomeView(pack, state, view, actions)
: choosingView(pack, state, view, actions),
),
h('aside', { class: 'people' },
h('h2', { class: 'people__heading' }, pack.peopleHeading ?? 'The people'),
(pack.characters ?? []).map((character) => characterCard(pack, state, schema, character)),
presentPeople(pack, state).map((person) => characterCard(pack, state, schema, person)),
),
),
@@ -84,44 +86,55 @@ function diary(pack, state) {
);
}
function choosingView(pack, view, actions) {
function choosingView(pack, state, view, actions) {
const { event, options } = view.turn;
const text = (t) => fillText(pack, state, t, view.turn.bindings);
const visible = options.filter((o) => !o.hidden);
// The first option a player can actually take gets the focus, so Enter plays
// it and Tab walks to the others.
const firstAvailable = visible.find((o) => o.available)?.option.id;
return h('section', { class: 'scenario' },
h('p', { class: 'scenario__text' }, event.description),
h('p', { class: 'scenario__text' }, text(event.description)),
h('ul', { class: 'options' },
visible.map(({ option, available, failed }) => h('li', { class: 'options__item' },
visible.map(({ option, available, failed, odds }) => h('li', { class: 'options__item' },
h('button', {
class: `option${available ? '' : ' option--locked'}`,
disabled: !available,
dataset: option.id === firstAvailable ? { autofocus: 'true' } : {},
onClick: available ? () => actions.onChoose(option.id) : null,
},
h('span', { class: 'option__label' }, option.label),
!available && h('span', { class: 'option__requirement' }, requirementText(pack, failed)),
h('span', { class: 'option__label' }, text(option.label)),
// Odds are shown in words, never as a number: "a long shot", "likely".
available && odds && h('span', { class: 'option__odds' }, odds.words),
!available && h('span', { class: 'option__requirement' }, requirementText(pack, failed, state)),
),
)),
),
);
}
function outcomeView(pack, view, actions) {
function outcomeView(pack, state, view, actions) {
const { record, option } = view;
// The state has already moved on to the next event, so the names in this
// outcome come from the bindings the turn was played with, not the new ones.
const text = (t) => fillText(pack, state, t, record.bindings ?? {});
const branch = record.check ? option[record.check.outcome] : null;
const result = branch ? branch.result : option.result;
const said = pack.checks?.said ?? { success: 'It came off.', failure: 'It did not come off.' };
return h('section', { class: 'outcome' },
h('p', { class: 'outcome__chose' }, option.label),
option.result && h('p', { class: 'outcome__text' }, option.result),
h('p', { class: 'outcome__chose' }, record.optionLabel ?? text(option.label)),
record.check && h('p', { class: `outcome__check outcome__check--${record.check.outcome}` },
`${capitalise(record.check.odds)} — ${said[record.check.outcome]}`),
result && h('p', { class: 'outcome__text' }, text(result)),
h('div', { class: 'outcome__ledger' },
h('h3', { class: 'outcome__heading' }, 'What that changed'),
changeList(pack, record.changes),
changeList(pack, record.changes, { state }),
record.upkeep.length > 0 && h('div', { class: 'outcome__upkeep' },
h('h3', { class: 'outcome__heading' }, 'And then the day happened'),
changeList(pack, record.upkeep),
changeList(pack, record.upkeep, { state }),
),
),
@@ -185,3 +198,5 @@ function feedbackWidget(view, actions) {
feedback.saved && h('p', { class: 'feedback__saved' }, 'Noted — thank you.'),
);
}
const capitalise = (words) => words.charAt(0).toUpperCase() + words.slice(1);
+7
View File
@@ -171,6 +171,13 @@ body {
.option__requirement {
font-size: 12px; letter-spacing: 0.04em; color: var(--ink-faint); font-style: italic;
}
/* The odds, in words: "a long shot", "likely". Never a number. */
.option__odds {
font-size: 12px; letter-spacing: 0.04em; color: var(--accent); font-style: italic;
}
.outcome__check { margin: 0 0 8px; font-size: 14px; font-style: italic; }
.outcome__check--success { color: var(--up); }
.outcome__check--failure { color: var(--down); }
/* ---------- outcome ---------- */
+1 -1
View File
@@ -8,7 +8,7 @@ export const APP = {
// Kept in step with package.json by a test, because it silently was not: the
// status bar read 0.3.0 through the 0.3.1 and 0.3.2 work, which is exactly
// the thing a playtester is told to check to know what they are playing.
version: '0.3.4',
version: '0.4.0',
};
/** "0.3.0 · a1b2c3d" — or "0.3.0 · dev" when running from source unstamped. */
+185
View File
@@ -0,0 +1,185 @@
// The cast drawn per run, names in text, and options whose outcome is a check.
// See DECISIONS #41. Runs against test/fixtures/cast-pack.js, never real content.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { startGame, currentTurn, takeTurn } from '../src/engine/game.js';
import { validatePack } from '../src/engine/validate.js';
import { hydrate, createState, resolveSchema } from '../src/engine/state.js';
import { presentPeople, presentSlots, personIn } from '../src/engine/cast.js';
import { fillText } from '../src/engine/text.js';
import { chanceOf, oddsWords } from '../src/engine/checks.js';
import { getPath, setPath } from '../src/engine/paths.js';
import { castPack } from './fixtures/cast-pack.js';
import { testPack } from './fixtures/test-pack.js';
const clone = (value) => structuredClone(value);
/** Advance until the current event is `eventId`, taking `work` otherwise. */
function until(state, eventId, pack = castPack) {
let next = state;
for (let i = 0; i < 200 && currentTurn(pack, next).event.id !== eventId; i++) {
next = takeTurn(pack, next, currentTurn(pack, next).options[0].option.id).state;
}
assert.equal(currentTurn(pack, next).event.id, eventId);
return next;
}
test('the cast pack is valid, with no warnings', () => {
const { errors, warnings } = validatePack(castPack);
assert.deepEqual(errors, []);
assert.deepEqual(warnings, []);
});
test('a run draws one boss and two to four different co-workers', () => {
const counts = new Set();
for (let seed = 0; seed < 40; seed++) {
const state = startGame(castPack, { seed });
const people = presentPeople(castPack, state);
assert.equal(people.filter((p) => p.role === 'boss').length, 1);
const coworkers = presentSlots(castPack, state, 'coworker');
assert.ok(coworkers.length >= 2 && coworkers.length <= 4, `drew ${coworkers.length}`);
counts.add(coworkers.length);
const names = people.map((p) => p.name);
assert.equal(new Set(names).size, names.length, 'nobody is drawn twice');
assert.ok(names.includes('Old Sam'), 'a fixed character is always present');
}
assert.deepEqual([...counts].sort(), [2, 3, 4], 'every head count turns up across seeds');
});
test('the cast is the seed\'s: the same seed draws the same people, others differ', () => {
const names = (seed) => presentPeople(castPack, startGame(castPack, { seed })).map((p) => p.name).join('|');
assert.equal(names('a'), names('a'));
const distinct = new Set(Array.from({ length: 20 }, (_, i) => names(`s${i}`)));
assert.ok(distinct.size > 10, `only ${distinct.size} different casts in 20 runs`);
});
test('an empty group slot is not in the run, and a drawn person brings their own starting regard', () => {
let seed = 0;
let state;
do state = startGame(castPack, { seed: seed++ });
while (presentSlots(castPack, state, 'coworker').length !== 2 || personIn(castPack, state, 'boss').name !== 'Walter Grose');
assert.equal(getPath(state, 'cast.coworker_3'), null);
assert.equal(personIn(castPack, state, 'coworker_3'), undefined);
assert.equal(getPath(state, 'relationships.boss.trust'), 30);
});
test('names are filled into text from whoever was drawn', () => {
const state = startGame(castPack, { seed: 'names' });
const boss = personIn(castPack, state, 'boss');
assert.equal(fillText(castPack, state, 'Ask {boss.first}, the {boss.title}.'),
`Ask ${boss.name.split(' ')[0]}, the Manager.`);
assert.equal(fillText(castPack, state, '{porter} nods.'), 'Old Sam nods.');
assert.equal(fillText(castPack, state, '{nobody} stays as written.'), '{nobody} stays as written.');
});
test('an event about a co-worker binds one who is present, and its effects land on them', () => {
const state = until(startGame(castPack, { seed: 'bind' }), 'needled');
const turn = currentTurn(castPack, state);
const slot = turn.bindings.coworker;
assert.ok(presentSlots(castPack, state, 'coworker').includes(slot));
assert.match(fillText(castPack, state, turn.event.description), new RegExp(personIn(castPack, state, slot).name));
const before = getPath(state, `relationships.${slot}.regard`);
const { state: after, record } = takeTurn(castPack, state, 'ignore');
assert.equal(getPath(after, `relationships.${slot}.regard`), before - 2);
assert.deepEqual(record.bindings, { coworker: slot });
});
test('the chance follows the formula, and the odds are words', () => {
let state = startGame(castPack, { seed: 'odds' });
const option = castPack.events[0].options[2]; // wits against difficulty 5
state = setPath(state, 'attributes.wits', 5);
assert.equal(chanceOf(castPack, state, option).chance, 50);
state = setPath(state, 'attributes.wits', 7);
assert.equal(chanceOf(castPack, state, option).chance, 70);
state = setPath(state, 'attributes.wits', 10);
assert.equal(chanceOf(castPack, state, option).chance, 100, 'clamped at the ceiling');
assert.equal(oddsWords(castPack, 15), 'a long shot');
assert.equal(oddsWords(castPack, 50), 'an even chance');
assert.equal(oddsWords(castPack, 90), 'a safe bet');
});
test('an opposed check reads the other person\'s attribute, and their regard moves it', () => {
let state = startGame(castPack, { seed: 'opposed' });
const option = castPack.events[0].options[1]; // charm against the boss's wits, leaning on trust
const boss = personIn(castPack, state, 'boss');
state = setPath(state, 'attributes.charm', 6);
state = setPath(state, 'relationships.boss.trust', 50);
const even = chanceOf(castPack, state, option);
assert.equal(even.theirs, boss.attributes.wits);
assert.equal(even.chance, Math.min(100, Math.max(0, 50 + 10 * (6 - boss.attributes.wits))));
state = setPath(state, 'relationships.boss.trust', 70);
assert.equal(chanceOf(castPack, state, option).chance, Math.min(100, even.chance + 10));
});
test('a check applies the branch it rolled, plus the option\'s own effects', () => {
let state = until(startGame(castPack, { seed: 'roll' }), 'routine');
const sure = setPath(setPath(state, 'attributes.charm', 10), 'relationships.boss.trust', 100);
const won = takeTurn(castPack, sure, 'ask_for_more');
assert.equal(won.record.check.outcome, 'success');
assert.equal(getPath(won.state, 'money'), getPath(sure, 'money') + 50);
assert.equal(getPath(won.state, 'relationships.boss.regard'), getPath(sure, 'relationships.boss.regard') - 1);
const hopeless = setPath(setPath(state, 'attributes.charm', 0), 'relationships.boss.trust', 0);
const lost = takeTurn(castPack, hopeless, 'ask_for_more');
assert.equal(lost.record.check.outcome, 'failure');
assert.equal(getPath(lost.state, 'money'), getPath(hopeless, 'money'));
assert.equal(getPath(lost.state, 'relationships.boss.trust'), 0, 'clamped');
assert.match(lost.record.check.odds, /long shot/);
});
test('a run with checks and bindings replays identically from its seed and choices', () => {
const play = () => {
let state = startGame(castPack, { seed: 'replay' });
const records = [];
for (let i = 0; i < 60; i++) {
const turn = currentTurn(castPack, state);
const pick = turn.options[i % turn.options.length].option.id;
const result = takeTurn(castPack, state, pick);
records.push(result.record);
state = i === 30 ? JSON.parse(JSON.stringify(result.state)) : result.state;
}
return { state, records };
};
const a = play();
const b = play();
assert.deepEqual(a.records.map((r) => r.check?.outcome ?? '-'), b.records.map((r) => r.check?.outcome ?? '-'));
assert.deepEqual(a.state, b.state);
assert.ok(a.records.some((r) => r.check?.outcome === 'success') && a.records.some((r) => r.check?.outcome === 'failure'));
});
test('a save from before the pack drew a cast gets one on load, the same one every time', () => {
const schema = resolveSchema(castPack);
const fresh = createState(castPack, { seed: 'old', schema });
const old = clone(fresh);
delete old.cast;
const once = hydrate(castPack, clone(old), schema);
const twice = hydrate(castPack, clone(old), schema);
assert.ok(once.added.includes('cast'));
assert.deepEqual(once.state.cast, twice.state.cast);
assert.deepEqual(once.state.cast, fresh.cast);
});
test('a pack with no drawn cast gets no cast and no bindings: its runs are unchanged', () => {
const state = startGame(testPack, { seed: 'plain' });
assert.equal(state.cast, undefined);
assert.equal(state.meta.bindings, undefined);
assert.equal(presentPeople(testPack, state).map((p) => p.name).join(), 'The Chief,Pat');
});
test('the validator catches cast and check mistakes', () => {
const broken = clone(castPack);
broken.events[0].options[1].check.attribute = 'strength';
broken.events[0].options[2].failure = undefined;
broken.events[0].description = 'Blame {coworker}.';
broken.events.push({ id: 'ghost', stage: 'desk', description: '{ghost} appears.', options: [{ id: 'x', label: 'x', effects: [] }] });
broken.characters[1].count = [2, 6];
const { errors } = validatePack(broken);
const has = (fragment) => assert.ok(errors.some((e) => e.includes(fragment)), `expected an error mentioning "${fragment}":\n${errors.join('\n')}`);
has('attribute "strength" is not in pack.attributes');
has('has no failure');
has('{coworker} is a group');
has('{ghost} names no character');
has('has only 5 candidates');
});
+7 -1
View File
@@ -144,7 +144,12 @@ for (const pack of packs) {
const overcharging = [];
for (const event of pack.events) {
for (const option of event.options ?? []) {
const effects = option.effects ?? [];
// A check's branches pay and spend too (DECISIONS #41): each is judged
// together with the effects that apply either way.
const branches = option.check
? [option.success, option.failure].map((b) => [...(option.effects ?? []), ...(b?.effects ?? [])])
: [option.effects ?? []];
for (const effects of branches) {
const spends = effects.some((e) => e.path === 'money' && e.op === 'add'
&& (typeof e.value === 'number' ? e.value < 0 : (e.value?.times ?? 1) < 0));
if (!spends) continue;
@@ -155,6 +160,7 @@ for (const pack of packs) {
.some((r) => r.path === e.path && r.op === '>=' && r.value >= -e.value);
if (!covered) overcharging.push(`${event.id}.${option.id} (${e.path} ${e.value})`);
}
}
}
}
assert.ok(balances.length > 0 || pack.display === undefined, 'a pack with a balance should declare it as money');
+114
View File
@@ -0,0 +1,114 @@
// A small pack for the cast and checks (DECISIONS #41): a drawn single role, a
// group role of two to four, a fixed character with attributes, opposed and
// unopposed checks, and an event about "one of the co-workers". Its checks run
// from 0 to 100 so tests can force an outcome; a real pack keeps a floor and a
// ceiling so nothing is ever certain.
export const castPack = {
id: 'castpack',
name: 'Cast Pack',
version: '1.0.0',
turnUnit: 'day',
startingStage: 'desk',
attributes: {
nerve: { label: 'Nerve' },
wits: { label: 'Wits' },
charm: { label: 'Charm' },
resolve: { label: 'Resolve' },
},
checks: {
base: 50, perPoint: 10, unopposed: 5,
relationshipPerPoint: 0.5, relationshipCentre: 50,
floor: 0, ceiling: 100,
},
state: {
'money': { initial: 100, min: -500, max: 10000 },
'attributes.nerve': { initial: 5, min: 0, max: 10 },
'attributes.wits': { initial: 5, min: 0, max: 10 },
'attributes.charm': { initial: 5, min: 0, max: 10 },
'attributes.resolve': { initial: 5, min: 0, max: 10 },
},
relationshipStats: {
trust: { label: 'Trusts you', initial: 50, min: 0, max: 100 },
regard: { label: 'Thinks of you', initial: 50, min: 0, max: 100 },
},
characters: [
{
id: 'boss',
role_type: 'boss',
title: 'Manager',
candidates: [
{ name: 'Agnes Pell', description: 'Reads every memo twice.', attributes: { nerve: 6, wits: 8, charm: 3, resolve: 7 } },
{ name: 'Walter Grose', description: 'Loud, and quicker than he looks.', attributes: { nerve: 7, wits: 6, charm: 6, resolve: 4 },
startingRelationship: { trust: 30 } },
{ name: 'Mina Hart', description: 'Has never raised her voice.', attributes: { nerve: 4, wits: 7, charm: 8, resolve: 6 } },
],
},
{
id: 'coworker',
role_type: 'peer',
title: 'Clerk',
count: [2, 4],
candidates: [
{ name: 'Ben Aske', description: 'Owes {boss} a favour.', attributes: { nerve: 3, wits: 4, charm: 6, resolve: 5 } },
{ name: 'Cora Dunn', description: 'First in, last out.', attributes: { nerve: 5, wits: 5, charm: 4, resolve: 8 } },
{ name: 'Del Fane', description: 'Knows everyone\'s business.', attributes: { nerve: 4, wits: 7, charm: 5, resolve: 3 } },
{ name: 'Edie Gow', description: 'Quiet until she is not.', attributes: { nerve: 7, wits: 5, charm: 3, resolve: 6 } },
{ name: 'Fitz Hale', description: 'Charming, unreliable.', attributes: { nerve: 5, wits: 3, charm: 8, resolve: 2 } },
],
},
{ id: 'porter', name: 'Old Sam', role_type: 'staff', description: 'Has seen everyone come and go.',
attributes: { nerve: 2, wits: 9, charm: 4, resolve: 9 } },
],
events: [
{
id: 'routine',
stage: 'desk',
weight: 100,
description: 'A day at the desk under {boss}.',
options: [
{ id: 'work', label: 'Work.', effects: [{ path: 'money', op: 'add', value: 10 }] },
{
id: 'ask_for_more',
label: 'Ask {boss.first} for more money.',
check: { attribute: 'charm', against: 'boss', opposedBy: 'wits', relationship: 'trust' },
effects: [{ path: 'relationships.boss.regard', op: 'add', value: -1 }],
success: { result: '{boss} agrees.', effects: [{ path: 'money', op: 'add', value: 50 }] },
failure: { result: '{boss} laughs.', effects: [{ path: 'relationships.boss.trust', op: 'add', value: -5 }] },
},
{
id: 'hard_sum',
label: 'Try the hard sum.',
check: { attribute: 'wits', difficulty: 5 },
success: { result: 'It adds up.', effects: [{ path: 'attributes.wits', op: 'add', value: 1 }] },
failure: { result: 'It does not.', effects: [] },
},
],
},
{
id: 'needled',
stage: 'desk',
weight: 100,
involves: ['coworker'],
description: '{coworker} makes a remark about your tie.',
options: [
{ id: 'ignore', label: 'Let it go.', effects: [{ path: 'relationships.{coworker}.regard', op: 'add', value: -2 }] },
{
id: 'retort',
label: 'Answer {coworker.first} back.',
check: { attribute: 'nerve', against: '{coworker}', relationship: 'regard' },
success: { result: '{coworker} laughs, and means it.', effects: [{ path: 'relationships.{coworker}.regard', op: 'add', value: 5 }] },
failure: { result: 'It lands badly.', effects: [{ path: 'relationships.{coworker}.trust', op: 'add', value: -5 }] },
},
],
},
],
};
export default castPack;
+24 -7
View File
@@ -24,13 +24,30 @@ const amountOf = (effect) => {
return 0;
};
const moneyGain = (resolved) => (resolved.option.effects ?? [])
.filter((e) => e.path === 'money' && e.op === 'add')
.reduce((sum, e) => sum + amountOf(e), 0);
/**
* Every effect an option can have, with the weight a player reading the odds
* would give it: plain effects count in full, a check's success and failure by
* their chance (DECISIONS #41). An option with no check is just its effects.
*/
export const weightedEffects = (resolved) => {
const { option, odds } = resolved;
const plain = (option.effects ?? []).map((effect) => ({ effect, weight: 1 }));
if (!option.check) return plain;
const p = (odds?.chance ?? 50) / 100;
return [
...plain,
...(option.success?.effects ?? []).map((effect) => ({ effect, weight: p })),
...(option.failure?.effects ?? []).map((effect) => ({ effect, weight: 1 - p })),
];
};
const relationshipGain = (resolved) => (resolved.option.effects ?? [])
.filter((e) => e.path.startsWith('relationships.'))
.reduce((sum, e) => sum + amountOf(e), 0);
const moneyGain = (resolved) => weightedEffects(resolved)
.filter(({ effect }) => effect.path === 'money' && effect.op === 'add')
.reduce((sum, { effect, weight }) => sum + amountOf(effect) * weight, 0);
const relationshipGain = (resolved) => weightedEffects(resolved)
.filter(({ effect }) => effect.path.startsWith('relationships.'))
.reduce((sum, { effect, weight }) => sum + amountOf(effect) * weight, 0);
const best = (options, score) => [...options].sort((a, b) => score(b) - score(a))[0];
@@ -59,7 +76,7 @@ export const strategies = {
*/
lifer: (options) => {
const stays = options.filter(
(o) => !(o.option.effects ?? []).some((e) => e.path === 'stage'),
(o) => !weightedEffects(o).some(({ effect }) => effect.path === 'stage'),
);
return best(stays.length ? stays : options, relationshipGain);
},
+49
View File
@@ -527,3 +527,52 @@ test('the header reads the second pack\'s display map, not the first\'s', async
dom.restore();
}
});
// ---- a drawn cast, and checks (DECISIONS #41) ----------------------------------
test('a pack with a drawn cast shows whoever this run drew, and never a placeholder', async () => {
const dom = await boot({ pack: 'countinghouse' });
try {
const people = byClass(dom.root, 'person');
// One partner, one chief clerk, and two to four clerks.
assert.ok(people.length >= 4 && people.length <= 6, `${people.length} people in the panel`);
const titles = byClass(dom.root, 'person__title').map((t) => t.textContent);
assert.equal(titles.filter((t) => t === 'Senior Partner').length, 1);
assert.equal(titles.filter((t) => t === 'Chief Clerk').length, 1);
const clerks = titles.filter((t) => t === 'Clerk').length;
assert.ok(clerks >= 2 && clerks <= 4, `${clerks} clerks`);
for (let turn = 0; turn < 25; turn++) {
assert.doesNotMatch(text(dom.root), /\{[a-z_0-9.]+\}/, 'a {placeholder} reached the screen');
enabledOptions(dom.root)[0].click();
assert.doesNotMatch(text(dom.root), /\{[a-z_0-9.]+\}/, 'a {placeholder} reached the outcome');
firstByClass(dom.root, 'button--next').click();
}
} finally {
dom.restore();
}
});
test('a checked option shows its odds in words, and the outcome says whether it came off', async () => {
const dom = await boot({ pack: 'countinghouse' });
try {
let checked;
for (let turn = 0; turn < 40 && !checked; turn++) {
checked = optionButtons(dom.root).find((b) => !b.disabled && firstByClass(b, 'option__odds'));
if (checked) break;
enabledOptions(dom.root)[0].click();
firstByClass(dom.root, 'button--next').click();
}
assert.ok(checked, 'a checked option turned up within forty days');
const odds = firstByClass(checked, 'option__odds').textContent;
assert.match(odds, /^(a long shot|unlikely|an even chance|likely|a safe bet)$/);
assert.doesNotMatch(text(dom.root), /\d+%/, 'no percentage is ever shown');
checked.click();
const said = text(firstByClass(dom.root, 'outcome__check'));
assert.match(said, /and it (came|did not come) off\./);
assert.ok(said.toLowerCase().startsWith(odds), `the outcome repeats the odds: "${said}"`);
assert.ok(firstByClass(dom.root, 'outcome__text'), 'and says what happened');
} finally {
dom.restore();
}
});