Add The Frontier, a second setting, and a picker to choose between them
Cold Fork is a freight yard: 29 events across two stages, a four-day cycle, and its own relationship and skill vocabulary. src/engine/ was not touched. Adding it needed three changes outside the engine: one save slot per pack, app.js holding the current pack, and turn.js no longer naming the mailroom (DECISIONS #34). build.js now handles imports wrapped across lines. A conformance suite runs over every registered pack. DECISIONS #35 records three calls on open questions. 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
4957c24e31
commit
7f20be83e8
@@ -36,6 +36,18 @@ test('the bundle builds, and the bundled game boots and plays', () => {
|
||||
// eslint-disable-next-line no-new-func -- running the build output is the point
|
||||
new Function(script)();
|
||||
|
||||
// Nothing is saved and nothing is remembered, so a build shipping more
|
||||
// than one setting opens on the picker. Click into the first one and carry
|
||||
// on — this exercises the front door in the real bundle, which is the one
|
||||
// place the pack registry, the picker screen and the save slots all meet.
|
||||
assert.ok(firstByClass(dom.root, 'screen--packs'), 'the bundled game rendered the picker');
|
||||
assert.match(dom.root.textContent, /Corporate Ladder/, 'the packs are named');
|
||||
assert.match(dom.root.textContent, /The Frontier/);
|
||||
|
||||
const start = byClass(dom.root, 'button').find((b) => /Start here|Keep playing/.test(b.textContent));
|
||||
assert.ok(start, 'the picker offers a way in');
|
||||
start.click();
|
||||
|
||||
assert.ok(firstByClass(dom.root, 'screen--turn'), 'the bundled game rendered a turn');
|
||||
assert.match(dom.root.textContent, /Day 1/);
|
||||
assert.match(dom.root.textContent, /Marlene Hobb/);
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
// What every content pack must do, whatever setting it is.
|
||||
//
|
||||
// This suite runs over `content/index.js` — every pack the build ships — and
|
||||
// asserts only things that are true of a pack because it is a pack, never
|
||||
// because it is about a mailroom or a freight yard. A new setting is therefore
|
||||
// tested the moment it appears in the registry, rather than by copying this
|
||||
// file and changing the nouns.
|
||||
//
|
||||
// Per-pack tuning lives with the pack: content.test.js and frontier.test.js
|
||||
// hold each one to its own economy, its own promotion and its own characters.
|
||||
// Anything a pack must declare for this suite to check it — the economic
|
||||
// baseline, for one — is a field on the pack, not a constant in here.
|
||||
//
|
||||
// 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.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { packs } from '../content/index.js';
|
||||
import { validatePack } from '../src/engine/validate.js';
|
||||
import { startGame, currentTurn, takeTurn } from '../src/engine/game.js';
|
||||
import { createRng } from '../src/engine/rng.js';
|
||||
import { play, sweep, median, strategies } from './fixtures/strategies.js';
|
||||
import { summarize } from '../src/ui/summary.js';
|
||||
|
||||
/** Play `turns` turns of `pack` with independently-seeded random choices. */
|
||||
function randomRun(pack, runSeed, turns) {
|
||||
const rng = createRng(`chooser-${runSeed}`);
|
||||
let state = startGame(pack, { seed: `run-${runSeed}` });
|
||||
for (let t = 0; t < turns; t++) {
|
||||
const turn = currentTurn(pack, state);
|
||||
const available = turn.options.filter((o) => o.available);
|
||||
assert.ok(available.length > 0,
|
||||
`${pack.id}: no available option on event "${turn.event.id}"`);
|
||||
state = takeTurn(pack, state, rng.pick(available).option.id).state;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Net movement of `money` from upkeep alone, per calendar cycle, at one stage.
|
||||
*
|
||||
* Derived rather than simulated: an entry worth `v` every `n` turns is worth
|
||||
* `v * cycleLength / n` per cycle, exactly, with no window to fall off the end
|
||||
* of. Simulating this over a fixed number of turns reads differently depending
|
||||
* 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.
|
||||
*/
|
||||
function baselinePerCycle(pack, stage) {
|
||||
const cycle = pack.calendar?.cycleLength ?? 1;
|
||||
const debtish = (path) => /debt|owed|loan/i.test(path);
|
||||
return (pack.upkeep ?? [])
|
||||
.filter((entry) => {
|
||||
const gate = entry.requires ?? [];
|
||||
if (gate.some((c) => debtish(c.path))) return false;
|
||||
return !gate.some((c) => c.path === 'stage' && c.value !== stage);
|
||||
})
|
||||
.reduce((total, entry) => {
|
||||
const money = entry.effects
|
||||
.filter((e) => e.path === 'money' && e.op === 'add')
|
||||
.reduce((sum, e) => sum + e.value, 0);
|
||||
return total + money * (cycle / (entry.every ?? 1));
|
||||
}, 0);
|
||||
}
|
||||
|
||||
for (const pack of packs) {
|
||||
const where = `[${pack.id}]`;
|
||||
|
||||
test(`${where} the pack is valid, with no warnings`, () => {
|
||||
const result = validatePack(pack);
|
||||
assert.deepEqual(result.errors, []);
|
||||
assert.deepEqual(result.warnings, []);
|
||||
});
|
||||
|
||||
test(`${where} declares what the interface and this suite need from it`, () => {
|
||||
// Everything the picker shows and everything asserted below. A pack that
|
||||
// omits one of these is not a broken game, but it is a pack that will
|
||||
// render badly or go unchecked, and both should fail loudly at build time.
|
||||
assert.ok(pack.name, 'a display name');
|
||||
assert.ok(pack.blurb, 'a one-line blurb for the setting picker');
|
||||
assert.ok(pack.peopleHeading, 'a heading for the panel of characters');
|
||||
assert.ok(pack.stages && Object.keys(pack.stages).length > 0, 'named stages');
|
||||
assert.ok(pack.tuning?.baselinePerCycle, 'a declared economic baseline');
|
||||
for (const stage of Object.keys(pack.stages)) {
|
||||
assert.ok(pack.stages[stage].title, `a title for stage "${stage}"`);
|
||||
assert.ok(pack.tuning.baselinePerCycle[stage], `a baseline for stage "${stage}"`);
|
||||
}
|
||||
});
|
||||
|
||||
test(`${where} no content is dead: every event fires and every option becomes available`, () => {
|
||||
// Across archetypes, not just random play: several things in a pack exist
|
||||
// for a player pursuing something, and "broke but well-liked" in
|
||||
// particular is a state no single-axis strategy ever reaches.
|
||||
//
|
||||
// Options are checked on *availability*, not on being taken. Whether a
|
||||
// particular bot picks an option is an artefact of how the bot scores;
|
||||
// what matters is whether a player is ever offered it unlocked.
|
||||
const { firedEvents, offeredOptions } = sweep(pack, { seeds: 12, turns: 250 });
|
||||
|
||||
const allEvents = pack.events.map((e) => e.id);
|
||||
const allOptions = pack.events.flatMap((e) => e.options.map((o) => `${e.id}.${o.id}`));
|
||||
assert.deepEqual(allEvents.filter((e) => !firedEvents.has(e)), [],
|
||||
'events that can never fire');
|
||||
assert.deepEqual(allOptions.filter((o) => !offeredOptions.has(o)), [],
|
||||
'options no player can ever be offered');
|
||||
});
|
||||
|
||||
test(`${where} every event leaves a player with nothing at least one way out`, () => {
|
||||
// A player with no skills, no money and no friends must still be able to
|
||||
// take a turn. The engine guarantees an event will be drawn; only content
|
||||
// can guarantee it is playable when everything is gated.
|
||||
const trapped = pack.events
|
||||
.filter((event) => !(event.options ?? []).some((option) => !option.requires))
|
||||
.map((event) => event.id);
|
||||
assert.deepEqual(trapped, [], 'events whose every option is gated');
|
||||
});
|
||||
|
||||
test(`${where} every turn always offers something to do, however badly it is going`, () => {
|
||||
for (let seed = 100; seed < 130; seed++) randomRun(pack, seed, 150);
|
||||
});
|
||||
|
||||
test(`${where} a played run replays identically from its seed`, () => {
|
||||
assert.deepEqual(randomRun(pack, 42, 80), randomRun(pack, 42, 80));
|
||||
});
|
||||
|
||||
test(`${where} how you play changes where you end up`, () => {
|
||||
// Measured before promotions land, so this is about how a player handles
|
||||
// money rather than how fast they left the first rung. Mixing the two
|
||||
// hides the economy behind the career.
|
||||
const { byStrategy } = sweep(pack, { seeds: 12, turns: 35 });
|
||||
const finalMoney = (name) => median(byStrategy[name].map((s) => s.money));
|
||||
assert.ok(byStrategy.thrifty.every((s) => s.stage === pack.startingStage),
|
||||
'nobody should be promoted this early');
|
||||
assert.ok(finalMoney('thrifty') > finalMoney('random'),
|
||||
'a careful player should get ahead');
|
||||
assert.ok(finalMoney('random') > finalMoney('spendthrift'),
|
||||
'a careless player should struggle');
|
||||
});
|
||||
|
||||
test(`${where} the upkeep baseline matches what the pack declares`, () => {
|
||||
// The guard that was missing when Corporate Ladder's rent was raised from
|
||||
// 385 to 455 without re-deriving what it meant: the baseline went from
|
||||
// -$3/day to -$13/day and trapped a real playtester below zero for 30% of
|
||||
// a 182-turn run. No archetype caught it — they all either optimised money
|
||||
// or were promoted out of the problem before it bit.
|
||||
for (const [stage, [low, high]] of Object.entries(pack.tuning.baselinePerCycle)) {
|
||||
const net = baselinePerCycle(pack, stage);
|
||||
assert.ok(net > low && net < high,
|
||||
`"${stage}" nets ${net} a cycle; the pack declares (${low}, ${high})`);
|
||||
}
|
||||
});
|
||||
|
||||
test(`${where} 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 service charge exceeds
|
||||
// anything the escape options can pay — inescapable by arithmetic.
|
||||
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`);
|
||||
}
|
||||
});
|
||||
|
||||
test(`${where} a run that goes broke can climb back out`, () => {
|
||||
let sawNegative = false;
|
||||
let recovered = false;
|
||||
play(pack, { strategy: 'spendthrift', seed: 'recovery', turns: 200,
|
||||
visit: ({ state }) => {
|
||||
if (state.money < 0) sawNegative = true;
|
||||
else if (sawNegative) recovered = true;
|
||||
} });
|
||||
assert.ok(sawNegative, 'a careless run should go broke');
|
||||
assert.ok(recovered, 'and should be able to climb back out');
|
||||
});
|
||||
|
||||
test(`${where} a long run does not collapse onto a handful of situations`, () => {
|
||||
// Motivated by a real 183-turn session. Before Dispatch was fleshed out,
|
||||
// its two floor events were 46% of every promoted run — one every three
|
||||
// turns. Variety is a property of the pack, and this should fail loudly
|
||||
// when a stage is too thin for the run it invites.
|
||||
for (const strategy of ['sociable', 'thrifty', 'random']) {
|
||||
const counts = new Map();
|
||||
let turns = 0;
|
||||
play(pack, { strategy, seed: 'variety', turns: 200,
|
||||
visit: ({ turn }) => {
|
||||
counts.set(turn.event.id, (counts.get(turn.event.id) ?? 0) + 1);
|
||||
turns++;
|
||||
} });
|
||||
|
||||
const [topEvent, topCount] = [...counts.entries()].sort((a, b) => b[1] - a[1])[0];
|
||||
const share = topCount / turns;
|
||||
assert.ok(share < 0.25,
|
||||
`${strategy}: "${topEvent}" was ${(share * 100).toFixed(0)}% of turns`);
|
||||
|
||||
// Measured against what was actually reachable: a run that never leaves
|
||||
// the first stage cannot see the second, and counting that as thin
|
||||
// content would be measuring career progress rather than variety.
|
||||
const visited = new Set([...counts.keys()]
|
||||
.map((id) => pack.events.find((e) => e.id === id).stage)
|
||||
.filter(Boolean));
|
||||
const reachable = pack.events
|
||||
.filter((e) => e.stage === undefined || visited.has(e.stage)).length;
|
||||
assert.ok(counts.size / reachable > 0.6,
|
||||
`${strategy}: saw ${counts.size} of ${reachable} reachable situations in ${turns} turns`);
|
||||
}
|
||||
});
|
||||
|
||||
test(`${where} the retrospective has something to read back`, () => {
|
||||
const state = randomRun(pack, 3, 60);
|
||||
assert.ok(state.notable.length > 0, 'notable moments should accumulate');
|
||||
assert.equal(state.history.length, 60, 'every turn is recorded');
|
||||
assert.ok(state.history.every((h) => Array.isArray(h.upkeep)), 'upkeep is recorded per turn');
|
||||
});
|
||||
|
||||
test(`${where} the retrospective reports how repetitive the run was`, () => {
|
||||
const state = play(pack, { strategy: 'sociable', seed: 'repeats', turns: 120 });
|
||||
const summary = summarize(pack, state);
|
||||
|
||||
assert.equal(summary.turns, 120);
|
||||
assert.ok(summary.eventsSeen > 0 && summary.eventsSeen <= summary.eventsTotal);
|
||||
assert.ok(summary.averageTimesSeen > 1, 'a long run repeats situations');
|
||||
assert.equal(
|
||||
summary.averageTimesSeen.toFixed(2),
|
||||
(summary.turns / summary.eventsSeen).toFixed(2),
|
||||
'the average is turns over distinct situations',
|
||||
);
|
||||
assert.ok(summary.mostSeen.count >= summary.averageTimesSeen);
|
||||
assert.ok(summary.repeatedSituations <= summary.eventsSeen);
|
||||
assert.ok(summary.stagesVisited.includes(pack.startingStage));
|
||||
});
|
||||
|
||||
if (pack.calendar) {
|
||||
test(`${where} the calendar names the unit and counts the cycle`, () => {
|
||||
const { cycleLength, unitNames } = pack.calendar;
|
||||
let state = startGame(pack, { seed: 'calendar' });
|
||||
assert.deepEqual(
|
||||
[state.calendar.name, state.calendar.cycle, state.calendar.index],
|
||||
[unitNames?.[0] ?? null, 1, 0],
|
||||
'turn 1 is the first unit of the first cycle',
|
||||
);
|
||||
|
||||
// One full cycle on, the name comes round and the cycle has ticked.
|
||||
for (let t = 0; t < cycleLength; t++) {
|
||||
const turn = currentTurn(pack, state);
|
||||
state = takeTurn(pack, state, turn.options.find((o) => o.available).option.id).state;
|
||||
}
|
||||
assert.equal(state.turn, cycleLength + 1);
|
||||
assert.deepEqual(
|
||||
[state.calendar.name, state.calendar.cycle, state.calendar.index],
|
||||
[unitNames?.[0] ?? null, 2, 0],
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
+10
-206
@@ -1,9 +1,13 @@
|
||||
// Tests for the shipped Corporate Ladder pack.
|
||||
// Corporate Ladder's own tuning.
|
||||
//
|
||||
// These are content tests, not engine tests: they assert that the pack is
|
||||
// playable, that none of it is dead, and that its economy behaves. Engine
|
||||
// behaviour is covered against the fixture pack instead, so tuning the game
|
||||
// cannot break the engine suite and vice versa.
|
||||
// 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
|
||||
@@ -14,65 +18,8 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { corporateLadder as pack } from '../content/corporateladder/index.js';
|
||||
import { validatePack } from '../src/engine/validate.js';
|
||||
import { startGame, currentTurn, takeTurn } from '../src/engine/game.js';
|
||||
import { createRng } from '../src/engine/rng.js';
|
||||
import { play, sweep, median, strategies } from './fixtures/strategies.js';
|
||||
import { summarize } from '../src/ui/summary.js';
|
||||
|
||||
/** Play `turns` turns with independently-seeded random choices. */
|
||||
function randomRun(runSeed, turns, visit = () => {}) {
|
||||
const rng = createRng(`chooser-${runSeed}`);
|
||||
let state = startGame(pack, { seed: `run-${runSeed}` });
|
||||
for (let t = 0; t < turns; t++) {
|
||||
const turn = currentTurn(pack, state);
|
||||
const available = turn.options.filter((o) => o.available);
|
||||
assert.ok(available.length > 0, `no available option on event "${turn.event.id}"`);
|
||||
const choice = rng.pick(available).option.id;
|
||||
visit(turn, state, choice);
|
||||
state = takeTurn(pack, state, choice).state;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
test('the pack is valid, with no warnings', () => {
|
||||
const result = validatePack(pack);
|
||||
assert.deepEqual(result.errors, []);
|
||||
assert.deepEqual(result.warnings, []);
|
||||
});
|
||||
|
||||
test('no content is dead: every event fires and every option becomes available', () => {
|
||||
// Across archetypes, not just random play: several things in the pack exist
|
||||
// for a player pursuing something, and "broke but well-liked" in particular
|
||||
// is a state no single-axis strategy ever reaches.
|
||||
//
|
||||
// Options are checked on *availability*, not on being taken. Whether a
|
||||
// particular bot picks an option is an artefact of how the bot scores; what
|
||||
// matters is whether a player is ever offered it unlocked.
|
||||
const { firedEvents, offeredOptions } = sweep(pack, { seeds: 12, turns: 250 });
|
||||
|
||||
const allEvents = pack.events.map((e) => e.id);
|
||||
const allOptions = pack.events.flatMap((e) => e.options.map((o) => `${e.id}.${o.id}`));
|
||||
assert.deepEqual(allEvents.filter((e) => !firedEvents.has(e)), [], 'events that can never fire');
|
||||
assert.deepEqual(allOptions.filter((o) => !offeredOptions.has(o)), [],
|
||||
'options no player can ever be offered');
|
||||
});
|
||||
|
||||
test('how you play changes where you end up', () => {
|
||||
// Measured before promotions land, so this is about how a player handles
|
||||
// money rather than about how fast they got out of the mailroom. Mixing the
|
||||
// two hides the economy behind the career.
|
||||
const { byStrategy } = sweep(pack, { seeds: 12, turns: 35 });
|
||||
const finalMoney = (name) => median(byStrategy[name].map((s) => s.money));
|
||||
assert.ok(byStrategy.thrifty.every((s) => s.stage === 'mailroom'),
|
||||
'nobody should be promoted this early');
|
||||
|
||||
// The shape that matters: minding the money climbs, ignoring it sinks, and
|
||||
// playing without a plan roughly treads water. Exact figures move with
|
||||
// tuning; the ordering is the design.
|
||||
assert.ok(finalMoney('thrifty') > finalMoney('random'), 'a careful player should get ahead');
|
||||
assert.ok(finalMoney('random') > finalMoney('spendthrift'), 'a careless player should struggle');
|
||||
});
|
||||
import { play, sweep, median } from './fixtures/strategies.js';
|
||||
|
||||
test('the promotion is worth taking, and reachable by a deliberate player', () => {
|
||||
const { byStrategy } = sweep(pack, { seeds: 12, turns: 200 });
|
||||
@@ -108,11 +55,6 @@ test('hard times is uncommon for a careful player and real for a careless one',
|
||||
assert.ok(countHardTimes('spendthrift') > 0, 'ignoring it should land you in it');
|
||||
});
|
||||
|
||||
test('every turn always offers something to do, however badly the run is going', () => {
|
||||
// randomRun asserts this on every turn; 40 long runs is the coverage.
|
||||
for (let seed = 100; seed < 140; seed++) randomRun(seed, 150);
|
||||
});
|
||||
|
||||
test('money arrives and leaves on a rhythm the player can plan around', () => {
|
||||
const wages = [];
|
||||
const rent = [];
|
||||
@@ -145,24 +87,6 @@ test('the calendar names the day and counts the week', () => {
|
||||
assert.deepEqual([state.calendar.name, state.calendar.cycle], ['Friday', 2]);
|
||||
});
|
||||
|
||||
test('hard times is survivable without any relationship or skill', () => {
|
||||
const event = pack.events.find((e) => e.id === 'rent_bounced');
|
||||
const ungated = event.options.filter((o) => !o.requires);
|
||||
assert.ok(ungated.length > 0, 'a player with nothing must still have a way out');
|
||||
});
|
||||
|
||||
test('the run recovers from hard times rather than sticking there', () => {
|
||||
let sawNegative = false;
|
||||
let recovered = false;
|
||||
play(pack, { strategy: 'spendthrift', seed: 'recovery', turns: 200,
|
||||
visit: ({ state }) => {
|
||||
if (state.money < 0) sawNegative = true;
|
||||
else if (sawNegative) recovered = true;
|
||||
} });
|
||||
assert.ok(sawNegative, 'a careless run should go broke');
|
||||
assert.ok(recovered, 'and should be able to climb back out');
|
||||
});
|
||||
|
||||
test('debt cannot spiral without limit: lending stops and the charge escalates', () => {
|
||||
let peakDebt = 0;
|
||||
for (const strategy of ['spendthrift', 'desperate', 'random']) {
|
||||
@@ -197,123 +121,3 @@ test('a deliberate player can reach relationship-gated content in a reasonable r
|
||||
assert.ok(reached && reached < 45, `Trevor's chain opened at turn ${reached ?? 'never'}`);
|
||||
});
|
||||
|
||||
test('a played run replays identically from its seed', () => {
|
||||
const a = randomRun(42, 80);
|
||||
const b = randomRun(42, 80);
|
||||
assert.deepEqual(a, b);
|
||||
});
|
||||
|
||||
test('the retrospective has something to read back', () => {
|
||||
const state = randomRun(3, 60);
|
||||
assert.ok(state.notable.length > 0, 'notable moments should accumulate');
|
||||
assert.equal(state.history.length, 60, 'every turn is recorded');
|
||||
assert.ok(state.history.every((h) => Array.isArray(h.upkeep)), 'upkeep is recorded per turn');
|
||||
});
|
||||
|
||||
test('a long run does not collapse onto a handful of situations', () => {
|
||||
// Motivated by a real 183-turn session. Before Dispatch was fleshed out, its
|
||||
// two floor events were 46% of every promoted run — one every three turns.
|
||||
// This is the guardrail on that: variety is a property of the pack, and it
|
||||
// should fail loudly when a stage is too thin for the run it invites.
|
||||
for (const strategy of ['sociable', 'thrifty', 'random']) {
|
||||
const counts = new Map();
|
||||
let turns = 0;
|
||||
play(pack, { strategy, seed: 'variety', turns: 200,
|
||||
visit: ({ turn }) => {
|
||||
counts.set(turn.event.id, (counts.get(turn.event.id) ?? 0) + 1);
|
||||
turns++;
|
||||
} });
|
||||
|
||||
const [topEvent, topCount] = [...counts.entries()].sort((a, b) => b[1] - a[1])[0];
|
||||
const share = topCount / turns;
|
||||
assert.ok(share < 0.25,
|
||||
`${strategy}: "${topEvent}" was ${(share * 100).toFixed(0)}% of turns`);
|
||||
|
||||
// Measured against what was actually reachable: a run that never leaves
|
||||
// the mailroom cannot see Dispatch, and counting that as thin content
|
||||
// would be measuring career progress rather than variety.
|
||||
const visited = new Set([...counts.keys()]
|
||||
.map((id) => pack.events.find((e) => e.id === id).stage)
|
||||
.filter(Boolean));
|
||||
const reachable = pack.events
|
||||
.filter((e) => e.stage === undefined || visited.has(e.stage)).length;
|
||||
assert.ok(counts.size / reachable > 0.6,
|
||||
`${strategy}: saw ${counts.size} of ${reachable} reachable situations in ${turns} turns`);
|
||||
}
|
||||
});
|
||||
|
||||
test('the retrospective reports how repetitive the run was', () => {
|
||||
const state = play(pack, { strategy: 'sociable', seed: 'repeats', turns: 120 });
|
||||
const summary = summarize(pack, state);
|
||||
|
||||
assert.equal(summary.turns, 120);
|
||||
assert.ok(summary.eventsSeen > 0 && summary.eventsSeen <= summary.eventsTotal);
|
||||
assert.ok(summary.averageTimesSeen > 1, 'a long run repeats situations');
|
||||
assert.equal(
|
||||
summary.averageTimesSeen.toFixed(2),
|
||||
(summary.turns / summary.eventsSeen).toFixed(2),
|
||||
'the average is turns over distinct situations',
|
||||
);
|
||||
assert.ok(summary.mostSeen.count >= summary.averageTimesSeen);
|
||||
assert.ok(summary.repeatedSituations <= summary.eventsSeen);
|
||||
assert.deepEqual(summary.stagesVisited, ['mailroom', 'dispatch'],
|
||||
'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`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
// 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 { play, sweep, median } from './fixtures/strategies.js';
|
||||
|
||||
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');
|
||||
assert.ok(median(sworn.map((s) => s.money)) > median(stayed.map((s) => s.money)),
|
||||
'deputy pay should leave a player better off');
|
||||
|
||||
// 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}`);
|
||||
});
|
||||
|
||||
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'}`);
|
||||
});
|
||||
+115
-4
@@ -10,9 +10,17 @@ import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { installFakeDom, byClass, firstByClass, byTag } from './fixtures/fake-dom.js';
|
||||
|
||||
/** Boot the app fresh against a clean fake DOM. */
|
||||
async function boot() {
|
||||
/**
|
||||
* Boot the app fresh against a clean fake DOM.
|
||||
*
|
||||
* `pack` is written to the remembered-pack slot before booting, which is how a
|
||||
* returning player lands back in a setting. Pass null to boot with nothing
|
||||
* remembered and nothing saved, which is the first-time player who gets the
|
||||
* setting picker instead of a turn.
|
||||
*/
|
||||
async function boot({ pack = 'corporateladder' } = {}) {
|
||||
const dom = installFakeDom();
|
||||
if (pack) dom.storage.setItem('theladder.pack.v1', pack);
|
||||
// A fresh module instance per test: the app holds module-level state.
|
||||
const { boot: bootApp } = await import(`../src/ui/app.js?t=${Math.random()}`);
|
||||
bootApp(dom.root);
|
||||
@@ -112,7 +120,7 @@ test('progress is written to local storage and restored on the next boot', async
|
||||
enabledOptions(dom.root)[0].click();
|
||||
firstByClass(dom.root, 'button--next').click();
|
||||
|
||||
const saved = JSON.parse(dom.storage.getItem('theladder.save.v1'));
|
||||
const saved = JSON.parse(dom.storage.getItem('theladder.save.v1.corporateladder'));
|
||||
assert.equal(saved.state.turn, 3);
|
||||
|
||||
// Boot again against the same storage: the run should resume, not restart.
|
||||
@@ -142,7 +150,7 @@ test('every played turn is logged, separately from the save', async () => {
|
||||
assert.ok(Array.isArray(entry.offered), 'the options offered are recorded');
|
||||
assert.equal(entry.rating, null, 'feedback starts empty');
|
||||
|
||||
const save = JSON.parse(dom.storage.getItem('theladder.save.v1'));
|
||||
const save = JSON.parse(dom.storage.getItem('theladder.save.v1.corporateladder'));
|
||||
assert.equal(save.state.history.length, 3);
|
||||
assert.ok(!JSON.stringify(save).includes('rating'), 'feedback is not mixed into the save');
|
||||
} finally {
|
||||
@@ -366,3 +374,106 @@ test('the ledger reads as English, and leaves bookkeeping out of it', async () =
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a first-time player is asked which setting to play', async () => {
|
||||
// Nothing saved and nothing remembered: the front door is the picker rather
|
||||
// than whichever pack happens to be first in the registry.
|
||||
const dom = await boot({ pack: null });
|
||||
try {
|
||||
assert.ok(firstByClass(dom.root, 'screen--packs'), 'the picker rendered');
|
||||
assert.match(text(dom.root), /Corporate Ladder/);
|
||||
assert.match(text(dom.root), /The Frontier/);
|
||||
assert.match(text(dom.root), /Not started/, 'a pack with no run says so');
|
||||
|
||||
const start = byClass(dom.root, 'button').find((b) => /Start here/.test(b.textContent));
|
||||
assert.ok(start, 'the picker offers a way in');
|
||||
start.click();
|
||||
assert.ok(firstByClass(dom.root, 'screen--turn'), 'choosing a setting starts it');
|
||||
} finally {
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('switching settings keeps both runs', async () => {
|
||||
// The defect this guards against: one global save slot, so the first switch
|
||||
// silently threw the other career away.
|
||||
const dom = await boot({ pack: 'corporateladder' });
|
||||
try {
|
||||
enabledOptions(dom.root)[0].click();
|
||||
byClass(dom.root, 'button').find((b) => /Next/.test(b.textContent))?.click();
|
||||
assert.match(text(dom.root), /Day 2/, 'a turn was played in the first setting');
|
||||
|
||||
byClass(dom.root, 'button').find((b) => /Change setting/.test(b.textContent)).click();
|
||||
const toFrontier = byClass(dom.root, 'button')
|
||||
.find((b) => /Start here|Go back to this run/.test(b.textContent));
|
||||
toFrontier.click();
|
||||
|
||||
assert.match(text(dom.root), /Hollis Vane/, 'the second setting is running');
|
||||
assert.match(text(dom.root), /Day 1/, 'and it started fresh');
|
||||
|
||||
// Both slots are on disk, independently.
|
||||
const corporate = JSON.parse(dom.storage.getItem('theladder.save.v1.corporateladder'));
|
||||
const frontier = JSON.parse(dom.storage.getItem('theladder.save.v1.frontier'));
|
||||
assert.equal(corporate.state.turn, 2, 'the first run is still where it was');
|
||||
assert.equal(frontier.state.turn, 1);
|
||||
assert.equal(corporate.state.meta.packId, 'corporateladder');
|
||||
assert.equal(frontier.state.meta.packId, 'frontier');
|
||||
|
||||
// And going back resumes rather than restarting.
|
||||
byClass(dom.root, 'button').find((b) => /Change setting/.test(b.textContent)).click();
|
||||
assert.match(text(dom.root), /Day 2, Mail Associate I, saved/,
|
||||
'the picker reports where the other run is');
|
||||
byClass(dom.root, 'button').find((b) => /Go back to this run/.test(b.textContent)).click();
|
||||
assert.match(text(dom.root), /Day 2/, 'the first career resumed where it was left');
|
||||
assert.match(text(dom.root), /Marlene Hobb/);
|
||||
} finally {
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a run saved before settings were selectable is not lost', async () => {
|
||||
// The old single slot, migrated on read. Someone mid-career when this
|
||||
// shipped should find their career, not a new one.
|
||||
const dom = installFakeDom();
|
||||
try {
|
||||
const { startGame } = await import('../src/engine/game.js');
|
||||
const { corporateLadder } = await import('../content/corporateladder/index.js');
|
||||
const legacy = startGame(corporateLadder, { seed: 'legacy' });
|
||||
legacy.turn = 61;
|
||||
dom.storage.setItem('theladder.save.v1',
|
||||
JSON.stringify({ format: 1, savedAt: null, state: legacy }));
|
||||
|
||||
const { boot: bootApp } = await import(`../src/ui/app.js?t=${Math.random()}`);
|
||||
bootApp(dom.root);
|
||||
|
||||
assert.ok(firstByClass(dom.root, 'screen--turn'), 'it booted into the run, not the picker');
|
||||
assert.match(text(dom.root), /Day 61/, 'the old career came back');
|
||||
} finally {
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('the header reads the second pack\'s display map, not the first\'s', async () => {
|
||||
// `display` is a per-pack contract with the interface, and a typo in it is
|
||||
// only visible at runtime. Corporate Ladder's version is covered above; this
|
||||
// is the same assertion against a pack with entirely different stat names.
|
||||
const dom = await boot({ pack: 'frontier' });
|
||||
try {
|
||||
const stats = text(firstByClass(dom.root, 'stats'));
|
||||
assert.match(stats, /Money/);
|
||||
assert.match(stats, /\$40/, 'money is formatted as money');
|
||||
assert.match(stats, /Nerve/);
|
||||
assert.match(stats, /Trade/);
|
||||
assert.match(stats, /Standing/);
|
||||
assert.doesNotMatch(stats, /Negotiation|Logistics/, 'no leakage from the other pack');
|
||||
assert.doesNotMatch(stats, /Owed at the store/, 'the ledger is hidden while it is zero');
|
||||
|
||||
assert.match(text(dom.root), /Cold Fork/, 'the people panel is named by the pack');
|
||||
assert.match(text(dom.root), /Hollis Vane/);
|
||||
assert.match(text(dom.root), /Marshal Ward/);
|
||||
assert.match(text(dom.root), /Hired Hand/, 'the stage title comes from the pack');
|
||||
assert.match(text(dom.root), /Stage day/, 'and so does the name of the day');
|
||||
} finally {
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user