From 55a21029f0fa68bc3ce825f99396d502fd13681e Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sat, 3 Oct 2026 01:21:49 -0400 Subject: [PATCH] Step 6.11: add reposition dispatch Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 5 +++ apps/server/src/game/dispatch.ts | 45 +++++++++++++++++++ apps/server/src/routes/dispatches.ts | 7 ++- apps/server/test/reposition.test.ts | 66 ++++++++++++++++++++++++++++ memory-bank/progress.md | 2 +- 5 files changed, 120 insertions(+), 5 deletions(-) create mode 100644 apps/server/test/reposition.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index 74a8559..726c517 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -320,3 +320,8 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **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. + +## Step 6.11 + +- **Repositions use sim `validateReposition`** with the player's bay locations; their failure reasons map to error codes unchanged (`no-bay-at-destination`, `already-there`, ...). No contract is accepted, and `storeDispatch` charges only the fuel. +- **On arrival** the ship and its pilot are idle at the destination, with no credits or XP (sim resolution gives 0 for repositions). diff --git a/apps/server/src/game/dispatch.ts b/apps/server/src/game/dispatch.ts index e664085..1876ca8 100644 --- a/apps/server/src/game/dispatch.ts +++ b/apps/server/src/game/dispatch.ts @@ -3,8 +3,10 @@ import { content } from "@deadhead/content"; import type { Dispatch } from "@deadhead/schema"; import { createDispatchRecord, + createRepositionRecord, loanerModel, validateDispatch, + validateReposition, type Contract, type DispatchRecord, type PilotState, @@ -15,6 +17,7 @@ import { and, eq, sql } from "drizzle-orm"; import type { Queryable } from "../db/client"; import { acceptedContracts, + bays, companies, contractBoards, dispatches, @@ -242,3 +245,45 @@ export async function setInFlight(tx: Queryable, shipId: string, pilotId: string .set({ status: "in_flight", locationId: null }) .where(eq(pilots.id, pilotId)); } + +/** Flies a ship and its pilot empty to another player bay, paying fuel only (Step 6.11). */ +export async function dispatchReposition( + tx: Queryable, + playerId: string, + input: { shipId: string; pilotId: string; destinationId: string }, + now: RealTime, +): Promise { + 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 bayRows = await tx + .select({ locationId: bays.locationId }) + .from(bays) + .where(eq(bays.playerId, playerId)); + + const proposal = { + ship: shipState(ship), + pilot: pilotState(pilot), + destinationId: input.destinationId, + bayLocationIds: new Set(bayRows.map((b) => b.locationId)), + credits: company.credits, + now, + }; + const check = validateReposition(proposal, content); + if (!check.ok) throw gameError(check.reason, VALIDATION_MESSAGES[check.reason]); + + const record = createRepositionRecord( + { id: randomUUID(), ...proposal, worldSeed: player.worldSeed }, + content, + ); + const dispatch = await storeDispatch(tx, { + playerId, + record, + shipId: ship.id, + periodIndex: null, + now, + }); + await setInFlight(tx, ship.id, pilot.id); + return dispatch; +} diff --git a/apps/server/src/routes/dispatches.ts b/apps/server/src/routes/dispatches.ts index 80bc6bb..ffe16b3 100644 --- a/apps/server/src/routes/dispatches.ts +++ b/apps/server/src/routes/dispatches.ts @@ -1,7 +1,6 @@ import { routes } from "@deadhead/schema"; import type { FastifyInstance } from "fastify"; -import { dispatchContract, dispatchTutorial } from "../game/dispatch"; -import { gameError } from "../http/errors"; +import { dispatchContract, dispatchReposition, dispatchTutorial } from "../game/dispatch"; import { registerRoute } from "../http/route"; import type { AppContext } from "./context"; import { withPlayer } from "./with-player"; @@ -15,8 +14,8 @@ export function registerDispatchRoutes(app: FastifyInstance, ctx: AppContext): v return { dispatch: await dispatchContract(tx, playerId, input, now) }; case "tutorial": return { dispatch: await dispatchTutorial(tx, playerId, input.contractId, now) }; - default: - throw gameError("not-found", `Dispatch kind "${input.kind}" is not available yet.`); + case "reposition": + return { dispatch: await dispatchReposition(tx, playerId, input, now) }; } }), ); diff --git a/apps/server/test/reposition.test.ts b/apps/server/test/reposition.test.ts new file mode 100644 index 0000000..f90a8fe --- /dev/null +++ b/apps/server/test/reposition.test.ts @@ -0,0 +1,66 @@ +import { randomUUID } from "node:crypto"; +import { dispatchResponseSchema, errorResponseSchema } from "@deadhead/schema"; +import { describe, expect, it } from "vitest"; +import { bays } from "../src/db/schema"; +import { makeOwner, setCredits, TEST_NOW, useTestDatabase } from "./db"; +import { call, getState, signUp, useTestApp } from "./game"; + +const database = useTestDatabase(); +const testApp = useTestApp(database); + +async function ownerWithMoonBay(withMoonBay = true) { + const { app } = testApp(); + const { db } = database(); + const guest = await signUp(app); + const shipId = await makeOwner(db, guest.playerId, guest.avatarId); + await setCredits(db, guest.playerId, 10_000); + if (withMoonBay) { + await db.insert(bays).values({ + id: randomUUID(), + playerId: guest.playerId, + locationId: "moon", + createdAt: TEST_NOW, + }); + } + return { guest, shipId }; +} + +describe("reposition dispatch", () => { + it("flies the ship empty to another bay, costing only fuel", async () => { + const { app, clock } = testApp(); + const { guest, shipId } = await ownerWithMoonBay(); + + const response = await call(app, guest, "POST", "/api/dispatches", { + kind: "reposition", + shipId, + pilotId: guest.avatarId, + destinationId: "moon", + }); + expect(response.statusCode).toBe(200); + const { dispatch } = dispatchResponseSchema.parse(response.json()); + expect(dispatch).toMatchObject({ contract: null, expectedPayout: 0, destinationId: "moon" }); + expect(dispatch.fuelCost).toBeGreaterThan(0); + + clock.now = dispatch.arrivalAt; + const state = await getState(app, guest); + expect(state.ships[0]).toMatchObject({ status: "idle", locationId: "moon" }); + expect(state.pilots[0]).toMatchObject({ status: "idle", locationId: "moon", xp: 0 }); + expect(state.company.credits).toBe(10_000 - dispatch.fuelCost); + }); + + it("refuses a destination without a player bay and changes nothing", async () => { + const { app } = testApp(); + const { guest, shipId } = await ownerWithMoonBay(false); + const response = await call(app, guest, "POST", "/api/dispatches", { + kind: "reposition", + shipId, + pilotId: guest.avatarId, + destinationId: "moon", + }); + expect(response.statusCode).toBe(409); + expect(errorResponseSchema.parse(response.json()).error.code).toBe("no-bay-at-destination"); + const state = await getState(app, guest); + expect(state.company.credits).toBe(10_000); + expect(state.ships[0]!.status).toBe("idle"); + }); +}); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 6956594..14b7f3d 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -64,7 +64,7 @@ - [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. - [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 +- [x] **Step 6.11 — Reposition dispatch.** `POST /api/dispatches` with `kind: "reposition"` (`dispatchReposition`): sim reposition validation against the player's bays, fuel only, no payout or XP; ship and pilot arrive at the destination. - [ ] Step 6.12 — Spend skill points - [ ] Step 6.13 — Acknowledge summaries - [ ] Step 6.14 — Concurrency and money safety