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

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,
};
}