deadhead-space-freight-tycoon/packages/engine/test/ported/tutorial-board.test.ts

119 lines
4.0 KiB
TypeScript

// Ported from apps/server/test (CR-014 Step 14.5): now runs on the engine.
import { content } from "@deadhead/content";
import { contractBoardResponseSchema, errorResponseSchema } from "@deadhead/schema";
import { boardPeriodMs, createDispatchRecord, type Contract } from "@deadhead/sim";
import { describe, expect, it } from "vitest";
import { useTestApp, signUp, call, edit, readSave, makeOwner, type Guest } from "../support";
const randomUUID = () => globalThis.crypto.randomUUID();
const testApp = useTestApp();
async function board(guest: Guest) {
const response = await call(testApp().app, guest, "GET", "/api/tutorial/board");
expect(response.statusCode).toBe(200);
return contractBoardResponseSchema.parse(response.json());
}
/** Records a contract as accepted, with the tutorial dispatch it would create. */
async function accept(guest: Guest, contract: Contract, now: number) {
const id = randomUUID();
const record = createDispatchRecord(
{
id,
ship: { id: "loaner", modelId: "courier", locationId: contract.originId, inFlight: false },
pilot: {
id: guest.avatarId,
isAvatar: true,
assignedShipId: null,
inFlight: false,
skills: {},
restEndsAt: null,
},
contract,
tutorial: true,
now,
worldSeed: 1,
},
content,
);
await edit(testApp().app, (save) => {
save.dispatches.push({
id,
shipId: null,
independent: false,
pilotId: guest.avatarId,
tutorial: true,
record,
arrivalAt: record.arrivalAt,
status: "in-flight",
resolution: null,
resolvedAt: null,
acknowledgedAt: null,
event: null,
eventApplied: null,
});
save.acceptedContracts.push({
contractId: contract.id,
periodIndex: 0,
dispatchId: id,
acceptedAt: now,
});
});
}
describe("tutorial board endpoint", () => {
it("gives a tutorial player at Earth a full board of Earth→Moon contracts", async () => {
const { app, clock } = testApp();
const guest = await signUp(app);
const { contracts, periodEndsAt } = await board(guest);
expect(contracts).toHaveLength(content.config.tutorialBoardSize);
for (const c of contracts) {
expect([c.originId, c.destinationId]).toEqual(["earth", "moon"]);
expect(c.cargoTypeId).toBe("standard");
}
expect(periodEndsAt).toBeGreaterThan(clock.now);
expect(periodEndsAt - clock.now).toBeLessThanOrEqual(boardPeriodMs(content.config));
});
it("offers Moon→Earth when the loaner is at the Moon", async () => {
const { app } = testApp();
const guest = await signUp(app);
await edit(app, (save) => {
save.player.loanerLocationId = "moon";
});
for (const c of (await board(guest)).contracts) {
expect([c.originId, c.destinationId]).toEqual(["moon", "earth"]);
}
});
it("stores the board: repeated requests in a period return the same contracts", async () => {
const { clock } = testApp();
const guest = await signUp(testApp().app);
const first = await board(guest);
clock.advance(60_000);
expect((await board(guest)).contracts).toEqual(first.contracts);
expect((await readSave(testApp().app)).contractBoards).toHaveLength(1);
});
it("hides contracts the player has already accepted", async () => {
const { clock } = testApp();
const guest = await signUp(testApp().app);
const before = await board(guest);
await accept(guest, before.contracts[0]!, clock.now);
const after = await board(guest);
expect(after.contracts).toHaveLength(before.contracts.length - 1);
expect(after.contracts.map((c) => c.id)).not.toContain(before.contracts[0]!.id);
});
it("refuses players who are past the tutorial", async () => {
const { app } = testApp();
const guest = await signUp(app);
await makeOwner(testApp().app, guest.avatarId);
const response = await call(app, guest, "GET", "/api/tutorial/board");
expect(response.statusCode).toBe(409);
expect(errorResponseSchema.parse(response.json()).error.code).toBe("not-in-tutorial");
});
});