115 lines
3.9 KiB
TypeScript
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;
|
|
}
|