Fix an inescapable debt trap the playtest logs exposed

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
This commit is contained in:
JesseMarkowitz
2026-09-10 06:01:33 -04:00
co-authored by Claude Opus 5
parent 76072edf4e
commit 5c1784d990
8 changed files with 181 additions and 21 deletions
+1 -1
View File
@@ -70,7 +70,7 @@ That is one self-contained file with the stylesheet and every module inlined.
It plays by double-clicking it — no server, no network, nothing installed. It plays by double-clicking it — no server, no network, nothing installed.
```sh ```sh
npm test # 119 tests: engine, content, UI wiring, and the build npm test # 121 tests: engine, content, UI wiring, and the build
``` ```
To see what a playtest actually did — turns against distinct situations, how To see what a playtest actually did — turns against distinct situations, how
+13 -3
View File
@@ -13,7 +13,13 @@ export const hardTimes = [
id: 'rent_bounced', id: 'rent_bounced',
// No stage: rent is due wherever you work, and the branch has to follow // No stage: rent is due wherever you work, and the branch has to follow
// the player up rather than vanishing on promotion. // the player up rather than vanishing on promotion.
weight: 5000, //
// Weighted heavily but not overwhelmingly, and on a cooldown: being broke
// should colour a run, not replace it. At weight 5000 with no cooldown a
// player who could not climb out saw this and almost nothing else, 45
// times in one session.
weight: 900,
cooldown: 3,
requires: [{ path: 'money', op: '<', value: 0 }], requires: [{ path: 'money', op: '<', value: 0 }],
description: description:
'Rent came out and did not clear. The letter uses the word ' + 'Rent came out and did not clear. The letter uses the word ' +
@@ -70,8 +76,10 @@ export const hardTimes = [
result: result:
'You do eleven days without one off. The bins stop being individual ' + 'You do eleven days without one off. The bins stop being individual ' +
'bins after a while.', 'bins after a while.',
// Has to be worth more than a week of rent, or it is not a way out of
// the hole — it is the hole with extra steps.
effects: [ effects: [
{ path: 'money', op: 'add', value: 120 }, { path: 'money', op: 'add', value: 220 },
{ path: 'skills.logistics', op: 'add', value: 2 }, { path: 'skills.logistics', op: 'add', value: 2 },
{ path: 'reputation', op: 'add', value: -1 }, { path: 'reputation', op: 'add', value: -1 },
], ],
@@ -81,7 +89,9 @@ export const hardTimes = [
{ {
id: 'debt_collector', id: 'debt_collector',
weight: 25, // The chance to pay down has to come round often enough to matter; at
// weight 25 it appeared six times in 262 turns and debt became a ratchet.
weight: 45,
cooldown: 4, cooldown: 4,
requires: [{ path: 'debt', op: '>', value: 0 }], requires: [{ path: 'debt', op: '>', value: 0 }],
description: description:
+8 -3
View File
@@ -56,8 +56,13 @@ export const corporateLadder = {
}, },
// Money moves on a schedule the player can feel: paid on alternate Fridays, // Money moves on a schedule the player can feel: paid on alternate Fridays,
// rent out every Monday, and only small change in between. Over a fortnight // rent out every Monday, and only small change in between.
// the baseline is about forty dollars down — a drift, not a bleed. //
// 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: [ upkeep: [
{ label: 'Coffee, transit, lunch', effects: [{ path: 'money', op: 'add', value: -18 }] }, { 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 // Turn 1 is a Monday, so offset 4 is the first Friday and every 14 makes it
@@ -69,7 +74,7 @@ export const corporateLadder = {
every: 14, every: 14,
offset: 4, offset: 4,
requires: [{ path: 'stage', op: '==', value: 'mailroom' }], requires: [{ path: 'stage', op: '==', value: 'mailroom' }],
effects: [{ path: 'money', op: 'add', value: 980 }], effects: [{ path: 'money', op: 'add', value: 1120 }],
}, },
{ {
label: 'Wages', label: 'Wages',
+49
View File
@@ -365,3 +365,52 @@ there when given no argument. The logs are gitignored: they record what a real
person did turn by turn, including anything they typed into the feedback box. person did turn by turn, including anything they typed into the feedback box.
That is not repository content, and making it so once would put it in the That is not repository content, and making it so once would put it in the
history for good. history for good.
## 29. The economy needs a test on its arithmetic, not just on its outcomes
A real 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 was promoted out of
the problem before it bit, and the 25%-dominance test passed because
`rent_bounced` was only 17% of turns.
Three things came out of it.
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 under +250. That test would have failed the moment
rent changed. Outcome tests over simulated play are worth having, but they are a
slow and noisy way to detect a number that is simply wrong.
A `lifer` archetype was added: it 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, and no other archetype can.
And a test now holds that no archetype spends 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.
Fixes: mailroom wages 980 → 1120, 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 — being broke should colour a run, not replace it — and
`debt_collector` from weight 25 to 45, since at 25 it appeared six times in 262
turns and debt became a one-way ratchet.
## 30. Gaps are measured within a run, never across two
`tools/analyze-log.js` reported "every ~-5 turns". One export can hold several
playthroughs and turn numbers restart with each, so a first-seen in run two
compared against a last-seen in run one produces nonsense.
Spans and pair-counts are now accumulated per run and pooled, so the figure is a
proper weighted average of within-run gaps. It is worth stating the general
form: any statistic over a log has to respect the run boundary, because a log is
not one sequence.
+1 -1
View File
@@ -2,6 +2,6 @@
// `npm run build`. The commit is whatever HEAD was at stamp time, so a dist // `npm run build`. The commit is whatever HEAD was at stamp time, so a dist
// file says exactly which commit produced it. // file says exactly which commit produced it.
export const BUILD = { export const BUILD = {
commit: '4cab9fc+', commit: '76072ed+',
builtAt: '2026-09-10', builtAt: '2026-09-10',
}; };
+59 -1
View File
@@ -17,7 +17,7 @@ import { corporateLadder as pack } from '../content/corporateladder/index.js';
import { validatePack } from '../src/engine/validate.js'; import { validatePack } from '../src/engine/validate.js';
import { startGame, currentTurn, takeTurn } from '../src/engine/game.js'; import { startGame, currentTurn, takeTurn } from '../src/engine/game.js';
import { createRng } from '../src/engine/rng.js'; import { createRng } from '../src/engine/rng.js';
import { play, sweep, median } from './fixtures/strategies.js'; import { play, sweep, median, strategies } from './fixtures/strategies.js';
import { summarize } from '../src/ui/summary.js'; import { summarize } from '../src/ui/summary.js';
/** Play `turns` turns with independently-seeded random choices. */ /** Play `turns` turns with independently-seeded random choices. */
@@ -259,3 +259,61 @@ test('the retrospective reports how repetitive the run was', () => {
assert.deepEqual(summary.stagesVisited, ['mailroom', 'dispatch'], assert.deepEqual(summary.stagesVisited, ['mailroom', 'dispatch'],
'the retrospective knows the career moved'); 'the retrospective knows the career moved');
}); });
test('the mailroom baseline is a drift, not a bleed', () => {
// The guard that was missing. Rent was once raised from 385 to 455 without
// re-deriving this, which quietly took the baseline from -$3/day to -$13/day
// and trapped a real playtester below zero for 30% of a 182-turn session.
// Nothing caught it, because every archetype either optimised money or got
// promoted out of the problem.
// Averaged over many fortnights rather than measured on the first one: the
// opening turns sit before rent's offset, so a single early window sees one
// rent cheque where the steady state has two.
const FORTNIGHTS = 20;
const perFortnight = (stage) => {
let total = 0;
for (let turn = 1; turn <= 14 * FORTNIGHTS; turn++) {
for (const entry of pack.upkeep) {
const every = entry.every ?? 1;
const elapsed = turn - 1 - (entry.offset ?? 0);
if (elapsed < 0 || elapsed % every !== 0) continue;
// Baseline means no debt: only stage-gated entries count.
const gate = entry.requires ?? [];
if (gate.some((c) => c.path === 'debt')) continue;
if (gate.some((c) => c.path === 'stage' && c.value !== stage)) continue;
total += entry.effects
.filter((e) => e.path === 'money' && e.op === 'add')
.reduce((sum, e) => sum + e.value, 0);
}
}
return Math.round(total / FORTNIGHTS);
};
const mailroom = perFortnight('mailroom');
assert.ok(mailroom > -90 && mailroom < 10,
`the mailroom fortnight nets ${mailroom}; it should be a small drift downward`);
const dispatch = perFortnight('dispatch');
assert.ok(dispatch > mailroom, 'the promotion should be worth having');
assert.ok(dispatch < 250, `Dispatch nets ${dispatch} a fortnight; money stops mattering above that`);
});
test('nobody spends the run stuck below zero', () => {
// Hard times is a state a player passes through, not one they live in. The
// failure this guards against is a debt whose weekly service charge exceeds
// anything the escape options can pay — inescapable by arithmetic, which is
// exactly what a real session hit.
for (const strategy of Object.keys(strategies)) {
const shares = [];
for (let seed = 0; seed < 8; seed++) {
let red = 0;
let turns = 0;
play(pack, { strategy, seed, turns: 150,
visit: ({ state }) => { turns++; if (state.money < 0) red++; } });
shares.push(red / turns);
}
const worst = Math.max(...shares);
assert.ok(worst < 0.25,
`${strategy} spent ${(worst * 100).toFixed(0)}% of a run below zero`);
}
});
+13
View File
@@ -39,6 +39,19 @@ export const strategies = {
desperate: (options, rng) => (rng.next() < 0.25 desperate: (options, rng) => (rng.next() < 0.25
? rng.pick(options) ? rng.pick(options)
: best(options, (o) => relationshipGain(o) - moneyGain(o) / 40)), : best(options, (o) => relationshipGain(o) - moneyGain(o) / 40)),
/**
* Never leaves the first rung — refuses anything that would change stage,
* and otherwise plays for people. Added after a real 182-turn session that
* stayed in the mailroom the whole way and spent 30% of it below zero: every
* other archetype either optimised money or got promoted out of the problem,
* so none of them could see it.
*/
lifer: (options) => {
const stays = options.filter(
(o) => !(o.option.effects ?? []).some((e) => e.path === 'stage'),
);
return best(stays.length ? stays : options, relationshipGain);
},
}; };
/** Play one run to `turns`, calling `visit` before each choice. */ /** Play one run to `turns`, calling `visit` before each choice. */
+37 -12
View File
@@ -54,18 +54,42 @@ function byRun(entries) {
return runs; return runs;
} }
function reportRepetition(entries) { /**
* How often each situation came round.
*
* Gaps are pooled *within* runs and never across them. An export can hold
* several playthroughs, and turn numbers restart with each — measuring a gap
* across that seam produced "every ~-5 turns", which is how this was caught.
*/
function reportRepetition(runs) {
const counts = new Map(); const counts = new Map();
const firstSeen = new Map(); const spans = new Map(); // summed (last - first) within each run
const lastSeen = new Map(); const pairs = new Map(); // summed (appearances - 1) within each run
for (const entry of entries) { const onlyTurn = new Map();
counts.set(entry.event_id, (counts.get(entry.event_id) ?? 0) + 1); let turns = 0;
if (!firstSeen.has(entry.event_id)) firstSeen.set(entry.event_id, entry.turn);
lastSeen.set(entry.event_id, entry.turn); for (const run of runs.values()) {
const firstSeen = new Map();
const lastSeen = new Map();
const inRun = new Map();
for (const entry of run) {
turns++;
counts.set(entry.event_id, (counts.get(entry.event_id) ?? 0) + 1);
inRun.set(entry.event_id, (inRun.get(entry.event_id) ?? 0) + 1);
if (!firstSeen.has(entry.event_id)) firstSeen.set(entry.event_id, entry.turn);
lastSeen.set(entry.event_id, entry.turn);
}
for (const [eventId, appearances] of inRun) {
if (appearances > 1) {
spans.set(eventId, (spans.get(eventId) ?? 0) + (lastSeen.get(eventId) - firstSeen.get(eventId)));
pairs.set(eventId, (pairs.get(eventId) ?? 0) + (appearances - 1));
} else if (!onlyTurn.has(eventId)) {
onlyTurn.set(eventId, firstSeen.get(eventId));
}
}
} }
const distinct = counts.size; const distinct = counts.size;
const turns = entries.length;
section('Repetition'); section('Repetition');
console.log(`${turns} turns across ${distinct} distinct situations ` + console.log(`${turns} turns across ${distinct} distinct situations ` +
`— each seen ${(turns / distinct).toFixed(1)} times on average.`); `— each seen ${(turns / distinct).toFixed(1)} times on average.`);
@@ -77,9 +101,10 @@ function reportRepetition(entries) {
const most = ranked[0][1]; const most = ranked[0][1];
console.log(''); console.log('');
for (const [eventId, count] of ranked) { for (const [eventId, count] of ranked) {
const gap = count > 1 const pairCount = pairs.get(eventId) ?? 0;
? ` every ~${Math.round((lastSeen.get(eventId) - firstSeen.get(eventId)) / (count - 1))} turns` const gap = pairCount > 0
: ` once, turn ${firstSeen.get(eventId)}`; ? ` every ~${Math.max(1, Math.round(spans.get(eventId) / pairCount))} turns`
: ` once${runs.size > 1 ? ' per run' : `, turn ${onlyTurn.get(eventId)}`}`;
console.log(` ${eventId.padEnd(24)} ${String(count).padStart(3)} ` + console.log(` ${eventId.padEnd(24)} ${String(count).padStart(3)} ` +
`${bar(count / most)} ${pct(count, turns).padStart(6)}${gap}`); `${bar(count / most)} ${pct(count, turns).padStart(6)}${gap}`);
} }
@@ -193,7 +218,7 @@ async function main() {
console.log(` seed ${seed}: ${run.length} turns, ${stages.join(' → ')}`); console.log(` seed ${seed}: ${run.length} turns, ${stages.join(' → ')}`);
} }
reportRepetition(entries); reportRepetition(runs);
reportChoices(entries); reportChoices(entries);
reportGates(entries); reportGates(entries);
reportFeedback(entries); reportFeedback(entries);