diff --git a/DECISIONS.md b/DECISIONS.md index 9ea9b0c..486b24c 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -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. diff --git a/apps/server/src/game/messages.ts b/apps/server/src/game/messages.ts index 2a8cee7..0a43e04 100644 --- a/apps/server/src/game/messages.ts +++ b/apps/server/src/game/messages.ts @@ -4,6 +4,7 @@ import type { DispatchFailure, RepositionFailure } from "@deadhead/sim"; export const VALIDATION_MESSAGES: Record = { "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.", diff --git a/apps/web/src/screens/DispatchSheet.tsx b/apps/web/src/screens/DispatchSheet.tsx index 4490d91..54c6821 100644 --- a/apps/web/src/screens/DispatchSheet.tsx +++ b/apps/web/src/screens/DispatchSheet.tsx @@ -35,6 +35,7 @@ import { formatWindow, goodName, PriorityChip } from "../components/Goods"; export const INELIGIBLE_REASONS: Record = { "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", diff --git a/apps/web/src/screens/Fleet.tsx b/apps/web/src/screens/Fleet.tsx index d9c302a..12dacd5 100644 --- a/apps/web/src/screens/Fleet.tsx +++ b/apps/web/src/screens/Fleet.tsx @@ -164,6 +164,7 @@ const DEADHEAD_REASONS: Partial> = { "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", diff --git a/memory-bank/progress.md b/memory-bank/progress.md index cbf4a02..e606da1 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -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 diff --git a/packages/sim/src/dispatch.ts b/packages/sim/src/dispatch.ts index ea1b6cf..9cf7120 100644 --- a/packages/sim/src/dispatch.ts +++ b/packages/sim/src/dispatch.ts @@ -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"); diff --git a/packages/sim/src/index.ts b/packages/sim/src/index.ts index e6bff5d..d364bac 100644 --- a/packages/sim/src/index.ts +++ b/packages/sim/src/index.ts @@ -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"; diff --git a/packages/sim/src/upgrades.ts b/packages/sim/src/upgrades.ts index f2fcd0e..6c3a9ca 100644 --- a/packages/sim/src/upgrades.ts +++ b/packages/sim/src/upgrades.ts @@ -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>; @@ -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, 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) }; +} diff --git a/packages/sim/test/upgrade-validation.test.ts b/packages/sim/test/upgrade-validation.test.ts new file mode 100644 index 0000000..bd709cc --- /dev/null +++ b/packages/sim/test/upgrade-validation.test.ts @@ -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 }); + }); +});