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**.
|
- **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).
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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/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";
|
||||||
|
|
|
||||||
|
|
@ -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). */
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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