Step 10I.3: upgrade validation, cost and the ship-in-shop check

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-04 19:17:42 -04:00
parent 92520dbc4f
commit 0338d5fdbe
9 changed files with 237 additions and 4 deletions

View File

@ -942,3 +942,11 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **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.
## Step 10I.3
- **Check order:** tutorial, loaner, in flight, in the shop, at an HQ, maxed, affordable. Situation checks come before price, so the reason shown is the one the player must fix first.
- **"At an HQ" is an input (`atHq`)**, not computed from bays in the sim, so the server decides it from the player's bays and the client from state, both with the same rule.
- **Success returns everything the server writes**: the new level, its cost, and in-shop-until (now + that level's shop time).
- **`ship-in-shop` also blocks deadheads**, since a ship in the shop isn't going anywhere. It sits right after `ship-not-idle` in both validators.
- **Costs are rounded to whole credits** like fuel; for current prices all three levels are exact.

View File

@ -4,6 +4,7 @@ import type { DispatchFailure, RepositionFailure } from "@deadhead/sim";
export const VALIDATION_MESSAGES: Record<DispatchFailure | RepositionFailure, string> = {
"ship-not-idle": "That ship is already flying.",
"ship-not-at-origin": "That ship is not at the contract's pickup location.",
"ship-in-shop": "That ship is in the upgrade shop until its work is done.",
"pilot-resting": "That pilot is resting and cannot fly until their rest ends.",
"pilot-not-idle": "That pilot is already flying.",
"pilot-not-assigned": "That pilot is not assigned to that ship.",

View File

@ -35,6 +35,7 @@ import { formatWindow, goodName, PriorityChip } from "../components/Goods";
export const INELIGIBLE_REASONS: Record<DispatchFailure, string> = {
"ship-not-idle": "Ship is flying",
"ship-not-at-origin": "Not at pickup",
"ship-in-shop": "Ship is in the shop",
"pilot-resting": "Pilot is resting",
"pilot-not-idle": "Pilot is flying",
"pilot-not-assigned": "No pilot assigned",

View File

@ -164,6 +164,7 @@ const DEADHEAD_REASONS: Partial<Record<RepositionFailure, string>> = {
"cannot-land": "Can't land there",
"out-of-range": "Out of range",
"insufficient-credits": "Not enough credits for fuel",
"ship-in-shop": "Ship is in the shop",
"pilot-resting": "Pilot is resting",
"pilot-not-idle": "Pilot is flying",
"pilot-not-assigned": "No pilot assigned",

View File

@ -228,7 +228,7 @@ Spec: `memory-bank/change-requests/CR-009-ship-upgrades.md` (received as "CR-006
- [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).
- [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
- [x] **Step 10I.3 — Sim: upgrade validation and cost.** `upgradeCost(model, level)` (whole credits, % of price), `upgradeShopMs(level)`, `nextUpgradeLevel`, `isInShop(ship, now)` and `validateUpgrade` with `UPGRADE_FAILURES` in check order: `tutorial-active`, `not-upgradable`, `ship-not-idle`, `ship-in-shop`, `not-at-hq`, `upgrade-maxed`, `insufficient-funds`; success returns the level, cost and in-shop-until. `ShipState.inShopUntil`; dispatch and deadhead validation fail `ship-in-shop` right after `ship-not-idle`. Test: `upgrade-validation.test.ts`.
- [ ] Step 10I.4 — Schema and database
- [ ] Step 10I.5 — Server: start upgrade
- [ ] Step 10I.6 — Client: upgrade shop

View File

@ -9,7 +9,7 @@ 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";
import { effectiveStats, isInShop, type EffectiveStats, type UpgradeLevels } from "./upgrades";
export interface ShipState {
id: string;
@ -18,6 +18,8 @@ export interface ShipState {
inFlight: boolean;
/** Installed upgrades (CR-009); absent means none. */
upgrades?: UpgradeLevels;
/** In the upgrade shop until this real time (decision 0.5.66); absent or null: not. */
inShopUntil?: RealTime | null;
}
/** A ship's effective stats: its model plus its upgrades (decision 0.5.63). */
@ -50,6 +52,7 @@ export interface DispatchProposal {
/** Every reason `validateDispatch` can fail, in check order. */
export const DISPATCH_FAILURES = [
"ship-not-idle",
"ship-in-shop",
"ship-not-at-origin",
"pilot-resting",
"pilot-not-idle",
@ -113,6 +116,7 @@ export function validateDispatch(proposal: DispatchProposal, content: SimContent
const departure = realToGame(now, content.config);
if (ship.inFlight) return fail("ship-not-idle");
if (isInShop(ship, now)) return fail("ship-in-shop");
if (ship.locationId !== contract.originId) return fail("ship-not-at-origin");
// Rest blocks flying only (decision 0.5.44); checked before the other pilot checks.
if (isResting(pilot.restEndsAt, now)) return fail("pilot-resting");
@ -240,6 +244,7 @@ export interface RepositionProposal {
/** Every reason `validateReposition` can fail, in check order. */
export const REPOSITION_FAILURES = [
"ship-not-idle",
"ship-in-shop",
"pilot-resting",
"pilot-not-idle",
"pilot-not-assigned",
@ -266,6 +271,7 @@ export function validateReposition(
const failWith = (reason: RepositionFailure): RepositionCheck => ({ ok: false, reason });
if (ship.inFlight) return failWith("ship-not-idle");
if (isInShop(ship, now)) return failWith("ship-in-shop");
if (isResting(pilot.restEndsAt, now)) return failWith("pilot-resting");
if (pilot.inFlight) return failWith("pilot-not-idle");
if (pilot.assignedShipId !== ship.id) return failWith("pilot-not-assigned");

View File

@ -75,5 +75,19 @@ 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";
export {
UPGRADE_FAILURES,
effectiveStats,
isInShop,
nextUpgradeLevel,
upgradeCost,
upgradeShopMs,
validateUpgrade,
} from "./upgrades";
export type {
EffectiveStats,
UpgradeCheck,
UpgradeFailure,
UpgradeLevels,
UpgradeProposal,
} from "./upgrades";

View File

@ -5,6 +5,8 @@ import {
type ShipModel,
type Upgrade,
} from "@deadhead/content";
import { MS_PER_MINUTE, type RealTime } from "./clock";
import type { ShipState } from "./dispatch";
/** Installed upgrade levels by upgrade id; a missing upgrade is level 0. */
export type UpgradeLevels = Readonly<Record<string, number>>;
@ -39,3 +41,85 @@ export function effectiveStats(
}
return stats;
}
/** Whether a ship is in the upgrade shop at real time `now` (until, not at, in-shop-until). */
export function isInShop(ship: Pick<ShipState, "inShopUntil">, now: RealTime): boolean {
return ship.inShopUntil != null && now < ship.inShopUntil;
}
/** The next level an upgrade would reach on a ship, or null when it is maxed. */
export function nextUpgradeLevel(
upgrade: Upgrade,
modelId: string,
levels: UpgradeLevels,
): number | null {
const next = (levels[upgrade.id] ?? 0) + 1;
return next > upgradeMaxLevel(upgrade, modelId) ? null : next;
}
/** Cost of an upgrade level: a percent of the ship model's price, in whole credits. */
export function upgradeCost(model: ShipModel, level: number, config: GameConfig): number {
const entry = config.upgradeLevels[level - 1];
if (!entry) throw new Error(`No upgrade level ${level}`);
return Math.round((model.price * entry.costPercentOfPrice) / 100);
}
/** Real milliseconds a ship spends in the shop for an upgrade level. */
export function upgradeShopMs(level: number, config: GameConfig): number {
const entry = config.upgradeLevels[level - 1];
if (!entry) throw new Error(`No upgrade level ${level}`);
return entry.shopRealMinutes * MS_PER_MINUTE;
}
/** Every reason `validateUpgrade` can fail, in check order (decisions 0.5.64, 0.5.65). */
export const UPGRADE_FAILURES = [
"tutorial-active",
"not-upgradable",
"ship-not-idle",
"ship-in-shop",
"not-at-hq",
"upgrade-maxed",
"insufficient-funds",
] as const;
export type UpgradeFailure = (typeof UPGRADE_FAILURES)[number];
export interface UpgradeProposal {
ship: ShipState;
upgrade: Upgrade;
/** Whether the player has a bay where the ship is. */
atHq: boolean;
tutorialActive: boolean;
credits: number;
now: RealTime;
}
export type UpgradeCheck =
| { ok: true; level: number; cost: number; shopUntil: RealTime }
| { ok: false; reason: UpgradeFailure };
/**
* Validates starting one level of an upgrade (decisions 0.5.64–0.5.66): tutorial
* over, a purchasable model (not the loaner), idle and out of the shop, at an HQ,
* not maxed, affordable. On success, gives the new level, its cost and when the
* ship leaves the shop.
*/
export function validateUpgrade(
proposal: UpgradeProposal,
content: { shipModels: readonly ShipModel[]; config: GameConfig },
): UpgradeCheck {
const { ship, upgrade, atHq, tutorialActive, credits, now } = proposal;
const fail = (reason: UpgradeFailure): UpgradeCheck => ({ ok: false, reason });
const model = content.shipModels.find((m) => m.id === ship.modelId);
if (!model) throw new Error(`Unknown ship model ${ship.modelId}`);
if (tutorialActive) return fail("tutorial-active");
if (!model.purchasable) return fail("not-upgradable");
if (ship.inFlight) return fail("ship-not-idle");
if (isInShop(ship, now)) return fail("ship-in-shop");
if (!atHq) return fail("not-at-hq");
const level = nextUpgradeLevel(upgrade, model.id, ship.upgrades ?? {});
if (level === null) return fail("upgrade-maxed");
const cost = upgradeCost(model, level, content.config);
if (credits < cost) return fail("insufficient-funds");
return { ok: true, level, cost, shopUntil: now + upgradeShopMs(level, content.config) };
}

View File

@ -0,0 +1,118 @@
import { content } from "@deadhead/content";
import { describe, expect, it } from "vitest";
import {
upgradeCost,
upgradeShopMs,
validateDispatch,
validateUpgrade,
type Contract,
type PilotState,
type ShipState,
type UpgradeProposal,
} from "../src/index";
const now = Date.parse("2100-01-01T00:30:00Z");
const freighter = content.shipModel("freighter")!;
const engines = content.upgrade("engines")!;
const ship: ShipState = { id: "s1", modelId: "freighter", locationId: "earth", inFlight: false };
const valid: UpgradeProposal = {
ship,
upgrade: engines,
atHq: true,
tutorialActive: false,
credits: 1_000_000,
now,
};
const reason = (proposal: UpgradeProposal) => {
const result = validateUpgrade(proposal, content);
return result.ok ? "valid" : result.reason;
};
describe("upgrade cost and shop time (Step 10I.3)", () => {
it("costs 10 % of a Freighter's price at level 1 and 35 % at level 3", () => {
expect(upgradeCost(freighter, 1, content.config)).toBe(freighter.price * 0.1);
expect(upgradeCost(freighter, 3, content.config)).toBe(freighter.price * 0.35);
expect(upgradeShopMs(2, content.config)).toBe(20 * 60_000);
});
it("starts the next level, with its cost and shop time", () => {
expect(
validateUpgrade({ ...valid, ship: { ...ship, upgrades: { engines: 1 } } }, content),
).toEqual({
ok: true,
level: 2,
cost: upgradeCost(freighter, 2, content.config),
shopUntil: now + upgradeShopMs(2, content.config),
});
});
});
describe("upgrade validation (Step 10I.3)", () => {
it("accepts a valid upgrade", () => expect(reason(valid)).toBe("valid"));
it("refuses during the tutorial", () =>
expect(reason({ ...valid, tutorialActive: true })).toBe("tutorial-active"));
it("refuses the company loaner", () =>
expect(reason({ ...valid, ship: { ...ship, modelId: "company-courier" } })).toBe(
"not-upgradable",
));
it("refuses a ship in flight", () =>
expect(reason({ ...valid, ship: { ...ship, inFlight: true } })).toBe("ship-not-idle"));
it("refuses a ship already in the shop", () =>
expect(reason({ ...valid, ship: { ...ship, inShopUntil: now + 1000 } })).toBe("ship-in-shop"));
it("refuses away from an HQ", () => expect(reason({ ...valid, atHq: false })).toBe("not-at-hq"));
it("refuses a maxed upgrade", () =>
expect(reason({ ...valid, ship: { ...ship, upgrades: { engines: 3 } } })).toBe(
"upgrade-maxed",
));
it("refuses without the credits", () =>
expect(reason({ ...valid, credits: upgradeCost(freighter, 1, content.config) - 1 })).toBe(
"insufficient-funds",
));
});
describe("ships in the shop can't fly (Step 10I.3)", () => {
const pilot: PilotState = {
id: "p1",
isAvatar: false,
assignedShipId: "s1",
inFlight: false,
skills: {},
restEndsAt: null,
};
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: now,
expiresAt: now + 3_600_000,
firstContact: false,
};
const at = (time: number, shopUntil: number) =>
validateDispatch(
{
ship: { ...ship, inShopUntil: shopUntil },
pilot,
contract,
tutorial: false,
credits: 1e6,
now: time,
},
content,
);
it("fails ship-in-shop one second before in-shop-until and passes at it", () => {
const until = now + 10 * 60_000;
expect(at(until - 1000, until)).toEqual({ ok: false, reason: "ship-in-shop" });
expect(at(until, until)).toEqual({ ok: true });
});
});