Both settings now carry a charge levied by standing itself, which no purchase ends. Corporate Ladder's steps on money (1200/-60, 2400/-120, 3600/-240): a flat charge cannot flatten a slope, since late income scales with how well the player plays, and the flat charge needed to stop an optimiser accumulating put ordinary archetypes below zero for 44% of a run. Stepped, the balance settles around $4,250 and ordinary play comes out better than before it existed. The Frontier's is flat at -4 a run, because its late game never accumulated in the first place. Found in the first two human sessions of this work: - A career saved before a pack gained a stat had no path for it, and conditions compare numbers only, so every event, option and upkeep entry gated on a new stat silently did not exist for that run. Loading now fills those in. - 'Wrap up my career' sat immediately after the last option in tab order, so one Tab too many and Enter read as the game ending at day 31. It goes last, and the retrospective says plainly that it is a summary. - A 181-turn session ran against a stale bundle and the change under test never fired. Every logged turn now records app_version and build. - src/version.js had reported 0.3.0 since 0.3.0, while playtesters were told to check the status bar. A test now holds it to package.json. - 'Put money against the book' was shown locked to a player who owed nothing, six times. Options gated on a state the player cannot choose to enter are now hidden rather than greyed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RxdJFbLq1rBKsUeticGV1g
229 lines
11 KiB
JavaScript
229 lines
11 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`);
|
|
|
|
// And the top of the ladder is not a plateau. A 96-turn session bought both
|
|
// tiers, reached 100 standing by turn 84, and from there had nothing left to
|
|
// spend on: every charge was switched off and the balance only climbed.
|
|
// Standing costs money to carry at every tier, with nothing to buy that ends
|
|
// it, so there is no position in this pack where money stops mattering.
|
|
const arrived = netPerCycle({ stage: 'dispatch', lifestyle: 2, reputation: 90, money: 2500 });
|
|
assert.ok(arrived < address,
|
|
`standing itself should cost something: ${arrived} against ${address} a week`);
|
|
|
|
// And it steepens with what there is to keep up, which is what stops the
|
|
// balance climbing forever. A flat charge could not: late income scales with
|
|
// how well the player plays, so flattening an optimiser's slope with one
|
|
// meant putting ordinary play 44% of a run below zero.
|
|
const wealthy = netPerCycle({ stage: 'dispatch', lifestyle: 2, reputation: 90, money: 6000 });
|
|
assert.ok(wealthy < arrived,
|
|
`the charge should steepen with money: ${wealthy} against ${arrived} a week`);
|
|
assert.ok(wealthy < 0, `and take a rich coordinator backwards: ${wealthy} a week`);
|
|
|
|
// At every tier, not only the top one: a coordinator who bought nothing is
|
|
// already paying for standing in standing, and pays in money besides.
|
|
const plainSomebody = netPerCycle({ stage: 'dispatch', lifestyle: 0, reputation: 90, money: 6000 });
|
|
assert.ok(plainSomebody < plain,
|
|
`standing should cost at tier 0 too: ${plainSomebody} against ${plain} a week`);
|
|
|
|
// 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'}`);
|
|
});
|
|
|