diff --git a/DECISIONS.md b/DECISIONS.md index 56b9703..95f8fd0 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -533,3 +533,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **The Step 6.7 tests were removed one step early.** They bought a ship and leased an Earth bay during the tutorial, which this step forbids, so they could no longer run. CR-001 §6 replaces Step 6.7 with Step 10A.8, which also removes the old automatic rule. - **Tests for purchases, races and pilot assignment now start from an owner** (`signUpOwner`), since only owners can buy. The "avatar can't be assigned during the tutorial" test puts a ship in the database directly. - **`tutorialRunsBeforeDeal = 3` is tested by mocking content** in its own file, like the earlier `cannot-land` test. + +## Step 10A.8 + +- **The deal hands over a content `courier` model at Earth and an Earth bay.** Constants `DEAL_SHIP_MODEL_ID` and `DEAL_BAY_LOCATION_ID` are server code, since they describe the story beat ("his old Courier and his Earth bay"), not tuning. +- **The endpoint returns the full new `PlayerState`** so the client can switch straight to the post-tutorial UI. +- **Exactly once:** the player lock serializes accepts, and `isDealAvailable` requires `deal_accepted` to be false, so a second or simultaneous accept fails with `deal-not-available` and changes nothing. +- **The old automatic tutorial end (`maybeEndTutorial`, Step 6.7) is deleted;** accepting the deal is now the only way the tutorial ends (decision 0.5.4 as replaced). diff --git a/apps/server/src/game/deal.ts b/apps/server/src/game/deal.ts index 9a69a28..c5c4d34 100644 --- a/apps/server/src/game/deal.ts +++ b/apps/server/src/game/deal.ts @@ -1,5 +1,15 @@ +import { randomUUID } from "node:crypto"; import { content } from "@deadhead/content"; -import type { players } from "../db/schema"; +import type { RealTime } from "@deadhead/sim"; +import { and, count, eq } from "drizzle-orm"; +import type { Queryable } from "../db/client"; +import { bays, companies, dispatches, pilots, players, ships } from "../db/schema"; +import { gameError } from "../http/errors"; +import { shipName } from "./names"; + +/** The ship model and bay location the deal hands over (decision 0.5.30). */ +export const DEAL_SHIP_MODEL_ID = "courier"; +export const DEAL_BAY_LOCATION_ID = "earth"; /** * The deal can be accepted now (decision 0.5.30): tutorial on, enough runs @@ -17,3 +27,56 @@ export function isDealAvailable( !tutorialRunInFlight ); } + +/** + * Accepts the deal (Step 10A.8): the loaner goes back to the company; the + * player gets the old Courier at Earth crewed by the avatar, the Earth bay, and + * the loan; the tutorial ends. Run inside the player lock, so two simultaneous + * accepts produce one deal. + */ +export async function acceptDeal(tx: Queryable, playerId: string, now: RealTime): Promise { + const [player] = await tx.select().from(players).where(eq(players.id, playerId)); + if (!player) throw new Error(`Player ${playerId} not found`); + const [running] = await tx + .select({ id: dispatches.id }) + .from(dispatches) + .where( + and( + eq(dispatches.playerId, playerId), + eq(dispatches.tutorial, true), + eq(dispatches.status, "in_flight"), + ), + ); + if (!isDealAvailable(player, running !== undefined)) { + throw gameError("deal-not-available", "There is no deal on the table right now."); + } + + const [owned] = await tx.select({ n: count() }).from(ships).where(eq(ships.playerId, playerId)); + const shipId = randomUUID(); + await tx.insert(ships).values({ + id: shipId, + playerId, + modelId: DEAL_SHIP_MODEL_ID, + name: shipName(player.worldSeed, owned!.n), + locationId: DEAL_BAY_LOCATION_ID, + createdAt: now, + }); + await tx.insert(bays).values({ + id: randomUUID(), + playerId, + locationId: DEAL_BAY_LOCATION_ID, + createdAt: now, + }); + await tx + .update(pilots) + .set({ assignedShipId: shipId, locationId: null, status: "idle" }) + .where(and(eq(pilots.playerId, playerId), eq(pilots.isAvatar, true))); + await tx + .update(companies) + .set({ loanBalance: content.config.dealLoanAmount }) + .where(eq(companies.playerId, playerId)); + await tx + .update(players) + .set({ tutorialActive: false, loanerLocationId: null, dealAccepted: true }) + .where(eq(players.id, playerId)); +} diff --git a/apps/server/src/game/resolve.ts b/apps/server/src/game/resolve.ts index 3144b45..12dab66 100644 --- a/apps/server/src/game/resolve.ts +++ b/apps/server/src/game/resolve.ts @@ -3,15 +3,13 @@ import { resolveDispatch, type RealTime } from "@deadhead/sim"; import { and, asc, eq, lte, sql } from "drizzle-orm"; import type { Queryable } from "../db/client"; import { companies, discoveredLocations, dispatches, pilots, players, ships } from "../db/schema"; -import { maybeEndTutorial } from "./tutorial"; /** * Resolves every in-flight dispatch of a player that has arrived by `now`, in * arrival order, applying credits, XP, ship/pilot location and discovery. Run * it inside `withPlayerLock`. Each dispatch moves from in_flight to resolved * exactly once: the status update is conditional, and effects are applied only - * when it changed a row. Ends the tutorial if its last run just landed - * (Step 6.7). Returns how many dispatches were resolved. + * when it changed a row. Returns how many dispatches were resolved. */ export async function resolveArrivedDispatches( tx: Queryable, @@ -102,6 +100,5 @@ export async function resolveArrivedDispatches( discoveredIds.add(dispatch.record.destinationId); } } - if (resolvedCount > 0) await maybeEndTutorial(tx, playerId); return resolvedCount; } diff --git a/apps/server/src/game/tutorial.ts b/apps/server/src/game/tutorial.ts deleted file mode 100644 index a2a506a..0000000 --- a/apps/server/src/game/tutorial.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { and, eq } from "drizzle-orm"; -import type { Queryable } from "../db/client"; -import { bays, dispatches, pilots, players, ships } from "../db/schema"; - -/** Where the avatar ends up when the tutorial ends (decision 0.5.4). */ -export const TUTORIAL_END_LOCATION_ID = "earth"; - -/** - * Ends the tutorial once the player owns a ship, has an Earth bay, and has no - * tutorial run in flight (decision 0.5.4): the loaner is removed and the avatar - * becomes a normal, unassigned pilot at Earth. Returns whether it ended now. - */ -export async function maybeEndTutorial(tx: Queryable, playerId: string): Promise { - const [player] = await tx.select().from(players).where(eq(players.id, playerId)); - if (!player?.tutorialActive) return false; - - const [ship] = await tx - .select({ id: ships.id }) - .from(ships) - .where(eq(ships.playerId, playerId)) - .limit(1); - if (!ship) return false; - const [earthBay] = await tx - .select({ id: bays.id }) - .from(bays) - .where(and(eq(bays.playerId, playerId), eq(bays.locationId, TUTORIAL_END_LOCATION_ID))); - if (!earthBay) return false; - const [running] = await tx - .select({ id: dispatches.id }) - .from(dispatches) - .where( - and( - eq(dispatches.playerId, playerId), - eq(dispatches.tutorial, true), - eq(dispatches.status, "in_flight"), - ), - ); - if (running) return false; - - await tx - .update(players) - .set({ tutorialActive: false, loanerLocationId: null }) - .where(eq(players.id, playerId)); - await tx - .update(pilots) - .set({ locationId: TUTORIAL_END_LOCATION_ID, assignedShipId: null, status: "idle" }) - .where(and(eq(pilots.playerId, playerId), eq(pilots.isAvatar, true))); - return true; -} diff --git a/apps/server/src/routes/player.ts b/apps/server/src/routes/player.ts index 2169970..733b118 100644 --- a/apps/server/src/routes/player.ts +++ b/apps/server/src/routes/player.ts @@ -4,6 +4,7 @@ import { routes } from "@deadhead/schema"; import { realToGame } from "@deadhead/sim"; import { fromNodeHeaders } from "better-auth/node"; import type { FastifyInstance } from "fastify"; +import { acceptDeal } from "../game/deal"; import { createPlayer } from "../game/new-player"; import { loadPlayerState } from "../game/player-state"; import { acknowledgeSummaries } from "../game/summaries"; @@ -49,4 +50,11 @@ export function registerPlayerRoutes(app: FastifyInstance, ctx: AppContext): voi acknowledged: await acknowledgeSummaries(tx, playerId, input.dispatchIds, now), })), ); + + registerRoute(app, routes.acceptDeal, options, async (_input, { playerId }) => + withPlayer(ctx, playerId, async (tx, now) => { + await acceptDeal(tx, playerId, now); + return loadPlayerState(tx, playerId); + }), + ); } diff --git a/apps/server/src/routes/with-player.ts b/apps/server/src/routes/with-player.ts index 089869b..e96bbdd 100644 --- a/apps/server/src/routes/with-player.ts +++ b/apps/server/src/routes/with-player.ts @@ -1,13 +1,11 @@ import type { Queryable } from "../db/client"; import { withPlayerLock } from "../game/lock"; import { resolveArrivedDispatches } from "../game/resolve"; -import { maybeEndTutorial } from "../game/tutorial"; import type { AppContext } from "./context"; /** * Runs a game action for a player: takes the player lock, applies any arrivals - * that are due (so the action sees current state), runs `fn`, then ends the - * tutorial if the action completed its conditions (Step 6.7). + * that are due (so the action sees current state), then runs `fn`. */ export function withPlayer( ctx: AppContext, @@ -17,8 +15,6 @@ export function withPlayer( const now = ctx.clock(); return withPlayerLock(ctx.db, playerId, async (tx) => { await resolveArrivedDispatches(tx, playerId, now); - const result = await fn(tx, now); - await maybeEndTutorial(tx, playerId); - return result; + return fn(tx, now); }); } diff --git a/apps/server/test/deal.test.ts b/apps/server/test/deal.test.ts new file mode 100644 index 0000000..137d8b0 --- /dev/null +++ b/apps/server/test/deal.test.ts @@ -0,0 +1,111 @@ +import { content } from "@deadhead/content"; +import { errorResponseSchema, playerStateSchema } from "@deadhead/schema"; +import { eq } from "drizzle-orm"; +import { describe, expect, it } from "vitest"; +import { bays, companies, pilots, players, ships } from "../src/db/schema"; +import { useTestDatabase } from "./db"; +import { call, getState, signUp, useTestApp, type Guest } from "./game"; + +const database = useTestDatabase(); +const testApp = useTestApp(database); + +/** Fast-forwards a new guest to "four runs done, loaner back at Earth". */ +async function readyForDeal(guest: Guest) { + await database() + .db.update(players) + .set({ + tutorialRunsCompleted: content.config.tutorialRunsBeforeDeal, + loanerLocationId: "earth", + }) + .where(eq(players.id, guest.playerId)); +} + +const accept = (guest: Guest) => call(testApp().app, guest, "POST", "/api/deal/accept"); + +/** Everything the deal touches, read straight from the database. */ +async function snapshot(guest: Guest) { + const { db } = database(); + return { + player: (await db.select().from(players).where(eq(players.id, guest.playerId)))[0], + company: (await db.select().from(companies).where(eq(companies.playerId, guest.playerId)))[0], + ships: await db.select().from(ships).where(eq(ships.playerId, guest.playerId)), + bays: await db.select().from(bays).where(eq(bays.playerId, guest.playerId)), + pilots: await db.select().from(pilots).where(eq(pilots.playerId, guest.playerId)), + }; +} + +describe("accepting the deal", () => { + it("hands over a crewed Courier at Earth, an Earth bay and the loan, and ends the tutorial", async () => { + const guest = await signUp(testApp().app); + await readyForDeal(guest); + expect((await getState(testApp().app, guest)).tutorial?.dealAvailable).toBe(true); + + const response = await accept(guest); + expect(response.statusCode).toBe(200); + const state = playerStateSchema.parse(response.json()); + + expect(state.tutorial).toBeNull(); + expect(state.ships).toHaveLength(1); + const [ship] = state.ships; + expect(ship).toMatchObject({ + modelId: "courier", + status: "idle", + locationId: "earth", + pilotId: guest.avatarId, + }); + expect(state.bays.map((b) => b.locationId)).toEqual(["earth"]); + expect(state.pilots[0]).toMatchObject({ + isAvatar: true, + assignedShipId: ship!.id, + locationId: "earth", + }); + expect(state.company.loanBalance).toBe(content.config.dealLoanAmount); + + const after = await snapshot(guest); + expect(after.player).toMatchObject({ + tutorialActive: false, + dealAccepted: true, + loanerLocationId: null, + }); + }); + + it("fails with no changes when the deal is not available", async () => { + const guest = await signUp(testApp().app); + const before = await snapshot(guest); + const response = await accept(guest); + expect(response.statusCode).toBe(409); + expect(errorResponseSchema.parse(response.json()).error.code).toBe("deal-not-available"); + expect(await snapshot(guest)).toEqual(before); + }); + + it("fails while the loaner is at the Moon", async () => { + const guest = await signUp(testApp().app); + await readyForDeal(guest); + await database() + .db.update(players) + .set({ loanerLocationId: "moon" }) + .where(eq(players.id, guest.playerId)); + expect((await accept(guest)).statusCode).toBe(409); + }); + + it("fails the second time", async () => { + const guest = await signUp(testApp().app); + await readyForDeal(guest); + expect((await accept(guest)).statusCode).toBe(200); + const before = await snapshot(guest); + const again = await accept(guest); + expect(errorResponseSchema.parse(again.json()).error.code).toBe("deal-not-available"); + expect(await snapshot(guest)).toEqual(before); + }); + + it("produces exactly one deal from two simultaneous accepts", async () => { + const guest = await signUp(testApp().app); + await readyForDeal(guest); + const responses = await Promise.all([accept(guest), accept(guest)]); + expect(responses.map((r) => r.statusCode).sort()).toEqual([200, 409]); + const after = await snapshot(guest); + expect(after.ships).toHaveLength(1); + expect(after.bays).toHaveLength(1); + expect(after.company!.loanBalance).toBe(content.config.dealLoanAmount); + }); +}); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index dee2c55..d36d6d2 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -113,7 +113,7 @@ Spec: `memory-bank/change-requests/CR-001-faster-start.md`. Decisions recorded a - [x] **Step 10A.5 — Sim: onboarding goal evaluation.** `completedGoals(goals, progress)` / `isGoalConditionMet` (`src/goals.ts`) over a `GoalProgress` summary; tests check each goal just short of and at its condition. Sim line coverage still 100%. - [x] **Step 10A.6 — Schema and database.** Migration `0002_cr001_faster_start`: `companies.loan_balance`, `players.tutorial_runs_completed` / `deal_accepted`, `contract_boards.tutorial_run` (in the unique key), table `claimed_goals`; repayment stored in each dispatch's resolution JSON. API: `company.loanBalance`, `tutorial.runsCompleted` / `dealAvailable`, `goals`, summary `loanRepayment` / `scriptedId`; routes `acceptDeal`, `repayLoan`, `claimGoal`; error codes `tutorial-active`, `deal-not-available`, `unknown-goal`, `goal-not-ready`, `goal-already-claimed`. `architecture.md` updated. - [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). -- [ ] Step 10A.8 — Server: accept the deal +- [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). - [ ] Step 10A.9 — Server: loan repayment - [ ] Step 10A.10 — Server: goals - [ ] Step 10A.11 — Client: first launch goes straight to work