132 lines
4.6 KiB
TypeScript
132 lines
4.6 KiB
TypeScript
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<GoalProgress> {
|
|
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<Set<string>> {
|
|
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<GoalState[]> {
|
|
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<Company> {
|
|
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,
|
|
};
|
|
}
|