diff --git a/DECISIONS.md b/DECISIONS.md index 30f763e..51169d5 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -273,3 +273,12 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Boards are generated and stored on first request** with `INSERT ... ON CONFLICT DO NOTHING` on the unique (player, kind, location, period) key, then re-read, so concurrent first requests end up with one stored board. - **Board responses hide accepted contracts** (`accepted_contracts`) and report `periodEndsAt`, when every contract on the board expires. - **Tutorial board errors:** `409 not-in-tutorial` once the tutorial is over. + +## Step 6.4 + +- **A tutorial contract must be on one of the player's stored tutorial boards** (found by a JSONB containment query on contract id). Unknown ids return `404 unknown-contract`; ids already in `accepted_contracts` return `409 contract-already-accepted`. A contract from an earlier period is found, and sim then rejects it as `contract-expired`. +- **The loaner's `ShipState`** is built from `players.loaner_location_id` with id `loaner` and the content Courier model. The dispatch row has `ship_id` null. +- **While flying, the avatar's stored location is null**; resolution sets it to the destination, along with the loaner's location. +- **`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. diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 5a0c071..72c0db1 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -7,6 +7,7 @@ import { registerErrorHandler } from "./http/errors"; import { requirePlayerId } from "./http/session"; import type { AppContext } from "./routes/context"; import { registerBoardRoutes } from "./routes/boards"; +import { registerDispatchRoutes } from "./routes/dispatches"; import { registerPlayerRoutes } from "./routes/player"; export interface AppDeps { @@ -41,6 +42,7 @@ export function buildApp(config: ServerConfig, deps: AppDeps = {}): FastifyInsta app.get("/api/health", async () => ({ status: "ok" })); registerPlayerRoutes(app, ctx); registerBoardRoutes(app, ctx); + registerDispatchRoutes(app, ctx); return app; } diff --git a/apps/server/src/game/dispatch.ts b/apps/server/src/game/dispatch.ts new file mode 100644 index 0000000..ca11ffc --- /dev/null +++ b/apps/server/src/game/dispatch.ts @@ -0,0 +1,187 @@ +import { randomUUID } from "node:crypto"; +import { content } from "@deadhead/content"; +import type { Dispatch } from "@deadhead/schema"; +import { + createDispatchRecord, + loanerModel, + validateDispatch, + type Contract, + type DispatchRecord, + type PilotState, + type RealTime, + type ShipState, +} from "@deadhead/sim"; +import { and, eq, sql } from "drizzle-orm"; +import type { Queryable } from "../db/client"; +import { + acceptedContracts, + companies, + contractBoards, + dispatches, + pilots, + players, +} from "../db/schema"; +import { gameError } from "../http/errors"; +import { acceptedContractIds } from "./boards"; +import { toApiDispatch } from "./mappers"; +import { VALIDATION_MESSAGES } from "./messages"; + +/** The id the tutorial loaner uses in dispatch records (it is not a ship row). */ +export const LOANER_SHIP_ID = "loaner"; + +/** Finds a contract on any of the player's stored boards of a kind. */ +export async function findBoardContract( + tx: Queryable, + playerId: string, + kind: "bay" | "tutorial", + contractId: string, +): Promise<{ contract: Contract; periodIndex: number } | undefined> { + const [board] = await tx + .select() + .from(contractBoards) + .where( + and( + eq(contractBoards.playerId, playerId), + eq(contractBoards.kind, kind), + sql`${contractBoards.contracts} @> ${JSON.stringify([{ id: contractId }])}::jsonb`, + ), + ); + const contract = board?.contracts.find((c) => c.id === contractId); + return board && contract ? { contract, periodIndex: board.periodIndex } : undefined; +} + +/** Loads a board contract the player may still accept, or throws. */ +export async function claimableContract( + tx: Queryable, + playerId: string, + kind: "bay" | "tutorial", + contractId: string, +): Promise<{ contract: Contract; periodIndex: number }> { + const found = await findBoardContract(tx, playerId, kind, contractId); + if (!found) throw gameError("unknown-contract", "No such contract on your boards."); + if ((await acceptedContractIds(tx, playerId)).has(contractId)) { + throw gameError("contract-already-accepted", "That contract has already been taken."); + } + return found; +} + +export function pilotState(row: typeof pilots.$inferSelect): PilotState { + return { + id: row.id, + isAvatar: row.isAvatar, + assignedShipId: row.assignedShipId, + inFlight: row.status === "in_flight", + skills: row.skills, + }; +} + +/** Stores a dispatch, marks its contract accepted, and charges fuel. */ +export async function storeDispatch( + tx: Queryable, + args: { + playerId: string; + record: DispatchRecord; + /** Null for the tutorial loaner. */ + shipId: string | null; + /** The board period the contract came from; null for a reposition. */ + periodIndex: number | null; + now: RealTime; + }, +): Promise { + const { playerId, record, shipId, now } = args; + const [row] = await tx + .insert(dispatches) + .values({ + id: record.id, + playerId, + shipId, + pilotId: record.pilotId, + tutorial: record.tutorial, + record, + arrivalAt: record.arrivalAt, + }) + .returning(); + if (record.contract && args.periodIndex !== null) { + await tx.insert(acceptedContracts).values({ + playerId, + contractId: record.contract.id, + periodIndex: args.periodIndex, + dispatchId: record.id, + acceptedAt: now, + }); + } + if (record.fuelCost > 0) { + await tx + .update(companies) + .set({ credits: sql`${companies.credits} - ${record.fuelCost}` }) + .where(eq(companies.playerId, playerId)); + } + return toApiDispatch(row!); +} + +/** + * The avatar flies the company loaner on a tutorial contract (Step 6.4). The + * company pays fuel, and only one tutorial run may be in flight. + */ +export async function dispatchTutorial( + tx: Queryable, + playerId: string, + contractId: string, + now: RealTime, +): Promise { + const [player] = await tx.select().from(players).where(eq(players.id, playerId)); + if (!player?.tutorialActive || !player.loanerLocationId) { + throw gameError("not-in-tutorial", "The tutorial is over."); + } + const [running] = await tx + .select({ id: dispatches.id }) + .from(dispatches) + .where( + and( + eq(dispatches.playerId, playerId), + eq(dispatches.tutorial, true), + eq(dispatches.status, "in_flight"), + ), + ); + if (running) throw gameError("tutorial-run-in-flight", "Your tutorial run is still flying."); + + const { contract, periodIndex } = await claimableContract(tx, playerId, "tutorial", contractId); + const [avatar] = await tx + .select() + .from(pilots) + .where(and(eq(pilots.playerId, playerId), eq(pilots.isAvatar, true))); + const [company] = await tx.select().from(companies).where(eq(companies.playerId, playerId)); + if (!avatar || !company) throw new Error(`Player ${playerId} is missing its avatar or company`); + + const loaner: ShipState = { + id: LOANER_SHIP_ID, + modelId: loanerModel(content).id, + locationId: player.loanerLocationId, + inFlight: false, + }; + const pilot = pilotState(avatar); + const check = validateDispatch( + { ship: loaner, pilot, contract, tutorial: true, credits: company.credits, now }, + content, + ); + if (!check.ok) throw gameError(check.reason, VALIDATION_MESSAGES[check.reason]); + + const record = createDispatchRecord( + { + id: randomUUID(), + ship: loaner, + pilot, + contract, + tutorial: true, + now, + worldSeed: player.worldSeed, + }, + content, + ); + const dispatch = await storeDispatch(tx, { playerId, record, shipId: null, periodIndex, now }); + await tx + .update(pilots) + .set({ status: "in_flight", locationId: null }) + .where(eq(pilots.id, avatar.id)); + return dispatch; +} diff --git a/apps/server/src/game/messages.ts b/apps/server/src/game/messages.ts new file mode 100644 index 0000000..d0b0a44 --- /dev/null +++ b/apps/server/src/game/messages.ts @@ -0,0 +1,18 @@ +import type { DispatchFailure, RepositionFailure } from "@deadhead/sim"; + +/** Player-facing text for each sim validation reason. */ +export const VALIDATION_MESSAGES: Record = { + "ship-not-idle": "That ship is already flying.", + "ship-not-at-origin": "That ship is not at the contract's pickup location.", + "pilot-not-idle": "That pilot is already flying.", + "pilot-not-assigned": "That pilot is not assigned to that ship.", + "over-capacity": "The cargo is too heavy for that ship.", + "cannot-land": "That ship cannot land at the destination.", + "out-of-range": "The destination is out of that ship's range right now.", + "cargo-skill-too-low": "The pilot's cargo skill is too low for this contract.", + "long-haul-too-low": "The pilot's Long Haul skill is too low for this distance.", + "contract-expired": "This contract has expired.", + "insufficient-credits": "Not enough credits for fuel.", + "already-there": "The ship is already there.", + "no-bay-at-destination": "You have no bay at that destination.", +}; diff --git a/apps/server/src/http/errors.ts b/apps/server/src/http/errors.ts index b0157c1..e3e5224 100644 --- a/apps/server/src/http/errors.ts +++ b/apps/server/src/http/errors.ts @@ -12,9 +12,15 @@ export class ApiError extends Error { } } +const NOT_FOUND_CODES: ReadonlySet = new Set([ + "not-found", + "unknown-contract", + "unknown-skill", +]); + /** A game-rule failure: 404 for unknown things, 409 for everything else. */ export function gameError(code: ErrorCode, message: string): ApiError { - return new ApiError(code === "not-found" ? 404 : 409, code, message); + return new ApiError(NOT_FOUND_CODES.has(code) ? 404 : 409, code, message); } export function errorBody(code: ErrorCode, message: string): ErrorResponse { diff --git a/apps/server/src/routes/dispatches.ts b/apps/server/src/routes/dispatches.ts new file mode 100644 index 0000000..64e48d9 --- /dev/null +++ b/apps/server/src/routes/dispatches.ts @@ -0,0 +1,21 @@ +import { routes } from "@deadhead/schema"; +import type { FastifyInstance } from "fastify"; +import { dispatchTutorial } from "../game/dispatch"; +import { gameError } from "../http/errors"; +import { registerRoute } from "../http/route"; +import type { AppContext } from "./context"; +import { withPlayer } from "./with-player"; + +/** Contract, tutorial and reposition dispatches. */ +export function registerDispatchRoutes(app: FastifyInstance, ctx: AppContext): void { + registerRoute(app, routes.createDispatch, ctx.options, async (input, { playerId }) => + withPlayer(ctx, playerId, async (tx, now) => { + switch (input.kind) { + case "tutorial": + return { dispatch: await dispatchTutorial(tx, playerId, input.contractId, now) }; + default: + throw gameError("not-found", `Dispatch kind "${input.kind}" is not available yet.`); + } + }), + ); +} diff --git a/apps/server/test/tutorial-dispatch.test.ts b/apps/server/test/tutorial-dispatch.test.ts new file mode 100644 index 0000000..6d46389 --- /dev/null +++ b/apps/server/test/tutorial-dispatch.test.ts @@ -0,0 +1,105 @@ +import { content } from "@deadhead/content"; +import { + contractBoardResponseSchema, + dispatchResponseSchema, + errorResponseSchema, +} from "@deadhead/schema"; +import { MS_PER_HOUR, travelTimeGameHours } from "@deadhead/sim"; +import { describe, expect, it } from "vitest"; +import { useTestDatabase } from "./db"; +import { call, getState, signUp, useTestApp, type Guest } from "./game"; + +const database = useTestDatabase(); +const testApp = useTestApp(database); + +async function tutorialBoard(guest: Guest) { + const response = await call(testApp().app, guest, "GET", "/api/tutorial/board"); + return contractBoardResponseSchema.parse(response.json()).contracts; +} + +function flyTutorial(guest: Guest, contractId: string) { + return call(testApp().app, guest, "POST", "/api/dispatches", { kind: "tutorial", contractId }); +} + +describe("tutorial dispatch", () => { + it("dispatches the avatar in the loaner, charging no fuel", async () => { + const { app, clock } = testApp(); + const guest = await signUp(app); + const [contract] = await tutorialBoard(guest); + + const response = await flyTutorial(guest, contract!.id); + expect(response.statusCode).toBe(200); + const { dispatch } = dispatchResponseSchema.parse(response.json()); + + expect(dispatch).toMatchObject({ + tutorial: true, + status: "in-flight", + originId: "earth", + destinationId: "moon", + pilotId: guest.avatarId, + fuelCost: 0, + departureAt: clock.now, + expectedPayout: contract!.payout, + }); + const travelMs = + (travelTimeGameHours(content.shipModel("courier")!, dispatch.distanceMillionKm) * + MS_PER_HOUR) / + content.config.timeCompression; + expect(dispatch.arrivalAt - dispatch.departureAt).toBe(Math.round(travelMs)); + + const state = await getState(app, guest); + expect(state.company.credits).toBe(content.config.startingCredits); + expect(state.tutorial).toEqual({ loanerLocationId: "earth", runInFlight: true }); + expect(state.pilots[0]).toMatchObject({ status: "in-flight", locationId: null }); + expect(state.activeDispatches.map((d) => d.id)).toEqual([dispatch.id]); + expect((await tutorialBoard(guest)).map((c) => c.id)).not.toContain(contract!.id); + }); + + it("rejects a second tutorial run while one is in flight", async () => { + const { app } = testApp(); + const guest = await signUp(app); + const [first, second] = await tutorialBoard(guest); + await flyTutorial(guest, first!.id); + + const response = await flyTutorial(guest, second!.id); + expect(response.statusCode).toBe(409); + expect(errorResponseSchema.parse(response.json()).error.code).toBe("tutorial-run-in-flight"); + }); + + it("pays exactly the tutorial share on arrival and moves the loaner and avatar to the Moon", async () => { + const { app, clock } = testApp(); + const guest = await signUp(app); + const [contract] = await tutorialBoard(guest); + const { dispatch } = dispatchResponseSchema.parse( + (await flyTutorial(guest, contract!.id)).json(), + ); + + clock.now = dispatch.arrivalAt; + const state = await getState(app, guest); + expect(state.company.credits).toBe(content.config.startingCredits + contract!.payout); + expect(state.pilots[0]!.xp).toBeGreaterThan(0); + expect(state.pilots[0]).toMatchObject({ status: "idle", locationId: "moon" }); + expect(state.tutorial).toEqual({ loanerLocationId: "moon", runInFlight: false }); + + const next = await tutorialBoard(guest); + expect(next.length).toBeGreaterThan(0); + for (const c of next) expect([c.originId, c.destinationId]).toEqual(["moon", "earth"]); + }); + + it("rejects unknown and already-taken contracts", async () => { + const { app, clock } = testApp(); + const guest = await signUp(app); + const [contract] = await tutorialBoard(guest); + + const unknown = await flyTutorial(guest, "tutorial:earth:0:999"); + expect(unknown.statusCode).toBe(404); + expect(errorResponseSchema.parse(unknown.json()).error.code).toBe("unknown-contract"); + + const { dispatch } = dispatchResponseSchema.parse( + (await flyTutorial(guest, contract!.id)).json(), + ); + clock.now = dispatch.arrivalAt; + const again = await flyTutorial(guest, contract!.id); + expect(errorResponseSchema.parse(again.json()).error.code).toBe("contract-already-accepted"); + }); +}); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 623b708..e59c4ce 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -57,7 +57,7 @@ - [x] **Step 6.1 — Lazy resolution on read.** `resolveArrivedDispatches` (`src/game/resolve.ts`) resolves arrived dispatches in arrival order inside `withPlayerLock` (`src/game/lock.ts`, a transaction holding the company row lock); a conditional status update makes each resolve exactly once. `GET /api/state` runs it before reading. Routes moved to `src/routes/`. - [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). -- [ ] Step 6.4 — Tutorial dispatch +- [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 - [ ] Step 6.6 — Lease a bay - [ ] Step 6.7 — End tutorial