Step 6.5: add buying a ship

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 01:08:28 -04:00
parent 6d31ed8d3c
commit a25fb3b5c5
7 changed files with 246 additions and 1 deletions

View File

@ -282,3 +282,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **`storeDispatch` is shared by every dispatch kind:** it inserts the dispatch, records the accepted contract, and deducts the record's fuel cost (0 for tutorial runs). - **`storeDispatch` is shared by every dispatch kind:** it inserts the dispatch, records the accepted contract, and deducts the record's fuel cost (0 for tutorial runs).
- **Sim validation reasons are returned as `409` with their own code** and a player-facing message from `src/game/messages.ts`. - **Sim validation reasons are returned as `409` with their own code** and a player-facing message from `src/game/messages.ts`.
- **`404 unknown-contract` and `404 unknown-skill`** join `not-found` as the codes that map to 404. - **`404 unknown-contract` and `404 unknown-skill`** join `not-found` as the codes that map to 404.
## Step 6.5
- **Ship names** are "Adjective Noun" from fixed word lists, picked by a generator seeded with `deriveSeed(worldSeed, "ship-name:<n>")`, where n is how many ships the player owned before this one. They are stable per world (decision 0.5.14) and are not unique; duplicates are acceptable. Word lists live in the server (`src/game/names.ts`) because they are not tuning numbers.
- **New ships always appear at Earth**, as the plan says, whatever the player's bays.
- **`spend()` checks and deducts credits inside the player lock**, so a failed purchase changes nothing (`409 insufficient-funds`). Unknown models return `404 not-found`.

View File

@ -8,6 +8,7 @@ import { requirePlayerId } from "./http/session";
import type { AppContext } from "./routes/context"; import type { AppContext } from "./routes/context";
import { registerBoardRoutes } from "./routes/boards"; import { registerBoardRoutes } from "./routes/boards";
import { registerDispatchRoutes } from "./routes/dispatches"; import { registerDispatchRoutes } from "./routes/dispatches";
import { registerFleetRoutes } from "./routes/fleet";
import { registerPlayerRoutes } from "./routes/player"; import { registerPlayerRoutes } from "./routes/player";
export interface AppDeps { export interface AppDeps {
@ -43,6 +44,7 @@ export function buildApp(config: ServerConfig, deps: AppDeps = {}): FastifyInsta
registerPlayerRoutes(app, ctx); registerPlayerRoutes(app, ctx);
registerBoardRoutes(app, ctx); registerBoardRoutes(app, ctx);
registerDispatchRoutes(app, ctx); registerDispatchRoutes(app, ctx);
registerFleetRoutes(app, ctx);
return app; return app;
} }

View File

@ -0,0 +1,71 @@
import { randomUUID } from "node:crypto";
import { content } from "@deadhead/content";
import type { Ship } from "@deadhead/schema";
import type { RealTime } from "@deadhead/sim";
import { count, eq, sql } from "drizzle-orm";
import type { Queryable } from "../db/client";
import { companies, players, ships } from "../db/schema";
import { gameError } from "../http/errors";
import { shipName } from "./names";
/** Where newly bought ships appear. */
export const SHIPYARD_LOCATION_ID = "earth";
export async function requireCompany(tx: Queryable, playerId: string) {
const [company] = await tx.select().from(companies).where(eq(companies.playerId, playerId));
if (!company) throw new Error(`Player ${playerId} has no company`);
return company;
}
export async function requirePlayer(tx: Queryable, playerId: string) {
const [player] = await tx.select().from(players).where(eq(players.id, playerId));
if (!player) throw new Error(`Player ${playerId} not found`);
return player;
}
/** Deducts credits, or throws `insufficient-funds` without changing anything. */
export async function spend(tx: Queryable, playerId: string, amount: number, what: string) {
const company = await requireCompany(tx, playerId);
if (company.credits < amount) {
throw gameError("insufficient-funds", `Not enough credits for ${what} (${amount} needed).`);
}
await tx
.update(companies)
.set({ credits: sql`${companies.credits} - ${amount}` })
.where(eq(companies.playerId, playerId));
}
/** Buys a ship model: it appears idle at Earth with no pilot (Step 6.5). */
export async function buyShip(
tx: Queryable,
playerId: string,
modelId: string,
now: RealTime,
): Promise<Ship> {
const model = content.shipModel(modelId);
if (!model) throw gameError("not-found", `No ship model "${modelId}".`);
await spend(tx, playerId, model.price, `a ${model.name}`);
const player = await requirePlayer(tx, playerId);
const [owned] = await tx.select({ n: count() }).from(ships).where(eq(ships.playerId, playerId));
const [ship] = await tx
.insert(ships)
.values({
id: randomUUID(),
playerId,
modelId,
name: shipName(player.worldSeed, owned!.n),
locationId: SHIPYARD_LOCATION_ID,
createdAt: now,
})
.returning();
return {
id: ship!.id,
modelId: ship!.modelId,
name: ship!.name,
status: "idle",
locationId: ship!.locationId,
dispatchId: null,
pilotId: null,
};
}

