Step 10A.10: add onboarding goals to the server
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
45ab0f3339
commit
c2278c6ec8
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue