295 lines
9.0 KiB
TypeScript
295 lines
9.0 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,
|
|
companies,
|
|
contractBoards,
|
|
discoveredLocations,
|
|
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: "location" | "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: "location" | "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,
|
|
"location",
|
|
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));
|
|
}
|
|
|
|
/** Deadhead: flies a ship and its pilot empty to another discovered location, paying fuel only (Steps 6.11, 10B.2). */
|
|
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 discovered = await tx
|
|
.select({ locationId: discoveredLocations.locationId })
|
|
.from(discoveredLocations)
|
|
.where(eq(discoveredLocations.playerId, playerId));
|
|
|
|
const proposal = {
|
|
ship: shipState(ship),
|
|
pilot: pilotState(pilot),
|
|
destinationId: input.destinationId,
|
|
discoveredIds: new Set(discovered.map((d) => d.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;
|
|
}
|