deadhead-space-freight-tycoon/apps/server/src/routes/player.ts

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),
})),
);
}