179 lines
5.0 KiB
TypeScript
179 lines
5.0 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { content } from "@deadhead/content";
|
|
import { createGuestResponseSchema, playerStateSchema, type PlayerState } from "@deadhead/schema";
|
|
import {
|
|
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 type { Database, Db } from "../src/db/client";
|
|
import { dispatches, pilots, players, ships } from "../src/db/schema";
|
|
import { 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;
|
|
}
|
|
|
|
/** 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();
|
|
current = { app: buildApp(testConfig(), { database: database(), clock: clock.read }), clock };
|
|
});
|
|
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,
|
|
};
|
|
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;
|
|
}
|