diff --git a/DECISIONS.md b/DECISIONS.md index 11bfb24..7cb0cd8 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -546,3 +546,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Within one resolution pass the balance carries forward:** each arrival repays against what the previous one left, so several arrivals resolving together never repay more than the balance. - **Repaying is checked against the amount actually taken:** `payment = min(amount, balance)`; if that exceeds credits the request fails with `insufficient-funds` and nothing changes. With a zero balance, a repay request succeeds and moves nothing. - **The repay endpoint returns `{ company }`** (credits and balance) and runs inside `withPlayer` like every money change. + +## Step 10A.10 + +- **"Own-ship deliveries" = resolved, non-tutorial dispatches that carried a contract** (repositions store a null contract and do not count). +- **A goal stays claimable whenever its condition holds at claim time:** status is computed fresh from current state, so a goal is not "ready" because of something that has since reversed (for example, discovered locations never reverse, but the counts are re-read each time). +- **Claims during the tutorial fail with `goal-not-ready`,** since goals do not exist until the tutorial is over. diff --git a/apps/server/src/game/goals.ts b/apps/server/src/game/goals.ts new file mode 100644 index 0000000..655cff1 --- /dev/null +++ b/apps/server/src/game/goals.ts @@ -0,0 +1,112 @@ +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`, + ), + ); + 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)); + 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, + pilotsHired: hired!.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, + }; +} diff --git a/apps/server/src/game/player-state.ts b/apps/server/src/game/player-state.ts index 6e38890..90cddb3 100644 --- a/apps/server/src/game/player-state.ts +++ b/apps/server/src/game/player-state.ts @@ -12,6 +12,7 @@ import { ships, } from "../db/schema"; import { isDealAvailable } from "./deal"; +import { goalStates } from "./goals"; import { toApiDispatch, toApiPilot, toApiStatus } from "./mappers"; /** Reads everything the client needs about one player. */ @@ -97,7 +98,6 @@ export async function loadPlayerState(db: Queryable, playerId: string): Promise< scriptedId: d.record.contract?.scriptedId ?? null, resolvedAt: d.resolvedAt ?? d.arrivalAt, })), - // Goal statuses arrive with Step 10A.10. - goals: [], + goals: await goalStates(db, playerId), }; } diff --git a/apps/server/src/routes/player.ts b/apps/server/src/routes/player.ts index 83b18eb..3e69843 100644 --- a/apps/server/src/routes/player.ts +++ b/apps/server/src/routes/player.ts @@ -5,6 +5,7 @@ import { realToGame } from "@deadhead/sim"; import { fromNodeHeaders } from "better-auth/node"; import type { FastifyInstance } from "fastify"; import { acceptDeal } from "../game/deal"; +import { claimGoal } from "../game/goals"; import { repayLoan } from "../game/loan"; import { createPlayer } from "../game/new-player"; import { loadPlayerState } from "../game/player-state"; @@ -64,4 +65,10 @@ export function registerPlayerRoutes(app: FastifyInstance, ctx: AppContext): voi company: await repayLoan(tx, playerId, input.amount), })), ); + + registerRoute(app, routes.claimGoal, options, async (input, { playerId }) => + withPlayer(ctx, playerId, async (tx, now) => ({ + company: await claimGoal(tx, playerId, input.goalId, now), + })), + ); } diff --git a/apps/server/test/goals.test.ts b/apps/server/test/goals.test.ts new file mode 100644 index 0000000..81bc268 --- /dev/null +++ b/apps/server/test/goals.test.ts @@ -0,0 +1,107 @@ +import { content } from "@deadhead/content"; +import { companyResponseSchema, errorResponseSchema } from "@deadhead/schema"; +import { eq } from "drizzle-orm"; +import { describe, expect, it } from "vitest"; +import { pilots, players } from "../src/db/schema"; +import { setCredits, useTestDatabase } from "./db"; +import { + call, + getState, + insertDispatch, + signUp, + signUpOwner, + useTestApp, + type Guest, +} from "./game"; + +const database = useTestDatabase(); +const testApp = useTestApp(database); + +const claim = (guest: Guest, goalId: string) => + call(testApp().app, guest, "POST", `/api/goals/${goalId}/claim`); + +const errorCode = (response: { json(): unknown }) => + errorResponseSchema.parse(response.json()).error.code; + +/** An owner who has accepted the deal (so "pay off your loan" can apply). */ +async function dealOwner() { + const owner = await signUpOwner(testApp().app, database().db); + await database() + .db.update(players) + .set({ dealAccepted: true }) + .where(eq(players.id, owner.playerId)); + return owner; +} + +const status = async (guest: Guest, goalId: string) => + (await getState(testApp().app, guest)).goals.find((g) => g.id === goalId)?.status; + +describe("onboarding goals", () => { + it("are absent from state during the tutorial", async () => { + const guest = await signUp(testApp().app); + expect((await getState(testApp().app, guest)).goals).toEqual([]); + }); + + it("are listed in content order once the tutorial is over", async () => { + const owner = await dealOwner(); + const goals = (await getState(testApp().app, owner)).goals; + expect(goals.map((g) => g.id)).toEqual(content.onboardingGoals.map((g) => g.id)); + // Paid-off loan: the test owner has no balance. + expect(goals.find((g) => g.id === "loan-paid-off")?.status).toBe("ready"); + expect(goals.find((g) => g.id === "first-hire")?.status).toBe("incomplete"); + }); + + it("becomes ready when its condition is met: a delivery with the player's own ship", async () => { + const { clock } = testApp(); + const owner = await dealOwner(); + expect(await status(owner, "first-own-delivery")).toBe("incomplete"); + const record = await insertDispatch(database().db, { + playerId: owner.playerId, + shipId: owner.shipId, + pilotId: owner.avatarId, + departAt: clock.now, + }); + clock.now = record.arrivalAt; + expect(await status(owner, "first-own-delivery")).toBe("ready"); + }); + + it("counts skill points spent", async () => { + const owner = await dealOwner(); + await database() + .db.update(pilots) + .set({ skillPointsSpent: 1 }) + .where(eq(pilots.id, owner.avatarId)); + expect(await status(owner, "first-skill-point")).toBe("ready"); + }); + + it("claiming a completed goal adds its reward once", async () => { + const owner = await dealOwner(); + await setCredits(database().db, owner.playerId, 100); + const reward = content.onboardingGoal("loan-paid-off")!.reward; + + const response = await claim(owner, "loan-paid-off"); + expect(response.statusCode).toBe(200); + expect(companyResponseSchema.parse(response.json()).company.credits).toBe(100 + reward); + expect(await status(owner, "loan-paid-off")).toBe("claimed"); + + const again = await claim(owner, "loan-paid-off"); + expect(again.statusCode).toBe(409); + expect(errorCode(again)).toBe("goal-already-claimed"); + expect((await getState(testApp().app, owner)).company.credits).toBe(100 + reward); + }); + + it("refuses an incomplete goal and an unknown one", async () => { + const owner = await dealOwner(); + await setCredits(database().db, owner.playerId, 100); + const response = await claim(owner, "first-hire"); + expect(response.statusCode).toBe(409); + expect(errorCode(response)).toBe("goal-not-ready"); + expect((await claim(owner, "win-the-game")).statusCode).toBe(404); + expect((await getState(testApp().app, owner)).company.credits).toBe(100); + }); + + it("refuses claims during the tutorial", async () => { + const guest = await signUp(testApp().app); + expect(errorCode(await claim(guest, "loan-paid-off"))).toBe("goal-not-ready"); + }); +}); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 18c8880..f720c3d 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -115,7 +115,7 @@ Spec: `memory-bank/change-requests/CR-001-faster-start.md`. Decisions recorded a - [x] **Step 10A.7 — Server: tutorial flow v2.** Tutorial boards come from the v2 generator, stored per run and period; each resolved tutorial run increments `tutorial_runs_completed`; `dealAvailable` in state; buying ships, leasing bays and hiring pilots return `409 tutorial-active` during the tutorial. Tests for 6.5/6.6/6.8/6.14 now start from an owner; old Step 6.7 tests removed (their trigger is now impossible; the rule itself goes in 10A.8). - [x] **Step 10A.8 — Server: accept the deal.** `POST /api/deal/accept` (`acceptDeal` in `src/game/deal.ts`) in one locked transaction: Courier at Earth (named from the seed) crewed by the avatar, Earth bay, loan = `dealLoanAmount`, tutorial off, loaner removed, deal accepted; returns the new player state. `409 deal-not-available` otherwise. Old `maybeEndTutorial` removed (replaces Step 6.7). - [x] **Step 10A.9 — Server: loan repayment.** Resolution reads the company loan balance, adds only the net payout and lowers the balance by the repayment (stored in the dispatch resolution, shown in summaries). `POST /api/loan/repay` (`src/game/loan.ts`) moves up to the balance from credits to the loan under the player lock; `409 insufficient-funds` with no changes if credits fall short. -- [ ] Step 10A.10 — Server: goals +- [x] **Step 10A.10 — Server: goals.** `src/game/goals.ts`: `goalProgress` (own-ship deliveries, skill points spent, ships, hires, extra discoveries, deal, loan balance) → `goalStates` (empty during the tutorial) in player state; `POST /api/goals/:goalId/claim` adds the reward once (`goal-not-ready`, `goal-already-claimed`, `404 unknown-goal`). - [ ] Step 10A.11 — Client: first launch goes straight to work - [ ] Step 10A.12 — Client: tutorial board shows the tradeoff - [ ] Step 10A.13 — Client: the deal