Release Healer Man 0.1.15

This commit is contained in:
phenom committed 2026-09-05 15:33:06 -04:00
1 parent 7b41b102fa
commit f49e54f35f
43 files changed
+1478 -112

No files matched your search

+34 -1
View File
@@ -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();
+66 -6
View File
@@ -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,
+11 -2
View File
@@ -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;
}
+11 -1
View File
@@ -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]}.`,
+1
View File
@@ -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";
+1 -1
View File
@@ -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", () => {
+14 -4
View File
@@ -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
View File
@@ -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;
+3
View File
@@ -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;