From f31d036f08116c6d2e0993346fb33d5c5e7f0dc1 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sat, 3 Oct 2026 01:15:38 -0400 Subject: [PATCH] Step 6.8: add hiring and assigning pilots Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 8 ++ apps/server/src/game/mappers.ts | 34 +++++- apps/server/src/game/pilots.ts | 140 ++++++++++++++++++++++ apps/server/src/game/player-state.ts | 27 +---- apps/server/src/routes/fleet.ts | 19 +++ apps/server/test/pilots.test.ts | 172 +++++++++++++++++++++++++++ memory-bank/progress.md | 2 +- packages/schema/src/errors.ts | 1 + 8 files changed, 377 insertions(+), 26 deletions(-) create mode 100644 apps/server/src/game/pilots.ts create mode 100644 apps/server/test/pilots.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index f6b3a8e..ccd71f3 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -299,3 +299,11 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **The end-of-tutorial check runs in two places:** at the end of every resolution pass that resolved something (which also covers Phase 7's arrival job), and after every action in `withPlayer`. Buying the ship, leasing the Earth bay, or the last tutorial run landing can each be the trigger. - **"Removing the loaner"** means clearing `players.loaner_location_id` and `tutorial_active`; the loaner never had a ship row. - **The avatar is moved to Earth** as an idle, unassigned pilot, wherever the loaner was (decision 0.5.4). + +## Step 6.8 + +- **New server error code `avatar-in-tutorial`** for assigning the avatar to an owned ship before the tutorial ends (decision 0.5.23). +- **Assignment checks, in order:** pilot and ship belong to the player (`404 not-found`), avatar not in tutorial, pilot idle (`pilot-not-idle`), pilot unassigned (`pilot-already-assigned`), ship idle (`ship-not-idle`), same location (`ship-not-idle-here`), ship uncrewed (`ship-crewed`). The unique `assigned_ship_id` constraint backs up the one-pilot-per-ship rule. +- **Unassigning** needs an assigned, idle pilot (`pilot-not-assigned-to-ship`, `pilot-not-idle`) whose ship is at a player bay (`no-bay-at-location`). The pilot then stores the ship's location (decision 0.5.27). +- **Hired pilot names** are "First Last" from fixed lists, seeded with `deriveSeed(worldSeed, "pilot-name:")`, where n counts the player's earlier hires. +- **`toApiPilot`** (`src/game/mappers.ts`) is shared by the state reader and the pilot endpoints. diff --git a/apps/server/src/game/mappers.ts b/apps/server/src/game/mappers.ts index efdeda2..a99eb38 100644 --- a/apps/server/src/game/mappers.ts +++ b/apps/server/src/game/mappers.ts @@ -1,5 +1,7 @@ -import type { Dispatch } from "@deadhead/schema"; -import type { dispatches } from "../db/schema"; +import { content } from "@deadhead/content"; +import type { Dispatch, Pilot } from "@deadhead/schema"; +import { pilotLevel, skillPointsAvailable } from "@deadhead/sim"; +import type { dispatches, pilots } from "../db/schema"; /** Database status values use `in_flight`; the API uses `in-flight`. */ export function toApiStatus( @@ -15,3 +17,31 @@ export function toApiDispatch(row: typeof dispatches.$inferSelect): Dispatch { resolvedAt: row.resolvedAt, }; } + +/** + * API view of a pilot. An assigned pilot is wherever its ship is + * (`assignedShipLocationId`); an unassigned pilot stores its own location. + */ +export function toApiPilot( + row: typeof pilots.$inferSelect, + assignedShipLocationId: string | null, +): Pilot { + const level = pilotLevel(row.xp, content.config); + return { + id: row.id, + name: row.name, + isAvatar: row.isAvatar, + xp: row.xp, + level, + skills: row.skills, + skillPointsAvailable: skillPointsAvailable(level, row.skillPointsSpent, content.config), + assignedShipId: row.assignedShipId, + status: toApiStatus(row.status), + locationId: + row.status === "in_flight" + ? null + : row.assignedShipId + ? assignedShipLocationId + : row.locationId, + }; +} diff --git a/apps/server/src/game/pilots.ts b/apps/server/src/game/pilots.ts new file mode 100644 index 0000000..d5d33d1 --- /dev/null +++ b/apps/server/src/game/pilots.ts @@ -0,0 +1,140 @@ +import { randomUUID } from "node:crypto"; +import { content } from "@deadhead/content"; +import type { Pilot } from "@deadhead/schema"; +import type { RealTime } from "@deadhead/sim"; +import { and, count, eq } from "drizzle-orm"; +import type { Queryable } from "../db/client"; +import { bays, pilots, ships } from "../db/schema"; +import { gameError } from "../http/errors"; +import { requirePlayer, spend } from "./fleet"; +import { toApiPilot } from "./mappers"; +import { VALIDATION_MESSAGES } from "./messages"; +import { pilotName } from "./names"; + +async function hasBayAt(tx: Queryable, playerId: string, locationId: string): Promise { + const [bay] = await tx + .select({ id: bays.id }) + .from(bays) + .where(and(eq(bays.playerId, playerId), eq(bays.locationId, locationId))); + return bay !== undefined; +} + +export async function requireOwnPilot(tx: Queryable, playerId: string, pilotId: string) { + const [pilot] = await tx + .select() + .from(pilots) + .where(and(eq(pilots.id, pilotId), eq(pilots.playerId, playerId))); + if (!pilot) throw gameError("not-found", "No such pilot."); + return pilot; +} + +export async function requireOwnShip(tx: Queryable, playerId: string, shipId: string) { + const [ship] = await tx + .select() + .from(ships) + .where(and(eq(ships.id, shipId), eq(ships.playerId, playerId))); + if (!ship) throw gameError("not-found", "No such ship."); + return ship; +} + +/** Hires a level-0 pilot at one of the player's bays (Step 6.8, decision 0.5.22). */ +export async function hirePilot( + tx: Queryable, + playerId: string, + locationId: string, + now: RealTime, +): Promise { + if (!(await hasBayAt(tx, playerId, locationId))) { + throw gameError("no-bay-at-location", "Pilots can only be hired at one of your bays."); + } + await spend(tx, playerId, content.config.pilotHireCost, "a new pilot"); + + const player = await requirePlayer(tx, playerId); + const [hired] = await tx + .select({ n: count() }) + .from(pilots) + .where(and(eq(pilots.playerId, playerId), eq(pilots.isAvatar, false))); + const [pilot] = await tx + .insert(pilots) + .values({ + id: randomUUID(), + playerId, + name: pilotName(player.worldSeed, hired!.n), + locationId, + createdAt: now, + }) + .returning(); + return toApiPilot(pilot!, null); +} + +/** + * Assigns an idle, unassigned pilot to an idle, uncrewed ship at the same + * location. The avatar is locked to the loaner during the tutorial (decision 0.5.23). + */ +export async function assignPilot( + tx: Queryable, + playerId: string, + pilotId: string, + shipId: string, +): Promise { + const pilot = await requireOwnPilot(tx, playerId, pilotId); + const ship = await requireOwnShip(tx, playerId, shipId); + const player = await requirePlayer(tx, playerId); + + if (pilot.isAvatar && player.tutorialActive) { + throw gameError("avatar-in-tutorial", "You fly the company loaner until the tutorial ends."); + } + if (pilot.status === "in_flight") { + throw gameError("pilot-not-idle", VALIDATION_MESSAGES["pilot-not-idle"]); + } + if (pilot.assignedShipId !== null) { + throw gameError( + "pilot-already-assigned", + "That pilot already has a ship. Unassign them first.", + ); + } + if (ship.status === "in_flight") { + throw gameError("ship-not-idle", VALIDATION_MESSAGES["ship-not-idle"]); + } + if (ship.locationId !== pilot.locationId) { + throw gameError("ship-not-idle-here", "The pilot and the ship must be at the same location."); + } + const [crew] = await tx + .select({ id: pilots.id }) + .from(pilots) + .where(eq(pilots.assignedShipId, shipId)); + if (crew) throw gameError("ship-crewed", "That ship already has a pilot."); + + const [updated] = await tx + .update(pilots) + .set({ assignedShipId: shipId, locationId: null }) + .where(eq(pilots.id, pilotId)) + .returning(); + return toApiPilot(updated!, ship.locationId); +} + +/** Unassigns a pilot from its ship; only allowed where the player has a bay (decision 0.5.2). */ +export async function unassignPilot( + tx: Queryable, + playerId: string, + pilotId: string, +): Promise { + const pilot = await requireOwnPilot(tx, playerId, pilotId); + if (pilot.assignedShipId === null) { + throw gameError("pilot-not-assigned-to-ship", "That pilot has no ship."); + } + if (pilot.status === "in_flight") { + throw gameError("pilot-not-idle", VALIDATION_MESSAGES["pilot-not-idle"]); + } + const ship = await requireOwnShip(tx, playerId, pilot.assignedShipId); + if (ship.locationId === null || !(await hasBayAt(tx, playerId, ship.locationId))) { + throw gameError("no-bay-at-location", "Pilots can only leave their ship at one of your bays."); + } + + const [updated] = await tx + .update(pilots) + .set({ assignedShipId: null, locationId: ship.locationId }) + .where(eq(pilots.id, pilotId)) + .returning(); + return toApiPilot(updated!, null); +} diff --git a/apps/server/src/game/player-state.ts b/apps/server/src/game/player-state.ts index d0a4fbf..2978760 100644 --- a/apps/server/src/game/player-state.ts +++ b/apps/server/src/game/player-state.ts @@ -1,6 +1,5 @@ import { content } from "@deadhead/content"; import type { PlayerState } from "@deadhead/schema"; -import { pilotLevel, skillPointsAvailable } from "@deadhead/sim"; import { and, eq, isNull } from "drizzle-orm"; import type { Queryable } from "../db/client"; import { @@ -12,7 +11,7 @@ import { players, ships, } from "../db/schema"; -import { toApiDispatch, toApiStatus } from "./mappers"; +import { toApiDispatch, toApiPilot, toApiStatus } from "./mappers"; /** Reads everything the client needs about one player. */ export async function loadPlayerState(db: Queryable, playerId: string): Promise { @@ -60,27 +59,9 @@ export async function loadPlayerState(db: Queryable, playerId: string): Promise< dispatchId: activeByShip.get(s.id) ?? null, pilotId: pilotByShip.get(s.id) ?? null, })), - pilots: pilotRows.map((p) => { - const level = pilotLevel(p.xp, content.config); - const locationId = - p.status === "in_flight" - ? null - : p.assignedShipId - ? (shipById.get(p.assignedShipId)?.locationId ?? null) - : p.locationId; - return { - id: p.id, - name: p.name, - isAvatar: p.isAvatar, - xp: p.xp, - level, - skills: p.skills, - skillPointsAvailable: skillPointsAvailable(level, p.skillPointsSpent, content.config), - assignedShipId: p.assignedShipId, - status: toApiStatus(p.status), - locationId, - }; - }), + pilots: pilotRows.map((p) => + toApiPilot(p, p.assignedShipId ? (shipById.get(p.assignedShipId)?.locationId ?? null) : null), + ), bays: bayRows.map((b) => ({ id: b.id, locationId: b.locationId })), locations: content.locations.map((l) => ({ id: l.id, discovered: discoveredIds.has(l.id) })), tutorial: diff --git a/apps/server/src/routes/fleet.ts b/apps/server/src/routes/fleet.ts index 5bf204f..eed0ee3 100644 --- a/apps/server/src/routes/fleet.ts +++ b/apps/server/src/routes/fleet.ts @@ -1,6 +1,7 @@ import { routes } from "@deadhead/schema"; import type { FastifyInstance } from "fastify"; import { buyShip, leaseBay } from "../game/fleet"; +import { assignPilot, hirePilot, unassignPilot } from "../game/pilots"; import { registerRoute } from "../http/route"; import type { AppContext } from "./context"; import { withPlayer } from "./with-player"; @@ -18,4 +19,22 @@ export function registerFleetRoutes(app: FastifyInstance, ctx: AppContext): void bay: await leaseBay(tx, playerId, input.locationId, now), })), ); + + registerRoute(app, routes.hirePilot, ctx.options, async (input, { playerId }) => + withPlayer(ctx, playerId, async (tx, now) => ({ + pilot: await hirePilot(tx, playerId, input.locationId, now), + })), + ); + + registerRoute(app, routes.assignPilot, ctx.options, async (input, { playerId }) => + withPlayer(ctx, playerId, async (tx) => ({ + pilot: await assignPilot(tx, playerId, input.pilotId, input.shipId), + })), + ); + + registerRoute(app, routes.unassignPilot, ctx.options, async (input, { playerId }) => + withPlayer(ctx, playerId, async (tx) => ({ + pilot: await unassignPilot(tx, playerId, input.pilotId), + })), + ); } diff --git a/apps/server/test/pilots.test.ts b/apps/server/test/pilots.test.ts new file mode 100644 index 0000000..b5133bc --- /dev/null +++ b/apps/server/test/pilots.test.ts @@ -0,0 +1,172 @@ +import { randomUUID } from "node:crypto"; +import { content } from "@deadhead/content"; +import { errorResponseSchema, pilotResponseSchema } from "@deadhead/schema"; +import { eq } from "drizzle-orm"; +import { describe, expect, it } from "vitest"; +import { bays, ships } from "../src/db/schema"; +import { makeOwner, setCredits, TEST_NOW, useTestDatabase } from "./db"; +import { call, getState, insertDispatch, signUp, useTestApp, type Guest } from "./game"; + +const database = useTestDatabase(); +const testApp = useTestApp(database); + +const errorCode = (response: { json(): unknown }) => + errorResponseSchema.parse(response.json()).error.code; + +/** An owner with a Moon bay, plenty of credits, and an extra uncrewed Courier at the Moon. */ +async function ownerWithMoonBay() { + const { app } = testApp(); + const { db } = database(); + const guest = await signUp(app); + const earthShipId = await makeOwner(db, guest.playerId, guest.avatarId); + await setCredits(db, guest.playerId, 100_000); + await db.insert(bays).values({ + id: randomUUID(), + playerId: guest.playerId, + locationId: "moon", + createdAt: TEST_NOW, + }); + const moonShipId = randomUUID(); + await db.insert(ships).values({ + id: moonShipId, + playerId: guest.playerId, + modelId: "courier", + name: "Moon Hopper", + locationId: "moon", + createdAt: TEST_NOW, + }); + return { guest, earthShipId, moonShipId }; +} + +async function hire(guest: Guest, locationId: string) { + return call(testApp().app, guest, "POST", "/api/pilots", { locationId }); +} + +describe("hiring pilots", () => { + it("hires a level-0 pilot at the Moon bay and deducts the fee", async () => { + const { app } = testApp(); + const { guest } = await ownerWithMoonBay(); + const response = await hire(guest, "moon"); + expect(response.statusCode).toBe(200); + const { pilot } = pilotResponseSchema.parse(response.json()); + expect(pilot).toMatchObject({ + isAvatar: false, + level: 0, + xp: 0, + locationId: "moon", + assignedShipId: null, + status: "idle", + }); + expect(pilot.name.length).toBeGreaterThan(0); + expect((await getState(app, guest)).company.credits).toBe( + 100_000 - content.config.pilotHireCost, + ); + }); + + it("refuses to hire where the player has no bay", async () => { + const { guest } = await ownerWithMoonBay(); + const response = await hire(guest, "mars"); + expect(response.statusCode).toBe(409); + expect(errorCode(response)).toBe("no-bay-at-location"); + }); + + it("refuses to hire without enough credits", async () => { + const { guest } = await ownerWithMoonBay(); + await setCredits(database().db, guest.playerId, content.config.pilotHireCost - 1); + expect(errorCode(await hire(guest, "moon"))).toBe("insufficient-funds"); + }); +}); + +describe("assigning pilots", () => { + it("assigns a pilot to an idle, uncrewed ship at the same location", async () => { + const { app } = testApp(); + const { guest, moonShipId } = await ownerWithMoonBay(); + const { pilot } = pilotResponseSchema.parse((await hire(guest, "moon")).json()); + + const response = await call(app, guest, "POST", `/api/pilots/${pilot.id}/assign`, { + shipId: moonShipId, + }); + expect(response.statusCode).toBe(200); + expect(pilotResponseSchema.parse(response.json()).pilot).toMatchObject({ + assignedShipId: moonShipId, + locationId: "moon", + }); + const state = await getState(app, guest); + expect(state.ships.find((s) => s.id === moonShipId)!.pilotId).toBe(pilot.id); + }); + + it("refuses a ship at another location", async () => { + const { app } = testApp(); + const { guest, moonShipId } = await ownerWithMoonBay(); + const { pilot } = pilotResponseSchema.parse((await hire(guest, "earth")).json()); + const response = await call(app, guest, "POST", `/api/pilots/${pilot.id}/assign`, { + shipId: moonShipId, + }); + expect(errorCode(response)).toBe("ship-not-idle-here"); + }); + + it("refuses a crewed ship", async () => { + const { app } = testApp(); + const { guest, earthShipId } = await ownerWithMoonBay(); + const { pilot } = pilotResponseSchema.parse((await hire(guest, "earth")).json()); + const response = await call(app, guest, "POST", `/api/pilots/${pilot.id}/assign`, { + shipId: earthShipId, + }); + expect(errorCode(response)).toBe("ship-crewed"); + }); + + it("refuses while the ship is in flight", async () => { + const { app, clock } = testApp(); + const { guest, earthShipId } = await ownerWithMoonBay(); + const { pilot } = pilotResponseSchema.parse((await hire(guest, "earth")).json()); + await insertDispatch(database().db, { + playerId: guest.playerId, + shipId: earthShipId, + pilotId: guest.avatarId, + departAt: clock.now, + }); + const response = await call(app, guest, "POST", `/api/pilots/${pilot.id}/assign`, { + shipId: earthShipId, + }); + expect(errorCode(response)).toBe("ship-not-idle"); + }); + + it("refuses to assign the avatar during the tutorial", async () => { + const { app } = testApp(); + const guest = await signUp(app); + await setCredits(database().db, guest.playerId, 100_000); + const ship = (await call(app, guest, "POST", "/api/ships", { modelId: "courier" })).json() as { + ship: { id: string }; + }; + const response = await call(app, guest, "POST", `/api/pilots/${guest.avatarId}/assign`, { + shipId: ship.ship.id, + }); + expect(errorCode(response)).toBe("avatar-in-tutorial"); + }); +}); + +describe("unassigning pilots", () => { + it("unassigns at a bay, leaving the pilot at the ship's location", async () => { + const { app } = testApp(); + const { guest, earthShipId } = await ownerWithMoonBay(); + const response = await call(app, guest, "POST", `/api/pilots/${guest.avatarId}/unassign`); + expect(response.statusCode).toBe(200); + expect(pilotResponseSchema.parse(response.json()).pilot).toMatchObject({ + assignedShipId: null, + locationId: "earth", + }); + const state = await getState(app, guest); + expect(state.ships.find((s) => s.id === earthShipId)!.pilotId).toBeNull(); + }); + + it("refuses where the player has no bay", async () => { + const { app } = testApp(); + const { guest, earthShipId } = await ownerWithMoonBay(); + // Park the crewed ship at Mars, where there is no bay. + const { db } = database(); + await db.update(ships).set({ locationId: "mars" }).where(eq(ships.id, earthShipId)); + const response = await call(app, guest, "POST", `/api/pilots/${guest.avatarId}/unassign`); + expect(response.statusCode).toBe(409); + expect(errorCode(response)).toBe("no-bay-at-location"); + }); +}); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index aca290e..5146cce 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -61,7 +61,7 @@ - [x] **Step 6.5 — Buy a ship.** `POST /api/ships` (`src/game/fleet.ts`, `src/routes/fleet.ts`): deducts the price, adds an idle uncrewed ship at Earth named from the world seed (`src/game/names.ts`); `409 insufficient-funds` changes nothing. - [x] **Step 6.6 — Lease a bay.** `POST /api/bays` (`leaseBay` in `src/game/fleet.ts`): one-time lease price at a discovered location; `409 location-undiscovered`, `409 bay-already-leased`, `409 insufficient-funds`, `404 not-found`. - [x] **Step 6.7 — End tutorial.** `maybeEndTutorial` (`src/game/tutorial.ts`) runs after each resolution pass and after every game action: with a ship, an Earth bay and no tutorial run in flight, tutorial mode turns off, the loaner is removed, and the avatar becomes an idle, unassigned pilot at Earth. -- [ ] Step 6.8 — Hire and assign pilots +- [x] **Step 6.8 — Hire and assign pilots.** `POST /api/pilots`, `/api/pilots/:pilotId/assign`, `/api/pilots/:pilotId/unassign` (`src/game/pilots.ts`): hire at a bay for the fee with a seeded name; assign to an idle, uncrewed ship at the same location; unassign only at a bay. New error code `avatar-in-tutorial`. - [ ] Step 6.9 — Bay contract board endpoint - [ ] Step 6.10 — Contract dispatch - [ ] Step 6.11 — Reposition dispatch diff --git a/packages/schema/src/errors.ts b/packages/schema/src/errors.ts index c56bed4..6897e60 100644 --- a/packages/schema/src/errors.ts +++ b/packages/schema/src/errors.ts @@ -27,6 +27,7 @@ export const SERVER_ERROR_CODES = [ "no-skill-points", "skill-maxed", "unknown-skill", + "avatar-in-tutorial", ] as const; export type ErrorCode = DispatchFailure | RepositionFailure | (typeof SERVER_ERROR_CODES)[number];