Step 6.8: add hiring and assigning pilots
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
badbac1890
commit
f31d036f08
|
|
@ -299,3 +299,11 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
|||
- **The end-of-tutorial check runs in two places:** at the end of every resolution pass that resolved something (which also covers Phase 7's arrival job), and after every action in `withPlayer`. Buying the ship, leasing the Earth bay, or the last tutorial run landing can each be the trigger.
|
||||
- **"Removing the loaner"** means clearing `players.loaner_location_id` and `tutorial_active`; the loaner never had a ship row.
|
||||
- **The avatar is moved to Earth** as an idle, unassigned pilot, wherever the loaner was (decision 0.5.4).
|
||||
|
||||
## Step 6.8
|
||||
|
||||
- **New server error code `avatar-in-tutorial`** for assigning the avatar to an owned ship before the tutorial ends (decision 0.5.23).
|
||||
- **Assignment checks, in order:** pilot and ship belong to the player (`404 not-found`), avatar not in tutorial, pilot idle (`pilot-not-idle`), pilot unassigned (`pilot-already-assigned`), ship idle (`ship-not-idle`), same location (`ship-not-idle-here`), ship uncrewed (`ship-crewed`). The unique `assigned_ship_id` constraint backs up the one-pilot-per-ship rule.
|
||||
- **Unassigning** needs an assigned, idle pilot (`pilot-not-assigned-to-ship`, `pilot-not-idle`) whose ship is at a player bay (`no-bay-at-location`). The pilot then stores the ship's location (decision 0.5.27).
|
||||
- **Hired pilot names** are "First Last" from fixed lists, seeded with `deriveSeed(worldSeed, "pilot-name:<n>")`, where n counts the player's earlier hires.
|
||||
- **`toApiPilot`** (`src/game/mappers.ts`) is shared by the state reader and the pilot endpoints.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import type { Dispatch } from "@deadhead/schema";
|
||||
import type { dispatches } from "../db/schema";
|
||||
import { content } from "@deadhead/content";
|
||||
import type { Dispatch, Pilot } from "@deadhead/schema";
|
||||
import { pilotLevel, skillPointsAvailable } from "@deadhead/sim";
|
||||
import type { dispatches, pilots } from "../db/schema";
|
||||
|
||||
/** Database status values use `in_flight`; the API uses `in-flight`. */
|
||||
export function toApiStatus<S extends "idle" | "in_flight" | "resolved">(
|
||||
|
|
@ -15,3 +17,31 @@ export function toApiDispatch(row: typeof dispatches.$inferSelect): Dispatch {
|
|||
resolvedAt: row.resolvedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* API view of a pilot. An assigned pilot is wherever its ship is
|
||||
* (`assignedShipLocationId`); an unassigned pilot stores its own location.
|
||||
*/
|
||||
export function toApiPilot(
|
||||
row: typeof pilots.$inferSelect,
|
||||
assignedShipLocationId: string | null,
|
||||
): Pilot {
|
||||
const level = pilotLevel(row.xp, content.config);
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
isAvatar: row.isAvatar,
|
||||
xp: row.xp,
|
||||
level,
|
||||
skills: row.skills,
|
||||
skillPointsAvailable: skillPointsAvailable(level, row.skillPointsSpent, content.config),
|
||||
assignedShipId: row.assignedShipId,
|
||||
status: toApiStatus(row.status),
|
||||
locationId:
|
||||
row.status === "in_flight"
|
||||
? null
|
||||
: row.assignedShipId
|
||||
? assignedShipLocationId
|
||||
: row.locationId,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,140 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { content } from "@deadhead/content";
|
||||
import type { Pilot } from "@deadhead/schema";
|
||||
import 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";
|
||||
import { gameError } from "../http/errors";
|
||||
import { requirePlayer, spend } from "./fleet";
|
||||
import { toApiPilot } from "./mappers";
|
||||
import { VALIDATION_MESSAGES } from "./messages";
|
||||
import { pilotName } from "./names";
|
||||
|
||||
async function hasBayAt(tx: Queryable, playerId: string, locationId: string): Promise<boolean> {
|
||||
const [bay] = await tx
|
||||
.select({ id: bays.id })
|
||||
.from(bays)
|
||||
.where(and(eq(bays.playerId, playerId), eq(bays.locationId, locationId)));
|
||||
return bay !== undefined;
|
||||
}
|
||||
|
||||
export async function requireOwnPilot(tx: Queryable, playerId: string, pilotId: string) {
|
||||
const [pilot] = await tx
|
||||
.select()
|
||||
.from(pilots)
|
||||
.where(and(eq(pilots.id, pilotId), eq(pilots.playerId, playerId)));
|
||||
if (!pilot) throw gameError("not-found", "No such pilot.");
|
||||
return pilot;
|
||||
}
|
||||
|
||||
export async function requireOwnShip(tx: Queryable, playerId: string, shipId: string) {
|
||||
const [ship] = await tx
|
||||
.select()
|
||||
.from(ships)
|
||||
.where(and(eq(ships.id, shipId), eq(ships.playerId, playerId)));
|
||||
if (!ship) throw gameError("not-found", "No such ship.");
|
||||
return ship;
|
||||
}
|
||||
|
||||
/** Hires a level-0 pilot at one of the player's bays (Step 6.8, decision 0.5.22). */
|
||||
export async function hirePilot(
|
||||
tx: Queryable,
|
||||
playerId: string,
|
||||
locationId: string,
|
||||
now: RealTime,
|
||||
): Promise<Pilot> {
|
||||
if (!(await hasBayAt(tx, playerId, locationId))) {
|
||||
throw gameError("no-bay-at-location", "Pilots can only be hired at one of your bays.");
|
||||
}
|
||||
await spend(tx, playerId, content.config.pilotHireCost, "a new pilot");
|
||||
|
||||
const player = await requirePlayer(tx, playerId);
|
||||
const [hired] = await tx
|
||||
.select({ n: count() })
|
||||
.from(pilots)
|
||||
.where(and(eq(pilots.playerId, playerId), eq(pilots.isAvatar, false)));
|
||||
const [pilot] = await tx
|
||||
.insert(pilots)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
playerId,
|
||||
name: pilotName(player.worldSeed, hired!.n),
|
||||
locationId,
|
||||
createdAt: now,
|
||||
})
|
||||
.returning();
|
||||
return toApiPilot(pilot!, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns an idle, unassigned pilot to an idle, uncrewed ship at the same
|
||||
* location. The avatar is locked to the loaner during the tutorial (decision 0.5.23).
|
||||
*/
|
||||
export async function assignPilot(
|
||||
tx: Queryable,
|
||||
playerId: string,
|
||||
pilotId: string,
|
||||
shipId: string,
|
||||
): Promise<Pilot> {
|
||||
const pilot = await requireOwnPilot(tx, playerId, pilotId);
|
||||
const ship = await requireOwnShip(tx, playerId, shipId);
|
||||
const player = await requirePlayer(tx, playerId);
|
||||
|
||||
if (pilot.isAvatar && player.tutorialActive) {
|
||||
throw gameError("avatar-in-tutorial", "You fly the company loaner until the tutorial ends.");
|
||||
}
|
||||
if (pilot.status === "in_flight") {
|
||||
throw gameError("pilot-not-idle", VALIDATION_MESSAGES["pilot-not-idle"]);
|
||||
}
|
||||
if (pilot.assignedShipId !== null) {
|
||||
throw gameError(
|
||||
"pilot-already-assigned",
|
||||
"That pilot already has a ship. Unassign them first.",
|
||||
);
|
||||
}
|
||||
if (ship.status === "in_flight") {
|
||||
throw gameError("ship-not-idle", VALIDATION_MESSAGES["ship-not-idle"]);
|
||||
}
|
||||
if (ship.locationId !== pilot.locationId) {
|
||||
throw gameError("ship-not-idle-here", "The pilot and the ship must be at the same location.");
|
||||
}
|
||||
const [crew] = await tx
|
||||
.select({ id: pilots.id })
|
||||
.from(pilots)
|
||||
.where(eq(pilots.assignedShipId, shipId));
|
||||
if (crew) throw gameError("ship-crewed", "That ship already has a pilot.");
|
||||
|
||||
const [updated] = await tx
|
||||
.update(pilots)
|
||||
.set({ assignedShipId: shipId, locationId: null })
|
||||
.where(eq(pilots.id, pilotId))
|
||||
.returning();
|
||||
return toApiPilot(updated!, ship.locationId);
|
||||
}
|
||||
|
||||
/** Unassigns a pilot from its ship; only allowed where the player has a bay (decision 0.5.2). */
|
||||
export async function unassignPilot(
|
||||
tx: Queryable,
|
||||
playerId: string,
|
||||
pilotId: string,
|
||||
): Promise<Pilot> {
|
||||
const pilot = await requireOwnPilot(tx, playerId, pilotId);
|
||||
if (pilot.assignedShipId === null) {
|
||||
throw gameError("pilot-not-assigned-to-ship", "That pilot has no ship.");
|
||||
}
|
||||
if (pilot.status === "in_flight") {
|
||||
throw gameError("pilot-not-idle", VALIDATION_MESSAGES["pilot-not-idle"]);
|
||||
}
|
||||
const ship = await requireOwnShip(tx, playerId, pilot.assignedShipId);
|
||||
if (ship.locationId === null || !(await hasBayAt(tx, playerId, ship.locationId))) {
|
||||
throw gameError("no-bay-at-location", "Pilots can only leave their ship at one of your bays.");
|
||||
}
|
||||
|
||||
const [updated] = await tx
|
||||
.update(pilots)
|
||||
.set({ assignedShipId: null, locationId: ship.locationId })
|
||||
.where(eq(pilots.id, pilotId))
|
||||
.returning();
|
||||
return toApiPilot(updated!, null);
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import type { PlayerState } from "@deadhead/schema";
|
||||
import { pilotLevel, skillPointsAvailable } from "@deadhead/sim";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import type { Queryable } from "../db/client";
|
||||
import {
|
||||
|
|
@ -12,7 +11,7 @@ import {
|
|||
players,
|
||||
ships,
|
||||
} from "../db/schema";
|
||||
import { toApiDispatch, toApiStatus } from "./mappers";
|
||||
import { toApiDispatch, toApiPilot, toApiStatus } from "./mappers";
|
||||
|
||||
/** Reads everything the client needs about one player. */
|
||||
export async function loadPlayerState(db: Queryable, playerId: string): Promise<PlayerState> {
|
||||
|
|
@ -60,27 +59,9 @@ export async function loadPlayerState(db: Queryable, playerId: string): Promise<
|
|||
dispatchId: activeByShip.get(s.id) ?? null,
|
||||
pilotId: pilotByShip.get(s.id) ?? null,
|
||||
})),
|
||||
pilots: pilotRows.map((p) => {
|
||||
const level = pilotLevel(p.xp, content.config);
|
||||
const locationId =
|
||||
p.status === "in_flight"
|
||||
? null
|
||||
: p.assignedShipId
|
||||
? (shipById.get(p.assignedShipId)?.locationId ?? null)
|
||||
: p.locationId;
|
||||
return {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
isAvatar: p.isAvatar,
|
||||
xp: p.xp,
|
||||
level,
|
||||
skills: p.skills,
|
||||
skillPointsAvailable: skillPointsAvailable(level, p.skillPointsSpent, content.config),
|
||||
assignedShipId: p.assignedShipId,
|
||||
status: toApiStatus(p.status),
|
||||
locationId,
|
||||
};
|
||||
}),
|
||||
pilots: pilotRows.map((p) =>
|
||||
toApiPilot(p, p.assignedShipId ? (shipById.get(p.assignedShipId)?.locationId ?? null) : null),
|
||||
),
|
||||
bays: bayRows.map((b) => ({ id: b.id, locationId: b.locationId })),
|
||||
locations: content.locations.map((l) => ({ id: l.id, discovered: discoveredIds.has(l.id) })),
|
||||
tutorial:
|
||||
|
|
|
|||
|
|
@ -1,6 +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 { registerRoute } from "../http/route";
|
||||
import type { AppContext } from "./context";
|
||||
import { withPlayer } from "./with-player";
|
||||
|
|
@ -18,4 +19,22 @@ export function registerFleetRoutes(app: FastifyInstance, ctx: AppContext): void
|
|||
bay: await leaseBay(tx, playerId, input.locationId, now),
|
||||
})),
|
||||
);
|
||||
|
||||
registerRoute(app, routes.hirePilot, ctx.options, async (input, { playerId }) =>
|
||||
withPlayer(ctx, playerId, async (tx, now) => ({
|
||||
pilot: await hirePilot(tx, playerId, input.locationId, now),
|
||||
})),
|
||||
);
|
||||
|
||||
registerRoute(app, routes.assignPilot, ctx.options, async (input, { playerId }) =>
|
||||
withPlayer(ctx, playerId, async (tx) => ({
|
||||
pilot: await assignPilot(tx, playerId, input.pilotId, input.shipId),
|
||||
})),
|
||||
);
|
||||
|
||||
registerRoute(app, routes.unassignPilot, ctx.options, async (input, { playerId }) =>
|
||||
withPlayer(ctx, playerId, async (tx) => ({
|
||||
pilot: await unassignPilot(tx, playerId, input.pilotId),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,172 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { content } from "@deadhead/content";
|
||||
import { errorResponseSchema, pilotResponseSchema } from "@deadhead/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { bays, ships } from "../src/db/schema";
|
||||
import { makeOwner, setCredits, TEST_NOW, useTestDatabase } from "./db";
|
||||
import { call, getState, insertDispatch, signUp, useTestApp, type Guest } from "./game";
|
||||
|
||||
const database = useTestDatabase();
|
||||
const testApp = useTestApp(database);
|
||||
|
||||
const errorCode = (response: { json(): unknown }) =>
|
||||
errorResponseSchema.parse(response.json()).error.code;
|
||||
|
||||
/** An owner with a Moon bay, plenty of credits, and an extra uncrewed Courier at the Moon. */
|
||||
async function ownerWithMoonBay() {
|
||||
const { app } = testApp();
|
||||
const { db } = database();
|
||||
const guest = await signUp(app);
|
||||
const earthShipId = await makeOwner(db, guest.playerId, guest.avatarId);
|
||||
await setCredits(db, guest.playerId, 100_000);
|
||||
await db.insert(bays).values({
|
||||
id: randomUUID(),
|
||||
playerId: guest.playerId,
|
||||
locationId: "moon",
|
||||
createdAt: TEST_NOW,
|
||||
});
|
||||
const moonShipId = randomUUID();
|
||||
await db.insert(ships).values({
|
||||
id: moonShipId,
|
||||
playerId: guest.playerId,
|
||||
modelId: "courier",
|
||||
name: "Moon Hopper",
|
||||
locationId: "moon",
|
||||
createdAt: TEST_NOW,
|
||||
});
|
||||
return { guest, earthShipId, moonShipId };
|
||||
}
|
||||
|
||||
async function hire(guest: Guest, locationId: string) {
|
||||
return call(testApp().app, guest, "POST", "/api/pilots", { locationId });
|
||||
}
|
||||
|
||||
describe("hiring pilots", () => {
|
||||
it("hires a level-0 pilot at the Moon bay and deducts the fee", async () => {
|
||||
const { app } = testApp();
|
||||
const { guest } = await ownerWithMoonBay();
|
||||
const response = await hire(guest, "moon");
|
||||
expect(response.statusCode).toBe(200);
|
||||
const { pilot } = pilotResponseSchema.parse(response.json());
|
||||
expect(pilot).toMatchObject({
|
||||
isAvatar: false,
|
||||
level: 0,
|
||||
xp: 0,
|
||||
locationId: "moon",
|
||||
assignedShipId: null,
|
||||
status: "idle",
|
||||
});
|
||||
expect(pilot.name.length).toBeGreaterThan(0);
|
||||
expect((await getState(app, guest)).company.credits).toBe(
|
||||
100_000 - content.config.pilotHireCost,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses to hire where the player has no bay", async () => {
|
||||
const { guest } = await ownerWithMoonBay();
|
||||
const response = await hire(guest, "mars");
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(errorCode(response)).toBe("no-bay-at-location");
|
||||
});
|
||||
|
||||
it("refuses to hire without enough credits", async () => {
|
||||
const { guest } = await ownerWithMoonBay();
|
||||
await setCredits(database().db, guest.playerId, content.config.pilotHireCost - 1);
|
||||
expect(errorCode(await hire(guest, "moon"))).toBe("insufficient-funds");
|
||||
});
|
||||
});
|
||||
|
||||
describe("assigning pilots", () => {
|
||||
it("assigns a pilot to an idle, uncrewed ship at the same location", async () => {
|
||||
const { app } = testApp();
|
||||
const { guest, moonShipId } = await ownerWithMoonBay();
|
||||
const { pilot } = pilotResponseSchema.parse((await hire(guest, "moon")).json());
|
||||
|
||||
const response = await call(app, guest, "POST", `/api/pilots/${pilot.id}/assign`, {
|
||||
shipId: moonShipId,
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(pilotResponseSchema.parse(response.json()).pilot).toMatchObject({
|
||||
assignedShipId: moonShipId,
|
||||
locationId: "moon",
|
||||
});
|
||||
const state = await getState(app, guest);
|
||||
expect(state.ships.find((s) => s.id === moonShipId)!.pilotId).toBe(pilot.id);
|
||||
});
|
||||
|
||||
it("refuses a ship at another location", async () => {
|
||||
const { app } = testApp();
|
||||
const { guest, moonShipId } = await ownerWithMoonBay();
|
||||
const { pilot } = pilotResponseSchema.parse((await hire(guest, "earth")).json());
|
||||
const response = await call(app, guest, "POST", `/api/pilots/${pilot.id}/assign`, {
|
||||
shipId: moonShipId,
|
||||
});
|
||||
expect(errorCode(response)).toBe("ship-not-idle-here");
|
||||
});
|
||||
|
||||
it("refuses a crewed ship", async () => {
|
||||
const { app } = testApp();
|
||||
const { guest, earthShipId } = await ownerWithMoonBay();
|
||||
const { pilot } = pilotResponseSchema.parse((await hire(guest, "earth")).json());
|
||||
const response = await call(app, guest, "POST", `/api/pilots/${pilot.id}/assign`, {
|
||||
shipId: earthShipId,
|
||||
});
|
||||
expect(errorCode(response)).toBe("ship-crewed");
|
||||
});
|
||||
|
||||
it("refuses while the ship is in flight", async () => {
|
||||
const { app, clock } = testApp();
|
||||
const { guest, earthShipId } = await ownerWithMoonBay();
|
||||
const { pilot } = pilotResponseSchema.parse((await hire(guest, "earth")).json());
|
||||
await insertDispatch(database().db, {
|
||||
playerId: guest.playerId,
|
||||
shipId: earthShipId,
|
||||
pilotId: guest.avatarId,
|
||||
departAt: clock.now,
|
||||
});
|
||||
const response = await call(app, guest, "POST", `/api/pilots/${pilot.id}/assign`, {
|
||||
shipId: earthShipId,
|
||||
});
|
||||
expect(errorCode(response)).toBe("ship-not-idle");
|
||||
});
|
||||
|
||||
it("refuses to assign the avatar during the tutorial", async () => {
|
||||
const { app } = testApp();
|
||||
const guest = await signUp(app);
|
||||
await setCredits(database().db, guest.playerId, 100_000);
|
||||
const ship = (await call(app, guest, "POST", "/api/ships", { modelId: "courier" })).json() as {
|
||||
ship: { id: string };
|
||||
};
|
||||
const response = await call(app, guest, "POST", `/api/pilots/${guest.avatarId}/assign`, {
|
||||
shipId: ship.ship.id,
|
||||
});
|
||||
expect(errorCode(response)).toBe("avatar-in-tutorial");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unassigning pilots", () => {
|
||||
it("unassigns at a bay, leaving the pilot at the ship's location", async () => {
|
||||
const { app } = testApp();
|
||||
const { guest, earthShipId } = await ownerWithMoonBay();
|
||||
const response = await call(app, guest, "POST", `/api/pilots/${guest.avatarId}/unassign`);
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(pilotResponseSchema.parse(response.json()).pilot).toMatchObject({
|
||||
assignedShipId: null,
|
||||
locationId: "earth",
|
||||
});
|
||||
const state = await getState(app, guest);
|
||||
expect(state.ships.find((s) => s.id === earthShipId)!.pilotId).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses where the player has no bay", async () => {
|
||||
const { app } = testApp();
|
||||
const { guest, earthShipId } = await ownerWithMoonBay();
|
||||
// Park the crewed ship at Mars, where there is no bay.
|
||||
const { db } = database();
|
||||
await db.update(ships).set({ locationId: "mars" }).where(eq(ships.id, earthShipId));
|
||||
const response = await call(app, guest, "POST", `/api/pilots/${guest.avatarId}/unassign`);
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(errorCode(response)).toBe("no-bay-at-location");
|
||||
});
|
||||
});
|
||||
|
|
@ -61,7 +61,7 @@
|
|||
- [x] **Step 6.5 — Buy a ship.** `POST /api/ships` (`src/game/fleet.ts`, `src/routes/fleet.ts`): deducts the price, adds an idle uncrewed ship at Earth named from the world seed (`src/game/names.ts`); `409 insufficient-funds` changes nothing.
|
||||
- [x] **Step 6.6 — Lease a bay.** `POST /api/bays` (`leaseBay` in `src/game/fleet.ts`): one-time lease price at a discovered location; `409 location-undiscovered`, `409 bay-already-leased`, `409 insufficient-funds`, `404 not-found`.
|
||||
- [x] **Step 6.7 — End tutorial.** `maybeEndTutorial` (`src/game/tutorial.ts`) runs after each resolution pass and after every game action: with a ship, an Earth bay and no tutorial run in flight, tutorial mode turns off, the loaner is removed, and the avatar becomes an idle, unassigned pilot at Earth.
|
||||
- [ ] Step 6.8 — Hire and assign pilots
|
||||
- [x] **Step 6.8 — Hire and assign pilots.** `POST /api/pilots`, `/api/pilots/:pilotId/assign`, `/api/pilots/:pilotId/unassign` (`src/game/pilots.ts`): hire at a bay for the fee with a seeded name; assign to an idle, uncrewed ship at the same location; unassign only at a bay. New error code `avatar-in-tutorial`.
|
||||
- [ ] Step 6.9 — Bay contract board endpoint
|
||||
- [ ] Step 6.10 — Contract dispatch
|
||||
- [ ] Step 6.11 — Reposition dispatch
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export const SERVER_ERROR_CODES = [
|
|||
"no-skill-points",
|
||||
"skill-maxed",
|
||||
"unknown-skill",
|
||||
"avatar-in-tutorial",
|
||||
] as const;
|
||||
|
||||
export type ErrorCode = DispatchFailure | RepositionFailure | (typeof SERVER_ERROR_CODES)[number];
|
||||
|
|
|
|||
Loading…
Reference in New Issue