143 lines
5.5 KiB
TypeScript
143 lines
5.5 KiB
TypeScript
import { randomInt } from "node:crypto";
|
|
import { content } from "@deadhead/content";
|
|
import { routes } from "@deadhead/schema";
|
|
import { realToGame } from "@deadhead/sim";
|
|
import { fromNodeHeaders } from "better-auth/node";
|
|
import type { FastifyInstance } from "fastify";
|
|
import { acceptDeal } from "../game/deal";
|
|
import { claimGoal } from "../game/goals";
|
|
import {
|
|
backupCodeStatus,
|
|
createBackupCode,
|
|
playerForBackupCode,
|
|
RestoreLimiter,
|
|
} from "../game/backup";
|
|
import { renameCompany } from "../game/company";
|
|
import { repayLoan } from "../game/loan";
|
|
import { newCompany } from "../game/new-company";
|
|
import { createPlayer } from "../game/new-player";
|
|
import { loadPlayerState } from "../game/player-state";
|
|
import { acknowledgeFinishedUpgrades, acknowledgeSummaries } from "../game/summaries";
|
|
import { playerLogins } from "../db/schema";
|
|
import { ApiError } from "../http/errors";
|
|
import { registerRoute } from "../http/route";
|
|
import type { AppContext } from "./context";
|
|
import { withPlayer } from "./with-player";
|
|
|
|
/** Server time, guest sign-up and player state. */
|
|
export function registerPlayerRoutes(app: FastifyInstance, ctx: AppContext): void {
|
|
const { db, auth, clock, options } = ctx;
|
|
|
|
registerRoute(app, routes.getServerTime, options, async () => {
|
|
const realTime = clock();
|
|
return { realTime, gameTime: realToGame(realTime, content.config) };
|
|
});
|
|
|
|
registerRoute(app, routes.createGuest, options, async (_input, { request, reply }) => {
|
|
const signIn = await auth.api.signInAnonymous({
|
|
headers: fromNodeHeaders(request.headers),
|
|
returnHeaders: true,
|
|
});
|
|
if (!signIn.response) throw new ApiError(500, "internal-error", "Could not create a guest.");
|
|
|
|
const { playerId } = await createPlayer(db, {
|
|
userId: signIn.response.user.id,
|
|
// A 32-bit unsigned seed (decision 0.5.10).
|
|
worldSeed: randomInt(0, 2 ** 32),
|
|
now: clock(),
|
|
});
|
|
// Pass Better Auth's session cookie through to the client.
|
|
reply.header("set-cookie", signIn.headers.getSetCookie());
|
|
return { playerId, state: await loadPlayerState(db, playerId, clock()) };
|
|
});
|
|
|
|
// Lazy resolution on read (Step 6.1): apply every arrival before returning state.
|
|
registerRoute(app, routes.getPlayerState, options, async (_input, { playerId }) =>
|
|
withPlayer(ctx, playerId, (tx, now) => loadPlayerState(tx, playerId, now)),
|
|
);
|
|
|
|
registerRoute(app, routes.acknowledgeSummaries, options, async (input, { playerId }) =>
|
|
withPlayer(ctx, playerId, async (tx, now) => ({
|
|
acknowledged:
|
|
(await acknowledgeSummaries(tx, playerId, input.dispatchIds, now)) +
|
|
(await acknowledgeFinishedUpgrades(tx, playerId, input.shipIds ?? [], now)),
|
|
})),
|
|
);
|
|
|
|
registerRoute(app, routes.acceptDeal, options, async (_input, { playerId }) =>
|
|
withPlayer(ctx, playerId, async (tx, now) => {
|
|
await acceptDeal(tx, playerId, now);
|
|
return loadPlayerState(tx, playerId, now);
|
|
}),
|
|
);
|
|
|
|
registerRoute(app, routes.getBackupCode, options, async (_input, { playerId }) =>
|
|
withPlayer(ctx, playerId, (tx) => backupCodeStatus(tx, playerId)),
|
|
);
|
|
|
|
registerRoute(app, routes.createBackupCode, options, async (_input, { playerId }) =>
|
|
withPlayer(ctx, playerId, (tx, now) => createBackupCode(tx, playerId, now)),
|
|
);
|
|
|
|
// Restoring signs this device in as the code's player (decisions 0.5.72, 0.5.73).
|
|
const limiter = new RestoreLimiter();
|
|
registerRoute(app, routes.restoreCompany, options, async (input, { request, reply }) => {
|
|
const now = clock();
|
|
const device = request.headers["x-device-id"];
|
|
const keys = [`ip:${request.ip}`, ...(typeof device === "string" ? [`device:${device}`] : [])];
|
|
if (limiter.blocked(keys, now)) {
|
|
throw new ApiError(
|
|
429,
|
|
"rate-limited",
|
|
"Too many attempts. Wait a few minutes and try again.",
|
|
);
|
|
}
|
|
const playerId = await playerForBackupCode(db, input.code);
|
|
if (!playerId) {
|
|
limiter.fail(keys, now);
|
|
throw new ApiError(
|
|
409,
|
|
"restore-failed",
|
|
"That code didn't open a company. Check it and try again.",
|
|
);
|
|
}
|
|
// A fresh guest sign-in for this device (any old session is left behind),
|
|
// linked to the code's player.
|
|
const headers = fromNodeHeaders(request.headers);
|
|
headers.delete("cookie");
|
|
const signIn = await auth.api.signInAnonymous({ headers, returnHeaders: true });
|
|
if (!signIn.response)
|
|
throw new ApiError(500, "internal-error", "Could not sign this device in.");
|
|
await db
|
|
.insert(playerLogins)
|
|
.values({ userId: signIn.response.user.id, playerId, createdAt: now });
|
|
reply.header("set-cookie", signIn.headers.getSetCookie());
|
|
return { playerId, state: await loadPlayerState(db, playerId, now) };
|
|
});
|
|
|
|
registerRoute(app, routes.newCompany, options, async (_input, { playerId }) =>
|
|
withPlayer(ctx, playerId, async (tx, now) => {
|
|
await newCompany(tx, playerId, now);
|
|
return loadPlayerState(tx, playerId, now);
|
|
}),
|
|
);
|
|
|
|
registerRoute(app, routes.renameCompany, options, async (input, { playerId }) =>
|
|
withPlayer(ctx, playerId, async (tx) => ({
|
|
company: await renameCompany(tx, playerId, input.name),
|
|
})),
|
|
);
|
|
|
|
registerRoute(app, routes.repayLoan, options, async (input, { playerId }) =>
|
|
withPlayer(ctx, playerId, async (tx) => ({
|
|
company: await repayLoan(tx, playerId, input.amount),
|
|
})),
|
|
);
|
|
|
|
registerRoute(app, routes.claimGoal, options, async (input, { playerId }) =>
|
|
withPlayer(ctx, playerId, async (tx, now) => ({
|
|
company: await claimGoal(tx, playerId, input.goalId, now),
|
|
})),
|
|
);
|
|
}
|