diff --git a/DECISIONS.md b/DECISIONS.md index 4a407f0..cc6aedf 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -335,3 +335,8 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` ## Step 6.13 - **Acknowledging is by dispatch id and idempotent.** Ids that are unknown, belong to another player, are still in flight or were already acknowledged are skipped silently, and the response counts only those newly acknowledged. A client retry is harmless. + +## Step 6.14 + +- **No new mechanism was needed:** since Step 6.1 every authenticated endpoint runs in `withPlayer`, a transaction that first takes `SELECT ... FOR UPDATE` on the player's company row. Requests for one player run one after another; different players never block each other. +- **Race tests go beyond the plan's double purchase:** eight simultaneous purchases with money for three, the same contract dispatched twice at once, and the same bay leased twice at once. With the row lock removed, three of the four fail, which confirms the tests exercise the lock. The two-purchase race can pass by luck without the lock, which is why the eight-purchase version exists. diff --git a/apps/server/test/concurrency.test.ts b/apps/server/test/concurrency.test.ts new file mode 100644 index 0000000..c5d3845 --- /dev/null +++ b/apps/server/test/concurrency.test.ts @@ -0,0 +1,77 @@ +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"; + +const database = useTestDatabase(); +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 price = content.shipModel("courier")!.price; + await setCredits(database().db, guest.playerId, price + price / 2); + + const responses = await Promise.all([ + call(app, guest, "POST", "/api/ships", { modelId: "courier" }), + call(app, guest, "POST", "/api/ships", { modelId: "courier" }), + ]); + expect(responses.map((r) => r.statusCode).sort()).toEqual([200, 409]); + + const state = await getState(app, guest); + expect(state.ships).toHaveLength(1); + 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 price = content.shipModel("courier")!.price; + await setCredits(database().db, guest.playerId, price * 3); + + const responses = await Promise.all( + Array.from({ length: 8 }, () => + call(app, guest, "POST", "/api/ships", { modelId: "courier" }), + ), + ); + expect(responses.filter((r) => r.statusCode === 200)).toHaveLength(3); + const state = await getState(app, guest); + expect(state.ships).toHaveLength(3); + expect(state.company.credits).toBe(0); + }); + + it("accepts a contract only once when dispatched twice at the same moment", async () => { + const { app } = testApp(); + const { db } = database(); + const guest = await signUp(app); + const shipId = await makeOwner(db, guest.playerId, guest.avatarId); + await setCredits(db, guest.playerId, 10_000); + const contract = testContract(); + await putBoard(db, guest.playerId, [contract]); + + const body = { kind: "contract", contractId: contract.id, shipId, pilotId: guest.avatarId }; + const responses = await Promise.all([ + call(app, guest, "POST", "/api/dispatches", body), + call(app, guest, "POST", "/api/dispatches", body), + ]); + expect(responses.map((r) => r.statusCode).sort()).toEqual([200, 409]); + const state = await getState(app, guest); + expect(state.activeDispatches).toHaveLength(1); + expect(state.company.credits).toBe(10_000 - state.activeDispatches[0]!.fuelCost); + }); + + it("leases one bay when the same lease is sent twice at once", async () => { + const { app } = testApp(); + const guest = await signUp(app); + await setCredits(database().db, guest.playerId, 100_000); + const responses = await Promise.all([ + call(app, guest, "POST", "/api/bays", { locationId: "moon" }), + call(app, guest, "POST", "/api/bays", { locationId: "moon" }), + ]); + expect(responses.map((r) => r.statusCode).sort()).toEqual([200, 409]); + const state = await getState(app, guest); + expect(state.bays).toHaveLength(1); + expect(state.company.credits).toBe(100_000 - content.location("moon")!.bayLeasePrice); + }); +}); diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index 1dd5774..a04639e 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -376,3 +376,20 @@ Every catalog endpoint is registered with `registerRoute(app, routes., opt 4. Outside production, validate the result against the route's `response` schema; a mismatch is logged and returned as `500 internal-error`. Handlers throw `ApiError(status, code, message)` for expected failures. Unknown paths return `404 not-found`. + +## Game logic (`apps/server/src/game`) + +| Module | Responsibility | +|---|---| +| `lock.ts` | `withPlayerLock`: transaction + `FOR UPDATE` on the player's company row | +| `resolve.ts` | `resolveArrivedDispatches`: exactly-once resolution of due dispatches (lazy on every request; Phase 7 job) | +| `tutorial.ts` | `maybeEndTutorial` (decision 0.5.4) | +| `boards.ts` | Stored tutorial and bay boards per period; accepted contracts hidden | +| `dispatch.ts` | Tutorial, contract and reposition dispatch; `storeDispatch` (record, accepted contract, fuel) | +| `fleet.ts` | Buy ship, lease bay, `spend` credits | +| `pilots.ts` | Hire, assign, unassign, spend skill point | +| `summaries.ts` | Acknowledge "while you were away" entries | +| `player-state.ts`, `mappers.ts` | Database rows to API shapes | +| `names.ts` | Seeded ship and pilot names | + +Routes (`apps/server/src/routes`) wrap every authenticated action in `withPlayer`: lock, resolve due arrivals, run the action, then `maybeEndTutorial`. Game-rule failures are `409` with their error code; unknown things are `404`. diff --git a/memory-bank/progress.md b/memory-bank/progress.md index d8197e1..7503399 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -67,4 +67,4 @@ - [x] **Step 6.11 — Reposition dispatch.** `POST /api/dispatches` with `kind: "reposition"` (`dispatchReposition`): sim reposition validation against the player's bays, fuel only, no payout or XP; ship and pilot arrive at the destination. - [x] **Step 6.12 — Spend skill points.** `POST /api/pilots/:pilotId/skills` (`spendSkillPoint`): +1 skill level, +1 point spent; `409 no-skill-points`, `409 skill-maxed`, `404 unknown-skill`. - [x] **Step 6.13 — Acknowledge summaries.** `POST /api/summaries/acknowledge` (`src/game/summaries.ts`) sets `acknowledged_at` on the player's resolved, unacknowledged dispatches among the given ids and returns how many; resolved effects are untouched. -- [ ] Step 6.14 — Concurrency and money safety +- [x] **Step 6.14 — Concurrency and money safety.** Every authenticated endpoint runs in `withPlayer` (transaction + company row lock). Race tests: two and eight simultaneous purchases, a contract dispatched twice at once, a bay leased twice at once; three of them fail when the lock is removed.