Step 6.11: add reposition dispatch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
c26983a00e
commit
55a21029f0
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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<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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) };
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue