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