deadhead-space-freight-tycoon/packages/engine/src/game/engine.ts

216 lines
8.3 KiB
TypeScript

import { content } from "@deadhead/content";
import {
routes,
type RouteName,
type RouteRequest,
type RouteResponse,
type SaveDocument,
type SaveParse,
} from "@deadhead/schema";
import { realToGame, type RealTime } from "@deadhead/sim";
import type { z } from "zod";
import { EngineCore, type CoreDeps, type RandomSource } from "../core";
import type { NotificationSync } from "../notifications";
import { decodeSave, encodeSave } from "../save-code";
import { commandTable } from "./commands";
import { startingSave } from "./company";
import { CommandError, fail } from "./errors";
import { pruneSave, resolveArrivals } from "./resolve";
import { playerState } from "./state";
/** The commands the engine runs: the whole route catalog (CR-014). */
export type CommandName = RouteName;
export const DEV_COMMANDS: readonly CommandName[] = [
"devAdvanceClock",
"devAddCredits",
"devPilot",
"devShip",
"devDiscoverAll",
];
/** What a command gets: a copy of the save (with due arrivals already applied). */
export interface CommandContext {
save: SaveDocument;
now: RealTime;
random: RandomSource;
}
export type CommandHandler<N extends CommandName> = (
ctx: CommandContext,
input: z.output<(typeof routes)[N]["request"]>,
) => RouteResponse<N>;
/** Commands that run before a company exists, or touch the clock rather than the save. */
type SpecialCommand =
"getServerTime" | "createGuest" | "newCompany" | "devAdvanceClock" | "exportSave" | "importSave";
export type CommandTable = { [N in Exclude<CommandName, SpecialCommand>]: CommandHandler<N> };
export interface GameEngineOptions extends CoreDeps {
clock: CoreDeps["clock"] & { advance?(ms: number): void; offsetMs?: number };
/** Developer commands (non-production builds only, CR-011). */
devTools?: boolean;
/** Check every response against its schema (tests and development). Default true. */
validateResponses?: boolean;
/** Replaces command handlers (tests only). */
overrides?: Partial<CommandTable>;
/** Local notifications (decision 0.5.91); none if absent. */
notifications?: NotificationSync;
/** Migrates and validates an imported save (tests replace it). Default: the schema's `migrateSave`. */
migrate?: (raw: unknown) => SaveParse;
}
function describeIssues(error: z.ZodError): string {
return error.issues
.map((issue) => (issue.path.length ? `${issue.path.join(".")}: ` : "") + issue.message)
.join("; ");
}
const noCompany = () => fail("unauthorized", "There is no company on this device yet.");
/**
* The game on the device (CR-014): every former server endpoint as a command
* with the same request and response schemas. Each command first applies any
* arrivals that are due (as the server's lazy resolution did), runs on a copy
* of the save through the single-writer queue, and is saved only if it changed
* something.
*/
export class GameEngine {
readonly core: EngineCore;
readonly #table: CommandTable;
constructor(private readonly options: GameEngineOptions) {
const notifications = options.notifications;
this.core = new EngineCore({
...options,
afterSave: notifications ? (save, now) => notifications.sync(save, now) : options.afterSave,
});
this.#table = { ...commandTable(), ...options.overrides } as CommandTable;
}
now(): RealTime {
return this.core.now();
}
/** Reschedules notifications from the current save (the Settings toggle changed). */
resyncNotifications(): Promise<void> {
const notifications = this.options.notifications;
if (!notifications) return Promise.resolve();
return this.core.read((save, now) => notifications.sync(save, now));
}
/** Asks for notification permission (right after the deal, decision 0.5.91). */
requestNotificationPermission(): Promise<void> {
return this.options.notifications?.requestPermission() ?? Promise.resolve();
}
/**
* The resolution loop's pass (Step 14.6): applies any due arrivals and counts
* rests and shop jobs that ended after `since`. Saves only when an arrival
* was resolved; does nothing before a company exists.
*/
resolveDue(
since: RealTime | null,
): Promise<{ resolved: number; eventsPassed: number; now: RealTime }> {
return this.core.write((save, now) => {
if (!save)
return { save: save!, result: { resolved: 0, eventsPassed: 0, now }, unchanged: true };
const resolved = resolveArrivals(save, now);
if (resolved > 0) pruneSave(save, now);
const ended = (t: RealTime | null) => since !== null && t !== null && t > since && t <= now;
const eventsPassed =
save.pilots.filter((p) => ended(p.restEndsAt)).length +
save.ships.filter((s) => ended(s.inShopUntil)).length;
return { save, result: { resolved, eventsPassed, now }, unchanged: resolved === 0 };
});
}
/** Runs one command; refusals throw `CommandError` with the server's codes. */
async call<N extends CommandName>(name: N, input: RouteRequest<N>): Promise<RouteResponse<N>> {
const route = routes[name];
if (!route) {
throw fail("not-found", `No command "${name}".`);
}
if (DEV_COMMANDS.includes(name) && !this.options.devTools) {
throw fail("not-found", `No command "${name}".`);
}
const parsed = route.request.safeParse(input);
if (!parsed.success) throw fail("validation-error", describeIssues(parsed.error));
const result = await this.#run(name, parsed.data);
if (this.options.validateResponses ?? true) {
const checked = route.response.safeParse(result);
if (!checked.success) {
throw new Error(
`Response for ${name} violates its schema: ${describeIssues(checked.error)}`,
);
}
}
return result as RouteResponse<N>;
}
async #run(name: CommandName, input: unknown): Promise<unknown> {
const { random } = this.core;
switch (name) {
case "getServerTime": {
const realTime = this.core.now();
return { realTime, gameTime: realToGame(realTime, content.config) };
}
case "devAdvanceClock": {
const clock = this.options.clock;
if (!clock.advance) throw fail("not-found", "This clock can't be moved.");
clock.advance((input as { advanceMs: number }).advanceMs);
return { offsetMs: clock.offsetMs ?? 0, realTime: this.core.now() };
}
case "createGuest":
// "Start your company": a new save when there is none; otherwise the existing one.
return this.core.write((save, now) => {
const next = save ?? startingSave(random, now);
resolveArrivals(next, now);
return {
save: next,
result: { playerId: next.saveId, state: playerState(next, now) },
unchanged: save !== null,
};
});
case "newCompany":
// "Start a new company" (decision 0.5.74): the starting state with a new world seed.
return this.core.write((save, now) => {
if (!save) throw noCompany();
const next = startingSave(random, now, {
saveId: save.saveId,
avoidSeed: save.player.worldSeed,
});
return { save: next, result: playerState(next, now) };
});
case "exportSave":
return this.core.read(async (save) => {
if (!save) throw noCompany();
return { code: await encodeSave(save) };
});
case "importSave": {
// Decoded before the queue, so a bad code never waits behind or blocks a write.
const parsed = await decodeSave((input as { code: string }).code, this.options.migrate);
if (!parsed.ok) throw fail("invalid-save", parsed.error);
return this.core.write((_current, now) => {
const next = parsed.save;
resolveArrivals(next, now);
pruneSave(next, now);
return { save: next, result: { playerId: next.saveId, state: playerState(next, now) } };
});
}
default: {
const handler = this.#table[name] as (ctx: CommandContext, input: unknown) => unknown;
return this.core.write((save, now) => {
if (!save) throw noCompany();
const before = JSON.stringify(save);
resolveArrivals(save, now);
const result = handler({ save, now, random }, input);
pruneSave(save, now);
return { save, result, unchanged: JSON.stringify(save) === before };
});
}
}
}
}
export { CommandError };