From ef7d483b408b6418eec65aa5bfc7e8d1c0842b9b Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sat, 17 Oct 2026 00:03:33 -0400 Subject: [PATCH] Step 26.4: sim: hull condition and repairs Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 6 ++ apps/web/src/screens/DispatchSheet.tsx | 1 + memory-bank/progress.md | 2 +- packages/engine/src/game/messages.ts | 1 + packages/sim/src/dispatch.ts | 12 ++++ packages/sim/src/events.ts | 22 ++---- packages/sim/src/hull.ts | 55 +++++++++++++++ packages/sim/src/index.ts | 10 ++- packages/sim/test/hull.test.ts | 93 ++++++++++++++++++++++++++ 9 files changed, 185 insertions(+), 17 deletions(-) create mode 100644 packages/sim/src/hull.ts create mode 100644 packages/sim/test/hull.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index 5956905..59f144b 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1922,3 +1922,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **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. + +## Step 26.4 + +- **The hull reason comes after the pilot checks and before expiry and fuel**, like a ship condition the player can fix: the dispatch sheet still lists every problem, and the fuel reason stays last ("Refuel here first" relies on it meaning nothing else is wrong). +- **`ShipState.hullConditionPct` is optional** (absent = 100), so the tutorial loaner and older test fixtures need nothing new. +- **Condition is kept to one decimal**, as rolled hits are, so repeated hits add up exactly. diff --git a/apps/web/src/screens/DispatchSheet.tsx b/apps/web/src/screens/DispatchSheet.tsx index 27f26f4..8d1ed15 100644 --- a/apps/web/src/screens/DispatchSheet.tsx +++ b/apps/web/src/screens/DispatchSheet.tsx @@ -59,6 +59,7 @@ export const INELIGIBLE_REASONS: Record = { "too-slow": "Too slow for the Priority window", "cargo-skill-too-low": "Cargo skill too low", "long-haul-too-low": "Long Haul too low", + "hull-too-damaged": "Hull too damaged for hazardous cargo: repair at an HQ", "contract-expired": "Contract expired", "not-enough-fuel": "Not enough fuel", }; diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 67b7ecd..05ef57e 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -550,7 +550,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. - [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 +- [x] **Step 26.4 — Sim: hull condition and repairs.** `packages/sim/src/hull.ts`: `hullRatingReduction` (10% a point, at most 50%), `damageAfterRating`, `damageChance` (cut by the rating, doubled below 25%), `hullAfterDamage` (never below 5%), `repairCost` (missing points × model price × 0.5%) and `repairShopMs` (half a minute a point, at least 5), `hullBarsDangerous` (below 50%). The event roll uses them. `validateDispatch` has a new reason, `hull-too-damaged`, for Dangerous cargo on a hull below 50% (after the skill checks, before expiry and fuel), with messages in the engine and the dispatch sheet; it isn't a permanent check, so boards still show those jobs (CR-019). Tests (`packages/sim/test/hull.test.ts`): rating 3 turns a 20% hit into 14%, and the cut stops at half; chances on a rated and a battered hull; the 5% floor; Dangerous refused at 40%, allowed at 50% and for other cargo at 40%; repair cost and time. - [ ] Step 26.5 — Engine: events in play and catch-up - [ ] Step 26.6 — Client: radio calls - [ ] Step 26.7 — Client: hull and results diff --git a/packages/engine/src/game/messages.ts b/packages/engine/src/game/messages.ts index b89507e..93432b0 100644 --- a/packages/engine/src/game/messages.ts +++ b/packages/engine/src/game/messages.ts @@ -16,6 +16,7 @@ export const VALIDATION_MESSAGES: Record= contract.expiresAt) return fail("contract-expired"); if (!tutorial && !hasFuelFor(ship, model, origin, destination, content)) { diff --git a/packages/sim/src/events.ts b/packages/sim/src/events.ts index 218607b..f700c58 100644 --- a/packages/sim/src/events.ts +++ b/packages/sim/src/events.ts @@ -4,6 +4,7 @@ import { MS_PER_HOUR, type RealTime } from "./clock"; import type { DispatchRecord } from "./dispatch"; import { findLocation } from "./dispatch"; import type { SkillLevels } from "./pilot"; +import { damageAfterRating, damageChance } from "./hull"; import { createRng, deriveSeed, type Rng } from "./rng"; /** @@ -145,11 +146,6 @@ export function eventWeight( return weight; } -/** How much the hull rating cuts damage chances and amounts (decision 0.5.148). */ -export function hullRatingReduction(hullRating: number, config: GameConfig): number { - return Math.min(config.hull.maxRatingReduction, hullRating * config.hull.ratingReductionPerPoint); -} - const between = (rng: Rng, [low, high]: readonly [number, number]) => low + rng.next() * (high - low); @@ -163,12 +159,6 @@ function rollOutcome( const { config } = content; const { record, ship } = input; const payout = record.contract?.payout ?? 0; - const reduction = hullRatingReduction(ship.hullRating, config); - // A badly damaged hull takes damage twice as often (decision 0.5.148). - const damagedFactor = - ship.hullConditionPct < config.hull.needsRepairBelowPct - ? config.hull.damagedChanceMultiplier - : 1; const outcome: EventOutcome = { delayMs: 0, hullLoss: 0, @@ -183,7 +173,7 @@ function rollOutcome( const hours = between(rng, effects.delayGameHours); outcome.delayMs += Math.round((hours * MS_PER_HOUR) / config.timeCompression); } - if (effects.hullLoss) outcome.hullLoss += between(rng, effects.hullLoss) * (1 - reduction); + if (effects.hullLoss) outcome.hullLoss += between(rng, effects.hullLoss); if (effects.payLossPct) outcome.payLoss += Math.round((payout * effects.payLossPct) / 100); if (effects.feePct) { outcome.fee += Math.max(effects.feeMin ?? 0, Math.round((payout * effects.feePct) / 100)); @@ -199,14 +189,16 @@ function rollOutcome( const draw = rng.next(); let edge = 0; for (const bucket of option.chances) { - const scale = bucket.effects.hullLoss ? (1 - reduction) * damagedFactor : 1; - edge += Math.min(1, bucket.chance * scale); + // Hull hits are rarer on a well-rated hull, likelier on a battered one (decision 0.5.148). + edge += bucket.effects.hullLoss + ? damageChance(bucket.chance, ship.hullRating, ship.hullConditionPct, config) + : bucket.chance; if (draw < edge) { add(bucket.effects); break; } } - outcome.hullLoss = Math.round(outcome.hullLoss * 10) / 10; + outcome.hullLoss = damageAfterRating(outcome.hullLoss, ship.hullRating, config); return outcome; } diff --git a/packages/sim/src/hull.ts b/packages/sim/src/hull.ts new file mode 100644 index 0000000..edbf1c9 --- /dev/null +++ b/packages/sim/src/hull.ts @@ -0,0 +1,55 @@ +import type { GameConfig, ShipModel } from "@deadhead/content"; +import { MS_PER_MINUTE } from "./clock"; + +/** How much the hull rating cuts damage chances and amounts (decision 0.5.148). */ +export function hullRatingReduction(hullRating: number, config: GameConfig): number { + return Math.min(config.hull.maxRatingReduction, hullRating * config.hull.ratingReductionPerPoint); +} + +/** An event's hull hit after the hull rating, in condition points (decision 0.5.148). */ +export function damageAfterRating(rawLoss: number, hullRating: number, config: GameConfig): number { + return Math.round(rawLoss * (1 - hullRatingReduction(hullRating, config)) * 10) / 10; +} + +/** The chance of a hull hit after the rating, doubled on a badly damaged hull (decision 0.5.148). */ +export function damageChance( + chance: number, + hullRating: number, + conditionPct: number, + config: GameConfig, +): number { + const damaged = conditionPct < config.hull.needsRepairBelowPct; + const scaled = + chance * + (1 - hullRatingReduction(hullRating, config)) * + (damaged ? config.hull.damagedChanceMultiplier : 1); + return Math.min(1, scaled); +} + +/** Condition after a hit: never below the floor, so ships are never lost (decision 0.5.147). */ +export function hullAfterDamage(conditionPct: number, loss: number, config: GameConfig): number { + const after = Math.round((conditionPct - loss) * 10) / 10; + return Math.max(Math.min(conditionPct, config.hull.floorPct), after); +} + +/** Missing condition points, the thing repairs are priced on. */ +function missing(conditionPct: number): number { + return Math.max(0, 100 - conditionPct); +} + +/** A repair to 100%: missing points × the model's price × the cost per point (decision 0.5.148). */ +export function repairCost(model: ShipModel, conditionPct: number, config: GameConfig): number { + return Math.round(missing(conditionPct) * model.price * config.hull.repairCostPerPoint); +} + +/** A repair's shop time, in real ms: per missing point, at least the minimum. */ +export function repairShopMs(conditionPct: number, config: GameConfig): number { + const { repairRealMinutesPerPoint, repairMinRealMinutes } = config.hull; + const minutes = Math.max(repairMinRealMinutes, missing(conditionPct) * repairRealMinutesPerPoint); + return Math.round(minutes * MS_PER_MINUTE); +} + +/** Below this condition a ship can't take Dangerous cargo (a temporary reason, decision 0.5.148). */ +export function hullBarsDangerous(conditionPct: number, config: GameConfig): boolean { + return conditionPct < config.hull.noDangerousBelowPct; +} diff --git a/packages/sim/src/index.ts b/packages/sim/src/index.ts index 0cb8770..ec676ac 100644 --- a/packages/sim/src/index.ts +++ b/packages/sim/src/index.ts @@ -154,7 +154,6 @@ export { payoutAfterEvent, eventChance, eventWeight, - hullRatingReduction, rollTripEvent, routeFacts, tripGameHoursOf, @@ -167,3 +166,12 @@ export type { EventRollInput, TripEvent, } from "./events"; +export { + damageAfterRating, + damageChance, + hullAfterDamage, + hullBarsDangerous, + hullRatingReduction, + repairCost, + repairShopMs, +} from "./hull"; diff --git a/packages/sim/test/hull.test.ts b/packages/sim/test/hull.test.ts new file mode 100644 index 0000000..3101633 --- /dev/null +++ b/packages/sim/test/hull.test.ts @@ -0,0 +1,93 @@ +import { content } from "@deadhead/content"; +import { describe, expect, it } from "vitest"; +import { + damageAfterRating, + damageChance, + hullAfterDamage, + repairCost, + repairShopMs, + validateDispatch, + type Contract, +} from "../src/index"; + +const { config } = content; +const T0 = Date.parse("2100-01-01T00:00:00Z"); + +describe("hull condition and repairs (Step 26.4, decisions 0.5.147, 0.5.148)", () => { + it("cuts a hit by 10% per hull rating point: rating 3 turns 20% into 14%", () => { + expect(damageAfterRating(20, 3, config)).toBe(14); + expect(damageAfterRating(20, 0, config)).toBe(20); + // At most half, however good the hull. + expect(damageAfterRating(20, 9, config)).toBe(10); + }); + + it("makes hits rarer on a rated hull and twice as likely below 25%", () => { + expect(damageChance(0.4, 0, 100, config)).toBeCloseTo(0.4); + expect(damageChance(0.4, 2, 100, config)).toBeCloseTo(0.32); + expect(damageChance(0.4, 2, 24, config)).toBeCloseTo(0.64); + expect(damageChance(0.8, 0, 10, config)).toBe(1); + }); + + it("never lets a hull go below 5%: no ship is ever lost", () => { + expect(hullAfterDamage(60, 20, config)).toBe(40); + expect(hullAfterDamage(12, 20, config)).toBe(5); + expect(hullAfterDamage(5, 50, config)).toBe(5); + }); + + it("refuses Dangerous cargo on a hull below 50%, until repaired", () => { + const job = (cargoTypeId: string): Contract => ({ + id: "c", + originId: "earth", + destinationId: "moon", + cargoTypeId, + goodId: cargoTypeId === "dangerous" ? "helium-3" : "consumer-goods", + shipper: "Pacific Orbital Logistics", + priority: false, + deliveryWindowGameHours: null, + tier: cargoTypeId === "dangerous" ? 1 : 0, + massTonnes: 5, + payout: 1_000, + requiredSkill: cargoTypeId === "dangerous" ? { skillId: "dangerous-cargo", level: 1 } : null, + requiredLongHaulLevel: 0, + offeredAt: T0, + expiresAt: T0 + 600_000, + firstContact: false, + }); + const check = (hullConditionPct: number, cargoTypeId: string) => + validateDispatch( + { + ship: { + id: "s1", + modelId: "courier", + locationId: "earth", + inFlight: false, + tank: { fuelFu: 40, avgPrice: 5 }, + hullConditionPct, + }, + pilot: { + id: "p1", + isAvatar: true, + assignedShipId: "s1", + inFlight: false, + skills: { "dangerous-cargo": 1 }, + restEndsAt: null, + }, + contract: job(cargoTypeId), + tutorial: false, + now: T0, + }, + content, + ); + expect(check(40, "dangerous")).toEqual({ ok: false, reason: "hull-too-damaged" }); + expect(check(50, "dangerous")).toEqual({ ok: true }); + expect(check(40, "standard")).toEqual({ ok: true }); + }); + + 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, 100, config)).toBe(0); + expect(repairShopMs(60, config)).toBe(20 * 60_000); + expect(repairShopMs(98, config)).toBe(5 * 60_000); + }); +});