From badbac1890ae4e507d890b808d9e82038c4c9df5 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sat, 3 Oct 2026 01:11:24 -0400 Subject: [PATCH] Step 6.7: end the tutorial automatically Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 6 ++ apps/server/src/game/resolve.ts | 5 +- apps/server/src/game/tutorial.ts | 49 ++++++++++++ apps/server/src/routes/with-player.ts | 8 +- apps/server/test/end-tutorial.test.ts | 108 ++++++++++++++++++++++++++ memory-bank/progress.md | 2 +- 6 files changed, 174 insertions(+), 4 deletions(-) create mode 100644 apps/server/src/game/tutorial.ts create mode 100644 apps/server/test/end-tutorial.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index 843d82a..f6b3a8e 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -293,3 +293,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **A bay lease is a one-time payment** of the location's `bayLeasePrice`, as the plan words it ("one-time lease price"); there is no recurring rent in the base game. - **Check order:** location exists (`404 not-found`), discovered (`409 location-undiscovered`), no bay there yet (`409 bay-already-leased`), affordable (`409 insufficient-funds`). + +## Step 6.7 + +- **The end-of-tutorial check runs in two places:** at the end of every resolution pass that resolved something (which also covers Phase 7's arrival job), and after every action in `withPlayer`. Buying the ship, leasing the Earth bay, or the last tutorial run landing can each be the trigger. +- **"Removing the loaner"** means clearing `players.loaner_location_id` and `tutorial_active`; the loaner never had a ship row. +- **The avatar is moved to Earth** as an idle, unassigned pilot, wherever the loaner was (decision 0.5.4). diff --git a/apps/server/src/game/resolve.ts b/apps/server/src/game/resolve.ts index f9e9471..3b03ded 100644 --- a/apps/server/src/game/resolve.ts +++ b/apps/server/src/game/resolve.ts @@ -3,13 +3,15 @@ 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. Returns how many dispatches were resolved. + * when it changed a row. Ends the tutorial if its last run just landed + * (Step 6.7). Returns how many dispatches were resolved. */ export async function resolveArrivedDispatches( tx: Queryable, @@ -90,5 +92,6 @@ 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 new file mode 100644 index 0000000..a2a506a --- /dev/null +++ b/apps/server/src/game/tutorial.ts @@ -0,0 +1,49 @@ +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/with-player.ts b/apps/server/src/routes/with-player.ts index e96bbdd..089869b 100644 --- a/apps/server/src/routes/with-player.ts +++ b/apps/server/src/routes/with-player.ts @@ -1,11 +1,13 @@ 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), then runs `fn`. + * that are due (so the action sees current state), runs `fn`, then ends the + * tutorial if the action completed its conditions (Step 6.7). */ export function withPlayer( ctx: AppContext, @@ -15,6 +17,8 @@ export function withPlayer( const now = ctx.clock(); return withPlayerLock(ctx.db, playerId, async (tx) => { await resolveArrivedDispatches(tx, playerId, now); - return fn(tx, now); + const result = await fn(tx, now); + await maybeEndTutorial(tx, playerId); + return result; }); } diff --git a/apps/server/test/end-tutorial.test.ts b/apps/server/test/end-tutorial.test.ts new file mode 100644 index 0000000..1d0b841 --- /dev/null +++ b/apps/server/test/end-tutorial.test.ts @@ -0,0 +1,108 @@ +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).toEqual({ + 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/memory-bank/progress.md b/memory-bank/progress.md index 1cba761..aca290e 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -60,7 +60,7 @@ - [x] **Step 6.4 — Tutorial dispatch.** `POST /api/dispatches` with `kind: "tutorial"` (`src/game/dispatch.ts`): avatar + loaner, sim validation, no fuel, one run at a time; contract marked accepted. Arrival pays the stored tutorial payout and moves loaner and avatar. - [x] **Step 6.5 — Buy a ship.** `POST /api/ships` (`src/game/fleet.ts`, `src/routes/fleet.ts`): deducts the price, adds an idle uncrewed ship at Earth named from the world seed (`src/game/names.ts`); `409 insufficient-funds` changes nothing. - [x] **Step 6.6 — Lease a bay.** `POST /api/bays` (`leaseBay` in `src/game/fleet.ts`): one-time lease price at a discovered location; `409 location-undiscovered`, `409 bay-already-leased`, `409 insufficient-funds`, `404 not-found`. -- [ ] Step 6.7 — End tutorial +- [x] **Step 6.7 — End tutorial.** `maybeEndTutorial` (`src/game/tutorial.ts`) runs after each resolution pass and after every game action: with a ship, an Earth bay and no tutorial run in flight, tutorial mode turns off, the loaner is removed, and the avatar becomes an idle, unassigned pilot at Earth. - [ ] Step 6.8 — Hire and assign pilots - [ ] Step 6.9 — Bay contract board endpoint - [ ] Step 6.10 — Contract dispatch