From f81a8e41efc6c9412ca57e7b8779924aea067d2b Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sun, 4 Oct 2026 19:31:37 -0400 Subject: [PATCH] Step 10I.5: start upgrades on the server Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 6 ++ apps/server/src/game/boards.ts | 14 ++- apps/server/src/game/dispatch.ts | 11 +- apps/server/src/game/messages.ts | 13 ++- apps/server/src/game/pilots.ts | 6 +- apps/server/src/game/upgrades.ts | 82 ++++++++++++++- apps/server/src/routes/fleet.ts | 7 ++ apps/server/test/upgrades.test.ts | 163 ++++++++++++++++++++++++++++++ memory-bank/progress.md | 2 +- 9 files changed, 293 insertions(+), 11 deletions(-) create mode 100644 apps/server/test/upgrades.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index 9e46a69..206693d 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -957,3 +957,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Acknowledging a finished upgrade reuses `acknowledgeSummaries`** with optional `shipIds`, since both are cleared from the same "While you were away" modal. The request needs at least one id overall (so `dispatchIds` may be empty). - **Effective stats are sent per ship** (`stats`), computed by the server with the sim, so every screen can show them without recomputing; the client still uses the sim for predictions. - **Upgrade codes come from the sim's `UPGRADE_FAILURES`**, joining the dispatch and deadhead reasons in the API's error list; the new ones are `not-at-hq`, `ship-in-shop`, `upgrade-maxed` and `not-upgradable`. + +## Step 10I.5 + +- **The upgrade runs in the player's locked transaction** (`withPlayer` locks the company row, Step 6.14), so the funds check and the charge cannot interleave with another purchase: of two simultaneous upgrades the player can afford one of, exactly one succeeds and the balance never goes negative (tested). +- **The level is raised at purchase** (decision 0.5.66). Effective stats change at once, but the ship can't fly until in-shop-until, so there is nothing to finish later. +- **Server messages for upgrade refusals** live with the other validation texts (`UPGRADE_MESSAGES`); an unknown upgrade id is `404 not-found`. diff --git a/apps/server/src/game/boards.ts b/apps/server/src/game/boards.ts index 3c63dae..0006edb 100644 --- a/apps/server/src/game/boards.ts +++ b/apps/server/src/game/boards.ts @@ -20,6 +20,7 @@ import { ships, } from "../db/schema"; import { gameError } from "../http/errors"; +import { loadUpgradeLevels } from "./upgrades"; export interface BoardView { contracts: Contract[]; @@ -179,10 +180,19 @@ export async function locationBoard( */ export async function playerFleet(tx: Queryable, playerId: string): Promise { const rows = await tx - .select({ modelId: ships.modelId, skills: pilots.skills }) + .select({ id: ships.id, modelId: ships.modelId, skills: pilots.skills }) .from(ships) .leftJoin(pilots, eq(pilots.assignedShipId, ships.id)) .where(eq(ships.playerId, playerId)) .orderBy(asc(ships.createdAt), asc(ships.id)); - return rows.map((r) => ({ modelId: r.modelId, skills: r.skills ?? {} })); + // Upgrades count: an expanded cargo bay gets bigger fit jobs (CR-009). + const upgrades = await loadUpgradeLevels( + tx, + rows.map((r) => r.id), + ); + return rows.map((r) => ({ + modelId: r.modelId, + skills: r.skills ?? {}, + upgrades: upgrades.get(r.id) ?? {}, + })); } diff --git a/apps/server/src/game/dispatch.ts b/apps/server/src/game/dispatch.ts index 1137358..f7dcccc 100644 --- a/apps/server/src/game/dispatch.ts +++ b/apps/server/src/game/dispatch.ts @@ -12,6 +12,7 @@ import { type PilotState, type RealTime, type ShipState, + type UpgradeLevels, } from "@deadhead/sim"; import { and, eq, sql } from "drizzle-orm"; import type { Queryable } from "../db/client"; @@ -31,6 +32,7 @@ import { requireCompany, requirePlayer } from "./fleet"; import { toApiDispatch } from "./mappers"; import { VALIDATION_MESSAGES } from "./messages"; import { requireOwnPilot, requireOwnShip } from "./pilots"; +import { shipUpgradeLevels } from "./upgrades"; /** The id the tutorial loaner uses in dispatch records (it is not a ship row). */ export const LOANER_SHIP_ID = "loaner"; @@ -193,13 +195,16 @@ export async function dispatchTutorial( return dispatch; } -export function shipState(row: typeof ships.$inferSelect): ShipState { +export function shipState(row: typeof ships.$inferSelect, upgrades: UpgradeLevels = {}): ShipState { return { id: row.id, modelId: row.modelId, // An in-flight ship has no location; validation reports it as not idle first. locationId: row.locationId ?? "", inFlight: row.status === "in_flight", + // Effective stats and the shop block (CR-009). + upgrades, + inShopUntil: row.inShopUntil, }; } @@ -226,7 +231,7 @@ export async function dispatchContract( const company = await requireCompany(tx, playerId); const proposal = { - ship: shipState(ship), + ship: shipState(ship, await shipUpgradeLevels(tx, ship.id)), pilot: pilotState(pilot), contract, tutorial: false, @@ -270,7 +275,7 @@ export async function dispatchReposition( .where(eq(discoveredLocations.playerId, playerId)); const proposal = { - ship: shipState(ship), + ship: shipState(ship, await shipUpgradeLevels(tx, ship.id)), pilot: pilotState(pilot), destinationId: input.destinationId, discoveredIds: new Set(discovered.map((d) => d.locationId)), diff --git a/apps/server/src/game/messages.ts b/apps/server/src/game/messages.ts index 0a43e04..d922969 100644 --- a/apps/server/src/game/messages.ts +++ b/apps/server/src/game/messages.ts @@ -1,4 +1,4 @@ -import type { DispatchFailure, RepositionFailure } from "@deadhead/sim"; +import type { DispatchFailure, RepositionFailure, UpgradeFailure } from "@deadhead/sim"; /** Player-facing text for each sim validation reason. */ export const VALIDATION_MESSAGES: Record = { @@ -19,3 +19,14 @@ export const VALIDATION_MESSAGES: Record = { + "tutorial-active": "Finish the company runs before upgrading ships.", + "not-upgradable": "The company loaner can't be upgraded.", + "ship-not-idle": "That ship is flying; upgrade it once it lands at an HQ.", + "ship-in-shop": "That ship is already in the shop.", + "not-at-hq": "Upgrades are installed at an HQ; bring the ship to one of your bays.", + "upgrade-maxed": "That upgrade is already at its highest level.", + "insufficient-funds": "Not enough credits for that upgrade.", +}; diff --git a/apps/server/src/game/pilots.ts b/apps/server/src/game/pilots.ts index c4ecbfa..96e8ee2 100644 --- a/apps/server/src/game/pilots.ts +++ b/apps/server/src/game/pilots.ts @@ -17,7 +17,11 @@ import { toApiPilot } from "./mappers"; import { VALIDATION_MESSAGES } from "./messages"; import { pilotName } from "./names"; -async function hasBayAt(tx: Queryable, playerId: string, locationId: string): Promise { +export async function hasBayAt( + tx: Queryable, + playerId: string, + locationId: string, +): Promise { const [bay] = await tx .select({ id: bays.id }) .from(bays) diff --git a/apps/server/src/game/upgrades.ts b/apps/server/src/game/upgrades.ts index 9cc8e3a..6673da1 100644 --- a/apps/server/src/game/upgrades.ts +++ b/apps/server/src/game/upgrades.ts @@ -1,7 +1,14 @@ -import type { UpgradeLevels } from "@deadhead/sim"; -import { inArray } from "drizzle-orm"; +import { content } from "@deadhead/content"; +import type { Ship } from "@deadhead/schema"; +import { validateUpgrade, type RealTime, type UpgradeLevels } from "@deadhead/sim"; +import { and, eq, inArray, max, sql } from "drizzle-orm"; import type { Queryable } from "../db/client"; -import { shipUpgrades } from "../db/schema"; +import { companies, dispatches, pilots, players, shipUpgrades, ships } from "../db/schema"; +import { gameError } from "../http/errors"; +import { shipState } from "./dispatch"; +import { toApiShip } from "./mappers"; +import { UPGRADE_MESSAGES } from "./messages"; +import { hasBayAt, requireOwnShip } from "./pilots"; /** Installed upgrade levels for each of the given ships (CR-009). */ export async function loadUpgradeLevels( @@ -23,3 +30,72 @@ export async function loadUpgradeLevels( export async function shipUpgradeLevels(tx: Queryable, shipId: string): Promise { return (await loadUpgradeLevels(tx, [shipId])).get(shipId) ?? {}; } + +/** + * Starts the next level of an upgrade on a ship (CR-009, decisions 0.5.64–0.5.66), + * inside the caller's player-locked transaction: validate, charge, raise the + * level now, and keep the ship in the shop until the work is done. + */ +export async function startUpgrade( + tx: Queryable, + playerId: string, + input: { shipId: string; upgradeId: string }, + now: RealTime, +): Promise { + const upgrade = content.upgrade(input.upgradeId); + if (!upgrade) throw gameError("not-found", `No upgrade "${input.upgradeId}".`); + const row = await requireOwnShip(tx, playerId, input.shipId); + const [player] = await tx.select().from(players).where(eq(players.id, playerId)); + const [company] = await tx.select().from(companies).where(eq(companies.playerId, playerId)); + if (!player || !company) throw new Error(`Player ${playerId} not found`); + + const levels = await shipUpgradeLevels(tx, row.id); + const check = validateUpgrade( + { + ship: shipState(row, levels), + upgrade, + atHq: row.locationId !== null && (await hasBayAt(tx, playerId, row.locationId)), + tutorialActive: player.tutorialActive, + credits: company.credits, + now, + }, + content, + ); + if (!check.ok) throw gameError(check.reason, UPGRADE_MESSAGES[check.reason]); + + await tx + .update(companies) + .set({ credits: sql`${companies.credits} - ${check.cost}` }) + .where(eq(companies.playerId, playerId)); + await tx + .insert(shipUpgrades) + .values({ shipId: row.id, upgradeId: upgrade.id, level: check.level }) + .onConflictDoUpdate({ + target: [shipUpgrades.shipId, shipUpgrades.upgradeId], + set: { level: check.level }, + }); + const [updated] = await tx + .update(ships) + .set({ inShopUntil: check.shopUntil, shopUpgradeId: upgrade.id, shopLevel: check.level }) + .where(eq(ships.id, row.id)) + .returning(); + + const [pilot] = await tx + .select({ id: pilots.id }) + .from(pilots) + .where(eq(pilots.assignedShipId, row.id)); + const [arrival] = await tx + .select({ at: max(dispatches.arrivalAt) }) + .from(dispatches) + .where(and(eq(dispatches.shipId, row.id), eq(dispatches.status, "resolved"))); + return toApiShip( + updated!, + { + dispatchId: null, + pilotId: pilot?.id ?? null, + lastArrivalAt: arrival?.at ?? row.createdAt, + upgrades: { ...levels, [upgrade.id]: check.level }, + }, + now, + ); +} diff --git a/apps/server/src/routes/fleet.ts b/apps/server/src/routes/fleet.ts index c5bf7de..592c7b4 100644 --- a/apps/server/src/routes/fleet.ts +++ b/apps/server/src/routes/fleet.ts @@ -2,6 +2,7 @@ import { routes } from "@deadhead/schema"; import type { FastifyInstance } from "fastify"; import { buyShip, leaseBay } from "../game/fleet"; import { assignPilot, hirePilot, restPilot, spendSkillPoint, unassignPilot } from "../game/pilots"; +import { startUpgrade } from "../game/upgrades"; import { registerRoute } from "../http/route"; import type { AppContext } from "./context"; import { withPlayer } from "./with-player"; @@ -38,6 +39,12 @@ export function registerFleetRoutes(app: FastifyInstance, ctx: AppContext): void })), ); + registerRoute(app, routes.startUpgrade, ctx.options, async (input, { playerId }) => + withPlayer(ctx, playerId, async (tx, now) => ({ + ship: await startUpgrade(tx, playerId, input, now), + })), + ); + registerRoute(app, routes.restPilot, ctx.options, async (input, { playerId }) => withPlayer(ctx, playerId, async (tx, now) => ({ pilot: await restPilot(tx, playerId, input.pilotId, now), diff --git a/apps/server/test/upgrades.test.ts b/apps/server/test/upgrades.test.ts new file mode 100644 index 0000000..36d7ce5 --- /dev/null +++ b/apps/server/test/upgrades.test.ts @@ -0,0 +1,163 @@ +import { content } from "@deadhead/content"; +import { + dispatchResponseSchema, + errorResponseSchema, + shipResponseSchema, + type PlayerState, +} from "@deadhead/schema"; +import { + effectiveStats, + MS_PER_HOUR, + realToGame, + routeDistance, + travelTimeGameHours, + upgradeCost, +} from "@deadhead/sim"; +import { describe, expect, it } from "vitest"; +import { setCredits, useTestDatabase } from "./db"; +import { + call, + getState, + insertDispatch, + putBoard, + signUpOwner, + testContract, + useTestApp, + type Guest, +} from "./game"; + +const database = useTestDatabase(); +const testApp = useTestApp(database); + +const errorCode = (response: { json(): unknown }) => + errorResponseSchema.parse(response.json()).error.code; +const freighterModel = content.shipModel("freighter")!; +const FAR_FUTURE = Date.parse("2200-01-01T00:00:00Z"); + +/** An owner whose avatar now crews a newly bought Freighter at the Earth HQ. */ +async function ownerWithFreighter() { + const { app } = testApp(); + const owner = await signUpOwner(app, database().db); + await setCredits(database().db, owner.playerId, freighterModel.price + 50_000); + const bought = await call(app, owner, "POST", "/api/ships", { + modelId: "freighter", + bayId: owner.earthBayId, + }); + const freighter = shipResponseSchema.parse(bought.json()).ship; + await call(app, owner, "POST", `/api/pilots/${owner.avatarId}/unassign`, {}); + await call(app, owner, "POST", `/api/pilots/${owner.avatarId}/assign`, { + shipId: freighter.id, + }); + return { owner, freighter }; +} + +const upgrade = (owner: Guest, shipId: string, upgradeId: string) => + call(testApp().app, owner, "POST", `/api/ships/${shipId}/upgrades`, { upgradeId }); +const shipIn = (state: PlayerState, id: string) => state.ships.find((s) => s.id === id)!; + +describe("starting an upgrade (Step 10I.5)", () => { + it("charges, raises the level, keeps the ship in the shop, then flies it faster", async () => { + const { app, clock } = testApp(); + const { owner, freighter } = await ownerWithFreighter(); + const before = (await getState(app, owner)).company.credits; + + const response = await upgrade(owner, freighter.id, "engines"); + expect(response.statusCode).toBe(200); + const until = clock.now + 10 * 60_000; + expect(shipResponseSchema.parse(response.json()).ship).toMatchObject({ + upgrades: { engines: 1 }, + inShopUntil: until, + shop: { upgradeId: "engines", level: 1, until }, + }); + const state = await getState(app, owner); + expect(state.company.credits).toBe(before - upgradeCost(freighterModel, 1, content.config)); + expect(shipIn(state, freighter.id).stats.cruiseSpeedKmPerGameHour).toBeCloseTo( + freighterModel.cruiseSpeedKmPerGameHour * 1.1, + ); + + const job = testContract({ massTonnes: 60, expiresAt: FAR_FUTURE }); + await putBoard(database().db, owner.playerId, [job], clock.now); + const dispatch = () => + call(app, owner, "POST", "/api/dispatches", { + kind: "contract", + contractId: job.id, + shipId: freighter.id, + pilotId: owner.avatarId, + }); + + const refused = await dispatch(); + expect(refused.statusCode).toBe(409); + expect(errorCode(refused)).toBe("ship-in-shop"); + + clock.now = until; + const flown = await dispatch(); + expect(flown.statusCode).toBe(200); + const { dispatch: trip } = dispatchResponseSchema.parse(flown.json()); + const distance = routeDistance( + content.location("earth")!, + content.location("moon")!, + realToGame(until, content.config), + ); + const hours = travelTimeGameHours( + effectiveStats(freighterModel, { engines: 1 }, content), + distance, + job.massTonnes, + content.config, + ); + expect(trip.arrivalAt - trip.departureAt).toBeCloseTo( + (hours * MS_PER_HOUR) / content.config.timeCompression, + -1, + ); + }); + + it("refuses away from an HQ with not-at-hq", async () => { + const { app, clock } = testApp(); + const owner = await signUpOwner(app, database().db); + await setCredits(database().db, owner.playerId, 50_000); + // Fly the Courier to the Moon, where the player has no bay. + const record = await insertDispatch(database().db, { + playerId: owner.playerId, + shipId: owner.shipId, + pilotId: owner.avatarId, + departAt: clock.now, + contract: testContract(), + }); + clock.now = record.arrivalAt; + const credits = (await getState(app, owner)).company.credits; + const response = await upgrade(owner, owner.shipId, "cargo-bay"); + expect(response.statusCode).toBe(409); + expect(errorCode(response)).toBe("not-at-hq"); + expect((await getState(app, owner)).company.credits).toBe(credits); + }); + + it("refuses an unknown upgrade with not-found", async () => { + const owner = await signUpOwner(testApp().app, database().db); + expect((await upgrade(owner, owner.shipId, "warp-drive")).statusCode).toBe(404); + }); + + it("makes exactly one of two simultaneous upgrades the player can afford only one of", async () => { + const { app } = testApp(); + const owner = await signUpOwner(app, database().db); + const courier = content.shipModel("courier")!; + await setCredits(database().db, owner.playerId, courier.price); + const second = shipResponseSchema.parse( + ( + await call(app, owner, "POST", "/api/ships", { + modelId: "courier", + bayId: owner.earthBayId, + }) + ).json(), + ).ship; + const cost = upgradeCost(courier, 1, content.config); + await setCredits(database().db, owner.playerId, cost + cost / 2); + + const responses = await Promise.all([ + upgrade(owner, owner.shipId, "engines"), + upgrade(owner, second.id, "engines"), + ]); + expect(responses.map((r) => r.statusCode).sort()).toEqual([200, 409]); + const state = await getState(app, owner); + expect(state.company.credits).toBe(cost / 2); + expect(state.ships.filter((s) => s.upgrades.engines === 1)).toHaveLength(1); + }); +}); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 776588a..6889658 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -230,7 +230,7 @@ Spec: `memory-bank/change-requests/CR-009-ship-upgrades.md` (received as "CR-006 - [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. - [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`. - [x] **Step 10I.4 — Schema and database.** Migration `0006_cr009_ship_upgrades`: `ship_upgrades` (ship, upgrade, level; PK ship+upgrade), and on `ships` `in_shop_until`, `shop_upgrade_id`, `shop_level`. API `Ship` gains `upgrades`, `stats` (effective), `inShopUntil`, `shop`; one server mapper `toApiShip` (player state and purchases). Route `startUpgrade` (`POST /api/ships/:shipId/upgrades`). Upgrade failure reasons join the API error codes. `acknowledgeSummaries` takes optional `shipIds`. Web test helper `shipExtras(modelId)`. Dev database migrated. -- [ ] Step 10I.5 — Server: start upgrade +- [x] **Step 10I.5 — Server: start upgrade.** `startUpgrade` (`game/upgrades.ts`) runs inside `withPlayer` (company row lock): sim `validateUpgrade` (at an HQ = a bay where the ship is), charge, upsert `ship_upgrades`, set in-shop-until and the shop job; returns the ship. Unknown upgrade → `not-found`. Dispatch and deadhead pass the ship's upgrades and in-shop time to the sim (effective stats, `ship-in-shop`); fleet-fitted boards use effective capacity. Test: `upgrades.test.ts` (engines on a Freighter: cost, level, shop time, refused then faster trip; `not-at-hq` at the Moon; two simultaneous upgrades → one). - [ ] Step 10I.6 — Client: upgrade shop - [ ] Step 10I.7 — Client: effective stats everywhere - [ ] Step 10I.8 — Client: shop in summaries and on HQ