View File

@ -0,0 +1,86 @@
import { createRng, deriveSeed, type Seed } from "@deadhead/sim";
const SHIP_ADJECTIVES = [
"Dusty",
"Rusty",
"Lucky",
"Stubborn",
"Quiet",
"Restless",
"Patient",
"Wandering",
"Crooked",
"Faithful",
"Midnight",
"Copper",
"Iron",
"Silver",
"Old",
"Brave",
];
const SHIP_NOUNS = [
"Mule",
"Ox",
"Heron",
"Comet",
"Lantern",
"Drifter",
"Kettle",
"Anchor",
"Sparrow",
"Badger",
"Tortoise",
"Pilgrim",
"Barge",
"Nomad",
"Hauler",
"Wren",
];
const FIRST_NAMES = [
"Rae",
"Jonah",
"Mira",
"Tobias",
"Ines",
"Kofi",
"Lena",
"Arjun",
"Sasha",
"Dmitri",
"Yuki",
"Nadia",
"Emeka",
"Freya",
"Mateo",
"Priya",
];
const LAST_NAMES = [
"Okafor",
"Lindqvist",
"Moreau",
"Tanaka",
"Reyes",
"Novak",
"Haddad",
"Kowalski",
"Mbeki",
"Castillo",
"Sorensen",
"Iyer",
"Brennan",
"Vasquez",
"Kim",
"Abara",
];
/** The nth ship name in a world (decision 0.5.14): stable for a given seed and n. */
export function shipName(worldSeed: Seed, n: number): string {
const rng = createRng(deriveSeed(worldSeed, `ship-name:${n}`));
return `${rng.pick(SHIP_ADJECTIVES)} ${rng.pick(SHIP_NOUNS)}`;
}
/** The nth hired pilot's name in a world: stable for a given seed and n. */
export function pilotName(worldSeed: Seed, n: number): string {
const rng = createRng(deriveSeed(worldSeed, `pilot-name:${n}`));
return `${rng.pick(FIRST_NAMES)} ${rng.pick(LAST_NAMES)}`;
}

View File

@ -0,0 +1,15 @@
import { routes } from "@deadhead/schema";
import type { FastifyInstance } from "fastify";
import { buyShip } from "../game/fleet";
import { registerRoute } from "../http/route";
import type { AppContext } from "./context";
import { withPlayer } from "./with-player";
/** Ships, bays and pilots. */
export function registerFleetRoutes(app: FastifyInstance, ctx: AppContext): void {
registerRoute(app, routes.buyShip, ctx.options, async (input, { playerId }) =>
withPlayer(ctx, playerId, async (tx, now) => ({
ship: await buyShip(tx, playerId, input.modelId, now),
})),
);
}

View File

