Step 21.8: balance
CI / check (push) Has been cancelled Details

An away scenario in the balance simulator: hired pilots fly Independent
for 8 hours and earn a median 56% of active play with the same fleet
(target 40-80%), never below the spending floor or stranded (CR-021).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-11 10:49:54 -04:00
parent a75a39e01f
commit 8b1f6428db
5 changed files with 216 additions and 10 deletions

View File

@ -1729,3 +1729,11 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **The logbook replaces per-trip cards for pilots' own trips**: an hour away is a dozen trips per pilot. Each pilot is a collapsible section, open when there's only one. - **The logbook replaces per-trip cards for pilots' own trips**: an hour away is a dozen trips per pilot. Each pilot is a collapsible section, open when there's only one.
- **A logbook job's payout is credits + pilot share + loan repayment**, rebuilt from the summary: what the contract paid before deductions. - **A logbook job's payout is credits + pilot share + loan repayment**, rebuilt from the summary: what the contract paid before deductions.
- **The indicator follows the loop's own pass**, via `onBusy`, not a separate catch-up call, so it covers the first pass on opening and each resume. - **The indicator follows the loop's own pass**, via `onBusy`, not a separate catch-up call, so it covers the first pass on opening and each resume.
## Step 21.8
- **"The same fleet" means the window buys and upgrades nothing in either mode**, and claims no goals, so only who flies differs. In active mode the player still spends skill points as they're earned; Independent pilots don't (decision 0.5.122).
- **Earnings are credits gained plus loan repaid** over the window: repayment is income that went to the lender.
- **The window opens at minute 180**, when every world owns four ships. Seed 777 (0.34) and 99 (0.38) fall below 40% on their own; the target is checked on the median, like the other balance targets.
- **No tuning**: the default 15-game-minute turnaround and 3-minute first look give a median of 56%.
- **A stranded Independent pilot is a violation** when the company has money above the floor but the ship can't reach anywhere. None occurred.

View File

