// 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 { applyEffects } from '../src/engine/effects.js'; import { resolveSchema } from '../src/engine/state.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 === 'Wages from Vane') 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`); // The top of the ladder is not a plateau here either: standing costs money // to carry at every tier, and nothing you can buy switches it off. // Flat here rather than stepped on money, as Corporate Ladder's is: this // pack's late game does not accumulate in the first place — measured, a // deputy who owns everything drifts 0 a run — so the charge exists to keep // money live, and break-even at the top is the intent rather than a shortfall. const somebody = netPerCycle({ stage: 'deputy', lifestyle: 2, standing: 90 }); assert.ok(somebody < room, `standing itself should cost: ${somebody} against ${room} a run`); assert.ok(somebody <= 0, `and leave a deputy who has everything no better than level: ${somebody} a run`); 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('the horse and the room are each offered only while they can still be bought', () => { // A real session was shown "Buy the bay" locked after buying it, and offered // the room under a description about the horse. See DECISIONS #40. const offered = (lifestyle) => pack.events .filter((e) => ['the_sale_barn', 'the_ellis_room'].includes(e.id)) .filter((e) => check({ stage: 'deputy', lifestyle }, e.requires).passed) .map((e) => e.id); assert.deepEqual(offered(0), ['the_sale_barn']); assert.deepEqual(offered(1), ['the_ellis_room']); assert.deepEqual(offered(2), []); }); test('paying the book down never takes more than is owed', () => { // Turn 27 of that session: four dollars owed, twenty taken. See DECISIONS #38. const schema = resolveSchema(pack); const payDown = pack.events.find((e) => e.id === 'the_store') .options.find((o) => o.id === 'pay_down'); const base = startGame(pack, { seed: 'the-book' }); const small = applyEffects({ ...base, money: 110, owed: 4 }, payDown.effects, schema).state; assert.equal(small.money, 106, 'four owed costs four'); assert.equal(small.owed, 0); const large = applyEffects({ ...base, money: 110, owed: 50 }, payDown.effects, schema).state; assert.equal(large.money, 90, 'more than twenty owed costs twenty'); assert.equal(large.owed, 30); }); 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'}`); });