From 2a8f788ce8452ab4a3a23f8a475af053f5292161 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sat, 3 Oct 2026 01:03:09 -0400 Subject: [PATCH] Step 6.2: test full player state Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 4 ++ apps/server/test/state.test.ts | 97 ++++++++++++++++++++++++++++++++++ memory-bank/progress.md | 2 +- 3 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 apps/server/test/state.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index eb7b0a7..8e58b8c 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -262,3 +262,7 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Game-rule failures return `409`** with their error code (`gameError`), except `not-found` (`404`). - **Routes live in `src/routes/*`** and share an `AppContext` (db, auth, clock, route options). - **Test helpers** (`test/game.ts`): `useTestApp` (fresh app plus a movable `TestClock`), `signUp` (a real guest session via `POST /api/guest`), `call`/`getState`, `insertDispatch` (puts a sim-built dispatch in the database). `makeOwner` / `setCredits` (`test/db.ts`) reshape a signed-up player. + +## Step 6.2 + +- **No new code was needed**: Step 5.5 built the state reader and Step 6.1 added resolution, so this step is the full test. A pilot's `level` and `skillPointsAvailable` are derived from XP and points spent each time state is read, never stored. diff --git a/apps/server/test/state.test.ts b/apps/server/test/state.test.ts new file mode 100644 index 0000000..242cd29 --- /dev/null +++ b/apps/server/test/state.test.ts @@ -0,0 +1,97 @@ +import { randomUUID } from "node:crypto"; +import { content } from "@deadhead/content"; +import { playerStateSchema } from "@deadhead/schema"; +import { pilotLevel } from "@deadhead/sim"; +import { describe, expect, it } from "vitest"; +import { pilots, ships } from "../src/db/schema"; +import { makeOwner, setCredits, TEST_NOW, useTestDatabase } from "./db"; +import { call, insertDispatch, signUp, useTestApp } from "./game"; + +const database = useTestDatabase(); +const testApp = useTestApp(database); + +describe("full player state", () => { + it("reflects one ship in flight and one idle, and validates against its schema", async () => { + const { app, clock } = testApp(); + const { db } = database(); + const guest = await signUp(app); + const flyingShipId = await makeOwner(db, guest.playerId, guest.avatarId); + await setCredits(db, guest.playerId, 9000); + + // A second ship at the Moon crewed by a hired pilot with some XP and a skill. + const idleShipId = randomUUID(); + const hiredId = randomUUID(); + await db.insert(ships).values({ + id: idleShipId, + playerId: guest.playerId, + modelId: "freighter", + name: "Slow Ox", + locationId: "moon", + createdAt: TEST_NOW, + }); + await db.insert(pilots).values({ + id: hiredId, + playerId: guest.playerId, + name: "Rae Okafor", + xp: 260, + skills: { "heavy-cargo": 1 }, + skillPointsSpent: 1, + assignedShipId: idleShipId, + createdAt: TEST_NOW, + }); + const record = await insertDispatch(db, { + playerId: guest.playerId, + shipId: flyingShipId, + pilotId: guest.avatarId, + departAt: clock.now, + }); + + const response = await call(app, guest, "GET", "/api/state"); + expect(response.statusCode).toBe(200); + const state = playerStateSchema.parse(response.json()); + + expect(state.company.credits).toBe(9000); + expect(state.tutorial).toBeNull(); + expect(state.bays.map((b) => b.locationId)).toEqual(["earth"]); + + const flying = state.ships.find((s) => s.id === flyingShipId)!; + const idle = state.ships.find((s) => s.id === idleShipId)!; + expect(flying).toMatchObject({ + status: "in-flight", + locationId: null, + dispatchId: record.id, + pilotId: guest.avatarId, + }); + expect(idle).toMatchObject({ + status: "idle", + locationId: "moon", + dispatchId: null, + pilotId: hiredId, + modelId: "freighter", + }); + + const avatar = state.pilots.find((p) => p.id === guest.avatarId)!; + const hired = state.pilots.find((p) => p.id === hiredId)!; + expect(avatar).toMatchObject({ + status: "in-flight", + locationId: null, + assignedShipId: flyingShipId, + }); + expect(hired).toMatchObject({ + status: "idle", + locationId: "moon", + assignedShipId: idleShipId, + xp: 260, + level: pilotLevel(260, content.config), + skills: { "heavy-cargo": 1 }, + }); + expect(hired.level).toBe(2); + expect(hired.skillPointsAvailable).toBe(2 * content.config.skillPointsPerLevel - 1); + + expect(state.activeDispatches).toEqual([ + expect.objectContaining({ id: record.id, status: "in-flight", arrivalAt: record.arrivalAt }), + ]); + expect(state.summaries).toEqual([]); + expect(state.locations).toHaveLength(content.locations.length); + }); +}); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 271d7bf..fd30990 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -55,7 +55,7 @@ ## Phase 6 — Server Game Logic - [x] **Step 6.1 — Lazy resolution on read.** `resolveArrivedDispatches` (`src/game/resolve.ts`) resolves arrived dispatches in arrival order inside `withPlayerLock` (`src/game/lock.ts`, a transaction holding the company row lock); a conditional status update makes each resolve exactly once. `GET /api/state` runs it before reading. Routes moved to `src/routes/`. -- [ ] Step 6.2 — Get full player state +- [x] **Step 6.2 — Get full player state.** Endpoint from Step 5.5 plus lazy resolution; test covers a player with one ship in flight and one idle (crewed by a hired pilot), checking ships, pilots (XP, level, skills, points, assignment, status, location), bays, dispatches, summaries and schema validity. - [ ] Step 6.3 — Tutorial board endpoint - [ ] Step 6.4 — Tutorial dispatch - [ ] Step 6.5 — Buy a ship