Step 6.6: add leasing a bay
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
a25fb3b5c5
commit
6a5a22a43d
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue