Step 10I.2: effective ship stats in the sim

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-04 19:13:11 -04:00
parent 0a7e9d97c8
commit 92520dbc4f
8 changed files with 194 additions and 7 deletions

View File

@ -935,3 +935,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **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).
## Step 10I.2
- **Effective stats are a ShipModel plus handling time** (`EffectiveStats extends ShipModel`), so every function that took a model (route check, travel time, fuel) takes effective stats unchanged. Only the place that looks a ship up changed: `shipStats(ship)` replaces the plain model lookup in dispatch, deadhead and record code.
- **Upgrades ride on `ShipState` as an optional `upgrades` map**; absent means none, so existing callers and tests needed no edits.
- **Levels above a model's max are clamped** in `effectiveStats`, so bad data can never break the "models stay distinct" caps.
- **Boards fit effective capacity:** CR-006's fleet-fitted boards now see an upgraded Cargo Bay. The Priority window's reference ship stays the base model, so windows are the same for every player.

View File

@ -227,7 +227,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.
- [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
- [x] **Step 10I.2 — Sim: effective stats.** `sim/src/upgrades.ts`: `effectiveStats(model, levels, content)` → `EffectiveStats` (the model with upgraded stats plus its own `handlingGameHoursPerTonne`). `ShipState.upgrades` (optional) and `shipStats(ship, content)` feed every check in `validateDispatch`, `validateReposition` and dispatch records (capacity, range, travel time, Priority window). `travelTimeGameHours` uses the ship's handling time when given. Board fit uses each fleet member's effective capacity (`FleetMember.upgrades`). `SimContent` gains `upgrades`. Test: `effective-stats.test.ts`; all earlier sim tests unchanged.
- [ ] Step 10I.3 — Sim: upgrade validation and cost
- [ ] Step 10I.4 — Schema and database
- [ ] Step 10I.5 — Server: start upgrade

View File

@ -6,6 +6,7 @@ import type {
ScriptedTutorialContract,
ShipModel,
Skill,
Upgrade,
} from "@deadhead/content";
import { MS_PER_MINUTE, type RealTime, realToGame } from "./clock";
import { contractPayout, type Contract } from "./contract";
@ -14,6 +15,7 @@ import { checkRoute } from "./eligibility";
import { LONG_HAUL_SKILL_ID, skillLevel, type SkillLevels } from "./pilot";
import { createRng, deriveSeed, type Rng, type Seed } from "./rng";
import { travelTimeGameHours } from "./travel";
import { effectiveStats, type UpgradeLevels } from "./upgrades";
/** The static data the sim needs. The `content` bundle satisfies this. */
export interface SimContent {
@ -23,6 +25,7 @@ export interface SimContent {
skills: readonly Skill[];
shipModels: readonly ShipModel[];
scriptedTutorialContracts: readonly ScriptedTutorialContract[];
upgrades: readonly Upgrade[];
config: GameConfig;
}
@ -50,6 +53,8 @@ export function standardCargo(content: SimContent): CargoType {
export interface FleetMember {
modelId: string;
skills: SkillLevels;
/** Installed upgrades: boards fit the ship's effective capacity (CR-009). */
upgrades?: UpgradeLevels;
}
export interface BoardInput {
@ -450,7 +455,9 @@ export function generateContractBoard(input: BoardInput, content: SimContent): C
const crews: Crew[] = (input.fleet ?? []).flatMap((f) => {
const model = content.shipModels.find((m) => m.id === f.modelId);
return model ? [{ model, skills: f.skills }] : [];
return model
? [{ model: effectiveStats(model, f.upgrades ?? {}, content), skills: f.skills }]
: [];
});
const offeredAt = periodIndex * boardPeriodMs(config);

View File

@ -9,12 +9,20 @@ import { fuelCost } from "./fuel";
import { checkPilotEligibility, type SkillLevels } from "./pilot";
import { deriveSeed, type Seed } from "./rng";
import { travelTimeGameHours } from "./travel";
import { effectiveStats, type EffectiveStats, type UpgradeLevels } from "./upgrades";
export interface ShipState {
id: string;
modelId: string;
locationId: string;
inFlight: boolean;
/** Installed upgrades (CR-009); absent means none. */
upgrades?: UpgradeLevels;
}
/** A ship's effective stats: its model plus its upgrades (decision 0.5.63). */
export function shipStats(ship: ShipState, content: SimContent): EffectiveStats {
return effectiveStats(findShipModel(ship.modelId, content), ship.upgrades ?? {}, content);
}
export interface PilotState {
@ -99,7 +107,7 @@ export function contractTripGameHours(
/** Validates a proposed contract dispatch, returning the first failing reason. */
export function validateDispatch(proposal: DispatchProposal, content: SimContent): DispatchCheck {
const { ship, pilot, contract, tutorial, credits, now } = proposal;
const model = findShipModel(ship.modelId, content);
const model = shipStats(ship, content);
const origin = findLocation(contract.originId, content);
const destination = findLocation(contract.destinationId, content);
const departure = realToGame(now, content.config);
@ -185,7 +193,7 @@ function buildRecord(
): DispatchRecord {
const { id, ship, pilot, contract, tutorial, now, worldSeed } = args;
const { config } = content;
const model = findShipModel(ship.modelId, content);
const model = shipStats(ship, content);
const origin = findLocation(args.originId, content);
const destination = findLocation(args.destinationId, content);
const departure = realToGame(now, config);
@ -264,7 +272,7 @@ export function validateReposition(
if (destinationId === ship.locationId) return failWith("already-there");
if (!discoveredIds.has(destinationId)) return failWith("location-undiscovered");
const model = findShipModel(ship.modelId, content);
const model = shipStats(ship, content);
const origin = findLocation(ship.locationId, content);
const destination = findLocation(destinationId, content);
const departure = realToGame(now, content.config);

View File

@ -41,6 +41,7 @@ export {
contractTripGameHours,
createDispatchRecord,
createRepositionRecord,
shipStats,
tripFuelCost,
validateDispatch,
validateReposition,
@ -73,3 +74,6 @@ export { resolveDispatch } from "./resolution";
export type { Resolution, ResolveInput, ResolveResult } from "./resolution";
export { dutyAfterTrip, dutyLimit, isResting, needsRest, restEndsAt, tripGameHours } from "./duty";
export { effectiveStats } from "./upgrades";
export type { EffectiveStats, UpgradeLevels } from "./upgrades";

View File

@ -7,12 +7,14 @@ import type { GameConfig, ShipModel } from "@deadhead/content";
* carry zero mass.
*/
export function travelTimeGameHours(
ship: ShipModel,
/** Effective stats carry the ship's own handling time (decision 0.5.63); a bare model uses the game-wide base. */
ship: ShipModel & { handlingGameHoursPerTonne?: number },
distanceMillionKm: number,
massTonnes: number,
config: GameConfig,
): number {
const cruiseHours = (distanceMillionKm * 1e6) / ship.cruiseSpeedKmPerGameHour;
const handlingHours = massTonnes * config.handlingGameHoursPerTonne;
const handlingHours =
massTonnes * (ship.handlingGameHoursPerTonne ?? config.handlingGameHoursPerTonne);
return cruiseHours + ship.overheadGameHours + handlingHours;
}

View File

@ -0,0 +1,41 @@
import {
upgradeMaxLevel,
upgradePercentPerLevel,
type GameConfig,
type ShipModel,
type Upgrade,
} from "@deadhead/content";
/** Installed upgrade levels by upgrade id; a missing upgrade is level 0. */
export type UpgradeLevels = Readonly<Record<string, number>>;
/**
* A ship's stats after upgrades (decision 0.5.63): its model with the four
* upgradable model stats adjusted, plus its own handling time per tonne.
*/
export interface EffectiveStats extends ShipModel {
handlingGameHoursPerTonne: number;
}
/**
* Applies installed upgrades to a model's base stats: each level adds the
* upgrade's percent (after model overrides) of the base stat, linearly.
* Levels above the model's max for an upgrade are clamped.
*/
export function effectiveStats(
model: ShipModel,
levels: UpgradeLevels,
content: { upgrades: readonly Upgrade[]; config: GameConfig },
): EffectiveStats {
const stats: EffectiveStats = {
...model,
handlingGameHoursPerTonne: content.config.handlingGameHoursPerTonne,
};
for (const upgrade of content.upgrades) {
const level = Math.min(levels[upgrade.id] ?? 0, upgradeMaxLevel(upgrade, model.id));
if (level <= 0) continue;
const base = stats[upgrade.stat];
stats[upgrade.stat] = base * (1 + (upgradePercentPerLevel(upgrade, model.id) * level) / 100);
}
return stats;
}

View File

@ -0,0 +1,118 @@
import { content, SHIP_STATS } from "@deadhead/content";
import { describe, expect, it } from "vitest";
import {
effectiveStats,
realToGame,
routeDistance,
shipStats,
travelTimeGameHours,
validateDispatch,
type Contract,
type DispatchProposal,
type PilotState,
type ShipState,
} from "../src/index";
const now = Date.parse("2100-01-01T00:30:00Z");
const courier = content.shipModel("courier")!;
const freighter = content.shipModel("freighter")!;
const contract: Contract = {
id: "c1",
originId: "earth",
destinationId: "moon",
cargoTypeId: "standard",
goodId: "consumer-goods",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
tier: 0,
massTonnes: 10,
payout: 2000,
requiredSkill: null,
requiredLongHaulLevel: 0,
offeredAt: Date.parse("2100-01-01T00:00:00Z"),
expiresAt: Date.parse("2100-01-01T01:00:00Z"),
firstContact: false,
};
const ship: ShipState = { id: "s1", modelId: "courier", locationId: "earth", inFlight: false };
const pilot: PilotState = {
id: "p1",
isAvatar: false,
assignedShipId: "s1",
inFlight: false,
skills: {},
restEndsAt: null,
};
const proposal: DispatchProposal = { ship, pilot, contract, tutorial: false, credits: 10_000, now };
const reason = (p: DispatchProposal) => {
const result = validateDispatch(p, content);
return result.ok ? "valid" : result.reason;
};
describe("effective stats (Step 10I.2)", () => {
it("equals the model's stats exactly with no upgrades", () => {
expect(effectiveStats(freighter, {}, content)).toEqual({
...freighter,
handlingGameHoursPerTonne: content.config.handlingGameHoursPerTonne,
});
expect(shipStats(ship, content)).toEqual(effectiveStats(courier, {}, content));
});
it("changes only the upgrade's own stat", () => {
const base = effectiveStats(freighter, {}, content);
for (const upgrade of content.upgrades) {
const upgraded = effectiveStats(freighter, { [upgrade.id]: 1 }, content);
for (const stat of SHIP_STATS) {
if (stat === upgrade.stat) expect(upgraded[stat], upgrade.id).not.toBe(base[stat]);
else expect(upgraded[stat], `${upgrade.id} → ${stat}`).toBe(base[stat]);
}
}
});
it("gives exactly +30 % speed at engines level 3", () => {
expect(effectiveStats(freighter, { engines: 3 }, content).cruiseSpeedKmPerGameHour).toBeCloseTo(
freighter.cruiseSpeedKmPerGameHour * 1.3,
6,
);
});
it("lets a cargo-bay upgrade carry a contract that was too heavy", () => {
const heavy = { ...contract, massTonnes: 22 };
expect(reason({ ...proposal, contract: heavy })).toBe("over-capacity");
expect(
reason({ ...proposal, contract: heavy, ship: { ...ship, upgrades: { "cargo-bay": 1 } } }),
).toBe("valid");
});
it("lets an engines upgrade make a Priority window that was too short", () => {
const distance = routeDistance(
content.location("earth")!,
content.location("moon")!,
realToGame(now, content.config),
);
const hours = (stats: typeof courier) =>
travelTimeGameHours(stats, distance, contract.massTonnes, content.config);
const slow = hours(effectiveStats(courier, {}, content));
const fast = hours(effectiveStats(courier, { engines: 3 }, content));
expect(fast).toBeLessThan(slow);
const priority = { ...contract, priority: true, deliveryWindowGameHours: (slow + fast) / 2 };
expect(reason({ ...proposal, contract: priority })).toBe("too-slow");
expect(
reason({ ...proposal, contract: priority, ship: { ...ship, upgrades: { engines: 3 } } }),
).toBe("valid");
});
it("uses the ship's own handling time in travel time", () => {
const geared = effectiveStats(courier, { "handling-gear": 2 }, content);
expect(geared.handlingGameHoursPerTonne).toBeCloseTo(
content.config.handlingGameHoursPerTonne * 0.7,
9,
);
expect(travelTimeGameHours(geared, 0, 20, content.config)).toBeCloseTo(
geared.overheadGameHours + 20 * geared.handlingGameHoursPerTonne,
9,
);
});
});