diff --git a/DECISIONS.md b/DECISIONS.md index 07aa7cc..37fb536 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -926,3 +926,12 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Decision numbering:** the CR's 0.5.45–0.5.50 are recorded as **0.5.63 Effective stats, 0.5.64 Where and when, 0.5.65 One level at a time, 0.5.66 Shop time, 0.5.67 Models stay distinct, 0.5.68 Courier to Mars**. - **Error codes:** kebab-case, reusing existing codes that mean the same: `ship_busy` → `ship-not-idle`, `insufficient_funds` → `insufficient-funds`, `tutorial_active` → `tutorial-active`, `too_slow` → `too-slow`. New: `not-at-hq`, `ship-in-shop`, `upgrade-maxed`, `not-upgradable`. - **Fits the current build:** the CR assumes Priority windows and a too-slow reason, which CR-007 added. + +## Step 10I.1 + +- **Courier fuel tanks: +600 % per level** (5 → 35 → 65 → 95 M km). Earth–Mars over the first two game years ranges 82–394 M km (monthly samples), so level 3 reaches Mars only near closest approach and level 2 never does. The Courier's base range (5 M km) and the Step 3.7 range tests are unchanged. +- **Freighter fuel tanks: +15 % per level** (650 → 942.5 M km). At the CR's default +20 % a maxed Freighter would reach 1040 M km, past a base Bulk Hauler's 1000 and Jupiter at its farthest, breaking decision 0.5.67. +- **Overrides cover the percent as well as the max level** (`modelOverrides`), since the CR allows tuning the Courier's per-level bonus. +- **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). diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 435cd23..9f4e689 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -226,7 +226,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. -- [ ] Step 10I.1 — Content: upgrades +- [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 - [ ] Step 10I.3 — Sim: upgrade validation and cost - [ ] Step 10I.4 — Schema and database diff --git a/packages/content/src/bundle.ts b/packages/content/src/bundle.ts index 13a6b68..68670a3 100644 --- a/packages/content/src/bundle.ts +++ b/packages/content/src/bundle.ts @@ -4,6 +4,7 @@ import { goods } from "./data/goods"; import { locations } from "./data/locations"; import { onboardingGoals, scriptedTutorialContracts } from "./data/onboarding"; import { shipModels } from "./data/ships"; +import { upgrades } from "./data/upgrades"; /** Returns a lookup that gives `undefined` for an unknown id instead of throwing. */ function byId(items: readonly T[]): (id: string) => T | undefined { @@ -23,11 +24,13 @@ export const content = { config: gameConfig, scriptedTutorialContracts, onboardingGoals, + upgrades, location: byId(locations), shipModel: byId(shipModels), cargoType: byId(cargoTypes), good: byId(goods), skill: byId(skills), onboardingGoal: byId(onboardingGoals), + upgrade: byId(upgrades), }; export type Content = typeof content; diff --git a/packages/content/src/data/config.ts b/packages/content/src/data/config.ts index 9c479bf..a1a1e65 100644 --- a/packages/content/src/data/config.ts +++ b/packages/content/src/data/config.ts @@ -24,6 +24,12 @@ export const gameConfig: GameConfig = { // Courier price + Earth bay lease. dealLoanAmount: 5000, contractBoardPeriodRealMinutes: 10, + // Ship upgrades (CR-009): level 1, 2, 3. + upgradeLevels: [ + { costPercentOfPrice: 10, shopRealMinutes: 10 }, + { costPercentOfPrice: 20, shopRealMinutes: 20 }, + { costPercentOfPrice: 35, shopRealMinutes: 30 }, + ], // Duty and rest (CR-004): about three Moon round trips, then rest. dutyLimitGameHours: 12, longHaulDutyBonusGameHours: 2, diff --git a/packages/content/src/data/upgrades.ts b/packages/content/src/data/upgrades.ts new file mode 100644 index 0000000..41b9570 --- /dev/null +++ b/packages/content/src/data/upgrades.ts @@ -0,0 +1,60 @@ +import type { Upgrade } from "../schemas"; + +/** + * Ship upgrades (CR-009 §2). Percentages are of the model's base stat and add + * up across levels. Overrides keep models distinct (decision 0.5.67) and give a + * maxed Courier a reach to Mars near closest approach only (decision 0.5.68). + */ +export const upgrades: Upgrade[] = [ + { + id: "engines", + name: "Engine Tuning", + stat: "cruiseSpeedKmPerGameHour", + percentPerLevel: 10, + maxLevel: 3, + }, + { + id: "cargo-bay", + name: "Cargo Bay Expansion", + stat: "cargoCapacityTonnes", + percentPerLevel: 15, + maxLevel: 3, + }, + { + id: "fuel-tanks", + name: "Extended Fuel Tanks", + stat: "fuelRangeMillionKm", + percentPerLevel: 20, + maxLevel: 3, + modelOverrides: { + // 5 → 35 → 65 → 95 M km: Mars (≈ 55–400 M km) only near closest approach, and only at level 3. + courier: { percentPerLevel: 600 }, + // 650 → 942.5 M km at level 3: still short of a base Bulk Hauler's 1000 (and of Jupiter at its farthest). + freighter: { percentPerLevel: 15 }, + }, + }, + { + id: "landing-thrusters", + name: "Landing Thrusters", + stat: "overheadGameHours", + percentPerLevel: -15, + maxLevel: 3, + }, + { + id: "handling-gear", + name: "Cargo Handling Gear", + stat: "handlingGameHoursPerTonne", + percentPerLevel: -15, + maxLevel: 3, + }, +]; + +/** An upgrade's percent per level for a ship model, after overrides. */ +export function upgradePercentPerLevel(upgrade: Upgrade, modelId: string): number { + return upgrade.modelOverrides?.[modelId]?.percentPerLevel ?? upgrade.percentPerLevel; +} + +/** An upgrade's highest level for a ship model, after overrides. */ +export function upgradeMaxLevel(upgrade: Upgrade, modelId: string): number { + return upgrade.modelOverrides?.[modelId]?.maxLevel ?? upgrade.maxLevel; +} diff --git a/packages/content/src/index.ts b/packages/content/src/index.ts index 7762b72..2dd0076 100644 --- a/packages/content/src/index.ts +++ b/packages/content/src/index.ts @@ -5,4 +5,5 @@ export * from "./data/cargo"; export * from "./data/goods"; export * from "./data/config"; export * from "./data/onboarding"; +export * from "./data/upgrades"; export * from "./bundle"; diff --git a/packages/content/src/schemas.ts b/packages/content/src/schemas.ts index a8a3c04..b3784c0 100644 --- a/packages/content/src/schemas.ts +++ b/packages/content/src/schemas.ts @@ -85,6 +85,39 @@ export const shipModelSchema = z.object({ }); export type ShipModel = z.infer; +/** + * Ship stats an upgrade can change (CR-009). The first four are ShipModel + * fields; handling time per tonne is a game-wide base (`handlingGameHoursPerTonne`) + * that becomes per ship once upgraded. + */ +export const SHIP_STATS = [ + "cruiseSpeedKmPerGameHour", + "cargoCapacityTonnes", + "fuelRangeMillionKm", + "overheadGameHours", + "handlingGameHoursPerTonne", +] as const; +export const shipStatSchema = z.enum(SHIP_STATS); +export type ShipStat = z.infer; + +/** One upgrade (decision 0.5.63): a stat change per level, as a percent of the base stat. */ +export const upgradeSchema = z.object({ + id: idSchema, + name: z.string().min(1), + stat: shipStatSchema, + /** Percent of the model's base stat per level; negative lowers it (e.g. −15 overhead). */ + percentPerLevel: z.number().refine((p) => p !== 0, "An upgrade must change its stat"), + maxLevel: positiveInt, + /** Per ship model overrides (decisions 0.5.67, 0.5.68). */ + modelOverrides: z + .record( + idSchema, + z.object({ percentPerLevel: z.number().optional(), maxLevel: positiveInt.optional() }), + ) + .optional(), +}); +export type Upgrade = z.infer; + export const cargoTypeSchema = z.object({ id: idSchema, name: z.string().min(1), @@ -165,6 +198,18 @@ export const gameConfigSchema = z /** Loan taken on with the deal, in credits (decision 0.5.30). */ dealLoanAmount: positiveInt, contractBoardPeriodRealMinutes: z.number().positive(), + /** + * Upgrade levels 1, 2, 3, …: cost as a percent of the ship model's price and + * real minutes in the shop (CR-009). + */ + upgradeLevels: z + .array( + z.object({ + costPercentOfPrice: z.number().positive(), + shopRealMinutes: z.number().positive(), + }), + ) + .min(1), /** Game hours a pilot may fly before mandatory rest, at Long Haul 0 (decision 0.5.43). */ dutyLimitGameHours: z.number().positive(), /** Extra duty game hours per Long Haul level (decision 0.5.43). */ diff --git a/packages/content/test/schemas.test.ts b/packages/content/test/schemas.test.ts index 79e1c5a..4819936 100644 --- a/packages/content/test/schemas.test.ts +++ b/packages/content/test/schemas.test.ts @@ -104,6 +104,7 @@ const validGameConfig = { tutorialRunsBeforeDeal: 4, tutorialMassTonnes: { light: 5, medium: 12, full: 20 }, handlingGameHoursPerTonne: 0.02, + upgradeLevels: [{ costPercentOfPrice: 10, shopRealMinutes: 10 }], loanRepaymentShare: 0.3, dealLoanAmount: 5000, }; diff --git a/packages/content/test/upgrades.test.ts b/packages/content/test/upgrades.test.ts new file mode 100644 index 0000000..5d6a9e0 --- /dev/null +++ b/packages/content/test/upgrades.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { + content, + gameConfig, + shipModels, + upgradeMaxLevel, + upgradePercentPerLevel, + upgradeSchema, + upgrades, + type ShipStat, +} from "../src/index"; + +const model = (id: string) => shipModels.find((m) => m.id === id)!; +const upgradeFor = (stat: ShipStat) => upgrades.find((u) => u.stat === stat)!; +/** A stat at the upgrade's max level for this model (decision 0.5.63: linear % of base). */ +const maxed = (modelId: string, stat: ShipStat & keyof (typeof shipModels)[number]) => { + const upgrade = upgradeFor(stat); + const level = upgradeMaxLevel(upgrade, modelId); + return model(modelId)[stat] * (1 + (upgradePercentPerLevel(upgrade, modelId) * level) / 100); +}; + +describe("ship upgrades content (CR-009)", () => { + it("has the five upgrades, all valid, three levels each", () => { + for (const upgrade of upgrades) expect(upgradeSchema.safeParse(upgrade).success).toBe(true); + expect(upgrades.map((u) => u.id)).toEqual([ + "engines", + "cargo-bay", + "fuel-tanks", + "landing-thrusters", + "handling-gear", + ]); + for (const upgrade of upgrades) expect(upgrade.maxLevel).toBe(3); + expect(content.upgrade("engines")?.name).toBe("Engine Tuning"); + }); + + it("only changes stats ships have", () => { + for (const upgrade of upgrades) { + if (upgrade.stat === "handlingGameHoursPerTonne") { + // A game-wide base until upgraded. + expect(gameConfig.handlingGameHoursPerTonne).toBeGreaterThan(0); + } else { + for (const m of shipModels) + expect(m, `${m.id}.${upgrade.stat}`).toHaveProperty(upgrade.stat); + } + for (const id of Object.keys(upgrade.modelOverrides ?? {})) { + expect(content.shipModel(id), id).toBeDefined(); + } + } + }); + + it("prices levels at 10/20/35 % of the ship and 10/20/30 minutes in the shop", () => { + expect(gameConfig.upgradeLevels).toEqual([ + { costPercentOfPrice: 10, shopRealMinutes: 10 }, + { costPercentOfPrice: 20, shopRealMinutes: 20 }, + { costPercentOfPrice: 35, shopRealMinutes: 30 }, + ]); + for (const upgrade of upgrades) { + for (const m of shipModels) { + expect(upgradeMaxLevel(upgrade, m.id)).toBeLessThanOrEqual(gameConfig.upgradeLevels.length); + } + } + }); + + it("keeps models distinct (decision 0.5.67)", () => { + expect(maxed("courier", "cargoCapacityTonnes")).toBeLessThan( + model("freighter").cargoCapacityTonnes, + ); + expect(maxed("bulk-hauler", "cruiseSpeedKmPerGameHour")).toBeLessThan( + model("freighter").cruiseSpeedKmPerGameHour, + ); + expect(maxed("freighter", "fuelRangeMillionKm")).toBeLessThan( + model("bulk-hauler").fuelRangeMillionKm, + ); + }); +}); diff --git a/packages/sim/test/courier-mars.test.ts b/packages/sim/test/courier-mars.test.ts new file mode 100644 index 0000000..f74a8b0 --- /dev/null +++ b/packages/sim/test/courier-mars.test.ts @@ -0,0 +1,34 @@ +import { content, upgradePercentPerLevel, type ShipModel } from "@deadhead/content"; +import { describe, expect, it } from "vitest"; +import { checkRoute, realToGame } from "../src/index"; + +const courier = content.shipModel("courier")!; +const tanks = content.upgrade("fuel-tanks")!; +const earth = content.location("earth")!; +const mars = content.location("mars")!; + +/** A Courier with fuel tanks at `level` (decision 0.5.63: linear % of base range). */ +const withTanks = (level: number): ShipModel => ({ + ...courier, + fuelRangeMillionKm: + courier.fuelRangeMillionKm * (1 + (upgradePercentPerLevel(tanks, "courier") * level) / 100), +}); + +/** Monthly samples over the first two game years. */ +const MONTH = 30.44 * 24 * 3_600_000; +const start = realToGame(Date.parse(content.config.epoch.realStart), content.config); +const samples = Array.from({ length: 25 }, (_, i) => start + i * MONTH); +const inRange = (ship: ShipModel) => + samples.map((t) => checkRoute(ship, earth, mars, t)).map((r) => r.ok); + +describe("Courier to Mars (decision 0.5.68)", () => { + it("reaches Mars with maxed fuel tanks near closest approach, but not at its farthest", () => { + const reach = inRange(withTanks(3)); + expect(reach.some(Boolean)).toBe(true); + expect(reach.some((ok) => !ok)).toBe(true); + }); + + it("never reaches Mars with fuel tanks at level 2 or less", () => { + for (const level of [0, 1, 2]) expect(inRange(withTanks(level)).some(Boolean)).toBe(false); + }); +});