@ -0,0 +1,65 @@
import { content } from "@deadhead/content";
import { errorResponseSchema, shipResponseSchema } from "@deadhead/schema";
import { describe, expect, it } from "vitest";
import { shipName } from "../src/game/names";
import { setCredits, useTestDatabase } from "./db";
import { call, getState, signUp, useTestApp } from "./game";
const database = useTestDatabase();
const testApp = useTestApp(database);
describe("buy a ship", () => {
it("deducts the price and adds an idle, uncrewed ship at Earth", async () => {
const { app } = testApp();
const guest = await signUp(app);
const price = content.shipModel("courier")!.price;
await setCredits(database().db, guest.playerId, price + 500);
const response = await call(app, guest, "POST", "/api/ships", { modelId: "courier" });
expect(response.statusCode).toBe(200);
const { ship } = shipResponseSchema.parse(response.json());
expect(ship).toMatchObject({
modelId: "courier",
status: "idle",
locationId: "earth",
pilotId: null,
dispatchId: null,
});
expect(ship.name.length).toBeGreaterThan(0);
const state = await getState(app, guest);
expect(state.company.credits).toBe(500);
expect(state.ships.map((s) => s.id)).toEqual([ship.id]);
});
it("returns insufficient-funds and changes nothing without enough credits", async () => {
const { app } = testApp();
const guest = await signUp(app);
const price = content.shipModel("freighter")!.price;
await setCredits(database().db, guest.playerId, price - 1);
const response = await call(app, guest, "POST", "/api/ships", { modelId: "freighter" });
expect(response.statusCode).toBe(409);
expect(errorResponseSchema.parse(response.json()).error.code).toBe("insufficient-funds");
const state = await getState(app, guest);
expect(state.company.credits).toBe(price - 1);
expect(state.ships).toHaveLength(0);
});
it("rejects an unknown model", async () => {
const { app } = testApp();
const guest = await signUp(app);
const response = await call(app, guest, "POST", "/api/ships", { modelId: "x-wing" });
expect(response.statusCode).toBe(404);
expect(errorResponseSchema.parse(response.json()).error.code).toBe("not-found");
});
});
describe("ship names", () => {
it("are stable for a world seed and differ between ships", () => {
expect(shipName(42, 0)).toBe(shipName(42, 0));
const names = new Set(Array.from({ length: 10 }, (_, i) => shipName(42, i)));
expect(names.size).toBeGreaterThan(1);
});
});

View File

@ -58,7 +58,7 @@
- [x] **Step 6.2 — Get full player state.** Endpoint from Step 5.5 plus lazy resolution; test covers a player with one ship in flight and one idle (crewed by a hired pilot), checking ships, pilots (XP, level, skills, points, assignment, status, location), bays, dispatches, summaries and schema validity. - [x] **Step 6.2 — Get full player state.** Endpoint from Step 5.5 plus lazy resolution; test covers a player with one ship in flight and one idle (crewed by a hired pilot), checking ships, pilots (XP, level, skills, points, assignment, status, location), bays, dispatches, summaries and schema validity.
- [x] **Step 6.3 — Tutorial board endpoint.** `GET /api/tutorial/board` (`src/routes/boards.ts`, `src/game/boards.ts`): stored per (period, loaner location) on first request, accepted contracts hidden, `409 not-in-tutorial` after the tutorial. Every game route now runs through `withPlayer` (lock + lazy resolution). - [x] **Step 6.3 — Tutorial board endpoint.** `GET /api/tutorial/board` (`src/routes/boards.ts`, `src/game/boards.ts`): stored per (period, loaner location) on first request, accepted contracts hidden, `409 not-in-tutorial` after the tutorial. Every game route now runs through `withPlayer` (lock + lazy resolution).
- [x] **Step 6.4 — Tutorial dispatch.** `POST /api/dispatches` with `kind: "tutorial"` (`src/game/dispatch.ts`): avatar + loaner, sim validation, no fuel, one run at a time; contract marked accepted. Arrival pays the stored tutorial payout and moves loaner and avatar. - [x] **Step 6.4 — Tutorial dispatch.** `POST /api/dispatches` with `kind: "tutorial"` (`src/game/dispatch.ts`): avatar + loaner, sim validation, no fuel, one run at a time; contract marked accepted. Arrival pays the stored tutorial payout and moves loaner and avatar.
- [ ] Step 6.5 — Buy a ship - [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.
- [ ] Step 6.6 — Lease a bay - [ ] Step 6.6 — Lease a bay
- [ ] Step 6.7 — End tutorial - [ ] Step 6.7 — End tutorial
- [ ] Step 6.8 — Hire and assign pilots - [ ] Step 6.8 — Hire and assign pilots