Step 10A.10: add onboarding goals to the server

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 11:21:21 -04:00
parent 45ab0f3339
commit c2278c6ec8
6 changed files with 235 additions and 3 deletions

View File

@ -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. - **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. - **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. - **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.

View File

@ -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<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`,
),
);
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<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,
};
}

View File

@ -12,6 +12,7 @@ import {
ships, ships,
} from "../db/schema"; } from "../db/schema";
import { isDealAvailable } from "./deal"; import { isDealAvailable } from "./deal";
import { goalStates } from "./goals";
import { toApiDispatch, toApiPilot, toApiStatus } from "./mappers"; import { toApiDispatch, toApiPilot, toApiStatus } from "./mappers";
/** Reads everything the client needs about one player. */ /** 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, scriptedId: d.record.contract?.scriptedId ?? null,
resolvedAt: d.resolvedAt ?? d.arrivalAt, resolvedAt: d.resolvedAt ?? d.arrivalAt,
})), })),
// Goal statuses arrive with Step 10A.10. goals: await goalStates(db, playerId),
goals: [],
}; };
} }

View File

@ -5,6 +5,7 @@ import { realToGame } from "@deadhead/sim";
import { fromNodeHeaders } from "better-auth/node"; import { fromNodeHeaders } from "better-auth/node";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { acceptDeal } from "../game/deal"; import { acceptDeal } from "../game/deal";
import { claimGoal } from "../game/goals";
import { repayLoan } from "../game/loan"; import { repayLoan } from "../game/loan";
import { createPlayer } from "../game/new-player"; import { createPlayer } from "../game/new-player";
import { loadPlayerState } from "../game/player-state"; 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), 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),
})),
);
} }

View File

@ -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");
});
});

View File

@ -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.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.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. - [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.11 — Client: first launch goes straight to work
- [ ] Step 10A.12 — Client: tutorial board shows the tradeoff - [ ] Step 10A.12 — Client: tutorial board shows the tradeoff
- [ ] Step 10A.13 — Client: the deal - [ ] Step 10A.13 — Client: the deal