deadhead-space-freight-tycoon/apps/server/test/tutorial-board.test.ts

115 lines
4.1 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { content } from "@deadhead/content";
import { contractBoardResponseSchema, errorResponseSchema } from "@deadhead/schema";
import { boardPeriodMs, createDispatchRecord, type Contract } from "@deadhead/sim";
import { eq } from "drizzle-orm";
import { describe, expect, it } from "vitest";
import type { Db } from "../src/db/client";
import { acceptedContracts, contractBoards, dispatches, players } from "../src/db/schema";
import { makeOwner, useTestDatabase } from "./db";
import { call, signUp, useTestApp, type Guest } from "./game";
const database = useTestDatabase();
const testApp = useTestApp(database);
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(db: Db, 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: {},
},
contract,
tutorial: true,
now,
worldSeed: 1,
},
content,
);
await db.insert(dispatches).values({
id,
playerId: guest.playerId,
pilotId: guest.avatarId,
tutorial: true,
record,
arrivalAt: record.arrivalAt,
});
await db.insert(acceptedContracts).values({
playerId: guest.playerId,
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 database()
.db.update(players)
.set({ loanerLocationId: "moon" })
.where(eq(players.id, guest.playerId));
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 database().db.select().from(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(database().db, 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(database().db, guest.playerId, 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");
});
});