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

290 lines
8.9 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { content } from "@deadhead/content";
import type { Dispatch } from "@deadhead/schema";
import {
createDispatchRecord,
createRepositionRecord,
loanerModel,
validateDispatch,
validateReposition,
type Contract,
type DispatchRecord,
type PilotState,
type RealTime,
type ShipState,
} from "@deadhead/sim";
import { and, eq, sql } from "drizzle-orm";
import type { Queryable } from "../db/client";
import {
acceptedContracts,
bays,
companies,
contractBoards,
dispatches,
pilots,
players,
ships,
} from "../db/schema";
import { gameError } from "../http/errors";
import { acceptedContractIds } from "./boards";
import { requireCompany, requirePlayer } from "./fleet";
import { toApiDispatch } from "./mappers";
import { VALIDATION_MESSAGES } from "./messages";
import { requireOwnPilot, requireOwnShip } from "./pilots";
/** The id the tutorial loaner uses in dispatch records (it is not a ship row). */
export const LOANER_SHIP_ID = "loaner";
/** Finds a contract on any of the player's stored boards of a kind. */
export async function findBoardContract(
tx: Queryable,
playerId: string,
kind: "bay" | "tutorial",
contractId: string,
): Promise<{ contract: Contract; periodIndex: number } | undefined> {
const [board] = await tx
.select()
.from(contractBoards)
.where(
and(
eq(contractBoards.playerId, playerId),
eq(contractBoards.kind, kind),
sql`${contractBoards.contracts} @> ${JSON.stringify([{ id: contractId }])}::jsonb`,
),
);
const contract = board?.contracts.find((c) => c.id === contractId);
return board && contract ? { contract, periodIndex: board.periodIndex } : undefined;
}
/** Loads a board contract the player may still accept, or throws. */
export async function claimableContract(
tx: Queryable,
playerId: string,
kind: "bay" | "tutorial",
contractId: string,
): Promise<{ contract: Contract; periodIndex: number }> {
const found = await findBoardContract(tx, playerId, kind, contractId);
if (!found) throw gameError("unknown-contract", "No such contract on your boards.");
if ((await acceptedContractIds(tx, playerId)).has(contractId)) {
throw gameError("contract-already-accepted", "That contract has already been taken.");
}
return found;
}
export function pilotState(row: typeof pilots.$inferSelect): PilotState {
return {
id: row.id,
isAvatar: row.isAvatar,
assignedShipId: row.assignedShipId,
inFlight: row.status === "in_flight",
skills: row.skills,
};
}
/** Stores a dispatch, marks its contract accepted, and charges fuel. */
export async function storeDispatch(
tx: Queryable,
args: {
playerId: string;
record: DispatchRecord;
/** Null for the tutorial loaner. */
shipId: string | null;
/** The board period the contract came from; null for a reposition. */
periodIndex: number | null;
now: RealTime;
},
): Promise<Dispatch> {
const { playerId, record, shipId, now } = args;
const [row] = await tx
.insert(dispatches)
.values({
id: record.id,
playerId,
shipId,
pilotId: record.pilotId,
tutorial: record.tutorial,
record,
arrivalAt: record.arrivalAt,
})
.returning();
if (record.contract && args.periodIndex !== null) {
await tx.insert(acceptedContracts).values({
playerId,
contractId: record.contract.id,
periodIndex: args.periodIndex,
dispatchId: record.id,
acceptedAt: now,
});
}
if (record.fuelCost > 0) {
await tx
.update(companies)
.set({ credits: sql`${companies.credits} - ${record.fuelCost}` })
.where(eq(companies.playerId, playerId));
}
return toApiDispatch(row!);
}
/**
* The avatar flies the company loaner on a tutorial contract (Step 6.4). The
* company pays fuel, and only one tutorial run may be in flight.
*/
export async function dispatchTutorial(
tx: Queryable,
playerId: string,
contractId: string,
now: RealTime,
): Promise<Dispatch> {
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 [running] = await tx
.select({ id: dispatches.id })
.from(dispatches)
.where(
and(
eq(dispatches.playerId, playerId),
eq(dispatches.tutorial, true),
eq(dispatches.status, "in_flight"),
),
);
if (running) throw gameError("tutorial-run-in-flight", "Your tutorial run is still flying.");
const { contract, periodIndex } = await claimableContract(tx, playerId, "tutorial", contractId);
const [avatar] = await tx
.select()
.from(pilots)
.where(and(eq(pilots.playerId, playerId), eq(pilots.isAvatar, true)));
const [company] = await tx.select().from(companies).where(eq(companies.playerId, playerId));
if (!avatar || !company) throw new Error(`Player ${playerId} is missing its avatar or company`);
const loaner: ShipState = {
id: LOANER_SHIP_ID,
modelId: loanerModel(content).id,
locationId: player.loanerLocationId,
inFlight: false,
};
const pilot = pilotState(avatar);
const check = validateDispatch(
{ ship: loaner, pilot, contract, tutorial: true, credits: company.credits, now },
content,
);
if (!check.ok) throw gameError(check.reason, VALIDATION_MESSAGES[check.reason]);
const record = createDispatchRecord(
{
id: randomUUID(),
ship: loaner,
pilot,
contract,
tutorial: true,
now,
worldSeed: player.worldSeed,
},
content,
);
const dispatch = await storeDispatch(tx, { playerId, record, shipId: null, periodIndex, now });
await tx
.update(pilots)
.set({ status: "in_flight", locationId: null })
.where(eq(pilots.id, avatar.id));
return dispatch;
}
export function shipState(row: typeof ships.$inferSelect): ShipState {
return {
id: row.id,
modelId: row.modelId,
// An in-flight ship has no location; validation reports it as not idle first.
locationId: row.locationId ?? "",
inFlight: row.status === "in_flight",
};
}
/**
* Dispatches an owned ship and its pilot on a bay-board contract (Step 6.10):
* sim validation, fuel charged, contract accepted, ship and pilot in flight.
*/
export async function dispatchContract(
tx: Queryable,
playerId: string,
input: { contractId: string; shipId: string; pilotId: string },
now: RealTime,
): Promise<Dispatch> {
const { contract, periodIndex } = await claimableContract(tx, playerId, "bay", input.contractId);
const ship = await requireOwnShip(tx, playerId, input.shipId);
const pilot = await requireOwnPilot(tx, playerId, input.pilotId);
const player = await requirePlayer(tx, playerId);
const company = await requireCompany(tx, playerId);
const proposal = {
ship: shipState(ship),
pilot: pilotState(pilot),
contract,
tutorial: false,
credits: company.credits,
now,
};
const check = validateDispatch(proposal, content);
if (!check.ok) throw gameError(check.reason, VALIDATION_MESSAGES[check.reason]);
const record = createDispatchRecord(
{ id: randomUUID(), ...proposal, worldSeed: player.worldSeed },
content,
);
const dispatch = await storeDispatch(tx, { playerId, record, shipId: ship.id, periodIndex, now });
await setInFlight(tx, ship.id, pilot.id);
return dispatch;
}
export async function setInFlight(tx: Queryable, shipId: string, pilotId: string) {
await tx.update(ships).set({ status: "in_flight", locationId: null }).where(eq(ships.id, shipId));
await tx
.update(pilots)
.set({ status: "in_flight", locationId: null })
.where(eq(pilots.id, pilotId));
}
/** Flies a ship and its pilot empty to another player bay, paying fuel only (Step 6.11). */
export async function dispatchReposition(
tx: Queryable,
playerId: string,
input: { shipId: string; pilotId: string; destinationId: string },
now: RealTime,
): Promise<Dispatch> {
const ship = await requireOwnShip(tx, playerId, input.shipId);
const pilot = await requireOwnPilot(tx, playerId, input.pilotId);
const player = await requirePlayer(tx, playerId);
const company = await requireCompany(tx, playerId);
const bayRows = await tx
.select({ locationId: bays.locationId })
.from(bays)
.where(eq(bays.playerId, playerId));
const proposal = {
ship: shipState(ship),
pilot: pilotState(pilot),
destinationId: input.destinationId,
bayLocationIds: new Set(bayRows.map((b) => b.locationId)),
credits: company.credits,
now,
};
const check = validateReposition(proposal, content);
if (!check.ok) throw gameError(check.reason, VALIDATION_MESSAGES[check.reason]);
const record = createRepositionRecord(
{ id: randomUUID(), ...proposal, worldSeed: player.worldSeed },
content,
);
const dispatch = await storeDispatch(tx, {
playerId,
record,
shipId: ship.id,
periodIndex: null,
now,
});
await setInFlight(tx, ship.id, pilot.id);
return dispatch;
}