256 lines
9.2 KiB
TypeScript
256 lines
9.2 KiB
TypeScript
// Ported from apps/server/test (CR-014 Step 14.5): now runs on the engine.
|
|
import { content } from "@deadhead/content";
|
|
import {
|
|
contractBoardResponseSchema,
|
|
dispatchResponseSchema,
|
|
errorResponseSchema,
|
|
} from "@deadhead/schema";
|
|
import { boardPeriodMs, contractTripGameHours } from "@deadhead/sim";
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
TEST_NOW,
|
|
useTestApp,
|
|
signUp,
|
|
call,
|
|
getState,
|
|
edit,
|
|
readSave,
|
|
setCredits,
|
|
testContract,
|
|
putBoard,
|
|
makeOwner,
|
|
addPilot,
|
|
type Guest,
|
|
} from "../support";
|
|
const randomUUID = () => globalThis.crypto.randomUUID();
|
|
|
|
const testApp = useTestApp();
|
|
|
|
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(testApp().app, guest.avatarId);
|
|
await setCredits(testApp().app, 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 save = await readSave(testApp().app);
|
|
expect(save.dispatches).toEqual([]);
|
|
expect(save.acceptedContracts).toEqual([]);
|
|
expect(save.company.credits).toBe(10_000);
|
|
}
|
|
|
|
describe("contract dispatch", () => {
|
|
it("dispatches, burns fuel from the tank, stores the record and removes the contract from the board", async () => {
|
|
const { app, clock } = testApp();
|
|
const o = await owner();
|
|
const contract = testContract();
|
|
await putBoard(testApp().app, [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.fuelBurnedFu).toBeGreaterThan(0);
|
|
expect(record.fuelCost).toBeGreaterThan(0);
|
|
|
|
// Fuel comes out of the tank; no credits change hands (decision 0.5.94).
|
|
const state = await getState(app, o.guest);
|
|
expect(state.company.credits).toBe(10_000);
|
|
const full = content.shipModel("courier")!.tankCapacityFu;
|
|
expect(state.ships[0]!.fuelFu).toBeCloseTo(full - record.fuelBurnedFu);
|
|
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(testApp().app, [contract]);
|
|
await edit(testApp().app, (save) => {
|
|
save.ships.find((s) => s.id === o.shipId)!.status = "in-flight";
|
|
});
|
|
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(testApp().app, [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(testApp().app, [contract]);
|
|
await edit(testApp().app, (save) => {
|
|
save.pilots.find((p) => p.id === o.guest.avatarId)!.status = "in-flight";
|
|
});
|
|
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(testApp().app, [contract]);
|
|
const otherPilotId = randomUUID();
|
|
await addPilot(testApp().app, { id: otherPilotId, name: "Spare Pilot", locationId: "earth" });
|
|
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(testApp().app, [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(testApp().app, [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(testApp().app, [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(testApp().app, [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(testApp().app, [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 not-enough-fuel (CR-015)", async () => {
|
|
const o = await owner();
|
|
const contract = testContract();
|
|
await putBoard(testApp().app, [contract]);
|
|
await edit(testApp().app, (save) => {
|
|
save.ships.find((s) => s.id === o.shipId)!.fuelFu = 0.5;
|
|
});
|
|
const response = await dispatch(o, contract.id);
|
|
expect(errorCode(response)).toBe("not-enough-fuel");
|
|
expect((await readSave(testApp().app)).dispatches).toEqual([]);
|
|
});
|
|
|
|
it("rejects an unknown contract and a contract taken twice", async () => {
|
|
const o = await owner();
|
|
const contract = testContract();
|
|
await putBoard(testApp().app, [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");
|
|
});
|
|
});
|
|
|
|
describe("Priority contracts (CR-007)", () => {
|
|
const courier = content.shipModel("courier")!;
|
|
|
|
/** A Priority Earth→Moon mail job whose window is the Courier's trip plus `slackHours`. */
|
|
async function priorityJob(o: Owner, id: string, slackHours: number) {
|
|
const { clock } = testApp();
|
|
const base = testContract({ id, goodId: "mail", massTonnes: 4, priority: true });
|
|
const trip = contractTripGameHours(courier, base, content);
|
|
const contract = { ...base, deliveryWindowGameHours: trip + slackHours };
|
|
await putBoard(testApp().app, [contract], clock.now);
|
|
return contract;
|
|
}
|
|
|
|
it("refuses a ship too slow for the window with too-slow and changes nothing", async () => {
|
|
const o = await owner();
|
|
const job = await priorityJob(o, "earth:1:0", -0.25);
|
|
await expectRejected(o, await dispatch(o, job.id), "too-slow");
|
|
});
|
|
|
|
it("dispatches a ship fast enough for the window", async () => {
|
|
const o = await owner();
|
|
const job = await priorityJob(o, "earth:1:0", 0.25);
|
|
const response = await dispatch(o, job.id);
|
|
expect(response.statusCode).toBe(200);
|
|
const { dispatch: stored } = dispatchResponseSchema.parse(response.json());
|
|
expect(stored.contract).toMatchObject({ priority: true, goodId: "mail" });
|
|
});
|
|
|
|
it("serves generated boards whose contracts all name a known good and a shipper", async () => {
|
|
const o = await owner();
|
|
const response = await call(testApp().app, o.guest, "GET", "/api/locations/earth/board");
|
|
const { contracts } = contractBoardResponseSchema.parse(response.json());
|
|
expect(contracts.length).toBeGreaterThan(0);
|
|
for (const c of contracts) {
|
|
const good = content.good(c.goodId);
|
|
expect(good?.name).toBeTruthy();
|
|
expect(good!.cargoClassId).toBe(c.cargoTypeId);
|
|
expect(content.location("earth")!.shippers).toContain(c.shipper);
|
|
}
|
|
});
|
|
});
|