From faa23d294540a3bd5703838dfb577bb00d5b35cd Mon Sep 17 00:00:00 2001 From: fireshaper Date: Mon, 5 Oct 2026 01:11:05 -0400 Subject: [PATCH] Step 10L.1: range factors, Lighter and Clipper Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 8 +++ apps/web/src/map/art/manifest.json | 68 +++++++++++++++++++---- memory-bank/progress.md | 2 +- packages/content/src/data/config.ts | 2 + packages/content/src/data/locations.ts | 5 ++ packages/content/src/data/ships.ts | 44 +++++++++++++++ packages/content/src/schemas.ts | 19 ++++++- packages/content/test/bundle.test.ts | 4 +- packages/content/test/locations.test.ts | 12 ++++ packages/content/test/onboarding.test.ts | 2 +- packages/content/test/schemas.test.ts | 3 + packages/content/test/ships.test.ts | 64 +++++++++++++++------ packages/content/test/upgrades.test.ts | 2 +- packages/sim/src/upgrades.ts | 4 +- packages/sim/test/effective-stats.test.ts | 16 ++++++ 15 files changed, 218 insertions(+), 37 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index 153fa77..54dda70 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1127,3 +1127,11 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Renumbered on arrival:** titled "CR-009 … Phase 10I"; CR-009 to CR-011 and Phases 10I–10K already exist, so it is **CR-012**, done as **Phase 10L** (its 10I.n are 10L.n). - **Decisions:** the CR's 0.5.59–0.5.63 are **0.5.77–0.5.81**; its amended "0.5.49" and "0.5.50" are this plan's **0.5.67** and **0.5.68** (ship upgrades CR). Its "CR-006" is **CR-009**; "CR-008" is **CR-011**. Error code `would-strand`. + +## Step 10L.1 + +- **New ship values** follow the CR's §2 descriptions: Lighter 42,400 cr (40 % of the way from Courier to Freighter), 1.6M km/h, 80 t (80 % of a Freighter), 2 h overhead, 0.012 h/t handling (40 % faster loading), 350 cr/M km; Clipper 61,600 cr (60 %), 6.5M km/h, 30 t (1.5× Courier), 2 h overhead, 600 cr/M km. Ranges (Lighter 10, Clipper 198) come from the measured leg distances; the reach table is enforced in 10L.4. +- **"Clipper is the fastest model" includes the loaner** (6.0M km/h), so the Clipper is 6.5M km/h rather than just the fastest for sale. +- **Handling time becomes per model where set** (`ShipModel.handlingGameHoursPerTonne`, optional, defaulting to the game-wide value); upgrades scale the model's own base. +- **Roles are content** (`role`), one line per model including the loaner, for the Market (10L.7). +- **The new sprites are listed in the manifest as pending** (`file: null`) so the "every ship has a sprite in the manifest" test holds; they use the placeholder until art arrives (10L.10). diff --git a/apps/web/src/map/art/manifest.json b/apps/web/src/map/art/manifest.json index 74deb2b..3b3e6fb 100644 --- a/apps/web/src/map/art/manifest.json +++ b/apps/web/src/map/art/manifest.json @@ -1,17 +1,61 @@ { "sourceDir": "public/assets/map", "assets": { - "bg": { "kind": "background", "file": "bg.png" }, - "sun": { "kind": "body", "file": "body-sun.png" }, - "body-earth": { "kind": "body", "file": "body-earth.png" }, - "body-moon": { "kind": "body", "file": "body-moon.png" }, - "body-mars": { "kind": "body", "file": "body-mars.png" }, - "body-ceres": { "kind": "body", "file": "body-ceres.png" }, - "body-jupiter": { "kind": "body", "file": "body-jupiter.png" }, - "badge-cloud-platform": { "kind": "badge", "file": null }, - "ship-courier": { "kind": "ship", "file": "ship-courier.png" }, - "ship-freighter": { "kind": "ship", "file": "ship-freighter.png" }, - "ship-bulk-hauler": { "kind": "ship", "file": "ship-bulk-hauler.png" }, - "ship-company-courier": { "kind": "ship", "file": "ship-company-courier.png" } + "bg": { + "kind": "background", + "file": "bg.png" + }, + "sun": { + "kind": "body", + "file": "body-sun.png" + }, + "body-earth": { + "kind": "body", + "file": "body-earth.png" + }, + "body-moon": { + "kind": "body", + "file": "body-moon.png" + }, + "body-mars": { + "kind": "body", + "file": "body-mars.png" + }, + "body-ceres": { + "kind": "body", + "file": "body-ceres.png" + }, + "body-jupiter": { + "kind": "body", + "file": "body-jupiter.png" + }, + "badge-cloud-platform": { + "kind": "badge", + "file": null + }, + "ship-courier": { + "kind": "ship", + "file": "ship-courier.png" + }, + "ship-freighter": { + "kind": "ship", + "file": "ship-freighter.png" + }, + "ship-bulk-hauler": { + "kind": "ship", + "file": "ship-bulk-hauler.png" + }, + "ship-company-courier": { + "kind": "ship", + "file": "ship-company-courier.png" + }, + "ship-lighter": { + "kind": "ship", + "file": null + }, + "ship-clipper": { + "kind": "ship", + "file": null + } } } diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 46bbbdb..e7a076f 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -269,7 +269,7 @@ Spec: `memory-bank/change-requests/CR-011-settings-backup.md` (received as "CR-0 Spec: `memory-bank/change-requests/CR-012-lighter-clipper-range.md` (received as "CR-009 … Phase 10I"). New decisions recorded as plan §0.5.77–0.5.81; §0.5.67 and §0.5.68 amended. -- [ ] Step 10L.1 — Content: range factors and new ships +- [x] **Step 10L.1 — Content: range factors and new ships.** `Location.launchRangeFactor` (Earth 1, Moon 1.3, Mars 1.15, Ceres 1.3, Jupiter 1). `ShipModel.role` (all six) and optional `ShipModel.handlingGameHoursPerTonne` (the sim's effective stats now start from it). New models: **Lighter** (42,400 cr, 1.6M km/h, 80 t, range 10, overhead 2 h, handling 0.012 h/t, fuel 350/M km) and **Clipper** (61,600 cr, 6.5M km/h, 30 t, range 198, overhead 2 h, fuel 600/M km); archetypes `lighter`, `clipper`. Config `noStrandMarginGameHours` 48. Manifest lists `ship-lighter` / `ship-clipper` as pending art. Tests: the CR's orderings replace Step 2.3's (`ships.test.ts`), range factors (`locations.test.ts`), Lighter handling (`effective-stats.test.ts`). - [ ] Step 10L.2 — Sim: effective range from origin - [ ] Step 10L.3 — Sim: no-strand rule - [ ] Step 10L.4 — Content tuning: reach table diff --git a/packages/content/src/data/config.ts b/packages/content/src/data/config.ts index a30f69e..2d0686d 100644 --- a/packages/content/src/data/config.ts +++ b/packages/content/src/data/config.ts @@ -24,6 +24,8 @@ export const gameConfig: GameConfig = { // Courier price + Earth bay lease. dealLoanAmount: 5000, contractBoardPeriodRealMinutes: 10, + // A ship must be able to fly out of its destination two game days after arriving (CR-012). + noStrandMarginGameHours: 48, // Ship upgrades (CR-009): level 1, 2, 3. upgradeLevels: [ { costPercentOfPrice: 10, shopRealMinutes: 10 }, diff --git a/packages/content/src/data/locations.ts b/packages/content/src/data/locations.ts index 5751df9..9855276 100644 --- a/packages/content/src/data/locations.ts +++ b/packages/content/src/data/locations.ts @@ -17,6 +17,7 @@ export const locations: Location[] = [ body: "earth", sprite: "body-earth", launchCostMultiplier: 3, + launchRangeFactor: 1, startsDiscovered: true, bayLeasePrice: 1000, produces: goodsProducedAt("earth"), @@ -37,6 +38,7 @@ export const locations: Location[] = [ body: "moon", sprite: "body-moon", launchCostMultiplier: 0.5, + launchRangeFactor: 1.3, startsDiscovered: true, bayLeasePrice: 800, produces: goodsProducedAt("moon"), @@ -55,6 +57,7 @@ export const locations: Location[] = [ body: "mars", sprite: "body-mars", launchCostMultiplier: 1.5, + launchRangeFactor: 1.15, startsDiscovered: false, bayLeasePrice: 20000, produces: goodsProducedAt("mars"), @@ -70,6 +73,7 @@ export const locations: Location[] = [ body: "ceres", sprite: "body-ceres", launchCostMultiplier: 0.3, + launchRangeFactor: 1.3, startsDiscovered: false, bayLeasePrice: 40000, produces: goodsProducedAt("ceres-station"), @@ -86,6 +90,7 @@ export const locations: Location[] = [ sprite: "body-jupiter", badge: "badge-cloud-platform", launchCostMultiplier: 2, + launchRangeFactor: 1, startsDiscovered: false, bayLeasePrice: 80000, produces: goodsProducedAt("jupiter-cloud-platform"), diff --git a/packages/content/src/data/ships.ts b/packages/content/src/data/ships.ts index 86c5730..26655a6 100644 --- a/packages/content/src/data/ships.ts +++ b/packages/content/src/data/ships.ts @@ -21,6 +21,47 @@ export const shipModels: ShipModel[] = [ overheadGameHours: 2, fuelCostPerMillionKm: 200, purchasable: true, + role: "Fast and cheap: small loads around Earth and the Moon.", + }, + { + id: "lighter", + sprite: "ship-lighter", + name: "Lighter", + archetype: "lighter", + // About 40 % of the way from Courier to Freighter. + price: 42_400, + // Slower than the Freighter, faster than the Bulk Hauler. + cruiseSpeedKmPerGameHour: 1_600_000, + cargoCapacityTonnes: 80, + // Earth and the Moon only, even with maxed tanks (CR-012 §3). + fuelRangeMillionKm: 10, + hullRating: 2, + landingClass: "atmospheric", + overheadGameHours: 2, + // Big cargo doors: the fastest loading in the fleet. + handlingGameHoursPerTonne: 0.012, + fuelCostPerMillionKm: 350, + purchasable: true, + role: "Short-haul heavy lifter: big loads between Earth and the Moon, loaded fast.", + }, + { + id: "clipper", + sprite: "ship-clipper", + name: "Clipper", + archetype: "clipper", + // About 60 % of the way from Courier to Freighter. + price: 61_600, + // The fastest ship in the game. + cruiseSpeedKmPerGameHour: 6_500_000, + cargoCapacityTonnes: 30, + // Mars near opposition, Ceres by way of Mars (CR-012 §3; tuned in 10L.4). + fuelRangeMillionKm: 198, + hullRating: 1, + landingClass: "atmospheric", + overheadGameHours: 2, + fuelCostPerMillionKm: 600, + purchasable: true, + role: "Fast long-legged runner: the cheapest way to Mars, built for Priority work.", }, { id: "freighter", @@ -37,6 +78,7 @@ export const shipModels: ShipModel[] = [ overheadGameHours: 4, fuelCostPerMillionKm: 400, purchasable: true, + role: "The all-rounder: big loads to Mars and Ceres, Jupiter by way of Ceres.", }, { id: "bulk-hauler", @@ -53,6 +95,7 @@ export const shipModels: ShipModel[] = [ overheadGameHours: 8, fuelCostPerMillionKm: 800, purchasable: true, + role: "Slow and enormous: the only ship that can fly any leg in the system.", }, { // The company's tutorial loaner (decision 0.5.29): not for sale. Faster with @@ -70,5 +113,6 @@ export const shipModels: ShipModel[] = [ overheadGameHours: 0.3, fuelCostPerMillionKm: 200, purchasable: false, + role: "Halvorsen's loaner for the company runs.", }, ]; diff --git a/packages/content/src/schemas.ts b/packages/content/src/schemas.ts index 285eacb..c6bbbc1 100644 --- a/packages/content/src/schemas.ts +++ b/packages/content/src/schemas.ts @@ -38,6 +38,11 @@ export const locationSchema = z.object({ body: astronomyBodySchema, /** Multiplies fuel cost for launches from this location. */ launchCostMultiplier: z.number().positive(), + /** + * Multiplies a ship's range for legs launched from here (decision 0.5.78): + * low gravity stretches it. Separate from the fuel-cost multiplier. + */ + launchRangeFactor: z.number().positive(), startsDiscovered: z.boolean(), /** Credits. */ bayLeasePrice: nonNegativeInt, @@ -59,7 +64,13 @@ export const locationSchema = z.object({ }); export type Location = z.infer; -export const shipArchetypeSchema = z.enum(["courier", "freighter", "bulk-hauler"]); +export const shipArchetypeSchema = z.enum([ + "courier", + "lighter", + "clipper", + "freighter", + "bulk-hauler", +]); export type ShipArchetype = z.infer; export const shipModelSchema = z.object({ @@ -80,6 +91,10 @@ export const shipModelSchema = z.object({ fuelCostPerMillionKm: z.number().nonnegative(), /** False for company-only ships such as the tutorial loaner (decision 0.5.29). */ purchasable: z.boolean(), + /** One line on what the ship is for, shown in the Market (CR-012). */ + role: z.string().min(1), + /** Loading time per tonne; defaults to the game-wide `handlingGameHoursPerTonne` (CR-012). */ + handlingGameHoursPerTonne: z.number().positive().optional(), /** Map art asset id (CR-003), e.g. `ship-freighter`. */ sprite: idSchema, }); @@ -198,6 +213,8 @@ export const gameConfigSchema = z /** Loan taken on with the deal, in credits (decision 0.5.30). */ dealLoanAmount: positiveInt, contractBoardPeriodRealMinutes: z.number().positive(), + /** No-strand check: the ship must have a way out this long after arrival (decision 0.5.79). */ + noStrandMarginGameHours: z.number().nonnegative(), /** * Upgrade levels 1, 2, 3, …: cost as a percent of the ship model's price and * real minutes in the shop (CR-009). diff --git a/packages/content/test/bundle.test.ts b/packages/content/test/bundle.test.ts index 5cfd4ec..20f792d 100644 --- a/packages/content/test/bundle.test.ts +++ b/packages/content/test/bundle.test.ts @@ -4,8 +4,8 @@ import { content } from "../src/index"; describe("content bundle", () => { it("contains every kind of content", () => { expect(content.locations).toHaveLength(5); - expect(content.shipModels).toHaveLength(4); - expect(content.purchasableShipModels).toHaveLength(3); + expect(content.shipModels).toHaveLength(6); + expect(content.purchasableShipModels).toHaveLength(5); expect(content.cargoTypes).toHaveLength(4); expect(content.skills).toHaveLength(4); expect(content.config.timeCompression).toBe(60); diff --git a/packages/content/test/locations.test.ts b/packages/content/test/locations.test.ts index fa6b92e..9c327ca 100644 --- a/packages/content/test/locations.test.ts +++ b/packages/content/test/locations.test.ts @@ -49,4 +49,16 @@ describe("locations", () => { for (const sprite of sprites) expect(sprite).toMatch(/^[a-z][a-z-]*$/); expect(new Set(sprites).size).toBe(sprites.length); }); + + it("gives every location a positive launch range factor (CR-012)", () => { + for (const l of locations) expect(l.launchRangeFactor, l.id).toBeGreaterThan(0); + const factor = (id: string) => locations.find((l) => l.id === id)!.launchRangeFactor; + expect([ + factor("earth"), + factor("moon"), + factor("mars"), + factor("ceres-station"), + factor("jupiter-cloud-platform"), + ]).toEqual([1, 1.3, 1.15, 1.3, 1]); + }); }); diff --git a/packages/content/test/onboarding.test.ts b/packages/content/test/onboarding.test.ts index 801f3ce..ff64de6 100644 --- a/packages/content/test/onboarding.test.ts +++ b/packages/content/test/onboarding.test.ts @@ -11,7 +11,7 @@ describe("loaner ship", () => { it("is not in the Market's purchasable list", () => { const ids = content.purchasableShipModels.map((s) => s.id); expect(ids).not.toContain("company-courier"); - expect(ids).toEqual(["courier", "freighter", "bulk-hauler"]); + expect(ids).toEqual(["courier", "lighter", "clipper", "freighter", "bulk-hauler"]); }); it("is faster with less overhead than the Courier", () => { diff --git a/packages/content/test/schemas.test.ts b/packages/content/test/schemas.test.ts index 661680a..461d3d4 100644 --- a/packages/content/test/schemas.test.ts +++ b/packages/content/test/schemas.test.ts @@ -18,6 +18,7 @@ const validLocation = { landingRequirement: "atmospheric", body: "earth", launchCostMultiplier: 3, + launchRangeFactor: 1, startsDiscovered: true, bayLeasePrice: 5000, sprite: "body-mars", @@ -52,6 +53,7 @@ const validShipModel = { overheadGameHours: 2, fuelCostPerMillionKm: 100, purchasable: true, + role: "Fast and cheap.", sprite: "ship-courier", }; @@ -78,6 +80,7 @@ const validGameConfig = { contractBoardSize: 8, tutorialBoardSize: 50, contractBoardPeriodRealMinutes: 60, + noStrandMarginGameHours: 48, dutyLimitGameHours: 12, longHaulDutyBonusGameHours: 2, restGameHours: 60, diff --git a/packages/content/test/ships.test.ts b/packages/content/test/ships.test.ts index 619cef2..8d3062b 100644 --- a/packages/content/test/ships.test.ts +++ b/packages/content/test/ships.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { shipModelSchema, shipModels } from "../src/index"; +import { shipModelSchema, shipModels, gameConfig } from "../src/index"; const ship = (id: string) => shipModels.find((s) => s.id === id)!; @@ -10,39 +10,67 @@ describe("ship models", () => { } }); - it("has the three archetypes, all atmospheric", () => { + // CR-012 §2 replaces the Step 2.3 ordering tests. + const order = ( + key: "price" | "cruiseSpeedKmPerGameHour" | "cargoCapacityTonnes", + ids: string[], + ) => + ids + .slice(1) + .forEach((id, i) => + expect(ship(id)[key], `${ids[i]} < ${id}`).toBeGreaterThan(ship(ids[i]!)[key]), + ); + const purchasable = shipModels.filter((m) => m.purchasable); + const handling = (id: string) => + ship(id).handlingGameHoursPerTonne ?? gameConfig.handlingGameHoursPerTonne; + + it("has five purchasable models plus the loaner, all atmospheric", () => { expect(shipModels.map((s) => s.id)).toEqual([ "courier", + "lighter", + "clipper", "freighter", "bulk-hauler", "company-courier", ]); for (const model of shipModels) { expect(model.landingClass).toBe("atmospheric"); + expect(model.role.length).toBeGreaterThan(10); } + expect(ship("lighter").purchasable && ship("clipper").purchasable).toBe(true); }); - it("orders speed Courier > Freighter > Bulk Hauler", () => { - expect(ship("courier").cruiseSpeedKmPerGameHour).toBeGreaterThan( - ship("freighter").cruiseSpeedKmPerGameHour, - ); - expect(ship("freighter").cruiseSpeedKmPerGameHour).toBeGreaterThan( - ship("bulk-hauler").cruiseSpeedKmPerGameHour, - ); + it("orders price Courier < Lighter < Clipper < Freighter < Bulk Hauler", () => { + order("price", ["courier", "lighter", "clipper", "freighter", "bulk-hauler"]); }); - it("orders capacity Bulk Hauler > Freighter > Courier", () => { - expect(ship("bulk-hauler").cargoCapacityTonnes).toBeGreaterThan( - ship("freighter").cargoCapacityTonnes, - ); - expect(ship("freighter").cargoCapacityTonnes).toBeGreaterThan( - ship("courier").cargoCapacityTonnes, + it("makes the Clipper fastest, the Bulk Hauler slowest, and the Lighter slower than the Freighter", () => { + const speeds = shipModels.map((m) => m.cruiseSpeedKmPerGameHour); + expect(ship("clipper").cruiseSpeedKmPerGameHour).toBe(Math.max(...speeds)); + expect(ship("bulk-hauler").cruiseSpeedKmPerGameHour).toBe( + Math.min(...purchasable.map((m) => m.cruiseSpeedKmPerGameHour)), ); + order("cruiseSpeedKmPerGameHour", ["bulk-hauler", "lighter", "freighter"]); }); - it("orders price Courier < Freighter < Bulk Hauler", () => { - expect(ship("courier").price).toBeLessThan(ship("freighter").price); - expect(ship("freighter").price).toBeLessThan(ship("bulk-hauler").price); + it("orders capacity Courier < Clipper < Lighter < Freighter < Bulk Hauler", () => { + order("cargoCapacityTonnes", ["courier", "clipper", "lighter", "freighter", "bulk-hauler"]); + const lighterShare = + ship("lighter").cargoCapacityTonnes / ship("freighter").cargoCapacityTonnes; + expect(lighterShare).toBeGreaterThanOrEqual(0.75); + expect(lighterShare).toBeLessThanOrEqual(0.9); + }); + + it("gives the Lighter the fastest loading and the Clipper costly fuel", () => { + for (const m of shipModels) { + if (m.id !== "lighter") expect(handling("lighter")).toBeLessThan(handling(m.id)); + } + expect(ship("clipper").fuelCostPerMillionKm).toBeGreaterThan( + ship("courier").fuelCostPerMillionKm, + ); + expect(ship("clipper").fuelCostPerMillionKm).toBeGreaterThan( + ship("freighter").fuelCostPerMillionKm, + ); }); it("gives every ship model its own map sprite key (CR-003)", () => { diff --git a/packages/content/test/upgrades.test.ts b/packages/content/test/upgrades.test.ts index 5d6a9e0..9ad6625 100644 --- a/packages/content/test/upgrades.test.ts +++ b/packages/content/test/upgrades.test.ts @@ -13,7 +13,7 @@ import { 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 maxed = (modelId: string, stat: Exclude) => { const upgrade = upgradeFor(stat); const level = upgradeMaxLevel(upgrade, modelId); return model(modelId)[stat] * (1 + (upgradePercentPerLevel(upgrade, modelId) * level) / 100); diff --git a/packages/sim/src/upgrades.ts b/packages/sim/src/upgrades.ts index 6c3a9ca..5f38797 100644 --- a/packages/sim/src/upgrades.ts +++ b/packages/sim/src/upgrades.ts @@ -31,7 +31,9 @@ export function effectiveStats( ): EffectiveStats { const stats: EffectiveStats = { ...model, - handlingGameHoursPerTonne: content.config.handlingGameHoursPerTonne, + // The model's own loading time if it has one (the Lighter, CR-012), else the game-wide base. + handlingGameHoursPerTonne: + model.handlingGameHoursPerTonne ?? content.config.handlingGameHoursPerTonne, }; for (const upgrade of content.upgrades) { const level = Math.min(levels[upgrade.id] ?? 0, upgradeMaxLevel(upgrade, model.id)); diff --git a/packages/sim/test/effective-stats.test.ts b/packages/sim/test/effective-stats.test.ts index a01f0d8..f8899ca 100644 --- a/packages/sim/test/effective-stats.test.ts +++ b/packages/sim/test/effective-stats.test.ts @@ -116,3 +116,19 @@ describe("effective stats (Step 10I.2)", () => { ); }); }); + +describe("per-model handling (CR-012)", () => { + it("loads the Lighter faster than other ships, and upgrades scale its own base", () => { + const lighter = content.shipModel("lighter")!; + expect(effectiveStats(lighter, {}, content).handlingGameHoursPerTonne).toBe( + lighter.handlingGameHoursPerTonne, + ); + expect( + effectiveStats(lighter, { "handling-gear": 1 }, content).handlingGameHoursPerTonne, + ).toBeCloseTo(lighter.handlingGameHoursPerTonne! * 0.85, 9); + expect(travelTimeGameHours(lighter, 0, 50, content.config)).toBeCloseTo( + lighter.overheadGameHours + 50 * lighter.handlingGameHoursPerTonne!, + 9, + ); + }); +});