Step 6.11: add reposition dispatch

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 01:21:49 -04:00
parent c26983a00e
commit 55a21029f0
5 changed files with 120 additions and 5 deletions

View File

@ -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. - **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). - **`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. - **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).

View File

@ -3,8 +3,10 @@ import { content } from "@deadhead/content";
import type { Dispatch } from "@deadhead/schema"; import type { Dispatch } from "@deadhead/schema";
import { import {
createDispatchRecord, createDispatchRecord,
createRepositionRecord,
loanerModel, loanerModel,
validateDispatch, validateDispatch,
validateReposition,
type Contract, type Contract,
type DispatchRecord, type DispatchRecord,
type PilotState, type PilotState,
@ -15,6 +17,7 @@ import { and, eq, sql } from "drizzle-orm";
import type { Queryable } from "../db/client"; import type { Queryable } from "../db/client";
import { import {
acceptedContracts, acceptedContracts,
bays,
companies, companies,
contractBoards, contractBoards,
dispatches, dispatches,
@ -242,3 +245,45 @@ export async function setInFlight(tx: Queryable, shipId: string, pilotId: string
.set({ status: "in_flight", locationId: null }) .set({ status: "in_flight", locationId: null })
.where(eq(pilots.id, pilotId)); .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<Dispatch> {
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;
}

View File

@ -1,7 +1,6 @@
import { routes } from "@deadhead/schema"; import { routes } from "@deadhead/schema";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { dispatchContract, dispatchTutorial } from "../game/dispatch"; import { dispatchContract, dispatchReposition, dispatchTutorial } from "../game/dispatch";
import { gameError } from "../http/errors";
import { registerRoute } from "../http/route"; import { registerRoute } from "../http/route";
import type { AppContext } from "./context"; import type { AppContext } from "./context";
import { withPlayer } from "./with-player"; 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) }; return { dispatch: await dispatchContract(tx, playerId, input, now) };
case "tutorial": case "tutorial":
return { dispatch: await dispatchTutorial(tx, playerId, input.contractId, now) }; return { dispatch: await dispatchTutorial(tx, playerId, input.contractId, now) };
default: case "reposition":
throw gameError("not-found", `Dispatch kind "${input.kind}" is not available yet.`); return { dispatch: await dispatchReposition(tx, playerId, input, now) };
} }
}), }),
); );

View File

@ -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");
});
});

View File

@ -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.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.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. - [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.12 — Spend skill points
- [ ] Step 6.13 — Acknowledge summaries - [ ] Step 6.13 — Acknowledge summaries
- [ ] Step 6.14 — Concurrency and money safety - [ ] Step 6.14 — Concurrency and money safety