From c02126c90852d511057ba74da3618143d222ce4f Mon Sep 17 00:00:00 2001 From: fireshaper Date: Mon, 5 Oct 2026 19:16:32 -0400 Subject: [PATCH] Step 12.1: balance simulation, economic distance and reachable first contact Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 10 + apps/web/src/screens/DispatchSheet.tsx | 1 + apps/web/src/screens/Fleet.tsx | 2 +- apps/web/test/dispatch-sheet.test.tsx | 2 +- apps/web/test/pilot-share.test.tsx | 1 + memory-bank/architecture.md | 2 +- memory-bank/implementation-plan.md | 5 +- memory-bank/progress.md | 9 + packages/content/src/data/config.ts | 4 +- packages/content/src/data/ships.ts | 2 +- packages/content/src/schemas.ts | 8 + packages/content/test/schemas.test.ts | 1 + packages/sim/balance/run.ts | 28 + packages/sim/balance/simulate.ts | 714 +++++++++++++++++++++++++ packages/sim/package.json | 6 +- packages/sim/src/board.ts | 17 +- packages/sim/src/contract.ts | 4 +- packages/sim/src/dispatch.ts | 11 +- packages/sim/src/fuel.ts | 31 +- packages/sim/src/index.ts | 2 +- packages/sim/test/balance.test.ts | 79 +++ packages/sim/test/contract.test.ts | 19 +- packages/sim/test/fuel.test.ts | 38 +- packages/sim/test/pay-rate.test.ts | 9 +- packages/sim/tsconfig.json | 2 +- pnpm-lock.yaml | 4 + 26 files changed, 977 insertions(+), 34 deletions(-) create mode 100644 packages/sim/balance/run.ts create mode 100644 packages/sim/balance/simulate.ts create mode 100644 packages/sim/test/balance.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index a8242fc..66a9b91 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1256,3 +1256,13 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Actions are disabled per button (`needsServer`)**, not app-wide: navigation, closing sheets, reading details, the map and display/help settings keep working. Buttons that only open an action sheet (Dispatch, Deadhead, Buy, Rename, Back up, Restore, New company) are disabled too, so the player isn't led into a form they can't submit. Viewing a ship's upgrades stays open (it's informational; its Install button is disabled). - **The away summary and the deal wait for the connection**: both exist to send something to the server, and a modal that can't be dismissed would lock the app offline. - **The browser test fakes the API with Playwright route interception** against the real production build and service worker, so it needs no server or database; the end-to-end tests against a real server are Phase 12's. + +## Step 12.1 — balance simulation and the economy + +- **Why the economy changed:** with pay linear in mass × distance and fuel paid up front, a Mars run paid ~340 Moon runs for the same cargo but needed ~164,000 cr of fuel; nothing could be discovered for hours, and with cheaper fuel one Mars delivery bought everything at once. The user chose a gentler pay curve (option 1). +- **Economic distance (decision 0.5.84):** pay and fuel use `0.384 × (d / 0.384)^0.65`. A Moon leg is unchanged, so the tutorial and early numbers are too; Mars at 130M km counts as about 17.5M km. `fuelCost` and `tripFuelCost` now take the config. Range, travel time, Long Haul requirements and XP still use the real distance. +- **A first contact the fleet can fly (decision 0.5.85):** first-contact jobs used to come only from open slots (10 % chance, a random undiscovered destination, any size), so most went to places nothing could reach or were too heavy; discovery ranged from 33 minutes to never. Now one fit slot per board is a first contact built with the fit rules (a crew that can reach it with its Long Haul, cargo it can carry and is skilled for). Once everything reachable is discovered, the slot is an ordinary fit job. +- **Tuning (searched with the script over 7 seeds):** exponent 0.65; Clipper fuel 600 → 450 cr/M km (still above the Freighter's 400, so "high" per CR-012); loan repayment share 0.3 → 0.25. A neighbourhood of values passes (exponent 0.63–0.7, repayment 0.2–0.3 with Clipper fuel 450), so the choice isn't knife-edge. +- **Targets are checked on the median of 7 world seeds**: one world's job luck moves the first purchase by tens of minutes (it lands right after the first big delivery). Invariants must hold in every world. The timing targets apply to the Lighter-first player (the plan's "buy the cheapest ship"); Freighter-first can't buy a 100,000 cr ship in 45–75 minutes with numbers that also keep Lighter-first in that window, so it is used for the four-ship comparison only. +- **The simulated player's choices** (not specified by the plan): best credits-per-minute contract after fuel and pilot pay; deadhead only to a place with work for that ship; rest a pilot with duty hours when there's nothing worth flying; skill points to Long Haul for ships with range past the first cut-off, alternating with the cargo skill that suits the hold (Heavy for 60 t and up, Expensive below); Market ships in policy order with a pilot hired at Earth, keeping 5,000 cr back for fuel; upgrades (cargo bay, then engines, on the most-used ship at an HQ) only once the planned ships are bought and only from 10 % of cash; goals claimed when ready; no extra bays. +- **Invariant definitions:** "soft-lock" = after the deal, nothing in flight and no affordable fuel for any contract on a board where a crewed ship is; "idle with nowhere to go" = a crewed, rested ship with no contract and no deadhead it could take **with unlimited credits** (being short of cash is the soft-lock check's job); "first session" = the first two real hours. diff --git a/apps/web/src/screens/DispatchSheet.tsx b/apps/web/src/screens/DispatchSheet.tsx index 604b3c0..56d26ef 100644 --- a/apps/web/src/screens/DispatchSheet.tsx +++ b/apps/web/src/screens/DispatchSheet.tsx @@ -206,6 +206,7 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number) location(contract.originId), location(contract.destinationId), gameNow, + content.config, ); // Profit-share pay for a hired pilot; the avatar is exempt (CR-010). const pilotPay = diff --git a/apps/web/src/screens/Fleet.tsx b/apps/web/src/screens/Fleet.tsx index d147488..3cc7d78 100644 --- a/apps/web/src/screens/Fleet.tsx +++ b/apps/web/src/screens/Fleet.tsx @@ -243,7 +243,7 @@ export function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => v : check.reason === "would-strand" ? strandedText(id) : (DEADHEAD_REASONS[check.reason] ?? check.reason), - fuelCost: tripFuelCost(model, location(origin), location(id), gameNow), + fuelCost: tripFuelCost(model, location(origin), location(id), gameNow, content.config), tripMs: tripRealMs(model, origin, id, 0, now), }; }); diff --git a/apps/web/test/dispatch-sheet.test.tsx b/apps/web/test/dispatch-sheet.test.tsx index 93abab7..f1e779e 100644 --- a/apps/web/test/dispatch-sheet.test.tsx +++ b/apps/web/test/dispatch-sheet.test.tsx @@ -126,7 +126,7 @@ describe("dispatch sheet", () => { ) * MS_PER_HOUR) / content.config.timeCompression; - const fuel = tripFuelCost(model, earth, mars, gameNow); + const fuel = tripFuelCost(model, earth, mars, gameNow, content.config); const net = marsJob.payout - fuel - pilotShare(marsJob.payout, fuel, content.config); expect(within(freighter).getByRole("radio")).toBeEnabled(); diff --git a/apps/web/test/pilot-share.test.tsx b/apps/web/test/pilot-share.test.tsx index d071fe6..d588ff7 100644 --- a/apps/web/test/pilot-share.test.tsx +++ b/apps/web/test/pilot-share.test.tsx @@ -111,6 +111,7 @@ describe("pilot share in the client (Step 10J.6)", () => { content.location("earth")!, content.location("moon")!, realToGame(NOW, content.config), + content.config, ); const share = pilotShare(job.payout, fuel, content.config); diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index 09f78fe..38155d1 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -7,7 +7,7 @@ Living reference for how the code is organised. Update it after any database sch | Package | Role | Depends on | |---|---|---| | `packages/content` | Static game data (locations, ship models, cargo, skills, `GameConfig`) as Zod-validated TypeScript, plus the `content` bundle with id lookups. | zod | -| `packages/sim` | Pure, deterministic game rules: clock, seeded RNG, positions, distances, travel, fuel, eligibility, contract boards, dispatch validation/records, flight progress, XP, resolution. No I/O, no `Date.now()`, no `Math.random()`. | content, astronomy-engine | +| `packages/sim` | Pure, deterministic game rules: clock, seeded RNG, positions, distances, travel, fuel, eligibility, contract boards, dispatch validation/records, flight progress, XP, resolution. No I/O, no `Date.now()`, no `Math.random()`. `balance/` holds the balance simulation (Step 12.1), outside `src/` and the coverage measure. | content, astronomy-engine | | `packages/schema` | Zod schemas for every API request, response and error. Shared by server and client. | sim (error codes), zod | | `apps/server` | Fastify API, Postgres via Drizzle, pg-boss workers. The authority on outcomes. | content, sim, schema | | `apps/web` | React client. Predicts with sim, never decides outcomes. | content, sim, schema | diff --git a/memory-bank/implementation-plan.md b/memory-bank/implementation-plan.md index 53d94fe..7090636 100644 --- a/memory-bank/implementation-plan.md +++ b/memory-bank/implementation-plan.md @@ -139,6 +139,8 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple 81. **Reach targets** (CR-012). CR-012 §3's reach table is the source of truth for ship range values; ship ranges (not location factors) are adjusted so every cell holds. 82. **The veteran's Clipper** (CR-013). Accepting the deal also creates an owned Clipper at Earth with the veteran assigned to it. 83. **Deal loan** (CR-013). The deal loan amount is Courier price + Clipper price + Earth bay lease, computed from content. +84. **Economic distance** (Step 12.1, user decision). Contract pay and fuel use `reference × (distance / reference)^exponent` instead of the plain distance (reference 0.384M km, Earth–Moon; exponent 0.65), so a Moon run is unchanged and long legs pay and cost on a gentler curve. Range, travel time, Long Haul and XP keep the plain distance. +85. **A first contact the fleet can fly** (Step 12.1). While some undiscovered place is reachable, one fit slot on each board is a first-contact job sized and skilled for a ship that can fly it from there (decision 0.5.52's fit rules), so exploring never waits on board luck. --- @@ -673,6 +675,7 @@ Steps 10M.1–10M.5 are specified in `memory-bank/change-requests/CR-013-veteran - **Amended by CR-010:** Replace "first pilot hired between 30 and 45 minutes" with "second ship bought between 30 and 45 minutes". Add a check that the veteran flies at least one contract in the first session. Re-check all targets with profit-share pay; adjust content values, not thresholds. - **Amended by CR-012:** The script runs two second-ship policies, Lighter-first and Clipper-first; both must reach "own three ships" within 25 % of each other's time. *(Replaced by CR-013, below.)* - **Amended by CR-013:** The player starts with two ships (Courier and Clipper) and a 66,600 cr loan; re-check every timing target with them. Targets: first Market purchase 45–75 minutes, first discovery under 90 minutes, loan paid off in under 4 hours of active play. Run two first-purchase policies, Lighter-first and Freighter-first; both must reach "own four ships" within 25 % of each other's time. +- **As built:** `packages/sim/balance/simulate.ts` (run with `pnpm --filter @deadhead/sim balance [seed] [hours]`), tested in `packages/sim/test/balance.test.ts` over seven world seeds: timing targets on the median of the Lighter-first runs (the default player buys the cheaper ship first), the four-ship comparison on both policies' medians, and every invariant in every run. Meeting the targets needed decisions 0.5.84 and 0.5.85 and the tuning in `DECISIONS.md` (Step 12.1). ### Step 12.2 — Revisit the tutorial economy test - Replace the placeholder average in the Step 2.5 test with the average tutorial payout measured by the balance script. @@ -697,9 +700,9 @@ Steps 10M.1–10M.5 are specified in `memory-bank/change-requests/CR-013-veteran ### Step 12.6 — End-to-end: pilot progression - Playwright test: complete enough deliveries to level up a pilot, spend a point in Heavy Cargo, then confirm a previously ineligible Heavy contract becomes eligible in the dispatch flow. - **Test:** The contract's ship/pilot pair changes from disabled ("skill too low") to enabled. +- **Amended by CR-004:** After leveling Long Haul, confirm the pilot's duty limit increased. --- -- **Amended by CR-004:** After leveling Long Haul, confirm the pilot's duty limit increased. ## Phase 13 — Deployment (Base Game) diff --git a/memory-bank/progress.md b/memory-bank/progress.md index f66d84c..641922a 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -303,3 +303,12 @@ Spec: `memory-bank/change-requests/CR-013-veterans-clipper.md` (received as "CR- - [x] **Step 11.2 — Offline shell behavior.** A request that gets no reply (or the browser's `offline` event) marks the app offline (`state/connection.ts`). Each fetched player state is kept on the device (schema-checked when read back) and returned when the server can't be reached, so a reload offline opens from the service worker cache with the last state; the last clock offset is kept too. The banner reads "Offline — showing last update from HH:MM" with Retry; the browser's `online` event refetches and clears it. `Button` gains `needsServer`, set on every server action and the buttons that open action sheets (dispatch, deadhead, buy, hire, lease, assign, rest, goals, loan, deal, tutorial runs, and the CR-011 rename, backup, restore, new company and developer tools); the two plain buttons (deadhead destinations, raise skill) check `useOnline`. The away summary and deal modal wait until back online. Display and help settings still work. Shared fixtures moved to `test/fixtures.ts` for Playwright. Tests: `e2e/offline.e2e.ts` (fake API by route interception; network off and reload: Fleet renders from cache with the banner and the in-flight ship, its progress bar advances, Deadhead and Buy are disabled, server-backed settings are disabled while the clock format still changes; back online the banner goes and actions return) and `test/offline.test.tsx` (cache fallback only on no reply, not on an error reply; first load offline with no cache still fails; cache cleared on sign-out and ignored if malformed; `needsServer` buttons disabled only offline). **Phase 11 complete — waiting for review.** + +## Phase 12 — Balance Harness and End-to-End Tests + +- [x] **Step 12.1 — Balance simulation script.** `packages/sim/balance/simulate.ts`: a pure, deterministic simulated player that plays from the start using only sim and content functions the way the server applies them (tutorial runs, deal, boards per period, dispatch, deadhead, rest, resolution, goals, skill points, Market purchases with a hired pilot, upgrades from spare cash), with a trip log for tracing. Reports milestones (deal, first Market purchase, first discovery, loan paid off, four ships), credits/loan/ships/pilot levels every 10 minutes, and breaches of the invariants (soft-lock, idle ship with no work and no way out, dispatch while resting). `pnpm --filter @deadhead/sim balance [seed] [hours]` prints both policies. **Design changes (user-approved):** pay and fuel use economic distance (decision 0.5.84), and boards offer a first contact the fleet can fly (0.5.85). **Tuning:** economic-distance exponent 0.65, Clipper fuel 600 → 450 cr/M km, loan repayment share 0.3 → 0.25. Medians over 7 seeds (Lighter-first): deal 2.9 min, first purchase 66, first discovery 33, loan paid off 188, four ships 170 (Freighter-first 201); no breaches in any run. Test: `test/balance.test.ts` (determinism, each target on the median, four-ship comparison, invariants in every run, veteran contract in the first session, samples reported). +- [ ] Step 12.2 — Revisit the tutorial economy test +- [ ] Step 12.3 — End-to-end: tutorial to first ship +- [ ] Step 12.4 — End-to-end: offline progress +- [ ] Step 12.5 — End-to-end: discovery +- [ ] Step 12.6 — End-to-end: pilot progression diff --git a/packages/content/src/data/config.ts b/packages/content/src/data/config.ts index 17d53c7..ca74d08 100644 --- a/packages/content/src/data/config.ts +++ b/packages/content/src/data/config.ts @@ -20,11 +20,13 @@ export const gameConfig: GameConfig = { tutorialMassTonnes: { light: 5, medium: 12, full: 20 }, // 20 t adds 0.4 game hours (24 real seconds) of loading and unloading. handlingGameHoursPerTonne: 0.02, - loanRepaymentShare: 0.3, + loanRepaymentShare: 0.25, // The deal (CR-013): Halvorsen's Courier for the avatar, his Clipper for the // veteran, and the Earth bay. The loan is computed from their prices. deal: { avatarShipModelId: "courier", veteranShipModelId: "clipper", bayLocationId: "earth" }, contractBoardPeriodRealMinutes: 10, + // Pay and fuel scale gently with distance past an Earth–Moon leg (decision 0.5.84). + economicDistance: { referenceMillionKm: 0.384, exponent: 0.65 }, // A ship must be able to fly out of its destination two game days after arriving (CR-012). noStrandMarginGameHours: 48, // Ship upgrades (CR-009): level 1, 2, 3. diff --git a/packages/content/src/data/ships.ts b/packages/content/src/data/ships.ts index 7f55a23..a8b47ed 100644 --- a/packages/content/src/data/ships.ts +++ b/packages/content/src/data/ships.ts @@ -59,7 +59,7 @@ export const shipModels: ShipModel[] = [ hullRating: 1, landingClass: "atmospheric", overheadGameHours: 2, - fuelCostPerMillionKm: 600, + fuelCostPerMillionKm: 450, purchasable: true, role: "Fast long-legged runner: the cheapest way to Mars, built for Priority work.", }, diff --git a/packages/content/src/schemas.ts b/packages/content/src/schemas.ts index 0336ddc..c5fcd8e 100644 --- a/packages/content/src/schemas.ts +++ b/packages/content/src/schemas.ts @@ -220,6 +220,14 @@ export const gameConfigSchema = z bayLocationId: z.string().min(1), }), contractBoardPeriodRealMinutes: z.number().positive(), + /** + * Pay and fuel use `reference × (distance / reference)^exponent` (decision + * 0.5.84), so long legs aren't worth hundreds of short ones. + */ + economicDistance: z.object({ + referenceMillionKm: z.number().positive(), + exponent: z.number().positive().max(1), + }), /** No-strand check: the ship must have a way out this long after arrival (decision 0.5.79). */ noStrandMarginGameHours: z.number().nonnegative(), /** diff --git a/packages/content/test/schemas.test.ts b/packages/content/test/schemas.test.ts index 8861212..fb14a42 100644 --- a/packages/content/test/schemas.test.ts +++ b/packages/content/test/schemas.test.ts @@ -109,6 +109,7 @@ const validGameConfig = { handlingGameHoursPerTonne: 0.02, upgradeLevels: [{ costPercentOfPrice: 10, shopRealMinutes: 10 }], loanRepaymentShare: 0.3, + economicDistance: { referenceMillionKm: 0.384, exponent: 0.6 }, deal: { avatarShipModelId: "courier", veteranShipModelId: "clipper", bayLocationId: "earth" }, }; diff --git a/packages/sim/balance/run.ts b/packages/sim/balance/run.ts new file mode 100644 index 0000000..2d05e45 --- /dev/null +++ b/packages/sim/balance/run.ts @@ -0,0 +1,28 @@ +/// +/** + * Prints a balance run for both first-purchase policies (plan Step 12.1): + * pnpm --filter @deadhead/sim balance [worldSeed] [hours] + */ +import { MS_PER_HOUR } from "../src/index"; +import { simulate, type PurchasePolicy } from "./simulate"; + +const worldSeed = Number(process.argv[2] ?? 12345); +const hours = Number(process.argv[3] ?? 8); +for (const policy of ["lighter-first", "freighter-first"] as PurchasePolicy[]) { + const result = simulate({ worldSeed, policy, durationMs: hours * MS_PER_HOUR }); + console.log(`\n=== ${policy} (seed ${worldSeed}) ===`); + console.log("milestones (real minutes):", result.milestones); + console.log( + `tutorial runs ${result.tutorialRuns}, contracts ${result.contractsDelivered}, ` + + `veteran contracts in first 2 h ${result.veteranContractsFirstSession}, purchases ${result.purchases.join(", ")}`, + ); + console.log("violations:", result.violations.length); + for (const v of result.violations.slice(0, 10)) + console.log(` ${v.minute} min ${v.rule} ${v.detail}`); + console.log("minute credits loan ships pilot levels"); + for (const s of result.samples.filter((_, i) => i % 3 === 0)) { + console.log( + `${String(s.minute).padStart(6)} ${String(s.credits).padStart(7)} ${String(s.loanBalance).padStart(6)} ${s.ships} ${s.pilotLevels.join(" ")}`, + ); + } +} diff --git a/packages/sim/balance/simulate.ts b/packages/sim/balance/simulate.ts new file mode 100644 index 0000000..3fdccd2 --- /dev/null +++ b/packages/sim/balance/simulate.ts @@ -0,0 +1,714 @@ +/** + * Balance simulation (plan Step 12.1, amended by CR-001 to CR-013): a simulated + * player plays from "Start your company" using only the sim and content, the + * way the server applies them, and the run records when each milestone was + * reached plus any breach of the game's invariants. + * + * The player is always active: it acts the moment something is possible, so + * times are "real time, active play". Pure and deterministic: same options, + * same result. + */ +import { content as defaultContent, type ShipModel } from "@deadhead/content"; +import { + boardPeriodIndex, + boardPeriodMs, + checkRoute, + completedGoals, + contractTripGameHours, + createDispatchRecord, + createRepositionRecord, + effectiveStats, + generateContractBoard, + generateTutorialBoard, + isResting, + loanerModel, + MS_PER_HOUR, + MS_PER_MINUTE, + nextUpgradeLevel, + pilotLevel, + pilotShare, + realToGame, + resolveDispatch, + restEndsAt, + skillPointsAvailable, + tripFuelCost, + validateDispatch, + validateReposition, + validateUpgrade, + type Contract, + type DispatchRecord, + type GoalProgress, + type PilotState, + type RealTime, + type ShipState, + type SimContent, + type UpgradeLevels, +} from "../src/index"; + +type Content = SimContent & { + onboardingGoals: typeof defaultContent.onboardingGoals; + veteranPilot: typeof defaultContent.veteranPilot; + dealLoanAmount: number; +}; + +/** The first two ships the player buys from the Market, in order (CR-013). */ +export type PurchasePolicy = "lighter-first" | "freighter-first"; + +export interface SimulationOptions { + worldSeed: number; + policy: PurchasePolicy; + /** Real time to simulate from the start, in ms. */ + durationMs: number; + /** When to stop early: every milestone reached. Default true. */ + stopWhenDone?: boolean; + content?: Content; + /** Called with one line per event, for tracing a run. */ + log?: (line: string) => void; +} + +export interface Sample { + /** Real minutes since the start. */ + minute: number; + credits: number; + loanBalance: number; + ships: number; + /** Level of each pilot, in hiring order (avatar first). */ + pilotLevels: number[]; +} + +/** Real minutes from the start until each milestone; null if never reached. */ +export interface Milestones { + dealAccepted: number | null; + firstMarketPurchase: number | null; + firstDiscovery: number | null; + loanPaidOff: number | null; + ownFourShips: number | null; +} + +export interface SimulationResult { + policy: PurchasePolicy; + milestones: Milestones; + /** Every 10 real minutes. */ + samples: Sample[]; + tutorialRuns: number; + contractsDelivered: number; + /** Contracts the veteran delivered in the first two hours (CR-010 check). */ + veteranContractsFirstSession: number; + /** Invariant breaches, each with the real minute it happened. */ + violations: { minute: number; rule: string; detail: string }[]; + /** Ships bought, in order. */ + purchases: string[]; +} + +interface SimShip extends ShipState { + upgrades: UpgradeLevels; + inShopUntil: RealTime | null; + fromDeal: boolean; + trips: number; +} + +interface SimPilot { + id: string; + isAvatar: boolean; + isVeteran: boolean; + xp: number; + skills: Record; + spent: number; + assignedShipId: string | null; + dutyHours: number; + restEndsAt: RealTime | null; + inFlight: boolean; +} + +interface Flight { + record: DispatchRecord; + shipId: string | null; + pilotId: string; +} + +const PURCHASE_ORDER: Record = { + "lighter-first": ["lighter", "freighter"], + "freighter-first": ["freighter", "lighter"], +}; +const FIRST_SESSION_MS = 2 * 60 * MS_PER_MINUTE; +const SAMPLE_EVERY_MS = 10 * MS_PER_MINUTE; +/** Cash kept back for fuel after buying a ship, so the fleet can keep flying. */ +const FUEL_RESERVE = 5_000; +/** Upgrades only from spare cash: never more than this share of credits on hand. */ +const UPGRADE_CASH_SHARE = 0.1; + +export function simulate(options: SimulationOptions): SimulationResult { + const content = options.content ?? (defaultContent as Content); + const { config } = content; + const start = Date.parse(config.epoch.realStart); + let now: RealTime = start; + const minute = (t: RealTime = now) => Math.round(((t - start) / MS_PER_MINUTE) * 10) / 10; + const purchaseOrder = PURCHASE_ORDER[options.policy]; + + // --- State, as a new guest has it (server new-player). --- + let credits = config.startingCredits; + let loanBalance = 0; + let tutorialActive = true; + let runsCompleted = 0; + let loanerLocationId: "earth" | "moon" = "earth"; + const discovered = new Set(content.locations.filter((l) => l.startsDiscovered).map((l) => l.id)); + const bays = new Set(); + const ships: SimShip[] = []; + const pilots: SimPilot[] = [ + { + id: "avatar", + isAvatar: true, + isVeteran: false, + xp: 0, + skills: {}, + spent: 0, + assignedShipId: null, + dutyHours: 0, + restEndsAt: null, + inFlight: false, + }, + ]; + const flights: Flight[] = []; + const accepted = new Set(); + const boards = new Map(); + const claimed = new Set(); + let nextId = 0; + const id = (prefix: string) => `${prefix}-${++nextId}`; + + const milestones: Milestones = { + dealAccepted: null, + firstMarketPurchase: null, + firstDiscovery: null, + loanPaidOff: null, + ownFourShips: null, + }; + const samples: Sample[] = []; + const violations: SimulationResult["violations"] = []; + const purchases: string[] = []; + let contractsDelivered = 0; + let priorityDeliveries = 0; + let veteranContractsFirstSession = 0; + const seenViolations = new Set(); + /** Records a breach once per rule, subject and board period. */ + const violate = (rule: string, detail: string) => { + const key = `${rule}:${detail}:${boardPeriodIndex(now, config)}`; + if (seenViolations.has(key)) return; + seenViolations.add(key); + violations.push({ minute: minute(), rule, detail }); + }; + + const model = (modelId: string): ShipModel => content.shipModels.find((m) => m.id === modelId)!; + const loc = (locationId: string) => content.locations.find((l) => l.id === locationId)!; + const pilotOf = (ship: SimShip) => pilots.find((p) => p.assignedShipId === ship.id) ?? null; + const pilotState = (p: SimPilot): PilotState => ({ + id: p.id, + isAvatar: p.isAvatar, + assignedShipId: p.assignedShipId, + inFlight: p.inFlight, + skills: p.skills, + restEndsAt: p.restEndsAt, + }); + const fleet = () => + ships.map((s) => ({ + modelId: s.modelId, + skills: pilotOf(s)?.skills ?? {}, + upgrades: s.upgrades, + })); + + /** The location board for the current period, generated once per period (as the server stores it). */ + function board(locationId: string): Contract[] { + const period = boardPeriodIndex(now, config); + const key = `${locationId}:${period}`; + let contracts = boards.get(key); + if (!contracts) { + contracts = generateContractBoard( + { + location: loc(locationId), + discoveredIds: discovered, + periodIndex: period, + worldSeed: options.worldSeed, + fleet: fleet(), + }, + content, + ); + boards.set(key, contracts); + } + return contracts.filter((c) => !accepted.has(c.id)); + } + + /** Credits per real minute the company keeps from a contract (payout − fuel − pilot pay). */ + function rate(contract: Contract, stats: ShipModel, pilot: SimPilot): number { + const departure = realToGame(now, config); + const fuel = tripFuelCost( + stats, + loc(contract.originId), + loc(contract.destinationId), + departure, + config, + ); + const pay = pilot.isAvatar ? 0 : pilotShare(contract.payout, fuel, config); + const tripMs = + (contractTripGameHours(stats, contract, now, content) * MS_PER_HOUR) / config.timeCompression; + return (contract.payout - fuel - pay) / Math.max(1, tripMs / MS_PER_MINUTE); + } + + /** Contracts this crew may take now; `budget` defaults to the credits on hand. */ + function eligibleContracts(ship: SimShip, pilot: SimPilot, budget = credits): Contract[] { + if (!discovered.has(ship.locationId)) return []; + return board(ship.locationId).filter( + (contract) => + validateDispatch( + { + ship, + pilot: pilotState(pilot), + contract, + tutorial: false, + credits: budget, + now, + discoveredIds: discovered, + }, + content, + ).ok, + ); + } + + const log = (line: string) => options.log?.(`${String(minute()).padStart(6)} ${line}`); + + function dispatch(ship: SimShip, pilot: SimPilot, contract: Contract) { + const record = createDispatchRecord( + { + id: id("d"), + ship, + pilot: pilotState(pilot), + contract, + tutorial: false, + now, + worldSeed: options.worldSeed, + }, + content, + ); + if (isResting(pilot.restEndsAt, now)) violate("no-dispatch-while-resting", pilot.id); + accepted.add(contract.id); + credits -= record.fuelCost; + log( + `${ship.modelId} ${contract.originId}→${contract.destinationId} ${contract.goodId} ${contract.massTonnes}t pays ${contract.payout}${contract.firstContact ? " FIRST CONTACT" : ""} fuel ${record.fuelCost} arrives +${Math.round((record.arrivalAt - now) / MS_PER_MINUTE)}m credits ${credits}`, + ); + ship.inFlight = true; + ship.trips++; + pilot.inFlight = true; + flights.push({ record, shipId: ship.id, pilotId: pilot.id }); + } + + // --- Arrivals (server resolve.ts). --- + function resolveArrivals() { + flights.sort((a, b) => a.record.arrivalAt - b.record.arrivalAt); + while (flights.length > 0 && flights[0]!.record.arrivalAt <= now) { + const flight = flights.shift()!; + const pilot = pilots.find((p) => p.id === flight.pilotId)!; + const result = resolveDispatch( + { + record: flight.record, + pilotIsAvatar: pilot.isAvatar, + discoveredIds: discovered, + loanBalance, + pilotDutyHours: pilot.dutyHours, + pilotSkills: pilot.skills, + now, + }, + content, + ); + if (result.status !== "resolved") throw new Error("arrived flight did not resolve"); + const r = result.resolution; + credits += r.credits; + loanBalance -= r.loanRepayment; + pilot.xp += r.pilotXp; + pilot.inFlight = false; + pilot.dutyHours = r.pilotDutyHours; + if (r.pilotRestEndsAt !== null) pilot.restEndsAt = r.pilotRestEndsAt; + if (flight.shipId) { + const ship = ships.find((s) => s.id === flight.shipId)!; + ship.inFlight = false; + ship.locationId = r.shipLocationId; + } + if (flight.record.tutorial) { + runsCompleted++; + loanerLocationId = r.shipLocationId as "earth" | "moon"; + } else if (flight.record.contract) { + contractsDelivered++; + if (flight.record.contract.priority) priorityDeliveries++; + if (pilot.isVeteran && flight.record.arrivalAt - start <= FIRST_SESSION_MS) { + veteranContractsFirstSession++; + } + } + if (flight.record.contract && !flight.record.tutorial) { + log( + ` arrived ${flight.record.destinationId}: +${r.credits} (repaid ${r.loanRepayment}, pilot ${r.pilotPay}) credits ${credits}`, + ); + } + if (r.discoversDestination) { + discovered.add(flight.record.destinationId); + milestones.firstDiscovery ??= minute(flight.record.arrivalAt); + } + if (!tutorialActive && loanBalance <= 0) + milestones.loanPaidOff ??= minute(flight.record.arrivalAt); + } + } + + // --- Tutorial and the deal (server dispatch.ts, deal.ts). --- + function playTutorial(): boolean { + if (flights.some((f) => f.record.tutorial)) return false; + if (runsCompleted >= config.tutorialRunsBeforeDeal && loanerLocationId === "earth") { + acceptDeal(); + return true; + } + const contracts = generateTutorialBoard( + { loanerLocationId, runsCompleted, periodIndex: boardPeriodIndex(now, config) }, + content, + ).filter((c) => !accepted.has(c.id)); + const loaner: ShipState = { + id: "loaner", + modelId: loanerModel(content).id, + locationId: loanerLocationId, + inFlight: false, + }; + const avatar = pilots[0]!; + // Company pays fuel; the player keeps the tutorial share: best share per minute. + const best = contracts + .filter( + (c) => + validateDispatch( + { ship: loaner, pilot: pilotState(avatar), contract: c, tutorial: true, credits, now }, + content, + ).ok, + ) + .map((c) => { + const record = createDispatchRecord( + { + id: id("t"), + ship: loaner, + pilot: pilotState(avatar), + contract: c, + tutorial: true, + now, + worldSeed: options.worldSeed, + }, + content, + ); + return { record, value: c.payout / (record.arrivalAt - now) }; + }) + .sort((a, b) => b.value - a.value)[0]; + if (!best) return false; + accepted.add(best.record.contract!.id); + avatar.inFlight = true; + flights.push({ record: best.record, shipId: null, pilotId: avatar.id }); + return true; + } + + function acceptDeal() { + const { avatarShipModelId, veteranShipModelId, bayLocationId } = config.deal; + const courier = newShip(avatarShipModelId, bayLocationId, true); + const clipper = newShip(veteranShipModelId, bayLocationId, true); + bays.add(bayLocationId); + pilots[0]!.assignedShipId = courier.id; + const veteran = content.veteranPilot; + pilots.push({ + id: "veteran", + isAvatar: false, + isVeteran: true, + xp: veteran.level === 0 ? 0 : config.pilotLevelXpThresholds[veteran.level - 1]!, + skills: { ...veteran.skills }, + spent: Object.values(veteran.skills).reduce((a, b) => a + b, 0), + assignedShipId: clipper.id, + dutyHours: 0, + restEndsAt: null, + inFlight: false, + }); + loanBalance = content.dealLoanAmount; + tutorialActive = false; + milestones.dealAccepted = minute(); + } + + function newShip(modelId: string, locationId: string, fromDeal: boolean): SimShip { + const ship: SimShip = { + id: id("s"), + modelId, + locationId, + inFlight: false, + upgrades: {}, + inShopUntil: null, + fromDeal, + trips: 0, + }; + ships.push(ship); + return ship; + } + + // --- Owner decisions. --- + function claimGoals(): boolean { + const progress: GoalProgress = { + ownShipDeliveries: contractsDelivered, + skillPointsSpent: pilots.reduce((n, p) => n + p.spent, 0), + shipsOwned: ships.length, + marketShipsBought: ships.filter((s) => !s.fromDeal).length, + pilotsHired: pilots.filter((p) => !p.isAvatar).length, + priorityDeliveries, + locationsDiscovered: [...discovered].filter((d) => !loc(d).startsDiscovered).length, + dealAccepted: !tutorialActive, + loanBalance, + }; + let any = false; + for (const goalId of completedGoals(content.onboardingGoals, progress)) { + if (claimed.has(goalId)) continue; + claimed.add(goalId); + credits += content.onboardingGoals.find((g) => g.id === goalId)!.reward; + any = true; + } + return any; + } + + /** + * Skill points (owner's choice): Long Haul when the pilot's ship can fly + * further than the first Long Haul cut-off and Long Haul is behind; otherwise + * the cargo skill that suits the ship (big holds: Heavy; small, fast: Expensive). + */ + function spendSkillPoints() { + for (const pilot of pilots) { + const ship = ships.find((s) => s.id === pilot.assignedShipId); + if (!ship) continue; + const stats = effectiveStats(model(ship.modelId), ship.upgrades, content); + while (skillPointsAvailable(pilotLevel(pilot.xp, config), pilot.spent, config) > 0) { + const cargoSkill = stats.cargoCapacityTonnes >= 60 ? "heavy-cargo" : "expensive-cargo"; + const longHaul = pilot.skills["long-haul"] ?? 0; + const cargo = pilot.skills[cargoSkill] ?? 0; + const wantsLongHaul = + stats.fuelRangeMillionKm > config.longHaulCutoffsMillionKm[0]! && longHaul <= cargo; + const skill = wantsLongHaul ? "long-haul" : cargoSkill; + pilot.skills[skill] = (pilot.skills[skill] ?? 0) + 1; + pilot.spent++; + } + } + } + + /** The next planned Market ship, bought at the Earth bay with a pilot hired for it. */ + function buyShips(): boolean { + const bought = ships.filter((s) => !s.fromDeal).length; + const next = purchaseOrder[bought]; + if (!next) return false; + const price = model(next).price + config.pilotHireCost; + if (credits < price + FUEL_RESERVE) return false; + credits -= price; + const ship = newShip(next, config.deal.bayLocationId, false); + pilots.push({ + id: id("p"), + isAvatar: false, + isVeteran: false, + xp: 0, + skills: {}, + spent: 0, + assignedShipId: ship.id, + dutyHours: 0, + restEndsAt: null, + inFlight: false, + }); + purchases.push(next); + log(`BUY ${next} credits ${credits}`); + milestones.firstMarketPurchase ??= minute(); + if (ships.length >= 4) milestones.ownFourShips ??= minute(); + return true; + } + + /** + * Upgrades (CR-009 amendment): cargo bay, then engines, on the most-used ship + * idle at an HQ, paid only from spare cash so saving for ships isn't stalled. + */ + function buyUpgrade(): boolean { + const candidates = ships + .filter((s) => !s.inFlight && bays.has(s.locationId)) + .sort((a, b) => b.trips - a.trips); + const ship = candidates[0]; + if (!ship) return false; + for (const upgradeId of ["cargo-bay", "engines"]) { + const upgrade = content.upgrades.find((u) => u.id === upgradeId)!; + if (nextUpgradeLevel(upgrade, ship.modelId, ship.upgrades) === null) continue; + const check = validateUpgrade( + { ship, upgrade, atHq: true, tutorialActive, credits, now }, + content, + ); + if (!check.ok || check.cost > credits * UPGRADE_CASH_SHARE) continue; + credits -= check.cost; + ship.upgrades = { ...ship.upgrades, [upgrade.id]: check.level }; + ship.inShopUntil = check.shopUntil; + return true; + } + return false; + } + + /** Fly every free ship: best contract here, else a deadhead to work, else rest the pilot. */ + function flyShips(): boolean { + let acted = false; + for (const ship of ships) { + const pilot = pilotOf(ship); + if (!pilot || ship.inFlight || pilot.inFlight || isResting(pilot.restEndsAt, now)) continue; + if (ship.inShopUntil !== null && ship.inShopUntil > now) continue; + const stats = effectiveStats(model(ship.modelId), ship.upgrades, content); + const choices = eligibleContracts(ship, pilot) + .map((c) => ({ c, value: rate(c, stats, pilot) })) + .filter((o) => o.value > 0) + .sort((a, b) => b.value - a.value); + if (choices[0]) { + dispatch(ship, pilot, choices[0].c); + acted = true; + continue; + } + const canDeadhead = (to: string, budget: number) => + to !== ship.locationId && + validateReposition( + { + ship, + pilot: pilotState(pilot), + destinationId: to, + discoveredIds: discovered, + credits: budget, + now, + }, + content, + ).ok; + const deadheads = [...discovered].filter((to) => canDeadhead(to, credits)); + // A dead end is no work and no way out whatever the balance (CR-002); + // running short of cash is the soft-lock check's business. + if ( + eligibleContracts(ship, pilot, Infinity).length === 0 && + ![...discovered].some((to) => canDeadhead(to, Infinity)) + ) { + violate("idle-ship-with-nowhere-to-go", `${ship.id} ${ship.modelId} at ${ship.locationId}`); + } + // Deadhead only to a place with paying work for this ship right now. + const target = deadheads + .map((to) => { + const best = board(to) + .filter( + (c) => checkRoute(stats, loc(to), loc(c.destinationId), realToGame(now, config)).ok, + ) + .map((c) => c.payout) + .sort((a, b) => b - a)[0]; + return { to, best: best ?? 0 }; + }) + .filter((t) => t.best > 0) + .sort((a, b) => b.best - a.best)[0]; + if (target) { + const record = createRepositionRecord( + { + id: id("r"), + ship, + pilot: pilotState(pilot), + destinationId: target.to, + now, + worldSeed: options.worldSeed, + }, + content, + ); + credits -= record.fuelCost; + ship.inFlight = true; + pilot.inFlight = true; + flights.push({ record, shipId: ship.id, pilotId: pilot.id }); + log( + `${ship.modelId} deadhead ${ship.locationId}→${target.to} fuel ${record.fuelCost} credits ${credits}`, + ); + acted = true; + } else if (pilot.dutyHours > 0) { + // Nothing worth flying: rest now so the pilot is fresh for the next board (CR-004). + pilot.dutyHours = 0; + pilot.restEndsAt = restEndsAt(now, config); + log(`${ship.modelId} pilot rests at ${ship.locationId}`); + acted = true; + } + } + return acted; + } + + function checkCashflow() { + if (tutorialActive || flights.length > 0) return; + const affordable = ships.some((ship) => { + const pilot = pilotOf(ship); + return ( + pilot !== null && + discovered.has(ship.locationId) && + board(ship.locationId).some( + (c) => + tripFuelCost( + model(ship.modelId), + loc(c.originId), + loc(c.destinationId), + realToGame(now, config), + config, + ) <= credits, + ) + ); + }); + if (!affordable) violate("soft-lock", `no flight and no affordable fuel with ${credits} cr`); + } + + function sample() { + samples.push({ + minute: minute(), + credits, + loanBalance, + ships: ships.length, + pilotLevels: pilots.map((p) => pilotLevel(p.xp, config)), + }); + } + + /** The next moment anything can change. */ + function nextEvent(): RealTime { + const period = boardPeriodMs(config); + const times = [ + (boardPeriodIndex(now, config) + 1) * period, + ...flights.map((f) => f.record.arrivalAt), + ...pilots.flatMap((p) => (p.restEndsAt !== null && p.restEndsAt > now ? [p.restEndsAt] : [])), + ...ships.flatMap((s) => + s.inShopUntil !== null && s.inShopUntil > now ? [s.inShopUntil] : [], + ), + ]; + return Math.min(...times); + } + + // --- Main loop. --- + const end = start + options.durationMs; + let nextSample = start; + while (now <= end) { + 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; + } + acted = flyShips() || acted; + } + if (!acted) break; + } + checkCashflow(); + while (nextSample <= now) { + sample(); + 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()); + } + + return { + policy: options.policy, + milestones, + samples, + tutorialRuns: runsCompleted, + contractsDelivered, + veteranContractsFirstSession, + violations, + purchases, + }; +} diff --git a/packages/sim/package.json b/packages/sim/package.json index 56d4633..f9a894e 100644 --- a/packages/sim/package.json +++ b/packages/sim/package.json @@ -6,10 +6,14 @@ "main": "./src/index.ts", "types": "./src/index.ts", "scripts": { - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "balance": "tsx balance/run.ts" }, "dependencies": { "@deadhead/content": "workspace:*", "astronomy-engine": "^2.1.19" + }, + "devDependencies": { + "tsx": "^4.23.15" } } diff --git a/packages/sim/src/board.ts b/packages/sim/src/board.ts index c129f3f..6da8c12 100644 --- a/packages/sim/src/board.ts +++ b/packages/sim/src/board.ts @@ -112,8 +112,8 @@ function planSlots(crews: readonly Crew[], config: GameConfig) { } /** - * A job one crew can take (decision 0.5.52): within its capacity, to a - * discovered place it can reach and land at with its Long Haul, with cargo its + * A job one crew can take (decision 0.5.52): within its capacity, to one of + * `destinations` (discovered places, or undiscovered ones for a first contact) it can reach and land at with its Long Haul, with cargo its * pilot is skilled for. Crews are tried from a random start; null if none fits. */ function fitJob( @@ -464,10 +464,19 @@ export function generateContractBoard(input: BoardInput, content: SimContent): C // One slot per contract, band by band. A guaranteed slot is Standard cargo // (no skill) to a discovered place, so every ship size always has something // a new pilot can fly; fit slots are built for the player's own ships. + // One fit slot per board is a first contact the fleet can fly, while anywhere + // reachable is undiscovered (decision 0.5.85): exploring never waits on luck. + let firstContactOffered = false; return planSlots(crews, config).map(({ band, kind }, i) => { const id = `${origin.id}:${periodIndex}:${i}`; + const explore = + kind === "fit" && !firstContactOffered && undiscovered.length > 0 + ? fitJob(rng, band, crews, origin, undiscovered, offeredAt, content) + : null; + if (explore) firstContactOffered = true; const fit = - kind === "fit" ? fitJob(rng, band, crews, origin, discovered, offeredAt, content) : null; + explore ?? + (kind === "fit" ? fitJob(rng, band, crews, origin, discovered, offeredAt, content) : null); if (fit) { const { model, ...job } = fit; return buildContract( @@ -475,7 +484,7 @@ export function generateContractBoard(input: BoardInput, content: SimContent): C id, origin, ...job, - firstContact: false, + firstContact: explore !== null, periodIndex, ...rollPriority(rng, { ...job, origin, offeredAt, model }, content), shipper: rng.pick(origin.shippers), diff --git a/packages/sim/src/contract.ts b/packages/sim/src/contract.ts index 3532507..85ebd87 100644 --- a/packages/sim/src/contract.ts +++ b/packages/sim/src/contract.ts @@ -1,5 +1,6 @@ import type { CargoType, GameConfig } from "@deadhead/content"; import type { RealTime } from "./clock"; +import { economicDistance } from "./fuel"; export interface Contract { id: string; @@ -60,7 +61,8 @@ export function contractPayout(input: PayoutInput, config: GameConfig): number { payMultiplier * cargoType.tierPayMultiplier ** tier * massTonnes * - distanceMillionKm * + // Long legs pay on a gentler curve than their length (decision 0.5.84). + economicDistance(distanceMillionKm, config) * (firstContact ? config.firstContactPayoutMultiplier : 1) * (input.priority ? config.priorityPayoutMultiplier : 1) * (input.marketRate ?? 1); diff --git a/packages/sim/src/dispatch.ts b/packages/sim/src/dispatch.ts index 455ae70..74e7dc4 100644 --- a/packages/sim/src/dispatch.ts +++ b/packages/sim/src/dispatch.ts @@ -1,4 +1,4 @@ -import type { Location, ShipModel } from "@deadhead/content"; +import type { GameConfig, Location, ShipModel } from "@deadhead/content"; import type { SimContent } from "./board"; import { type GameTime, MS_PER_HOUR, type RealTime, realToGame } from "./clock"; import type { Contract } from "./contract"; @@ -94,8 +94,9 @@ export function tripFuelCost( origin: Location, destination: Location, departure: GameTime, + config: GameConfig, ): number { - return Math.round(fuelCost(model, origin, routeDistance(origin, destination, departure))); + return Math.round(fuelCost(model, origin, routeDistance(origin, destination, departure), config)); } const fail = (reason: DispatchFailure): DispatchCheck => ({ ok: false, reason }); @@ -191,7 +192,7 @@ export function validateDispatch(proposal: DispatchProposal, content: SimContent if (now >= contract.expiresAt) return fail("contract-expired"); if (!tutorial) { - if (credits < tripFuelCost(model, origin, destination, departure)) + if (credits < tripFuelCost(model, origin, destination, departure, content.config)) return fail("insufficient-credits"); } return { ok: true }; @@ -262,7 +263,7 @@ function buildRecord( departureAt: now, arrivalAt: now + Math.round(travelGameMs / config.timeCompression), distanceMillionKm: distance, - fuelCost: tutorial ? 0 : tripFuelCost(model, origin, destination, departure), + fuelCost: tutorial ? 0 : tripFuelCost(model, origin, destination, departure, content.config), expectedPayout: contract?.payout ?? 0, seed: deriveSeed(worldSeed, `dispatch:${id}`), }; @@ -335,7 +336,7 @@ export function validateReposition( return failWith("would-strand"); } - if (credits < tripFuelCost(model, origin, destination, departure)) { + if (credits < tripFuelCost(model, origin, destination, departure, content.config)) { return failWith("insufficient-credits"); } return { ok: true }; diff --git a/packages/sim/src/fuel.ts b/packages/sim/src/fuel.ts index 42e71c8..c21926c 100644 --- a/packages/sim/src/fuel.ts +++ b/packages/sim/src/fuel.ts @@ -1,6 +1,29 @@ -import type { Location, ShipModel } from "@deadhead/content"; +import type { GameConfig, Location, ShipModel } from "@deadhead/content"; -/** Fuel cost in credits: distance × the ship's cost per million km × the origin's launch multiplier. */ -export function fuelCost(ship: ShipModel, origin: Location, distanceMillionKm: number): number { - return distanceMillionKm * ship.fuelCostPerMillionKm * origin.launchCostMultiplier; +/** + * The distance pay and fuel are charged on (decision 0.5.84): the route distance + * scaled down for long legs, `reference × (distance / reference)^exponent`. + * A leg of the reference length (Earth–Moon) is unchanged; an exponent of 1 + * makes it the plain distance. Range, travel time and XP use the plain distance. + */ +export function economicDistance(distanceMillionKm: number, config: GameConfig): number { + const { referenceMillionKm, exponent } = config.economicDistance; + return referenceMillionKm * (distanceMillionKm / referenceMillionKm) ** exponent; +} + +/** + * Fuel cost in credits: economic distance × the ship's cost per million km × + * the origin's launch multiplier. + */ +export function fuelCost( + ship: ShipModel, + origin: Location, + distanceMillionKm: number, + config: GameConfig, +): number { + return ( + economicDistance(distanceMillionKm, config) * + ship.fuelCostPerMillionKm * + origin.launchCostMultiplier + ); } diff --git a/packages/sim/src/index.ts b/packages/sim/src/index.ts index 515bc80..ae04c70 100644 --- a/packages/sim/src/index.ts +++ b/packages/sim/src/index.ts @@ -12,7 +12,7 @@ export type { Position } from "./positions"; export { routeDistance } from "./distance"; export { travelTimeGameHours } from "./travel"; -export { fuelCost } from "./fuel"; +export { economicDistance, fuelCost } from "./fuel"; export { canLandAt, checkRoute, rangeFrom } from "./eligibility"; export type { RouteCheck } from "./eligibility"; diff --git a/packages/sim/test/balance.test.ts b/packages/sim/test/balance.test.ts new file mode 100644 index 0000000..4a2d2cd --- /dev/null +++ b/packages/sim/test/balance.test.ts @@ -0,0 +1,79 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { simulate, type Milestones, type SimulationResult } from "../balance/simulate"; +import { MS_PER_HOUR } from "../src/index"; + +/** + * Balance targets (plan Step 12.1 as amended by CR-001 to CR-013), checked on + * the median of several worlds: one world's job luck moves a milestone by tens + * of minutes. The invariants must hold in every world. + */ +const SEEDS = [12345, 777, 4242, 99, 2026, 31337, 8]; +const RUN = { durationMs: 8 * MS_PER_HOUR }; + +let lighterFirst: SimulationResult[]; +let freighterFirst: SimulationResult[]; +beforeAll(() => { + lighterFirst = SEEDS.map((worldSeed) => simulate({ ...RUN, worldSeed, policy: "lighter-first" })); + freighterFirst = SEEDS.map((worldSeed) => + simulate({ ...RUN, worldSeed, policy: "freighter-first" }), + ); +}); + +/** Median real minutes to a milestone; never reached counts as infinitely late. */ +function median(results: SimulationResult[], milestone: keyof Milestones): number { + const sorted = results + .map((r) => r.milestones[milestone] ?? Number.POSITIVE_INFINITY) + .sort((a, b) => a - b); + return sorted[Math.floor(sorted.length / 2)]!; +} + +describe("balance simulation (Step 12.1)", () => { + it("gives identical output for the same seed", () => { + const a = simulate({ ...RUN, worldSeed: 4242, policy: "lighter-first" }); + const b = simulate({ ...RUN, worldSeed: 4242, policy: "lighter-first" }); + expect(b).toEqual(a); + }); + + it("accepts the deal in under 10 real minutes", () => { + expect(median(lighterFirst, "dealAccepted")).toBeLessThan(10); + }); + + it("buys the first Market ship in 45–75 minutes (CR-013)", () => { + const minutes = median(lighterFirst, "firstMarketPurchase"); + expect(minutes).toBeGreaterThanOrEqual(45); + expect(minutes).toBeLessThanOrEqual(75); + }); + + it("discovers a new location in under 90 minutes", () => { + expect(median(lighterFirst, "firstDiscovery")).toBeLessThan(90); + }); + + it("pays off the loan in under 4 hours of active play (CR-013)", () => { + expect(median(lighterFirst, "loanPaidOff")).toBeLessThan(240); + }); + + it("reaches four ships Lighter-first and Freighter-first within 25 % of each other", () => { + const a = median(lighterFirst, "ownFourShips"); + const b = median(freighterFirst, "ownFourShips"); + expect(Number.isFinite(a) && Number.isFinite(b)).toBe(true); + expect(Math.max(a, b)).toBeLessThanOrEqual(1.25 * Math.min(a, b)); + }); + + it("never soft-locks, strands an idle ship or flies a resting pilot, in any world", () => { + for (const result of [...lighterFirst, ...freighterFirst]) { + expect(result.violations, `${result.policy}`).toEqual([]); + } + }); + + it("has the veteran fly at least one contract in the first session (CR-010)", () => { + for (const result of lighterFirst) + expect(result.veteranContractsFirstSession).toBeGreaterThan(0); + }); + + it("reports credits and pilot levels over time", () => { + const [run] = lighterFirst; + expect(run!.samples.length).toBeGreaterThan(5); + expect(run!.samples[0]).toMatchObject({ minute: 0, credits: expect.any(Number) }); + expect(run!.samples.at(-1)!.pilotLevels.length).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/packages/sim/test/contract.test.ts b/packages/sim/test/contract.test.ts index 97d4250..ac8b048 100644 --- a/packages/sim/test/contract.test.ts +++ b/packages/sim/test/contract.test.ts @@ -1,9 +1,13 @@ import { content } from "@deadhead/content"; import { describe, expect, it } from "vitest"; import { tierFor } from "../src/board"; -import { contractPayout, createRng, type PayoutInput } from "../src/index"; +import { contractPayout, createRng, economicDistance, type PayoutInput } from "../src/index"; -const config = content.config; +/** Plain distance, so each factor's effect is exact; the curve is tested below. */ +const config = { + ...content.config, + economicDistance: { ...content.config.economicDistance, exponent: 1 }, +}; const standard = content.cargoType("standard")!; const dangerous = content.cargoType("dangerous")!; @@ -54,6 +58,17 @@ describe("contract payout", () => { }); }); +describe("payout on economic distance (decision 0.5.84)", () => { + const real = content.config; + it("pays a Moon-length leg exactly as before, and long legs on the gentler curve", () => { + const moon = { ...base, distanceMillionKm: real.economicDistance.referenceMillionKm }; + expect(contractPayout(moon, real)).toBe(contractPayout(moon, config)); + const mars = { ...base, distanceMillionKm: 130 }; + expect(contractPayout(mars, real)).toBeLessThan(contractPayout(mars, config)); + expect(contractPayout(mars, real)).toBe(Math.round(100 * 10 * economicDistance(130, real))); + }); +}); + describe("contracts carry goods (CR-007)", () => { const rng = createRng(42); diff --git a/packages/sim/test/fuel.test.ts b/packages/sim/test/fuel.test.ts index 7871edf..22b95ed 100644 --- a/packages/sim/test/fuel.test.ts +++ b/packages/sim/test/fuel.test.ts @@ -1,25 +1,49 @@ import { content } from "@deadhead/content"; import { describe, expect, it } from "vitest"; -import { fuelCost, routeDistance } from "../src/index"; +import { economicDistance, fuelCost, routeDistance } from "../src/index"; +const { config } = content; const courier = content.shipModel("courier")!; const earth = content.location("earth")!; const moon = content.location("moon")!; +const { referenceMillionKm: ref, exponent } = config.economicDistance; + +describe("economic distance (decision 0.5.84)", () => { + it("leaves an Earth–Moon length leg unchanged", () => { + expect(economicDistance(ref, config)).toBeCloseTo(ref); + }); + + it("grows more slowly than distance past the reference, and keeps order", () => { + expect(economicDistance(130, config)).toBeLessThan(130); + expect(economicDistance(130, config)).toBeGreaterThan(economicDistance(60, config)); + // Doubling the distance multiplies it by 2^exponent. + expect(economicDistance(20, config) / economicDistance(10, config)).toBeCloseTo(2 ** exponent); + }); + + it("is the plain distance with an exponent of 1", () => { + const linear = { ...config, economicDistance: { referenceMillionKm: ref, exponent: 1 } }; + expect(economicDistance(130, linear)).toBeCloseTo(130); + }); +}); describe("fuel cost", () => { it("costs more Earth→Moon than Moon→Earth for the same ship", () => { const t = Date.parse("2100-01-01T00:00:00Z"); const distance = routeDistance(earth, moon, t); - expect(fuelCost(courier, earth, distance)).toBeGreaterThan(fuelCost(courier, moon, distance)); + expect(fuelCost(courier, earth, distance, config)).toBeGreaterThan( + fuelCost(courier, moon, distance, config), + ); }); - it("scales linearly with distance", () => { - expect(fuelCost(courier, earth, 2)).toBe(2 * fuelCost(courier, earth, 1)); - expect(fuelCost(courier, earth, 10)).toBe(10 * fuelCost(courier, earth, 1)); + it("scales with economic distance", () => { + expect(fuelCost(courier, earth, 10, config) / fuelCost(courier, earth, 5, config)).toBeCloseTo( + 2 ** exponent, + ); }); it("matches a hand calculation", () => { - // 0.5M km × 200 credits/M km × Earth multiplier 2.1. - expect(fuelCost(courier, earth, 0.5)).toBeCloseTo(210); + // Economic distance of 0.5M km × 200 credits/M km × Earth multiplier 2.1. + const expected = ref * (0.5 / ref) ** exponent * 200 * 2.1; + expect(fuelCost(courier, earth, 0.5, config)).toBeCloseTo(expected); }); }); diff --git a/packages/sim/test/pay-rate.test.ts b/packages/sim/test/pay-rate.test.ts index fa54474..9e0a6e0 100644 --- a/packages/sim/test/pay-rate.test.ts +++ b/packages/sim/test/pay-rate.test.ts @@ -42,6 +42,11 @@ const contracts = Array.from({ length: 1000 }, (_, p) => describe("market rate (CR-008)", () => { it("scales a payout by its rate", () => { + // Plain distance keeps the base payout whole, so the rate's effect is exact. + const linear = { + ...config, + economicDistance: { ...config.economicDistance, exponent: 1 }, + }; const input = { cargoType: content.cargoType("standard")!, payMultiplier: 1, @@ -51,8 +56,8 @@ describe("market rate (CR-008)", () => { firstContact: false, priority: false, }; - expect(contractPayout({ ...input, marketRate: 1.1 }, config)).toBe( - Math.round(contractPayout(input, config) * 1.1), + expect(contractPayout({ ...input, marketRate: 1.1 }, linear)).toBe( + Math.round(contractPayout(input, linear) * 1.1), ); }); diff --git a/packages/sim/tsconfig.json b/packages/sim/tsconfig.json index 7680f99..1200fc8 100644 --- a/packages/sim/tsconfig.json +++ b/packages/sim/tsconfig.json @@ -1,4 +1,4 @@ { "extends": "../../tsconfig.base.json", - "include": ["src", "test"] + "include": ["src", "test", "balance"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9311e57..9d9ccfa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -184,6 +184,10 @@ importers: astronomy-engine: specifier: ^2.1.19 version: 2.1.19 + devDependencies: + tsx: + specifier: ^4.23.15 + version: 4.23.15 packages: