deadhead-space-freight-tycoon/apps/server/test/contract-dispatch.test.ts

208 lines
7.6 KiB
TypeScript

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/locations/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",
goodId: "machine-parts",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
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");
});
});