Step 10I.5: start upgrades on the server
CI / check (push) Successful in 4m50s Details

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-04 19:31:37 -04:00
parent c352936a09
commit f81a8e41ef
9 changed files with 293 additions and 11 deletions

View File

@ -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`.

View File

@ -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<FleetMember[]> {
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) ?? {},
}));
}

View File

@ -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)),

View File

@ -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<DispatchFailure | RepositionFailure, string> = {
@ -19,3 +19,14 @@ export const VALIDATION_MESSAGES: Record<DispatchFailure | RepositionFailure, st
"already-there": "The ship is already there.",
"location-undiscovered": "You can only fly empty to places you have discovered.",
};
/** Player-facing text for each upgrade refusal (CR-009). */
export const UPGRADE_MESSAGES: Record<UpgradeFailure, string> = {
"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.",
};

View File

@ -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<boolean> {
export async function hasBayAt(
tx: Queryable,
playerId: string,
locationId: string,
): Promise<boolean> {
const [bay] = await tx
.select({ id: bays.id })
.from(bays)

View File

@ -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<UpgradeLevels> {
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<Ship> {
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,
);
}

View File

@ -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),

View File

@ -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);
});
});

View File

@ -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