Step 6.14: test concurrency and money safety
CI / check (push) Successful in 1m30s
Details
CI / check (push) Successful in 1m30s
Details
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
66ed680602
commit
0044e355f1
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -376,3 +376,20 @@ Every catalog endpoint is registered with `registerRoute(app, routes.<name>, 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`.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue