Step 6.13: add acknowledging summaries
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
6796eb0ea9
commit
66ed680602
|
|
@ -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`.
|
- **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.
|
- **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`).
|
- **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.
|
||||||
|
|
|
||||||
|
|
@ -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<number> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import { fromNodeHeaders } from "better-auth/node";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { createPlayer } from "../game/new-player";
|
import { createPlayer } from "../game/new-player";
|
||||||
import { loadPlayerState } from "../game/player-state";
|
import { loadPlayerState } from "../game/player-state";
|
||||||
|
import { acknowledgeSummaries } from "../game/summaries";
|
||||||
import { ApiError } from "../http/errors";
|
import { ApiError } from "../http/errors";
|
||||||
import { registerRoute } from "../http/route";
|
import { registerRoute } from "../http/route";
|
||||||
import type { AppContext } from "./context";
|
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 }) =>
|
registerRoute(app, routes.getPlayerState, options, async (_input, { playerId }) =>
|
||||||
withPlayer(ctx, playerId, (tx) => loadPlayerState(tx, 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),
|
||||||
|
})),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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.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.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.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
|
- [ ] Step 6.14 — Concurrency and money safety
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue