diff --git a/DECISIONS.md b/DECISIONS.md index 726c517..63cd2da 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -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. - **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`). diff --git a/apps/server/src/game/pilots.ts b/apps/server/src/game/pilots.ts index d5d33d1..205741c 100644 --- a/apps/server/src/game/pilots.ts +++ b/apps/server/src/game/pilots.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import { content } from "@deadhead/content"; 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 type { Queryable } from "../db/client"; import { bays, pilots, ships } from "../db/schema"; @@ -138,3 +138,37 @@ export async function unassignPilot( .returning(); 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 { + 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); +} diff --git a/apps/server/src/routes/fleet.ts b/apps/server/src/routes/fleet.ts index eed0ee3..9ae624b 100644 --- a/apps/server/src/routes/fleet.ts +++ b/apps/server/src/routes/fleet.ts @@ -1,7 +1,7 @@ import { routes } from "@deadhead/schema"; import type { FastifyInstance } from "fastify"; 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 type { AppContext } from "./context"; import { withPlayer } from "./with-player"; @@ -37,4 +37,10 @@ export function registerFleetRoutes(app: FastifyInstance, ctx: AppContext): void 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), + })), + ); } diff --git a/apps/server/test/skills.test.ts b/apps/server/test/skills.test.ts new file mode 100644 index 0000000..a884288 --- /dev/null +++ b/apps/server/test/skills.test.ts @@ -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 = {}, 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"); + }); +}); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 14b7f3d..e842528 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -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.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. -- [ ] 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.14 — Concurrency and money safety