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
205 lines
9.3 KiB
JavaScript
205 lines
9.3 KiB
JavaScript
// Corporate Ladder's own tuning.
|
|
//
|
|
// What is true of this pack because it is *this* pack: the shape of its week,
|
|
// when wages and rent land, how the loan behaves, when Dispatch opens and how
|
|
// deep the Trevor chain is. Everything true of a pack because it is a pack —
|
|
// validity, dead content, the economic baseline, variety, replay — is in
|
|
// conformance.test.js, which runs over every pack in the registry.
|
|
//
|
|
// Engine behaviour is covered against the fixture pack instead, so tuning the
|
|
// game cannot break the engine suite and vice versa.
|
|
//
|
|
// One trap, learned the hard way: the chooser's RNG must be seeded
|
|
// *differently* from the game's. Both draw one number per turn from the same
|
|
// generator, so sharing a seed correlates the choice index with the event
|
|
// draw and silently makes whole option combinations unreachable — which looks
|
|
// exactly like a content bug.
|
|
|
|
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');
|
|
|
|
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.
|
|
const timings = [];
|
|
for (let seed = 0; seed < 12; seed++) {
|
|
let at = null;
|
|
play(pack, { strategy: 'sociable', seed, turns: 200,
|
|
visit: ({ state }) => { if (state.stage === 'dispatch' && at === null) at = state.turn; } });
|
|
if (at) timings.push(at);
|
|
}
|
|
assert.equal(timings.length, 12, 'a sociable player should always get the offer');
|
|
const typical = median(timings);
|
|
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;
|
|
play(pack, { strategy, seed: 'ht', turns: 200,
|
|
visit: ({ turn }) => { if (turn.event.id === 'rent_bounced') days++; } });
|
|
return days;
|
|
};
|
|
assert.equal(countHardTimes('thrifty'), 0, 'minding the money should keep you out of trouble');
|
|
assert.ok(countHardTimes('spendthrift') > 0, 'ignoring it should land you in it');
|
|
});
|
|
|
|
test('money arrives and leaves on a rhythm the player can plan around', () => {
|
|
const wages = [];
|
|
const rent = [];
|
|
let state = startGame(pack, { seed: 'rhythm' });
|
|
for (let t = 0; t < 30; t++) {
|
|
const turn = currentTurn(pack, state);
|
|
const result = takeTurn(pack, state, turn.options.find((o) => o.available).option.id);
|
|
for (const change of result.record.upkeep) {
|
|
if (change.label === 'Wages') wages.push(state.turn);
|
|
if (change.label === 'Rent') rent.push(state.turn);
|
|
}
|
|
state = result.state;
|
|
}
|
|
|
|
assert.deepEqual(wages, [5, 19], 'paid on alternate Fridays');
|
|
assert.deepEqual(rent, [8, 15, 22, 29], 'rent every Monday from the second week');
|
|
});
|
|
|
|
test('the calendar names the day and counts the week', () => {
|
|
let state = startGame(pack, { seed: 'calendar' });
|
|
assert.deepEqual(
|
|
[state.calendar.name, state.calendar.cycle, state.calendar.index],
|
|
['Monday', 1, 0],
|
|
);
|
|
for (let t = 0; t < 11; t++) {
|
|
const turn = currentTurn(pack, state);
|
|
state = takeTurn(pack, state, turn.options.find((o) => o.available).option.id).state;
|
|
}
|
|
assert.equal(state.turn, 12);
|
|
assert.deepEqual([state.calendar.name, state.calendar.cycle], ['Friday', 2]);
|
|
});
|
|
|
|
test('debt cannot spiral without limit: lending stops and the charge escalates', () => {
|
|
let peakDebt = 0;
|
|
for (const strategy of ['spendthrift', 'desperate', 'random']) {
|
|
for (let seed = 0; seed < 12; seed++) {
|
|
play(pack, { strategy, seed, turns: 200,
|
|
visit: ({ state }) => { peakDebt = Math.max(peakDebt, state.debt); } });
|
|
}
|
|
}
|
|
// The loan is gated at debt < 2000 and lends 500, so 2500 plus stalled
|
|
// interest is the ceiling. Well above that means the gate has stopped working.
|
|
assert.ok(peakDebt <= 3000, `debt reached ${peakDebt}; the borrowing ceiling is not holding`);
|
|
|
|
const charges = pack.upkeep.filter((u) => u.label?.startsWith('Loan service charge'));
|
|
assert.ok(charges.length >= 3, 'the service charge should step up as debt grows');
|
|
});
|
|
|
|
test('a deliberate player can reach relationship-gated content in a reasonable run', () => {
|
|
// The Trevor chain is the deepest gate in the pack. A player cultivating him
|
|
// should get there well inside a normal session, not at turn 100.
|
|
const regard = (o) => (o.option.effects ?? [])
|
|
.filter((e) => e.path === 'relationships.trevor.likes_you')
|
|
.reduce((sum, e) => sum + e.value, 0);
|
|
|
|
let state = startGame(pack, { seed: 'befriend-trevor' });
|
|
let reached = null;
|
|
for (let t = 0; t < 60 && reached === null; t++) {
|
|
const turn = currentTurn(pack, state);
|
|
if (turn.event.id === 'trevor_favor') { reached = state.turn; break; }
|
|
const best = turn.options.filter((o) => o.available).sort((a, b) => regard(b) - regard(a))[0];
|
|
state = takeTurn(pack, state, best.option.id).state;
|
|
}
|
|
assert.ok(reached && reached < 45, `Trevor's chain opened at turn ${reached ?? 'never'}`);
|
|
});
|
|
|