diff --git a/DECISIONS.md b/DECISIONS.md index 51169d5..4254c28 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -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). - **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. + +## Step 6.5 + +- **Ship names** are "Adjective Noun" from fixed word lists, picked by a generator seeded with `deriveSeed(worldSeed, "ship-name:")`, 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`. diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 72c0db1..53d7627 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -8,6 +8,7 @@ import { requirePlayerId } from "./http/session"; import type { AppContext } from "./routes/context"; import { registerBoardRoutes } from "./routes/boards"; import { registerDispatchRoutes } from "./routes/dispatches"; +import { registerFleetRoutes } from "./routes/fleet"; import { registerPlayerRoutes } from "./routes/player"; export interface AppDeps { @@ -43,6 +44,7 @@ export function buildApp(config: ServerConfig, deps: AppDeps = {}): FastifyInsta registerPlayerRoutes(app, ctx); registerBoardRoutes(app, ctx); registerDispatchRoutes(app, ctx); + registerFleetRoutes(app, ctx); return app; } diff --git a/apps/server/src/game/fleet.ts b/apps/server/src/game/fleet.ts new file mode 100644 index 0000000..a49be70 --- /dev/null +++ b/apps/server/src/game/fleet.ts @@ -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 { + 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, + }; +} diff --git a/apps/server/src/game/names.ts b/apps/server/src/game/names.ts new file mode 100644 index 0000000..54f2cc4 --- /dev/null +++ b/apps/server/src/game/names.ts @@ -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)}`; +} diff --git a/apps/server/src/routes/fleet.ts b/apps/server/src/routes/fleet.ts new file mode 100644 index 0000000..fc9a9f6 --- /dev/null +++ b/apps/server/src/routes/fleet.ts @@ -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), + })), + ); +} diff --git a/apps/server/test/buy-ship.test.ts b/apps/server/test/buy-ship.test.ts new file mode 100644 index 0000000..5fbc44f --- /dev/null +++ b/apps/server/test/buy-ship.test.ts @@ -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); + }); +}); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index e59c4ce..d2bd36b 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -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.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. -- [ ] 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.7 — End tutorial - [ ] Step 6.8 — Hire and assign pilots