import { content } from "@deadhead/content"; import type { Company, GoalState } from "@deadhead/schema"; import { completedGoals, type GoalProgress, type RealTime } from "@deadhead/sim"; import { and, count, eq, notInArray, sql, sum } from "drizzle-orm"; import type { Queryable } from "../db/client"; import { claimedGoals, companies, discoveredLocations, dispatches, pilots, ships, } from "../db/schema"; import { gameError } from "../http/errors"; import { requireCompany, requirePlayer } from "./fleet"; const START_LOCATION_IDS = content.locations.filter((l) => l.startsDiscovered).map((l) => l.id); /** The facts goals are checked against, read from the database (Step 10A.10). */ export async function goalProgress(tx: Queryable, playerId: string): Promise { const player = await requirePlayer(tx, playerId); const company = await requireCompany(tx, playerId); const [deliveries] = await tx .select({ n: count() }) .from(dispatches) .where( and( eq(dispatches.playerId, playerId), eq(dispatches.status, "resolved"), eq(dispatches.tutorial, false), // Repositions store a null contract. sql`${dispatches.record} -> 'contract' <> 'null'::jsonb`, ), ); // Priority contracts delivered, by any pilot (CR-013). const [priorityDeliveries] = await tx .select({ n: count() }) .from(dispatches) .where( and( eq(dispatches.playerId, playerId), eq(dispatches.status, "resolved"), eq(dispatches.tutorial, false), sql`(${dispatches.record} -> 'contract' ->> 'priority') = 'true'`, ), ); const [spent] = await tx .select({ n: sum(pilots.skillPointsSpent) }) .from(pilots) .where(eq(pilots.playerId, playerId)); const [owned] = await tx.select({ n: count() }).from(ships).where(eq(ships.playerId, playerId)); // The deal's ships don't count as Market purchases (CR-013). const [bought] = await tx .select({ n: count() }) .from(ships) .where(and(eq(ships.playerId, playerId), eq(ships.fromDeal, false))); const [hired] = await tx .select({ n: count() }) .from(pilots) .where(and(eq(pilots.playerId, playerId), eq(pilots.isAvatar, false))); const [discovered] = await tx .select({ n: count() }) .from(discoveredLocations) .where( and( eq(discoveredLocations.playerId, playerId), notInArray(discoveredLocations.locationId, START_LOCATION_IDS), ), ); return { ownShipDeliveries: deliveries!.n, skillPointsSpent: Number(spent?.n ?? 0), shipsOwned: owned!.n, marketShipsBought: bought!.n, pilotsHired: hired!.n, priorityDeliveries: priorityDeliveries!.n, locationsDiscovered: discovered!.n, dealAccepted: player.dealAccepted, loanBalance: company.loanBalance, }; } async function claimedIds(tx: Queryable, playerId: string): Promise> { const rows = await tx .select({ goalId: claimedGoals.goalId }) .from(claimedGoals) .where(eq(claimedGoals.playerId, playerId)); return new Set(rows.map((r) => r.goalId)); } /** Every goal's status in content order; none while the tutorial runs (decision 0.5.34). */ export async function goalStates(tx: Queryable, playerId: string): Promise { const player = await requirePlayer(tx, playerId); if (player.tutorialActive) return []; const complete = new Set( completedGoals(content.onboardingGoals, await goalProgress(tx, playerId)), ); const claimed = await claimedIds(tx, playerId); return content.onboardingGoals.map((goal) => ({ id: goal.id, status: claimed.has(goal.id) ? "claimed" : complete.has(goal.id) ? "ready" : "incomplete", })); } /** Claims a completed goal's reward, once. Run inside the player lock. */ export async function claimGoal( tx: Queryable, playerId: string, goalId: string, now: RealTime, ): Promise { const goal = content.onboardingGoal(goalId); if (!goal) throw gameError("unknown-goal", `No goal "${goalId}".`); const status = (await goalStates(tx, playerId)).find((g) => g.id === goalId)?.status; if (status === "claimed") throw gameError("goal-already-claimed", "You already claimed that reward."); if (status !== "ready") throw gameError("goal-not-ready", `Not done yet: ${goal.title}.`); await tx.insert(claimedGoals).values({ playerId, goalId, claimedAt: now }); const [company] = await tx .update(companies) .set({ credits: sql`${companies.credits} + ${goal.reward}` }) .where(eq(companies.playerId, playerId)) .returning(); return { id: company!.id, name: company!.name, credits: company!.credits, loanBalance: company!.loanBalance, }; }