Step 6.4: add tutorial dispatch

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 01:07:09 -04:00
parent 18f8a5ac86
commit 6d31ed8d3c
8 changed files with 350 additions and 2 deletions

View File

@ -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.

View File

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

View File

@ -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<Dispatch> {
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<Dispatch> {
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;
}

View File

@ -0,0 +1,18 @@
import type { DispatchFailure, RepositionFailure } from "@deadhead/sim";
/** Player-facing text for each sim validation reason. */
export const VALIDATION_MESSAGES: Record<DispatchFailure | RepositionFailure, string> = {
"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.",
};

View File

@ -12,9 +12,15 @@ export class ApiError extends Error {
}
}
const NOT_FOUND_CODES: ReadonlySet<ErrorCode> = 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 {

View File

@ -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.`);
}
}),
);
}

View File

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

View File

@ -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