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
259 lines
12 KiB
JavaScript
259 lines
12 KiB
JavaScript
// The Frontier's own tuning.
|
|
//
|
|
// What is true of this pack because it is *this* pack: a four-day run instead
|
|
// of a week, a settlement that barely covers the room, a store ledger rather
|
|
// than a loan, and when the star is offered. Everything true of a pack because
|
|
// it is a pack is in conformance.test.js, which runs over every pack in the
|
|
// registry — including this one.
|
|
|
|
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.
|
|
let state = startGame(pack, { seed: 'calendar' });
|
|
assert.deepEqual(
|
|
[state.calendar.name, state.calendar.cycle, state.calendar.index],
|
|
['Stage day', 1, 0],
|
|
);
|
|
const names = [];
|
|
for (let t = 0; t < 7; t++) {
|
|
names.push(state.calendar.name);
|
|
const turn = currentTurn(pack, state);
|
|
state = takeTurn(pack, state, turn.options.find((o) => o.available).option.id).state;
|
|
}
|
|
assert.deepEqual(names,
|
|
['Stage day', 'Haul', 'Yard', 'Rest', 'Stage day', 'Haul', 'Yard']);
|
|
assert.equal(state.calendar.cycle, 2, 'the second run has begun');
|
|
});
|
|
|
|
test('the settlement and the room land on the days the pack says', () => {
|
|
const settlement = [];
|
|
const room = [];
|
|
let state = startGame(pack, { seed: 'rhythm' });
|
|
for (let t = 0; t < 16; 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 === 'Settlement') settlement.push(state.turn);
|
|
if (change.label === 'Room at the Cordwood') room.push(state.turn);
|
|
}
|
|
state = result.state;
|
|
}
|
|
// The boarding house collects on the Rest day, the night before the outfit
|
|
// settles up on the Stage day. That ordering is the whole reason a hand is
|
|
// never quite ahead.
|
|
assert.deepEqual(room, [4, 8, 12, 16], 'the room is collected every Rest day');
|
|
assert.deepEqual(settlement, [5, 9, 13], 'the outfit settles up every Stage day');
|
|
});
|
|
|
|
test('the star is worth taking, and reachable by a deliberate player', () => {
|
|
const { byStrategy } = sweep(pack, { seeds: 12, turns: 200 });
|
|
|
|
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');
|
|
|
|
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.
|
|
const timings = [];
|
|
for (let seed = 0; seed < 12; seed++) {
|
|
let at = null;
|
|
play(pack, { strategy: 'sociable', seed, turns: 200,
|
|
visit: ({ state }) => { if (state.stage === 'deputy' && 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 >= 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;
|
|
play(pack, { strategy, seed: 'ht', turns: 200,
|
|
visit: ({ turn }) => { if (turn.event.id === 'store_cut_off') days++; } });
|
|
return days;
|
|
};
|
|
assert.equal(countHardTimes('thrifty'), 0, 'minding the money should keep you out of the book');
|
|
assert.ok(countHardTimes('spendthrift') > 0, 'ignoring it should land you in it');
|
|
});
|
|
|
|
test('the store ledger cannot spiral without limit', () => {
|
|
let peak = 0;
|
|
for (const strategy of ['spendthrift', 'desperate', 'random']) {
|
|
for (let seed = 0; seed < 12; seed++) {
|
|
play(pack, { strategy, seed, turns: 200,
|
|
visit: ({ state }) => { peak = Math.max(peak, state.owed); } });
|
|
}
|
|
}
|
|
// He stops carrying you at 120 and the largest single line is 32, so 152
|
|
// plus a few stalled charges is the ceiling. Well above that means the gate
|
|
// has stopped working.
|
|
assert.ok(peak <= 220, `the ledger reached ${peak}; the credit ceiling is not holding`);
|
|
|
|
const charges = pack.upkeep.filter((u) => u.label?.startsWith('Store credit'));
|
|
assert.ok(charges.length >= 3, 'the charge should step up as the ledger grows');
|
|
});
|
|
|
|
test('a skill gate is never gated behind itself', () => {
|
|
// The lesson Corporate Ladder's logs taught, applied here as a rule rather
|
|
// than as a hope. Four of its options need negotiation 15 and several of its
|
|
// largest negotiation gains sit behind that same gate, so a player who is
|
|
// not deliberately chasing the skill bounces off the wall for thirty turns.
|
|
//
|
|
// The structural property that prevents it: for every skill threshold in the
|
|
// pack, the growth available *without* passing that threshold must be able
|
|
// to reach it, and repeatably — a single one-off award is not a route.
|
|
const gates = new Map();
|
|
for (const event of pack.events) {
|
|
for (const option of event.options ?? []) {
|
|
for (const requirement of option.requires ?? []) {
|
|
if (!requirement.path.startsWith('skills.')) continue;
|
|
const highest = gates.get(requirement.path) ?? 0;
|
|
gates.set(requirement.path, Math.max(highest, requirement.value));
|
|
}
|
|
}
|
|
}
|
|
assert.ok(gates.size > 0, 'the pack should gate something on a skill');
|
|
|
|
for (const [path, highest] of gates) {
|
|
const start = pack.state[path].initial;
|
|
let repeatable = 0;
|
|
for (const event of pack.events) {
|
|
if (event.once) continue;
|
|
for (const option of event.options ?? []) {
|
|
// Ungated on this skill: a route a player has open from turn one.
|
|
if ((option.requires ?? []).some((r) => r.path === path)) continue;
|
|
const gain = (option.effects ?? [])
|
|
.filter((e) => e.path === path && e.op === 'add')
|
|
.reduce((sum, e) => sum + e.value, 0);
|
|
if (gain > 0) repeatable += gain;
|
|
}
|
|
}
|
|
assert.ok(start + repeatable > highest,
|
|
`${path} tops out at ${highest}, but only ${repeatable} is reachable without it`);
|
|
}
|
|
});
|
|
|
|
test('a player who wants a skill does not wait long for the first door', () => {
|
|
// Measured rather than assumed: a player steering for a skill should be
|
|
// through its lowest gate inside the first two runs of the stage.
|
|
const firstGate = 12;
|
|
for (const path of ['skills.nerve', 'skills.trade']) {
|
|
const gain = (o) => (o.option.effects ?? [])
|
|
.filter((e) => e.path === path && e.op === 'add')
|
|
.reduce((sum, e) => sum + e.value, 0);
|
|
|
|
const times = [];
|
|
for (let seed = 0; seed < 12; seed++) {
|
|
let state = startGame(pack, { seed: `gate-${seed}` });
|
|
let at = null;
|
|
for (let t = 0; t < 120 && at === null; t++) {
|
|
const turn = currentTurn(pack, state);
|
|
const available = turn.options.filter((o) => o.available);
|
|
const best = [...available].sort((a, b) => gain(b) - gain(a))[0];
|
|
state = takeTurn(pack, state, best.option.id).state;
|
|
const value = path.split('.').reduce((o, k) => o?.[k], state);
|
|
if (value >= firstGate) at = state.turn;
|
|
}
|
|
times.push(at ?? Infinity);
|
|
}
|
|
const typical = median(times);
|
|
assert.ok(typical <= 20,
|
|
`${path} reached ${firstGate} at turn ${typical}; the first door is too far off`);
|
|
}
|
|
});
|
|
|
|
test('a deliberate player can put Dutch in their debt', () => {
|
|
// The deepest relationship gate in the pack: `send_for_dutch` in the
|
|
// marshal's office needs a favour banked two rungs earlier.
|
|
const owed = (o) => (o.option.effects ?? [])
|
|
.filter((e) => e.path === 'relationships.dutch.owes_you')
|
|
.reduce((sum, e) => sum + e.value, 0);
|
|
|
|
let state = startGame(pack, { seed: 'dutch' });
|
|
let reached = null;
|
|
for (let t = 0; t < 60 && reached === null; t++) {
|
|
if (state.relationships.dutch.owes_you >= 25) { reached = state.turn; break; }
|
|
const turn = currentTurn(pack, state);
|
|
const best = turn.options.filter((o) => o.available).sort((a, b) => owed(b) - owed(a))[0];
|
|
state = takeTurn(pack, state, best.option.id).state;
|
|
}
|
|
assert.ok(reached && reached < 45, `Dutch's book opened at turn ${reached ?? 'never'}`);
|
|
});
|