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:
co-authored by
Claude Opus 5.5
parent
112e558dbb
commit
3108319a2b
@@ -0,0 +1,122 @@
|
||||
// Harrow & Pike's counting house, as it might be staffed.
|
||||
//
|
||||
// Nobody here is fixed. Each role has several people who could fill it, and a
|
||||
// run draws one partner, one chief clerk and two to four clerks from them
|
||||
// (DECISIONS #41), so a second career in the same pack meets a different
|
||||
// office. Every candidate carries the same four attributes the player does,
|
||||
// and a description that should agree with them: a partner written as sharp
|
||||
// has the wits to see through a bluff, and one written as easily flattered
|
||||
// does not.
|
||||
//
|
||||
// Descriptions may name another role in braces, filled from whoever was drawn
|
||||
// into it, so a nephew can be the nephew of whichever partner this run has.
|
||||
|
||||
export const characters = [
|
||||
{
|
||||
id: 'partner',
|
||||
role_type: 'boss',
|
||||
title: 'Senior Partner',
|
||||
candidates: [
|
||||
{
|
||||
name: 'Josiah Crane',
|
||||
description:
|
||||
'Built the house from one room and a borrowed desk. Remembers every figure ' +
|
||||
'he has ever been shown, and forgives very few of them.',
|
||||
attributes: { nerve: 7, wits: 8, charm: 3, resolve: 8 },
|
||||
startingRelationship: { trust: 40 },
|
||||
},
|
||||
{
|
||||
name: 'Harriet Voss',
|
||||
description:
|
||||
'Took over the firm when her husband died and has doubled it since. ' +
|
||||
'Listens to everything and says about a fifth of it.',
|
||||
attributes: { nerve: 6, wits: 9, charm: 5, resolve: 7 },
|
||||
},
|
||||
{
|
||||
name: 'Lionel Ashby',
|
||||
description:
|
||||
'Inherited the chair and would rather be at his club. Easily flattered, ' +
|
||||
'easily bored, and sharper than he lets anyone see.',
|
||||
attributes: { nerve: 4, wits: 6, charm: 7, resolve: 3 },
|
||||
startingRelationship: { regard: 55 },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'chief',
|
||||
role_type: 'supervisor',
|
||||
title: 'Chief Clerk',
|
||||
candidates: [
|
||||
{
|
||||
name: 'Silas Pruett',
|
||||
description:
|
||||
'Thirty years at the same high desk. Counts the pen nibs on Fridays and ' +
|
||||
'knows to the penny what the stationery cupboard should hold.',
|
||||
attributes: { nerve: 5, wits: 6, charm: 2, resolve: 9 },
|
||||
},
|
||||
{
|
||||
name: 'Ada Kemble',
|
||||
description:
|
||||
'Runs the floor with a pencil behind her ear and a list, kept in the ' +
|
||||
'same pencil, of everybody\'s lapses.',
|
||||
attributes: { nerve: 6, wits: 7, charm: 4, resolve: 7 },
|
||||
startingRelationship: { trust: 40 },
|
||||
},
|
||||
{
|
||||
name: 'Horace Lumb',
|
||||
description:
|
||||
'Wanted your desk for somebody else\'s nephew and has not forgotten it. ' +
|
||||
'Polite to your face, in a way that takes some effort.',
|
||||
attributes: { nerve: 4, wits: 5, charm: 5, resolve: 6 },
|
||||
startingRelationship: { regard: 35 },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'clerk',
|
||||
role_type: 'peer',
|
||||
title: 'Clerk',
|
||||
count: [2, 4],
|
||||
candidates: [
|
||||
{
|
||||
name: 'Tom Bassett',
|
||||
description: 'Cheerful, generous with other people\'s time, and a borrower of small sums he means to repay.',
|
||||
attributes: { nerve: 5, wits: 4, charm: 7, resolve: 3 },
|
||||
},
|
||||
{
|
||||
name: 'Nell Farrow',
|
||||
description: 'The quickest pen on the floor and does not mind who knows it. Wants the next desk up, and soon.',
|
||||
attributes: { nerve: 5, wits: 8, charm: 5, resolve: 6 },
|
||||
startingRelationship: { regard: 40 },
|
||||
},
|
||||
{
|
||||
name: 'Arthur Quill',
|
||||
description: 'Timid, exact, and never wrong about a column of figures. Apologises to doors.',
|
||||
attributes: { nerve: 2, wits: 7, charm: 3, resolve: 7 },
|
||||
},
|
||||
{
|
||||
name: 'Joe Madden',
|
||||
description: 'The loudest laugh in the building and most of the jokes, not all of them kind.',
|
||||
attributes: { nerve: 7, wits: 4, charm: 6, resolve: 3 },
|
||||
},
|
||||
{
|
||||
name: 'Lottie Swain',
|
||||
description: 'Types the partner\'s letters and so knows everything a week early. Tells almost none of it.',
|
||||
attributes: { nerve: 4, wits: 7, charm: 7, resolve: 5 },
|
||||
},
|
||||
{
|
||||
name: 'Percy Dyer',
|
||||
description: 'A nephew of {partner}\'s, sent to learn the business. Has so far learned where the kettle is.',
|
||||
attributes: { nerve: 3, wits: 3, charm: 6, resolve: 2 },
|
||||
startingRelationship: { trust: 40 },
|
||||
},
|
||||
{
|
||||
name: 'Walt Rudge',
|
||||
description: 'Twenty years a clerk and content with it. The one everyone asks, and the last to be thanked.',
|
||||
attributes: { nerve: 6, wits: 6, charm: 4, resolve: 8 },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default characters;
|
||||
@@ -0,0 +1,882 @@
|
||||
// A clerk's days at Harrow & Pike.
|
||||
//
|
||||
// Every situation here comes from the story-to-pack catalogue (tools/
|
||||
// story-to-pack/probe/catalogue/v3), where it recurred across public-domain
|
||||
// office fiction: Torchy's door, Keefe's pay talk, Pooter's cheeky juniors,
|
||||
// Lorimer's hard boss. The id names the catalogue entry where one fits.
|
||||
//
|
||||
// This pack exists to try checks and a drawn cast (DECISIONS #41). Most options
|
||||
// test one of the four attributes, against a named person or against a set
|
||||
// difficulty, and say what happens either way; the player sees only the odds
|
||||
// in words. Names are written as `{partner}`, `{chief}` and `{clerk}` and filled
|
||||
// from whoever this run drew. An event about "a clerk" lists `clerk` in
|
||||
// `involves`, and the engine picks one present clerk for the turn.
|
||||
|
||||
const moment = (text) => ({ path: 'notable', op: 'push', value: text });
|
||||
|
||||
export const events = [
|
||||
{
|
||||
id: 'first_day',
|
||||
stage: 'desk',
|
||||
weight: 1000,
|
||||
once: true,
|
||||
involves: ['clerk'],
|
||||
description:
|
||||
'Your first morning at Harrow & Pike. {chief} shows you a stool, a pen, a ' +
|
||||
'blotter and the clock, in that order, and leaves you to draw your own ' +
|
||||
'conclusions about which matters most.',
|
||||
options: [
|
||||
{
|
||||
id: 'before_the_clock',
|
||||
label: 'Be on the stool before the clock is.',
|
||||
result: '{chief} notices, and writes something down. It may even be good.',
|
||||
effects: [
|
||||
{ path: 'relationships.chief.regard', op: 'add', value: 4 },
|
||||
moment('Began at Harrow & Pike, early.'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'meet_the_floor',
|
||||
label: 'Go round and introduce yourself to {clerk}.',
|
||||
result: '{clerk} shakes your hand and tells you which of the pens leak.',
|
||||
effects: [
|
||||
{ path: 'relationships.{clerk}.regard', op: 'add', value: 5 },
|
||||
moment('Began at Harrow & Pike, and met {clerk} first.'),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// The floor: the one event nothing can exclude.
|
||||
{
|
||||
id: 'the_ledgers',
|
||||
stage: 'desk',
|
||||
weight: 40,
|
||||
description:
|
||||
'The ledgers are the same as last week\'s, only longer. {chief} has left a ' +
|
||||
'note on your blotter about the width of your margins.',
|
||||
options: [
|
||||
{
|
||||
id: 'head_down',
|
||||
label: 'Keep your head down and work.',
|
||||
result: 'The day goes into the columns, as days here do.',
|
||||
effects: [
|
||||
{ path: 'standing', op: 'add', value: 1 },
|
||||
{ path: 'relationships.chief.trust', op: 'add', value: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'stay_late',
|
||||
label: 'Stay on after six to finish the day book.',
|
||||
result: 'Overtime is paid, at the rate the firm thinks overtime is worth.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: 2 },
|
||||
{ path: 'relationships.chief.regard', op: 'add', value: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ease_off',
|
||||
label: 'Make the day last.',
|
||||
result: 'Nobody notices, today.',
|
||||
effects: [{ path: 'standing', op: 'add', value: -1 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'ask_for_more',
|
||||
stage: 'desk',
|
||||
weight: 25,
|
||||
cooldown: 12,
|
||||
requires: [
|
||||
{ path: 'turn', op: '>=', value: 10 },
|
||||
{ path: 'rises', op: '<', value: 3 },
|
||||
],
|
||||
description:
|
||||
'You have been at the desk long enough to know what the work is worth, and ' +
|
||||
'your lodgings have not got any cheaper. {partner} is in, and alone.',
|
||||
options: [
|
||||
{
|
||||
id: 'ask',
|
||||
label: 'Knock, and ask {partner} for a rise.',
|
||||
check: { attribute: 'charm', against: 'partner', opposedBy: 'wits', relationship: 'trust' },
|
||||
success: {
|
||||
result: '{partner} looks at you for a long moment and says the firm can manage something.',
|
||||
effects: [
|
||||
{ path: 'rises', op: 'add', value: 1 },
|
||||
{ path: 'standing', op: 'add', value: 3 },
|
||||
moment('Asked {partner} for a rise, and got it.'),
|
||||
],
|
||||
},
|
||||
failure: {
|
||||
result: '{partner} asks whether you have any idea what a clerk is worth. You find you do not.',
|
||||
effects: [
|
||||
{ path: 'relationships.partner.trust', op: 'add', value: -5 },
|
||||
{ path: 'standing', op: 'add', value: -2 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'hint',
|
||||
label: 'Say nothing directly; mention the price of lodgings.',
|
||||
check: { attribute: 'wits', difficulty: 5 },
|
||||
success: {
|
||||
result: '{partner} takes the point and, better, thinks it was a delicate one.',
|
||||
effects: [{ path: 'relationships.partner.regard', op: 'add', value: 4 }],
|
||||
},
|
||||
failure: {
|
||||
result: '{partner} agrees that lodgings are very dear and goes back to the post.',
|
||||
effects: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'not_yet',
|
||||
label: 'Wait for a better moment.',
|
||||
result: 'There will be one. There usually is.',
|
||||
effects: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'called_in',
|
||||
stage: 'desk',
|
||||
weight: 30,
|
||||
cooldown: 8,
|
||||
involves: ['clerk'],
|
||||
description:
|
||||
'{chief} has found a column that does not add up, and it is in your hand. You ' +
|
||||
'are to explain it, standing, in front of the high desk.',
|
||||
options: [
|
||||
{
|
||||
id: 'own_up',
|
||||
label: 'Say plainly that it was your mistake.',
|
||||
check: { attribute: 'resolve', difficulty: 4 },
|
||||
success: {
|
||||
result: 'It is noted, and then, which is rarer, it is let go.',
|
||||
effects: [
|
||||
{ path: 'relationships.partner.trust', op: 'add', value: 4 },
|
||||
{ path: 'attributes.resolve', op: 'add', value: 1 },
|
||||
],
|
||||
},
|
||||
failure: {
|
||||
result: 'You start well and finish apologising for the weather.',
|
||||
effects: [{ path: 'standing', op: 'add', value: -2 }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'argue',
|
||||
label: 'Argue that the figures were right.',
|
||||
check: { attribute: 'wits', against: 'chief' },
|
||||
success: {
|
||||
result: 'They were. {chief} checks twice and hands the page back without a word.',
|
||||
effects: [
|
||||
{ path: 'standing', op: 'add', value: 3 },
|
||||
{ path: 'relationships.chief.regard', op: 'add', value: -4 },
|
||||
],
|
||||
},
|
||||
failure: {
|
||||
result: 'They were not, and now everybody knows you thought they were.',
|
||||
effects: [
|
||||
{ path: 'standing', op: 'add', value: -4 },
|
||||
{ path: 'relationships.partner.trust', op: 'add', value: -3 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'blame',
|
||||
label: 'Say the figures came from {clerk}\'s desk.',
|
||||
check: { attribute: 'wits', against: 'chief' },
|
||||
success: {
|
||||
result: '{chief} goes to find {clerk}. You do not watch.',
|
||||
effects: [
|
||||
{ path: 'standing', op: 'add', value: 1 },
|
||||
{ path: 'relationships.{clerk}.trust', op: 'add', value: -8 },
|
||||
],
|
||||
},
|
||||
failure: {
|
||||
result: '{chief} has the day book open at your page before you finish the sentence.',
|
||||
effects: [
|
||||
{ path: 'standing', op: 'add', value: -5 },
|
||||
{ path: 'relationships.{clerk}.trust', op: 'add', value: -10 },
|
||||
{ path: 'relationships.partner.trust', op: 'add', value: -5 },
|
||||
moment('Tried to put a mistake on {clerk}, and was found out.'),
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'the_door',
|
||||
stage: 'desk',
|
||||
weight: 30,
|
||||
cooldown: 6,
|
||||
description:
|
||||
'A caller in a good coat wants {partner} and will not give his name. You ' +
|
||||
'were told the partner was not to be disturbed before three.',
|
||||
options: [
|
||||
{
|
||||
id: 'turn_away',
|
||||
label: 'Tell him the partner is out.',
|
||||
check: { attribute: 'nerve', difficulty: 5 },
|
||||
success: {
|
||||
result: 'He goes. {partner} hears of it later and approves of the lie.',
|
||||
effects: [{ path: 'relationships.partner.trust', op: 'add', value: 3 }],
|
||||
},
|
||||
failure: {
|
||||
result: 'He sits down to wait, and {partner} comes out at a quarter past and finds him there.',
|
||||
effects: [
|
||||
{ path: 'relationships.partner.regard', op: 'add', value: -3 },
|
||||
{ path: 'standing', op: 'add', value: -1 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'size_him_up',
|
||||
label: 'Size him up and decide for yourself.',
|
||||
check: { attribute: 'wits', difficulty: 6 },
|
||||
success: {
|
||||
result: 'You read him right. He mattered, and he got in, and {partner} knows who let him.',
|
||||
effects: [
|
||||
{ path: 'relationships.partner.trust', op: 'add', value: 5 },
|
||||
{ path: 'standing', op: 'add', value: 2 },
|
||||
moment('Judged a caller at the door, and judged him right.'),
|
||||
],
|
||||
},
|
||||
failure: {
|
||||
result: 'He was selling encyclopaedias, and {partner} bought one to get rid of him.',
|
||||
effects: [{ path: 'relationships.partner.trust', op: 'add', value: -4 }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'pass_it_on',
|
||||
label: 'Fetch {chief} and let it be somebody else\'s decision.',
|
||||
result: '{chief} deals with it, and with you, briefly.',
|
||||
effects: [{ path: 'standing', op: 'add', value: -1 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'needled',
|
||||
stage: 'desk',
|
||||
weight: 40,
|
||||
cooldown: 4,
|
||||
involves: ['clerk'],
|
||||
description:
|
||||
'{clerk} reads your last letter aloud in a voice meant to be yours, and the ' +
|
||||
'floor laughs, and keeps laughing longer than it deserves.',
|
||||
options: [
|
||||
{
|
||||
id: 'let_it_go',
|
||||
label: 'Let it pass.',
|
||||
result: 'It passes. They will remember that you let it.',
|
||||
effects: [
|
||||
{ path: 'relationships.{clerk}.regard', op: 'add', value: 1 },
|
||||
{ path: 'standing', op: 'add', value: -1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'retort',
|
||||
label: 'Answer {clerk} back, with the floor listening.',
|
||||
check: { attribute: 'wits', against: '{clerk}', relationship: 'regard' },
|
||||
success: {
|
||||
result: 'It gets a bigger laugh than the letter did, and {clerk} laughs too.',
|
||||
effects: [
|
||||
{ path: 'standing', op: 'add', value: 2 },
|
||||
{ path: 'relationships.{clerk}.regard', op: 'add', value: 3 },
|
||||
],
|
||||
},
|
||||
failure: {
|
||||
result: 'It comes out too sharp, and the laugh turns on you.',
|
||||
effects: [
|
||||
{ path: 'standing', op: 'add', value: -2 },
|
||||
{ path: 'relationships.{clerk}.regard', op: 'add', value: -3 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'report',
|
||||
label: 'Mention it to {chief}.',
|
||||
result: '{chief} has a word. {clerk} knows where it came from.',
|
||||
effects: [
|
||||
{ path: 'relationships.chief.trust', op: 'add', value: 2 },
|
||||
{ path: 'relationships.{clerk}.trust', op: 'add', value: -6 },
|
||||
{ path: 'standing', op: 'add', value: -1 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'red_ink',
|
||||
stage: 'desk',
|
||||
weight: 30,
|
||||
cooldown: 6,
|
||||
description:
|
||||
'{chief} has gone over your morning\'s work in red ink, and left the page ' +
|
||||
'open on the high desk where the whole floor can see it.',
|
||||
options: [
|
||||
{
|
||||
id: 'swallow_it',
|
||||
label: 'Take it, and do the page again.',
|
||||
result: 'You do it again. {chief} initials it without looking up.',
|
||||
effects: [
|
||||
{ path: 'relationships.chief.regard', op: 'add', value: 2 },
|
||||
{ path: 'standing', op: 'add', value: -1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'answer_back',
|
||||
label: 'Point out that the red ink is wrong.',
|
||||
check: { attribute: 'wits', against: 'chief' },
|
||||
success: {
|
||||
result: 'It is, in two places. {chief} says nothing, which from {chief.first} is a concession.',
|
||||
effects: [
|
||||
{ path: 'standing', op: 'add', value: 2 },
|
||||
{ path: 'relationships.chief.regard', op: 'add', value: 3 },
|
||||
],
|
||||
},
|
||||
failure: {
|
||||
result: 'The red ink is right. It usually is.',
|
||||
effects: [
|
||||
{ path: 'standing', op: 'add', value: -2 },
|
||||
{ path: 'relationships.chief.regard', op: 'add', value: -5 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'over_the_head',
|
||||
label: 'Take the page to {partner}.',
|
||||
check: { attribute: 'charm', against: 'partner', opposedBy: 'resolve', relationship: 'trust' },
|
||||
success: {
|
||||
result: '{partner} reads it, and has a word with {chief}, and {chief} has one with you.',
|
||||
effects: [
|
||||
{ path: 'relationships.partner.trust', op: 'add', value: 3 },
|
||||
{ path: 'relationships.chief.regard', op: 'add', value: -8 },
|
||||
{ path: 'standing', op: 'add', value: 2 },
|
||||
moment('Went over {chief}\'s head, and it worked.'),
|
||||
],
|
||||
},
|
||||
failure: {
|
||||
result: '{partner} says the chief clerk runs the floor, and asks whether there was anything else.',
|
||||
effects: [
|
||||
{ path: 'relationships.partner.trust', op: 'add', value: -4 },
|
||||
{ path: 'relationships.chief.regard', op: 'add', value: -8 },
|
||||
{ path: 'standing', op: 'add', value: -3 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'sure_thing',
|
||||
stage: 'desk',
|
||||
weight: 15,
|
||||
cooldown: 20,
|
||||
description:
|
||||
'A pleasant man at the chop-house has a share in a tramway in Brazil going ' +
|
||||
'begging. Twelve dollars today, he says, will be forty by Christmas.',
|
||||
options: [
|
||||
{
|
||||
id: 'buy_in',
|
||||
label: 'Put in twelve dollars.',
|
||||
effects: [{ path: 'money', op: 'add', value: -12 }],
|
||||
check: { attribute: 'wits', difficulty: 7 },
|
||||
success: {
|
||||
result: 'Against every expectation, including his, it pays.',
|
||||
effects: [{ path: 'money', op: 'add', value: 30 }, moment('Bought a share in a tramway, and it paid.')],
|
||||
},
|
||||
failure: {
|
||||
result: 'There is no tramway. There is, briefly, a man.',
|
||||
effects: [moment('Bought a share in a tramway that did not exist.')],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'see_through',
|
||||
label: 'Ask him the one question he cannot answer.',
|
||||
check: { attribute: 'wits', difficulty: 6 },
|
||||
success: {
|
||||
result: 'He finds he has an appointment. You find you are sharper than you were.',
|
||||
effects: [{ path: 'attributes.wits', op: 'add', value: 1 }, moment('Saw through a tramway in Brazil.')],
|
||||
},
|
||||
failure: {
|
||||
result: 'He answers it smoothly, and you are left not knowing.',
|
||||
effects: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'decline',
|
||||
label: 'Keep your money in your pocket.',
|
||||
result: 'He is sorry for you, he says, and goes to find someone less careful.',
|
||||
effects: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'the_round',
|
||||
stage: 'desk',
|
||||
weight: 30,
|
||||
cooldown: 5,
|
||||
involves: ['clerk'],
|
||||
description:
|
||||
'{clerk} and the others are going to the chop-house after work, and they ' +
|
||||
'have kept you a place. The round there is not cheap.',
|
||||
options: [
|
||||
{
|
||||
id: 'go',
|
||||
label: 'Go, and stand your round.',
|
||||
result: 'A good evening, and a dear one.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: -5 },
|
||||
{ path: 'relationships.{clerk}.regard', op: 'add', value: 4 },
|
||||
{ path: 'standing', op: 'add', value: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'talk_your_way',
|
||||
label: 'Go, and talk your way out of the round.',
|
||||
check: { attribute: 'charm', against: '{clerk}', opposedBy: 'wits' },
|
||||
success: {
|
||||
result: 'Nobody notices you never reached for your purse, and {clerk} laughs at all your jokes.',
|
||||
effects: [{ path: 'relationships.{clerk}.regard', op: 'add', value: 3 }],
|
||||
},
|
||||
failure: {
|
||||
result: '{clerk} notices, and says so, and you pay for the round and the remark.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: -5 },
|
||||
{ path: 'relationships.{clerk}.regard', op: 'add', value: -2 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'excuse',
|
||||
label: 'Make an excuse and go home.',
|
||||
result: 'They go without you. They will ask again, perhaps.',
|
||||
effects: [{ path: 'relationships.{clerk}.regard', op: 'add', value: -2 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'night_school',
|
||||
stage: 'desk',
|
||||
weight: 25,
|
||||
cooldown: 5,
|
||||
requires: [{ path: 'money', op: '>=', value: 3 }],
|
||||
description:
|
||||
'A card in the tram window: evening classes in bookkeeping, in the art of ' +
|
||||
'address, and at the boxing club behind the Institute. Three dollars a course.',
|
||||
options: [
|
||||
{
|
||||
id: 'bookkeeping',
|
||||
label: 'Take the bookkeeping course.',
|
||||
result: 'Double entry, twice a week. Your columns begin to add up before you do.',
|
||||
effects: [{ path: 'money', op: 'add', value: -3 }, { path: 'attributes.wits', op: 'add', value: 1 }],
|
||||
},
|
||||
{
|
||||
id: 'address',
|
||||
label: 'Take the art of address.',
|
||||
result: 'You learn when to say nothing, which is most of it.',
|
||||
effects: [{ path: 'money', op: 'add', value: -3 }, { path: 'attributes.charm', op: 'add', value: 1 }],
|
||||
},
|
||||
{
|
||||
id: 'boxing',
|
||||
label: 'Join the boxing club.',
|
||||
result: 'You are hit in the face by a draper. It does you a world of good.',
|
||||
effects: [{ path: 'money', op: 'add', value: -3 }, { path: 'attributes.nerve', op: 'add', value: 1 }],
|
||||
},
|
||||
{
|
||||
id: 'not_this_term',
|
||||
label: 'Not this term.',
|
||||
result: 'The card is still there next week.',
|
||||
effects: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'stand_in',
|
||||
stage: 'desk',
|
||||
weight: 15,
|
||||
cooldown: 10,
|
||||
requires: [{ path: 'standing', op: '>=', value: 45 }],
|
||||
description:
|
||||
'{partner} is laid up and sends you, of all people, to sit in on the meeting ' +
|
||||
'with the shipping agents. You are to listen, and to say as little as possible.',
|
||||
options: [
|
||||
{
|
||||
id: 'listen',
|
||||
label: 'Listen, and take good notes.',
|
||||
result: 'Your notes are better than the minutes. {partner} keeps them.',
|
||||
effects: [
|
||||
{ path: 'relationships.partner.trust', op: 'add', value: 2 },
|
||||
{ path: 'standing', op: 'add', value: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'speak_up',
|
||||
label: 'Say the thing you noticed in their figures.',
|
||||
check: { attribute: 'wits', difficulty: 7 },
|
||||
success: {
|
||||
result: 'You were right, the agents know it, and so, by the afternoon, does {partner}.',
|
||||
effects: [
|
||||
{ path: 'relationships.partner.trust', op: 'add', value: 6 },
|
||||
{ path: 'standing', op: 'add', value: 5 },
|
||||
moment('Spoke up at the agents\' meeting in {partner}\'s place, and was right.'),
|
||||
],
|
||||
},
|
||||
failure: {
|
||||
result: 'You were wrong in front of people who were paid to be right.',
|
||||
effects: [
|
||||
{ path: 'relationships.partner.trust', op: 'add', value: -4 },
|
||||
{ path: 'standing', op: 'add', value: -3 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'charm_them',
|
||||
label: 'Charm the agents over lunch.',
|
||||
check: { attribute: 'charm', difficulty: 6 },
|
||||
success: {
|
||||
result: 'They like you. They say so to {partner}, which is the point.',
|
||||
effects: [
|
||||
{ path: 'relationships.partner.regard', op: 'add', value: 5 },
|
||||
{ path: 'standing', op: 'add', value: 3 },
|
||||
],
|
||||
},
|
||||
failure: {
|
||||
result: 'The lunch is long, and you pay for it.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: -4 },
|
||||
{ path: 'standing', op: 'add', value: -1 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'lost_bill',
|
||||
stage: 'desk',
|
||||
weight: 20,
|
||||
cooldown: 12,
|
||||
involves: ['clerk'],
|
||||
description:
|
||||
'{clerk} has lost a bill of lading, and {chief} is talking about a letter to ' +
|
||||
'the partner. {clerk} has not looked up from the desk all morning.',
|
||||
options: [
|
||||
{
|
||||
id: 'speak_for',
|
||||
label: 'Put {clerk}\'s case to {partner} yourself.',
|
||||
check: { attribute: 'charm', against: 'partner', opposedBy: 'resolve', relationship: 'trust' },
|
||||
success: {
|
||||
result: '{partner} hears you out and lets it go. {clerk} does not forget who spoke.',
|
||||
effects: [
|
||||
{ path: 'relationships.{clerk}.trust', op: 'add', value: 10 },
|
||||
{ path: 'relationships.partner.regard', op: 'add', value: 2 },
|
||||
moment('Spoke up for {clerk}, and it held.'),
|
||||
],
|
||||
},
|
||||
failure: {
|
||||
result: '{partner} asks whether you would like to share the letter.',
|
||||
effects: [
|
||||
{ path: 'relationships.partner.trust', op: 'add', value: -3 },
|
||||
{ path: 'relationships.{clerk}.trust', op: 'add', value: 4 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'help_look',
|
||||
label: 'Stay late and help look for it.',
|
||||
check: { attribute: 'resolve', difficulty: 5 },
|
||||
success: {
|
||||
result: 'It is in the wrong drawer, under the right one. You find it at nine.',
|
||||
effects: [
|
||||
{ path: 'relationships.{clerk}.trust', op: 'add', value: 8 },
|
||||
{ path: 'relationships.{clerk}.regard', op: 'add', value: 4 },
|
||||
],
|
||||
},
|
||||
failure: {
|
||||
result: 'It is not found, but {clerk} saw you look.',
|
||||
effects: [{ path: 'relationships.{clerk}.regard', op: 'add', value: 2 }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'keep_out',
|
||||
label: 'Keep out of it.',
|
||||
result: 'The letter goes. So, very nearly, does {clerk}.',
|
||||
effects: [{ path: 'relationships.{clerk}.trust', op: 'add', value: -3 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'the_butt',
|
||||
stage: 'desk',
|
||||
weight: 20,
|
||||
cooldown: 8,
|
||||
involves: ['clerk'],
|
||||
description:
|
||||
'Somebody has pinned a card to your back, and you have walked the length of ' +
|
||||
'the floor to {chief}\'s desk wearing it. {clerk} cannot keep a straight face.',
|
||||
options: [
|
||||
{
|
||||
id: 'laugh_along',
|
||||
label: 'Laugh louder than anybody.',
|
||||
check: { attribute: 'charm', difficulty: 4 },
|
||||
success: {
|
||||
result: 'It becomes your joke instead of theirs.',
|
||||
effects: [
|
||||
{ path: 'standing', op: 'add', value: 1 },
|
||||
{ path: 'relationships.{clerk}.regard', op: 'add', value: 3 },
|
||||
],
|
||||
},
|
||||
failure: {
|
||||
result: 'The laugh comes out wrong, and everyone hears that it did.',
|
||||
effects: [{ path: 'standing', op: 'add', value: -2 }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'dignity',
|
||||
label: 'Take it off and say nothing at all.',
|
||||
check: { attribute: 'resolve', difficulty: 4 },
|
||||
success: {
|
||||
result: 'It is not funny for long, when nobody is laughing at the front.',
|
||||
effects: [{ path: 'standing', op: 'add', value: 1 }],
|
||||
},
|
||||
failure: {
|
||||
result: 'Your ears give you away.',
|
||||
effects: [
|
||||
{ path: 'standing', op: 'add', value: -1 },
|
||||
{ path: 'relationships.{clerk}.regard', op: 'add', value: -2 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'get_even',
|
||||
label: 'Resolve to do something about {clerk} tomorrow.',
|
||||
result: 'You do. It is not as funny, and {clerk} knows exactly who it was.',
|
||||
effects: [
|
||||
{ path: 'relationships.{clerk}.regard', op: 'add', value: -2 },
|
||||
{ path: 'relationships.{clerk}.trust', op: 'add', value: -3 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'saturday',
|
||||
stage: 'desk',
|
||||
weight: 60,
|
||||
cooldown: 5,
|
||||
requires: [{ path: 'calendar.name', op: '==', value: 'Saturday' }],
|
||||
description:
|
||||
'Saturday, and the office shuts at one. The pay envelope is in your inside ' +
|
||||
'pocket, and the whole of the afternoon is in front of you.',
|
||||
options: [
|
||||
{
|
||||
id: 'new_collar',
|
||||
label: 'Buy the collar the senior clerks wear.',
|
||||
result: 'It pinches, and it is noticed.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: -5 },
|
||||
{ path: 'standing', op: 'add', value: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'good_dinner',
|
||||
label: 'Dine somewhere with a tablecloth.',
|
||||
result: 'A dinner you will describe, later, to people who were not there.',
|
||||
effects: [{ path: 'money', op: 'add', value: -4 }],
|
||||
},
|
||||
{
|
||||
id: 'pay_back',
|
||||
label: 'Pay back what you owe the floor.',
|
||||
requires: [{ path: 'owed', op: '>', value: 0 }],
|
||||
whenLocked: 'hide',
|
||||
result: 'Squared, or nearer it. It is a better feeling than the dinner would have been.',
|
||||
// The money is read from the balance before either moves, so a small
|
||||
// debt costs what it is and no more (DECISIONS #38).
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: { from: 'owed', times: -1, cap: 10 } },
|
||||
{ path: 'owed', op: 'add', value: { from: 'owed', times: -1, cap: 10 } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'straight_home',
|
||||
label: 'Straight home, envelope sealed.',
|
||||
result: 'The envelope is lighter than it was in your imagination, but it is all there.',
|
||||
effects: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'family_errand',
|
||||
stage: 'desk',
|
||||
weight: 20,
|
||||
cooldown: 8,
|
||||
involves: ['clerk'],
|
||||
description:
|
||||
'A parcel for {partner}\'s sister has to cross the city before four, in the ' +
|
||||
'rain, and {partner} has asked for you by name. It is not in the job.',
|
||||
options: [
|
||||
{
|
||||
id: 'take_it',
|
||||
label: 'Take it yourself, and pay your own tram.',
|
||||
result: 'The sister gives you tea, and a message for {partner} that is more about you than the parcel.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: -1 },
|
||||
{ path: 'relationships.partner.regard', op: 'add', value: 4 },
|
||||
{ path: 'standing', op: 'add', value: -1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'make_an_impression',
|
||||
label: 'Take it, and make yourself agreeable when you get there.',
|
||||
check: { attribute: 'charm', difficulty: 5 },
|
||||
success: {
|
||||
result: 'The sister writes to {partner} that evening. You are mentioned warmly.',
|
||||
effects: [
|
||||
{ path: 'relationships.partner.regard', op: 'add', value: 6 },
|
||||
{ path: 'relationships.partner.trust', op: 'add', value: 2 },
|
||||
moment('Carried a parcel for {partner}\'s sister, and made a friend of her.'),
|
||||
],
|
||||
},
|
||||
failure: {
|
||||
result: 'You drip on the carpet and say something about the weather twice.',
|
||||
effects: [{ path: 'relationships.partner.regard', op: 'add', value: 1 }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'hand_it_on',
|
||||
label: 'Hand it on to {clerk}, who is going that way.',
|
||||
result: '{clerk} goes, and gets the tea, and does not say whose errand it was.',
|
||||
effects: [
|
||||
{ path: 'relationships.{clerk}.regard', op: 'add', value: -3 },
|
||||
{ path: 'relationships.partner.regard', op: 'add', value: -2 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'window_desk',
|
||||
stage: 'desk',
|
||||
weight: 20,
|
||||
cooldown: 10,
|
||||
involves: ['clerk'],
|
||||
description:
|
||||
'The desk by the window has come free. {chief} says it will go to whoever ' +
|
||||
'deserves it, and {clerk} has been in before the clock all week.',
|
||||
options: [
|
||||
{
|
||||
id: 'outwork',
|
||||
label: 'Come in earlier than {clerk} does.',
|
||||
check: { attribute: 'resolve', against: '{clerk}' },
|
||||
success: {
|
||||
result: 'You are there before the cleaners. The desk is yours, and so is the draught.',
|
||||
effects: [
|
||||
{ path: 'standing', op: 'add', value: 4 },
|
||||
{ path: 'relationships.{clerk}.regard', op: 'add', value: -3 },
|
||||
moment('Won the desk by the window from {clerk}.'),
|
||||
],
|
||||
},
|
||||
failure: {
|
||||
result: '{clerk} was there before you, every morning, and has the desk.',
|
||||
effects: [{ path: 'standing', op: 'add', value: -1 }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'word_with_chief',
|
||||
label: 'Have a quiet word with {chief}.',
|
||||
check: { attribute: 'charm', against: 'chief', opposedBy: 'wits', relationship: 'regard' },
|
||||
success: {
|
||||
result: '{chief} decides the matter in your favour, and makes sure {clerk} knows why.',
|
||||
effects: [
|
||||
{ path: 'standing', op: 'add', value: 3 },
|
||||
{ path: 'relationships.{clerk}.trust', op: 'add', value: -6 },
|
||||
],
|
||||
},
|
||||
failure: {
|
||||
result: '{chief} writes your name down, next to the word "canvassing".',
|
||||
effects: [
|
||||
{ path: 'relationships.chief.trust', op: 'add', value: -4 },
|
||||
{ path: 'standing', op: 'add', value: -2 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'let_them_have_it',
|
||||
label: 'Let {clerk} have it.',
|
||||
result: '{clerk} takes the desk and, to your surprise, remembers who did not fight for it.',
|
||||
effects: [
|
||||
{ path: 'relationships.{clerk}.regard', op: 'add', value: 5 },
|
||||
{ path: 'relationships.{clerk}.trust', op: 'add', value: 3 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'hard_times',
|
||||
stage: 'desk',
|
||||
weight: 400,
|
||||
cooldown: 2,
|
||||
requires: [{ path: 'money', op: '<', value: 0 }],
|
||||
involves: ['clerk'],
|
||||
description:
|
||||
'The landlady has knocked twice about the week, and the purse under your ' +
|
||||
'pillow is empty. It is a long walk to Saturday.',
|
||||
options: [
|
||||
{
|
||||
id: 'copying',
|
||||
label: 'Take in copying work to do at night.',
|
||||
result: 'Law papers, by lamp, at a halfpenny a folio. It keeps the landlady quiet.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: 8 },
|
||||
{ path: 'attributes.resolve', op: 'add', value: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'borrow',
|
||||
label: 'Ask {clerk} for the loan of ten dollars.',
|
||||
check: { attribute: 'charm', against: '{clerk}', opposedBy: 'resolve', relationship: 'trust' },
|
||||
success: {
|
||||
result: '{clerk} lends it, and tells you not to mention it, and you will not forget.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: 10 },
|
||||
{ path: 'owed', op: 'add', value: 10 },
|
||||
{ path: 'relationships.{clerk}.trust', op: 'add', value: -2 },
|
||||
],
|
||||
},
|
||||
failure: {
|
||||
result: '{clerk} is short too, but finds you three, and makes a face about it.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: 3 },
|
||||
{ path: 'owed', op: 'add', value: 3 },
|
||||
{ path: 'relationships.{clerk}.regard', op: 'add', value: -3 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'pawn_the_watch',
|
||||
label: 'Pawn your father\'s watch.',
|
||||
requires: [{ path: 'flags.pawned_watch', op: '==', value: false }],
|
||||
whenLocked: 'hide',
|
||||
result: 'Twelve dollars and a ticket. You will get it back. Everybody says that.',
|
||||
effects: [
|
||||
{ path: 'money', op: 'add', value: 12 },
|
||||
{ path: 'flags.pawned_watch', op: 'set', value: true },
|
||||
moment('Pawned your father\'s watch to pay the landlady.'),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default events;
|
||||
@@ -0,0 +1,136 @@
|
||||
// The Counting House — a prototype pack for checks and a drawn cast.
|
||||
//
|
||||
// Harrow & Pike, a trading firm's counting house, somewhere about 1910. You
|
||||
// are the newest clerk. The situations are the ones public-domain office
|
||||
// fiction kept returning to (tools/story-to-pack, catalogue v3); the people are
|
||||
// drawn fresh each run from several candidates per role, two to four clerks
|
||||
// among them; and most choices are a check, shown to the player only as odds
|
||||
// in words. See DECISIONS #41.
|
||||
//
|
||||
// One stage, deliberately: this pack exists to find out whether chance and a
|
||||
// varying cast make the same situations worth replaying, before a whole career
|
||||
// is built on them.
|
||||
|
||||
import { characters } from './characters.js';
|
||||
import { events } from './events.js';
|
||||
|
||||
export const countingHouse = {
|
||||
id: 'countinghouse',
|
||||
name: 'The Counting House',
|
||||
blurb:
|
||||
'Harrow & Pike, about 1910: a stool, a pen, a chief clerk with red ink, and a ' +
|
||||
'different office every time you start.',
|
||||
version: '0.1.0',
|
||||
turnUnit: 'day',
|
||||
startingStage: 'desk',
|
||||
peopleHeading: 'Harrow & Pike',
|
||||
|
||||
stages: {
|
||||
desk: { label: 'The Desk', title: 'Junior Clerk' },
|
||||
},
|
||||
|
||||
// A six-day week: the office shuts at one on Saturday, and that is pay day.
|
||||
calendar: {
|
||||
cycleLength: 6,
|
||||
cycleName: 'Week',
|
||||
unitNames: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'],
|
||||
},
|
||||
|
||||
// The four attributes, the same for the player and for everyone in the cast.
|
||||
// A pack may call them what it likes; these are the prototype's names.
|
||||
attributes: {
|
||||
nerve: { label: 'Nerve' },
|
||||
wits: { label: 'Wits' },
|
||||
charm: { label: 'Charm' },
|
||||
resolve: { label: 'Resolve' },
|
||||
},
|
||||
|
||||
// How attributes turn into chances. The player sees only the words.
|
||||
// chance = 50 + 8 x (yours - theirs) + 0.4 x (their regard - 50), from 5 to 95
|
||||
// A new clerk (3) against a sharp partner (8) is 10%, a long shot; against an
|
||||
// ordinary task (4) it is 42%. Each point of training is worth eight.
|
||||
checks: {
|
||||
base: 50,
|
||||
perPoint: 8,
|
||||
unopposed: 4,
|
||||
relationshipPerPoint: 0.4,
|
||||
relationshipCentre: 50,
|
||||
floor: 5,
|
||||
ceiling: 95,
|
||||
odds: [
|
||||
{ upTo: 20, words: 'a long shot' },
|
||||
{ upTo: 40, words: 'unlikely' },
|
||||
{ upTo: 60, words: 'an even chance' },
|
||||
{ upTo: 80, words: 'likely' },
|
||||
{ upTo: 95, words: 'a safe bet' },
|
||||
],
|
||||
said: { success: 'and it came off.', failure: 'and it did not come off.' },
|
||||
},
|
||||
|
||||
state: {
|
||||
'money': { initial: 20, min: -200, max: 100000 },
|
||||
'standing': { initial: 40, min: 0, max: 100 },
|
||||
'attributes.nerve': { initial: 3, min: 0, max: 10 },
|
||||
'attributes.wits': { initial: 3, min: 0, max: 10 },
|
||||
'attributes.charm': { initial: 3, min: 0, max: 10 },
|
||||
'attributes.resolve': { initial: 3, min: 0, max: 10 },
|
||||
// Borrowed from the other clerks when times are hard, repaid on a Saturday.
|
||||
'owed': { initial: 0, min: 0, max: 100000 },
|
||||
// Rises won from the partner: each is a permanent addition to the wage.
|
||||
'rises': { initial: 0, min: 0, max: 3 },
|
||||
'flags.pawned_watch': { initial: false },
|
||||
'notable': { initial: [] },
|
||||
},
|
||||
|
||||
// Two axes, not one: whether they believe you, and what they think of you.
|
||||
relationshipStats: {
|
||||
trust: { label: 'Trusts you', initial: 50, min: 0, max: 100 },
|
||||
regard: { label: 'Thinks well of you', initial: 50, min: 0, max: 100 },
|
||||
},
|
||||
|
||||
characters,
|
||||
events,
|
||||
|
||||
// Per six-day week: -12 board and lodging +16 wages = +4.
|
||||
// Each rise adds four; a rise is earned, so it is outside the baseline.
|
||||
upkeep: [
|
||||
{ label: 'Board and lodging', effects: [{ path: 'money', op: 'add', value: -2 }] },
|
||||
{
|
||||
label: 'Wages from Harrow & Pike',
|
||||
every: 6,
|
||||
offset: 5,
|
||||
effects: [{ path: 'money', op: 'add', value: 16 }],
|
||||
},
|
||||
...[1, 2, 3].map((n) => ({
|
||||
label: n === 1 ? 'Your rise' : `Your rise (${n})`,
|
||||
every: 6,
|
||||
offset: 5,
|
||||
requires: [{ path: 'rises', op: '>=', value: n }],
|
||||
effects: [{ path: 'money', op: 'add', value: 4 }],
|
||||
})),
|
||||
],
|
||||
|
||||
tuning: {
|
||||
baselinePerCycle: { desk: [0, 10] },
|
||||
baselineExcludes: ['rises'],
|
||||
},
|
||||
|
||||
display: {
|
||||
'money': { label: 'Money', format: 'money', order: 1 },
|
||||
'owed': { label: 'Owed to the floor', format: 'money', order: 2, hideWhenZero: true },
|
||||
'standing': { label: 'Standing', format: 'meter', order: 2 },
|
||||
'attributes.nerve': { label: 'Nerve', format: 'meter', order: 3 },
|
||||
'attributes.wits': { label: 'Wits', format: 'meter', order: 4 },
|
||||
'attributes.charm': { label: 'Charm', format: 'meter', order: 5 },
|
||||
'attributes.resolve': { label: 'Resolve', format: 'meter', order: 6 },
|
||||
'rises': { label: 'Rises', order: 7, hideWhenZero: true },
|
||||
'notable': { hidden: true, inLedger: false },
|
||||
'flags.pawned_watch': {
|
||||
label: 'Your father\'s watch', hidden: true,
|
||||
whenTrue: 'Your father\'s watch is at the pawnbroker\'s.',
|
||||
whenFalse: 'You have your father\'s watch back.',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default countingHouse;
|
||||
+2
-1
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
// Who is in this run.
|
||||
//
|
||||
// A pack's characters are fixed people by default: a name and a description,
|
||||
// written once. A character may instead be a *role* with `candidates` — several
|
||||
// authored people who could fill it — and one is drawn when a run starts. A role
|
||||
// may also be a *group* with a `count` range, like two to four co-workers, in
|
||||
// which case that many different candidates are drawn into numbered slots.
|
||||
// Replaying a pack therefore meets a different cast, which is the point.
|
||||
//
|
||||
// Slots are what state is keyed by: a single role is its own id (`boss`), a
|
||||
// group's members are `coworker_1` .. `coworker_<max>`. Every slot up to the
|
||||
// group's maximum exists in the schema from the start, so relationship state
|
||||
// is declared, validated and saved like any other; a slot nobody was drawn into
|
||||
// is simply not present in the run. See DECISIONS #41.
|
||||
//
|
||||
// The draw uses its own stream derived from the run's seed, so it neither moves
|
||||
// the event generator nor depends on it: a pack that gains a cast does not
|
||||
// change which events any other pack draws, and a save that predates the cast
|
||||
// can have one drawn for it on load, reproducibly.
|
||||
|
||||
import { createRng } from './rng.js';
|
||||
import { getPath } from './paths.js';
|
||||
|
||||
/** The count range of a group role, or null for a single character. */
|
||||
export function groupRange(character) {
|
||||
if (character.count === undefined) return null;
|
||||
return Array.isArray(character.count) ? character.count : [character.count, character.count];
|
||||
}
|
||||
|
||||
/** Every state slot a character occupies: its own id, or one per possible group member. */
|
||||
export function slotsOf(character) {
|
||||
const range = groupRange(character);
|
||||
if (!range) return [character.id];
|
||||
return Array.from({ length: range[1] }, (_, i) => `${character.id}_${i + 1}`);
|
||||
}
|
||||
|
||||
/** Whether a pack draws any part of its cast per run. */
|
||||
export function hasDrawnCast(pack) {
|
||||
return (pack.characters ?? []).some((c) => c.candidates?.length || groupRange(c));
|
||||
}
|
||||
|
||||
/** The authored person behind a candidate entry, in the shape the state keeps. */
|
||||
function personOf(character, candidate, index) {
|
||||
return {
|
||||
role: character.id,
|
||||
candidate: index,
|
||||
name: candidate.name,
|
||||
title: candidate.title ?? character.title ?? null,
|
||||
description: candidate.description ?? '',
|
||||
attributes: { ...(candidate.attributes ?? {}) },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw the cast for a run. Returns `{ <slot>: person | null }` for every slot of
|
||||
* every drawn role; a group slot left empty is `null`, which is how "not in this
|
||||
* run" is recorded. Fixed characters are not in it: they need no drawing.
|
||||
*/
|
||||
export function drawCast(pack, seed) {
|
||||
const rng = createRng(`cast:${seed}`);
|
||||
const cast = {};
|
||||
for (const character of pack.characters ?? []) {
|
||||
const pool = character.candidates ?? [];
|
||||
const range = groupRange(character);
|
||||
if (!range && !pool.length) continue;
|
||||
|
||||
const slots = slotsOf(character);
|
||||
const wanted = range ? range[0] + rng.int(range[1] - range[0] + 1) : 1;
|
||||
const remaining = pool.map((candidate, index) => ({ candidate, index }));
|
||||
for (let i = 0; i < slots.length; i++) {
|
||||
if (i >= wanted || !remaining.length) {
|
||||
cast[slots[i]] = null;
|
||||
continue;
|
||||
}
|
||||
const [picked] = remaining.splice(rng.int(remaining.length), 1);
|
||||
cast[slots[i]] = personOf(character, picked.candidate, picked.index);
|
||||
}
|
||||
}
|
||||
return cast;
|
||||
}
|
||||
|
||||
/** Starting relationship overrides a drawn person brings with them. */
|
||||
export function drawnRelationships(pack, cast) {
|
||||
const overrides = [];
|
||||
for (const character of pack.characters ?? []) {
|
||||
for (const slot of slotsOf(character)) {
|
||||
const person = cast?.[slot];
|
||||
if (!person) continue;
|
||||
const candidate = character.candidates?.[person.candidate];
|
||||
for (const [stat, value] of Object.entries(candidate?.startingRelationship ?? {})) {
|
||||
overrides.push({ path: `relationships.${slot}.${stat}`, value });
|
||||
}
|
||||
}
|
||||
}
|
||||
return overrides;
|
||||
}
|
||||
|
||||
/** The character definition that owns a slot. */
|
||||
export function characterForSlot(pack, slot) {
|
||||
for (const character of pack.characters ?? []) {
|
||||
if (slotsOf(character).includes(slot)) return character;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The person in a slot as the interface and the checks see them: name, title,
|
||||
* description and attributes, whether drawn for this run or written into the
|
||||
* pack. Undefined if the slot is empty this run or unknown.
|
||||
*/
|
||||
export function personIn(pack, state, slot) {
|
||||
const character = characterForSlot(pack, slot);
|
||||
if (!character) return undefined;
|
||||
const drawn = getPath(state, `cast.${slot}`);
|
||||
if (drawn === null) return undefined;
|
||||
if (drawn) return { id: slot, ...drawn };
|
||||
if (character.candidates?.length || groupRange(character)) return undefined;
|
||||
return {
|
||||
id: slot,
|
||||
role: character.id,
|
||||
name: character.name,
|
||||
title: character.title ?? null,
|
||||
description: character.description ?? '',
|
||||
attributes: { ...(character.attributes ?? {}) },
|
||||
};
|
||||
}
|
||||
|
||||
/** Everyone present in this run, in pack order. */
|
||||
export function presentPeople(pack, state) {
|
||||
return (pack.characters ?? []).flatMap((character) =>
|
||||
slotsOf(character).map((slot) => personIn(pack, state, slot)).filter(Boolean));
|
||||
}
|
||||
|
||||
/** The slots of a role that are filled in this run. */
|
||||
export function presentSlots(pack, state, roleId) {
|
||||
const character = (pack.characters ?? []).find((c) => c.id === roleId);
|
||||
if (!character) return [];
|
||||
return slotsOf(character).filter((slot) => personIn(pack, state, slot));
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// Options whose outcome is not certain.
|
||||
//
|
||||
// An option may carry a `check`: which of the player's attributes it tests,
|
||||
// optionally *against* a character who opposes it with their own attribute, and
|
||||
// optionally leaning on how that character regards the player. It then has a
|
||||
// `success` and a `failure`, each with its own result text and effects; the
|
||||
// option's plain `effects` apply either way. The engine works out the chance,
|
||||
// rolls on the run's own generator, and records which way it went.
|
||||
//
|
||||
// The engine knows the arithmetic and nothing about what any attribute means:
|
||||
// the numbers are the pack's (`pack.checks`), the attribute names are the
|
||||
// pack's, and so are the words the player sees in place of a percentage. A
|
||||
// player is shown "a long shot" or "likely", never a number. See DECISIONS #41.
|
||||
//
|
||||
// chance = base + perPoint × (yours − theirs)
|
||||
// + relationshipPerPoint × (their regard − relationshipCentre)
|
||||
// + modifier, clamped to [floor, ceiling]
|
||||
//
|
||||
// where "theirs" is the opposing character's attribute when the check is
|
||||
// `against` someone, and the check's `difficulty` (or the pack's `unopposed`)
|
||||
// when it is not.
|
||||
|
||||
import { getPath } from './paths.js';
|
||||
import { personIn } from './cast.js';
|
||||
import { resolveSlot } from './text.js';
|
||||
|
||||
/** Odds words used when a pack does not name its own. Upper bounds, in percent. */
|
||||
export const DEFAULT_ODDS = [
|
||||
{ upTo: 20, words: 'a long shot' },
|
||||
{ upTo: 40, words: 'unlikely' },
|
||||
{ upTo: 60, words: 'an even chance' },
|
||||
{ upTo: 80, words: 'likely' },
|
||||
{ upTo: 100, words: 'a safe bet' },
|
||||
];
|
||||
|
||||
/** The words for a chance, from the pack's bands. */
|
||||
export function oddsWords(pack, chance) {
|
||||
const bands = pack.checks?.odds ?? DEFAULT_ODDS;
|
||||
return (bands.find((band) => chance <= band.upTo) ?? bands[bands.length - 1]).words;
|
||||
}
|
||||
|
||||
/** The slot a check is made against this turn, if any. */
|
||||
function opponentSlot(check, bindings) {
|
||||
if (!check.against) return undefined;
|
||||
const id = check.against.replace(/^\{|\}$/g, '');
|
||||
return resolveSlot(id, bindings);
|
||||
}
|
||||
|
||||
/**
|
||||
* The chance of an option's check succeeding in this state, as a whole
|
||||
* percentage, with the parts it was built from. Undefined for an option with
|
||||
* no check.
|
||||
*/
|
||||
export function chanceOf(pack, state, option, bindings = state?.meta?.bindings ?? {}) {
|
||||
const check = option.check;
|
||||
if (!check) return undefined;
|
||||
const rules = pack.checks ?? {};
|
||||
const yours = getPath(state, `attributes.${check.attribute}`) ?? 0;
|
||||
|
||||
const slot = opponentSlot(check, bindings);
|
||||
const opponent = slot ? personIn(pack, state, slot) : undefined;
|
||||
const theirs = opponent
|
||||
? opponent.attributes?.[check.opposedBy ?? check.attribute] ?? 0
|
||||
: check.difficulty ?? rules.unopposed ?? 0;
|
||||
|
||||
let regard = 0;
|
||||
if (check.relationship && slot) {
|
||||
const value = getPath(state, `relationships.${slot}.${check.relationship}`);
|
||||
if (typeof value === 'number') {
|
||||
regard = (rules.relationshipPerPoint ?? 0) * (value - (rules.relationshipCentre ?? 50));
|
||||
}
|
||||
}
|
||||
|
||||
const raw = (rules.base ?? 50) + (rules.perPoint ?? 0) * (yours - theirs) + regard + (check.modifier ?? 0);
|
||||
const chance = Math.round(Math.min(rules.ceiling ?? 95, Math.max(rules.floor ?? 5, raw)));
|
||||
return { chance, words: oddsWords(pack, chance), yours, theirs, against: slot ?? null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll a check. `rng` is the run's generator; exactly one number is drawn, so
|
||||
* a replay from the same seed and choices rolls the same way.
|
||||
*/
|
||||
export function rollCheck(pack, state, option, rng, bindings) {
|
||||
const odds = chanceOf(pack, state, option, bindings);
|
||||
const roll = rng.next() * 100;
|
||||
return { ...odds, succeeded: roll < odds.chance };
|
||||
}
|
||||
+36
-4
@@ -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) => ({
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
+23
-3
@@ -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 ?? []) {
|
||||
// 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.${character.id}.${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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
+178
-11
@@ -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 ?? []) {
|
||||
const checkEffects = (at, effects) => {
|
||||
for (const effect of effects ?? []) {
|
||||
if (!OPERATIONS.includes(effect.op)) {
|
||||
fail(optionWhere, `unknown effect operation ${JSON.stringify(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(effect.value.from);
|
||||
if (problem) fail(optionWhere, `effect "${effect.path}" reads its amount from ${problem}`);
|
||||
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(optionWhere, `effect "${effect.path}" has a non-numeric ${key}`);
|
||||
fail(at, `effect "${effect.path}" has a non-numeric ${key}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fail(optionWhere, `effect "${effect.path}" uses ${effect.op} with a non-numeric value`);
|
||||
fail(at, `effect "${effect.path}" uses ${effect.op} with a non-numeric value`);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const effect of option.effects ?? []) {
|
||||
for (const effect of effects ?? []) {
|
||||
if (typeof effect.path !== 'string') continue;
|
||||
const problem = writable(effect.path, effect, optionWhere);
|
||||
if (problem) fail(optionWhere, `effect writes ${problem}`);
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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;
|
||||
@@ -157,6 +162,7 @@ for (const pack of packs) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(balances.length > 0 || pack.display === undefined, 'a pack with a balance should declare it as money');
|
||||
assert.deepEqual(overcharging, [], 'options that can charge for more than is owed');
|
||||
});
|
||||
|
||||
Vendored
+114
@@ -0,0 +1,114 @@
|
||||
// A small pack for the cast and checks (DECISIONS #41): a drawn single role, a
|
||||
// group role of two to four, a fixed character with attributes, opposed and
|
||||
// unopposed checks, and an event about "one of the co-workers". Its checks run
|
||||
// from 0 to 100 so tests can force an outcome; a real pack keeps a floor and a
|
||||
// ceiling so nothing is ever certain.
|
||||
|
||||
export const castPack = {
|
||||
id: 'castpack',
|
||||
name: 'Cast Pack',
|
||||
version: '1.0.0',
|
||||
turnUnit: 'day',
|
||||
startingStage: 'desk',
|
||||
|
||||
attributes: {
|
||||
nerve: { label: 'Nerve' },
|
||||
wits: { label: 'Wits' },
|
||||
charm: { label: 'Charm' },
|
||||
resolve: { label: 'Resolve' },
|
||||
},
|
||||
|
||||
checks: {
|
||||
base: 50, perPoint: 10, unopposed: 5,
|
||||
relationshipPerPoint: 0.5, relationshipCentre: 50,
|
||||
floor: 0, ceiling: 100,
|
||||
},
|
||||
|
||||
state: {
|
||||
'money': { initial: 100, min: -500, max: 10000 },
|
||||
'attributes.nerve': { initial: 5, min: 0, max: 10 },
|
||||
'attributes.wits': { initial: 5, min: 0, max: 10 },
|
||||
'attributes.charm': { initial: 5, min: 0, max: 10 },
|
||||
'attributes.resolve': { initial: 5, min: 0, max: 10 },
|
||||
},
|
||||
|
||||
relationshipStats: {
|
||||
trust: { label: 'Trusts you', initial: 50, min: 0, max: 100 },
|
||||
regard: { label: 'Thinks of you', initial: 50, min: 0, max: 100 },
|
||||
},
|
||||
|
||||
characters: [
|
||||
{
|
||||
id: 'boss',
|
||||
role_type: 'boss',
|
||||
title: 'Manager',
|
||||
candidates: [
|
||||
{ name: 'Agnes Pell', description: 'Reads every memo twice.', attributes: { nerve: 6, wits: 8, charm: 3, resolve: 7 } },
|
||||
{ name: 'Walter Grose', description: 'Loud, and quicker than he looks.', attributes: { nerve: 7, wits: 6, charm: 6, resolve: 4 },
|
||||
startingRelationship: { trust: 30 } },
|
||||
{ name: 'Mina Hart', description: 'Has never raised her voice.', attributes: { nerve: 4, wits: 7, charm: 8, resolve: 6 } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'coworker',
|
||||
role_type: 'peer',
|
||||
title: 'Clerk',
|
||||
count: [2, 4],
|
||||
candidates: [
|
||||
{ name: 'Ben Aske', description: 'Owes {boss} a favour.', attributes: { nerve: 3, wits: 4, charm: 6, resolve: 5 } },
|
||||
{ name: 'Cora Dunn', description: 'First in, last out.', attributes: { nerve: 5, wits: 5, charm: 4, resolve: 8 } },
|
||||
{ name: 'Del Fane', description: 'Knows everyone\'s business.', attributes: { nerve: 4, wits: 7, charm: 5, resolve: 3 } },
|
||||
{ name: 'Edie Gow', description: 'Quiet until she is not.', attributes: { nerve: 7, wits: 5, charm: 3, resolve: 6 } },
|
||||
{ name: 'Fitz Hale', description: 'Charming, unreliable.', attributes: { nerve: 5, wits: 3, charm: 8, resolve: 2 } },
|
||||
],
|
||||
},
|
||||
{ id: 'porter', name: 'Old Sam', role_type: 'staff', description: 'Has seen everyone come and go.',
|
||||
attributes: { nerve: 2, wits: 9, charm: 4, resolve: 9 } },
|
||||
],
|
||||
|
||||
events: [
|
||||
{
|
||||
id: 'routine',
|
||||
stage: 'desk',
|
||||
weight: 100,
|
||||
description: 'A day at the desk under {boss}.',
|
||||
options: [
|
||||
{ id: 'work', label: 'Work.', effects: [{ path: 'money', op: 'add', value: 10 }] },
|
||||
{
|
||||
id: 'ask_for_more',
|
||||
label: 'Ask {boss.first} for more money.',
|
||||
check: { attribute: 'charm', against: 'boss', opposedBy: 'wits', relationship: 'trust' },
|
||||
effects: [{ path: 'relationships.boss.regard', op: 'add', value: -1 }],
|
||||
success: { result: '{boss} agrees.', effects: [{ path: 'money', op: 'add', value: 50 }] },
|
||||
failure: { result: '{boss} laughs.', effects: [{ path: 'relationships.boss.trust', op: 'add', value: -5 }] },
|
||||
},
|
||||
{
|
||||
id: 'hard_sum',
|
||||
label: 'Try the hard sum.',
|
||||
check: { attribute: 'wits', difficulty: 5 },
|
||||
success: { result: 'It adds up.', effects: [{ path: 'attributes.wits', op: 'add', value: 1 }] },
|
||||
failure: { result: 'It does not.', effects: [] },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'needled',
|
||||
stage: 'desk',
|
||||
weight: 100,
|
||||
involves: ['coworker'],
|
||||
description: '{coworker} makes a remark about your tie.',
|
||||
options: [
|
||||
{ id: 'ignore', label: 'Let it go.', effects: [{ path: 'relationships.{coworker}.regard', op: 'add', value: -2 }] },
|
||||
{
|
||||
id: 'retort',
|
||||
label: 'Answer {coworker.first} back.',
|
||||
check: { attribute: 'nerve', against: '{coworker}', relationship: 'regard' },
|
||||
success: { result: '{coworker} laughs, and means it.', effects: [{ path: 'relationships.{coworker}.regard', op: 'add', value: 5 }] },
|
||||
failure: { result: 'It lands badly.', effects: [{ path: 'relationships.{coworker}.trust', op: 'add', value: -5 }] },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default castPack;
|
||||
Vendored
+24
-7
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user