From 8b1f6428dbe524523075327786364c58ff0dcc71 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sun, 11 Oct 2026 10:49:54 -0400 Subject: [PATCH] Step 21.8: balance 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 --- DECISIONS.md | 8 ++ memory-bank/progress.md | 2 +- packages/sim/balance/run.ts | 17 +++ packages/sim/balance/simulate.ts | 169 ++++++++++++++++++++++++++++-- packages/sim/test/balance.test.ts | 30 ++++++ 5 files changed, 216 insertions(+), 10 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index 66e1dbc..ca0a3bd 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -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. - **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. + +## 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. diff --git a/memory-bank/progress.md b/memory-bank/progress.md index af390bd..b88e3c7 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -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. 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 diff --git a/packages/sim/balance/run.ts b/packages/sim/balance/run.ts index e99b27b..80613f3 100644 --- a/packages/sim/balance/run.ts +++ b/packages/sim/balance/run.ts @@ -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(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}`, +); diff --git a/packages/sim/balance/simulate.ts b/packages/sim/balance/simulate.ts index d7b0364..881a176 100644 --- a/packages/sim/balance/simulate.ts +++ b/packages/sim/balance/simulate.ts @@ -18,6 +18,9 @@ import { fullTankReach, boardPeriodMs, checkRoute, + chooseAction, + defaultOrders, + refuel, completedGoals, contractTripGameHours, createDispatchRecord, @@ -70,6 +73,13 @@ export interface SimulationOptions { content?: Content; /** Called with one line per event, for tracing a run. */ 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 { @@ -111,6 +121,8 @@ export interface SimulationResult { /** Board jobs offered to the player (CR-019): those shown, and those no qualifying pair could complete. */ jobsShown: 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 { @@ -133,6 +145,9 @@ interface SimPilot { dutyHours: number; restEndsAt: RealTime | null; inFlight: boolean; + /** Independent pilots (away scenario): the next decision point and empty periods away. */ + nextDecisionAt?: RealTime | null; + emptyPeriods?: number; } interface Flight { @@ -393,6 +408,10 @@ export function simulate(options: SimulationOptions): SimulationResult { } pilot.xp += r.pilotXp; pilot.inFlight = false; + if (away) { + pilot.nextDecisionAt = + Math.max(flight.record.arrivalAt, r.pilotRestEndsAt ?? 0) + turnaroundMs; + } pilot.dutyHours = r.pilotDutyHours; if (r.pilotRestEndsAt !== null) pilot.restEndsAt = r.pilotRestEndsAt; if (flight.shipId) { @@ -788,24 +807,155 @@ export function simulate(options: SimulationOptions): SimulationResult { ...ships.flatMap((s) => s.inShopUntil !== null && s.inShopUntil > now ? [s.inShopUntil] : [], ), + ...pilots.flatMap((p) => + p.nextDecisionAt != null && p.nextDecisionAt > now ? [p.nextDecisionAt] : [], + ), ]; 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. --- - const end = start + options.durationMs; + const end = scenario + ? windowStart + scenario.minutes * MS_PER_MINUTE + : start + options.durationMs; let nextSample = start; 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(); for (let guard = 0; guard < 50; guard++) { let acted = false; if (tutorialActive) acted = playTutorial() || acted; - else { - acted = claimGoals() || acted; - spendSkillPoints(); - acted = buyShips() || acted; - if (purchaseOrder[ships.filter((s) => !s.fromDeal).length] === undefined) { - acted = buyUpgrade() || acted; + else if (away) { + acted = flyIndependent() || acted; + } else { + if (!inWindow) { + acted = claimGoals() || acted; + spendSkillPoints(); + acted = buyShips() || acted; + if (purchaseOrder[ships.filter((s) => !s.fromDeal).length] === undefined) { + acted = buyUpgrade() || acted; + } + } else { + spendSkillPoints(); } acted = flyShips() || acted; } @@ -817,8 +967,8 @@ export function simulate(options: SimulationOptions): SimulationResult { nextSample += SAMPLE_EVERY_MS; } const done = Object.values(milestones).every((m) => m !== null); - if (done && (options.stopWhenDone ?? true)) break; - now = Math.max(now + 1, nextEvent()); + if (!scenario && done && (options.stopWhenDone ?? true)) break; + now = Math.max(now + 1, Math.min(nextEvent(), scenario && !inWindow ? windowStart : Infinity)); } return { @@ -834,5 +984,6 @@ export function simulate(options: SimulationOptions): SimulationResult { purchases, jobsShown, uncompletableShown, + scenarioEarnings: scenario ? credits - windowCredits + (windowLoan - loanBalance) : null, }; } diff --git a/packages/sim/test/balance.test.ts b/packages/sim/test/balance.test.ts index bd7fe70..b30bb0a 100644 --- a/packages/sim/test/balance.test.ts +++ b/packages/sim/test/balance.test.ts @@ -111,3 +111,33 @@ describe("balance simulation (Step 12.1)", () => { 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([]); + }); +});