408 lines
11 KiB
TypeScript
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,
|
|
});
|
|
});
|
|
}
|