import { randomUUID } from "node:crypto"; import { content } from "@deadhead/content"; import { boardPeriodIndex, boardPeriodMs, generateContractBoard, generateTutorialBoard, type Contract, type FleetMember, type RealTime, } from "@deadhead/sim"; import { and, asc, eq } from "drizzle-orm"; import type { Queryable } from "../db/client"; import { acceptedContracts, contractBoards, discoveredLocations, pilots, players, ships, } from "../db/schema"; import { gameError } from "../http/errors"; export interface BoardView { contracts: Contract[]; periodEndsAt: RealTime; } /** * Returns the stored board for (player, kind, location, period), generating and * storing it on the first request (decisions 0.5.5 and 0.5.21). Concurrent * first requests store one board: the insert ignores conflicts and re-reads. */ export async function getOrCreateBoard( tx: Queryable, key: { playerId: string; kind: "location" | "tutorial"; locationId: string; periodIndex: number; /** Tutorial boards: runs completed (CR-001). Location boards: 0. */ tutorialRun?: number; }, generate: () => Contract[] | Promise, now: RealTime, ): Promise { const where = and( eq(contractBoards.playerId, key.playerId), eq(contractBoards.kind, key.kind), eq(contractBoards.locationId, key.locationId), eq(contractBoards.periodIndex, key.periodIndex), eq(contractBoards.tutorialRun, key.tutorialRun ?? 0), ); const [existing] = await tx.select().from(contractBoards).where(where); if (existing) return existing.contracts; await tx .insert(contractBoards) .values({ id: randomUUID(), ...key, tutorialRun: key.tutorialRun ?? 0, contracts: await generate(), createdAt: now, }) .onConflictDoNothing(); const [stored] = await tx.select().from(contractBoards).where(where); return stored!.contracts; } export async function acceptedContractIds(tx: Queryable, playerId: string): Promise> { const rows = await tx .select({ contractId: acceptedContracts.contractId }) .from(acceptedContracts) .where(eq(acceptedContracts.playerId, playerId)); return new Set(rows.map((r) => r.contractId)); } /** Board contracts minus the ones this player already accepted. */ export async function boardView( tx: Queryable, playerId: string, contracts: Contract[], periodIndex: number, ): Promise { const accepted = await acceptedContractIds(tx, playerId); return { contracts: contracts.filter((c) => !accepted.has(c.id)), periodEndsAt: (periodIndex + 1) * boardPeriodMs(content.config), }; } /** The tutorial board for the current run and period at the loaner's location (Steps 6.3, 10A.7). */ export async function tutorialBoard( tx: Queryable, playerId: string, now: RealTime, ): Promise { const [player] = await tx.select().from(players).where(eq(players.id, playerId)); if (!player?.tutorialActive || !player.loanerLocationId) { throw gameError("not-in-tutorial", "The tutorial is over."); } const loanerLocationId = player.loanerLocationId as "earth" | "moon"; const periodIndex = boardPeriodIndex(now, content.config); const runsCompleted = player.tutorialRunsCompleted; // One board per run (and per period), so every run gets three fresh contracts. const contracts = await getOrCreateBoard( tx, { playerId, kind: "tutorial", locationId: loanerLocationId, periodIndex, tutorialRun: runsCompleted, }, () => generateTutorialBoard({ loanerLocationId, runsCompleted, periodIndex }, content), now, ); return boardView(tx, playerId, contracts, periodIndex); } /** * The contract board (freight market) of any discovered location for the * current period (Step 6.9, CR-002): no bay needed. Generated on the first * request in the period from the locations discovered and the fleet owned at * that moment (CR-006), then stored, so later changes in the same period do * not change it. */ export async function locationBoard( tx: Queryable, playerId: string, locationId: string, now: RealTime, ): Promise { const origin = content.location(locationId); if (!origin) throw gameError("not-found", `No location "${locationId}".`); const [known] = await tx .select({ locationId: discoveredLocations.locationId }) .from(discoveredLocations) .where( and( eq(discoveredLocations.playerId, playerId), eq(discoveredLocations.locationId, locationId), ), ); if (!known) { throw gameError("location-undiscovered", `Deliver to ${origin.name} to open its market.`); } const [player] = await tx.select().from(players).where(eq(players.id, playerId)); const periodIndex = boardPeriodIndex(now, content.config); const contracts = await getOrCreateBoard( tx, { playerId, kind: "location", locationId, periodIndex }, async () => { const discovered = await tx .select({ locationId: discoveredLocations.locationId }) .from(discoveredLocations) .where(eq(discoveredLocations.playerId, playerId)); return generateContractBoard( { location: origin, discoveredIds: new Set(discovered.map((d) => d.locationId)), periodIndex, worldSeed: player!.worldSeed, fleet: await playerFleet(tx, playerId), }, content, ); }, now, ); return boardView(tx, playerId, contracts, periodIndex); } /** * The player's ships with their pilots' skills (none without a pilot), in a * stable order so the same fleet always gives the same board (decision 0.5.52). */ export async function playerFleet(tx: Queryable, playerId: string): Promise { const rows = await tx .select({ modelId: ships.modelId, skills: pilots.skills }) .from(ships) .leftJoin(pilots, eq(pilots.assignedShipId, ships.id)) .where(eq(ships.playerId, playerId)) .orderBy(asc(ships.createdAt), asc(ships.id)); return rows.map((r) => ({ modelId: r.modelId, skills: r.skills ?? {} })); }