Step 6.12: add spending skill points
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
55a21029f0
commit
6796eb0ea9
|
|
@ -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`).
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
})),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue