import { randomUUID } from "node:crypto"; import { content } from "@deadhead/content"; import type { RealTime } from "@deadhead/sim"; import { and, count, eq } from "drizzle-orm"; import type { Queryable } from "../db/client"; import { bays, companies, dispatches, pilots, players, ships } from "../db/schema"; import { gameError } from "../http/errors"; import { shipName } from "./names"; /** The ship model and bay location the deal hands over (decision 0.5.30). */ export const DEAL_SHIP_MODEL_ID = "courier"; export const DEAL_BAY_LOCATION_ID = "earth"; /** * The deal can be accepted now (decision 0.5.30): tutorial on, enough runs * completed, the loaner back at Earth, and no tutorial run in flight. */ export function isDealAvailable( player: typeof players.$inferSelect, tutorialRunInFlight: boolean, ): boolean { return ( player.tutorialActive && !player.dealAccepted && player.tutorialRunsCompleted >= content.config.tutorialRunsBeforeDeal && player.loanerLocationId === "earth" && !tutorialRunInFlight ); } /** * Accepts the deal (Step 10A.8): the loaner goes back to the company; the * player gets the old Courier at Earth crewed by the avatar, the Earth bay, and * the loan; the tutorial ends. Run inside the player lock, so two simultaneous * accepts produce one deal. */ export async function acceptDeal(tx: Queryable, playerId: string, now: RealTime): Promise { const [player] = await tx.select().from(players).where(eq(players.id, playerId)); if (!player) throw new Error(`Player ${playerId} not found`); 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 (!isDealAvailable(player, running !== undefined)) { throw gameError("deal-not-available", "There is no deal on the table right now."); } const [owned] = await tx.select({ n: count() }).from(ships).where(eq(ships.playerId, playerId)); const shipId = randomUUID(); await tx.insert(ships).values({ id: shipId, playerId, modelId: DEAL_SHIP_MODEL_ID, name: shipName(player.worldSeed, owned!.n), locationId: DEAL_BAY_LOCATION_ID, fromDeal: true, createdAt: now, }); await tx.insert(bays).values({ id: randomUUID(), playerId, locationId: DEAL_BAY_LOCATION_ID, createdAt: now, }); await tx .update(pilots) .set({ assignedShipId: shipId, locationId: null, status: "idle" }) .where(and(eq(pilots.playerId, playerId), eq(pilots.isAvatar, true))); // Halvorsen's veteran comes with the business (decision 0.5.70): at Earth, // unassigned, at their level with their skills bought and the rest unspent. const veteran = content.veteranPilot; const xpForLevel = veteran.level === 0 ? 0 : content.config.pilotLevelXpThresholds[veteran.level - 1]!; await tx.insert(pilots).values({ id: randomUUID(), playerId, name: veteran.name, isVeteran: true, xp: xpForLevel, skills: veteran.skills, skillPointsSpent: Object.values(veteran.skills).reduce((a, b) => a + b, 0), locationId: DEAL_BAY_LOCATION_ID, createdAt: now, }); await tx .update(companies) .set({ loanBalance: content.dealLoanAmount }) .where(eq(companies.playerId, playerId)); await tx .update(players) .set({ tutorialActive: false, loanerLocationId: null, dealAccepted: true }) .where(eq(players.id, playerId)); }