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

115 lines
3.9 KiB
TypeScript

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<number> {
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;
}