Step 6.10: add contract dispatch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
4536569957
commit
c26983a00e
|
|
@ -313,3 +313,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
||||||
- **Discovered locations are read only when a board is first generated** (inside the generator callback), so a discovery later in the period does not change the stored board (decision 0.5.5).
|
- **Discovered locations are read only when a board is first generated** (inside the generator callback), so a discovery later in the period does not change the stored board (decision 0.5.5).
|
||||||
- **Older periods' boards are never shown**: the endpoint always uses the current period index. Stored older boards stay in the database until Phase 7's cleanup, so that dispatching from one returns `contract-expired` instead of "unknown contract".
|
- **Older periods' boards are never shown**: the endpoint always uses the current period index. Stored older boards stay in the database until Phase 7's cleanup, so that dispatching from one returns `contract-expired` instead of "unknown contract".
|
||||||
- **The plan's "dispatching from an old board fails as expired" check is tested in Step 6.10**, once contract dispatch exists.
|
- **The plan's "dispatching from an old board fails as expired" check is tested in Step 6.10**, once contract dispatch exists.
|
||||||
|
|
||||||
|
## Step 6.10
|
||||||
|
|
||||||
|
- **A contract must be on one of the player's stored bay boards** (any period). Once found, sim validation decides; an older board's contract fails with `contract-expired`.
|
||||||
|
- **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.
|
||||||
|
|
|
||||||
|
|
@ -20,11 +20,14 @@ import {
|
||||||
dispatches,
|
dispatches,
|
||||||
pilots,
|
pilots,
|
||||||
players,
|
players,
|
||||||
|
ships,
|
||||||
} from "../db/schema";
|
} from "../db/schema";
|
||||||
import { gameError } from "../http/errors";
|
import { gameError } from "../http/errors";
|
||||||
import { acceptedContractIds } from "./boards";
|
import { acceptedContractIds } from "./boards";
|
||||||
|
import { requireCompany, requirePlayer } from "./fleet";
|
||||||
import { toApiDispatch } from "./mappers";
|
import { toApiDispatch } from "./mappers";
|
||||||
import { VALIDATION_MESSAGES } from "./messages";
|
import { VALIDATION_MESSAGES } from "./messages";
|
||||||
|
import { requireOwnPilot, requireOwnShip } from "./pilots";
|
||||||
|
|
||||||
/** The id the tutorial loaner uses in dispatch records (it is not a ship row). */
|
/** The id the tutorial loaner uses in dispatch records (it is not a ship row). */
|
||||||
export const LOANER_SHIP_ID = "loaner";
|
export const LOANER_SHIP_ID = "loaner";
|
||||||
|
|
@ -185,3 +188,57 @@ export async function dispatchTutorial(
|
||||||
.where(eq(pilots.id, avatar.id));
|
.where(eq(pilots.id, avatar.id));
|
||||||
return dispatch;
|
return dispatch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function shipState(row: typeof ships.$inferSelect): ShipState {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
modelId: row.modelId,
|
||||||
|
// An in-flight ship has no location; validation reports it as not idle first.
|
||||||
|
locationId: row.locationId ?? "",
|
||||||
|
inFlight: row.status === "in_flight",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatches an owned ship and its pilot on a bay-board contract (Step 6.10):
|
||||||
|
* sim validation, fuel charged, contract accepted, ship and pilot in flight.
|
||||||
|
*/
|
||||||
|
export async function dispatchContract(
|
||||||
|
tx: Queryable,
|
||||||
|
playerId: string,
|
||||||
|
input: { contractId: string; shipId: string; pilotId: string },
|
||||||
|
now: RealTime,
|
||||||
|
): Promise<Dispatch> {
|
||||||
|
const { contract, periodIndex } = await claimableContract(tx, playerId, "bay", input.contractId);
|
||||||
|
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 proposal = {
|
||||||
|
ship: shipState(ship),
|
||||||
|
pilot: pilotState(pilot),
|
||||||
|
contract,
|
||||||
|
tutorial: false,
|
||||||
|
credits: company.credits,
|
||||||
|
now,
|
||||||
|
};
|
||||||
|
const check = validateDispatch(proposal, content);
|
||||||
|
if (!check.ok) throw gameError(check.reason, VALIDATION_MESSAGES[check.reason]);
|
||||||
|
|
||||||
|
const record = createDispatchRecord(
|
||||||
|
{ id: randomUUID(), ...proposal, worldSeed: player.worldSeed },
|
||||||
|
content,
|
||||||
|
);
|
||||||
|
const dispatch = await storeDispatch(tx, { playerId, record, shipId: ship.id, periodIndex, now });
|
||||||
|
await setInFlight(tx, ship.id, pilot.id);
|
||||||
|
return dispatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setInFlight(tx: Queryable, shipId: string, pilotId: string) {
|
||||||
|
await tx.update(ships).set({ status: "in_flight", locationId: null }).where(eq(ships.id, shipId));
|
||||||
|
await tx
|
||||||
|
.update(pilots)
|
||||||
|
.set({ status: "in_flight", locationId: null })
|
||||||
|
.where(eq(pilots.id, pilotId));
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { routes } from "@deadhead/schema";
|
import { routes } from "@deadhead/schema";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { dispatchTutorial } from "../game/dispatch";
|
import { dispatchContract, dispatchTutorial } from "../game/dispatch";
|
||||||
import { gameError } from "../http/errors";
|
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";
|
||||||
|
|
@ -11,6 +11,8 @@ export function registerDispatchRoutes(app: FastifyInstance, ctx: AppContext): v
|
||||||
registerRoute(app, routes.createDispatch, ctx.options, async (input, { playerId }) =>
|
registerRoute(app, routes.createDispatch, ctx.options, async (input, { playerId }) =>
|
||||||
withPlayer(ctx, playerId, async (tx, now) => {
|
withPlayer(ctx, playerId, async (tx, now) => {
|
||||||
switch (input.kind) {
|
switch (input.kind) {
|
||||||
|
case "contract":
|
||||||
|
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:
|
default:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { errorResponseSchema } from "@deadhead/schema";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { dispatches, pilots, ships } from "../src/db/schema";
|
||||||
|
import { makeOwner, setCredits, TEST_NOW, useTestDatabase } from "./db";
|
||||||
|
import { call, putBoard, signUp, testContract, useTestApp } from "./game";
|
||||||
|
|
||||||
|
// Every base-game ship is atmospheric (decision 0.5.1), so `cannot-land` needs
|
||||||
|
// a test-only vacuum ship in the content the server sees.
|
||||||
|
vi.mock("@deadhead/content", async (importOriginal) => {
|
||||||
|
const original = await importOriginal<typeof import("@deadhead/content")>();
|
||||||
|
const courier = original.content.shipModel("courier")!;
|
||||||
|
const shipModels = [
|
||||||
|
...original.content.shipModels,
|
||||||
|
{ ...courier, id: "test-vacuum", landingClass: "vacuum" as const },
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
...original,
|
||||||
|
content: {
|
||||||
|
...original.content,
|
||||||
|
shipModels,
|
||||||
|
shipModel: (id: string) => shipModels.find((s) => s.id === id),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const database = useTestDatabase();
|
||||||
|
const testApp = useTestApp(database);
|
||||||
|
|
||||||
|
describe("contract dispatch landing check", () => {
|
||||||
|
it("rejects cannot-land and changes nothing", async () => {
|
||||||
|
const { app } = testApp();
|
||||||
|
const { db } = database();
|
||||||
|
const guest = await signUp(app);
|
||||||
|
await makeOwner(db, guest.playerId, guest.avatarId);
|
||||||
|
await setCredits(db, guest.playerId, 10_000);
|
||||||
|
|
||||||
|
// A vacuum-only ship at the Moon, crewed by a pilot, offered a Moon→Earth job.
|
||||||
|
const shipId = randomUUID();
|
||||||
|
await db.insert(ships).values({
|
||||||
|
id: shipId,
|
||||||
|
playerId: guest.playerId,
|
||||||
|
modelId: "test-vacuum",
|
||||||
|
name: "Moon Crawler",
|
||||||
|
locationId: "moon",
|
||||||
|
createdAt: TEST_NOW,
|
||||||
|
});
|
||||||
|
const pilotId = randomUUID();
|
||||||
|
await db.insert(pilots).values({
|
||||||
|
id: pilotId,
|
||||||
|
playerId: guest.playerId,
|
||||||
|
name: "Vac Pilot",
|
||||||
|
assignedShipId: shipId,
|
||||||
|
createdAt: TEST_NOW,
|
||||||
|
});
|
||||||
|
const contract = testContract({ originId: "moon", destinationId: "earth" });
|
||||||
|
await putBoard(db, guest.playerId, [contract]);
|
||||||
|
|
||||||
|
const response = await call(app, guest, "POST", "/api/dispatches", {
|
||||||
|
kind: "contract",
|
||||||
|
contractId: contract.id,
|
||||||
|
shipId,
|
||||||
|
pilotId,
|
||||||
|
});
|
||||||
|
expect(response.statusCode).toBe(409);
|
||||||
|
expect(errorResponseSchema.parse(response.json()).error.code).toBe("cannot-land");
|
||||||
|
expect(await db.select().from(dispatches)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,203 @@
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { content } from "@deadhead/content";
|
||||||
|
import {
|
||||||
|
contractBoardResponseSchema,
|
||||||
|
dispatchResponseSchema,
|
||||||
|
errorResponseSchema,
|
||||||
|
} from "@deadhead/schema";
|
||||||
|
import { boardPeriodMs } from "@deadhead/sim";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { acceptedContracts, companies, dispatches, pilots, ships } from "../src/db/schema";
|
||||||
|
import { makeOwner, setCredits, TEST_NOW, useTestDatabase } from "./db";
|
||||||
|
import { call, getState, putBoard, signUp, testContract, useTestApp, type Guest } from "./game";
|
||||||
|
|
||||||
|
const database = useTestDatabase();
|
||||||
|
const testApp = useTestApp(database);
|
||||||
|
|
||||||
|
const errorCode = (response: { json(): unknown }) =>
|
||||||
|
errorResponseSchema.parse(response.json()).error.code;
|
||||||
|
|
||||||
|
interface Owner {
|
||||||
|
guest: Guest;
|
||||||
|
shipId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An owner with a crewed Courier at Earth, an Earth bay and 10,000 credits. */
|
||||||
|
async function owner(): Promise<Owner> {
|
||||||
|
const guest = await signUp(testApp().app);
|
||||||
|
const shipId = await makeOwner(database().db, guest.playerId, guest.avatarId);
|
||||||
|
await setCredits(database().db, guest.playerId, 10_000);
|
||||||
|
return { guest, shipId };
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatch(
|
||||||
|
o: Owner,
|
||||||
|
contractId: string,
|
||||||
|
overrides: { shipId?: string; pilotId?: string } = {},
|
||||||
|
) {
|
||||||
|
return call(testApp().app, o.guest, "POST", "/api/dispatches", {
|
||||||
|
kind: "contract",
|
||||||
|
contractId,
|
||||||
|
shipId: overrides.shipId ?? o.shipId,
|
||||||
|
pilotId: overrides.pilotId ?? o.guest.avatarId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Asserts the request failed with `code` and changed nothing. */
|
||||||
|
async function expectRejected(
|
||||||
|
o: Owner,
|
||||||
|
response: Awaited<ReturnType<typeof dispatch>>,
|
||||||
|
code: string,
|
||||||
|
) {
|
||||||
|
expect(response.statusCode).toBe(409);
|
||||||
|
expect(errorCode(response)).toBe(code);
|
||||||
|
const { db } = database();
|
||||||
|
expect(
|
||||||
|
await db.select().from(dispatches).where(eq(dispatches.playerId, o.guest.playerId)),
|
||||||
|
).toEqual([]);
|
||||||
|
expect(await db.select().from(acceptedContracts)).toEqual([]);
|
||||||
|
const [company] = await db
|
||||||
|
.select()
|
||||||
|
.from(companies)
|
||||||
|
.where(eq(companies.playerId, o.guest.playerId));
|
||||||
|
expect(company!.credits).toBe(10_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("contract dispatch", () => {
|
||||||
|
it("dispatches, charges fuel, stores the record and removes the contract from the board", async () => {
|
||||||
|
const { app, clock } = testApp();
|
||||||
|
const o = await owner();
|
||||||
|
const contract = testContract();
|
||||||
|
await putBoard(database().db, o.guest.playerId, [contract]);
|
||||||
|
|
||||||
|
const response = await dispatch(o, contract.id);
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const record = dispatchResponseSchema.parse(response.json()).dispatch;
|
||||||
|
expect(record).toMatchObject({
|
||||||
|
status: "in-flight",
|
||||||
|
shipId: o.shipId,
|
||||||
|
pilotId: o.guest.avatarId,
|
||||||
|
departureAt: clock.now,
|
||||||
|
expectedPayout: contract.payout,
|
||||||
|
tutorial: false,
|
||||||
|
});
|
||||||
|
expect(record.fuelCost).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const state = await getState(app, o.guest);
|
||||||
|
expect(state.company.credits).toBe(10_000 - record.fuelCost);
|
||||||
|
expect(state.ships[0]).toMatchObject({ status: "in-flight", dispatchId: record.id });
|
||||||
|
expect(state.pilots[0]!.status).toBe("in-flight");
|
||||||
|
const board = contractBoardResponseSchema.parse(
|
||||||
|
(await call(app, o.guest, "GET", "/api/bays/earth/board")).json(),
|
||||||
|
);
|
||||||
|
expect(board.contracts).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects ship-not-idle", async () => {
|
||||||
|
const o = await owner();
|
||||||
|
const contract = testContract();
|
||||||
|
await putBoard(database().db, o.guest.playerId, [contract]);
|
||||||
|
await database().db.update(ships).set({ status: "in_flight" }).where(eq(ships.id, o.shipId));
|
||||||
|
await expectRejected(o, await dispatch(o, contract.id), "ship-not-idle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects ship-not-at-origin", async () => {
|
||||||
|
const o = await owner();
|
||||||
|
const contract = testContract({ originId: "moon", destinationId: "earth" });
|
||||||
|
await putBoard(database().db, o.guest.playerId, [contract]);
|
||||||
|
await expectRejected(o, await dispatch(o, contract.id), "ship-not-at-origin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects pilot-not-idle", async () => {
|
||||||
|
const o = await owner();
|
||||||
|
const contract = testContract();
|
||||||
|
await putBoard(database().db, o.guest.playerId, [contract]);
|
||||||
|
await database()
|
||||||
|
.db.update(pilots)
|
||||||
|
.set({ status: "in_flight" })
|
||||||
|
.where(eq(pilots.id, o.guest.avatarId));
|
||||||
|
await expectRejected(o, await dispatch(o, contract.id), "pilot-not-idle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects pilot-not-assigned", async () => {
|
||||||
|
const o = await owner();
|
||||||
|
const contract = testContract();
|
||||||
|
await putBoard(database().db, o.guest.playerId, [contract]);
|
||||||
|
const otherPilotId = randomUUID();
|
||||||
|
await database().db.insert(pilots).values({
|
||||||
|
id: otherPilotId,
|
||||||
|
playerId: o.guest.playerId,
|
||||||
|
name: "Spare Pilot",
|
||||||
|
locationId: "earth",
|
||||||
|
createdAt: TEST_NOW,
|
||||||
|
});
|
||||||
|
await expectRejected(
|
||||||
|
o,
|
||||||
|
await dispatch(o, contract.id, { pilotId: otherPilotId }),
|
||||||
|
"pilot-not-assigned",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects over-capacity", async () => {
|
||||||
|
const o = await owner();
|
||||||
|
const contract = testContract({ massTonnes: 50 });
|
||||||
|
await putBoard(database().db, o.guest.playerId, [contract]);
|
||||||
|
await expectRejected(o, await dispatch(o, contract.id), "over-capacity");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects out-of-range", async () => {
|
||||||
|
const o = await owner();
|
||||||
|
const contract = testContract({ destinationId: "mars" });
|
||||||
|
await putBoard(database().db, o.guest.playerId, [contract]);
|
||||||
|
await expectRejected(o, await dispatch(o, contract.id), "out-of-range");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects cargo-skill-too-low", async () => {
|
||||||
|
const o = await owner();
|
||||||
|
const contract = testContract({
|
||||||
|
cargoTypeId: "heavy",
|
||||||
|
tier: 2,
|
||||||
|
requiredSkill: { skillId: "heavy-cargo", level: 2 },
|
||||||
|
});
|
||||||
|
await putBoard(database().db, o.guest.playerId, [contract]);
|
||||||
|
await expectRejected(o, await dispatch(o, contract.id), "cargo-skill-too-low");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects long-haul-too-low", async () => {
|
||||||
|
const o = await owner();
|
||||||
|
const contract = testContract({ requiredLongHaulLevel: 1 });
|
||||||
|
await putBoard(database().db, o.guest.playerId, [contract]);
|
||||||
|
await expectRejected(o, await dispatch(o, contract.id), "long-haul-too-low");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects contract-expired once the board's period has rolled over", async () => {
|
||||||
|
const { clock } = testApp();
|
||||||
|
const o = await owner();
|
||||||
|
const contract = testContract();
|
||||||
|
await putBoard(database().db, o.guest.playerId, [contract]);
|
||||||
|
// The contract expires at the end of its board period (TEST_NOW is a period start).
|
||||||
|
expect(contract.expiresAt).toBe(TEST_NOW + boardPeriodMs(content.config));
|
||||||
|
clock.now = contract.expiresAt;
|
||||||
|
await expectRejected(o, await dispatch(o, contract.id), "contract-expired");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects insufficient-credits", async () => {
|
||||||
|
const o = await owner();
|
||||||
|
const contract = testContract();
|
||||||
|
await putBoard(database().db, o.guest.playerId, [contract]);
|
||||||
|
await setCredits(database().db, o.guest.playerId, 0);
|
||||||
|
const response = await dispatch(o, contract.id);
|
||||||
|
expect(errorCode(response)).toBe("insufficient-credits");
|
||||||
|
expect(await database().db.select().from(dispatches)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unknown contract and a contract taken twice", async () => {
|
||||||
|
const o = await owner();
|
||||||
|
const contract = testContract();
|
||||||
|
await putBoard(database().db, o.guest.playerId, [contract]);
|
||||||
|
expect((await dispatch(o, "earth:0:99")).statusCode).toBe(404);
|
||||||
|
expect((await dispatch(o, contract.id)).statusCode).toBe(200);
|
||||||
|
expect(errorCode(await dispatch(o, contract.id))).toBe("contract-already-accepted");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
||||||
import { content } from "@deadhead/content";
|
import { content } from "@deadhead/content";
|
||||||
import { createGuestResponseSchema, playerStateSchema, type PlayerState } from "@deadhead/schema";
|
import { createGuestResponseSchema, playerStateSchema, type PlayerState } from "@deadhead/schema";
|
||||||
import {
|
import {
|
||||||
|
boardPeriodIndex,
|
||||||
createDispatchRecord,
|
createDispatchRecord,
|
||||||
createRepositionRecord,
|
createRepositionRecord,
|
||||||
type Contract,
|
type Contract,
|
||||||
|
|
@ -11,7 +12,7 @@ import { eq } from "drizzle-orm";
|
||||||
import { afterEach, beforeEach } from "vitest";
|
import { afterEach, beforeEach } from "vitest";
|
||||||
import { buildApp } from "../src/app";
|
import { buildApp } from "../src/app";
|
||||||
import type { Database, Db } from "../src/db/client";
|
import type { Database, Db } from "../src/db/client";
|
||||||
import { dispatches, pilots, players, ships } from "../src/db/schema";
|
import { contractBoards, dispatches, pilots, players, ships } from "../src/db/schema";
|
||||||
import { TEST_NOW } from "./db";
|
import { TEST_NOW } from "./db";
|
||||||
import { testConfig } from "./helpers";
|
import { testConfig } from "./helpers";
|
||||||
|
|
||||||
|
|
@ -176,3 +177,16 @@ export async function insertDispatch(
|
||||||
await db.update(pilots).set({ status: "in_flight" }).where(eq(pilots.id, pilot!.id));
|
await db.update(pilots).set({ status: "in_flight" }).where(eq(pilots.id, pilot!.id));
|
||||||
return record;
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Stores a hand-made Earth bay board for the current period. */
|
||||||
|
export async function putBoard(db: Db, playerId: string, contracts: Contract[], now = TEST_NOW) {
|
||||||
|
await db.insert(contractBoards).values({
|
||||||
|
id: randomUUID(),
|
||||||
|
playerId,
|
||||||
|
kind: "bay",
|
||||||
|
locationId: "earth",
|
||||||
|
periodIndex: boardPeriodIndex(now, content.config),
|
||||||
|
contracts,
|
||||||
|
createdAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@
|
||||||
- [x] **Step 6.7 — End tutorial.** `maybeEndTutorial` (`src/game/tutorial.ts`) runs after each resolution pass and after every game action: with a ship, an Earth bay and no tutorial run in flight, tutorial mode turns off, the loaner is removed, and the avatar becomes an idle, unassigned pilot at Earth.
|
- [x] **Step 6.7 — End tutorial.** `maybeEndTutorial` (`src/game/tutorial.ts`) runs after each resolution pass and after every game action: with a ship, an Earth bay and no tutorial run in flight, tutorial mode turns off, the loaner is removed, and the avatar becomes an idle, unassigned pilot at Earth.
|
||||||
- [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.
|
||||||
- [ ] Step 6.10 — Contract dispatch
|
- [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
|
- [ ] Step 6.11 — Reposition dispatch
|
||||||
- [ ] Step 6.12 — Spend skill points
|
- [ ] Step 6.12 — Spend skill points
|
||||||
- [ ] Step 6.13 — Acknowledge summaries
|
- [ ] Step 6.13 — Acknowledge summaries
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue