Step 6.8: add hiring and assigning pilots

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 01:15:38 -04:00
parent badbac1890
commit f31d036f08
8 changed files with 377 additions and 26 deletions

View File

@ -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:<n>")`, where n counts the player's earlier hires.
- **`toApiPilot`** (`src/game/mappers.ts`) is shared by the state reader and the pilot endpoints.

View File

@ -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<S extends "idle" | "in_flight" | "resolved">(
@ -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,
};
}

View File

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

View File

@ -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<PlayerState> {
@ -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:

View File

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

View File

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

View File

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

View File

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