@ -480,5 +480,5 @@ Spec: `memory-bank/change-requests/CR-021-independent-pilots.md` (received as "C
- **Pilot logbook.** The API summary carries `independent`. On the "While you were away" sheet, `pilotLogbook` groups Independent trips by pilot: jobs (good, route, payout, share, fuel), deadheads, rests, XP, and what the pilot is doing now. Company totals sit at the top. The player's own trips stay as cards. - **Pilot logbook.** The API summary carries `independent`. On the "While you were away" sheet, `pilotLogbook` groups Independent trips by pilot: jobs (good, route, payout, share, fuel), deadheads, rests, XP, and what the pilot is doing now. Company totals sit at the top. The player's own trips stay as cards.
Screenshots: `docs/screenshots/21-7-logbook-phone.png`, `21-7-logbook-desktop.png` (Rosa, an hour away: 11–12 jobs, two rests), `21-7-first-look-desktop.png`. Tests (`apps/web/test/board-logbook.test.tsx`): the chip counts down from 02:31 and disappears after the window; no chip without Independent pilots or on a first contact; the indicator only after 0.5 s and never for a quick pass; the logbook's entries, XP and current status per pilot; totals that are the sums of the entries; the sheet shows the logbook and the player's own trip as a card. Summary fixtures gained `independent`. Screenshots: `docs/screenshots/21-7-logbook-phone.png`, `21-7-logbook-desktop.png` (Rosa, an hour away: 11–12 jobs, two rests), `21-7-first-look-desktop.png`. Tests (`apps/web/test/board-logbook.test.tsx`): the chip counts down from 02:31 and disappears after the window; no chip without Independent pilots or on a first contact; the indicator only after 0.5 s and never for a quick pass; the logbook's entries, XP and current status per pilot; totals that are the sums of the entries; the sheet shows the logbook and the player's own trip as a card. Summary fixtures gained `independent`.
- [ ] Step 21.8 — Balance - [x] **Step 21.8 — Balance.** `simulate` takes `scenario: { atMinute, minutes, mode }`. It plays as usual until `atMinute` (180: four ships), then for `minutes` neither buys, upgrades nor claims goals. In `active` mode the usual policy flies every ship. In `away` mode the avatar stays put and hired pilots act at their decision points through the sim's `chooseAction`, as the engine's catch-up does: turnaround, first-look window, the spending floor, waiting and heading home. `scenarioEarnings` is the window's credits gained plus loan repaid. The balance script prints the comparison. Results with default content (7 worlds, 8 hours): away / active ratios 0.34–0.62, median **0.56** (target 40–80%), so no tuning was needed; no pilot bought fuel below the floor or was stranded. Tests (`packages/sim/test/balance.test.ts`): the median ratio is within 40–80%; the away runs have no violations.
- [ ] Step 21.9 — Plan amendments and regression - [ ] Step 21.9 — Plan amendments and regression

View File

@ -32,3 +32,20 @@ for (const policy of ["lighter-first", "freighter-first"] as PurchasePolicy[]) {
console.log("\n=== fuel share of median payout, per ship and route (CR-018) ==="); console.log("\n=== fuel share of median payout, per ship and route (CR-018) ===");
console.log(formatFuelShares(fuelShares(content))); console.log(formatFuelShares(fuelShares(content)));
// The away scenario (CR-021 Step 21.8): 3 hours of play, then 8 hours either way.
const window = (mode: "active" | "away") =>
simulate({
worldSeed,
policy: "lighter-first",
durationMs: 12 * MS_PER_HOUR,
scenario: { atMinute: 180, minutes: 8 * 60, mode },
});
const active = window("active");
const away = window("away");
console.log("\n=== away scenario: 8 h with Independent pilots vs active play (CR-021) ===");
console.log(
`active ${active.scenarioEarnings} cr, away ${away.scenarioEarnings} cr, ` +
`ratio ${((away.scenarioEarnings! / active.scenarioEarnings!) * 100).toFixed(0)}% (target 40–80%), ` +
`violations ${away.violations.length}`,
);

View File

@ -18,6 +18,9 @@ import {
fullTankReach, fullTankReach,
boardPeriodMs, boardPeriodMs,
checkRoute, checkRoute,
chooseAction,
defaultOrders,
refuel,
completedGoals, completedGoals,
contractTripGameHours, contractTripGameHours,
createDispatchRecord, createDispatchRecord,
@ -70,6 +73,13 @@ export interface SimulationOptions {
content?: Content; content?: Content;
/** Called with one line per event, for tracing a run. */ /** Called with one line per event, for tracing a run. */
log?: (line: string) => void; log?: (line: string) => void;
/**
* The away scenario (CR-021 Step 21.8): play as usual until `atMinute`, then
* for `minutes` either keep playing actively with the same fleet ("active"),
* or leave the hired pilots to fly Independent while the player is away
* ("away"). Neither buys, upgrades nor claims goals in that window.
*/
scenario?: { atMinute: number; minutes: number; mode: "active" | "away" };
} }
export interface Sample { export interface Sample {
@ -111,6 +121,8 @@ export interface SimulationResult {
/** Board jobs offered to the player (CR-019): those shown, and those no qualifying pair could complete. */ /** Board jobs offered to the player (CR-019): those shown, and those no qualifying pair could complete. */
jobsShown: number; jobsShown: number;
uncompletableShown: number; uncompletableShown: number;
/** The scenario window's earnings: credits gained plus loan repaid (Step 21.8); null without one. */
scenarioEarnings: number | null;
} }
interface SimShip extends ShipState { interface SimShip extends ShipState {
@ -133,6 +145,9 @@ interface SimPilot {
dutyHours: number; dutyHours: number;
restEndsAt: RealTime | null; restEndsAt: RealTime | null;
inFlight: boolean; inFlight: boolean;
/** Independent pilots (away scenario): the next decision point and empty periods away. */
nextDecisionAt?: RealTime | null;
emptyPeriods?: number;
} }
interface Flight { interface Flight {
@ -393,6 +408,10 @@ export function simulate(options: SimulationOptions): SimulationResult {
} }
pilot.xp += r.pilotXp; pilot.xp += r.pilotXp;
pilot.inFlight = false; pilot.inFlight = false;
if (away) {
pilot.nextDecisionAt =
Math.max(flight.record.arrivalAt, r.pilotRestEndsAt ?? 0) + turnaroundMs;
}
pilot.dutyHours = r.pilotDutyHours; pilot.dutyHours = r.pilotDutyHours;
if (r.pilotRestEndsAt !== null) pilot.restEndsAt = r.pilotRestEndsAt; if (r.pilotRestEndsAt !== null) pilot.restEndsAt = r.pilotRestEndsAt;
if (flight.shipId) { if (flight.shipId) {
@ -788,25 +807,156 @@ export function simulate(options: SimulationOptions): SimulationResult {
...ships.flatMap((s) => ...ships.flatMap((s) =>
s.inShopUntil !== null && s.inShopUntil > now ? [s.inShopUntil] : [], s.inShopUntil !== null && s.inShopUntil > now ? [s.inShopUntil] : [],
), ),
...pilots.flatMap((p) =>
p.nextDecisionAt != null && p.nextDecisionAt > now ? [p.nextDecisionAt] : [],
),
]; ];
return Math.min(...times); return Math.min(...times);
} }
// --- The away scenario (CR-021 Step 21.8). ---
const scenario = options.scenario;
const windowStart = scenario ? start + scenario.atMinute * MS_PER_MINUTE : Infinity;
const turnaroundMs =
(config.independentPilots.turnaroundGameMinutes * MS_PER_MINUTE) / config.timeCompression;
const firstLookMs = config.independentPilots.firstLookRealMinutes * MS_PER_MINUTE;
const floor = config.independentPilots.defaultSpendingFloor;
let away = false;
let windowCredits = 0;
let windowLoan = 0;
// Every simulated pilot is hired at the Earth bay: that is their home HQ.
const home = config.deal.bayLocationId;
let windowOpen = false;
/** One Independent pilot's decision point, as the engine's catch-up makes it. */
function decideIndependent(pilot: SimPilot) {
const ship = ships.find((x) => x.id === pilot.assignedShipId);
if (!ship || ship.inFlight || pilot.inFlight) {
pilot.nextDecisionAt = null;
return;
}
const period = boardPeriodMs(config);
const lookEnd = boardPeriodIndex(now, config) * period + firstLookMs;
if (pilot.restEndsAt !== null && pilot.restEndsAt > now) {
pilot.nextDecisionAt = pilot.restEndsAt + turnaroundMs;
return;
}
if (ship.inShopUntil !== null && ship.inShopUntil > now) {
pilot.nextDecisionAt = ship.inShopUntil + turnaroundMs;
return;
}
if (now < lookEnd) {
pilot.nextDecisionAt = lookEnd;
return;
}
const action = chooseAction(
{
ship,
pilot: pilotState(pilot),
orders: defaultOrders(home, content),
board: board(ship.locationId),
now,
credits,
spendingFloor: floor,
discoveredIds: discovered,
emptyPeriods: pilot.emptyPeriods ?? 0,
},
content,
);
if (action.kind === "wait") {
if (ship.locationId !== home) pilot.emptyPeriods = (pilot.emptyPeriods ?? 0) + 1;
pilot.nextDecisionAt = lookEnd + period;
// Never stuck: with money above the floor, a pilot can always buy a way out.
if (credits > floor && emergencyFuel({ ship, credits, discoveredIds: discovered }, content)) {
violate("independent-stranded", `${ship.id} ${ship.modelId} at ${ship.locationId}`);
}
return;
}
if (action.buyFu > 0) {
const capacity = effectiveStats(model(ship.modelId), ship.upgrades, content).tankCapacityFu;
const bought = refuel(
ship.tank,
capacity,
action.buyFu,
fuelPriceAt(loc(ship.locationId), config),
);
credits -= bought.cost;
ship.tank = bought.tank;
if (credits < floor) violate("below-spending-floor", `${pilot.id} left ${credits} cr`);
}
if (action.kind === "take") {
const contract = board(ship.locationId).find((c) => c.id === action.contractId)!;
dispatch(ship, pilot, contract);
} else {
const record = createRepositionRecord(
{
id: id("r"),
ship,
pilot: pilotState(pilot),
destinationId: action.destinationId,
now,
worldSeed: options.worldSeed,
},
content,
);
ship.tank = { ...ship.tank, fuelFu: ship.tank.fuelFu - record.fuelBurnedFu };
ship.inFlight = true;
pilot.inFlight = true;
flights.push({ record, shipId: ship.id, pilotId: pilot.id });
}
pilot.emptyPeriods = 0;
pilot.nextDecisionAt = null;
}
function flyIndependent(): boolean {
let acted = false;
for (;;) {
const due = pilots
.filter((p) => !p.isAvatar && p.nextDecisionAt != null && p.nextDecisionAt <= now)
.sort((a, b) => a.nextDecisionAt! - b.nextDecisionAt! || (a.id < b.id ? -1 : 1))[0];
if (!due) return acted;
const before = due.nextDecisionAt;
decideIndependent(due);
acted = acted || due.nextDecisionAt !== before;
}
}
// --- Main loop. --- // --- Main loop. ---
const end = start + options.durationMs; const end = scenario
? windowStart + scenario.minutes * MS_PER_MINUTE
: start + options.durationMs;
let nextSample = start; let nextSample = start;
while (now <= end) { while (now <= end) {
if (scenario && !windowOpen && now >= windowStart) {
windowOpen = true;
// The window opens: from here the fleet is fixed.
windowCredits = credits;
windowLoan = loanBalance;
if (scenario.mode === "away") {
away = true;
for (const pilot of pilots) {
if (!pilot.isAvatar) pilot.nextDecisionAt = now + turnaroundMs;
}
}
}
const inWindow = scenario !== undefined && now >= windowStart;
resolveArrivals(); resolveArrivals();
for (let guard = 0; guard < 50; guard++) { for (let guard = 0; guard < 50; guard++) {
let acted = false; let acted = false;
if (tutorialActive) acted = playTutorial() || acted; if (tutorialActive) acted = playTutorial() || acted;
else { else if (away) {
acted = flyIndependent() || acted;
} else {
if (!inWindow) {
acted = claimGoals() || acted; acted = claimGoals() || acted;
spendSkillPoints(); spendSkillPoints();
acted = buyShips() || acted; acted = buyShips() || acted;
if (purchaseOrder[ships.filter((s) => !s.fromDeal).length] === undefined) { if (purchaseOrder[ships.filter((s) => !s.fromDeal).length] === undefined) {
acted = buyUpgrade() || acted; acted = buyUpgrade() || acted;
} }
} else {
spendSkillPoints();
}
acted = flyShips() || acted; acted = flyShips() || acted;
} }
if (!acted) break; if (!acted) break;
@ -817,8 +967,8 @@ export function simulate(options: SimulationOptions): SimulationResult {
nextSample += SAMPLE_EVERY_MS; nextSample += SAMPLE_EVERY_MS;
} }
const done = Object.values(milestones).every((m) => m !== null); const done = Object.values(milestones).every((m) => m !== null);
if (done && (options.stopWhenDone ?? true)) break; if (!scenario && done && (options.stopWhenDone ?? true)) break;
now = Math.max(now + 1, nextEvent()); now = Math.max(now + 1, Math.min(nextEvent(), scenario && !inWindow ? windowStart : Infinity));
} }
return { return {
@ -834,5 +984,6 @@ export function simulate(options: SimulationOptions): SimulationResult {
purchases, purchases,
jobsShown, jobsShown,
uncompletableShown, uncompletableShown,
scenarioEarnings: scenario ? credits - windowCredits + (windowLoan - loanBalance) : null,
}; };
} }

View File

@ -111,3 +111,33 @@ describe("balance simulation (Step 12.1)", () => {
expect(run!.samples.at(-1)!.pilotLevels.length).toBeGreaterThanOrEqual(2); expect(run!.samples.at(-1)!.pilotLevels.length).toBeGreaterThanOrEqual(2);
}); });
}); });
describe("the away scenario (CR-021 Step 21.8)", () => {
// Three hours of play (four ships), then eight hours either way.
const scenario = (worldSeed: number, mode: "active" | "away") =>
simulate({
worldSeed,
policy: "lighter-first",
durationMs: 12 * MS_PER_HOUR,
scenario: { atMinute: 180, minutes: 8 * 60, mode },
});
let ratios: number[];
let awayRuns: SimulationResult[];
beforeAll(() => {
awayRuns = SEEDS.map((seed) => scenario(seed, "away"));
ratios = SEEDS.map(
(seed, i) => awayRuns[i]!.scenarioEarnings! / scenario(seed, "active").scenarioEarnings!,
);
});
it("earns 40–80% of active play with the same fleet over 8 hours away", () => {
const sorted = [...ratios].sort((a, b) => a - b);
const median = sorted[Math.floor(sorted.length / 2)]!;
expect(median).toBeGreaterThanOrEqual(0.4);
expect(median).toBeLessThanOrEqual(0.8);
});
it("never lets a pilot buy fuel below the spending floor or get stranded", () => {
for (const run of awayRuns) expect(run.violations).toEqual([]);
});
});