diff --git a/DECISIONS.md b/DECISIONS.md index 37fb536..9ea9b0c 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -935,3 +935,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Handling time per tonne stays a game-wide base** (`handlingGameHoursPerTonne`) rather than a ShipModel field: it was a single config value, and upgrades make it per ship through effective stats (10I.2). The "affects a stat that exists" test accepts it as that base; the other four are checked against every ShipModel. - **Reductions are negative percents** (−15 % overhead and handling per level), so one linear rule covers every upgrade. - **Upgrade costs and shop times are config** (`upgradeLevels`, one entry per level), so the max level any upgrade may have is bounded by its length (tested). + +## Step 10I.2 + +- **Effective stats are a ShipModel plus handling time** (`EffectiveStats extends ShipModel`), so every function that took a model (route check, travel time, fuel) takes effective stats unchanged. Only the place that looks a ship up changed: `shipStats(ship)` replaces the plain model lookup in dispatch, deadhead and record code. +- **Upgrades ride on `ShipState` as an optional `upgrades` map**; absent means none, so existing callers and tests needed no edits. +- **Levels above a model's max are clamped** in `effectiveStats`, so bad data can never break the "models stay distinct" caps. +- **Boards fit effective capacity:** CR-006's fleet-fitted boards now see an upgraded Cargo Bay. The Priority window's reference ship stays the base model, so windows are the same for every player. diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 9f4e689..cbf4a02 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -227,7 +227,7 @@ Spec: `memory-bank/change-requests/CR-008-fresh-boards.md`. New decisions record Spec: `memory-bank/change-requests/CR-009-ship-upgrades.md` (received as "CR-006 … Phase 10F"). New decisions recorded as plan §0.5.63–0.5.68. - [x] **Step 10I.1 — Content: upgrades.** `upgradeSchema` (id, name, `stat` from `SHIP_STATS`, `percentPerLevel`, `maxLevel`, optional `modelOverrides`) and `data/upgrades.ts`: engines +10 %, cargo-bay +15 %, fuel-tanks +20 % (Courier +600 %, Freighter +15 %), landing-thrusters −15 %, handling-gear −15 %, all 3 levels; `upgradePercentPerLevel` / `upgradeMaxLevel`; `content.upgrades` / `content.upgrade(id)`. Config `upgradeLevels`: 10/20/35 % of price, 10/20/30 real minutes. Tests: `content/test/upgrades.test.ts` (validity, stats exist, decision 0.5.67), `sim/test/courier-mars.test.ts` (decision 0.5.68 over monthly samples for two game years). -- [ ] Step 10I.2 — Sim: effective stats +- [x] **Step 10I.2 — Sim: effective stats.** `sim/src/upgrades.ts`: `effectiveStats(model, levels, content)` → `EffectiveStats` (the model with upgraded stats plus its own `handlingGameHoursPerTonne`). `ShipState.upgrades` (optional) and `shipStats(ship, content)` feed every check in `validateDispatch`, `validateReposition` and dispatch records (capacity, range, travel time, Priority window). `travelTimeGameHours` uses the ship's handling time when given. Board fit uses each fleet member's effective capacity (`FleetMember.upgrades`). `SimContent` gains `upgrades`. Test: `effective-stats.test.ts`; all earlier sim tests unchanged. - [ ] Step 10I.3 — Sim: upgrade validation and cost - [ ] Step 10I.4 — Schema and database - [ ] Step 10I.5 — Server: start upgrade diff --git a/packages/sim/src/board.ts b/packages/sim/src/board.ts index cbda36b..c129f3f 100644 --- a/packages/sim/src/board.ts +++ b/packages/sim/src/board.ts @@ -6,6 +6,7 @@ import type { ScriptedTutorialContract, ShipModel, Skill, + Upgrade, } from "@deadhead/content"; import { MS_PER_MINUTE, type RealTime, realToGame } from "./clock"; import { contractPayout, type Contract } from "./contract"; @@ -14,6 +15,7 @@ import { checkRoute } from "./eligibility"; import { LONG_HAUL_SKILL_ID, skillLevel, type SkillLevels } from "./pilot"; import { createRng, deriveSeed, type Rng, type Seed } from "./rng"; import { travelTimeGameHours } from "./travel"; +import { effectiveStats, type UpgradeLevels } from "./upgrades"; /** The static data the sim needs. The `content` bundle satisfies this. */ export interface SimContent { @@ -23,6 +25,7 @@ export interface SimContent { skills: readonly Skill[]; shipModels: readonly ShipModel[]; scriptedTutorialContracts: readonly ScriptedTutorialContract[]; + upgrades: readonly Upgrade[]; config: GameConfig; } @@ -50,6 +53,8 @@ export function standardCargo(content: SimContent): CargoType { export interface FleetMember { modelId: string; skills: SkillLevels; + /** Installed upgrades: boards fit the ship's effective capacity (CR-009). */ + upgrades?: UpgradeLevels; } export interface BoardInput { @@ -450,7 +455,9 @@ export function generateContractBoard(input: BoardInput, content: SimContent): C const crews: Crew[] = (input.fleet ?? []).flatMap((f) => { const model = content.shipModels.find((m) => m.id === f.modelId); - return model ? [{ model, skills: f.skills }] : []; + return model + ? [{ model: effectiveStats(model, f.upgrades ?? {}, content), skills: f.skills }] + : []; }); const offeredAt = periodIndex * boardPeriodMs(config); diff --git a/packages/sim/src/dispatch.ts b/packages/sim/src/dispatch.ts index f423a23..ea1b6cf 100644 --- a/packages/sim/src/dispatch.ts +++ b/packages/sim/src/dispatch.ts @@ -9,12 +9,20 @@ import { fuelCost } from "./fuel"; import { checkPilotEligibility, type SkillLevels } from "./pilot"; import { deriveSeed, type Seed } from "./rng"; import { travelTimeGameHours } from "./travel"; +import { effectiveStats, type EffectiveStats, type UpgradeLevels } from "./upgrades"; export interface ShipState { id: string; modelId: string; locationId: string; inFlight: boolean; + /** Installed upgrades (CR-009); absent means none. */ + upgrades?: UpgradeLevels; +} + +/** A ship's effective stats: its model plus its upgrades (decision 0.5.63). */ +export function shipStats(ship: ShipState, content: SimContent): EffectiveStats { + return effectiveStats(findShipModel(ship.modelId, content), ship.upgrades ?? {}, content); } export interface PilotState { @@ -99,7 +107,7 @@ export function contractTripGameHours( /** Validates a proposed contract dispatch, returning the first failing reason. */ export function validateDispatch(proposal: DispatchProposal, content: SimContent): DispatchCheck { const { ship, pilot, contract, tutorial, credits, now } = proposal; - const model = findShipModel(ship.modelId, content); + const model = shipStats(ship, content); const origin = findLocation(contract.originId, content); const destination = findLocation(contract.destinationId, content); const departure = realToGame(now, content.config); @@ -185,7 +193,7 @@ function buildRecord( ): DispatchRecord { const { id, ship, pilot, contract, tutorial, now, worldSeed } = args; const { config } = content; - const model = findShipModel(ship.modelId, content); + const model = shipStats(ship, content); const origin = findLocation(args.originId, content); const destination = findLocation(args.destinationId, content); const departure = realToGame(now, config); @@ -264,7 +272,7 @@ export function validateReposition( if (destinationId === ship.locationId) return failWith("already-there"); if (!discoveredIds.has(destinationId)) return failWith("location-undiscovered"); - const model = findShipModel(ship.modelId, content); + const model = shipStats(ship, content); const origin = findLocation(ship.locationId, content); const destination = findLocation(destinationId, content); const departure = realToGame(now, content.config); diff --git a/packages/sim/src/index.ts b/packages/sim/src/index.ts index 1e748cc..e6bff5d 100644 --- a/packages/sim/src/index.ts +++ b/packages/sim/src/index.ts @@ -41,6 +41,7 @@ export { contractTripGameHours, createDispatchRecord, createRepositionRecord, + shipStats, tripFuelCost, validateDispatch, validateReposition, @@ -73,3 +74,6 @@ export { resolveDispatch } from "./resolution"; export type { Resolution, ResolveInput, ResolveResult } from "./resolution"; export { dutyAfterTrip, dutyLimit, isResting, needsRest, restEndsAt, tripGameHours } from "./duty"; + +export { effectiveStats } from "./upgrades"; +export type { EffectiveStats, UpgradeLevels } from "./upgrades"; diff --git a/packages/sim/src/travel.ts b/packages/sim/src/travel.ts index d41dba0..878a705 100644 --- a/packages/sim/src/travel.ts +++ b/packages/sim/src/travel.ts @@ -7,12 +7,14 @@ import type { GameConfig, ShipModel } from "@deadhead/content"; * carry zero mass. */ export function travelTimeGameHours( - ship: ShipModel, + /** Effective stats carry the ship's own handling time (decision 0.5.63); a bare model uses the game-wide base. */ + ship: ShipModel & { handlingGameHoursPerTonne?: number }, distanceMillionKm: number, massTonnes: number, config: GameConfig, ): number { const cruiseHours = (distanceMillionKm * 1e6) / ship.cruiseSpeedKmPerGameHour; - const handlingHours = massTonnes * config.handlingGameHoursPerTonne; + const handlingHours = + massTonnes * (ship.handlingGameHoursPerTonne ?? config.handlingGameHoursPerTonne); return cruiseHours + ship.overheadGameHours + handlingHours; } diff --git a/packages/sim/src/upgrades.ts b/packages/sim/src/upgrades.ts new file mode 100644 index 0000000..f2fcd0e --- /dev/null +++ b/packages/sim/src/upgrades.ts @@ -0,0 +1,41 @@ +import { + upgradeMaxLevel, + upgradePercentPerLevel, + type GameConfig, + type ShipModel, + type Upgrade, +} from "@deadhead/content"; + +/** Installed upgrade levels by upgrade id; a missing upgrade is level 0. */ +export type UpgradeLevels = Readonly>; + +/** + * A ship's stats after upgrades (decision 0.5.63): its model with the four + * upgradable model stats adjusted, plus its own handling time per tonne. + */ +export interface EffectiveStats extends ShipModel { + handlingGameHoursPerTonne: number; +} + +/** + * Applies installed upgrades to a model's base stats: each level adds the + * upgrade's percent (after model overrides) of the base stat, linearly. + * Levels above the model's max for an upgrade are clamped. + */ +export function effectiveStats( + model: ShipModel, + levels: UpgradeLevels, + content: { upgrades: readonly Upgrade[]; config: GameConfig }, +): EffectiveStats { + const stats: EffectiveStats = { + ...model, + handlingGameHoursPerTonne: content.config.handlingGameHoursPerTonne, + }; + for (const upgrade of content.upgrades) { + const level = Math.min(levels[upgrade.id] ?? 0, upgradeMaxLevel(upgrade, model.id)); + if (level <= 0) continue; + const base = stats[upgrade.stat]; + stats[upgrade.stat] = base * (1 + (upgradePercentPerLevel(upgrade, model.id) * level) / 100); + } + return stats; +} diff --git a/packages/sim/test/effective-stats.test.ts b/packages/sim/test/effective-stats.test.ts new file mode 100644 index 0000000..a01f0d8 --- /dev/null +++ b/packages/sim/test/effective-stats.test.ts @@ -0,0 +1,118 @@ +import { content, SHIP_STATS } from "@deadhead/content"; +import { describe, expect, it } from "vitest"; +import { + effectiveStats, + realToGame, + routeDistance, + shipStats, + travelTimeGameHours, + validateDispatch, + type Contract, + type DispatchProposal, + type PilotState, + type ShipState, +} from "../src/index"; + +const now = Date.parse("2100-01-01T00:30:00Z"); +const courier = content.shipModel("courier")!; +const freighter = content.shipModel("freighter")!; + +const contract: Contract = { + id: "c1", + originId: "earth", + destinationId: "moon", + cargoTypeId: "standard", + goodId: "consumer-goods", + shipper: "Pacific Orbital Logistics", + priority: false, + deliveryWindowGameHours: null, + tier: 0, + massTonnes: 10, + payout: 2000, + requiredSkill: null, + requiredLongHaulLevel: 0, + offeredAt: Date.parse("2100-01-01T00:00:00Z"), + expiresAt: Date.parse("2100-01-01T01:00:00Z"), + firstContact: false, +}; +const ship: ShipState = { id: "s1", modelId: "courier", locationId: "earth", inFlight: false }; +const pilot: PilotState = { + id: "p1", + isAvatar: false, + assignedShipId: "s1", + inFlight: false, + skills: {}, + restEndsAt: null, +}; +const proposal: DispatchProposal = { ship, pilot, contract, tutorial: false, credits: 10_000, now }; +const reason = (p: DispatchProposal) => { + const result = validateDispatch(p, content); + return result.ok ? "valid" : result.reason; +}; + +describe("effective stats (Step 10I.2)", () => { + it("equals the model's stats exactly with no upgrades", () => { + expect(effectiveStats(freighter, {}, content)).toEqual({ + ...freighter, + handlingGameHoursPerTonne: content.config.handlingGameHoursPerTonne, + }); + expect(shipStats(ship, content)).toEqual(effectiveStats(courier, {}, content)); + }); + + it("changes only the upgrade's own stat", () => { + const base = effectiveStats(freighter, {}, content); + for (const upgrade of content.upgrades) { + const upgraded = effectiveStats(freighter, { [upgrade.id]: 1 }, content); + for (const stat of SHIP_STATS) { + if (stat === upgrade.stat) expect(upgraded[stat], upgrade.id).not.toBe(base[stat]); + else expect(upgraded[stat], `${upgrade.id} → ${stat}`).toBe(base[stat]); + } + } + }); + + it("gives exactly +30 % speed at engines level 3", () => { + expect(effectiveStats(freighter, { engines: 3 }, content).cruiseSpeedKmPerGameHour).toBeCloseTo( + freighter.cruiseSpeedKmPerGameHour * 1.3, + 6, + ); + }); + + it("lets a cargo-bay upgrade carry a contract that was too heavy", () => { + const heavy = { ...contract, massTonnes: 22 }; + expect(reason({ ...proposal, contract: heavy })).toBe("over-capacity"); + expect( + reason({ ...proposal, contract: heavy, ship: { ...ship, upgrades: { "cargo-bay": 1 } } }), + ).toBe("valid"); + }); + + it("lets an engines upgrade make a Priority window that was too short", () => { + const distance = routeDistance( + content.location("earth")!, + content.location("moon")!, + realToGame(now, content.config), + ); + const hours = (stats: typeof courier) => + travelTimeGameHours(stats, distance, contract.massTonnes, content.config); + const slow = hours(effectiveStats(courier, {}, content)); + const fast = hours(effectiveStats(courier, { engines: 3 }, content)); + expect(fast).toBeLessThan(slow); + const priority = { ...contract, priority: true, deliveryWindowGameHours: (slow + fast) / 2 }; + + expect(reason({ ...proposal, contract: priority })).toBe("too-slow"); + expect( + reason({ ...proposal, contract: priority, ship: { ...ship, upgrades: { engines: 3 } } }), + ).toBe("valid"); + }); + + it("uses the ship's own handling time in travel time", () => { + const geared = effectiveStats(courier, { "handling-gear": 2 }, content); + expect(geared.handlingGameHoursPerTonne).toBeCloseTo( + content.config.handlingGameHoursPerTonne * 0.7, + 9, + ); + expect(travelTimeGameHours(geared, 0, 20, content.config)).toBeCloseTo( + geared.overheadGameHours + 20 * geared.handlingGameHoursPerTonne, + 9, + ); + }); +});