diff --git a/DECISIONS.md b/DECISIONS.md index 73c6f77..74a8559 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -313,3 +313,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Discovered locations are read only when a board is first generated** (inside the generator callback), so a discovery later in the period does not change the stored board (decision 0.5.5). - **Older periods' boards are never shown**: the endpoint always uses the current period index. Stored older boards stay in the database until Phase 7's cleanup, so that dispatching from one returns `contract-expired` instead of "unknown contract". - **The plan's "dispatching from an old board fails as expired" check is tested in Step 6.10**, once contract dispatch exists. + +## Step 6.10 + +- **A contract must be on one of the player's stored bay boards** (any period). Once found, sim validation decides; an older board's contract fails with `contract-expired`. +- **Ships and pilots must belong to the player** (`404 not-found`) before sim validation runs. An in-flight ship's missing location is passed to sim as an empty string; sim reports `ship-not-idle` first anyway. +- **`cannot-land` is tested with a test-only vacuum ship** added by mocking `@deadhead/content` in its own test file, since every base-game ship is atmospheric (decision 0.5.1). +- **Failure tests check that nothing changed:** no dispatch row, no accepted contract, credits untouched. diff --git a/apps/server/src/game/dispatch.ts b/apps/server/src/game/dispatch.ts index ca11ffc..e664085 100644 --- a/apps/server/src/game/dispatch.ts +++ b/apps/server/src/game/dispatch.ts @@ -20,11 +20,14 @@ import { dispatches, pilots, players, + ships, } from "../db/schema"; import { gameError } from "../http/errors"; import { acceptedContractIds } from "./boards"; +import { requireCompany, requirePlayer } from "./fleet"; import { toApiDispatch } from "./mappers"; import { VALIDATION_MESSAGES } from "./messages"; +import { requireOwnPilot, requireOwnShip } from "./pilots"; /** The id the tutorial loaner uses in dispatch records (it is not a ship row). */ export const LOANER_SHIP_ID = "loaner"; @@ -185,3 +188,57 @@ export async function dispatchTutorial( .where(eq(pilots.id, avatar.id)); return dispatch; } + +export function shipState(row: typeof ships.$inferSelect): ShipState { + return { + id: row.id, + modelId: row.modelId, + // An in-flight ship has no location; validation reports it as not idle first. + locationId: row.locationId ?? "", + inFlight: row.status === "in_flight", + }; +} + +/** + * Dispatches an owned ship and its pilot on a bay-board contract (Step 6.10): + * sim validation, fuel charged, contract accepted, ship and pilot in flight. + */ +export async function dispatchContract( + tx: Queryable, + playerId: string, + input: { contractId: string; shipId: string; pilotId: string }, + now: RealTime, +): Promise { + const { contract, periodIndex } = await claimableContract(tx, playerId, "bay", input.contractId); + const ship = await requireOwnShip(tx, playerId, input.shipId); + const pilot = await requireOwnPilot(tx, playerId, input.pilotId); + const player = await requirePlayer(tx, playerId); + const company = await requireCompany(tx, playerId); + + const proposal = { + ship: shipState(ship), + pilot: pilotState(pilot), + contract, + tutorial: false, + credits: company.credits, + now, + }; + const check = validateDispatch(proposal, content); + if (!check.ok) throw gameError(check.reason, VALIDATION_MESSAGES[check.reason]); + + const record = createDispatchRecord( + { id: randomUUID(), ...proposal, worldSeed: player.worldSeed }, + content, + ); + const dispatch = await storeDispatch(tx, { playerId, record, shipId: ship.id, periodIndex, now }); + await setInFlight(tx, ship.id, pilot.id); + return dispatch; +} + +export async function setInFlight(tx: Queryable, shipId: string, pilotId: string) { + await tx.update(ships).set({ status: "in_flight", locationId: null }).where(eq(ships.id, shipId)); + await tx + .update(pilots) + .set({ status: "in_flight", locationId: null }) + .where(eq(pilots.id, pilotId)); +} diff --git a/apps/server/src/routes/dispatches.ts b/apps/server/src/routes/dispatches.ts index 64e48d9..80bc6bb 100644 --- a/apps/server/src/routes/dispatches.ts +++ b/apps/server/src/routes/dispatches.ts @@ -1,6 +1,6 @@ import { routes } from "@deadhead/schema"; import type { FastifyInstance } from "fastify"; -import { dispatchTutorial } from "../game/dispatch"; +import { dispatchContract, dispatchTutorial } from "../game/dispatch"; import { gameError } from "../http/errors"; import { registerRoute } from "../http/route"; import type { AppContext } from "./context"; @@ -11,6 +11,8 @@ export function registerDispatchRoutes(app: FastifyInstance, ctx: AppContext): v registerRoute(app, routes.createDispatch, ctx.options, async (input, { playerId }) => withPlayer(ctx, playerId, async (tx, now) => { switch (input.kind) { + case "contract": + return { dispatch: await dispatchContract(tx, playerId, input, now) }; case "tutorial": return { dispatch: await dispatchTutorial(tx, playerId, input.contractId, now) }; default: diff --git a/apps/server/test/contract-dispatch-landing.test.ts b/apps/server/test/contract-dispatch-landing.test.ts new file mode 100644 index 0000000..3199313 --- /dev/null +++ b/apps/server/test/contract-dispatch-landing.test.ts @@ -0,0 +1,69 @@ +import { randomUUID } from "node:crypto"; +import { errorResponseSchema } from "@deadhead/schema"; +import { describe, expect, it, vi } from "vitest"; +import { dispatches, pilots, ships } from "../src/db/schema"; +import { makeOwner, setCredits, TEST_NOW, useTestDatabase } from "./db"; +import { call, putBoard, signUp, testContract, useTestApp } from "./game"; + +// Every base-game ship is atmospheric (decision 0.5.1), so `cannot-land` needs +// a test-only vacuum ship in the content the server sees. +vi.mock("@deadhead/content", async (importOriginal) => { + const original = await importOriginal(); + const courier = original.content.shipModel("courier")!; + const shipModels = [ + ...original.content.shipModels, + { ...courier, id: "test-vacuum", landingClass: "vacuum" as const }, + ]; + return { + ...original, + content: { + ...original.content, + shipModels, + shipModel: (id: string) => shipModels.find((s) => s.id === id), + }, + }; +}); + +const database = useTestDatabase(); +const testApp = useTestApp(database); + +describe("contract dispatch landing check", () => { + it("rejects cannot-land and changes nothing", async () => { + const { app } = testApp(); + const { db } = database(); + const guest = await signUp(app); + await makeOwner(db, guest.playerId, guest.avatarId); + await setCredits(db, guest.playerId, 10_000); + + // A vacuum-only ship at the Moon, crewed by a pilot, offered a Moon→Earth job. + const shipId = randomUUID(); + await db.insert(ships).values({ + id: shipId, + playerId: guest.playerId, + modelId: "test-vacuum", + name: "Moon Crawler", + locationId: "moon", + createdAt: TEST_NOW, + }); + const pilotId = randomUUID(); + await db.insert(pilots).values({ + id: pilotId, + playerId: guest.playerId, + name: "Vac Pilot", + assignedShipId: shipId, + createdAt: TEST_NOW, + }); + const contract = testContract({ originId: "moon", destinationId: "earth" }); + await putBoard(db, guest.playerId, [contract]); + + const response = await call(app, guest, "POST", "/api/dispatches", { + kind: "contract", + contractId: contract.id, + shipId, + pilotId, + }); + expect(response.statusCode).toBe(409); + expect(errorResponseSchema.parse(response.json()).error.code).toBe("cannot-land"); + expect(await db.select().from(dispatches)).toEqual([]); + }); +}); diff --git a/apps/server/test/contract-dispatch.test.ts b/apps/server/test/contract-dispatch.test.ts new file mode 100644 index 0000000..6491921 --- /dev/null +++ b/apps/server/test/contract-dispatch.test.ts @@ -0,0 +1,203 @@ +import { randomUUID } from "node:crypto"; +import { content } from "@deadhead/content"; +import { + contractBoardResponseSchema, + dispatchResponseSchema, + errorResponseSchema, +} from "@deadhead/schema"; +import { boardPeriodMs } from "@deadhead/sim"; +import { eq } from "drizzle-orm"; +import { describe, expect, it } from "vitest"; +import { acceptedContracts, companies, dispatches, pilots, ships } from "../src/db/schema"; +import { makeOwner, setCredits, TEST_NOW, useTestDatabase } from "./db"; +import { call, getState, putBoard, signUp, testContract, useTestApp, type Guest } from "./game"; + +const database = useTestDatabase(); +const testApp = useTestApp(database); + +const errorCode = (response: { json(): unknown }) => + errorResponseSchema.parse(response.json()).error.code; + +interface Owner { + guest: Guest; + shipId: string; +} + +/** An owner with a crewed Courier at Earth, an Earth bay and 10,000 credits. */ +async function owner(): Promise { + const guest = await signUp(testApp().app); + const shipId = await makeOwner(database().db, guest.playerId, guest.avatarId); + await setCredits(database().db, guest.playerId, 10_000); + return { guest, shipId }; +} + +function dispatch( + o: Owner, + contractId: string, + overrides: { shipId?: string; pilotId?: string } = {}, +) { + return call(testApp().app, o.guest, "POST", "/api/dispatches", { + kind: "contract", + contractId, + shipId: overrides.shipId ?? o.shipId, + pilotId: overrides.pilotId ?? o.guest.avatarId, + }); +} + +/** Asserts the request failed with `code` and changed nothing. */ +async function expectRejected( + o: Owner, + response: Awaited>, + code: string, +) { + expect(response.statusCode).toBe(409); + expect(errorCode(response)).toBe(code); + const { db } = database(); + expect( + await db.select().from(dispatches).where(eq(dispatches.playerId, o.guest.playerId)), + ).toEqual([]); + expect(await db.select().from(acceptedContracts)).toEqual([]); + const [company] = await db + .select() + .from(companies) + .where(eq(companies.playerId, o.guest.playerId)); + expect(company!.credits).toBe(10_000); +} + +describe("contract dispatch", () => { + it("dispatches, charges fuel, stores the record and removes the contract from the board", async () => { + const { app, clock } = testApp(); + const o = await owner(); + const contract = testContract(); + await putBoard(database().db, o.guest.playerId, [contract]); + + const response = await dispatch(o, contract.id); + expect(response.statusCode).toBe(200); + const record = dispatchResponseSchema.parse(response.json()).dispatch; + expect(record).toMatchObject({ + status: "in-flight", + shipId: o.shipId, + pilotId: o.guest.avatarId, + departureAt: clock.now, + expectedPayout: contract.payout, + tutorial: false, + }); + expect(record.fuelCost).toBeGreaterThan(0); + + const state = await getState(app, o.guest); + expect(state.company.credits).toBe(10_000 - record.fuelCost); + expect(state.ships[0]).toMatchObject({ status: "in-flight", dispatchId: record.id }); + expect(state.pilots[0]!.status).toBe("in-flight"); + const board = contractBoardResponseSchema.parse( + (await call(app, o.guest, "GET", "/api/bays/earth/board")).json(), + ); + expect(board.contracts).toEqual([]); + }); + + it("rejects ship-not-idle", async () => { + const o = await owner(); + const contract = testContract(); + await putBoard(database().db, o.guest.playerId, [contract]); + await database().db.update(ships).set({ status: "in_flight" }).where(eq(ships.id, o.shipId)); + await expectRejected(o, await dispatch(o, contract.id), "ship-not-idle"); + }); + + it("rejects ship-not-at-origin", async () => { + const o = await owner(); + const contract = testContract({ originId: "moon", destinationId: "earth" }); + await putBoard(database().db, o.guest.playerId, [contract]); + await expectRejected(o, await dispatch(o, contract.id), "ship-not-at-origin"); + }); + + it("rejects pilot-not-idle", async () => { + const o = await owner(); + const contract = testContract(); + await putBoard(database().db, o.guest.playerId, [contract]); + await database() + .db.update(pilots) + .set({ status: "in_flight" }) + .where(eq(pilots.id, o.guest.avatarId)); + await expectRejected(o, await dispatch(o, contract.id), "pilot-not-idle"); + }); + + it("rejects pilot-not-assigned", async () => { + const o = await owner(); + const contract = testContract(); + await putBoard(database().db, o.guest.playerId, [contract]); + const otherPilotId = randomUUID(); + await database().db.insert(pilots).values({ + id: otherPilotId, + playerId: o.guest.playerId, + name: "Spare Pilot", + locationId: "earth", + createdAt: TEST_NOW, + }); + await expectRejected( + o, + await dispatch(o, contract.id, { pilotId: otherPilotId }), + "pilot-not-assigned", + ); + }); + + it("rejects over-capacity", async () => { + const o = await owner(); + const contract = testContract({ massTonnes: 50 }); + await putBoard(database().db, o.guest.playerId, [contract]); + await expectRejected(o, await dispatch(o, contract.id), "over-capacity"); + }); + + it("rejects out-of-range", async () => { + const o = await owner(); + const contract = testContract({ destinationId: "mars" }); + await putBoard(database().db, o.guest.playerId, [contract]); + await expectRejected(o, await dispatch(o, contract.id), "out-of-range"); + }); + + it("rejects cargo-skill-too-low", async () => { + const o = await owner(); + const contract = testContract({ + cargoTypeId: "heavy", + tier: 2, + requiredSkill: { skillId: "heavy-cargo", level: 2 }, + }); + await putBoard(database().db, o.guest.playerId, [contract]); + await expectRejected(o, await dispatch(o, contract.id), "cargo-skill-too-low"); + }); + + it("rejects long-haul-too-low", async () => { + const o = await owner(); + const contract = testContract({ requiredLongHaulLevel: 1 }); + await putBoard(database().db, o.guest.playerId, [contract]); + await expectRejected(o, await dispatch(o, contract.id), "long-haul-too-low"); + }); + + it("rejects contract-expired once the board's period has rolled over", async () => { + const { clock } = testApp(); + const o = await owner(); + const contract = testContract(); + await putBoard(database().db, o.guest.playerId, [contract]); + // The contract expires at the end of its board period (TEST_NOW is a period start). + expect(contract.expiresAt).toBe(TEST_NOW + boardPeriodMs(content.config)); + clock.now = contract.expiresAt; + await expectRejected(o, await dispatch(o, contract.id), "contract-expired"); + }); + + it("rejects insufficient-credits", async () => { + const o = await owner(); + const contract = testContract(); + await putBoard(database().db, o.guest.playerId, [contract]); + await setCredits(database().db, o.guest.playerId, 0); + const response = await dispatch(o, contract.id); + expect(errorCode(response)).toBe("insufficient-credits"); + expect(await database().db.select().from(dispatches)).toEqual([]); + }); + + it("rejects an unknown contract and a contract taken twice", async () => { + const o = await owner(); + const contract = testContract(); + await putBoard(database().db, o.guest.playerId, [contract]); + expect((await dispatch(o, "earth:0:99")).statusCode).toBe(404); + expect((await dispatch(o, contract.id)).statusCode).toBe(200); + expect(errorCode(await dispatch(o, contract.id))).toBe("contract-already-accepted"); + }); +}); diff --git a/apps/server/test/game.ts b/apps/server/test/game.ts index 3d0eba0..7e55672 100644 --- a/apps/server/test/game.ts +++ b/apps/server/test/game.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import { content } from "@deadhead/content"; import { createGuestResponseSchema, playerStateSchema, type PlayerState } from "@deadhead/schema"; import { + boardPeriodIndex, createDispatchRecord, createRepositionRecord, type Contract, @@ -11,7 +12,7 @@ import { eq } from "drizzle-orm"; import { afterEach, beforeEach } from "vitest"; import { buildApp } from "../src/app"; import type { Database, Db } from "../src/db/client"; -import { dispatches, pilots, players, ships } from "../src/db/schema"; +import { contractBoards, dispatches, pilots, players, ships } from "../src/db/schema"; import { TEST_NOW } from "./db"; import { testConfig } from "./helpers"; @@ -176,3 +177,16 @@ export async function insertDispatch( await db.update(pilots).set({ status: "in_flight" }).where(eq(pilots.id, pilot!.id)); return record; } + +/** Stores a hand-made Earth bay board for the current period. */ +export async function putBoard(db: Db, playerId: string, contracts: Contract[], now = TEST_NOW) { + await db.insert(contractBoards).values({ + id: randomUUID(), + playerId, + kind: "bay", + locationId: "earth", + periodIndex: boardPeriodIndex(now, content.config), + contracts, + createdAt: now, + }); +} diff --git a/memory-bank/progress.md b/memory-bank/progress.md index c8d8502..6956594 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -63,7 +63,7 @@ - [x] **Step 6.7 — End tutorial.** `maybeEndTutorial` (`src/game/tutorial.ts`) runs after each resolution pass and after every game action: with a ship, an Earth bay and no tutorial run in flight, tutorial mode turns off, the loaner is removed, and the avatar becomes an idle, unassigned pilot at Earth. - [x] **Step 6.8 — Hire and assign pilots.** `POST /api/pilots`, `/api/pilots/:pilotId/assign`, `/api/pilots/:pilotId/unassign` (`src/game/pilots.ts`): hire at a bay for the fee with a seeded name; assign to an idle, uncrewed ship at the same location; unassign only at a bay. New error code `avatar-in-tutorial`. - [x] **Step 6.9 — Bay contract board endpoint.** `GET /api/bays/:locationId/board` (`bayBoard` in `src/game/boards.ts`): generated from the discovered set on first request in a period and stored; accepted contracts hidden; `409 no-bay-at-location` without a bay. The expired-old-board dispatch check is tested in Step 6.10. -- [ ] Step 6.10 — Contract dispatch +- [x] **Step 6.10 — Contract dispatch.** `POST /api/dispatches` with `kind: "contract"` (`dispatchContract`): sim validation, fuel deducted, contract accepted, ship and pilot in flight. One integration test per sim reason (`cannot-land` via a test-only vacuum ship in mocked content), each checking nothing changed; also covers the expired-old-board case from Step 6.9. - [ ] Step 6.11 — Reposition dispatch - [ ] Step 6.12 — Spend skill points - [ ] Step 6.13 — Acknowledge summaries