deadhead-space-freight-tycoon/apps/server/test/veteran.test.ts

148 lines
5.5 KiB
TypeScript

import { content } from "@deadhead/content";
import {
dispatchResponseSchema,
errorResponseSchema,
playerStateSchema,
type PlayerState,
} from "@deadhead/schema";
import { pilotShare } from "@deadhead/sim";
import { describe, expect, it } from "vitest";
import { setCredits, useTestDatabase } from "./db";
import {
call,
getState,
insertDispatch,
putBoard,
signUp,
testContract,
useTestApp,
type Guest,
} from "./game";
import { flyTutorialRun } from "./tutorial-flow";
const database = useTestDatabase();
const testApp = useTestApp(database);
const errorCode = (response: { json(): unknown }) =>
errorResponseSchema.parse(response.json()).error.code;
const veteranOf = (state: PlayerState) => state.pilots.find((p) => p.isVeteran)!;
const avatarOf = (state: PlayerState) => state.pilots.find((p) => p.isAvatar)!;
const FAR_FUTURE = Date.parse("2200-01-01T00:00:00Z");
/** A guest who flies the tutorial runs and accepts the deal. */
async function dealt(): Promise<{ guest: Guest; state: PlayerState }> {
const guest = await signUp(testApp().app);
for (let run = 0; run < content.config.tutorialRunsBeforeDeal; run++) {
await flyTutorialRun(testApp(), guest);
}
const response = await call(testApp().app, guest, "POST", "/api/deal/accept", {});
expect(response.statusCode).toBe(200);
return { guest, state: playerStateSchema.parse(response.json()) };
}
describe("the veteran comes with the deal (Step 10J.4)", () => {
it("adds the veteran at Earth, unassigned, level 2 with Expensive Cargo 1", async () => {
const { state } = await dealt();
expect(state.pilots).toHaveLength(2);
const courier = state.ships[0]!;
expect(avatarOf(state)).toMatchObject({ assignedShipId: courier.id, locationId: "earth" });
const spentPoints = 1;
expect(veteranOf(state)).toMatchObject({
name: content.veteranPilot.name,
isAvatar: false,
isVeteran: true,
level: 2,
skills: { "expensive-cargo": 1 },
skillPointsAvailable: 2 * content.config.skillPointsPerLevel - spentPoints,
assignedShipId: null,
locationId: "earth",
status: "idle",
});
});
it("creates no veteran on a refused or repeated accept", async () => {
const guest = await signUp(testApp().app);
const early = await call(testApp().app, guest, "POST", "/api/deal/accept", {});
expect(errorCode(early)).toBe("deal-not-available");
expect((await getState(testApp().app, guest)).pilots).toHaveLength(1);
const { guest: owner } = await dealt();
const again = await call(testApp().app, owner, "POST", "/api/deal/accept", {});
expect(again.statusCode).toBe(409);
const pilots = (await getState(testApp().app, owner)).pilots;
expect(pilots.filter((p) => p.isVeteran)).toHaveLength(1);
});
it("swaps in the veteran while the avatar rests, and pays the veteran a profit share", async () => {
const { app, clock } = testApp();
const { guest, state } = await dealt();
const courier = state.ships[0]!;
const veteran = veteranOf(state);
await setCredits(database().db, guest.playerId, 10_000);
expect(
(await call(app, guest, "POST", `/api/pilots/${guest.avatarId}/rest`, {})).statusCode,
).toBe(200);
expect(
(await call(app, guest, "POST", `/api/pilots/${guest.avatarId}/unassign`, {})).statusCode,
).toBe(200);
expect(
(await call(app, guest, "POST", `/api/pilots/${veteran.id}/assign`, { shipId: courier.id }))
.statusCode,
).toBe(200);
const job = testContract({ payout: 2000, expiresAt: FAR_FUTURE });
await putBoard(database().db, guest.playerId, [job], clock.now);
const response = await call(app, guest, "POST", "/api/dispatches", {
kind: "contract",
contractId: job.id,
shipId: courier.id,
pilotId: veteran.id,
});
expect(response.statusCode).toBe(200);
const { dispatch } = dispatchResponseSchema.parse(response.json());
clock.now = dispatch.arrivalAt;
const after = await getState(app, guest);
const summary = after.summaries.find((s) => s.dispatchId === dispatch.id)!;
const pay = pilotShare(job.payout, dispatch.fuelCost, content.config);
expect(pay).toBeGreaterThan(0);
expect(summary.credits + summary.loanRepayment).toBe(job.payout - pay);
expect(after.goals.find((g) => g.id === "veteran-contract")?.status).toBe("ready");
});
it("counts only contracts the veteran flew toward goal 4", async () => {
const { app, clock } = testApp();
const { guest, state } = await dealt();
const courier = state.ships[0]!;
const veteran = veteranOf(state);
const goal = async () =>
(await getState(app, guest)).goals.find((g) => g.id === "veteran-contract")?.status;
// The avatar's delivery doesn't count.
const byAvatar = await insertDispatch(database().db, {
playerId: guest.playerId,
shipId: courier.id,
pilotId: guest.avatarId,
departAt: clock.now,
contract: testContract({ expiresAt: FAR_FUTURE }),
});
clock.now = byAvatar.arrivalAt;
expect(await goal()).toBe("incomplete");
// A deadhead by the veteran doesn't count either.
await call(app, guest, "POST", `/api/pilots/${guest.avatarId}/unassign`, {});
await call(app, guest, "POST", `/api/pilots/${veteran.id}/assign`, { shipId: courier.id });
const deadhead = await insertDispatch(database().db, {
playerId: guest.playerId,
shipId: courier.id,
pilotId: veteran.id,
departAt: clock.now,
contract: null,
destinationId: "earth",
});
clock.now = deadhead.arrivalAt;
expect(await goal()).toBe("incomplete");
});
});