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

217 lines
6.1 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { content } from "@deadhead/content";
import { createGuestResponseSchema, playerStateSchema, type PlayerState } from "@deadhead/schema";
import {
boardPeriodIndex,
createDispatchRecord,
createRepositionRecord,
type Contract,
type DispatchRecord,
} from "@deadhead/sim";
import { eq } from "drizzle-orm";
import { afterEach, beforeEach } from "vitest";
import { buildApp } from "../src/app";
import { createRecordingQueue } from "../src/jobs/queue";
import type { Database, Db } from "../src/db/client";
import { contractBoards, dispatches, pilots, players, ships } from "../src/db/schema";
import { makeOwner, TEST_NOW } from "./db";
import { testConfig } from "./helpers";
/** A clock tests can move. Starts at `TEST_NOW`. */
export class TestClock {
now = TEST_NOW;
read = () => this.now;
advance(ms: number) {
this.now += ms;
}
}
export interface TestApp {
app: ReturnType<typeof buildApp>;
clock: TestClock;
/** Jobs the app scheduled; tests run their handlers directly. */
jobs: ReturnType<typeof createRecordingQueue>;
}
/** A fresh app per test on the shared test database, with a controllable clock. */
export function useTestApp(database: () => Database): () => TestApp {
let current: TestApp | undefined;
beforeEach(() => {
const clock = new TestClock();
const jobs = createRecordingQueue();
current = {
app: buildApp(testConfig(), { database: database(), clock: clock.read, jobs }),
clock,
jobs,
};
});
afterEach(() => current?.app.close());
return () => current!;
}
function cookieFrom(setCookie: string | string[] | undefined): string {
const values = Array.isArray(setCookie) ? setCookie : setCookie ? [setCookie] : [];
return values.map((c) => c.split(";")[0]).join("; ");
}
export interface Guest {
cookie: string;
playerId: string;
avatarId: string;
}
/** Signs up a guest through the API (a tutorial player) and returns its session. */
export async function signUp(app: TestApp["app"]): Promise<Guest> {
const response = await app.inject({ method: "POST", url: "/api/guest" });
const body = createGuestResponseSchema.parse(response.json());
return {
cookie: cookieFrom(response.headers["set-cookie"]),
playerId: body.playerId,
avatarId: body.state.pilots[0]!.id,
};
}
/** Calls an endpoint as a guest. */
export async function call(
app: TestApp["app"],
guest: Guest,
method: "GET" | "POST",
url: string,
payload?: object,
) {
return app.inject({
method,
url,
headers: { cookie: guest.cookie },
...(payload ? { payload } : {}),
});
}
export async function getState(app: TestApp["app"], guest: Guest): Promise<PlayerState> {
const response = await call(app, guest, "GET", "/api/state");
if (response.statusCode !== 200) throw new Error(`state failed: ${response.body}`);
return playerStateSchema.parse(response.json());
}
export function testContract(overrides: Partial<Contract> = {}): Contract {
return {
id: `test:${randomUUID()}`,
originId: "earth",
destinationId: "moon",
cargoTypeId: "standard",
tier: 0,
massTonnes: 10,
payout: 2000,
requiredSkill: null,
requiredLongHaulLevel: 0,
offeredAt: TEST_NOW,
expiresAt: TEST_NOW + 3_600_000,
firstContact: false,
...overrides,
};
}
/**
* Puts an in-flight dispatch straight into the database, as if it had been
* dispatched at `departAt`: the ship and pilot are marked in flight.
*/
export async function insertDispatch(
db: Db,
args: {
playerId: string;
shipId: string;
pilotId: string;
departAt: number;
contract?: Contract | null;
destinationId?: string;
},
): Promise<DispatchRecord> {
const [player] = await db.select().from(players).where(eq(players.id, args.playerId));
const [ship] = await db.select().from(ships).where(eq(ships.id, args.shipId));
const [pilot] = await db.select().from(pilots).where(eq(pilots.id, args.pilotId));
const shipState = {
id: ship!.id,
modelId: ship!.modelId,
locationId: ship!.locationId!,
inFlight: false,
};
const pilotState = {
id: pilot!.id,
isAvatar: pilot!.isAvatar,
assignedShipId: pilot!.assignedShipId,
inFlight: false,
skills: pilot!.skills,
restEndsAt: null,
};
const id = randomUUID();
const contract = args.contract === undefined ? testContract() : args.contract;
const record = contract
? createDispatchRecord(
{
id,
ship: shipState,
pilot: pilotState,
contract,
tutorial: false,
now: args.departAt,
worldSeed: player!.worldSeed,
},
content,
)
: createRepositionRecord(
{
id,
ship: shipState,
pilot: pilotState,
destinationId: args.destinationId!,
now: args.departAt,
worldSeed: player!.worldSeed,
},
content,
);
await db.insert(dispatches).values({
id,
playerId: args.playerId,
shipId: ship!.id,
pilotId: pilot!.id,
tutorial: false,
record,
arrivalAt: record.arrivalAt,
});
await db
.update(ships)
.set({ status: "in_flight", locationId: null })
.where(eq(ships.id, ship!.id));
await db.update(pilots).set({ status: "in_flight" }).where(eq(pilots.id, pilot!.id));
return record;
}
/** Stores a hand-made location board (Earth by default) for the period containing `now`. */
export async function putBoard(
db: Db,
playerId: string,
contracts: Contract[],
now = TEST_NOW,
locationId = "earth",
) {
await db.insert(contractBoards).values({
id: randomUUID(),
playerId,
kind: "location",
locationId,
periodIndex: boardPeriodIndex(now, content.config),
contracts,
createdAt: now,
});
}
/** Signs up a guest and turns them into an owner: tutorial over, a Courier at Earth crewed by the avatar, an Earth bay. */
export async function signUpOwner(
app: TestApp["app"],
db: Db,
): Promise<Guest & { shipId: string; earthBayId: string }> {
const guest = await signUp(app);
return { ...guest, ...(await makeOwner(db, guest.playerId, guest.avatarId)) };
}