diff --git a/DECISIONS.md b/DECISIONS.md index 63cd2da..4a407f0 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -331,3 +331,7 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Points available come from sim:** `skillPointsAvailable(pilotLevel(xp), pointsSpent)`. Spending adds one level to the skill in the `skills` JSONB and one to `skill_points_spent`. - **Skill points can be spent any time**, including while the pilot is flying; nothing in the plan restricts it, and skills only matter when the next dispatch is checked. - **Check order:** pilot belongs to the player (`404 not-found`), skill exists (`404 unknown-skill`), a point is available (`409 no-skill-points`), skill below max (`409 skill-maxed`). + +## 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. diff --git a/apps/server/src/game/summaries.ts b/apps/server/src/game/summaries.ts new file mode 100644 index 0000000..57a807e --- /dev/null +++ b/apps/server/src/game/summaries.ts @@ -0,0 +1,30 @@ +import type { RealTime } from "@deadhead/sim"; +import { and, eq, inArray, isNull } from "drizzle-orm"; +import type { Queryable } from "../db/client"; +import { dispatches } from "../db/schema"; + +/** + * Marks "while you were away" summaries as seen (Step 6.13). Only this + * player's resolved, unacknowledged dispatches are touched; other ids are + * ignored. Returns how many were acknowledged. + */ +export async function acknowledgeSummaries( + tx: Queryable, + playerId: string, + dispatchIds: string[], + now: RealTime, +): Promise { + const updated = await tx + .update(dispatches) + .set({ acknowledgedAt: now }) + .where( + and( + eq(dispatches.playerId, playerId), + inArray(dispatches.id, dispatchIds), + eq(dispatches.status, "resolved"), + isNull(dispatches.acknowledgedAt), + ), + ) + .returning({ id: dispatches.id }); + return updated.length; +} diff --git a/apps/server/src/routes/player.ts b/apps/server/src/routes/player.ts index ade26d5..2169970 100644 --- a/apps/server/src/routes/player.ts +++ b/apps/server/src/routes/player.ts @@ -6,6 +6,7 @@ import { fromNodeHeaders } from "better-auth/node"; import type { FastifyInstance } from "fastify"; import { createPlayer } from "../game/new-player"; import { loadPlayerState } from "../game/player-state"; +import { acknowledgeSummaries } from "../game/summaries"; import { ApiError } from "../http/errors"; import { registerRoute } from "../http/route"; import type { AppContext } from "./context"; @@ -42,4 +43,10 @@ export function registerPlayerRoutes(app: FastifyInstance, ctx: AppContext): voi registerRoute(app, routes.getPlayerState, options, async (_input, { playerId }) => withPlayer(ctx, playerId, (tx) => loadPlayerState(tx, playerId)), ); + + registerRoute(app, routes.acknowledgeSummaries, options, async (input, { playerId }) => + withPlayer(ctx, playerId, async (tx, now) => ({ + acknowledged: await acknowledgeSummaries(tx, playerId, input.dispatchIds, now), + })), + ); } diff --git a/apps/server/test/summaries.test.ts b/apps/server/test/summaries.test.ts new file mode 100644 index 0000000..a6bbe53 --- /dev/null +++ b/apps/server/test/summaries.test.ts @@ -0,0 +1,67 @@ +import { acknowledgeSummariesResponseSchema } from "@deadhead/schema"; +import { describe, expect, it } from "vitest"; +import { makeOwner, setCredits, useTestDatabase } from "./db"; +import { call, getState, insertDispatch, signUp, useTestApp } from "./game"; + +const database = useTestDatabase(); +const testApp = useTestApp(database); + +describe("acknowledging summaries", () => { + it("clears unacknowledged summaries while keeping the resolved effects", async () => { + const { app, clock } = testApp(); + const { db } = database(); + const guest = await signUp(app); + const shipId = await makeOwner(db, guest.playerId, guest.avatarId); + await setCredits(db, guest.playerId, 1000); + const record = await insertDispatch(db, { + playerId: guest.playerId, + shipId, + pilotId: guest.avatarId, + departAt: clock.now, + }); + clock.now = record.arrivalAt; + + const before = await getState(app, guest); + expect(before.summaries.map((s) => s.dispatchId)).toEqual([record.id]); + + const response = await call(app, guest, "POST", "/api/summaries/acknowledge", { + dispatchIds: [record.id, "not-a-real-dispatch"], + }); + expect(response.statusCode).toBe(200); + expect(acknowledgeSummariesResponseSchema.parse(response.json())).toEqual({ acknowledged: 1 }); + + const after = await getState(app, guest); + expect(after.summaries).toEqual([]); + expect(after.company.credits).toBe(before.company.credits); + expect(after.pilots[0]!.xp).toBe(before.pilots[0]!.xp); + expect(after.ships[0]!.locationId).toBe("moon"); + + // Acknowledging again changes nothing. + const again = await call(app, guest, "POST", "/api/summaries/acknowledge", { + dispatchIds: [record.id], + }); + expect(again.json()).toEqual({ acknowledged: 0 }); + }); + + it("does not acknowledge another player's dispatches", async () => { + const { app, clock } = testApp(); + const { db } = database(); + const owner = await signUp(app); + const shipId = await makeOwner(db, owner.playerId, owner.avatarId); + const record = await insertDispatch(db, { + playerId: owner.playerId, + shipId, + pilotId: owner.avatarId, + departAt: clock.now, + }); + clock.now = record.arrivalAt; + await getState(app, owner); + + const stranger = await signUp(app); + const response = await call(app, stranger, "POST", "/api/summaries/acknowledge", { + dispatchIds: [record.id], + }); + expect(response.json()).toEqual({ acknowledged: 0 }); + expect((await getState(app, owner)).summaries).toHaveLength(1); + }); +}); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index e842528..d8197e1 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -66,5 +66,5 @@ - [x] **Step 6.10 — Contract dispatch.** `POST /api/dispatches` with `kind: "contract"` (`dispatchContract`): sim validation, fuel deducted, contract accepted, ship and pilot in flight. One integration test per sim reason (`cannot-land` via a test-only vacuum ship in mocked content), each checking nothing changed; also covers the expired-old-board case from Step 6.9. - [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`. -- [ ] Step 6.13 — Acknowledge summaries +- [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