diff --git a/DECISIONS.md b/DECISIONS.md index a7c27c6..ce2786b 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1964,3 +1964,12 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Scheduled from the save, like every notification**: the plan is rebuilt after each change, so "cancel if the trip or company changes" needs nothing of its own, and an answered or fired call simply drops out. - **Only trips the player sent**: Independent pilots' trips are chosen during catch-up, so their calls aren't known ahead (as their arrivals aren't announced, decision 0.5.130). - **Fleet sleep doesn't silence them**: a call on a trip already flying still happens when the fleet is asleep (CR-026 §5). + +## Step 26.9 + +- **Decision 0.5.152: hull wear (the user's choice).** The usual simulated fleet flies 93% Earth↔Moon shuttles, where events are capped at 3%, so 8–15% of trips with events was impossible, and repairs only came from rare event damage (≈ 0% of income). Asked, the user chose to measure the rate and repair targets on a long-haul fleet and to add slow wear: 0.35 condition points per game hour flown, applied on landing (never below 5%), in the engine and the simulator. +- **Repairs cost 1% of the model's price per point**, not CR-026 §4's 0.5%: long hauls pay 100k–500k cr, so at 0.5% even a hull worn to the floor cost under 5% of income. The CR allows tuning repair cost; wear is kept moderate so hulls reach home before the floor. +- **"Defaults" and "answered well" are measured by the events' own worth**, not by comparing two whole runs: a delay changes which job a ship takes next, so runs drift apart (±10% per world). Each event's worth is its pay change, its delay at the job's pay rate and its hull damage at repair cost, counted for the default and for the best option on the same events, against the window's income. Whole-run ratios are still printed by `balance/measure-events.ts`. +- **The measured fleet starts past its first event** (`firstEventDone`), as a mid-game company is, and events start with the scenario window, so the run before it matches a no-events run. +- **Weights retuned for the realised good share**: most bad events need belt, Jupiter, Venus or special-cargo routes, so on the long hauls the fleet flies, good events came out at 75%. Bad events that can happen anywhere (customs at Earth and Mars, flares, micrometeors, hiccups) now weigh more and the route-specific ones less; total good weight is 30%. +- **The away scenario for the report's world reads 90%** (above 40–80%) now that the active player pays for repairs and Independent pilots don't repair; the test's median over seven worlds holds. diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index 476756a..934eb5a 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -148,6 +148,8 @@ On the device the client keeps the save (IndexedDB, or files on native) and, in Notifications (`packages/engine/src/notifications.ts`) also plan each radio call to come on a trip the player sent (`upcomingRadioCalls`, CR-026), and the fleet's sleep time ("Your pilots are waiting for orders.") while it's ahead and some pilot is Independent (CR-024). +Balance simulator events (CR-026 Step 26.9): `simulate` options `events` ("off" / "defaults" / "best"), `eventsFromMinute`, `firstEventDone` and `longHaulMinGameHours`; results `events` (trips, events, good, radio, repair spend, window counts, hull cost, and the events' worth with defaults and with the best answers). + `packages/sim/src/events.ts` (CR-026): `rollTripEvent` rolls a trip's event from its seed at dispatch (null when quiet): fire time, window end, every option's outcome, availability and the default; `eventChance` and `eventWeight` hold the pacing and conditions; `applyEventOption` gives the `AppliedEvent` (new arrival, hull loss, pay changes, Priority bonus lost, extra XP and fuel); `payoutAfterEvent` and `resolveDispatch`'s `event` input apply it to the delivery. `packages/sim/src/away.ts`: `buildAwaySummary({ from, to, entries, pilots, problems })` builds "While you were away" from logbook entries (CR-025): duration, totals, ≤3 highlights in priority order with an overflow count, ≤5 pilot lines by net with an overflow line and the idle count, and a direct list for short absences. `flightNet` is a flight's net. Its `LogbookEntry` type is the save's `LogEntry`. diff --git a/memory-bank/implementation-plan.md b/memory-bank/implementation-plan.md index 07cb606..1637bbc 100644 --- a/memory-bank/implementation-plan.md +++ b/memory-bank/implementation-plan.md @@ -207,6 +207,7 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple 149. **Hull Plating** (CR-026). A fourth upgrade: +1 hull rating per level, three levels, the usual cost and shop time. 150. **Radio-call notifications** (CR-026). On iOS and Android, one local notification at each radio call's fire time on trips the player sent, with the Notifications toggle. 151. **Independent pilots and events** (CR-026). Independent pilots' trips get the same events, radio calls and defaults; the player can answer their calls too. +152. **Hull wear** (CR-026, the user's choice in Step 26.9). Every hull loses 0.35 condition points per game hour flown, applied when a trip lands (never below 5%), so every fleet repairs now and then; with it, repairs cost 1% of the model's price per missing point (CR-026 §4 said 0.5%; tuned in Step 26.9). ### 0.6 Translating change requests written for the server (CR-014 §7) diff --git a/memory-bank/progress.md b/memory-bank/progress.md index ba461bd..86958b0 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -555,5 +555,5 @@ Spec: `memory-bank/change-requests/CR-026-random-events-hull.md` (received as "C - [x] **Step 26.6 — Client: radio calls.** `apps/web/src/components/RadioCalls.tsx`: `RadioCallSheet` shows the first waiting call (after "While you were away" is closed): the event's title and text with the pilot, the ship, "Answer within mm:ss — then Rosa will …" (the default), and each option with what it might do from content ("+3 h", "35% chance: hull −10–20%"), the default marked, unavailable ones disabled ("Not possible on this trip"); "Radio calls (2)" when more wait; Decide later tucks it away. The Bridge lists the waiting calls (`RadioCallsPanel`), each reopening its card, and its tab badge counts them. `PlayerState.nextRadioCallAt` (new) and the waiting calls' window ends refetch the state on time (`useRefetchOnRadio`). Tests (`apps/web/test/radio-calls.test.tsx`): a call's text, options, effects and countdown; answering calls the engine and the call leaves; the window closing removes it; two calls read Radio calls (2); the Bridge badge, Decide later and Answer from the Bridge; an unavailable option is disabled. The result in the summary is Step 26.7's. Screenshots: `docs/screenshots/26-6-radio-call-{phone,desktop}.png` (a fresh company's first event, the derelict pod). - [x] **Step 26.7 — Client: hull and results.** Fleet cards have a hull bar (`HullGauge`: green, amber below 50%, red below 25% with "Needs repair") and say "Under repair" in the shop. The ship's Upgrades sheet has a Hull section: Repair to 100% with its cost and shop time at an HQ (with "Not enough credits" when short), "Repairs are done at one of your HQs" elsewhere; Hull Plating shows in the upgrade list ("Hull rating 1 → 2"). The client's ship state carries the hull, and the dispatch sheet lists "Hull too damaged for hazardous cargo: repair at an HQ". Event results (`apps/web/src/components/EventResults.tsx`): "Debris field ahead — Rosa: Go around (no answer) · +3 h" in the pop-up's short list, the Logbook (and repairs there), and as highlights: good events and hull hits rank just below level-ups (the builder's new `event` highlight; a short absence lists its trips' events, not counted towards the 3). Developer tools: Damage hull (to 20%). Tests: `apps/web/test/hull-events.test.tsx` (the bar and Needs repair; Repair's cost, time and call; away from an HQ; Hull Plating in the shop; the dispatch reason at 40% and not at 50%; event and repair wording; highlight order); `packages/engine/test/trip-events.test.ts` (Damage hull). Screenshots: `docs/screenshots/26-7-hull-card-{phone,desktop}.png`, `26-7-repair-{phone,desktop}.png`. - [x] **Step 26.8 — Notifications.** `plannedNotifications` adds one notification per radio call still to come on a trip the player sent (`upcomingRadioCalls`), at its fire time: "Debris field ahead — Rosa needs a call.", on its own (not grouped by minute). Independent pilots' trips get none; fleet sleep doesn't stop them. Like the rest, the plan is rebuilt from the save after every change, so a new company, an import or an answered call drops it, and the Notifications toggle (now "Arrivals, radio calls, rest, upgrades and a quiet fleet") turns them off. Tests (`packages/engine/test/notifications.test.ts`): a dispatch with a radio call schedules one at its fire time; a quiet trip and an Independent pilot's trip schedule none; turning notifications off cancels it, and so does the call firing. -- [ ] Step 26.9 — Balance +- [x] **Step 26.9 — Balance.** The balance simulator has events (`events: "off" | "defaults" | "best"`, default "defaults"; `eventsFromMinute`; `firstEventDone`): every non-tutorial launch rolls and applies its event, the player repairs at an HQ below 60%, and every hull wears on landing. A long-haul option (`longHaulMinGameHours`) has the player take only long jobs in the scenario window. The CR's targets can't be met by the usual fleet (93% Earth↔Moon shuttles, where events are capped at 3%; repairs came only from rare event damage), so, by the user's choice, they're measured on a long-haul fleet, and hulls now wear 0.35 points per game hour flown (decision 0.5.152, engine and simulator). Tuned (not the targets): event weights (bad 70: flare 12, micrometeor 10, hiccup 13, debris 5, storm 4, customs 20, hazmat 2, load shift 2, spoilage 2; good 30: tailwind 5, grateful client 5, pod 8, beacon 6, spot freight 6), wear 0.35/h, repair cost 1% of price per point. Medians over 20 worlds, 48 h of a long-haul fleet: events on 12.8% of trips (8–15%), 30.8% good (28–38%), defaults worth 99.8% of no events (≥ 93%), answered well 100.4% (≥ 100%), repairs 5.9% of income (5–10%). Earlier targets still hold (balance tests, every seed). Tests: `packages/sim/test/balance.test.ts` (the long-haul targets); `packages/engine/test/trip-events.test.ts` (wear on landing); `packages/sim/test/events.test.ts` and `hull.test.ts` follow the new weights and cost. `pnpm balance` reports them; `balance/measure-events.ts` compares off, defaults and best by hand. - [ ] Step 26.10 — Plan amendments and regression diff --git a/packages/content/src/data/config.ts b/packages/content/src/data/config.ts index 27732b6..135186b 100644 --- a/packages/content/src/data/config.ts +++ b/packages/content/src/data/config.ts @@ -123,8 +123,9 @@ export const gameConfig: GameConfig = { noDangerousBelowPct: 50, needsRepairBelowPct: 25, damagedChanceMultiplier: 2, - repairCostPerPoint: 0.005, + repairCostPerPoint: 0.01, repairRealMinutesPerPoint: 0.5, repairMinRealMinutes: 5, + wearPerGameHour: 0.35, }, }; diff --git a/packages/content/src/data/events.ts b/packages/content/src/data/events.ts index 9e65aa5..e2a76ac 100644 --- a/packages/content/src/data/events.ts +++ b/packages/content/src/data/events.ts @@ -13,7 +13,7 @@ export const tripEvents: TripEventDef[] = [ id: "solar-flare", kind: "automatic", good: false, - weight: 10, + weight: 12, title: "Solar flare", text: "{pilot} sheltered from a solar flare behind the ship's shielding.", when: { minTripGameHours: 24 }, @@ -31,7 +31,7 @@ export const tripEvents: TripEventDef[] = [ id: "micrometeor-strike", kind: "automatic", good: false, - weight: 8, + weight: 10, title: "Micrometeor strike", text: "A micrometeor shower peppered the hull.", when: { minTripGameHours: 24 }, @@ -43,7 +43,7 @@ export const tripEvents: TripEventDef[] = [ id: "engine-hiccup", kind: "automatic", good: false, - weight: 10, + weight: 13, title: "Engine hiccup", text: "An engine stalled and {pilot} had to restart it in flight.", when: { hullBelowWeightFactor: { belowPct: 50, factor: 2 } }, @@ -61,7 +61,7 @@ export const tripEvents: TripEventDef[] = [ id: "debris-field", kind: "radio", good: false, - weight: 10, + weight: 5, title: "Debris field ahead", text: "{pilot} reports a field of rock and wreckage across the route.", when: { crossesBelt: true }, @@ -89,7 +89,7 @@ export const tripEvents: TripEventDef[] = [ id: "storm-approach", kind: "radio", good: false, - weight: 8, + weight: 4, title: "Storm approaching", text: "A storm is building over the landing site, {pilot} says.", when: { arrivalRegions: ["jupiter"], arrivalIds: ["venus-cloud-city"] }, @@ -114,7 +114,7 @@ export const tripEvents: TripEventDef[] = [ id: "customs-hold", kind: "radio", good: false, - weight: 8, + weight: 20, title: "Customs hold", text: "Customs wants to inspect {pilot}'s cargo before landing.", when: { arrivalIds: ["earth", "mars"], contract: true }, @@ -139,7 +139,7 @@ export const tripEvents: TripEventDef[] = [ id: "hazmat-leak", kind: "radio", good: false, - weight: 6, + weight: 2, title: "Hazmat leak", text: "A container in the hold is leaking, {pilot} reports.", when: { cargoClassId: "dangerous", skillHalving: "dangerous-cargo" }, @@ -164,7 +164,7 @@ export const tripEvents: TripEventDef[] = [ id: "load-shift", kind: "radio", good: false, - weight: 6, + weight: 2, title: "Load shift", text: "The load has shifted in the hold, {pilot} says.", when: { cargoClassId: "heavy", skillHalving: "heavy-cargo" }, @@ -189,7 +189,7 @@ export const tripEvents: TripEventDef[] = [ id: "spoilage-risk", kind: "radio", good: false, - weight: 4, + weight: 2, title: "Spoilage risk", text: "The cargo's cooling is struggling, {pilot} reports.", when: { @@ -223,7 +223,7 @@ export const tripEvents: TripEventDef[] = [ id: "solar-tailwind", kind: "automatic", good: true, - weight: 10, + weight: 5, title: "Solar tailwind", text: "A strong solar wind carried {pilot} along.", when: { minTripGameHours: 12 }, @@ -241,7 +241,7 @@ export const tripEvents: TripEventDef[] = [ id: "grateful-client", kind: "automatic", good: true, - weight: 10, + weight: 5, title: "Grateful client", text: "The client was so pleased with {pilot}'s delivery they added a tip.", when: { contract: true }, @@ -253,7 +253,7 @@ export const tripEvents: TripEventDef[] = [ id: "derelict-pod", kind: "radio", good: true, - weight: 6, + weight: 8, title: "Derelict cargo pod", text: "{pilot} has spotted a drifting cargo pod with no beacon.", when: { minTripGameHours: 6, contract: true }, @@ -279,7 +279,7 @@ export const tripEvents: TripEventDef[] = [ id: "distress-beacon", kind: "radio", good: true, - weight: 4, + weight: 6, title: "Distress beacon", text: "{pilot} has picked up a distress beacon near the route.", when: { minTripGameHours: 12, contract: true }, diff --git a/packages/content/src/schemas.ts b/packages/content/src/schemas.ts index f9b8a7b..b115038 100644 --- a/packages/content/src/schemas.ts +++ b/packages/content/src/schemas.ts @@ -500,6 +500,8 @@ export const gameConfigSchema = z repairCostPerPoint: z.number().positive(), repairRealMinutesPerPoint: z.number().positive(), repairMinRealMinutes: z.number().positive(), + /** Condition points every hull loses per game hour flown (decision 0.5.152). */ + wearPerGameHour: z.number().nonnegative(), }), }) .refine( diff --git a/packages/content/test/schemas.test.ts b/packages/content/test/schemas.test.ts index 30f5f26..f4fd2dc 100644 --- a/packages/content/test/schemas.test.ts +++ b/packages/content/test/schemas.test.ts @@ -138,9 +138,10 @@ const validGameConfig = { noDangerousBelowPct: 50, needsRepairBelowPct: 25, damagedChanceMultiplier: 2, - repairCostPerPoint: 0.005, + repairCostPerPoint: 0.01, repairRealMinutesPerPoint: 0.5, repairMinRealMinutes: 5, + wearPerGameHour: 0.35, }, tutorialLoanerShipModelId: "company-courier", tutorialRunsBeforeDeal: 5, diff --git a/packages/engine/src/game/resolve.ts b/packages/engine/src/game/resolve.ts index b2ca9a0..609d528 100644 --- a/packages/engine/src/game/resolve.ts +++ b/packages/engine/src/game/resolve.ts @@ -3,9 +3,12 @@ import type { SaveDocument } from "@deadhead/schema"; import { boardPeriodIndex, effectiveStats, + hullAfterDamage, + hullWear, pilotLevel, resolveDispatch, skillPointsAvailable, + tripGameHours, type RealTime, type Resolution, } from "@deadhead/sim"; @@ -80,6 +83,17 @@ export function resolveArrivals(save: SaveDocument, now: RealTime): number { if (pilot.assignedShipId === null) pilot.locationId = r.shipLocationId; if (ship) { + // Flying wears the hull a little (decision 0.5.152), never below the floor. + const hours = tripGameHours( + dispatch.record.departureAt, + dispatch.record.arrivalAt, + content.config, + ); + ship.hullConditionPct = hullAfterDamage( + ship.hullConditionPct, + hullWear(hours, content.config), + content.config, + ); ship.status = "idle"; ship.locationId = r.shipLocationId; ship.lastArrivalAt = Math.max(ship.lastArrivalAt, dispatch.arrivalAt); diff --git a/packages/engine/test/trip-events.test.ts b/packages/engine/test/trip-events.test.ts index e794a08..bea7ea9 100644 --- a/packages/engine/test/trip-events.test.ts +++ b/packages/engine/test/trip-events.test.ts @@ -303,3 +303,25 @@ describe("developer tools: damage a hull (Step 26.7)", () => { ).toBe(20); }); }); + +describe("hull wear (Step 26.9, decision 0.5.152)", () => { + it("wears a hull by the game hours flown when the trip lands", async () => { + const test = newTestApp(); + const owner = await signUpOwner(test.app); + const avatar = (await readSave(test.app)).pilots.find((p) => p.isAvatar)!.id; + const record = await insertDispatch(test.app, { + shipId: owner.shipId, + pilotId: avatar, + departAt: test.clock.now, + contract: testContract({ destinationId: "mars" }), + }); + test.clock.now = record.arrivalAt + MINUTE; + const ship = (await test.app.call("getPlayerState", {})).ships.find( + (s) => s.id === owner.shipId, + )!; + const hours = + ((record.arrivalAt - record.departureAt) * content.config.timeCompression) / MS_PER_HOUR; + expect(ship.hullConditionPct).toBeCloseTo(100 - hours * content.config.hull.wearPerGameHour, 0); + expect(ship.hullConditionPct).toBeLessThan(100); + }); +}); diff --git a/packages/sim/balance/measure-events.ts b/packages/sim/balance/measure-events.ts new file mode 100644 index 0000000..6143dc0 --- /dev/null +++ b/packages/sim/balance/measure-events.ts @@ -0,0 +1,48 @@ +import { simulate } from "./simulate"; + +// Event balance by hand (Step 26.9): `tsx balance/measure-events.ts `. +const SEEDS = [12345, 777, 4242, 99, 2026, 31337, 8, 1, 2, 3, 4, 5, 6, 7, 9, 10, 11, 12, 13, 14]; +const LONG = Number(process.argv[2] ?? 12); +const HOURS = Number(process.argv[3] ?? 8); +const window = (worldSeed: number, events: "off" | "defaults" | "best", longHaul: number) => + simulate({ + worldSeed, + policy: "lighter-first", + durationMs: (6 + HOURS) * 3_600_000, + scenario: { atMinute: 300, minutes: HOURS * 60, mode: "active" }, + events, + eventsFromMinute: 300, + longHaulMinGameHours: longHaul, + firstEventDone: true, + }); +const median = (xs: number[]) => [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)]!; +const r3 = (v: number) => Math.round(v * 1000) / 1000; +for (const longHaul of process.argv[4] === "long" ? [LONG] : [0, LONG]) { + const rows = SEEDS.map((seed) => { + const off = window(seed, "off", longHaul); + const def = window(seed, "defaults", longHaul); + const best = window(seed, "best", longHaul); + const income = Math.max(1, off.scenarioEarnings!); + return { + seed, + trips: def.events.windowTrips, + share: r3(def.events.windowWithEvent / Math.max(1, def.events.windowTrips)), + good: r3(def.events.good / Math.max(1, def.events.withEvent)), + defRun: r3(def.scenarioEarnings! / income), + bestRun: r3(best.scenarioEarnings! / income), + defValue: r3(1 + def.events.windowDefaultValue / income), + bestValue: r3(1 + def.events.windowBestValue / income), + hullCost: r3(def.events.windowHullCost / income), + repairSpend: r3(def.events.windowRepairSpend / income), + violations: def.violations.length, + }; + }); + console.log(`--- longHaul ${longHaul}, ${HOURS} h`); + for (const r of rows) console.log(JSON.stringify(r)); + const m = (k: keyof (typeof rows)[number]) => r3(median(rows.map((r) => r[k] as number))); + console.log( + ["share", "good", "defRun", "bestRun", "defValue", "bestValue", "hullCost", "repairSpend"] + .map((k) => `${k} ${m(k as keyof (typeof rows)[number])}`) + .join(" "), + ); +} diff --git a/packages/sim/balance/run.ts b/packages/sim/balance/run.ts index 8c0222d..bd155c1 100644 --- a/packages/sim/balance/run.ts +++ b/packages/sim/balance/run.ts @@ -68,3 +68,25 @@ console.log( ? "OK" : "TOO MUCH"), ); + +// Events (CR-026 Step 26.9): a long-haul fleet over 48 h, events left to their defaults. +const longHaul = simulate({ + worldSeed, + policy: "lighter-first", + durationMs: 54 * MS_PER_HOUR, + scenario: { atMinute: 300, minutes: 48 * 60, mode: "active" }, + events: "defaults", + eventsFromMinute: 300, + longHaulMinGameHours: 12, + firstEventDone: true, +}); +const ev = longHaul.events; +const pct = (v: number) => `${(v * 100).toFixed(1)}%`; +console.log("\n=== events on a long-haul fleet, 48 h (CR-026) ==="); +console.log( + `trips ${ev.windowTrips}, with an event ${pct(ev.windowWithEvent / ev.windowTrips)} (8–15%), ` + + `good ${pct(ev.good / Math.max(1, ev.withEvent))} (28–38%), ` + + `defaults ${pct(1 + ev.windowDefaultValue / longHaul.scenarioEarnings!)} (≥ 93%), ` + + `answered well ${pct(1 + ev.windowBestValue / longHaul.scenarioEarnings!)} (≥ 100%), ` + + `repairs ${pct(ev.windowRepairSpend / longHaul.scenarioEarnings!)} of income (5–10%)`, +); diff --git a/packages/sim/balance/simulate.ts b/packages/sim/balance/simulate.ts index 849f4ae..567a17a 100644 --- a/packages/sim/balance/simulate.ts +++ b/packages/sim/balance/simulate.ts @@ -11,6 +11,14 @@ import { content as defaultContent, type ShipModel } from "@deadhead/content"; import { affordableRefuel, + applyEventOption, + hullAfterDamage, + repairCost, + repairShopMs, + rollTripEvent, + hullWear, + tripGameHours, + type AppliedEvent, emergencyFuel, boardPeriodIndex, fuelPriceAt, @@ -58,6 +66,7 @@ type Content = SimContent & { onboardingGoals: typeof defaultContent.onboardingGoals; veteranPilot: typeof defaultContent.veteranPilot; dealLoanAmount: number; + tripEvents: typeof defaultContent.tripEvents; }; /** The first two ships the player buys from the Market, in order (CR-013). */ @@ -82,6 +91,21 @@ export interface SimulationOptions { * `fleetSleep.defaultHours` into it (CR-024). */ scenario?: { atMinute: number; minutes: number; mode: "active" | "away" }; + /** + * Random events (CR-026 Step 26.9): off, on with every radio call left to + * its default (cautious) option, or on with the player picking the best + * option on every call. Default "defaults". `eventsFromMinute` starts them + * later (default 0), so a run can match a no-events run up to that point. + */ + events?: "off" | "defaults" | "best"; + eventsFromMinute?: number; + /** + * A long-haul fleet (CR-026 Step 26.9): in the scenario window the player + * only takes contracts of at least this many game hours. + */ + longHaulMinGameHours?: number; + /** A mid-game company past its first event (the derelict pod), as most are. */ + firstEventDone?: boolean; } export interface Sample { @@ -130,6 +154,28 @@ export interface SimulationResult { * Step 24.7), the most it can earn after; null if the window ended first. */ payoutInFlightAtSleep: number | null; + /** Events (CR-026): non-tutorial trips flown and those with an event, good ones, and repairs. */ + events: { + trips: number; + withEvent: number; + good: number; + radio: number; + /** Credits spent on hull repairs. */ + repairSpend: number; + /** The same, inside the scenario window. */ + windowTrips: number; + windowWithEvent: number; + windowRepairSpend: number; + /** What restoring the hull lost in the window costs: wear and event damage (Step 26.9). */ + windowHullCost: number; + /** + * What the window's events were worth to the company (pay changes, delay + * at the job's pay rate, hull damage at repair cost), had every radio call + * been left to its default, or answered as well as possible. + */ + windowDefaultValue: number; + windowBestValue: number; + }; } interface SimShip extends ShipState { @@ -139,6 +185,8 @@ interface SimShip extends ShipState { inShopUntil: RealTime | null; fromDeal: boolean; trips: number; + /** Trips since its last event (CR-026 cooldown); absent: past the cooldown. */ + quietTrips?: number; } interface SimPilot { @@ -161,6 +209,8 @@ interface Flight { record: DispatchRecord; shipId: string | null; pilotId: string; + /** Its event once applied (CR-026). */ + event?: AppliedEvent | null; } const PURCHASE_ORDER: Record = { @@ -373,7 +423,142 @@ export function simulate(options: SimulationOptions): SimulationResult { ship.inFlight = true; ship.trips++; pilot.inFlight = true; - flights.push({ record, shipId: ship.id, pilotId: pilot.id }); + launch(ship, pilot, record); + } + + // --- Events (CR-026, Step 26.9). --- + const eventMode = options.events ?? "defaults"; + const eventsFrom = start + (options.eventsFromMinute ?? 0) * MS_PER_MINUTE; + let firstEventDone = options.firstEventDone ?? false; + let tripsSinceDeal = 0; + const radioTimes: RealTime[] = []; + const eventStats = { + trips: 0, + withEvent: 0, + good: 0, + radio: 0, + repairSpend: 0, + windowTrips: 0, + windowWithEvent: 0, + windowRepairSpend: 0, + windowHullCost: 0, + windowDefaultValue: 0, + windowBestValue: 0, + }; + const inScenario = () => windowOpen; + + /** What an option is worth to the player: pay, less losses, hull repairs and time. */ + function optionValue(record: DispatchRecord, applied: AppliedEvent, ship: SimShip): number { + const span = Math.max(1, record.arrivalAt - record.departureAt); + const payout = record.contract?.payout ?? 0; + const timeCost = ((applied.arrivalAt - record.arrivalAt) / span) * payout; + const hullCost = applied.hullLoss * model(ship.modelId).price * config.hull.repairCostPerPoint; + return ( + applied.bonus - applied.payLoss - applied.fee - applied.priorityLost - hullCost - timeCost + ); + } + + /** Puts a trip in the air, with its event rolled and applied at once (CR-026). */ + function launch(ship: SimShip, pilot: SimPilot, record: DispatchRecord) { + let flown = record; + let applied: AppliedEvent | null = null; + eventStats.trips++; + if (inScenario()) eventStats.windowTrips++; + if (eventMode !== "off" && now >= eventsFrom) { + const stats = effectiveStats(model(ship.modelId), ship.upgrades, content); + const event = rollTripEvent( + { + record, + ship: { + hullRating: stats.hullRating, + hullConditionPct: ship.hullConditionPct ?? 100, + cargoCapacityTonnes: stats.cargoCapacityTonnes, + fuelAfterDepartureFu: ship.tank.fuelFu, + quietTrips: ship.quietTrips ?? config.events.shipCooldownTrips, + }, + pilotSkills: pilot.skills, + fleetRadioCalls: radioTimes, + company: { firstEventDone, tripsSinceDeal }, + }, + content, + ); + tripsSinceDeal++; + if (event) { + ship.quietTrips = 0; + firstEventDone = true; + if (event.kind === "radio") { + radioTimes.push(event.fireAt); + eventStats.radio++; + } + eventStats.withEvent++; + if (event.good) eventStats.good++; + if (inScenario()) eventStats.windowWithEvent++; + const choices = event.options + .filter((o) => o.available) + .map((o) => + applyEventOption( + { record, event, optionId: o.id, answered: o.id !== event.defaultOptionId }, + content, + ), + ); + const bestChoice = + event.kind === "radio" + ? choices.reduce((a, b) => + optionValue(record, b, ship) > optionValue(record, a, ship) ? b : a, + ) + : choices[0]!; + const defaultChoice = choices.find((c) => c.optionId === event.defaultOptionId)!; + // The events' own worth, both ways, on the same events (Step 26.9). + if (inScenario()) { + eventStats.windowDefaultValue += optionValue(record, defaultChoice, ship); + eventStats.windowBestValue += optionValue(record, bestChoice, ship); + } + applied = eventMode === "best" ? bestChoice : defaultChoice; + loseHull(ship, applied.hullLoss); + const extra = Math.min(applied.extraFuelFu, ship.tank.fuelFu); + ship.tank = { ...ship.tank, fuelFu: ship.tank.fuelFu - extra }; + flown = { + ...record, + arrivalAt: applied.arrivalAt, + fuelBurnedFu: record.fuelBurnedFu + extra, + fuelCost: record.fuelCost + Math.round(extra * ship.tank.avgPrice), + }; + } else { + ship.quietTrips = (ship.quietTrips ?? config.events.shipCooldownTrips) + 1; + } + } + flights.push({ record: flown, shipId: ship.id, pilotId: pilot.id, event: applied }); + } + + /** A hull hit or wear; in the scenario window, counted at what repairing it costs. */ + function loseHull(ship: SimShip, points: number) { + const before = ship.hullConditionPct ?? 100; + ship.hullConditionPct = hullAfterDamage(before, points, config); + if (inScenario()) { + const lost = before - ship.hullConditionPct; + eventStats.windowHullCost += + lost * model(ship.modelId).price * config.hull.repairCostPerPoint; + } + } + + /** Hull repairs at an HQ (decision 0.5.148): below 60%, when the cash is there. */ + function repairShips(): boolean { + let acted = false; + for (const ship of ships) { + const hull = ship.hullConditionPct ?? 100; + if (ship.inFlight || hull >= 60 || !bays.has(ship.locationId)) continue; + if (ship.inShopUntil != null && ship.inShopUntil > now) continue; + const cost = repairCost(model(ship.modelId), hull, config); + if (credits < cost + FUEL_RESERVE) continue; + credits -= cost; + eventStats.repairSpend += cost; + if (inScenario()) eventStats.windowRepairSpend += cost; + ship.inShopUntil = now + repairShopMs(hull, config); + ship.hullConditionPct = 100; + log(`repair ${ship.modelId} from ${hull}% for ${cost} credits ${credits}`); + acted = true; + } + return acted; } // --- Arrivals (server resolve.ts). --- @@ -392,6 +577,7 @@ export function simulate(options: SimulationOptions): SimulationResult { pilotDutyHours: pilot.dutyHours, pilotSkills: pilot.skills, now, + event: flight.event ?? null, fuel: flightShip ? { tank: flightShip.tank, @@ -423,6 +609,9 @@ export function simulate(options: SimulationOptions): SimulationResult { if (r.pilotRestEndsAt !== null) pilot.restEndsAt = r.pilotRestEndsAt; if (flight.shipId) { const ship = ships.find((s) => s.id === flight.shipId)!; + // Flying wears every hull, events or not (decision 0.5.152). + const hours = tripGameHours(flight.record.departureAt, flight.record.arrivalAt, config); + loseHull(ship, hullWear(hours, config)); ship.inFlight = false; ship.locationId = r.shipLocationId; } @@ -675,8 +864,10 @@ export function simulate(options: SimulationOptions): SimulationResult { ) { violate("needs-emergency-fuel", `${ship.id} ${ship.modelId} at ${ship.locationId}`); } + const longHaul = windowOpen ? (options.longHaulMinGameHours ?? 0) : 0; const best = (candidate: SimShip) => eligibleContracts(candidate, pilot) + .filter((c) => contractTripGameHours(stats, c, content) >= longHaul) .map((c) => ({ c, value: rate(c, stats, pilot) })) .filter((o) => o.value > 0) .sort((a, b) => b.value - a.value)[0]; @@ -695,6 +886,8 @@ export function simulate(options: SimulationOptions): SimulationResult { acted = true; continue; } + // A long-haul fleet (Step 26.9): a ship with no long job waits for one. + if (longHaul > 0) continue; // No job here: fuel up to go looking for one. topUpHere(ship, stats.tankCapacityFu); const canDeadhead = (to: string, fullTank: boolean) => @@ -744,7 +937,7 @@ export function simulate(options: SimulationOptions): SimulationResult { 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 }); + launch(ship, pilot, record); log( `${ship.modelId} deadhead ${ship.locationId}→${target.to} fuel ${record.fuelCost} credits ${credits}`, ); @@ -912,7 +1105,7 @@ export function simulate(options: SimulationOptions): SimulationResult { 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 }); + launch(ship, pilot, record); } pilot.emptyPeriods = 0; pilot.nextDecisionAt = null; @@ -977,6 +1170,7 @@ export function simulate(options: SimulationOptions): SimulationResult { } else { spendSkillPoints(); } + acted = repairShips() || acted; acted = flyShips() || acted; } if (!acted) break; @@ -1006,5 +1200,6 @@ export function simulate(options: SimulationOptions): SimulationResult { uncompletableShown, scenarioEarnings: scenario ? credits - windowCredits + (windowLoan - loanBalance) : null, payoutInFlightAtSleep, + events: eventStats, }; } diff --git a/packages/sim/src/hull.ts b/packages/sim/src/hull.ts index edbf1c9..59efa68 100644 --- a/packages/sim/src/hull.ts +++ b/packages/sim/src/hull.ts @@ -53,3 +53,8 @@ export function repairShopMs(conditionPct: number, config: GameConfig): number { export function hullBarsDangerous(conditionPct: number, config: GameConfig): boolean { return conditionPct < config.hull.noDangerousBelowPct; } + +/** Wear on a hull from flying, in condition points (decision 0.5.152): so every fleet repairs now and then. */ +export function hullWear(tripGameHours: number, config: GameConfig): number { + return Math.round(tripGameHours * config.hull.wearPerGameHour * 10) / 10; +} diff --git a/packages/sim/src/index.ts b/packages/sim/src/index.ts index ec676ac..3147c2c 100644 --- a/packages/sim/src/index.ts +++ b/packages/sim/src/index.ts @@ -172,6 +172,7 @@ export { hullAfterDamage, hullBarsDangerous, hullRatingReduction, + hullWear, repairCost, repairShopMs, } from "./hull"; diff --git a/packages/sim/test/balance.test.ts b/packages/sim/test/balance.test.ts index 4ccccfe..357e2bf 100644 --- a/packages/sim/test/balance.test.ts +++ b/packages/sim/test/balance.test.ts @@ -165,3 +165,56 @@ describe("a week away (CR-024 Step 24.7, plan 12.1)", () => { } }); }); + +describe("events in a long-haul mid-game fleet (CR-026 Step 26.9)", () => { + // Most of the usual fleet's trips are Earth↔Moon shuttles, where events stay at 3% or + // less (test/events.test.ts); the rate and repair targets are for a fleet on long + // hauls (decision 0.5.152's note, the user's choice). + const EVENT_SEEDS = [ + 12345, 777, 4242, 99, 2026, 31337, 8, 1, 2, 3, 4, 5, 6, 7, 9, 10, 11, 12, 13, 14, + ]; + const median = (xs: number[]) => [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)]!; + let rows: { share: number; good: number; defaults: number; best: number; repairs: number }[]; + + beforeAll(() => { + rows = EVENT_SEEDS.map((worldSeed) => { + const run = simulate({ + worldSeed, + policy: "lighter-first", + durationMs: 54 * MS_PER_HOUR, + scenario: { atMinute: 300, minutes: 48 * 60, mode: "active" }, + events: "defaults", + eventsFromMinute: 300, + longHaulMinGameHours: 12, + firstEventDone: true, + }); + const e = run.events; + const income = run.scenarioEarnings!; + return { + share: e.windowWithEvent / e.windowTrips, + good: e.good / e.withEvent, + // What the window's events were worth, every call left to its default or answered well. + defaults: 1 + e.windowDefaultValue / income, + best: 1 + e.windowBestValue / income, + repairs: e.windowRepairSpend / income, + }; + }); + }, 120_000); + + it("gives 8–15% of trips an event, about a third of them good", () => { + expect(median(rows.map((r) => r.share))).toBeGreaterThanOrEqual(0.08); + expect(median(rows.map((r) => r.share))).toBeLessThanOrEqual(0.15); + expect(median(rows.map((r) => r.good))).toBeGreaterThanOrEqual(0.28); + expect(median(rows.map((r) => r.good))).toBeLessThanOrEqual(0.38); + }); + + it("keeps defaults at 93%+ of no events, and good answers at no less", () => { + expect(median(rows.map((r) => r.defaults))).toBeGreaterThanOrEqual(0.93); + expect(median(rows.map((r) => r.best))).toBeGreaterThanOrEqual(1); + }); + + it("spends 5–10% of income on hull repairs", () => { + expect(median(rows.map((r) => r.repairs))).toBeGreaterThanOrEqual(0.05); + expect(median(rows.map((r) => r.repairs))).toBeLessThanOrEqual(0.1); + }); +}); diff --git a/packages/sim/test/events.test.ts b/packages/sim/test/events.test.ts index 5b83e48..b5feec2 100644 --- a/packages/sim/test/events.test.ts +++ b/packages/sim/test/events.test.ts @@ -229,7 +229,7 @@ describe("rolling a trip's event (Step 26.2, decisions 0.5.143–0.5.145)", () = }; const atTier = share(1); const twoAbove = share(3); - expect(atTier).toBeGreaterThan(0.03); + expect(atTier).toBeGreaterThan(0.015); // Two levels above: a quarter of the weight, so far rarer among events. expect(twoAbove).toBeLessThan(atTier * 0.4); }); diff --git a/packages/sim/test/hull.test.ts b/packages/sim/test/hull.test.ts index 3101633..7c71a25 100644 --- a/packages/sim/test/hull.test.ts +++ b/packages/sim/test/hull.test.ts @@ -85,7 +85,9 @@ describe("hull condition and repairs (Step 26.4, decisions 0.5.147, 0.5.148)", ( it("prices a repair on the missing points and times it at a minute per 2%, at least 5 minutes", () => { const courier = content.shipModel("courier")!; - expect(repairCost(courier, 60, config)).toBe(Math.round(40 * courier.price * 0.005)); + expect(repairCost(courier, 60, config)).toBe( + Math.round(40 * courier.price * config.hull.repairCostPerPoint), + ); expect(repairCost(courier, 100, config)).toBe(0); expect(repairShopMs(60, config)).toBe(20 * 60_000); expect(repairShopMs(98, config)).toBe(5 * 60_000);