Step 10I.2: effective ship stats in the sim
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
0a7e9d97c8
commit
92520dbc4f
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue