diff --git a/DECISIONS.md b/DECISIONS.md index f43a7a3..5956905 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1914,3 +1914,11 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **The hull rating cuts both the chance and the amount** of hull damage (decision 0.5.148): a chance bucket with hull damage is scaled by (1 − reduction) and doubled below 25% condition; the amount by (1 − reduction). Automatic hull damage (micrometeor) always happens once rolled, so only its amount is cut. - **The first event waits for a long-enough contract**: until the company has had one, trips under 6 game hours, and deadheads, are quiet, so the pod really is the first event. - **A radio call too close to another is dropped, not swapped** for an automatic event, as the CR says ("the trip is quiet"). + +## Step 26.3 + +- **Delays are measured from the original arrival**, which is the same as "from the fire time" for a trip that keeps flying: the event adds its hours to what was left. +- **A speed-up's floor is the later of half the trip and the event's own time**, so a tailwind early in a short trip can't land the ship before it happened. +- **The Priority check uses the actual trip time after the event** against the delivery window; missing it costs `payout − payout ÷ Priority multiplier`, the bonus only (decision 0.5.146). +- **Losses never take the payout below zero, and bonuses come on top** (decision 0.5.147): a vented load and a fee on a small job leave 0, plus any tip. +- **The pilot's share is taken from the payout after the event**, like the loan: a salvage bonus is shared with the pilot who flew it, and a loss costs them too. diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index 8ec350c..dcbbe33 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -146,6 +146,8 @@ On the device the client keeps the save (IndexedDB, or files on native) and, in Notifications (`packages/engine/src/notifications.ts`) also plan the fleet's sleep time ("Your pilots are waiting for orders.") while it's ahead and some pilot is Independent (CR-024). +`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`. `packages/engine/src/game/catchup.ts`: `catchUp(save, until, { decideUntil, maxEvents, viewedContractId })` replays time as an event queue. It takes the next in-flight arrival or the next Independent pilot's `nextDecisionAt`, in time order: arrivals first at the same moment, then pilots by id. Each arrival resolves at its own time. A decision point checks rest, the shop and the first-look window (each pushes `nextDecisionAt` later), then asks the sim's `chooseAction` about the pilot's board. It acts through the normal `refuelShip`, `dispatchContract` and `dispatchReposition`, with ids made from the pilot and the moment (`ind-…`), so the same time always gives the same save. Waiting schedules the next board period's first-look end and counts empty periods away from home. A trip's landing schedules the next decision a turnaround after the arrival, or after the rest it triggers. Decisions stop `maxCatchUpRealDays` after the save's `lastTimeSeen` (`decideUntil`) and at the fleet's sleep time (`sleepsAt` in `state.ts`: `company.lastManualDecisionAt` plus `sleepAfterHours`, CR-024), and later ones resume from `until`, so the next manual decision wakes pilots from then. Arrivals and shop ends always resolve. A null `lastManualDecisionAt` (a save just migrated) is set to `until` first. Every command runs `catchUp` to its `now` before it acts. `GameEngine.resolveDue(since, { chunkEvents, onProgress })` runs the loop's pass in chunks (500 events, one save each), with `onProgress(0…1)` in between. `GameEngine.setViewedContract(id)` tells pilots which contract the player has open. Dispatches an Independent pilot chose are flagged `independent`, and no notification is scheduled for them. diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 90d7e41..67b7ecd 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -549,7 +549,7 @@ Spec: `memory-bank/change-requests/CR-026-random-events-hull.md` (received as "C - [x] **Step 26.1 — Content.** `content.tripEvents` (`packages/content/src/data/events.ts`, `tripEventSchema`): the 14 events of CR-026 §3, each with kind (automatic or radio), good flag, weight, title, text, when it can happen (trip length, belt crossings, inner/belt legs, arrival regions or places, contracts, cargo class with skill halving, Priority perishables, a hull-below weight factor) and options (label, default, effects, exclusive chances, free hold or fuel needed). Config `events` (pacing, decision 0.5.145) and `hull` (decision 0.5.148). Every location has a `region` (inner, belt, jupiter). Hull Plating (`hull-plating`, +1 hull rating a level, three levels): upgrades can now add a flat amount (`flatPerLevel`), and `hullRating` is an upgradable stat. Tests: `packages/content/test/events.test.ts` (the 14 valid events; one default per radio call, and two refused; chances at most 100%, and over refused; good weight 34%, within 28–38%; ids that exist; the config numbers and regions; Hull Plating); `packages/sim/test/effective-stats.test.ts` (Hull Plating adds 1 a level). - [x] **Step 26.2 — Sim: event roll at dispatch.** `rollTripEvent` (`packages/sim/src/events.ts`, pure, seeded from the trip's seed) returns null or a `TripEvent`: its id, kind, fire time (20–70% of the trip), window end (radio: 30 real minutes or arrival, whichever is first; automatic: the fire time), every option's pre-rolled `EventOutcome` (delay in real ms, hull loss after the hull rating, pay lost, fee, bonus, extra XP %, extra fuel), whether each is available (10% free hold, fuel for the burn) and the default (another available option when the cautious one can't be taken). Quiet in the tutorial, the first 3 trips after the deal and a ship's 2 trips after an event; the first event is the derelict pod on the first contract of 6+ game hours; otherwise `eventChance` (decision 0.5.145, capped at 35% after the route multipliers) and a weighted pick among the events allowed on the trip (`eventWeight`: conditions, skill halving, the hull factor); a radio call within 45 real minutes of another in the fleet is dropped. Tests (`packages/sim/test/events.test.ts`): the same trip gives the same event; none in the tutorial or the grace trips; the first event is the pod on 6+ hours only; the cooldown; fleet pacing at 44 and 46 minutes, the window, the fire point; 100,000 trips (Earth↔Moon ≤ 3%, long hauls ≤ 35%, every event only where allowed); Dangerous Cargo two levels above the tier makes leaks under 40% as common. -- [ ] Step 26.3 — Sim: applying outcomes +- [x] **Step 26.3 — Sim: applying outcomes.** `applyEventOption` turns an option's pre-rolled outcome into an `AppliedEvent`: the new arrival (the delay added to the original arrival; a speed-up never below half the trip or before the event), hull loss, pay lost, fee, bonus, the Priority bonus lost when the new trip misses the window, extra XP and extra fuel; it refuses an unavailable option. `payoutAfterEvent` takes losses, the fee and a lost Priority bonus off the payout (never below zero) and adds the bonus. `resolveDispatch` takes the applied event: the pilot's share and the loan come from the new payout, and XP gets the extra percent. Tests (`packages/sim/test/events.test.ts`): a 3-hour detour moves arrival by exactly 3 game hours; a tailwind stops at half the trip; pay never below zero, bonus on top; a missed Priority window costs only the bonus; a full hold makes salvage unavailable and refused; an option's outcome is its pre-rolled one; resolution pays the bonus and extra XP and shares the new payout. - [ ] Step 26.4 — Sim: hull condition and repairs - [ ] Step 26.5 — Engine: events in play and catch-up - [ ] Step 26.6 — Client: radio calls diff --git a/packages/sim/src/events.ts b/packages/sim/src/events.ts index 5b983e5..218607b 100644 --- a/packages/sim/src/events.ts +++ b/packages/sim/src/events.ts @@ -306,3 +306,78 @@ export function rollTripEvent(input: EventRollInput, content: EventContent): Tri } return event; } + +/** What an event changed about a trip once its option applied (decision 0.5.143). */ +export interface AppliedEvent { + eventId: string; + optionId: string; + /** The player chose it; false for an automatic event or a default. */ + answered: boolean; + /** The trip's arrival after the event. */ + arrivalAt: RealTime; + hullLoss: number; + payLoss: number; + fee: number; + bonus: number; + /** The Priority bonus lost by missing the window (decision 0.5.146). */ + priorityLost: number; + xpBonusPct: number; + extraFuelFu: number; +} + +/** + * Applies an option's pre-rolled outcome to a trip (decision 0.5.143). Effects + * count from when the event fired: a delay moves the arrival by exactly its + * amount, a speed-up never cuts the trip below `minTripFraction` of it (nor + * before the event), and a late Priority delivery loses only its bonus. + * Throws for an option the trip can't take. + */ +export function applyEventOption( + args: { record: DispatchRecord; event: TripEvent; optionId: string; answered: boolean }, + content: EventContent, +): AppliedEvent { + const { record, event, optionId, answered } = args; + const option = event.options.find((o) => o.id === optionId); + if (!option) throw new Error(`No option "${optionId}" on ${event.eventId}`); + if (!option.available) throw new Error(`Option "${optionId}" isn't available on this trip`); + const { outcome } = option; + const { config } = content; + const span = record.arrivalAt - record.departureAt; + const floor = Math.max( + event.fireAt, + record.departureAt + Math.ceil(span * config.events.minTripFraction), + ); + const arrivalAt = Math.max(floor, record.arrivalAt + outcome.delayMs); + return { + eventId: event.eventId, + optionId, + answered, + arrivalAt, + hullLoss: outcome.hullLoss, + payLoss: outcome.payLoss, + fee: outcome.fee, + bonus: outcome.bonus, + priorityLost: priorityLost(record, arrivalAt, config), + xpBonusPct: outcome.xpBonusPct, + extraFuelFu: outcome.extraFuelFu, + }; +} + +/** The Priority bonus a delivery loses for arriving outside its window (decision 0.5.146). */ +function priorityLost(record: DispatchRecord, arrivalAt: RealTime, config: GameConfig): number { + const contract = record.contract; + if (!contract?.priority || contract.deliveryWindowGameHours === null) return 0; + const hours = ((arrivalAt - record.departureAt) * config.timeCompression) / MS_PER_HOUR; + if (hours <= contract.deliveryWindowGameHours) return 0; + return contract.payout - Math.round(contract.payout / config.priorityPayoutMultiplier); +} + +/** + * A contract's payout after its event (decision 0.5.147): losses, the fee and + * a missed Priority bonus never take it below zero; bonuses add on top. + */ +export function payoutAfterEvent(payout: number, applied: AppliedEvent | null | undefined): number { + if (!applied) return payout; + const losses = applied.payLoss + applied.fee + applied.priorityLost; + return Math.max(0, payout - losses) + applied.bonus; +} diff --git a/packages/sim/src/index.ts b/packages/sim/src/index.ts index d207239..0cb8770 100644 --- a/packages/sim/src/index.ts +++ b/packages/sim/src/index.ts @@ -150,6 +150,8 @@ export type { LogbookEntry, } from "./away"; export { + applyEventOption, + payoutAfterEvent, eventChance, eventWeight, hullRatingReduction, @@ -157,4 +159,11 @@ export { routeFacts, tripGameHoursOf, } from "./events"; -export type { EventContent, EventOption, EventOutcome, EventRollInput, TripEvent } from "./events"; +export type { + AppliedEvent, + EventContent, + EventOption, + EventOutcome, + EventRollInput, + TripEvent, +} from "./events"; diff --git a/packages/sim/src/resolution.ts b/packages/sim/src/resolution.ts index a9f7861..8f81854 100644 --- a/packages/sim/src/resolution.ts +++ b/packages/sim/src/resolution.ts @@ -8,6 +8,7 @@ import { splitLoanRepayment } from "./loan"; import { pilotShare } from "./pay"; import type { SkillLevels } from "./pilot"; import { deliveryXp } from "./xp"; +import { payoutAfterEvent, type AppliedEvent } from "./events"; /** Auto top-up (default) fills the tank on landing; Manual never does (decision 0.5.97). */ export type RefuelPolicy = "auto" | "manual"; @@ -67,6 +68,8 @@ export interface ResolveInput { * tutorial loaner uses no fuel). */ fuel?: { tank: Tank; capacityFu: number; refuelPolicy: RefuelPolicy; credits: number }; + /** The trip's event once applied (CR-026): it changes the pay and XP. */ + event?: AppliedEvent | null; } /** Auto top-up on landing (decision 0.5.97): as much as the credits after this trip cover. */ @@ -134,14 +137,14 @@ export function resolveDispatch(input: ResolveInput, content: SimContent): Resol }; } + // An event's losses, fees and bonuses change what the shipper pays (CR-026). + const payout = payoutAfterEvent(contract.payout, input.event); // Profit-share pay (decision 0.5.12, CR-010), taken before the loan repayment (0.5.69). const pilotPay = - record.tutorial || pilotIsAvatar - ? 0 - : pilotShare(contract.payout, record.fuelCost, content.config); + record.tutorial || pilotIsAvatar ? 0 : pilotShare(payout, record.fuelCost, content.config); // Tutorial contracts already store exactly the tutorial share as their payout. const { repayment, net } = splitLoanRepayment( - contract.payout - pilotPay, + payout - pilotPay, loanBalance, content.config.loanRepaymentShare, ); @@ -153,7 +156,9 @@ export function resolveDispatch(input: ResolveInput, content: SimContent): Resol topUp: topUpOnLanding(input, net, content), pilotPay, loanRepayment: repayment, - pilotXp: deliveryXpFor(record, contract.tier, content), + pilotXp: Math.round( + deliveryXpFor(record, contract.tier, content) * (1 + (input.event?.xpBonusPct ?? 0) / 100), + ), discoversDestination: !discoveredIds.has(record.destinationId), }, }; diff --git a/packages/sim/test/events.test.ts b/packages/sim/test/events.test.ts index 56e8adb..5b83e48 100644 --- a/packages/sim/test/events.test.ts +++ b/packages/sim/test/events.test.ts @@ -1,12 +1,19 @@ import { content } from "@deadhead/content"; import { describe, expect, it } from "vitest"; import { + applyEventOption, MS_PER_HOUR, + payoutAfterEvent, + pilotShare, + resolveDispatch, rollTripEvent, routeFacts, + type AppliedEvent, type Contract, type DispatchRecord, + type EventOutcome, type EventRollInput, + type TripEvent, } from "../src/index"; const T0 = Date.parse("2100-01-01T00:00:00Z"); @@ -227,3 +234,142 @@ describe("rolling a trip's event (Step 26.2, decisions 0.5.143–0.5.145)", () = expect(twoAbove).toBeLessThan(atTier * 0.4); }); }); + +describe("applying an event's outcome (Step 26.3, decisions 0.5.143, 0.5.146, 0.5.147)", () => { + const HOUR_REAL = MS_PER_HOUR / timeCompression; + const none: EventOutcome = { + delayMs: 0, + hullLoss: 0, + payLoss: 0, + fee: 0, + bonus: 0, + xpBonusPct: 0, + extraFuelFu: 0, + }; + const event = ( + trip: DispatchRecord, + outcome: Partial, + available = true, + ): TripEvent => ({ + eventId: "debris-field", + kind: "radio", + good: false, + fireAt: trip.departureAt + Math.round((trip.arrivalAt - trip.departureAt) * 0.4), + windowEndsAt: trip.arrivalAt, + options: [ + { id: "a", available, outcome: { ...none, ...outcome } }, + { id: "b", available: true, outcome: none }, + ], + defaultOptionId: "b", + }); + const apply = (trip: DispatchRecord, e: TripEvent, optionId = "a") => + applyEventOption({ record: trip, event: e, optionId, answered: true }, content); + + it("moves arrival by exactly a 3-hour detour", () => { + const trip = record(1, "earth", "jupiter-cloud-platform", 200); + const applied = apply(trip, event(trip, { delayMs: 3 * HOUR_REAL })); + expect(applied.arrivalAt - trip.arrivalAt).toBe(3 * HOUR_REAL); + }); + + it("never lets a tailwind shorten a trip below half of it, or before the event", () => { + const trip = record(1, "earth", "mars", 10); + const applied = apply(trip, event(trip, { delayMs: -100 * HOUR_REAL })); + const half = trip.departureAt + (trip.arrivalAt - trip.departureAt) / 2; + expect(applied.arrivalAt).toBe(Math.ceil(half)); + expect(applied.arrivalAt).toBeGreaterThanOrEqual(event(trip, {}).fireAt); + }); + + it("never takes pay below zero, and pays bonuses on top", () => { + const trip = record(1, "earth", "mars", 10, { payout: 1_000 }); + const lost = apply(trip, event(trip, { payLoss: 900, fee: 500 })); + expect(payoutAfterEvent(1_000, lost)).toBe(0); + const tipped = apply(trip, event(trip, { payLoss: 900, fee: 500, bonus: 200 })); + expect(payoutAfterEvent(1_000, tipped)).toBe(200); + }); + + it("costs only the Priority bonus when a delay misses the window", () => { + const trip = record(1, "earth", "mars", 10, { + payout: 1_500, + priority: true, + deliveryWindowGameHours: 12, + }); + const inTime = apply(trip, event(trip, { delayMs: 1 * HOUR_REAL })); + expect(inTime.priorityLost).toBe(0); + const late = apply(trip, event(trip, { delayMs: 3 * HOUR_REAL })); + // 1,500 is 1,000 × the 1.5 Priority bonus: the base 1,000 is still paid. + expect(late.priorityLost).toBe(500); + expect(payoutAfterEvent(1_500, late)).toBe(1_000); + }); + + it("refuses an option the trip can't take: salvage and spot freight need 10% of the hold free", () => { + const full = (s: number) => + input(record(s, "earth", "mars", 40, { massTonnes: 95 }), { + company: { firstEventDone: false, tripsSinceDeal: 3 }, + }); + const pod = rollTripEvent(full(7), content)!; + expect(pod.eventId).toBe("derelict-pod"); + expect(pod.options.find((o) => o.id === "salvage")!.available).toBe(false); + expect(() => apply(full(7).record, pod, "salvage")).toThrow(/isn't available/); + const roomy = rollTripEvent({ ...full(7), record: record(7, "earth", "mars", 40) }, content)!; + expect(roomy.options.find((o) => o.id === "salvage")!.available).toBe(true); + }); + + it("gives an option its pre-rolled outcome whenever it's chosen", () => { + const trip = record(3, "earth", "mars", 40); + const pod = rollTripEvent( + input(trip, { company: { firstEventDone: false, tripsSinceDeal: 3 } }), + content, + )!; + const early = apply(trip, pod, "salvage"); + const late = applyEventOption( + { record: trip, event: pod, optionId: "salvage", answered: true }, + content, + ); + expect(late).toEqual(early); + const salvage = pod.options.find((o) => o.id === "salvage")!.outcome; + expect(early.bonus).toBe(salvage.bonus); + expect(salvage.bonus).toBeGreaterThanOrEqual(10_000 * 0.15); + expect(salvage.bonus).toBeLessThanOrEqual(10_000 * 0.4); + }); +}); + +describe("resolving a trip with its event (Step 26.3)", () => { + it("pays the event's bonus and extra XP, and takes the pilot's share of the new payout", () => { + const trip = record(5, "earth", "mars", 10, { payout: 4_000 }); + const resolve = (event: AppliedEvent | null, pilotIsAvatar: boolean) => + resolveDispatch( + { + record: trip, + pilotIsAvatar, + discoveredIds: new Set(["earth", "mars"]), + loanBalance: 0, + pilotDutyHours: 0, + pilotSkills: {}, + now: trip.arrivalAt, + event, + }, + content, + ); + const applied: AppliedEvent = { + eventId: "distress-beacon", + optionId: "help", + answered: true, + arrivalAt: trip.arrivalAt, + hullLoss: 0, + payLoss: 0, + fee: 0, + bonus: 1_000, + priorityLost: 0, + xpBonusPct: 50, + extraFuelFu: 0, + }; + const plain = resolve(null, true); + const helped = resolve(applied, true); + if (plain.status !== "resolved" || helped.status !== "resolved") throw new Error("in flight"); + expect(helped.resolution.credits).toBe(plain.resolution.credits + 1_000); + expect(helped.resolution.pilotXp).toBe(Math.round(plain.resolution.pilotXp * 1.5)); + const hired = resolve(applied, false); + if (hired.status !== "resolved") throw new Error("in flight"); + expect(hired.resolution.pilotPay).toBe(pilotShare(5_000, trip.fuelCost, content.config)); + }); +});