189 lines
6.0 KiB
TypeScript
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 ?? {} }));
|
|
}
|