Step 10I.1: ship upgrade content
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
bf166c1deb
commit
0a7e9d97c8
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<T extends { id: string }>(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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -85,6 +85,39 @@ export const shipModelSchema = z.object({
|
|||
});
|
||||
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({
|
||||
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). */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue