A 182-turn session spent 30% of its turns below zero, went negative on turn 18 and never recovered, and chose the same hard-times option — "take every shift going" — thirty-seven times. The cause was a tuning change made without re-deriving what it meant. Rent went from 385 to 455 while chasing a different problem, which took the mailroom baseline from the designed -$3/day to -$13/day. Worse, at the debt that run accumulated the weekly service charge came to $180 against an escape option worth $120. The hole was inescapable by arithmetic, whatever the player did. Nothing caught it. Every archetype either optimised money or got promoted out of the problem before it bit, and the dominance test passed because rent_bounced was only 17% of turns. Fixes: mailroom wages 980 -> 1120, restoring the -$3/day baseline; the escape option 120 -> 220, so it is worth more than a week's rent; rent_bounced from weight 5000 with no cooldown to 900 on a cooldown of 3, because being broke should colour a run rather than replace it; debt_collector from weight 25 to 45, since at 25 it appeared six times in 262 turns and debt became a ratchet. Three guards so this class of bug cannot recur quietly. The baseline is now asserted directly: a test sums the pack's own upkeep over twenty fortnights and requires the mailroom to net between -90 and +10, and Dispatch to be better but not so much better that money stops mattering. That would have failed the moment rent changed. Outcome tests over simulated play are a slow and noisy way to detect a number that is simply wrong. A `lifer` archetype refuses any option that would change stage — generically, by looking for an effect on `stage` — and otherwise plays for people. It reproduces the session that found this; no other archetype can. And no archetype may spend more than a quarter of a run below zero. Hard times is a state a player passes through; living in it is the failure mode. Also fixed: the analyser reported "every ~-5 turns". One export can hold several playthroughs and turn numbers restart with each, so spans are now accumulated per run and pooled rather than measured across the seam between two. 121 tests. Reasoning in docs/DECISIONS.md §29-30. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VMSFHyVPitUoosW5wyEADj
161 lines
6.6 KiB
JavaScript
161 lines
6.6 KiB
JavaScript
// Corporate Ladder — the default content pack for The Ladder.
|
|
//
|
|
// A modern office, the mailroom, and two people who have been there longer
|
|
// than you. Everything the engine needs to run this setting is in this file
|
|
// and the ones it imports; the engine itself knows nothing about mailrooms.
|
|
|
|
import { characters } from './characters.js';
|
|
import { events } from './events/index.js';
|
|
|
|
export const corporateLadder = {
|
|
id: 'corporateladder',
|
|
name: 'Corporate Ladder',
|
|
version: '0.1.0',
|
|
turnUnit: 'day',
|
|
startingStage: 'mailroom',
|
|
|
|
stages: {
|
|
mailroom: { label: 'Mailroom', title: 'Mail Associate I' },
|
|
dispatch: { label: 'Dispatch', title: 'Dispatch Coordinator' },
|
|
},
|
|
|
|
// Turn 1 is a Monday. Content can gate on the day — and, more importantly,
|
|
// money arrives and leaves on a rhythm the player can plan around instead of
|
|
// draining by an unexplained amount every day.
|
|
calendar: {
|
|
cycleLength: 7,
|
|
cycleName: 'Week',
|
|
unitNames: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'],
|
|
},
|
|
|
|
// Money is tight but not hopeless. The baseline bleeds about three dollars a
|
|
// day, so a careful player creeps forward and a careless one does not — the
|
|
// pressure comes from what choices cost, not from an unexplained drain.
|
|
state: {
|
|
'money': { initial: 520, min: -5000, max: 1000000 },
|
|
'debt': { initial: 0, min: 0, max: 1000000 },
|
|
'reputation': { initial: 50, min: 0, max: 100 },
|
|
'skills.negotiation': { initial: 5, min: 0, max: 100 },
|
|
'skills.logistics': { initial: 5, min: 0, max: 100 },
|
|
'flags.took_loan': { initial: false },
|
|
'flags.holds_the_envelope': { initial: false },
|
|
'flags.covered_for_trevor': { initial: false },
|
|
'flags.promoted': { initial: false },
|
|
'flags.recommended_trevor': { initial: false },
|
|
// Moments the retrospective reads back. Content decides what is memorable;
|
|
// the full turn-by-turn record lives in history regardless.
|
|
'notable': { initial: [] },
|
|
},
|
|
|
|
// Nobody fears a new hire. Trevor's defaults are lower still — see
|
|
// characters.js, where he starts two weeks ahead of you and knows it.
|
|
relationshipStats: {
|
|
likes_you: { label: 'Likes you', initial: 50, min: 0, max: 100 },
|
|
fears_you: { label: 'Wary of you', initial: 10, min: 0, max: 100 },
|
|
wants_to_help_you: { label: 'Will help you', initial: 40, min: 0, max: 100 },
|
|
},
|
|
|
|
// Money moves on a schedule the player can feel: paid on alternate Fridays,
|
|
// rent out every Monday, and only small change in between.
|
|
//
|
|
// Keep this arithmetic honest when tuning: wages, minus two rents, minus
|
|
// fourteen days of small change. It should come to roughly forty dollars
|
|
// down over a fortnight — a drift, not a bleed. Rent was once raised without
|
|
// re-deriving it, which quietly made the baseline four times steeper and
|
|
// trapped a real playtester below zero for 30% of a 182-turn run.
|
|
upkeep: [
|
|
{ label: 'Coffee, transit, lunch', effects: [{ path: 'money', op: 'add', value: -18 }] },
|
|
// Turn 1 is a Monday, so offset 4 is the first Friday and every 14 makes it
|
|
// alternate Fridays. Rent lands on Mondays, starting the second week.
|
|
// Wages depend on the job. Two entries rather than one, gated on stage:
|
|
// the engine has no idea what a promotion is, and does not need to.
|
|
{
|
|
label: 'Wages',
|
|
every: 14,
|
|
offset: 4,
|
|
requires: [{ path: 'stage', op: '==', value: 'mailroom' }],
|
|
effects: [{ path: 'money', op: 'add', value: 1120 }],
|
|
},
|
|
{
|
|
label: 'Wages',
|
|
every: 14,
|
|
offset: 4,
|
|
// A real raise, but not a different life — the coordinator job is, as
|
|
// Marlene puts it, mostly the same work with a different phone. Net of
|
|
// rent it comes to a few dollars a day ahead instead of behind.
|
|
requires: [{ path: 'stage', op: '==', value: 'dispatch' }],
|
|
effects: [{ path: 'money', op: 'add', value: 1260 }],
|
|
},
|
|
{ label: 'Rent', every: 7, offset: 7, effects: [{ path: 'money', op: 'add', value: -455 }] },
|
|
// The service charge steps up with the size of the debt, so the spiral
|
|
// tightens instead of flattening out. A step function in data costs the
|
|
// engine nothing; a percentage would have needed a multiply operation.
|
|
{
|
|
label: 'Loan service charge',
|
|
every: 7,
|
|
offset: 7,
|
|
requires: [{ path: 'debt', op: '>', value: 0 }],
|
|
effects: [{ path: 'money', op: 'add', value: -70 }],
|
|
},
|
|
{
|
|
label: 'Loan service charge (arrears)',
|
|
every: 7,
|
|
offset: 7,
|
|
requires: [{ path: 'debt', op: '>=', value: 1000 }],
|
|
effects: [{ path: 'money', op: 'add', value: -40 }],
|
|
},
|
|
{
|
|
label: 'Loan service charge (collections)',
|
|
every: 7,
|
|
offset: 7,
|
|
requires: [{ path: 'debt', op: '>=', value: 2000 }],
|
|
effects: [{ path: 'money', op: 'add', value: -70 }],
|
|
},
|
|
],
|
|
|
|
// How the interface names and formats this pack's state. The UI reads only
|
|
// this map, so renaming a stat or reordering the header is content's job.
|
|
display: {
|
|
'money': { label: 'Money', format: 'money', order: 1 },
|
|
'debt': { label: 'Debt', format: 'money', order: 2, hideWhenZero: true },
|
|
'reputation': { label: 'Standing', format: 'meter', order: 3 },
|
|
'skills.negotiation': { label: 'Negotiation', format: 'meter', order: 4 },
|
|
'skills.logistics': { label: 'Logistics', format: 'meter', order: 5 },
|
|
// Kept out of the header, and out of the ledger too: `notable` is the
|
|
// retrospective's own list, and "Notable — changed" tells nobody anything.
|
|
'notable': { hidden: true, inLedger: false },
|
|
// Flags do belong in the ledger — a flag flipping is a fact about the
|
|
// world — but as a sentence rather than as "now true".
|
|
'flags.took_loan': {
|
|
label: 'The loan', hidden: true,
|
|
whenTrue: 'You are carrying a loan now.',
|
|
whenFalse: 'The loan is settled.',
|
|
},
|
|
'flags.holds_the_envelope': {
|
|
label: 'The envelope', hidden: true,
|
|
whenTrue: 'The envelope is in your locker.',
|
|
whenFalse: 'The envelope is back in the system.',
|
|
},
|
|
'flags.covered_for_trevor': {
|
|
label: "Trevor's Thursday", hidden: true,
|
|
whenTrue: 'Trevor owes you a Thursday.',
|
|
whenFalse: 'You and Trevor are square.',
|
|
},
|
|
'flags.promoted': {
|
|
label: 'Promotion', hidden: true,
|
|
whenTrue: 'You are out of the mailroom.',
|
|
whenFalse: 'Back in the mailroom.',
|
|
},
|
|
'flags.recommended_trevor': {
|
|
label: "Trevor's recommendation", hidden: true,
|
|
whenTrue: "Trevor's name is in for the mailroom.",
|
|
whenFalse: "Trevor's name is not in for the mailroom.",
|
|
},
|
|
},
|
|
|
|
characters,
|
|
events,
|
|
};
|
|
|
|
export default corporateLadder;
|