Release Healer Man 0.1.15
This commit is contained in:
1 parent
7b41b102fa
commit
f49e54f35f
43 files changed
+1478
-112
No files matched your search
@@ -14,6 +14,7 @@ import {
|
||||
updateCharacterManastormProgress,
|
||||
updateCharacterProgression,
|
||||
updateCharacterSettings,
|
||||
updateCharacterSpecialization,
|
||||
updateCombatPresentationSettings,
|
||||
type StorageLike,
|
||||
} from "./accountRepository";
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
recordManastormFailure,
|
||||
setManastormLoadout,
|
||||
} from "../game/manastormProgress";
|
||||
import { abilitiesForClass } from "../game/abilityCatalog";
|
||||
|
||||
class MemoryStorage implements StorageLike {
|
||||
private values = new Map<string, string>();
|
||||
@@ -38,6 +40,37 @@ class MemoryStorage implements StorageLike {
|
||||
}
|
||||
|
||||
describe("local profile repository", () => {
|
||||
it("persists specializations and supports Class Hall keep/reset choices", () => {
|
||||
const storage = new MemoryStorage();
|
||||
const talentAbility = abilitiesForClass("stormbringer").find((ability) => ability.talentEntryId !== undefined)!;
|
||||
const created = createCharacter({
|
||||
ownerId: "offline-roster",
|
||||
name: "Tempest",
|
||||
categoryId: "coa",
|
||||
raceId: "human",
|
||||
classId: "stormbringer",
|
||||
specializationId: "stormbringer:lightning",
|
||||
gender: "female",
|
||||
appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 },
|
||||
}, storage).character!;
|
||||
updateCharacterProgression("offline-roster", created.id, {
|
||||
level: 20,
|
||||
experience: 0,
|
||||
talentRanks: { [`coa-entry-${talentAbility.talentEntryId}`]: 1 },
|
||||
}, storage);
|
||||
updateCharacterActionBindings("offline-roster", created.id, { "primary:face-top": talentAbility.id }, storage);
|
||||
|
||||
const kept = updateCharacterSpecialization("offline-roster", created.id, "stormbringer:wind", false, storage).character!;
|
||||
expect(kept.specializationId).toBe("stormbringer:wind");
|
||||
expect(kept.talentRanks).not.toEqual({});
|
||||
expect(kept.actionBindings["primary:face-top"]).toBe(talentAbility.id);
|
||||
|
||||
const reset = updateCharacterSpecialization("offline-roster", created.id, "stormbringer:maelstrom", true, storage).character!;
|
||||
expect(reset.specializationId).toBe("stormbringer:maelstrom");
|
||||
expect(reset.talentRanks).toEqual({});
|
||||
expect(reset.actionBindings["primary:face-top"]).toBeNull();
|
||||
});
|
||||
|
||||
it("creates and verifies a local account without storing its raw password", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const created = await createLocalAccount("CaveHealer", "serpent-pass", storage);
|
||||
@@ -149,7 +182,7 @@ describe("local profile repository", () => {
|
||||
}));
|
||||
expect(loadCombatPresentationSettings("offline-roster", storage))
|
||||
.toEqual(DEFAULT_COMBAT_PRESENTATION_SETTINGS);
|
||||
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(3);
|
||||
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(4);
|
||||
}
|
||||
|
||||
const legacyV3Storage = new MemoryStorage();
|
||||
|
||||
@@ -22,6 +22,11 @@ import {
|
||||
normalizeManastormProgress,
|
||||
type ManastormProgress,
|
||||
} from "../game/manastormProgress";
|
||||
import { abilitiesForClass } from "../game/abilityCatalog";
|
||||
import {
|
||||
normalizeSpecializationId,
|
||||
specializationById,
|
||||
} from "../game/specializationCatalog";
|
||||
|
||||
const DATABASE_KEY = "healer-man.profile-database.v1";
|
||||
const SESSION_KEY = "healer-man.session.v1";
|
||||
@@ -51,7 +56,7 @@ interface LocalAccount {
|
||||
}
|
||||
|
||||
interface ProfileDatabase {
|
||||
version: 3;
|
||||
version: 4;
|
||||
accounts: LocalAccount[];
|
||||
rosters: Record<string, CharacterProfile[]>;
|
||||
presentationSettingsByOwner: Record<string, CombatPresentationSettings>;
|
||||
@@ -93,7 +98,7 @@ function browserSessionStorage(): StorageLike | null {
|
||||
}
|
||||
|
||||
function emptyDatabase(): ProfileDatabase {
|
||||
return { version: 3, accounts: [], rosters: {}, presentationSettingsByOwner: {} };
|
||||
return { version: 4, accounts: [], rosters: {}, presentationSettingsByOwner: {} };
|
||||
}
|
||||
|
||||
function isCharacterProfile(value: unknown): value is CharacterProfile {
|
||||
@@ -120,6 +125,7 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
|
||||
settings?: unknown;
|
||||
secondaryClassId?: unknown;
|
||||
actionLoadouts?: unknown;
|
||||
specializationId?: unknown;
|
||||
};
|
||||
const level = typeof legacy.level === "number" && Number.isFinite(legacy.level)
|
||||
? Math.max(1, Math.min(80, Math.floor(legacy.level)))
|
||||
@@ -138,6 +144,9 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
|
||||
.map(([bindingId, abilityId]) => [bindingId, abilityId as string | null]))
|
||||
: {};
|
||||
const categoryId = contentCategoryForClass(value.classId);
|
||||
const specializationId = categoryId === "rom"
|
||||
? null
|
||||
: normalizeSpecializationId(value.classId, legacy.specializationId);
|
||||
const secondaryClassId = categoryId === "rom"
|
||||
&& isRomClassId(value.classId)
|
||||
&& typeof legacy.secondaryClassId === "string"
|
||||
@@ -168,6 +177,7 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
|
||||
talentRanks,
|
||||
actionBindings,
|
||||
categoryId,
|
||||
specializationId,
|
||||
secondaryClassId,
|
||||
actionLoadouts,
|
||||
inventory,
|
||||
@@ -186,7 +196,7 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
|
||||
rosters?: unknown;
|
||||
presentationSettingsByOwner?: unknown;
|
||||
}) | null;
|
||||
if (!parsed || ![1, 2, 3].includes(parsed.version ?? 0) || !Array.isArray(parsed.accounts) || !parsed.rosters || typeof parsed.rosters !== "object") {
|
||||
if (!parsed || ![1, 2, 3, 4].includes(parsed.version ?? 0) || !Array.isArray(parsed.accounts) || !parsed.rosters || typeof parsed.rosters !== "object") {
|
||||
return emptyDatabase();
|
||||
}
|
||||
const accounts = parsed.accounts.filter((account): account is LocalAccount => Boolean(
|
||||
@@ -226,8 +236,8 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
|
||||
return [ownerId, normalized];
|
||||
}),
|
||||
);
|
||||
const database: ProfileDatabase = { version: 3, accounts, rosters, presentationSettingsByOwner };
|
||||
if (parsed.version !== 3 || presentationSettingsMigrated) writeDatabase(database, storage);
|
||||
const database: ProfileDatabase = { version: 4, accounts, rosters, presentationSettingsByOwner };
|
||||
if (parsed.version !== 4 || presentationSettingsMigrated) writeDatabase(database, storage);
|
||||
return database;
|
||||
} catch {
|
||||
return emptyDatabase();
|
||||
@@ -421,7 +431,9 @@ export function replaceCharactersForOwner(
|
||||
}
|
||||
|
||||
export function createCharacter(
|
||||
profile: Omit<CharacterProfile, "id" | "createdAt" | "lastPlayedAt" | "level" | "experience" | "talentRanks" | "actionBindings" | "inventory" | "equipment" | "manastormProgress" | "location">,
|
||||
profile: Omit<CharacterProfile, "id" | "createdAt" | "lastPlayedAt" | "level" | "experience" | "talentRanks" | "actionBindings" | "inventory" | "equipment" | "manastormProgress" | "location" | "specializationId"> & {
|
||||
specializationId?: string | null;
|
||||
},
|
||||
storage: StorageLike | null = browserLocalStorage(),
|
||||
): CharacterResult {
|
||||
if (!storage) return { ok: false, error: "Local storage is unavailable on this device." };
|
||||
@@ -491,6 +503,54 @@ export function updateCharacterProgression(
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function updateCharacterSpecialization(
|
||||
ownerId: string,
|
||||
characterId: string,
|
||||
specializationId: string,
|
||||
resetTalents: boolean,
|
||||
storage: StorageLike | null = browserLocalStorage(),
|
||||
): CharacterResult {
|
||||
if (!storage) return { ok: false, error: "Local storage is unavailable on this device." };
|
||||
const database = readDatabase(storage);
|
||||
let result: CharacterResult = { ok: false, error: "That character is no longer available." };
|
||||
database.rosters[ownerId] = (database.rosters[ownerId] ?? []).map((character) => {
|
||||
if (character.id !== characterId) return character;
|
||||
if (character.categoryId === "rom") {
|
||||
result = { ok: false, error: "RoM gear follows the active primary class and activity role." };
|
||||
return character;
|
||||
}
|
||||
const specialization = specializationById(specializationId);
|
||||
if (!specialization || specialization.classId !== character.classId) {
|
||||
result = { ok: false, error: "That specialization is not available to this class." };
|
||||
return character;
|
||||
}
|
||||
const talentAbilityIds = resetTalents
|
||||
? new Set(abilitiesForClass(character.classId)
|
||||
.filter((ability) => ability.talentEntryId !== undefined || ability.source === "coa-tree")
|
||||
.map((ability) => ability.id))
|
||||
: new Set<string>();
|
||||
const actionBindings = resetTalents
|
||||
? Object.fromEntries(Object.entries(character.actionBindings)
|
||||
.map(([bindingId, abilityId]) => [bindingId, abilityId && talentAbilityIds.has(abilityId) ? null : abilityId]))
|
||||
: character.actionBindings;
|
||||
const loadoutKey = orderedClassLoadoutKey(character.classId, character.secondaryClassId);
|
||||
const updated = normalizeCharacterProfile({
|
||||
...character,
|
||||
specializationId: specialization.id,
|
||||
talentRanks: resetTalents ? {} : character.talentRanks,
|
||||
actionBindings,
|
||||
actionLoadouts: {
|
||||
...(character.actionLoadouts ?? {}),
|
||||
[loadoutKey]: { ...actionBindings },
|
||||
},
|
||||
});
|
||||
result = { ok: true, character: updated };
|
||||
return updated;
|
||||
});
|
||||
if (result.ok) writeDatabase(database, storage);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function updateCharacterActionBindings(
|
||||
ownerId: string,
|
||||
characterId: string,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { useShellStore } from "./shellStore";
|
||||
import { registerDungeonShellRuntime } from "./dungeonShellRuntimeBridge";
|
||||
import { touchCharacter } from "./accountRepository";
|
||||
import { specializationById } from "../game/specializationCatalog";
|
||||
|
||||
export function installDungeonShellRuntime(): void {
|
||||
registerDungeonShellRuntime({ enterDungeon });
|
||||
@@ -33,7 +34,16 @@ function enterDungeon(dungeonId: string, requestedRole?: PartyRole): boolean {
|
||||
useShellStore.setState({ notice: "That character is no longer available." });
|
||||
return false;
|
||||
}
|
||||
const playerRole = requestedRole ?? defaultPartyRoleForClass(activeCharacter.classId);
|
||||
const savedSpecialization = specializationById(activeCharacter.specializationId);
|
||||
if (activeCharacter.categoryId !== "rom" && !savedSpecialization) {
|
||||
useShellStore.setState({ phase: "class-hall", notice: "Choose a specialization before entering activities." });
|
||||
return false;
|
||||
}
|
||||
const playerRole = savedSpecialization?.role ?? requestedRole ?? defaultPartyRoleForClass(activeCharacter.classId);
|
||||
if (savedSpecialization && requestedRole && requestedRole !== savedSpecialization.role) {
|
||||
useShellStore.setState({ notice: `${savedSpecialization.displayName} is locked to ${PARTY_ROLE_LABELS[savedSpecialization.role]}.` });
|
||||
return false;
|
||||
}
|
||||
if (!classCanFillPartyRole(activeCharacter.classId, playerRole)) {
|
||||
useShellStore.setState({
|
||||
notice: `${activeCharacter.name}'s class cannot enter as ${PARTY_ROLE_LABELS[playerRole]}.`,
|
||||
@@ -56,4 +66,3 @@ function enterDungeon(dungeonId: string, requestedRole?: PartyRole): boolean {
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
type ManastormPartySize,
|
||||
} from "../game/manastormProgress";
|
||||
import { activateManastormStage, releaseActiveManastormStage } from "../game/manastormStageLoader";
|
||||
import { specializationById } from "../game/specializationCatalog";
|
||||
import {
|
||||
manastormActiveSpellLoadoutModel,
|
||||
validateManastormActiveSpellLoadout,
|
||||
@@ -63,7 +64,16 @@ function enterManastorm(
|
||||
useShellStore.setState({ notice: "That character is no longer available." });
|
||||
return false;
|
||||
}
|
||||
const playerRole = requestedRole ?? defaultPartyRoleForClass(activeCharacter.classId);
|
||||
const savedSpecialization = specializationById(activeCharacter.specializationId);
|
||||
if (activeCharacter.categoryId !== "rom" && !savedSpecialization) {
|
||||
useShellStore.setState({ phase: "class-hall", notice: "Choose a specialization before entering activities." });
|
||||
return false;
|
||||
}
|
||||
const playerRole = savedSpecialization?.role ?? requestedRole ?? defaultPartyRoleForClass(activeCharacter.classId);
|
||||
if (savedSpecialization && requestedRole && requestedRole !== savedSpecialization.role) {
|
||||
useShellStore.setState({ notice: `${savedSpecialization.displayName} is locked to ${PARTY_ROLE_LABELS[savedSpecialization.role]}.` });
|
||||
return false;
|
||||
}
|
||||
if (!classCanFillPartyRole(activeCharacter.classId, playerRole)) {
|
||||
useShellStore.setState({
|
||||
notice: `${activeCharacter.name}'s class cannot enter as ${PARTY_ROLE_LABELS[playerRole]}.`,
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface OnlineGroupCharacter {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly classId: string;
|
||||
readonly specializationId?: string | null;
|
||||
readonly categoryId: string;
|
||||
readonly raceId: string;
|
||||
readonly gender: "female" | "male";
|
||||
|
||||
@@ -58,7 +58,7 @@ describe("RoM dual-class persistence", () => {
|
||||
"priest:none": { "primary:face-bottom": "priest-flash-heal" },
|
||||
},
|
||||
});
|
||||
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(3);
|
||||
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(4);
|
||||
});
|
||||
|
||||
it("locks selection to level ten and preserves separate bars through atomic swaps", () => {
|
||||
|
||||
@@ -30,6 +30,7 @@ const character = {
|
||||
name: "Remedy",
|
||||
raceId: "human" as const,
|
||||
classId: "priest" as const,
|
||||
specializationId: "priest:holy",
|
||||
gender: "female" as const,
|
||||
appearance: createDefaultAppearance(),
|
||||
level: 1,
|
||||
@@ -100,6 +101,15 @@ describe("shell store", () => {
|
||||
expect(useShellStore.getState().notice).toMatch(/select or create/i);
|
||||
});
|
||||
|
||||
it("routes a legacy WoW character through the mandatory specialization picker", () => {
|
||||
prepareSelectedCharacter();
|
||||
useShellStore.setState({ characters: [{ ...character, specializationId: null }] });
|
||||
expect(useShellStore.getState().openMainMenu()).toBe(true);
|
||||
expect(useShellStore.getState().phase).toBe("class-hall");
|
||||
expect(useShellStore.getState().openDungeons()).toBe(false);
|
||||
expect(useShellStore.getState().notice).toMatch(/choose a specialization/i);
|
||||
});
|
||||
|
||||
it("opens the main menu and dungeon list without resetting gameplay", () => {
|
||||
prepareSelectedCharacter();
|
||||
useGameStore.setState({ paused: true, mapOpen: true, companionOpen: true, distanceTravelled: 42 });
|
||||
@@ -124,11 +134,11 @@ describe("shell store", () => {
|
||||
useShellStore.getState().openDungeons();
|
||||
useGameStore.setState({ paused: true, mapOpen: true, companionOpen: true, distanceTravelled: 42 });
|
||||
|
||||
expect(useShellStore.getState().enterDungeon(DUNGEON_MANIFEST.id, "damage")).toBe(true);
|
||||
expect(useShellStore.getState().enterDungeon(DUNGEON_MANIFEST.id, "healer")).toBe(true);
|
||||
expect(useShellStore.getState()).toMatchObject({
|
||||
phase: "game",
|
||||
activeCharacter: character,
|
||||
activeDungeonRole: "damage",
|
||||
activeDungeonRole: "healer",
|
||||
});
|
||||
expect(useGameStore.getState()).toMatchObject({
|
||||
paused: false,
|
||||
@@ -142,7 +152,7 @@ describe("shell store", () => {
|
||||
prepareSelectedCharacter();
|
||||
expect(useShellStore.getState().enterDungeon(DUNGEON_MANIFEST.id, "tank")).toBe(false);
|
||||
expect(useShellStore.getState().phase).toBe("characters");
|
||||
expect(useShellStore.getState().notice).toMatch(/cannot enter as tank/i);
|
||||
expect(useShellStore.getState().notice).toMatch(/locked to healer/i);
|
||||
});
|
||||
|
||||
it("rejects an unknown dungeon without resetting or leaving the list", () => {
|
||||
@@ -234,7 +244,7 @@ describe("shell store", () => {
|
||||
expect(useShellStore.getState().openManastorms()).toBe(true);
|
||||
expect(useShellStore.getState().phase).toBe("manastorms");
|
||||
expect(useShellStore.getState().enterManastorm("tank")).toBe(false);
|
||||
expect(useShellStore.getState().notice).toMatch(/cannot enter as tank/i);
|
||||
expect(useShellStore.getState().notice).toMatch(/locked to healer/i);
|
||||
|
||||
expect(useShellStore.getState().enterManastorm("healer")).toBe(true);
|
||||
expect(useShellStore.getState()).toMatchObject({
|
||||
|
||||
+64
-3
@@ -14,6 +14,7 @@ import {
|
||||
updateCharacterManastormProgress,
|
||||
updateCharacterProgression,
|
||||
updateCharacterSettings,
|
||||
updateCharacterSpecialization,
|
||||
updateCombatPresentationSettings as persistCombatPresentationSettings,
|
||||
swapRomClasses,
|
||||
} from "./accountRepository";
|
||||
@@ -55,6 +56,11 @@ import {
|
||||
} from "./onlineAccountClient";
|
||||
import { isGameMasterSession } from "../game/manastormAdminConfig";
|
||||
import { useGameStore } from "../game/store";
|
||||
import {
|
||||
normalizeSpecializationId,
|
||||
specializationById,
|
||||
specializationsForClass,
|
||||
} from "../game/specializationCatalog";
|
||||
|
||||
export interface ShellState {
|
||||
phase: ShellPhase;
|
||||
@@ -82,6 +88,7 @@ export interface ShellState {
|
||||
openManastorms: () => boolean;
|
||||
openGmAdmin: () => boolean;
|
||||
openClassHall: () => boolean;
|
||||
changeSelectedSpecialization: (specializationId: string, resetTalents: boolean) => boolean;
|
||||
chooseRomSecondaryClass: (classId: RomClassId) => boolean;
|
||||
swapSelectedRomClasses: () => boolean;
|
||||
returnToMainMenu: () => void;
|
||||
@@ -110,11 +117,13 @@ export interface ShellState {
|
||||
|
||||
function defaultDraft(categoryId: ContentCategoryId = "wow"): CharacterDraft {
|
||||
const raceId: RaceId = categoryId === "rom" ? "rom-human" : "human";
|
||||
const classId = categoryId === "rom" ? "rom-priest" : categoryId === "coa" ? "barbarian" : "priest";
|
||||
return {
|
||||
name: "",
|
||||
categoryId,
|
||||
raceId,
|
||||
classId: categoryId === "rom" ? "rom-priest" : categoryId === "coa" ? "barbarian" : "priest",
|
||||
classId,
|
||||
specializationId: specializationsForClass(classId)[0]?.id ?? null,
|
||||
gender: "female",
|
||||
appearance: createDefaultAppearance(),
|
||||
rotation: -12,
|
||||
@@ -202,6 +211,11 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
if (!availableClasses.some((definition) => definition.id === next.classId)) {
|
||||
next.classId = availableClasses[0].id;
|
||||
}
|
||||
next.specializationId = next.categoryId === "rom"
|
||||
? null
|
||||
: normalizeSpecializationId(next.classId, next.specializationId)
|
||||
?? specializationsForClass(next.classId)[0]?.id
|
||||
?? null;
|
||||
next.appearance = clampAppearance(next.raceId, next.gender, next.appearance);
|
||||
return { draft: next, notice: "" };
|
||||
}),
|
||||
@@ -233,12 +247,17 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
set({ notice: "That gender model is not available for the selected race." });
|
||||
return false;
|
||||
}
|
||||
if (draft.categoryId !== "rom" && !normalizeSpecializationId(draft.classId, draft.specializationId)) {
|
||||
set({ notice: "Choose a specialization before creating this character." });
|
||||
return false;
|
||||
}
|
||||
const result = persistCharacter({
|
||||
ownerId: session.ownerId,
|
||||
name: normalizeCharacterName(draft.name),
|
||||
categoryId: draft.categoryId,
|
||||
raceId: draft.raceId,
|
||||
classId: draft.classId,
|
||||
specializationId: draft.categoryId === "rom" ? null : draft.specializationId,
|
||||
gender: draft.gender,
|
||||
appearance: clampAppearance(draft.raceId, draft.gender, draft.appearance),
|
||||
});
|
||||
@@ -278,6 +297,11 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
set({ notice: "That character is no longer available." });
|
||||
return false;
|
||||
}
|
||||
const character = characters.find((entry) => entry.id === selectedCharacterId)!;
|
||||
if (character.categoryId !== "rom" && !character.specializationId) {
|
||||
set({ phase: "class-hall", activeCharacter: null, notice: "Choose a specialization before continuing." });
|
||||
return true;
|
||||
}
|
||||
set({ phase: "main-menu", activeCharacter: null, notice: "" });
|
||||
return true;
|
||||
},
|
||||
@@ -287,6 +311,11 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
set({ phase: session ? "characters" : "login", notice: "Select or create a character first." });
|
||||
return false;
|
||||
}
|
||||
const character = characters.find((entry) => entry.id === selectedCharacterId)!;
|
||||
if (character.categoryId !== "rom" && !character.specializationId) {
|
||||
set({ phase: "class-hall", activeCharacter: null, notice: "Choose a specialization before entering activities." });
|
||||
return false;
|
||||
}
|
||||
set({ phase: "dungeons", activeCharacter: null, notice: "" });
|
||||
return true;
|
||||
},
|
||||
@@ -296,6 +325,11 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
set({ phase: session ? "characters" : "login", notice: "Select or create a character first." });
|
||||
return false;
|
||||
}
|
||||
const character = characters.find((entry) => entry.id === selectedCharacterId)!;
|
||||
if (character.categoryId !== "rom" && !character.specializationId) {
|
||||
set({ phase: "class-hall", activeCharacter: null, notice: "Choose a specialization before entering activities." });
|
||||
return false;
|
||||
}
|
||||
set({ phase: "manastorms", activeCharacter: null, notice: "" });
|
||||
return true;
|
||||
},
|
||||
@@ -315,13 +349,40 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
openClassHall: () => {
|
||||
const { session, selectedCharacterId, characters } = get();
|
||||
const character = characters.find((entry) => entry.id === selectedCharacterId);
|
||||
if (!session || !character || character.categoryId !== "rom") {
|
||||
set({ notice: "The Class Hall is available to Runes of Magic characters." });
|
||||
if (!session || !character) {
|
||||
set({ notice: "Select a character before opening the Class Hall." });
|
||||
return false;
|
||||
}
|
||||
set({ phase: "class-hall", activeCharacter: null, notice: "" });
|
||||
return true;
|
||||
},
|
||||
changeSelectedSpecialization: (specializationId, resetTalents) => {
|
||||
const { session, selectedCharacterId, phase } = get();
|
||||
if (!session || !selectedCharacterId || phase !== "class-hall") {
|
||||
set({ notice: "Specializations may only be changed inside the Class Hall." });
|
||||
return false;
|
||||
}
|
||||
const definition = specializationById(specializationId);
|
||||
if (!definition) {
|
||||
set({ notice: "That specialization is unavailable." });
|
||||
return false;
|
||||
}
|
||||
const result = updateCharacterSpecialization(
|
||||
session.ownerId,
|
||||
selectedCharacterId,
|
||||
definition.id,
|
||||
resetTalents,
|
||||
);
|
||||
if (!result.ok || !result.character) {
|
||||
set({ notice: result.error ?? "The specialization could not be changed." });
|
||||
return false;
|
||||
}
|
||||
set((state) => ({
|
||||
characters: state.characters.map((entry) => entry.id === result.character!.id ? result.character! : entry),
|
||||
notice: `${definition.displayName} selected. ${resetTalents ? "Talents reset." : "Talents kept."}`,
|
||||
}));
|
||||
return true;
|
||||
},
|
||||
chooseRomSecondaryClass: (classId) => {
|
||||
const { session, selectedCharacterId } = get();
|
||||
if (!session || !selectedCharacterId) return false;
|
||||
|
||||
@@ -24,6 +24,8 @@ export interface CharacterProfile {
|
||||
name: string;
|
||||
raceId: RaceId;
|
||||
classId: ClassId;
|
||||
/** Stable specialization registry ID. Normalized profiles always include it; optional for legacy callers. */
|
||||
specializationId?: string | null;
|
||||
/** Always present after repository normalization; optional for v1 callers. */
|
||||
categoryId?: ContentCategoryId;
|
||||
/** RoM's inactive class. The active primary remains `classId`. */
|
||||
@@ -51,6 +53,7 @@ export interface CharacterDraft {
|
||||
categoryId: ContentCategoryId;
|
||||
raceId: RaceId;
|
||||
classId: ClassId;
|
||||
specializationId: string | null;
|
||||
gender: GenderId;
|
||||
appearance: CharacterAppearance;
|
||||
rotation: number;
|
||||
|
||||
Reference in new issue
Block a user