diff --git a/DECISIONS.md b/DECISIONS.md index 4254c28..843d82a 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -288,3 +288,8 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **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`. + +## Step 6.6 + +- **A bay lease is a one-time payment** of the location's `bayLeasePrice`, as the plan words it ("one-time lease price"); there is no recurring rent in the base game. +- **Check order:** location exists (`404 not-found`), discovered (`409 location-undiscovered`), no bay there yet (`409 bay-already-leased`), affordable (`409 insufficient-funds`). diff --git a/apps/server/src/game/fleet.ts b/apps/server/src/game/fleet.ts index a49be70..ccbf2b6 100644 --- a/apps/server/src/game/fleet.ts +++ b/apps/server/src/game/fleet.ts @@ -1,10 +1,10 @@ import { randomUUID } from "node:crypto"; import { content } from "@deadhead/content"; -import type { Ship } from "@deadhead/schema"; +import type { Bay, Ship } from "@deadhead/schema"; import type { RealTime } from "@deadhead/sim"; -import { count, eq, sql } from "drizzle-orm"; +import { and, count, eq, sql } from "drizzle-orm"; import type { Queryable } from "../db/client"; -import { companies, players, ships } from "../db/schema"; +import { bays, companies, discoveredLocations, players, ships } from "../db/schema"; import { gameError } from "../http/errors"; import { shipName } from "./names"; @@ -35,6 +35,46 @@ export async function spend(tx: Queryable, playerId: string, amount: number, wha .where(eq(companies.playerId, playerId)); } +/** Leases a bay at a discovered location for its one-time price (Step 6.6). */ +export async function leaseBay( + tx: Queryable, + playerId: string, + locationId: string, + now: RealTime, +): Promise { + const location = content.location(locationId); + if (!location) throw gameError("not-found", `No location "${locationId}".`); + + const [discovered] = await tx + .select() + .from(discoveredLocations) + .where( + and( + eq(discoveredLocations.playerId, playerId), + eq(discoveredLocations.locationId, locationId), + ), + ); + if (!discovered) { + throw gameError( + "location-undiscovered", + `Deliver to ${location.name} before leasing a bay there.`, + ); + } + const [existing] = await tx + .select() + .from(bays) + .where(and(eq(bays.playerId, playerId), eq(bays.locationId, locationId))); + if (existing) + throw gameError("bay-already-leased", `You already have a bay at ${location.name}.`); + + await spend(tx, playerId, location.bayLeasePrice, `a bay at ${location.name}`); + const [bay] = await tx + .insert(bays) + .values({ id: randomUUID(), playerId, locationId, createdAt: now }) + .returning(); + return { id: bay!.id, locationId: bay!.locationId }; +} + /** Buys a ship model: it appears idle at Earth with no pilot (Step 6.5). */ export async function buyShip( tx: Queryable, diff --git a/apps/server/src/routes/fleet.ts b/apps/server/src/routes/fleet.ts index fc9a9f6..5bf204f 100644 --- a/apps/server/src/routes/fleet.ts +++ b/apps/server/src/routes/fleet.ts @@ -1,6 +1,6 @@ import { routes } from "@deadhead/schema"; import type { FastifyInstance } from "fastify"; -import { buyShip } from "../game/fleet"; +import { buyShip, leaseBay } from "../game/fleet"; import { registerRoute } from "../http/route"; import type { AppContext } from "./context"; import { withPlayer } from "./with-player"; @@ -12,4 +12,10 @@ export function registerFleetRoutes(app: FastifyInstance, ctx: AppContext): void ship: await buyShip(tx, playerId, input.modelId, now), })), ); + + registerRoute(app, routes.leaseBay, ctx.options, async (input, { playerId }) => + withPlayer(ctx, playerId, async (tx, now) => ({ + bay: await leaseBay(tx, playerId, input.locationId, now), + })), + ); } diff --git a/apps/server/test/lease-bay.test.ts b/apps/server/test/lease-bay.test.ts new file mode 100644 index 0000000..d502b8c --- /dev/null +++ b/apps/server/test/lease-bay.test.ts @@ -0,0 +1,63 @@ +import { content } from "@deadhead/content"; +import { bayResponseSchema, errorResponseSchema } from "@deadhead/schema"; +import { describe, expect, it } from "vitest"; +import { setCredits, useTestDatabase } from "./db"; +import { call, getState, signUp, useTestApp } from "./game"; + +const database = useTestDatabase(); +const testApp = useTestApp(database); + +const errorCode = (response: { json(): unknown }) => + errorResponseSchema.parse(response.json()).error.code; + +describe("lease a bay", () => { + it("leases a bay at Earth and deducts the price", async () => { + const { app } = testApp(); + const guest = await signUp(app); + const price = content.location("earth")!.bayLeasePrice; + await setCredits(database().db, guest.playerId, price + 100); + + const response = await call(app, guest, "POST", "/api/bays", { locationId: "earth" }); + expect(response.statusCode).toBe(200); + const { bay } = bayResponseSchema.parse(response.json()); + expect(bay.locationId).toBe("earth"); + + const state = await getState(app, guest); + expect(state.company.credits).toBe(100); + expect(state.bays).toEqual([bay]); + }); + + it("refuses an undiscovered location", async () => { + const { app } = testApp(); + const guest = await signUp(app); + await setCredits(database().db, guest.playerId, 1_000_000); + const response = await call(app, guest, "POST", "/api/bays", { locationId: "mars" }); + expect(response.statusCode).toBe(409); + expect(errorCode(response)).toBe("location-undiscovered"); + expect((await getState(app, guest)).company.credits).toBe(1_000_000); + }); + + it("refuses a second bay at the same location", async () => { + const { app } = testApp(); + const guest = await signUp(app); + await setCredits(database().db, guest.playerId, 1_000_000); + await call(app, guest, "POST", "/api/bays", { locationId: "moon" }); + const response = await call(app, guest, "POST", "/api/bays", { locationId: "moon" }); + expect(response.statusCode).toBe(409); + expect(errorCode(response)).toBe("bay-already-leased"); + const state = await getState(app, guest); + expect(state.bays).toHaveLength(1); + expect(state.company.credits).toBe(1_000_000 - content.location("moon")!.bayLeasePrice); + }); + + it("refuses when the player cannot afford it, and unknown locations", async () => { + const { app } = testApp(); + const guest = await signUp(app); + await setCredits(database().db, guest.playerId, 0); + expect(errorCode(await call(app, guest, "POST", "/api/bays", { locationId: "earth" }))).toBe( + "insufficient-funds", + ); + const unknown = await call(app, guest, "POST", "/api/bays", { locationId: "pluto" }); + expect(unknown.statusCode).toBe(404); + }); +}); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index d2bd36b..1cba761 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -59,7 +59,7 @@ - [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.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 +- [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`. - [ ] Step 6.7 — End tutorial - [ ] Step 6.8 — Hire and assign pilots - [ ] Step 6.9 — Bay contract board endpoint