216 lines
8.3 KiB
TypeScript
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 };
|