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
@@ -48,16 +48,22 @@ function randomRun(pack, runSeed, turns) {
|
||||
* 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.
|
||||
*
|
||||
* Entries gated on a debt path are excluded: the baseline is what a player
|
||||
* faces owing nothing, which is the floor the pack is designed around.
|
||||
* Entries gated on a path the pack calls elective are excluded: a debt it took
|
||||
* 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) {
|
||||
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 ?? [])
|
||||
.filter((entry) => {
|
||||
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);
|
||||
})
|
||||
.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.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`, () => {
|
||||
|
||||
+83
-2
@@ -19,16 +19,40 @@ import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { corporateLadder as pack } from '../content/corporateladder/index.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';
|
||||
|
||||
/**
|
||||
* 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', () => {
|
||||
const { byStrategy } = sweep(pack, { seeds: 12, turns: 200 });
|
||||
|
||||
const promoted = Object.values(byStrategy).flat().filter((s) => s.stage === 'dispatch');
|
||||
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(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
|
||||
// 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}`);
|
||||
});
|
||||
|
||||
/**
|
||||
* 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', () => {
|
||||
const countHardTimes = (strategy) => {
|
||||
let days = 0;
|
||||
|
||||
+70
-2
@@ -10,8 +10,27 @@ import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { frontier as pack } from '../content/frontier/index.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';
|
||||
|
||||
/**
|
||||
* 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', () => {
|
||||
// The point of this pack, structurally: the engine derives a cycle from
|
||||
// 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 stayed = Object.values(byStrategy).flat().filter((s) => s.stage === 'hand');
|
||||
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
|
||||
// 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}`);
|
||||
});
|
||||
|
||||
/** 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', () => {
|
||||
const countHardTimes = (strategy) => {
|
||||
let days = 0;
|
||||
|
||||
Reference in New Issue
Block a user