diff --git a/DECISIONS.md b/DECISIONS.md index 76131ee..56b9703 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -525,3 +525,11 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **New state fields:** `company.loanBalance`; `tutorial.runsCompleted` and `tutorial.dealAvailable` (computed by `isDealAvailable`: tutorial on, deal not yet taken, runs ≥ `tutorialRunsBeforeDeal`, loaner at Earth, no run in flight); `goals` as `{ id, status }`, empty until Step 10A.10 fills it; summaries gain `loanRepayment` and `scriptedId`. - **New routes:** `POST /api/deal/accept` returns the whole new `PlayerState`, since the deal changes almost everything; `POST /api/loan/repay` and `POST /api/goals/:goalId/claim` return `{ company }`. - **Error codes:** `tutorial-active` (purchases during the tutorial), `deal-not-available`, `unknown-goal` (404), `goal-not-ready`, `goal-already-claimed`. + +## Step 10A.7 + +- **The run counter increments when a tutorial dispatch resolves** (in the same locked resolution pass), not when it is dispatched, so "runs completed" means runs that landed. +- **Purchase blocking happens first** in buy-ship, lease-bay and hire-pilot (`requireTutorialOver`), so a tutorial player gets `tutorial-active` before any other check and nothing changes. +- **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. diff --git a/apps/server/src/game/boards.ts b/apps/server/src/game/boards.ts index f61a6ce..8389fc8 100644 --- a/apps/server/src/game/boards.ts +++ b/apps/server/src/game/boards.ts @@ -31,7 +31,14 @@ export interface BoardView { */ export async function getOrCreateBoard( tx: Queryable, - key: { playerId: string; kind: "bay" | "tutorial"; locationId: string; periodIndex: number }, + key: { + playerId: string; + kind: "bay" | "tutorial"; + locationId: string; + periodIndex: number; + /** Tutorial boards: runs completed (CR-001). Bay boards: 0. */ + tutorialRun?: number; + }, generate: () => Contract[] | Promise, now: RealTime, ): Promise { @@ -40,13 +47,20 @@ export async function getOrCreateBoard( eq(contractBoards.kind, key.kind), eq(contractBoards.locationId, key.locationId), eq(contractBoards.periodIndex, key.periodIndex), + eq(contractBoards.tutorialRun, key.tutorialRun ?? 0), ); const [existing] = await tx.select().from(contractBoards).where(where); if (existing) return existing.contracts; await tx .insert(contractBoards) - .values({ id: randomUUID(), ...key, contracts: await generate(), createdAt: now }) + .values({ + id: randomUUID(), + ...key, + tutorialRun: key.tutorialRun ?? 0, + contracts: await generate(), + createdAt: now, + }) .onConflictDoNothing(); const [stored] = await tx.select().from(contractBoards).where(where); return stored!.contracts; @@ -74,7 +88,7 @@ export async function boardView( }; } -/** The tutorial board for the current period at the loaner's location (Step 6.3). */ +/** The tutorial board for the current run and period at the loaner's location (Steps 6.3, 10A.7). */ export async function tutorialBoard( tx: Queryable, playerId: string, @@ -86,13 +100,18 @@ export async function tutorialBoard( } const loanerLocationId = player.loanerLocationId as "earth" | "moon"; const periodIndex = boardPeriodIndex(now, content.config); + const runsCompleted = player.tutorialRunsCompleted; + // One board per run (and per period), so every run gets three fresh contracts. const contracts = await getOrCreateBoard( tx, - { playerId, kind: "tutorial", locationId: loanerLocationId, periodIndex }, - () => - // Run counting arrives with the database change (Step 10A.7); until then - // every board is the first run's. - generateTutorialBoard({ loanerLocationId, runsCompleted: 0, periodIndex }, content), + { + playerId, + kind: "tutorial", + locationId: loanerLocationId, + periodIndex, + tutorialRun: runsCompleted, + }, + () => generateTutorialBoard({ loanerLocationId, runsCompleted, periodIndex }, content), now, ); return boardView(tx, playerId, contracts, periodIndex); diff --git a/apps/server/src/game/fleet.ts b/apps/server/src/game/fleet.ts index 511668d..ecba064 100644 --- a/apps/server/src/game/fleet.ts +++ b/apps/server/src/game/fleet.ts @@ -23,6 +23,14 @@ export async function requirePlayer(tx: Queryable, playerId: string) { return player; } +/** Purchases are blocked while the tutorial runs (decision 0.5.16, CR-001). */ +export async function requireTutorialOver(tx: Queryable, playerId: string, what: string) { + const player = await requirePlayer(tx, playerId); + if (player.tutorialActive) { + throw gameError("tutorial-active", `Finish the company runs before ${what}.`); + } +} + /** Deducts credits, or throws `insufficient-funds` without changing anything. */ export async function spend(tx: Queryable, playerId: string, amount: number, what: string) { const company = await requireCompany(tx, playerId); @@ -42,6 +50,7 @@ export async function leaseBay( locationId: string, now: RealTime, ): Promise { + await requireTutorialOver(tx, playerId, "leasing a bay"); const location = content.location(locationId); if (!location) throw gameError("not-found", `No location "${locationId}".`); @@ -82,6 +91,7 @@ export async function buyShip( modelId: string, now: RealTime, ): Promise { + await requireTutorialOver(tx, playerId, "buying a ship"); const model = content.shipModel(modelId); if (!model?.purchasable) throw gameError("not-found", `No ship model "${modelId}" for sale.`); await spend(tx, playerId, model.price, `a ${model.name}`); diff --git a/apps/server/src/game/pilots.ts b/apps/server/src/game/pilots.ts index 205741c..09f4e08 100644 --- a/apps/server/src/game/pilots.ts +++ b/apps/server/src/game/pilots.ts @@ -6,7 +6,7 @@ import { and, count, eq } from "drizzle-orm"; import type { Queryable } from "../db/client"; import { bays, pilots, ships } from "../db/schema"; import { gameError } from "../http/errors"; -import { requirePlayer, spend } from "./fleet"; +import { requirePlayer, requireTutorialOver, spend } from "./fleet"; import { toApiPilot } from "./mappers"; import { VALIDATION_MESSAGES } from "./messages"; import { pilotName } from "./names"; @@ -44,6 +44,7 @@ export async function hirePilot( locationId: string, now: RealTime, ): Promise { + await requireTutorialOver(tx, playerId, "hiring pilots"); if (!(await hasBayAt(tx, playerId, locationId))) { throw gameError("no-bay-at-location", "Pilots can only be hired at one of your bays."); } diff --git a/apps/server/src/game/resolve.ts b/apps/server/src/game/resolve.ts index 067e4a0..3144b45 100644 --- a/apps/server/src/game/resolve.ts +++ b/apps/server/src/game/resolve.ts @@ -88,7 +88,10 @@ export async function resolveArrivedDispatches( if (dispatch.tutorial) { await tx .update(players) - .set({ loanerLocationId: resolution.shipLocationId }) + .set({ + loanerLocationId: resolution.shipLocationId, + tutorialRunsCompleted: sql`${players.tutorialRunsCompleted} + 1`, + }) .where(eq(players.id, playerId)); } if (resolution.discoversDestination) { diff --git a/apps/server/test/buy-ship.test.ts b/apps/server/test/buy-ship.test.ts index a149ec3..a138f2b 100644 --- a/apps/server/test/buy-ship.test.ts +++ b/apps/server/test/buy-ship.test.ts @@ -3,19 +3,22 @@ import { errorResponseSchema, shipResponseSchema } from "@deadhead/schema"; import { describe, expect, it } from "vitest"; import { shipName } from "../src/game/names"; import { setCredits, useTestDatabase } from "./db"; -import { call, getState, signUp, useTestApp } from "./game"; +import { call, getState, signUp, signUpOwner, useTestApp } from "./game"; const database = useTestDatabase(); const testApp = useTestApp(database); +const errorCode = (response: { json(): unknown }) => + errorResponseSchema.parse(response.json()).error.code; + describe("buy a ship", () => { it("deducts the price and adds an idle, uncrewed ship at Earth", async () => { const { app } = testApp(); - const guest = await signUp(app); + const owner = await signUpOwner(app, database().db); const price = content.shipModel("courier")!.price; - await setCredits(database().db, guest.playerId, price + 500); + await setCredits(database().db, owner.playerId, price + 500); - const response = await call(app, guest, "POST", "/api/ships", { modelId: "courier" }); + const response = await call(app, owner, "POST", "/api/ships", { modelId: "courier" }); expect(response.statusCode).toBe(200); const { ship } = shipResponseSchema.parse(response.json()); expect(ship).toMatchObject({ @@ -27,40 +30,54 @@ describe("buy a ship", () => { }); expect(ship.name.length).toBeGreaterThan(0); - const state = await getState(app, guest); + const state = await getState(app, owner); expect(state.company.credits).toBe(500); - expect(state.ships.map((s) => s.id)).toEqual([ship.id]); + expect(state.ships.map((s) => s.id)).toEqual([owner.shipId, ship.id]); }); it("returns insufficient-funds and changes nothing without enough credits", async () => { const { app } = testApp(); - const guest = await signUp(app); + const owner = await signUpOwner(app, database().db); const price = content.shipModel("freighter")!.price; - await setCredits(database().db, guest.playerId, price - 1); + await setCredits(database().db, owner.playerId, price - 1); - const response = await call(app, guest, "POST", "/api/ships", { modelId: "freighter" }); + const response = await call(app, owner, "POST", "/api/ships", { modelId: "freighter" }); expect(response.statusCode).toBe(409); - expect(errorResponseSchema.parse(response.json()).error.code).toBe("insufficient-funds"); + expect(errorCode(response)).toBe("insufficient-funds"); + + const state = await getState(app, owner); + expect(state.company.credits).toBe(price - 1); + expect(state.ships).toHaveLength(1); + }); + + it("returns tutorial-active during the tutorial and changes nothing", async () => { + const { app } = testApp(); + const guest = await signUp(app); + await setCredits(database().db, guest.playerId, 1_000_000); + + const response = await call(app, guest, "POST", "/api/ships", { modelId: "courier" }); + expect(response.statusCode).toBe(409); + expect(errorCode(response)).toBe("tutorial-active"); const state = await getState(app, guest); - expect(state.company.credits).toBe(price - 1); + expect(state.company.credits).toBe(1_000_000); expect(state.ships).toHaveLength(0); }); it("refuses to sell the company loaner", async () => { const { app } = testApp(); - const guest = await signUp(app); - await setCredits(database().db, guest.playerId, 1_000_000); - const response = await call(app, guest, "POST", "/api/ships", { modelId: "company-courier" }); + const owner = await signUpOwner(app, database().db); + await setCredits(database().db, owner.playerId, 1_000_000); + const response = await call(app, owner, "POST", "/api/ships", { modelId: "company-courier" }); expect(response.statusCode).toBe(404); }); it("rejects an unknown model", async () => { const { app } = testApp(); - const guest = await signUp(app); - const response = await call(app, guest, "POST", "/api/ships", { modelId: "x-wing" }); + const owner = await signUpOwner(app, database().db); + const response = await call(app, owner, "POST", "/api/ships", { modelId: "x-wing" }); expect(response.statusCode).toBe(404); - expect(errorResponseSchema.parse(response.json()).error.code).toBe("not-found"); + expect(errorCode(response)).toBe("not-found"); }); }); diff --git a/apps/server/test/concurrency.test.ts b/apps/server/test/concurrency.test.ts index c5d3845..5bff6fd 100644 --- a/apps/server/test/concurrency.test.ts +++ b/apps/server/test/concurrency.test.ts @@ -1,7 +1,7 @@ import { content } from "@deadhead/content"; import { describe, expect, it } from "vitest"; import { makeOwner, setCredits, useTestDatabase } from "./db"; -import { call, getState, putBoard, signUp, testContract, useTestApp } from "./game"; +import { call, getState, putBoard, signUp, signUpOwner, testContract, useTestApp } from "./game"; const database = useTestDatabase(); const testApp = useTestApp(database); @@ -9,7 +9,7 @@ const testApp = useTestApp(database); describe("concurrency and money safety", () => { it("lets only one of two simultaneous purchases through when only one is affordable", async () => { const { app } = testApp(); - const guest = await signUp(app); + const guest = await signUpOwner(app, database().db); const price = content.shipModel("courier")!.price; await setCredits(database().db, guest.playerId, price + price / 2); @@ -20,13 +20,14 @@ describe("concurrency and money safety", () => { expect(responses.map((r) => r.statusCode).sort()).toEqual([200, 409]); const state = await getState(app, guest); - expect(state.ships).toHaveLength(1); + // The owner already had one ship. + expect(state.ships).toHaveLength(2); expect(state.company.credits).toBe(price / 2); }); it("never goes negative under many simultaneous purchases", async () => { const { app } = testApp(); - const guest = await signUp(app); + const guest = await signUpOwner(app, database().db); const price = content.shipModel("courier")!.price; await setCredits(database().db, guest.playerId, price * 3); @@ -37,7 +38,7 @@ describe("concurrency and money safety", () => { ); expect(responses.filter((r) => r.statusCode === 200)).toHaveLength(3); const state = await getState(app, guest); - expect(state.ships).toHaveLength(3); + expect(state.ships).toHaveLength(1 + 3); expect(state.company.credits).toBe(0); }); @@ -63,7 +64,7 @@ describe("concurrency and money safety", () => { it("leases one bay when the same lease is sent twice at once", async () => { const { app } = testApp(); - const guest = await signUp(app); + const guest = await signUpOwner(app, database().db); await setCredits(database().db, guest.playerId, 100_000); const responses = await Promise.all([ call(app, guest, "POST", "/api/bays", { locationId: "moon" }), @@ -71,7 +72,7 @@ describe("concurrency and money safety", () => { ]); expect(responses.map((r) => r.statusCode).sort()).toEqual([200, 409]); const state = await getState(app, guest); - expect(state.bays).toHaveLength(1); + expect(state.bays.map((b) => b.locationId).sort()).toEqual(["earth", "moon"]); expect(state.company.credits).toBe(100_000 - content.location("moon")!.bayLeasePrice); }); }); diff --git a/apps/server/test/end-tutorial.test.ts b/apps/server/test/end-tutorial.test.ts deleted file mode 100644 index 8163130..0000000 --- a/apps/server/test/end-tutorial.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { content } from "@deadhead/content"; -import { - contractBoardResponseSchema, - dispatchResponseSchema, - 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, signUp, useTestApp, type Guest } from "./game"; - -const database = useTestDatabase(); -const testApp = useTestApp(database); - -async function buyShipAndEarthBay(guest: Guest) { - const { app } = testApp(); - expect((await call(app, guest, "POST", "/api/ships", { modelId: "courier" })).statusCode).toBe( - 200, - ); - expect((await call(app, guest, "POST", "/api/bays", { locationId: "earth" })).statusCode).toBe( - 200, - ); -} - -describe("end of tutorial", () => { - it("ends when the player owns a ship and an Earth bay, moving the avatar to Earth unassigned", async () => { - const { app } = testApp(); - const { db } = database(); - const guest = await signUp(app); - await setCredits(db, guest.playerId, 1_000_000); - // Put the loaner and avatar at the Moon. - await db - .update(players) - .set({ loanerLocationId: "moon" }) - .where(eq(players.id, guest.playerId)); - await db.update(pilots).set({ locationId: "moon" }).where(eq(pilots.id, guest.avatarId)); - - await call(app, guest, "POST", "/api/ships", { modelId: "courier" }); - expect((await getState(app, guest)).tutorial).not.toBeNull(); - await call(app, guest, "POST", "/api/bays", { locationId: "earth" }); - - const state = await getState(app, guest); - expect(state.tutorial).toBeNull(); - expect(state.pilots[0]).toMatchObject({ - isAvatar: true, - status: "idle", - locationId: "earth", - assignedShipId: null, - }); - - const board = await call(app, guest, "GET", "/api/tutorial/board"); - expect(board.statusCode).toBe(409); - expect(errorResponseSchema.parse(board.json()).error.code).toBe("not-in-tutorial"); - }); - - it("waits for an in-flight tutorial run to land before ending", async () => { - const { app, clock } = testApp(); - const guest = await signUp(app); - await setCredits(database().db, guest.playerId, 1_000_000); - const [contract] = contractBoardResponseSchema.parse( - (await call(app, guest, "GET", "/api/tutorial/board")).json(), - ).contracts; - const { dispatch } = dispatchResponseSchema.parse( - ( - await call(app, guest, "POST", "/api/dispatches", { - kind: "tutorial", - contractId: contract!.id, - }) - ).json(), - ); - - await buyShipAndEarthBay(guest); - expect((await getState(app, guest)).tutorial).toMatchObject({ - loanerLocationId: "earth", - runInFlight: true, - }); - - clock.now = dispatch.arrivalAt; - const state = await getState(app, guest); - expect(state.tutorial).toBeNull(); - // The run still paid out, and the avatar was brought back to Earth. - expect(state.company.credits).toBe( - 1_000_000 - - content.shipModel("courier")!.price - - content.location("earth")!.bayLeasePrice + - contract!.payout, - ); - expect(state.pilots[0]).toMatchObject({ - locationId: "earth", - status: "idle", - assignedShipId: null, - }); - }); - - it("does not end with only a ship, or only an Earth bay", async () => { - const { app } = testApp(); - const shipOnly = await signUp(app); - await setCredits(database().db, shipOnly.playerId, 1_000_000); - await call(app, shipOnly, "POST", "/api/ships", { modelId: "courier" }); - expect((await getState(app, shipOnly)).tutorial).not.toBeNull(); - - const bayOnly = await signUp(app); - await setCredits(database().db, bayOnly.playerId, 1_000_000); - await call(app, bayOnly, "POST", "/api/bays", { locationId: "earth" }); - expect((await getState(app, bayOnly)).tutorial).not.toBeNull(); - }); -}); diff --git a/apps/server/test/game.ts b/apps/server/test/game.ts index a114075..3527871 100644 --- a/apps/server/test/game.ts +++ b/apps/server/test/game.ts @@ -14,7 +14,7 @@ import { buildApp } from "../src/app"; import { createRecordingQueue } from "../src/jobs/queue"; import type { Database, Db } from "../src/db/client"; import { contractBoards, dispatches, pilots, players, ships } from "../src/db/schema"; -import { TEST_NOW } from "./db"; +import { makeOwner, TEST_NOW } from "./db"; import { testConfig } from "./helpers"; /** A clock tests can move. Starts at `TEST_NOW`. */ @@ -198,3 +198,13 @@ export async function putBoard(db: Db, playerId: string, contracts: Contract[], createdAt: now, }); } + +/** Signs up a guest and turns them into an owner: tutorial over, a Courier at Earth crewed by the avatar, an Earth bay. */ +export async function signUpOwner( + app: TestApp["app"], + db: Db, +): Promise { + const guest = await signUp(app); + const shipId = await makeOwner(db, guest.playerId, guest.avatarId); + return { ...guest, shipId }; +} diff --git a/apps/server/test/lease-bay.test.ts b/apps/server/test/lease-bay.test.ts index d502b8c..c0c1ca9 100644 --- a/apps/server/test/lease-bay.test.ts +++ b/apps/server/test/lease-bay.test.ts @@ -2,7 +2,7 @@ import { content } from "@deadhead/content"; import { bayResponseSchema, errorResponseSchema } from "@deadhead/schema"; import { describe, expect, it } from "vitest"; import { setCredits, useTestDatabase } from "./db"; -import { call, getState, signUp, useTestApp } from "./game"; +import { call, getState, signUp, signUpOwner, useTestApp } from "./game"; const database = useTestDatabase(); const testApp = useTestApp(database); @@ -11,53 +11,64 @@ const errorCode = (response: { json(): unknown }) => errorResponseSchema.parse(response.json()).error.code; describe("lease a bay", () => { - it("leases a bay at Earth and deducts the price", async () => { + it("leases a bay at the Moon and deducts the price", async () => { const { app } = testApp(); - const guest = await signUp(app); - const price = content.location("earth")!.bayLeasePrice; - await setCredits(database().db, guest.playerId, price + 100); + const owner = await signUpOwner(app, database().db); + const price = content.location("moon")!.bayLeasePrice; + await setCredits(database().db, owner.playerId, price + 100); - const response = await call(app, guest, "POST", "/api/bays", { locationId: "earth" }); + const response = await call(app, owner, "POST", "/api/bays", { locationId: "moon" }); expect(response.statusCode).toBe(200); const { bay } = bayResponseSchema.parse(response.json()); - expect(bay.locationId).toBe("earth"); + expect(bay.locationId).toBe("moon"); - const state = await getState(app, guest); + const state = await getState(app, owner); expect(state.company.credits).toBe(100); - expect(state.bays).toEqual([bay]); + expect(state.bays.map((b) => b.locationId).sort()).toEqual(["earth", "moon"]); }); it("refuses an undiscovered location", async () => { const { app } = testApp(); - const guest = await signUp(app); - await setCredits(database().db, guest.playerId, 1_000_000); - const response = await call(app, guest, "POST", "/api/bays", { locationId: "mars" }); + const owner = await signUpOwner(app, database().db); + await setCredits(database().db, owner.playerId, 1_000_000); + const response = await call(app, owner, "POST", "/api/bays", { locationId: "mars" }); expect(response.statusCode).toBe(409); expect(errorCode(response)).toBe("location-undiscovered"); - expect((await getState(app, guest)).company.credits).toBe(1_000_000); + expect((await getState(app, owner)).company.credits).toBe(1_000_000); }); it("refuses a second bay at the same location", async () => { const { app } = testApp(); - const guest = await signUp(app); - await setCredits(database().db, guest.playerId, 1_000_000); - await call(app, guest, "POST", "/api/bays", { locationId: "moon" }); - const response = await call(app, guest, "POST", "/api/bays", { locationId: "moon" }); + const owner = await signUpOwner(app, database().db); + await setCredits(database().db, owner.playerId, 1_000_000); + const response = await call(app, owner, "POST", "/api/bays", { locationId: "earth" }); expect(response.statusCode).toBe(409); expect(errorCode(response)).toBe("bay-already-leased"); - const state = await getState(app, guest); + const state = await getState(app, owner); expect(state.bays).toHaveLength(1); - expect(state.company.credits).toBe(1_000_000 - content.location("moon")!.bayLeasePrice); + expect(state.company.credits).toBe(1_000_000); }); it("refuses when the player cannot afford it, and unknown locations", async () => { const { app } = testApp(); - const guest = await signUp(app); - await setCredits(database().db, guest.playerId, 0); - expect(errorCode(await call(app, guest, "POST", "/api/bays", { locationId: "earth" }))).toBe( + const owner = await signUpOwner(app, database().db); + await setCredits(database().db, owner.playerId, 0); + expect(errorCode(await call(app, owner, "POST", "/api/bays", { locationId: "moon" }))).toBe( "insufficient-funds", ); - const unknown = await call(app, guest, "POST", "/api/bays", { locationId: "pluto" }); + const unknown = await call(app, owner, "POST", "/api/bays", { locationId: "pluto" }); expect(unknown.statusCode).toBe(404); }); + + it("returns tutorial-active during the tutorial and changes nothing", async () => { + const { app } = testApp(); + const guest = await signUp(app); + await setCredits(database().db, guest.playerId, 1_000_000); + const response = await call(app, guest, "POST", "/api/bays", { locationId: "earth" }); + expect(response.statusCode).toBe(409); + expect(errorCode(response)).toBe("tutorial-active"); + const state = await getState(app, guest); + expect(state.bays).toEqual([]); + expect(state.company.credits).toBe(1_000_000); + }); }); diff --git a/apps/server/test/pilots.test.ts b/apps/server/test/pilots.test.ts index b5133bc..17f88c5 100644 --- a/apps/server/test/pilots.test.ts +++ b/apps/server/test/pilots.test.ts @@ -134,12 +134,18 @@ describe("assigning pilots", () => { it("refuses to assign the avatar during the tutorial", async () => { const { app } = testApp(); const guest = await signUp(app); - await setCredits(database().db, guest.playerId, 100_000); - const ship = (await call(app, guest, "POST", "/api/ships", { modelId: "courier" })).json() as { - ship: { id: string }; - }; + // Ships cannot be bought during the tutorial, so put one at Earth directly. + const shipId = randomUUID(); + await database().db.insert(ships).values({ + id: shipId, + playerId: guest.playerId, + modelId: "courier", + name: "Gift Courier", + locationId: "earth", + createdAt: TEST_NOW, + }); const response = await call(app, guest, "POST", `/api/pilots/${guest.avatarId}/assign`, { - shipId: ship.ship.id, + shipId, }); expect(errorCode(response)).toBe("avatar-in-tutorial"); }); diff --git a/apps/server/test/tutorial-flow.ts b/apps/server/test/tutorial-flow.ts new file mode 100644 index 0000000..ebe7329 --- /dev/null +++ b/apps/server/test/tutorial-flow.ts @@ -0,0 +1,31 @@ +import { + contractBoardResponseSchema, + dispatchResponseSchema, + type PlayerState, +} from "@deadhead/schema"; +import { call, getState, type Guest, type TestApp } from "./game"; + +/** The tutorial board the player sees now. */ +export async function tutorialBoard(app: TestApp["app"], guest: Guest) { + const response = await call(app, guest, "GET", "/api/tutorial/board"); + if (response.statusCode !== 200) throw new Error(`board failed: ${response.body}`); + return contractBoardResponseSchema.parse(response.json()).contracts; +} + +/** Flies one tutorial run (the given slot, light by default) and lets it land. */ +export async function flyTutorialRun( + testApp: TestApp, + guest: Guest, + slot = 0, +): Promise { + const { app, clock } = testApp; + const contract = (await tutorialBoard(app, guest))[slot]!; + const response = await call(app, guest, "POST", "/api/dispatches", { + kind: "tutorial", + contractId: contract.id, + }); + if (response.statusCode !== 200) throw new Error(`dispatch failed: ${response.body}`); + const { dispatch } = dispatchResponseSchema.parse(response.json()); + clock.now = dispatch.arrivalAt; + return getState(app, guest); +} diff --git a/apps/server/test/tutorial-v2-three-runs.test.ts b/apps/server/test/tutorial-v2-three-runs.test.ts new file mode 100644 index 0000000..d9048f2 --- /dev/null +++ b/apps/server/test/tutorial-v2-three-runs.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from "vitest"; +import { useTestDatabase } from "./db"; +import { getState, signUp, useTestApp } from "./game"; +import { flyTutorialRun } from "./tutorial-flow"; + +// The deal after three runs instead of four: the loaner is then at the Moon. +vi.mock("@deadhead/content", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + content: { + ...original.content, + config: { ...original.content.config, tutorialRunsBeforeDeal: 3 }, + }, + }; +}); + +const database = useTestDatabase(); +const testApp = useTestApp(database); + +describe("deal availability with tutorialRunsBeforeDeal = 3", () => { + it("waits until a fourth run returns the loaner to Earth", async () => { + const guest = await signUp(testApp().app); + for (let run = 1; run <= 3; run++) await flyTutorialRun(testApp(), guest); + expect((await getState(testApp().app, guest)).tutorial).toMatchObject({ + runsCompleted: 3, + loanerLocationId: "moon", + dealAvailable: false, + }); + + const state = await flyTutorialRun(testApp(), guest); + expect(state.tutorial).toMatchObject({ + runsCompleted: 4, + loanerLocationId: "earth", + dealAvailable: true, + }); + }); +}); diff --git a/apps/server/test/tutorial-v2.test.ts b/apps/server/test/tutorial-v2.test.ts new file mode 100644 index 0000000..f667882 --- /dev/null +++ b/apps/server/test/tutorial-v2.test.ts @@ -0,0 +1,80 @@ +import { content } from "@deadhead/content"; +import { errorResponseSchema } from "@deadhead/schema"; +import { describe, expect, it } from "vitest"; +import { setCredits, useTestDatabase } from "./db"; +import { call, getState, signUp, useTestApp } from "./game"; +import { flyTutorialRun, tutorialBoard } from "./tutorial-flow"; + +const database = useTestDatabase(); +const testApp = useTestApp(database); + +describe("tutorial flow v2", () => { + it("starts a guest with the starting cushion, the Company Courier loaner at Earth and no runs", async () => { + const { app } = testApp(); + const guest = await signUp(app); + const state = await getState(app, guest); + expect(state.company).toMatchObject({ + credits: content.config.startingCredits, + loanBalance: 0, + }); + expect(state.tutorial).toEqual({ + loanerLocationId: "earth", + runInFlight: false, + runsCompleted: 0, + dealAvailable: false, + }); + expect(state.pilots[0]).toMatchObject({ isAvatar: true, locationId: "earth" }); + expect(state.goals).toEqual([]); + }); + + it("counts each resolved run and gives every run a fresh three-contract board", async () => { + const guest = await signUp(testApp().app); + const first = await tutorialBoard(testApp().app, guest); + expect(first).toHaveLength(3); + + const state = await flyTutorialRun(testApp(), guest); + expect(state.tutorial).toMatchObject({ runsCompleted: 1, loanerLocationId: "moon" }); + + const second = await tutorialBoard(testApp().app, guest); + expect(second).toHaveLength(3); + for (const c of second) expect([c.originId, c.destinationId]).toEqual(["moon", "earth"]); + expect(second.map((c) => c.id)).not.toEqual(first.map((c) => c.id)); + }); + + it("offers Unmarked crates on the third run's board", async () => { + const guest = await signUp(testApp().app); + await flyTutorialRun(testApp(), guest); + await flyTutorialRun(testApp(), guest); + const third = await tutorialBoard(testApp().app, guest); + expect(third.find((c) => c.scriptedId === "unmarked-crates")).toBeDefined(); + }); + + it("makes the deal available after four runs ending at Earth, not after three", async () => { + const guest = await signUp(testApp().app); + for (let run = 1; run <= 3; run++) await flyTutorialRun(testApp(), guest); + const afterThree = await getState(testApp().app, guest); + expect(afterThree.tutorial).toMatchObject({ + runsCompleted: 3, + loanerLocationId: "moon", + dealAvailable: false, + }); + + const afterFour = await flyTutorialRun(testApp(), guest); + expect(afterFour.tutorial).toMatchObject({ + runsCompleted: 4, + loanerLocationId: "earth", + runInFlight: false, + dealAvailable: true, + }); + }); + + it("blocks hiring pilots during the tutorial", async () => { + const { app } = testApp(); + const guest = await signUp(app); + await setCredits(database().db, guest.playerId, 1_000_000); + const response = await call(app, guest, "POST", "/api/pilots", { locationId: "earth" }); + expect(response.statusCode).toBe(409); + expect(errorResponseSchema.parse(response.json()).error.code).toBe("tutorial-active"); + expect((await getState(app, guest)).pilots).toHaveLength(1); + }); +}); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index bdfba16..dee2c55 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -112,7 +112,7 @@ Spec: `memory-bank/change-requests/CR-001-faster-start.md`. Decisions recorded a - [x] **Step 10A.4 — Sim: loan repayment.** `splitLoanRepayment(credits, balance, share)` (`src/loan.ts`); `resolveDispatch` takes `loanBalance` and returns `loanRepayment` with net `credits`. Server passes a zero balance until Step 10A.9. - [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. -- [ ] Step 10A.7 — Server: tutorial flow v2 +- [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 - [ ] Step 10A.9 — Server: loan repayment - [ ] Step 10A.10 — Server: goals