deadhead-space-freight-tycoon/packages/engine/test/support.ts

408 lines
11 KiB
TypeScript

import { content } from "@deadhead/content";
import { newPilotOrders } from "../src/game/independent";
import {
createGuestResponseSchema,
playerStateSchema,
routes,
type Contract,
type PlayerState,
type SaveDocument,
} from "@deadhead/schema";
import {
boardPeriodIndex,
boardPeriodMs,
createDispatchRecord,
createRepositionRecord,
type DispatchRecord,
} from "@deadhead/sim";
const randomUUID = () => globalThis.crypto.randomUUID();
import { beforeEach } from "vitest";
import {
CommandError,
GameEngine,
MemoryBackend,
NOT_FOUND_CODES,
SaveStorage,
type CommandName,
} from "../src/index";
import { deliveredTank } from "../src/game/state";
/**
* Helpers for the ported server tests (CR-014 Step 14.5): the same names and
* shapes as the server's test helpers, over an engine with in-memory storage
* and a clock the test sets. `call` routes an HTTP-style request to its
* command and answers with the status the server would have sent.
*/
export const TEST_NOW = Date.parse("2100-01-01T00:00:00Z");
/** A clock tests can move. Starts at `TEST_NOW`. */
export class TestClock {
now = TEST_NOW;
advance(ms: number) {
this.now += ms;
}
}
export interface TestApp {
app: GameEngine;
clock: TestClock;
storage: SaveStorage;
backend: MemoryBackend;
}
export function newTestApp(options: { devTools?: boolean } = {}): TestApp {
const clock = new TestClock();
const backend = new MemoryBackend();
const storage = new SaveStorage(backend);
const app = new GameEngine({
storage,
clock: {
now: () => clock.now,
advance: (ms) => clock.advance(ms),
get offsetMs() {
return clock.now - TEST_NOW;
},
},
devTools: options.devTools ?? true,
});
return { app, clock, storage, backend };
}
/** A fresh engine and storage per test. */
export function useTestApp(options: { devTools?: boolean } = {}): () => TestApp {
let current: TestApp | undefined;
beforeEach(() => {
current = newTestApp(options);
});
return () => current!;
}
export interface Guest {
playerId: string;
avatarId: string;
}
/** "Start your company" (the old guest sign-up). */
export async function signUp(app: GameEngine): Promise<Guest> {
const body = createGuestResponseSchema.parse(await app.call("createGuest", {}));
return { playerId: body.playerId, avatarId: body.state.pilots[0]!.id };
}
function routeFor(
method: string,
url: string,
): { name: CommandName; params: Record<string, string> } {
const path = url.split("?")[0]!;
for (const [name, route] of Object.entries(routes)) {
if (route.method !== method) continue;
const pattern = new RegExp(`^${route.path.replace(/:(\w+)/g, "(?<$1>[^/]+)")}$`);
const match = pattern.exec(path);
if (match) return { name: name as CommandName, params: { ...match.groups } };
}
throw new Error(`No command for ${method} ${url}`);
}
export interface CallResult {
statusCode: number;
body: string;
json(): unknown;
}
/** Calls a command the way the server test called an endpoint. */
export async function call(
app: GameEngine,
_guest: Guest | null,
method: "GET" | "POST",
url: string,
payload?: object,
): Promise<CallResult> {
const { name, params } = routeFor(method, url);
const query = Object.fromEntries(new URL(url, "http://x").searchParams);
const input = { ...query, ...(payload ?? {}), ...params };
const reply = (statusCode: number, data: unknown): CallResult => {
const body = JSON.stringify(data);
return { statusCode, body, json: () => JSON.parse(body) };
};
try {
return reply(200, await app.call(name, input as never));
} catch (error) {
if (!(error instanceof CommandError)) throw error;
const status =
error.code === "validation-error"
? 400
: error.code === "unauthorized"
? 401
: NOT_FOUND_CODES.has(error.code)
? 404
: 409;
return reply(status, { error: { code: error.code, message: error.message } });
}
}
export async function getState(app: GameEngine, _guest?: Guest): Promise<PlayerState> {
return playerStateSchema.parse(await app.call("getPlayerState", {}));
}
/** Changes the save directly (what the server tests did with database updates). */
export function edit<T = void>(app: GameEngine, fn: (save: SaveDocument) => T): Promise<T> {
return app.core.write((save) => {
if (!save) throw new Error("No company to edit");
const result = fn(save);
return { save, result };
});
}
/** Reads the save directly. */
export function readSave(app: GameEngine): Promise<SaveDocument> {
return app.core.read((save) => {
if (!save) throw new Error("No company");
return structuredClone(save);
});
}
export function setCredits(app: GameEngine, credits: number): Promise<void> {
return edit(app, (save) => {
save.company.credits = credits;
});
}
export function testContract(overrides: Partial<Contract> = {}): Contract {
return {
id: `test:${randomUUID()}`,
originId: "earth",
destinationId: "moon",
cargoTypeId: "standard",
goodId: "consumer-goods",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
tier: 0,
massTonnes: 10,
payout: 2000,
requiredSkill: null,
requiredLongHaulLevel: 0,
offeredAt: TEST_NOW,
// Contracts expire at the end of their board period (TEST_NOW is a period start).
expiresAt: TEST_NOW + boardPeriodMs(content.config),
firstContact: false,
...overrides,
};
}
/**
* Puts an in-flight dispatch straight into the save, as if it had been
* dispatched at `departAt`: the ship and pilot are marked in flight.
*/
export function insertDispatch(
app: GameEngine,
args: {
shipId: string;
pilotId: string;
departAt: number;
contract?: Contract | null;
destinationId?: string;
},
): Promise<DispatchRecord> {
return edit(app, (save) => {
const ship = save.ships.find((s) => s.id === args.shipId)!;
const pilot = save.pilots.find((p) => p.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: save.player.worldSeed,
},
content,
)
: createRepositionRecord(
{
id,
ship: shipState,
pilot: pilotState,
destinationId: args.destinationId!,
now: args.departAt,
worldSeed: save.player.worldSeed,
},
content,
);
save.dispatches.push({
id,
shipId: ship.id,
pilotId: pilot.id,
tutorial: false,
record,
arrivalAt: record.arrivalAt,
status: "in-flight",
resolution: null,
resolvedAt: null,
acknowledgedAt: null,
});
Object.assign(ship, { status: "in-flight", locationId: null });
pilot.status = "in-flight";
return record;
});
}
/** Stores a hand-made location board (Earth by default) for the period containing `now`. */
export function putBoard(
app: GameEngine,
contracts: Contract[],
now = TEST_NOW,
locationId = "earth",
): Promise<void> {
return edit(app, (save) => {
save.contractBoards.push({
id: randomUUID(),
kind: "location",
locationId,
periodIndex: boardPeriodIndex(now, content.config),
tutorialRun: 0,
contracts,
fleet: null,
createdAt: now,
});
});
}
/**
* Turns a tutorial company into an owner: tutorial over, one Courier at Earth
* crewed by the avatar, an Earth bay. Returns the ship and bay ids.
*/
export function makeOwner(
app: GameEngine,
avatarId: string,
): Promise<{ shipId: string; earthBayId: string }> {
const shipId = randomUUID();
const earthBayId = randomUUID();
return edit(app, (save) => {
Object.assign(save.player, { tutorialActive: false, loanerLocationId: null });
save.ships.push({
id: shipId,
modelId: "courier",
name: "Test Courier",
status: "idle",
locationId: "earth",
upgrades: {},
inShopUntil: null,
shopUpgradeId: null,
shopLevel: null,
// Stands in for the deal's Courier (not a Market purchase, CR-013), with a full tank.
fromDeal: true,
createdAt: TEST_NOW,
lastArrivalAt: TEST_NOW,
...deliveredTank("courier", "earth"),
});
Object.assign(
save.pilots.find((p) => p.id === avatarId)!,
{
assignedShipId: shipId,
locationId: null,
},
);
save.bays.push({ id: earthBayId, locationId: "earth", createdAt: TEST_NOW });
return { shipId, earthBayId };
});
}
/** A new company turned into an owner (see `makeOwner`). */
export async function signUpOwner(
app: GameEngine,
): Promise<Guest & { shipId: string; earthBayId: string }> {
const guest = await signUp(app);
return { ...guest, ...(await makeOwner(app, guest.avatarId)) };
}
/** The tutorial board the player sees now. */
export async function tutorialBoard(app: GameEngine, _guest?: Guest): Promise<Contract[]> {
return (await app.call("getTutorialBoard", {})).contracts;
}
/** Flies one tutorial run (the given slot, light by default) and lets it land. */
export async function flyTutorialRun(
testApp: TestApp,
_guest?: Guest,
slot = 0,
): Promise<PlayerState> {
const { app, clock } = testApp;
const contract = (await tutorialBoard(app))[slot]!;
const { dispatch } = await app.call("createDispatch", {
kind: "tutorial",
contractId: contract.id,
});
clock.now = dispatch.arrivalAt;
return getState(app);
}
type SavedShip = SaveDocument["ships"][number];
type SavedPilot = SaveDocument["pilots"][number];
/** Adds a ship to the save (idle, uncrewed, no upgrades unless given). */
export function addShip(
app: GameEngine,
ship: Partial<SavedShip> & Pick<SavedShip, "id" | "modelId" | "name">,
): Promise<void> {
return edit(app, (save) => {
save.ships.push({
status: "idle",
locationId: "earth",
upgrades: {},
inShopUntil: null,
shopUpgradeId: null,
shopLevel: null,
fromDeal: false,
createdAt: TEST_NOW,
lastArrivalAt: TEST_NOW,
// A full tank, bought where the ship is (decision 0.5.94).
...deliveredTank(ship.modelId, ship.locationId ?? "earth"),
...ship,
});
});
}
/** Adds a pilot to the save (idle, level 0, unassigned unless given). */
export function addPilot(
app: GameEngine,
pilot: Partial<SavedPilot> & Pick<SavedPilot, "id" | "name">,
): Promise<void> {
return edit(app, (save) => {
save.pilots.push({
isAvatar: false,
isVeteran: false,
xp: 0,
skills: {},
skillPointsSpent: 0,
assignedShipId: null,
locationId: null,
status: "idle",
dutyHours: 0,
restEndsAt: null,
createdAt: TEST_NOW,
mode: "manual",
orders: newPilotOrders("earth"),
...pilot,
});
});
}