Step 6.6: add leasing a bay

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 01:09:35 -04:00
parent a25fb3b5c5
commit 6a5a22a43d
5 changed files with 119 additions and 5 deletions

View File

@ -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:<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`.
## 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`).

View File

@ -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<Bay> {
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,

View File

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

View File

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

View File

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