Step 6.12: add spending skill points

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 01:23:14 -04:00
parent 55a21029f0
commit 6796eb0ea9
5 changed files with 110 additions and 3 deletions

View File

@ -325,3 +325,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **Repositions use sim `validateReposition`** with the player's bay locations; their failure reasons map to error codes unchanged (`no-bay-at-destination`, `already-there`, ...). No contract is accepted, and `storeDispatch` charges only the fuel. - **Repositions use sim `validateReposition`** with the player's bay locations; their failure reasons map to error codes unchanged (`no-bay-at-destination`, `already-there`, ...). No contract is accepted, and `storeDispatch` charges only the fuel.
- **On arrival** the ship and its pilot are idle at the destination, with no credits or XP (sim resolution gives 0 for repositions). - **On arrival** the ship and its pilot are idle at the destination, with no credits or XP (sim resolution gives 0 for repositions).
## Step 6.12
- **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`).

View File

@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { content } from "@deadhead/content"; import { content } from "@deadhead/content";
import type { Pilot } from "@deadhead/schema"; import type { Pilot } from "@deadhead/schema";
import type { RealTime } from "@deadhead/sim"; import { pilotLevel, skillPointsAvailable, type RealTime } from "@deadhead/sim";
import { and, count, eq } from "drizzle-orm"; import { and, count, eq } from "drizzle-orm";
import type { Queryable } from "../db/client"; import type { Queryable } from "../db/client";
import { bays, pilots, ships } from "../db/schema"; import { bays, pilots, ships } from "../db/schema";
@ -138,3 +138,37 @@ export async function unassignPilot(
.returning(); .returning();
return toApiPilot(updated!, null); return toApiPilot(updated!, null);
} }
/** Spends one available skill point on a skill below its max level (Step 6.12). */
export async function spendSkillPoint(
tx: Queryable,
playerId: string,
pilotId: string,
skillId: string,
): Promise<Pilot> {
const pilot = await requireOwnPilot(tx, playerId, pilotId);
const skill = content.skill(skillId);
if (!skill) throw gameError("unknown-skill", `No skill "${skillId}".`);
const level = pilotLevel(pilot.xp, content.config);
if (skillPointsAvailable(level, pilot.skillPointsSpent, content.config) <= 0) {
throw gameError("no-skill-points", "That pilot has no skill points to spend.");
}
const current = pilot.skills[skillId] ?? 0;
if (current >= skill.maxLevel) {
throw gameError("skill-maxed", `${skill.name} is already at its maximum level.`);
}
const [updated] = await tx
.update(pilots)
.set({
skills: { ...pilot.skills, [skillId]: current + 1 },
skillPointsSpent: pilot.skillPointsSpent + 1,
})
.where(eq(pilots.id, pilotId))
.returning();
const [ship] = updated!.assignedShipId
? await tx.select().from(ships).where(eq(ships.id, updated!.assignedShipId))
: [];
return toApiPilot(updated!, ship?.locationId ?? null);
}

View File

@ -1,7 +1,7 @@
import { routes } from "@deadhead/schema"; import { routes } from "@deadhead/schema";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { buyShip, leaseBay } from "../game/fleet"; import { buyShip, leaseBay } from "../game/fleet";
import { assignPilot, hirePilot, unassignPilot } from "../game/pilots"; import { assignPilot, hirePilot, spendSkillPoint, unassignPilot } from "../game/pilots";
import { registerRoute } from "../http/route"; import { registerRoute } from "../http/route";
import type { AppContext } from "./context"; import type { AppContext } from "./context";
import { withPlayer } from "./with-player"; import { withPlayer } from "./with-player";
@ -37,4 +37,10 @@ export function registerFleetRoutes(app: FastifyInstance, ctx: AppContext): void
pilot: await unassignPilot(tx, playerId, input.pilotId), pilot: await unassignPilot(tx, playerId, input.pilotId),
})), })),
); );
registerRoute(app, routes.spendSkillPoint, ctx.options, async (input, { playerId }) =>
withPlayer(ctx, playerId, async (tx) => ({
pilot: await spendSkillPoint(tx, playerId, input.pilotId, input.skillId),
})),
);
} }

View File

@ -0,0 +1,61 @@
import { content } from "@deadhead/content";
import { errorResponseSchema, pilotResponseSchema } from "@deadhead/schema";
import { eq } from "drizzle-orm";
import { describe, expect, it } from "vitest";
import { pilots } from "../src/db/schema";
import { makeOwner, useTestDatabase } from "./db";
import { call, signUp, useTestApp, type Guest } from "./game";
const database = useTestDatabase();
const testApp = useTestApp(database);
/** An owner whose avatar has `xp` XP and the given skills and points spent. */
async function pilotWith(xp: number, skills: Record<string, number> = {}, spent = 0) {
const guest = await signUp(testApp().app);
await makeOwner(database().db, guest.playerId, guest.avatarId);
await database()
.db.update(pilots)
.set({ xp, skills, skillPointsSpent: spent })
.where(eq(pilots.id, guest.avatarId));
return guest;
}
function spend(guest: Guest, skillId: string) {
return call(testApp().app, guest, "POST", `/api/pilots/${guest.avatarId}/skills`, { skillId });
}
const errorCode = (response: { json(): unknown }) =>
errorResponseSchema.parse(response.json()).error.code;
// Level 1 gives `skillPointsPerLevel` points.
const level1Xp = content.config.pilotLevelXpThresholds[0]!;
describe("spending skill points", () => {
it("raises the skill by one and uses one point", async () => {
const guest = await pilotWith(level1Xp);
const response = await spend(guest, "heavy-cargo");
expect(response.statusCode).toBe(200);
const { pilot } = pilotResponseSchema.parse(response.json());
expect(pilot.skills).toEqual({ "heavy-cargo": 1 });
expect(pilot.skillPointsAvailable).toBe(content.config.skillPointsPerLevel - 1);
});
it("refuses with no points available", async () => {
const guest = await pilotWith(0);
const response = await spend(guest, "heavy-cargo");
expect(response.statusCode).toBe(409);
expect(errorCode(response)).toBe("no-skill-points");
});
it("refuses a skill already at max level", async () => {
const guest = await pilotWith(level1Xp, { "long-haul": 5 });
expect(errorCode(await spend(guest, "long-haul"))).toBe("skill-maxed");
});
it("refuses an unknown skill", async () => {
const guest = await pilotWith(level1Xp);
const response = await spend(guest, "juggling");
expect(response.statusCode).toBe(404);
expect(errorCode(response)).toBe("unknown-skill");
});
});

View File

@ -65,6 +65,6 @@
- [x] **Step 6.9 — Bay contract board endpoint.** `GET /api/bays/:locationId/board` (`bayBoard` in `src/game/boards.ts`): generated from the discovered set on first request in a period and stored; accepted contracts hidden; `409 no-bay-at-location` without a bay. The expired-old-board dispatch check is tested in Step 6.10. - [x] **Step 6.9 — Bay contract board endpoint.** `GET /api/bays/:locationId/board` (`bayBoard` in `src/game/boards.ts`): generated from the discovered set on first request in a period and stored; accepted contracts hidden; `409 no-bay-at-location` without a bay. The expired-old-board dispatch check is tested in Step 6.10.
- [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.
- [ ] Step 6.12 — Spend skill points - [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 - [ ] Step 6.13 — Acknowledge summaries
- [ ] Step 6.14 — Concurrency and money safety - [ ] Step 6.14 — Concurrency and money safety