deadhead-space-freight-tycoon/apps/server/src/game/boards.ts

189 lines
6.0 KiB
TypeScript

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<Contract[]>,
now: RealTime,
): Promise<Contract[]> {
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<Set<string>> {
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<BoardView> {
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<BoardView> {
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<BoardView> {
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<FleetMember[]> {
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 ?? {} }));
}