import { content } from "@deadhead/content"; import { resolveDispatch, type RealTime } from "@deadhead/sim"; import { and, asc, eq, lte, sql } from "drizzle-orm"; import type { Queryable } from "../db/client"; import { companies, discoveredLocations, dispatches, pilots, players, ships } from "../db/schema"; /** * Resolves every in-flight dispatch of a player that has arrived by `now`, in * arrival order, applying credits (net of loan repayment), the repayment, XP, * ship/pilot location and discovery. Run * it inside `withPlayerLock`. Each dispatch moves from in_flight to resolved * exactly once: the status update is conditional, and effects are applied only * when it changed a row. Returns how many dispatches were resolved. */ export async function resolveArrivedDispatches( tx: Queryable, playerId: string, now: RealTime, ): Promise { const due = await tx .select() .from(dispatches) .where( and( eq(dispatches.playerId, playerId), eq(dispatches.status, "in_flight"), lte(dispatches.arrivalAt, now), ), ) .orderBy(asc(dispatches.arrivalAt)); if (due.length === 0) return 0; const discovered = await tx .select({ locationId: discoveredLocations.locationId }) .from(discoveredLocations) .where(eq(discoveredLocations.playerId, playerId)); const discoveredIds = new Set(discovered.map((d) => d.locationId)); const [company] = await tx.select().from(companies).where(eq(companies.playerId, playerId)); // Carried forward so each arrival repays against the balance left by the previous one. let loanBalance = company?.loanBalance ?? 0; let resolvedCount = 0; for (const dispatch of due) { const [pilot] = await tx.select().from(pilots).where(eq(pilots.id, dispatch.pilotId)); if (!pilot) throw new Error(`Dispatch ${dispatch.id} has no pilot`); const result = resolveDispatch( { record: dispatch.record, pilotIsAvatar: pilot.isAvatar, discoveredIds, loanBalance, // Duty is stored from Step 10D.4. pilotDutyHours: 0, pilotSkills: pilot.skills, now, }, content, ); if (result.status !== "resolved") continue; const { resolution } = result; const claimed = await tx .update(dispatches) .set({ status: "resolved", resolution, resolvedAt: now }) .where(and(eq(dispatches.id, dispatch.id), eq(dispatches.status, "in_flight"))) .returning({ id: dispatches.id }); if (claimed.length === 0) continue; resolvedCount++; loanBalance -= resolution.loanRepayment; await tx .update(companies) .set({ credits: sql`${companies.credits} + ${resolution.credits}`, loanBalance: sql`${companies.loanBalance} - ${resolution.loanRepayment}`, }) .where(eq(companies.playerId, playerId)); await tx .update(pilots) .set({ xp: sql`${pilots.xp} + ${resolution.pilotXp}`, status: "idle", // Only unassigned pilots store a location (decision 0.5.27): the tutorial avatar. ...(pilot.assignedShipId === null ? { locationId: resolution.shipLocationId } : {}), }) .where(eq(pilots.id, pilot.id)); if (dispatch.shipId) { await tx .update(ships) .set({ status: "idle", locationId: resolution.shipLocationId }) .where(eq(ships.id, dispatch.shipId)); } if (dispatch.tutorial) { await tx .update(players) .set({ loanerLocationId: resolution.shipLocationId, tutorialRunsCompleted: sql`${players.tutorialRunsCompleted} + 1`, }) .where(eq(players.id, playerId)); } if (resolution.discoversDestination) { await tx .insert(discoveredLocations) .values({ playerId, locationId: dispatch.record.destinationId, discoveredAt: now }) .onConflictDoNothing(); discoveredIds.add(dispatch.record.destinationId); } } return resolvedCount; }