157 lines
4.9 KiB
TypeScript
157 lines
4.9 KiB
TypeScript
import { content } from "@deadhead/content";
|
|
import type { PlayerState, RouteName, RouteRequest, RouteResponse, Ship } from "@deadhead/schema";
|
|
import { effectiveStats } from "@deadhead/sim";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { render } from "@testing-library/react";
|
|
import { MemoryRouter } from "react-router";
|
|
import { vi } from "vitest";
|
|
import { App } from "../src/App";
|
|
import { ApiError, type ApiClient } from "../src/api/client";
|
|
import { ApiProvider } from "../src/api/context";
|
|
|
|
type Handlers = {
|
|
[N in RouteName]?: (input: RouteRequest<N>) => RouteResponse<N> | Promise<RouteResponse<N>>;
|
|
};
|
|
|
|
/**
|
|
* A fake API: each route answers from its handler. Unhandled routes fail with
|
|
* `not-found`; `getServerTime` defaults to the device clock.
|
|
*/
|
|
export function fakeApi(handlers: Handlers) {
|
|
const call = vi.fn(async (name: RouteName, input: unknown) => {
|
|
if (name === "getServerTime" && !handlers.getServerTime) {
|
|
return { realTime: Date.now(), gameTime: 0 };
|
|
}
|
|
const handler = handlers[name] as ((input: unknown) => unknown) | undefined;
|
|
if (!handler) throw new ApiError(404, "not-found", `No fake for ${name}`);
|
|
return handler(input);
|
|
});
|
|
return { call } as unknown as ApiClient & { call: typeof call };
|
|
}
|
|
|
|
export const unauthorized = () => {
|
|
throw new ApiError(401, "unauthorized", "Sign in to continue.");
|
|
};
|
|
|
|
/** A brand-new tutorial player, as guest sign-up returns it. */
|
|
/**
|
|
* The CR-009 fields of a ship with no upgrades and not in the shop: its model's
|
|
* stats as effective stats. Spread into Ship fixtures, then override as needed.
|
|
*/
|
|
export function shipExtras(
|
|
modelId: string,
|
|
upgrades: Record<string, number> = {},
|
|
): Pick<Ship, "upgrades" | "stats" | "inShopUntil" | "shop"> {
|
|
const model = content.shipModel(modelId);
|
|
if (!model) throw new Error(`Unknown ship model ${modelId}`);
|
|
const s = effectiveStats(model, upgrades, content);
|
|
return {
|
|
upgrades,
|
|
stats: {
|
|
cruiseSpeedKmPerGameHour: s.cruiseSpeedKmPerGameHour,
|
|
cargoCapacityTonnes: s.cargoCapacityTonnes,
|
|
fuelRangeMillionKm: s.fuelRangeMillionKm,
|
|
overheadGameHours: s.overheadGameHours,
|
|
handlingGameHoursPerTonne: s.handlingGameHoursPerTonne,
|
|
},
|
|
inShopUntil: null,
|
|
shop: null,
|
|
};
|
|
}
|
|
|
|
export function samplePlayerState(overrides: Partial<PlayerState> = {}): PlayerState {
|
|
return {
|
|
playerId: "player-1",
|
|
company: { id: "co1", name: "Deadhead Freight", credits: 0, loanBalance: 0 },
|
|
ships: [],
|
|
pilots: [
|
|
{
|
|
id: "avatar",
|
|
name: "Captain",
|
|
isAvatar: true,
|
|
isVeteran: false,
|
|
xp: 0,
|
|
level: 0,
|
|
skills: {},
|
|
skillPointsAvailable: 0,
|
|
assignedShipId: null,
|
|
status: "idle",
|
|
locationId: "earth",
|
|
dutyHours: 0,
|
|
dutyLimit: 12,
|
|
resting: false,
|
|
restEndsAt: null,
|
|
},
|
|
],
|
|
bays: [],
|
|
locations: [
|
|
{ id: "earth", discovered: true },
|
|
{ id: "moon", discovered: true },
|
|
{ id: "mars", discovered: false },
|
|
{ id: "ceres-station", discovered: false },
|
|
{ id: "jupiter-cloud-platform", discovered: false },
|
|
],
|
|
tutorial: {
|
|
loanerLocationId: "earth",
|
|
runInFlight: false,
|
|
runsCompleted: 0,
|
|
dealAvailable: false,
|
|
},
|
|
activeDispatches: [],
|
|
summaries: [],
|
|
goals: [],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
/** Renders the whole app at `path` with a fresh query cache and the given API. */
|
|
export function renderApp(api: ApiClient, path = "/") {
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
|
});
|
|
const result = render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<ApiProvider api={api}>
|
|
<MemoryRouter initialEntries={[path]}>
|
|
<App />
|
|
</MemoryRouter>
|
|
</ApiProvider>
|
|
</QueryClientProvider>,
|
|
);
|
|
return { ...result, queryClient };
|
|
}
|
|
|
|
/** A tutorial board like the server's: light, medium and full Earth→Moon runs. */
|
|
export function sampleTutorialBoard(
|
|
now = Date.parse("2100-01-01T00:00:00Z"),
|
|
options: { scriptedMedium?: boolean } = {},
|
|
) {
|
|
const contract = (slot: number, massTonnes: number, payout: number) => ({
|
|
id: `tutorial:earth:1:0:${slot}`,
|
|
originId: "earth",
|
|
destinationId: "moon",
|
|
cargoTypeId: "standard",
|
|
goodId: "consumer-goods",
|
|
shipper: "Pacific Orbital Logistics",
|
|
priority: false,
|
|
deliveryWindowGameHours: null,
|
|
tier: 0,
|
|
massTonnes,
|
|
payout,
|
|
requiredSkill: null,
|
|
requiredLongHaulLevel: 0,
|
|
offeredAt: now,
|
|
expiresAt: now + 3_600_000,
|
|
firstContact: false,
|
|
});
|
|
const medium = contract(1, 12, options.scriptedMedium ? 461 : 230);
|
|
return {
|
|
contracts: [
|
|
contract(0, 5, 96),
|
|
options.scriptedMedium ? { ...medium, scriptedId: "unmarked-crates" } : medium,
|
|
contract(2, 20, 384),
|
|
],
|
|
periodEndsAt: now + 3_600_000,
|
|
};
|
|
}
|