Step 10I.1: ship upgrade content

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-04 19:08:59 -04:00
parent bf166c1deb
commit 0a7e9d97c8
10 changed files with 235 additions and 1 deletions

View File

@ -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**. - **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`. - **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. - **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).

View File

@ -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. 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.2 — Sim: effective stats
- [ ] Step 10I.3 — Sim: upgrade validation and cost - [ ] Step 10I.3 — Sim: upgrade validation and cost
- [ ] Step 10I.4 — Schema and database - [ ] Step 10I.4 — Schema and database

View File

@ -4,6 +4,7 @@ import { goods } from "./data/goods";
import { locations } from "./data/locations"; import { locations } from "./data/locations";
import { onboardingGoals, scriptedTutorialContracts } from "./data/onboarding"; import { onboardingGoals, scriptedTutorialContracts } from "./data/onboarding";
import { shipModels } from "./data/ships"; import { shipModels } from "./data/ships";
import { upgrades } from "./data/upgrades";
/** Returns a lookup that gives `undefined` for an unknown id instead of throwing. */ /** Returns a lookup that gives `undefined` for an unknown id instead of throwing. */
function byId<T extends { id: string }>(items: readonly T[]): (id: string) => T | undefined { function byId<T extends { id: string }>(items: readonly T[]): (id: string) => T | undefined {
@ -23,11 +24,13 @@ export const content = {
config: gameConfig, config: gameConfig,
scriptedTutorialContracts, scriptedTutorialContracts,
onboardingGoals, onboardingGoals,
upgrades,
location: byId(locations), location: byId(locations),
shipModel: byId(shipModels), shipModel: byId(shipModels),
cargoType: byId(cargoTypes), cargoType: byId(cargoTypes),
good: byId(goods), good: byId(goods),
skill: byId(skills), skill: byId(skills),
onboardingGoal: byId(onboardingGoals), onboardingGoal: byId(onboardingGoals),
upgrade: byId(upgrades),
}; };
export type Content = typeof content; export type Content = typeof content;

View File

@ -24,6 +24,12 @@ export const gameConfig: GameConfig = {
// Courier price + Earth bay lease. // Courier price + Earth bay lease.
dealLoanAmount: 5000, dealLoanAmount: 5000,
contractBoardPeriodRealMinutes: 10, 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. // Duty and rest (CR-004): about three Moon round trips, then rest.
dutyLimitGameHours: 12, dutyLimitGameHours: 12,
longHaulDutyBonusGameHours: 2, longHaulDutyBonusGameHours: 2,

View File

@ -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;
}

View File

@ -5,4 +5,5 @@ export * from "./data/cargo";
export * from "./data/goods"; export * from "./data/goods";
export * from "./data/config"; export * from "./data/config";
export * from "./data/onboarding"; export * from "./data/onboarding";
export * from "./data/upgrades";
export * from "./bundle"; export * from "./bundle";

View File

@ -85,6 +85,39 @@ export const shipModelSchema = z.object({
}); });
export type ShipModel = z.infer<typeof shipModelSchema>; export type ShipModel = z.infer<typeof shipModelSchema>;
/**
* 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<typeof shipStatSchema>;
/** 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<typeof upgradeSchema>;
export const cargoTypeSchema = z.object({ export const cargoTypeSchema = z.object({
id: idSchema, id: idSchema,
name: z.string().min(1), 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). */ /** Loan taken on with the deal, in credits (decision 0.5.30). */
dealLoanAmount: positiveInt, dealLoanAmount: positiveInt,
contractBoardPeriodRealMinutes: z.number().positive(), 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). */ /** Game hours a pilot may fly before mandatory rest, at Long Haul 0 (decision 0.5.43). */
dutyLimitGameHours: z.number().positive(), dutyLimitGameHours: z.number().positive(),
/** Extra duty game hours per Long Haul level (decision 0.5.43). */ /** Extra duty game hours per Long Haul level (decision 0.5.43). */

View File

@ -104,6 +104,7 @@ const validGameConfig = {
tutorialRunsBeforeDeal: 4, tutorialRunsBeforeDeal: 4,
tutorialMassTonnes: { light: 5, medium: 12, full: 20 }, tutorialMassTonnes: { light: 5, medium: 12, full: 20 },
handlingGameHoursPerTonne: 0.02, handlingGameHoursPerTonne: 0.02,
upgradeLevels: [{ costPercentOfPrice: 10, shopRealMinutes: 10 }],
loanRepaymentShare: 0.3, loanRepaymentShare: 0.3,
dealLoanAmount: 5000, dealLoanAmount: 5000,
}; };

View File

@ -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,
);
});
});

View File

@ -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);
});
});