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