Make standing cost money to keep, in both settings
Each pack declares a lifestyle tier, bought on the second rung and charged for every cycle after: Corporate Ladder's car and address, the Frontier's bay and the room over the Ellis place. Falling behind what the job expects costs standing, in steps that steepen as standing rises. A Dispatch coordinator keeping up fully nets -21 a week and earns the difference out of the days. No engine change; the only interface change is a tier display format. Sized by measurement, not feel (DECISIONS #36): a -2/week penalty is invisible against Dispatch's standing gains, and a player who chases standing every turn beats any flat penalty — 99 standing while buying nothing, and $2,245 richer than one who kept up. Hence the top step. Two defects fixed on the way. The conformance suite excluded debt from the economic baseline by matching /debt|owed|loan/i on path names, which would have counted every lifestyle tier into it; packs now declare tuning.baselineExcludes. And 'the promotion leaves a player better off' pooled end balances across archetypes that do not spend alike, so it measured the mix — it is now derived from the upkeep arithmetic. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RxdJFbLq1rBKsUeticGV1g
This commit is contained in:
co-authored by
Claude Opus 5
parent
7f20be83e8
commit
5d645e6452
@@ -114,10 +114,12 @@ Open questions, in the order they are likely to matter:
|
|||||||
evidence that was not enough the last two times. It needs a session before its
|
evidence that was not enough the last two times. It needs a session before its
|
||||||
numbers are believed.
|
numbers are believed.
|
||||||
|
|
||||||
- **Money stops mattering late, and it should not.** A careful player ends
|
- **Standing now costs money to keep, and nobody has played it.** Each setting
|
||||||
around $8,300 over 200 turns. The direction is set: standing should cost
|
has a `lifestyle` tier bought on the second rung and charged for every cycle
|
||||||
money to keep — a better car, a better place to live, better clothes — so
|
after — and a standing penalty, steepening as standing rises, for lagging
|
||||||
expenses climb as the player rises. Not built yet.
|
behind what the job expects. A Dispatch coordinator keeping up fully runs at
|
||||||
|
-$21 a week and has to earn the difference. It is measured (DECISIONS #36)
|
||||||
|
and, like everything else here, unplayed.
|
||||||
- **Dispatch is eleven events against the mailroom's nineteen.** It passes the
|
- **Dispatch is eleven events against the mailroom's nineteen.** It passes the
|
||||||
variety guard, but it has had far less play than the mailroom. Waits on more
|
variety guard, but it has had far less play than the mailroom. Waits on more
|
||||||
playtesting.
|
playtesting.
|
||||||
@@ -126,7 +128,8 @@ Open questions, in the order they are likely to matter:
|
|||||||
Waits until the Frontier has been played.
|
Waits until the Frontier has been played.
|
||||||
|
|
||||||
Settled, and not open: Corporate Ladder's negotiation gate stays as it is, and
|
Settled, and not open: Corporate Ladder's negotiation gate stays as it is, and
|
||||||
the feedback widget stays as it is (DECISIONS #35).
|
the feedback widget stays as it is (DECISIONS #35). Money mattering late is
|
||||||
|
built rather than open (DECISIONS #36).
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
|
|||||||
@@ -485,6 +485,65 @@ export const dispatch = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
// The cost of standing, offered rather than imposed. Buying nothing is
|
||||||
|
// always playable and always cheaper; it is Dispatch's weekly "looking the
|
||||||
|
// part" charge that makes it a real decision instead of a shop.
|
||||||
|
id: 'the_car_lot',
|
||||||
|
stage: 'dispatch',
|
||||||
|
weight: 45,
|
||||||
|
cooldown: 6,
|
||||||
|
requires: [{ path: 'lifestyle', op: '<', value: 2 }],
|
||||||
|
description:
|
||||||
|
'Marlene mentions, not quite to you, that the coordinator before last ' +
|
||||||
|
'drove something with a roof rack, and that people noticed when he ' +
|
||||||
|
'stopped. There is a lot on Ninth with a sign about payments.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'buy_the_car',
|
||||||
|
label: 'Put money down on the car.',
|
||||||
|
requires: [
|
||||||
|
{ path: 'lifestyle', op: '==', value: 0 },
|
||||||
|
{ path: 'money', op: '>=', value: 450 },
|
||||||
|
],
|
||||||
|
result:
|
||||||
|
'It is four years old and the radio only gets one station, and on ' +
|
||||||
|
'Monday two people who have never spoken to you ask about it.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: -450 },
|
||||||
|
{ path: 'lifestyle', op: 'set', value: 1 },
|
||||||
|
{ path: 'reputation', op: 'add', value: 3 },
|
||||||
|
{ path: 'notable', op: 'push', value: 'Bought a car with a roof rack.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'trade_up',
|
||||||
|
label: 'Trade up, and take the lease on the better side of the park.',
|
||||||
|
requires: [
|
||||||
|
{ path: 'lifestyle', op: '==', value: 1 },
|
||||||
|
{ path: 'money', op: '>=', value: 900 },
|
||||||
|
],
|
||||||
|
result:
|
||||||
|
'The address is two stops closer and four hundred a month dearer. ' +
|
||||||
|
'Nobody says anything about it, which is the entire point of it.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: -900 },
|
||||||
|
{ path: 'lifestyle', op: 'set', value: 2 },
|
||||||
|
{ path: 'reputation', op: 'add', value: 5 },
|
||||||
|
{ path: 'notable', op: 'push', value: 'Moved to the better side of the park.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'keep_the_bus_pass',
|
||||||
|
label: 'Keep the bus pass. The bus is fine.',
|
||||||
|
result:
|
||||||
|
'The bus is fine. You read two chapters a day on it, which is two ' +
|
||||||
|
'more than anybody on Ninth manages.',
|
||||||
|
effects: [{ path: 'skills.logistics', op: 'add', value: 1 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default dispatch;
|
export default dispatch;
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ export const corporateLadder = {
|
|||||||
'reputation': { initial: 50, min: 0, max: 100 },
|
'reputation': { initial: 50, min: 0, max: 100 },
|
||||||
'skills.negotiation': { initial: 5, min: 0, max: 100 },
|
'skills.negotiation': { initial: 5, min: 0, max: 100 },
|
||||||
'skills.logistics': { initial: 5, min: 0, max: 100 },
|
'skills.logistics': { initial: 5, min: 0, max: 100 },
|
||||||
|
// What standing costs to keep. Nothing forces a player to climb this, but
|
||||||
|
// Dispatch charges standing for staying at the bottom of it — see the
|
||||||
|
// "Looking the part" entries in upkeep, and DECISIONS #36.
|
||||||
|
'lifestyle': { initial: 0, min: 0, max: 2 },
|
||||||
'flags.took_loan': { initial: false },
|
'flags.took_loan': { initial: false },
|
||||||
'flags.holds_the_envelope': { initial: false },
|
'flags.holds_the_envelope': { initial: false },
|
||||||
'flags.covered_for_trevor': { initial: false },
|
'flags.covered_for_trevor': { initial: false },
|
||||||
@@ -96,6 +100,76 @@ export const corporateLadder = {
|
|||||||
effects: [{ path: 'money', op: 'add', value: 1260 }],
|
effects: [{ path: 'money', op: 'add', value: 1260 }],
|
||||||
},
|
},
|
||||||
{ label: 'Rent', every: 7, offset: 7, effects: [{ path: 'money', op: 'add', value: -455 }] },
|
{ label: 'Rent', every: 7, offset: 7, effects: [{ path: 'money', op: 'add', value: -455 }] },
|
||||||
|
// What the job expects of you, and what meeting that expectation costs.
|
||||||
|
// Dispatch nets +49 a week; a coordinator with the car is +19, and one
|
||||||
|
// with the car and the address is -21 and has to earn the difference out
|
||||||
|
// of the days themselves. That is the answer to "money stops mattering
|
||||||
|
// late": it stops mattering only if standing is free to keep.
|
||||||
|
{
|
||||||
|
label: 'Car payment and parking',
|
||||||
|
every: 7,
|
||||||
|
offset: 7,
|
||||||
|
requires: [{ path: 'lifestyle', op: '>=', value: 1 }],
|
||||||
|
effects: [{ path: 'money', op: 'add', value: -30 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'The better address',
|
||||||
|
every: 7,
|
||||||
|
offset: 7,
|
||||||
|
requires: [{ path: 'lifestyle', op: '>=', value: 2 }],
|
||||||
|
effects: [{ path: 'money', op: 'add', value: -40 }],
|
||||||
|
},
|
||||||
|
// The pressure that makes the spending a choice rather than a vanity. Both
|
||||||
|
// are gated on Dispatch: the mailroom pays too little for this to be
|
||||||
|
// anything but a trap, and "later in a career" is the point.
|
||||||
|
{
|
||||||
|
label: 'Looking the part',
|
||||||
|
every: 7,
|
||||||
|
offset: 7,
|
||||||
|
requires: [
|
||||||
|
{ path: 'stage', op: '==', value: 'dispatch' },
|
||||||
|
{ path: 'lifestyle', op: '<', value: 1 },
|
||||||
|
],
|
||||||
|
effects: [{ path: 'reputation', op: 'add', value: -4 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Standing above sixty-five is a thing you are seen to be, and being seen
|
||||||
|
// to be it runs on a better address than a coordinator's first one.
|
||||||
|
//
|
||||||
|
// Sized by measurement, not by feel. At -2 a week this was invisible:
|
||||||
|
// Dispatch hands out standing several points at a time, so a coordinator
|
||||||
|
// who bought nothing still finished pinned at 100 and the charge may as
|
||||||
|
// well not have existed. At -6, with the entry above, a non-buyer settles
|
||||||
|
// in the low seventies while somebody keeping up finishes at 100 — the
|
||||||
|
// ceiling is real and money is what lifts it.
|
||||||
|
label: 'Looking the part',
|
||||||
|
every: 7,
|
||||||
|
offset: 7,
|
||||||
|
requires: [
|
||||||
|
{ path: 'stage', op: '==', value: 'dispatch' },
|
||||||
|
{ path: 'reputation', op: '>=', value: 65 },
|
||||||
|
{ path: 'lifestyle', op: '<', value: 2 },
|
||||||
|
],
|
||||||
|
effects: [{ path: 'reputation', op: 'add', value: -6 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The top step, and the one that makes the whole thing a choice rather
|
||||||
|
// than a tax on carelessness. Without it, a player who spends every turn
|
||||||
|
// chasing standing simply out-earns the charge: measured, they held 99
|
||||||
|
// while never buying anything, and finished $2,245 richer than a player
|
||||||
|
// who kept up — so the lifestyle was a pure loss to anybody paying
|
||||||
|
// attention. With it they plateau in the low eighties. What money buys
|
||||||
|
// up here is the last fifteen points, which cannot be worked for.
|
||||||
|
label: 'Looking the part',
|
||||||
|
every: 7,
|
||||||
|
offset: 7,
|
||||||
|
requires: [
|
||||||
|
{ path: 'stage', op: '==', value: 'dispatch' },
|
||||||
|
{ path: 'reputation', op: '>=', value: 80 },
|
||||||
|
{ path: 'lifestyle', op: '<', value: 2 },
|
||||||
|
],
|
||||||
|
effects: [{ path: 'reputation', op: 'add', value: -14 }],
|
||||||
|
},
|
||||||
// The service charge steps up with the size of the debt, so the spiral
|
// The service charge steps up with the size of the debt, so the spiral
|
||||||
// tightens instead of flattening out. A step function in data costs the
|
// tightens instead of flattening out. A step function in data costs the
|
||||||
// engine nothing; a percentage would have needed a multiply operation.
|
// engine nothing; a percentage would have needed a multiply operation.
|
||||||
@@ -132,6 +206,9 @@ export const corporateLadder = {
|
|||||||
mailroom: [-45, 5],
|
mailroom: [-45, 5],
|
||||||
dispatch: [5, 125],
|
dispatch: [5, 125],
|
||||||
},
|
},
|
||||||
|
// Upkeep gated on these is excluded from that baseline: both are states a
|
||||||
|
// player opts into, and the baseline is the floor underneath everybody.
|
||||||
|
baselineExcludes: ['debt', 'lifestyle'],
|
||||||
},
|
},
|
||||||
|
|
||||||
// How the interface names and formats this pack's state. The UI reads only
|
// How the interface names and formats this pack's state. The UI reads only
|
||||||
@@ -142,6 +219,12 @@ export const corporateLadder = {
|
|||||||
'reputation': { label: 'Standing', format: 'meter', order: 3 },
|
'reputation': { label: 'Standing', format: 'meter', order: 3 },
|
||||||
'skills.negotiation': { label: 'Negotiation', format: 'meter', order: 4 },
|
'skills.negotiation': { label: 'Negotiation', format: 'meter', order: 4 },
|
||||||
'skills.logistics': { label: 'Logistics', format: 'meter', order: 5 },
|
'skills.logistics': { label: 'Logistics', format: 'meter', order: 5 },
|
||||||
|
// Hidden until it is bought: a mailroom associate has no lifestyle to
|
||||||
|
// speak of, and a row reading "The bus" from day one is noise.
|
||||||
|
'lifestyle': {
|
||||||
|
label: 'Lifestyle', format: 'tier', order: 6, hideWhenZero: true,
|
||||||
|
labels: ['The bus', 'A car', 'The address'],
|
||||||
|
},
|
||||||
// Kept out of the header, and out of the ledger too: `notable` is the
|
// Kept out of the header, and out of the ledger too: `notable` is the
|
||||||
// retrospective's own list, and "Notable — changed" tells nobody anything.
|
// retrospective's own list, and "Notable — changed" tells nobody anything.
|
||||||
'notable': { hidden: true, inLedger: false },
|
'notable': { hidden: true, inLedger: false },
|
||||||
|
|||||||
@@ -579,6 +579,66 @@ export const deputy = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
// Corporate Ladder's car lot, in Cold Fork's terms and at its scale. Same
|
||||||
|
// structure deliberately: an offer, never an obligation, with the weekly
|
||||||
|
// charge for falling short of it sitting in upkeep instead.
|
||||||
|
id: 'the_sale_barn',
|
||||||
|
stage: 'deputy',
|
||||||
|
weight: 45,
|
||||||
|
cooldown: 6,
|
||||||
|
requires: [{ path: 'lifestyle', op: '<', value: 2 }],
|
||||||
|
description:
|
||||||
|
'The sale barn has a bay with four good years in him and a price on ' +
|
||||||
|
'him. Ward says nothing about it, except that the county pays mileage ' +
|
||||||
|
'on a horse a man owns and nothing at all on a hired one.',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'buy_the_bay',
|
||||||
|
label: 'Buy the bay.',
|
||||||
|
requires: [
|
||||||
|
{ path: 'lifestyle', op: '==', value: 0 },
|
||||||
|
{ path: 'money', op: '>=', value: 70 },
|
||||||
|
],
|
||||||
|
result:
|
||||||
|
'He is a better animal than the livery has ever put under you, and ' +
|
||||||
|
'the whole street watches you ride him down it once.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: -70 },
|
||||||
|
{ path: 'lifestyle', op: 'set', value: 1 },
|
||||||
|
{ path: 'standing', op: 'add', value: 3 },
|
||||||
|
{ path: 'notable', op: 'push', value: 'Bought the bay out of the sale barn.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'take_the_room',
|
||||||
|
label: 'Take the room over the Ellis place, with the stove.',
|
||||||
|
requires: [
|
||||||
|
{ path: 'lifestyle', op: '==', value: 1 },
|
||||||
|
{ path: 'money', op: '>=', value: 150 },
|
||||||
|
],
|
||||||
|
result:
|
||||||
|
'A door that locks, a stove, and a window over the street. The ' +
|
||||||
|
'Cordwood does not hold it against you; the Cordwood holds nothing ' +
|
||||||
|
'against anybody who pays up.',
|
||||||
|
effects: [
|
||||||
|
{ path: 'money', op: 'add', value: -150 },
|
||||||
|
{ path: 'lifestyle', op: 'set', value: 2 },
|
||||||
|
{ path: 'standing', op: 'add', value: 5 },
|
||||||
|
{ path: 'notable', op: 'push', value: 'Took the room with the stove over the Ellis place.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ride_the_livery',
|
||||||
|
label: 'Ride what the livery gives you.',
|
||||||
|
result:
|
||||||
|
'A different animal every week, and you learn something about each ' +
|
||||||
|
'one of them in the first mile.',
|
||||||
|
effects: [{ path: 'skills.trade', op: 'add', value: 1 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default deputy;
|
export default deputy;
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ export const frontier = {
|
|||||||
'standing': { initial: 50, min: 0, max: 100 },
|
'standing': { initial: 50, min: 0, max: 100 },
|
||||||
'skills.nerve': { initial: 5, min: 0, max: 100 },
|
'skills.nerve': { initial: 5, min: 0, max: 100 },
|
||||||
'skills.trade': { initial: 5, min: 0, max: 100 },
|
'skills.trade': { initial: 5, min: 0, max: 100 },
|
||||||
|
// The same shape as Corporate Ladder's lifestyle, in this setting's own
|
||||||
|
// terms and at this setting's own scale: what the office expects a man to
|
||||||
|
// turn up on, and what keeping that up costs. See DECISIONS #36.
|
||||||
|
'lifestyle': { initial: 0, min: 0, max: 2 },
|
||||||
'flags.carries_iron': { initial: false },
|
'flags.carries_iron': { initial: false },
|
||||||
'flags.owes_the_store': { initial: false },
|
'flags.owes_the_store': { initial: false },
|
||||||
'flags.covered_for_dutch': { initial: false },
|
'flags.covered_for_dutch': { initial: false },
|
||||||
@@ -112,6 +116,66 @@ export const frontier = {
|
|||||||
requires: [{ path: 'stage', op: '==', value: 'deputy' }],
|
requires: [{ path: 'stage', op: '==', value: 'deputy' }],
|
||||||
effects: [{ path: 'money', op: 'add', value: 38 }],
|
effects: [{ path: 'money', op: 'add', value: 38 }],
|
||||||
},
|
},
|
||||||
|
// What the star is expected to arrive on, and what that expectation costs
|
||||||
|
// to meet. The office nets +18 a run; a deputy on his own horse is +11,
|
||||||
|
// and one with the horse and the room is +4 and makes the rest on the
|
||||||
|
// days. Money is meant to keep mattering after the promotion — but the
|
||||||
|
// first pass charged 9 for the room and left a keeper's median balance at
|
||||||
|
// $8 after 200 turns, which is not pressure, it is the debt trap this pack
|
||||||
|
// was tuned twice to avoid.
|
||||||
|
{
|
||||||
|
label: 'Feed and shoeing',
|
||||||
|
every: 4,
|
||||||
|
offset: 4,
|
||||||
|
requires: [{ path: 'lifestyle', op: '>=', value: 1 }],
|
||||||
|
effects: [{ path: 'money', op: 'add', value: -7 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'The room over the Ellis place',
|
||||||
|
every: 4,
|
||||||
|
offset: 4,
|
||||||
|
requires: [{ path: 'lifestyle', op: '>=', value: 2 }],
|
||||||
|
effects: [{ path: 'money', op: 'add', value: -7 }],
|
||||||
|
},
|
||||||
|
// Gated on the office for the same reason Corporate Ladder gates its
|
||||||
|
// equivalent on Dispatch: a hand cannot afford any of this, and being
|
||||||
|
// charged standing for that would be a trap rather than a choice.
|
||||||
|
{
|
||||||
|
label: 'What the star is expected to be',
|
||||||
|
every: 4,
|
||||||
|
offset: 4,
|
||||||
|
requires: [
|
||||||
|
{ path: 'stage', op: '==', value: 'deputy' },
|
||||||
|
{ path: 'lifestyle', op: '<', value: 1 },
|
||||||
|
],
|
||||||
|
effects: [{ path: 'standing', op: 'add', value: -2 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'What the star is expected to be',
|
||||||
|
every: 4,
|
||||||
|
offset: 4,
|
||||||
|
requires: [
|
||||||
|
{ path: 'stage', op: '==', value: 'deputy' },
|
||||||
|
{ path: 'standing', op: '>=', value: 65 },
|
||||||
|
{ path: 'lifestyle', op: '<', value: 2 },
|
||||||
|
],
|
||||||
|
effects: [{ path: 'standing', op: 'add', value: -2 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The top step, for the reason Corporate Ladder's has one: without it a
|
||||||
|
// man who plays for standing every single day holds a hundred of it on a
|
||||||
|
// livery horse, and the bay is money thrown away. With it he settles in
|
||||||
|
// the low eighties and the last stretch has to be bought.
|
||||||
|
label: 'What the star is expected to be',
|
||||||
|
every: 4,
|
||||||
|
offset: 4,
|
||||||
|
requires: [
|
||||||
|
{ path: 'stage', op: '==', value: 'deputy' },
|
||||||
|
{ path: 'standing', op: '>=', value: 80 },
|
||||||
|
{ path: 'lifestyle', op: '<', value: 2 },
|
||||||
|
],
|
||||||
|
effects: [{ path: 'standing', op: 'add', value: -14 }],
|
||||||
|
},
|
||||||
// Store credit costs more the deeper in you are, the same step function
|
// Store credit costs more the deeper in you are, the same step function
|
||||||
// Corporate Ladder uses for its loan — a shape, not a setting.
|
// Corporate Ladder uses for its loan — a shape, not a setting.
|
||||||
{
|
{
|
||||||
@@ -149,6 +213,9 @@ export const frontier = {
|
|||||||
hand: [0, 12],
|
hand: [0, 12],
|
||||||
deputy: [10, 30],
|
deputy: [10, 30],
|
||||||
},
|
},
|
||||||
|
// States a player opts into, excluded from the baseline above: the floor
|
||||||
|
// is what a man faces owing nothing and keeping nothing.
|
||||||
|
baselineExcludes: ['owed', 'lifestyle'],
|
||||||
},
|
},
|
||||||
|
|
||||||
display: {
|
display: {
|
||||||
@@ -157,6 +224,10 @@ export const frontier = {
|
|||||||
'standing': { label: 'Standing', format: 'meter', order: 3 },
|
'standing': { label: 'Standing', format: 'meter', order: 3 },
|
||||||
'skills.nerve': { label: 'Nerve', format: 'meter', order: 4 },
|
'skills.nerve': { label: 'Nerve', format: 'meter', order: 4 },
|
||||||
'skills.trade': { label: 'Trade', format: 'meter', order: 5 },
|
'skills.trade': { label: 'Trade', format: 'meter', order: 5 },
|
||||||
|
'lifestyle': {
|
||||||
|
label: 'What you keep', format: 'tier', order: 6, hideWhenZero: true,
|
||||||
|
labels: ['The livery horse', 'Your own horse', 'Horse and a room'],
|
||||||
|
},
|
||||||
'notable': { hidden: true, inLedger: false },
|
'notable': { hidden: true, inLedger: false },
|
||||||
'flags.carries_iron': {
|
'flags.carries_iron': {
|
||||||
label: 'The scattergun', hidden: true,
|
label: 'The scattergun', hidden: true,
|
||||||
|
|||||||
@@ -547,3 +547,50 @@ ends around $8,300 over 200 turns, and money stops being a constraint. The
|
|||||||
direction is that rising standing brings rising expenses — the car, the place to
|
direction is that rising standing brings rising expenses — the car, the place to
|
||||||
live, the clothes a position expects. Upkeep entries are already gated
|
live, the clothes a position expects. Upkeep entries are already gated
|
||||||
conditions over state, so this is content work, not an engine change.
|
conditions over state, so this is content work, not an engine change.
|
||||||
|
|
||||||
|
## 36. Standing costs money to keep
|
||||||
|
|
||||||
|
Built in both packs, with no engine change, exactly as #35 predicted.
|
||||||
|
|
||||||
|
Each pack declares a `lifestyle` tier — Corporate Ladder's car and address, the
|
||||||
|
Frontier's own horse and the room over the Ellis place — bought at a second-rung
|
||||||
|
event and charged for every cycle thereafter. Dispatch nets +49 a week; with the
|
||||||
|
car +19; with both, **-21**, so a coordinator keeping up has to earn the
|
||||||
|
difference out of the days themselves. The Frontier runs the same shape at its
|
||||||
|
own scale: +18, +11, +4 a run.
|
||||||
|
|
||||||
|
**The charge that makes it a choice is levied in standing, not money.** A player
|
||||||
|
whose lifestyle lags behind the job loses standing every cycle, in steps that
|
||||||
|
steepen as standing rises. Both are gated on the second stage: the first rung
|
||||||
|
pays too little for any of this, and charging a mailroom associate for a car
|
||||||
|
would be a trap rather than a decision.
|
||||||
|
|
||||||
|
Three things were measured rather than guessed, and all three changed the design:
|
||||||
|
|
||||||
|
- **At -2 a week the charge was invisible.** Dispatch hands out standing several
|
||||||
|
points at a time, so a coordinator who bought nothing still finished pinned at
|
||||||
|
100. The step had to reach -4/-6 before ordinary play settled in the seventies.
|
||||||
|
- **A player who chased standing every turn beat it anyway** — 99 standing while
|
||||||
|
buying nothing, and $2,245 richer than one who kept up. The lifestyle was a
|
||||||
|
pure loss to anybody paying attention, which is the opposite of the intent.
|
||||||
|
The top step (-14 above 80 standing) puts that player's ceiling at 82, so the
|
||||||
|
last fifteen points are bought rather than worked for.
|
||||||
|
- **The Frontier's first numbers left a keeper on $8 after 200 turns.** That is
|
||||||
|
not pressure, it is the debt trap this pack was tuned twice to avoid; the room
|
||||||
|
went from 9 a run to 7.
|
||||||
|
|
||||||
|
**Which upkeep counts as the baseline is now the pack's to declare.** The
|
||||||
|
conformance suite used to exclude debt-gated entries by matching
|
||||||
|
`/debt|owed|loan/i` against path names — the generic suite guessing at content's
|
||||||
|
vocabulary, which #31 exists to prevent, and which would have silently counted
|
||||||
|
every lifestyle tier at once into a baseline that is supposed to mean "the floor
|
||||||
|
a player faces having opted into nothing." Packs now name those paths in
|
||||||
|
`tuning.baselineExcludes`, and the suite fails if one is not declared state.
|
||||||
|
|
||||||
|
**And a test that had passed for two releases was measuring the wrong thing.**
|
||||||
|
"The promotion leaves a player better off" compared the end balances of promoted
|
||||||
|
and unpromoted players pooled across archetypes — but which archetypes get
|
||||||
|
promoted is not independent of how they spend, so the comparison mostly measured
|
||||||
|
the mix. It inverted the moment there was something to spend money on. It is now
|
||||||
|
derived from upkeep arithmetic, and the lifestyle's effect is tested with two
|
||||||
|
players who differ in exactly one habit.
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "theladder",
|
"name": "theladder",
|
||||||
"version": "0.3.1",
|
"version": "0.3.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "A turn-based career simulation game. Engine is theme-agnostic; settings are content packs.",
|
"description": "A turn-based career simulation game. Engine is theme-agnostic; settings are content packs.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -117,6 +117,10 @@ function deltaText(pack, change) {
|
|||||||
if (typeof change.to !== 'number' || typeof change.from !== 'number') {
|
if (typeof change.to !== 'number' || typeof change.from !== 'number') {
|
||||||
return typeof change.to === 'boolean' ? (change.to ? 'now true' : 'no longer true') : 'changed';
|
return typeof change.to === 'boolean' ? (change.to ? 'now true' : 'no longer true') : 'changed';
|
||||||
}
|
}
|
||||||
|
// A tier moved: "+1" says nothing, the name of the step says everything.
|
||||||
|
const rule = pack.display?.[change.path];
|
||||||
|
if (rule?.format === 'tier') return rule.labels?.[change.to] ?? String(change.to);
|
||||||
|
|
||||||
const delta = change.to - change.from;
|
const delta = change.to - change.from;
|
||||||
const money = pack.display?.[change.path]?.format === 'money';
|
const money = pack.display?.[change.path]?.format === 'money';
|
||||||
const magnitude = money
|
const magnitude = money
|
||||||
|
|||||||
+5
-1
@@ -34,7 +34,11 @@ export function labelFor(pack, path) {
|
|||||||
/** Money is written as money; everything else is a plain number. */
|
/** Money is written as money; everything else is a plain number. */
|
||||||
export function formatValue(pack, path, value) {
|
export function formatValue(pack, path, value) {
|
||||||
if (typeof value === 'boolean') return value ? 'yes' : 'no';
|
if (typeof value === 'boolean') return value ? 'yes' : 'no';
|
||||||
if (displayFor(pack, path)?.format === 'money') return formatMoney(value);
|
const rule = displayFor(pack, path);
|
||||||
|
if (rule?.format === 'money') return formatMoney(value);
|
||||||
|
// A tier is a number the player should never see as one: a pack declares
|
||||||
|
// what each step is called and the interface reads the name off the list.
|
||||||
|
if (rule?.format === 'tier') return rule.labels?.[value] ?? String(value);
|
||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,16 +48,22 @@ function randomRun(pack, runSeed, turns) {
|
|||||||
* on where the last rent lands — Corporate Ladder measures -19 a fortnight over
|
* on where the last rent lands — Corporate Ladder measures -19 a fortnight over
|
||||||
* 280 turns and -42 over 560, and only one of those is the steady state.
|
* 280 turns and -42 over 560, and only one of those is the steady state.
|
||||||
*
|
*
|
||||||
* Entries gated on a debt path are excluded: the baseline is what a player
|
* Entries gated on a path the pack calls elective are excluded: a debt it took
|
||||||
* faces owing nothing, which is the floor the pack is designed around.
|
* on, a lifestyle it bought. The baseline is what a player faces having opted
|
||||||
|
* into none of them, which is the floor the pack is designed around.
|
||||||
|
*
|
||||||
|
* Which paths those are is the pack's to name, in `tuning.baselineExcludes`.
|
||||||
|
* This used to be a regex over path names — /debt|owed|loan/i — which is the
|
||||||
|
* generic suite guessing at content's vocabulary, exactly what DECISIONS #31
|
||||||
|
* says it must not do. It also silently missed anything named otherwise.
|
||||||
*/
|
*/
|
||||||
function baselinePerCycle(pack, stage) {
|
function baselinePerCycle(pack, stage) {
|
||||||
const cycle = pack.calendar?.cycleLength ?? 1;
|
const cycle = pack.calendar?.cycleLength ?? 1;
|
||||||
const debtish = (path) => /debt|owed|loan/i.test(path);
|
const elective = new Set(pack.tuning?.baselineExcludes ?? []);
|
||||||
return (pack.upkeep ?? [])
|
return (pack.upkeep ?? [])
|
||||||
.filter((entry) => {
|
.filter((entry) => {
|
||||||
const gate = entry.requires ?? [];
|
const gate = entry.requires ?? [];
|
||||||
if (gate.some((c) => debtish(c.path))) return false;
|
if (gate.some((c) => elective.has(c.path))) return false;
|
||||||
return !gate.some((c) => c.path === 'stage' && c.value !== stage);
|
return !gate.some((c) => c.path === 'stage' && c.value !== stage);
|
||||||
})
|
})
|
||||||
.reduce((total, entry) => {
|
.reduce((total, entry) => {
|
||||||
@@ -90,6 +96,12 @@ for (const pack of packs) {
|
|||||||
assert.ok(pack.stages[stage].title, `a title for stage "${stage}"`);
|
assert.ok(pack.stages[stage].title, `a title for stage "${stage}"`);
|
||||||
assert.ok(pack.tuning.baselinePerCycle[stage], `a baseline for stage "${stage}"`);
|
assert.ok(pack.tuning.baselinePerCycle[stage], `a baseline for stage "${stage}"`);
|
||||||
}
|
}
|
||||||
|
// A typo here would not fail: the entry simply would not be excluded, and
|
||||||
|
// the baseline would quietly become a number nobody meant.
|
||||||
|
for (const path of pack.tuning.baselineExcludes ?? []) {
|
||||||
|
assert.ok(path in pack.state,
|
||||||
|
`baselineExcludes names "${path}", which the pack does not declare`);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test(`${where} no content is dead: every event fires and every option becomes available`, () => {
|
test(`${where} no content is dead: every event fires and every option becomes available`, () => {
|
||||||
|
|||||||
+83
-2
@@ -19,16 +19,40 @@ import { test } from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { corporateLadder as pack } from '../content/corporateladder/index.js';
|
import { corporateLadder as pack } from '../content/corporateladder/index.js';
|
||||||
import { startGame, currentTurn, takeTurn } from '../src/engine/game.js';
|
import { startGame, currentTurn, takeTurn } from '../src/engine/game.js';
|
||||||
|
import { check } from '../src/engine/conditions.js';
|
||||||
import { play, sweep, median } from './fixtures/strategies.js';
|
import { play, sweep, median } from './fixtures/strategies.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Net money from upkeep per week for a player in a given position: at a stage,
|
||||||
|
* at a lifestyle tier, owing nothing.
|
||||||
|
*
|
||||||
|
* Derived rather than simulated. Comparing simulated end-balances between
|
||||||
|
* promoted and unpromoted players looks like the obvious test and is
|
||||||
|
* confounded: which archetypes get promoted is not independent of how they
|
||||||
|
* spend, so the pooled medians mostly measure the archetype mix. It passed for
|
||||||
|
* two releases and then inverted the moment there was something to spend on.
|
||||||
|
*/
|
||||||
|
function netPerCycle(position) {
|
||||||
|
const state = { debt: 0, lifestyle: 0, reputation: 0, ...position };
|
||||||
|
const cycle = pack.calendar?.cycleLength ?? 1;
|
||||||
|
return (pack.upkeep ?? [])
|
||||||
|
.filter((entry) => check(state, entry.requires).passed)
|
||||||
|
.reduce((total, entry) => total + entry.effects
|
||||||
|
.filter((e) => e.path === 'money' && e.op === 'add')
|
||||||
|
.reduce((sum, e) => sum + e.value, 0) * (cycle / (entry.every ?? 1)), 0);
|
||||||
|
}
|
||||||
|
|
||||||
test('the promotion is worth taking, and reachable by a deliberate player', () => {
|
test('the promotion is worth taking, and reachable by a deliberate player', () => {
|
||||||
const { byStrategy } = sweep(pack, { seeds: 12, turns: 200 });
|
const { byStrategy } = sweep(pack, { seeds: 12, turns: 200 });
|
||||||
|
|
||||||
const promoted = Object.values(byStrategy).flat().filter((s) => s.stage === 'dispatch');
|
const promoted = Object.values(byStrategy).flat().filter((s) => s.stage === 'dispatch');
|
||||||
const stayed = Object.values(byStrategy).flat().filter((s) => s.stage === 'mailroom');
|
const stayed = Object.values(byStrategy).flat().filter((s) => s.stage === 'mailroom');
|
||||||
assert.ok(promoted.length > 0 && stayed.length > 0, 'both outcomes should occur');
|
assert.ok(promoted.length > 0 && stayed.length > 0, 'both outcomes should occur');
|
||||||
assert.ok(median(promoted.map((s) => s.money)) > median(stayed.map((s) => s.money)),
|
|
||||||
'the coordinator wage should leave a player better off');
|
const mailroom = netPerCycle({ stage: 'mailroom' });
|
||||||
|
const coordinator = netPerCycle({ stage: 'dispatch' });
|
||||||
|
assert.ok(coordinator > mailroom + 40,
|
||||||
|
`dispatch nets ${coordinator} a week against the mailroom's ${mailroom}`);
|
||||||
|
|
||||||
// A player actually pursuing it should get there mid-run, not at the very
|
// A player actually pursuing it should get there mid-run, not at the very
|
||||||
// end — the mailroom is a career, not a tutorial.
|
// end — the mailroom is a career, not a tutorial.
|
||||||
@@ -44,6 +68,63 @@ test('the promotion is worth taking, and reachable by a deliberate player', () =
|
|||||||
assert.ok(typical > 40 && typical < 90, `promotion landed at turn ${typical}`);
|
assert.ok(typical > 40 && typical < 90, `promotion landed at turn ${typical}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play to `turns` chasing standing, buying the lifestyle or refusing to.
|
||||||
|
*
|
||||||
|
* Two players differing in exactly one habit. The archetype sweep cannot
|
||||||
|
* answer this question: which archetypes get promoted is not independent of
|
||||||
|
* how they spend, so its pooled medians compare populations, not policies.
|
||||||
|
*/
|
||||||
|
function chaseStanding({ seed, keepUp, turns = 200 }) {
|
||||||
|
const buys = (r) => (r.option.effects ?? []).some((e) => e.path === 'lifestyle');
|
||||||
|
const gain = (r) => (r.option.effects ?? [])
|
||||||
|
.filter((e) => e.path === 'reputation' && e.op === 'add')
|
||||||
|
.reduce((sum, e) => sum + e.value, 0);
|
||||||
|
|
||||||
|
let state = startGame(pack, { seed: `standing-${seed}` });
|
||||||
|
for (let t = 0; t < turns; t++) {
|
||||||
|
const turn = currentTurn(pack, state);
|
||||||
|
const available = turn.options.filter((o) => o.available);
|
||||||
|
const buy = available.find(buys);
|
||||||
|
const chosen = (keepUp && buy)
|
||||||
|
? buy
|
||||||
|
: [...available.filter((o) => keepUp || !buys(o))].sort((a, b) => gain(b) - gain(a))[0]
|
||||||
|
?? available[0];
|
||||||
|
state = takeTurn(pack, state, chosen.option.id).state;
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('standing costs money to keep, and the top of it cannot be worked for', () => {
|
||||||
|
// Derived: each tier is a real weekly charge, and a coordinator keeping up
|
||||||
|
// fully is running at a loss on upkeep alone. That is the answer to "money
|
||||||
|
// stops mattering late" — it stopped mattering because standing was free.
|
||||||
|
const plain = netPerCycle({ stage: 'dispatch' });
|
||||||
|
const car = netPerCycle({ stage: 'dispatch', lifestyle: 1 });
|
||||||
|
const address = netPerCycle({ stage: 'dispatch', lifestyle: 2 });
|
||||||
|
assert.ok(car < plain - 25, `the car costs ${plain - car} a week`);
|
||||||
|
assert.ok(address < car - 35, `the address costs ${car - address} a week`);
|
||||||
|
assert.ok(address < 0,
|
||||||
|
`a coordinator keeping up nets ${address} a week and has to earn the difference`);
|
||||||
|
|
||||||
|
// Simulated, controlled: the ceiling. Without the top step a player who
|
||||||
|
// chased standing every turn held 99 while buying nothing, which made the
|
||||||
|
// whole system a tax on inattention rather than a choice.
|
||||||
|
const never = [];
|
||||||
|
const keeps = [];
|
||||||
|
for (let seed = 0; seed < 8; seed++) {
|
||||||
|
never.push(chaseStanding({ seed, keepUp: false }));
|
||||||
|
keeps.push(chaseStanding({ seed, keepUp: true }));
|
||||||
|
}
|
||||||
|
const standing = (runs) => median(runs.map((s) => s.reputation));
|
||||||
|
assert.ok(standing(never) <= 90,
|
||||||
|
`a coordinator who buys nothing reached ${standing(never)} standing`);
|
||||||
|
assert.ok(standing(keeps) >= 95,
|
||||||
|
`a coordinator who keeps up reached only ${standing(keeps)} standing`);
|
||||||
|
assert.ok(median(keeps.map((s) => s.money)) < median(never.map((s) => s.money)),
|
||||||
|
'and the difference is paid for out of the balance');
|
||||||
|
});
|
||||||
|
|
||||||
test('hard times is uncommon for a careful player and real for a careless one', () => {
|
test('hard times is uncommon for a careful player and real for a careless one', () => {
|
||||||
const countHardTimes = (strategy) => {
|
const countHardTimes = (strategy) => {
|
||||||
let days = 0;
|
let days = 0;
|
||||||
|
|||||||
+70
-2
@@ -10,8 +10,27 @@ import { test } from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { frontier as pack } from '../content/frontier/index.js';
|
import { frontier as pack } from '../content/frontier/index.js';
|
||||||
import { startGame, currentTurn, takeTurn } from '../src/engine/game.js';
|
import { startGame, currentTurn, takeTurn } from '../src/engine/game.js';
|
||||||
|
import { check } from '../src/engine/conditions.js';
|
||||||
import { play, sweep, median } from './fixtures/strategies.js';
|
import { play, sweep, median } from './fixtures/strategies.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Net money from upkeep per run for a player in a given position: at a stage,
|
||||||
|
* at a lifestyle tier, owing the store nothing.
|
||||||
|
*
|
||||||
|
* Derived rather than simulated, and for a reason worth keeping: comparing the
|
||||||
|
* end-balances of promoted and unpromoted players pools archetypes that do not
|
||||||
|
* spend alike, so it measures the mix rather than the wage.
|
||||||
|
*/
|
||||||
|
function netPerCycle(position) {
|
||||||
|
const state = { owed: 0, lifestyle: 0, standing: 0, ...position };
|
||||||
|
const cycle = pack.calendar?.cycleLength ?? 1;
|
||||||
|
return (pack.upkeep ?? [])
|
||||||
|
.filter((entry) => check(state, entry.requires).passed)
|
||||||
|
.reduce((total, entry) => total + entry.effects
|
||||||
|
.filter((e) => e.path === 'money' && e.op === 'add')
|
||||||
|
.reduce((sum, e) => sum + e.value, 0) * (cycle / (entry.every ?? 1)), 0);
|
||||||
|
}
|
||||||
|
|
||||||
test('the calendar is a four-day run, not a week', () => {
|
test('the calendar is a four-day run, not a week', () => {
|
||||||
// The point of this pack, structurally: the engine derives a cycle from
|
// The point of this pack, structurally: the engine derives a cycle from
|
||||||
// whatever length the pack declares, and nothing anywhere assumes seven.
|
// whatever length the pack declares, and nothing anywhere assumes seven.
|
||||||
@@ -57,8 +76,11 @@ test('the star is worth taking, and reachable by a deliberate player', () => {
|
|||||||
const sworn = Object.values(byStrategy).flat().filter((s) => s.stage === 'deputy');
|
const sworn = Object.values(byStrategy).flat().filter((s) => s.stage === 'deputy');
|
||||||
const stayed = Object.values(byStrategy).flat().filter((s) => s.stage === 'hand');
|
const stayed = Object.values(byStrategy).flat().filter((s) => s.stage === 'hand');
|
||||||
assert.ok(sworn.length > 0 && stayed.length > 0, 'both outcomes should occur');
|
assert.ok(sworn.length > 0 && stayed.length > 0, 'both outcomes should occur');
|
||||||
assert.ok(median(sworn.map((s) => s.money)) > median(stayed.map((s) => s.money)),
|
|
||||||
'deputy pay should leave a player better off');
|
const yard = netPerCycle({ stage: 'hand' });
|
||||||
|
const office = netPerCycle({ stage: 'deputy' });
|
||||||
|
assert.ok(office > yard + 10,
|
||||||
|
`the office nets ${office} a run against the yard's ${yard}`);
|
||||||
|
|
||||||
// The yard is a career, not a tutorial: the offer is gated at turn 40 and
|
// The yard is a career, not a tutorial: the offer is gated at turn 40 and
|
||||||
// should land somewhere in the middle of a run rather than at the very end.
|
// should land somewhere in the middle of a run rather than at the very end.
|
||||||
@@ -74,6 +96,52 @@ test('the star is worth taking, and reachable by a deliberate player', () => {
|
|||||||
assert.ok(typical >= 41 && typical < 90, `the star landed at turn ${typical}`);
|
assert.ok(typical >= 41 && typical < 90, `the star landed at turn ${typical}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Play to `turns` playing for standing, buying what the star expects or not. */
|
||||||
|
function chaseStanding({ seed, keepUp, turns = 200 }) {
|
||||||
|
const buys = (r) => (r.option.effects ?? []).some((e) => e.path === 'lifestyle');
|
||||||
|
const gain = (r) => (r.option.effects ?? [])
|
||||||
|
.filter((e) => e.path === 'standing' && e.op === 'add')
|
||||||
|
.reduce((sum, e) => sum + e.value, 0);
|
||||||
|
|
||||||
|
let state = startGame(pack, { seed: `standing-${seed}` });
|
||||||
|
for (let t = 0; t < turns; t++) {
|
||||||
|
const turn = currentTurn(pack, state);
|
||||||
|
const available = turn.options.filter((o) => o.available);
|
||||||
|
const buy = available.find(buys);
|
||||||
|
const chosen = (keepUp && buy)
|
||||||
|
? buy
|
||||||
|
: [...available.filter((o) => keepUp || !buys(o))].sort((a, b) => gain(b) - gain(a))[0]
|
||||||
|
?? available[0];
|
||||||
|
state = takeTurn(pack, state, chosen.option.id).state;
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the star costs money to wear, and the top of standing cannot be worked for', () => {
|
||||||
|
// The same mechanism as Corporate Ladder's, at this pack's scale and in its
|
||||||
|
// own vocabulary — which is the whole claim the second setting exists to
|
||||||
|
// make. No engine code is involved in either.
|
||||||
|
const plain = netPerCycle({ stage: 'deputy' });
|
||||||
|
const horse = netPerCycle({ stage: 'deputy', lifestyle: 1 });
|
||||||
|
const room = netPerCycle({ stage: 'deputy', lifestyle: 2 });
|
||||||
|
assert.ok(horse < plain - 5, `the bay costs ${plain - horse} a run`);
|
||||||
|
assert.ok(room < horse - 5, `the room costs ${horse - room} a run`);
|
||||||
|
assert.ok(room > 0,
|
||||||
|
`a deputy keeping up nets ${room} a run; the yard's own floor is barely above water`);
|
||||||
|
|
||||||
|
const never = [];
|
||||||
|
const keeps = [];
|
||||||
|
for (let seed = 0; seed < 8; seed++) {
|
||||||
|
never.push(chaseStanding({ seed, keepUp: false }));
|
||||||
|
keeps.push(chaseStanding({ seed, keepUp: true }));
|
||||||
|
}
|
||||||
|
const standing = (runs) => median(runs.map((s) => s.standing));
|
||||||
|
assert.ok(standing(never) <= 90, `a deputy on a livery horse reached ${standing(never)} standing`);
|
||||||
|
assert.ok(standing(keeps) >= 95, `a deputy keeping up reached only ${standing(keeps)} standing`);
|
||||||
|
assert.ok(median(keeps.map((s) => s.money)) < median(never.map((s) => s.money)),
|
||||||
|
'and it is paid for in money');
|
||||||
|
});
|
||||||
|
|
||||||
test('hard times is uncommon for a careful player and real for a careless one', () => {
|
test('hard times is uncommon for a careful player and real for a careless one', () => {
|
||||||
const countHardTimes = (strategy) => {
|
const countHardTimes = (strategy) => {
|
||||||
let days = 0;
|
let days = 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user