Step 6.4: add tutorial dispatch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
18f8a5ac86
commit
6d31ed8d3c
|
|
@ -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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { registerErrorHandler } from "./http/errors";
|
||||||
import { requirePlayerId } from "./http/session";
|
import { requirePlayerId } from "./http/session";
|
||||||
import type { AppContext } from "./routes/context";
|
import type { AppContext } from "./routes/context";
|
||||||
import { registerBoardRoutes } from "./routes/boards";
|
import { registerBoardRoutes } from "./routes/boards";
|
||||||
|
import { registerDispatchRoutes } from "./routes/dispatches";
|
||||||
import { registerPlayerRoutes } from "./routes/player";
|
import { registerPlayerRoutes } from "./routes/player";
|
||||||
|
|
||||||
export interface AppDeps {
|
export interface AppDeps {
|
||||||
|
|
@ -41,6 +42,7 @@ export function buildApp(config: ServerConfig, deps: AppDeps = {}): FastifyInsta
|
||||||
app.get("/api/health", async () => ({ status: "ok" }));
|
app.get("/api/health", async () => ({ status: "ok" }));
|
||||||
registerPlayerRoutes(app, ctx);
|
registerPlayerRoutes(app, ctx);
|
||||||
registerBoardRoutes(app, ctx);
|
registerBoardRoutes(app, ctx);
|
||||||
|
registerDispatchRoutes(app, ctx);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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.",
|
||||||
|
};
|
||||||
|
|
@ -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. */
|
/** A game-rule failure: 404 for unknown things, 409 for everything else. */
|
||||||
export function gameError(code: ErrorCode, message: string): ApiError {
|
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 {
|
export function errorBody(code: ErrorCode, message: string): ErrorResponse {
|
||||||
|
|
|
||||||
|
|
@ -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.`);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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.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.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).
|
- [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.5 — Buy a ship
|
||||||
- [ ] Step 6.6 — Lease a bay
|
- [ ] Step 6.6 — Lease a bay
|
||||||
- [ ] Step 6.7 — End tutorial
|
- [ ] Step 6.7 — End tutorial
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue