Update class mechanics, stat rulesets, and CoA reference catalogs

This commit is contained in:
phenom committed 2026-10-04 12:56:30 -04:00
1 parent b16821e92e
commit 39458d5de6
221 files changed
+2584962 -568

No files matched your search

+202
View File
@@ -9,6 +9,7 @@ import {
loadAccountBossCards,
saveAccountBossCards,
replaceCharactersForOwner,
setRosterChangeListener,
deleteCharacter,
loadCombatPresentationSettings,
loginLocalAccount,
@@ -16,10 +17,12 @@ import {
saveSession,
updateCharacterActionBindings,
updateCharacterEquipment,
updateCharacterHunterPetFamily,
updateCharacterDungeonRunCheckpoint,
updateCharacterInventory,
updateCharacterManastormProgress,
updateCharacterProgression,
updateCharacterStatRuleset,
updateCharacterSettings,
updateCharacterSpecialization,
updateCombatPresentationSettings,
@@ -48,7 +51,206 @@ class MemoryStorage implements StorageLike {
removeItem(key: string) { this.values.delete(key); }
}
class FailingWriteStorage extends MemoryStorage {
failWrites = false;
setItem(key: string, value: string) {
if (this.failWrites) throw new Error("Storage quota exceeded.");
super.setItem(key, value);
}
}
describe("local profile repository", () => {
it("normalizes legacy Hunter family data to Wolf and persists implemented family choices", () => {
const storage = new MemoryStorage();
const hunter = createCharacter({
ownerId: "offline-roster",
name: "Shellguard",
categoryId: "wow",
raceId: "human",
classId: "hunter",
gender: "female",
appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 },
}, storage).character!;
expect(hunter.hunterPetFamily).toBe("wolf");
const cat = updateCharacterHunterPetFamily(hunter.ownerId, hunter.id, "cat", storage)!;
expect(cat.hunterPetFamily).toBe("cat");
expect(listCharacters(hunter.ownerId, storage)[0]?.hunterPetFamily).toBe("cat");
expect(updateCharacterHunterPetFamily(hunter.ownerId, hunter.id, "bear", storage)).toBeNull();
expect(listCharacters(hunter.ownerId, storage)[0]?.hunterPetFamily).toBe("cat");
const invalidStoredFamily = { ...cat, hunterPetFamily: "bear" } as unknown as typeof cat;
const [normalizedLegacy] = replaceCharactersForOwner(hunter.ownerId, [invalidStoredFamily], storage);
expect(normalizedLegacy?.hunterPetFamily).toBe("wolf");
});
it("defaults legacy saved characters to WoW stat rules and new RoM characters to native RoM", () => {
const storage = new MemoryStorage();
const legacyRom = {
id: "legacy-rom",
ownerId: "offline-roster",
name: "Old Healer",
categoryId: "rom",
raceId: "rom-human",
classId: "rom-priest",
gender: "female",
appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 },
level: 4,
location: "Dungeon Entrance",
createdAt: 1,
lastPlayedAt: 1,
};
storage.setItem(PROFILE_STORAGE_KEYS.database, JSON.stringify({
version: 7,
accounts: [],
rosters: { "offline-roster": [legacyRom] },
bossCardsByOwner: {},
presentationSettingsByOwner: {},
}));
expect(listCharacters("offline-roster", storage)[0]?.statRuleset).toBe("wow");
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).rosters["offline-roster"][0].statRuleset)
.toBe("wow");
const created = createCharacter({
ownerId: "offline-roster",
name: "New Healer",
categoryId: "rom",
raceId: "rom-human",
classId: "rom-priest",
gender: "female",
appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 },
}, storage).character!;
expect(created.statRuleset).toBe("rom");
});
it("keeps a legacy roster readable when its ruleset migration cannot be written", () => {
const storage = new FailingWriteStorage();
const character = createCharacter({
ownerId: "offline-roster", name: "Legacy Healer", classId: "rom-priest",
raceId: "rom-human", gender: "female",
appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 },
}, storage).character!;
const raw = JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!);
delete raw.rosters[character.ownerId][0].statRuleset;
const legacySave = JSON.stringify(raw);
storage.setItem(PROFILE_STORAGE_KEYS.database, legacySave);
storage.failWrites = true;
expect(listCharacters(character.ownerId, storage)[0]).toMatchObject({
id: character.id, statRuleset: "wow",
});
expect(updateCharacterStatRuleset(character.ownerId, character.id, "rom", storage)).toBeNull();
expect(storage.getItem(PROFILE_STORAGE_KEYS.database)).toBe(legacySave);
expect(listCharacters(character.ownerId, storage)[0]?.statRuleset).toBe("wow");
storage.failWrites = false;
expect(listCharacters(character.ownerId, storage)[0]?.statRuleset).toBe("wow");
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).rosters[character.ownerId][0].statRuleset)
.toBe("wow");
});
it("switches one RoM profile while preserving its build and other characters", () => {
const storage = new MemoryStorage();
const first = createCharacter({
ownerId: "offline-roster",
name: "First",
categoryId: "rom",
raceId: "rom-human",
classId: "rom-priest",
secondaryClassId: "rom-mage",
actionLoadouts: { "rom-priest:rom-mage": { "primary:face-top": "rom-priest-heal" } },
gender: "female",
appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 },
}, storage).character!;
const item = {
instanceId: "kept-item",
itemId: 10412,
itemKey: "belt-of-the-fang",
name: "Belt of the Fang",
quality: "rare" as const,
inventorySlot: "waist",
armorType: "leather",
weaponType: null,
itemLevel: 17,
sourceItemLevel: 21,
stats: { ...EMPTY_ITEM_STATS, agility: 4, stamina: 4, armor: 15 },
quantity: 1,
sourceBossKey: "lady-anacondra",
sourceBossName: "Lady Anacondra",
acquiredAt: 5_000,
};
updateCharacterActionBindings(first.ownerId, first.id, { "primary:face-top": "rom-priest-heal" }, storage);
updateCharacterInventory(first.ownerId, first.id, [item], storage);
const second = createCharacter({
ownerId: "offline-roster",
name: "Second",
categoryId: "rom",
raceId: "rom-human",
classId: "rom-mage",
gender: "female",
appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 },
}, storage).character!;
const wowCharacter = createCharacter({
ownerId: "offline-roster",
name: "WoW Character",
categoryId: "wow",
raceId: "human",
classId: "priest",
gender: "female",
appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 },
}, storage).character!;
let cloudSnapshot: ReadonlyArray<ReturnType<typeof listCharacters>[number]> = [];
setRosterChangeListener((ownerId, characters) => {
if (ownerId === first.ownerId) cloudSnapshot = characters;
});
const updated = updateCharacterStatRuleset(first.ownerId, first.id, "wow", storage)!;
setRosterChangeListener(null);
expect(updated).toMatchObject({
statRuleset: "wow",
secondaryClassId: "rom-mage",
actionBindings: { "primary:face-top": "rom-priest-heal" },
actionLoadouts: { "rom-priest:rom-mage": { "primary:face-top": "rom-priest-heal" } },
inventory: [item],
});
expect(listCharacters(first.ownerId, storage).find((entry) => entry.id === first.id)?.statRuleset).toBe("wow");
expect(listCharacters(second.ownerId, storage).find((entry) => entry.id === second.id)?.statRuleset).toBe("rom");
expect(updateCharacterStatRuleset(first.ownerId, first.id, "rom", storage)?.statRuleset).toBe("rom");
expect(cloudSnapshot.find((entry) => entry.id === first.id)?.statRuleset).toBe("wow");
expect(updateCharacterDungeonRunCheckpoint(first.ownerId, first.id, {
version: DUNGEON_RUN_CHECKPOINT_VERSION,
dungeonId: "wailing-caverns",
difficultyId: "normal",
checkpointId: "entrance",
spawn: { footPosition: [-158, -82, 123], yaw: 0 },
encounters: [],
savedAt: 42,
}, storage)?.statRuleset).toBe("rom");
expect(updateCharacterStatRuleset(first.ownerId, second.id, "invalid", storage)).toBeNull();
expect(updateCharacterStatRuleset(first.ownerId, wowCharacter.id, "rom", storage)).toBeNull();
expect(updateCharacterStatRuleset(first.ownerId, "missing", "wow", storage)).toBeNull();
});
it("leaves the saved stat ruleset unchanged when storage rejects a switch", () => {
const storage = new FailingWriteStorage();
const character = createCharacter({
ownerId: "offline-roster",
name: "Steady",
categoryId: "rom",
raceId: "rom-human",
classId: "rom-priest",
gender: "female",
appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 },
}, storage).character!;
storage.failWrites = true;
expect(updateCharacterStatRuleset(character.ownerId, character.id, "wow", storage)).toBeNull();
storage.failWrites = false;
expect(listCharacters(character.ownerId, storage).find((entry) => entry.id === character.id)?.statRuleset)
.toBe("rom");
});
it("defaults new glyph loadouts to empty and persists explicit glyph choices", () => {
const storage = new MemoryStorage();
const character = createCharacter({
+66 -1
View File
@@ -33,6 +33,14 @@ import {
normalizeDungeonRunCheckpoint,
type DungeonRunCheckpoint,
} from "../game/dungeonRunCheckpoint";
import {
isWow335HunterPetFamily,
normalizeWow335HunterPetFamily,
} from "../game/wow335HunterPetFamilySelection";
import {
isCharacterStatRuleset,
normalizeCharacterStatRuleset,
} from "../game/characterStatRuleset";
const DATABASE_KEY = "healer-man.profile-database.v1";
const SESSION_KEY = "healer-man.session.v1";
@@ -123,6 +131,12 @@ function isCharacterProfile(value: unknown): value is CharacterProfile {
function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
const profile = { ...value };
profile.statRuleset = normalizeCharacterStatRuleset(value.statRuleset, value.classId);
if (value.classId === "hunter") {
profile.hunterPetFamily = normalizeWow335HunterPetFamily(value.hunterPetFamily);
} else {
delete profile.hunterPetFamily;
}
// Cards now belong to the owner; legacy fields are consumed before normalization.
delete profile.bossCards;
const legacy = value as CharacterProfile & {
@@ -235,8 +249,13 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
? parsed.bossCardsByOwner : {};
const bossCardsByOwner = Object.fromEntries(Object.entries(rawCards).map(([owner, cards]) => [owner, normalizeBossCardCollection(cards)]));
let cardsMigrated = false;
let statRulesetsMigrated = false;
const rosters = Object.fromEntries(Object.entries(parsed.rosters as Record<string, unknown>).map(([ownerId, roster]) => {
const characters = Array.isArray(roster) ? roster.filter(isCharacterProfile).slice(0, MAX_CHARACTERS) : [];
if (characters.some((character) => (
!isCharacterStatRuleset(character.statRuleset)
|| normalizeCharacterStatRuleset(character.statRuleset, character.classId) !== character.statRuleset
))) statRulesetsMigrated = true;
if (characters.some((character) => character.bossCards !== undefined)) {
const legacyCards = mergeBossCardCollections(characters.map((character) => character.bossCards), "sum");
bossCardsByOwner[ownerId] = mergeBossCardCollections([bossCardsByOwner[ownerId], legacyCards]);
@@ -278,7 +297,11 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
const rawUi = parsed.uiPreferencesByOwner && typeof parsed.uiPreferencesByOwner === "object" ? parsed.uiPreferencesByOwner : {};
const uiPreferencesByOwner = Object.fromEntries(Object.entries(rawUi).map(([owner, value]) => [owner, normalizeUiPreferences(value)]));
const database: ProfileDatabase = { version: 7, accounts, rosters, bossCardsByOwner, presentationSettingsByOwner, uiPreferencesByOwner };
if (parsed.version !== 7 || presentationSettingsMigrated || cardsMigrated) writeDatabase(database, storage);
if (parsed.version !== 7 || presentationSettingsMigrated || cardsMigrated || statRulesetsMigrated) {
// A full or read-only device must still expose its existing roster.
// Retry migration on the next read when persistence is available.
try { writeDatabase(database, storage); } catch { /* Keep the normalized in-memory profiles. */ }
}
return database;
} catch {
return emptyDatabase();
@@ -535,6 +558,7 @@ export function createCharacter(
const now = Date.now();
const character = normalizeCharacterProfile({
...profile,
statRuleset: profile.statRuleset ?? (contentCategoryForClass(profile.classId) === "rom" ? "rom" : "wow"),
id: makeId("character"),
level: 1,
experience: 0,
@@ -553,6 +577,29 @@ export function createCharacter(
return { ok: true, character };
}
export function updateCharacterStatRuleset(
ownerId: string,
characterId: string,
value: unknown,
storage: StorageLike | null = browserLocalStorage(),
): CharacterProfile | null {
if (!storage || !isCharacterStatRuleset(value)) return null;
const database = readDatabase(storage);
let updated: CharacterProfile | null = null;
database.rosters[ownerId] = (database.rosters[ownerId] ?? []).map((character) => {
if (character.id !== characterId || contentCategoryForClass(character.classId) !== "rom") return character;
updated = normalizeCharacterProfile({ ...character, statRuleset: value });
return updated;
});
if (!updated) return null;
try {
writeDatabase(database, storage);
} catch {
return null;
}
return updated;
}
export function touchCharacter(
ownerId: string,
characterId: string,
@@ -788,6 +835,24 @@ export function updateCharacterEquipment(
return updated;
}
export function updateCharacterHunterPetFamily(
ownerId: string,
characterId: string,
family: unknown,
storage: StorageLike | null = browserLocalStorage(),
): CharacterProfile | null {
if (!storage || !isWow335HunterPetFamily(family)) return null;
const database = readDatabase(storage);
let updated: CharacterProfile | null = null;
database.rosters[ownerId] = (database.rosters[ownerId] ?? []).map((character) => {
if (character.id !== characterId || character.classId !== "hunter") return character;
updated = normalizeCharacterProfile({ ...character, hunterPetFamily: family });
return updated;
});
if (updated) writeDatabase(database, storage);
return updated;
}
export function loadAccountBossCards(
ownerId: string,
storage: StorageLike | null = browserLocalStorage(),
+6 -1
View File
@@ -1,6 +1,7 @@
import { dungeonById } from "../game/dungeonCatalog";
import { activateDungeonSession } from "../game/dungeonSession";
import { useGameStore } from "../game/store";
import { useCombatStore } from "../game/combatStore";
import {
classCanFillPartyRole,
defaultPartyRoleForClass,
@@ -15,7 +16,11 @@ import { dungeonContentIsUsable } from "../content/contentManager";
import { preparedBiteSizedRunSelection } from "./biteSizedLayoutContent";
export function installDungeonShellRuntime(): void {
registerDungeonShellRuntime({ enterDungeon });
registerDungeonShellRuntime({
enterDungeon,
canChangeCharacterStatRuleset: () => !Object.values(useCombatStore.getState().mobs)
.some((mob) => mob.engaged && !mob.dead),
});
}
function enterDungeon(dungeonId: string, requestedRole?: PartyRole, settings?: DungeonEntrySettings): boolean {
+1
View File
@@ -2,6 +2,7 @@ import type { PartyRole } from "../game/partyRoles";
export interface DungeonShellRuntime {
enterDungeon: (dungeonId: string, playerRole?: PartyRole, settings?: DungeonEntrySettings) => boolean;
canChangeCharacterStatRuleset?: () => boolean;
}
export interface DungeonEntrySettings {
+104
View File
@@ -6,6 +6,7 @@ import { installManastormShellRuntime } from "./manastormShellRuntime";
import { SHELVED_MODES_ENABLED } from "./releaseFeatures";
import { DUNGEON_MANIFEST } from "../game/dungeonManifest";
import { useGameStore } from "../game/store";
import { useCombatStore } from "../game/combatStore";
import { useManastormStore } from "../game/manastormStore";
import { createEmptyManastormProgress, recordManastormCheckpoint } from "../game/manastormProgress";
import {
@@ -21,6 +22,8 @@ import {
resolveBossAdminEntry,
} from "../game/manastormAdminConfig";
import { useManastormAdminStore } from "../game/manastormAdminStore";
import { createCharacter, listCharacters, type StorageLike } from "./accountRepository";
import { useOnlineGroupStore } from "./onlineGroupStore";
installDungeonShellRuntime();
installManastormShellRuntime();
@@ -62,6 +65,20 @@ function readyBossForMode(modeId: number) {
const readyLevelingBoss = readyBossForMode(0);
const readyEndgameBoss = readyBossForMode(2);
if (!readyLevelingBoss || !readyEndgameBoss) throw new Error("Shell tests require runnable Manastorm bosses for both modes.");
class MemoryStorage implements StorageLike {
private values = new Map<string, string>();
getItem(key: string) { return this.values.get(key) ?? null; }
setItem(key: string, value: string) { this.values.set(key, value); }
removeItem(key: string) { this.values.delete(key); }
}
class FailingWriteStorage extends MemoryStorage {
failWrites = false;
setItem(key: string, value: string) {
if (this.failWrites) throw new Error("Storage quota exceeded.");
super.setItem(key, value);
}
}
const readyTestConfig = {
...DEFAULT_MANASTORM_ADMIN_CONFIG,
bosses: {
@@ -84,9 +101,11 @@ function prepareSelectedCharacter(): void {
describe("shell store", () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});
beforeEach(() => {
useOnlineGroupStore.getState().reset();
useShellStore.setState({
phase: "login",
session: null,
@@ -108,6 +127,91 @@ describe("shell store", () => {
expect(useShellStore.getState().notice).toMatch(/select or create/i);
});
it("saves a Hunter's selected family to the active character and local roster", () => {
const storage = new MemoryStorage();
vi.stubGlobal("window", { localStorage: storage });
const hunter = createCharacter({
ownerId: "offline-roster",
name: "Turtlekeeper",
categoryId: "wow",
raceId: "human",
classId: "hunter",
specializationId: "hunter:marksmanship",
gender: "female",
appearance: createDefaultAppearance(),
}, storage).character!;
useShellStore.setState({
session: { kind: "offline", ownerId: hunter.ownerId, displayName: "Offline" },
characters: [hunter],
activeCharacter: hunter,
});
expect(useShellStore.getState().saveActiveHunterPetFamily("turtle")).toBe(true);
expect(useShellStore.getState().activeCharacter?.hunterPetFamily).toBe("turtle");
expect(listCharacters(hunter.ownerId, storage)[0]?.hunterPetFamily).toBe("turtle");
expect(useShellStore.getState().saveActiveHunterPetFamily("cat")).toBe(true);
expect(useShellStore.getState().activeCharacter?.hunterPetFamily).toBe("cat");
expect(listCharacters(hunter.ownerId, storage)[0]?.hunterPetFamily).toBe("cat");
});
it("switches the selected RoM stat profile only from the main menu and reports storage failure", () => {
const storage = new FailingWriteStorage();
vi.stubGlobal("window", { localStorage: storage });
const romCharacter = createCharacter({
ownerId: "offline-roster",
name: "Runekeeper",
categoryId: "rom",
raceId: "rom-human",
classId: "rom-priest",
gender: "female",
appearance: createDefaultAppearance(),
}, storage).character!;
useShellStore.setState({
phase: "main-menu",
session: { kind: "offline", ownerId: romCharacter.ownerId, displayName: "Offline" },
characters: [romCharacter],
selectedCharacterId: romCharacter.id,
activeCharacter: null,
});
expect(useShellStore.getState().setSelectedCharacterStatRuleset("wow")).toBe(true);
expect(useShellStore.getState().characters[0]?.statRuleset).toBe("wow");
expect(listCharacters(romCharacter.ownerId, storage)[0]?.statRuleset).toBe("wow");
storage.failWrites = true;
expect(useShellStore.getState().setSelectedCharacterStatRuleset("rom")).toBe(false);
expect(useShellStore.getState().characters[0]?.statRuleset).toBe("wow");
expect(useShellStore.getState().notice).toMatch(/could not be saved/i);
storage.failWrites = false;
const previousMobs = useCombatStore.getState().mobs;
try {
useCombatStore.setState({ mobs: { "live-fight": { engaged: true, dead: false } as never } });
expect(useShellStore.getState().setSelectedCharacterStatRuleset("rom")).toBe(false);
expect(useShellStore.getState().notice).toMatch(/combat is active/i);
expect(listCharacters(romCharacter.ownerId, storage)[0]?.statRuleset).toBe("wow");
} finally {
useCombatStore.setState({ mobs: previousMobs });
}
useShellStore.setState({ activeCharacter: romCharacter });
expect(useShellStore.getState().setSelectedCharacterStatRuleset("rom")).toBe(false);
useShellStore.setState({ activeCharacter: null });
useOnlineGroupStore.setState({ snapshot: {
group: { activity: { id: "live-run" } } as never,
invitations: [],
} });
useShellStore.setState({ phase: "main-menu", notice: "" });
expect(useShellStore.getState().setSelectedCharacterStatRuleset("rom")).toBe(false);
expect(useShellStore.getState().characters[0]?.statRuleset).toBe("wow");
expect(useShellStore.getState().notice).toMatch(/live online group activity/i);
useOnlineGroupStore.getState().reset();
useShellStore.setState({ phase: "game", notice: "" });
expect(useShellStore.getState().setSelectedCharacterStatRuleset("rom")).toBe(false);
expect(useShellStore.getState().characters[0]?.statRuleset).toBe("wow");
expect(listCharacters(romCharacter.ownerId, storage)[0]?.statRuleset).toBe("wow");
});
it("routes a legacy WoW character through the mandatory specialization picker", () => {
prepareSelectedCharacter();
useShellStore.setState({ characters: [{ ...character, specializationId: null }] });
+47
View File
@@ -12,6 +12,8 @@ import {
updateCharacterActionBindings,
updateCharacterEquipment,
updateCharacterInventory,
updateCharacterHunterPetFamily,
updateCharacterStatRuleset,
saveAccountBossCards as persistAccountBossCards,
updateCharacterManastormProgress,
updateCharacterDungeonRunCheckpoint,
@@ -119,6 +121,8 @@ export interface ShellState {
saveActiveInventory: (inventory: readonly InventoryItem[]) => void;
saveAccountBossCards: (bossCards: BossCardCollection) => void;
saveActiveEquipment: (equipment: EquipmentAssignments) => void;
saveActiveHunterPetFamily: (family: unknown) => boolean;
setSelectedCharacterStatRuleset: (ruleset: unknown) => boolean;
saveActiveSettings: (settings: GameplaySettings) => void;
updateCombatPresentationSettings: (patch: Partial<CombatPresentationSettings>) => void;
resetCombatPresentationSettings: () => void;
@@ -286,6 +290,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
categoryId: draft.categoryId,
raceId: draft.raceId,
classId: draft.classId,
statRuleset: draft.categoryId === "rom" ? "rom" : "wow",
specializationId: draft.categoryId === "rom" ? null : draft.specializationId,
gender: draft.gender,
appearance: clampAppearance(draft.raceId, draft.gender, draft.appearance),
@@ -595,6 +600,48 @@ export const useShellStore = create<ShellState>((set, get) => ({
characters: state.characters.map((character) => character.id === updated.id ? updated : character),
}));
},
saveActiveHunterPetFamily: (family) => {
const { session, activeCharacter } = get();
if (!session || !activeCharacter || activeCharacter.classId !== "hunter") return false;
const updated = updateCharacterHunterPetFamily(
session.ownerId,
activeCharacter.id,
family,
);
if (!updated) return false;
set((state) => ({
activeCharacter: updated,
characters: state.characters.map((character) => character.id === updated.id ? updated : character),
}));
return true;
},
setSelectedCharacterStatRuleset: (ruleset) => {
const { phase, session, selectedCharacterId, activeCharacter } = get();
if (phase !== "main-menu" || !session || !selectedCharacterId) return false;
if (activeCharacter || dungeonShellRuntime()?.canChangeCharacterStatRuleset?.() === false) {
set({ notice: "Stat profiles cannot change while combat is active. Finish leaving the expedition first." });
return false;
}
if (useOnlineGroupStore.getState().snapshot.group?.activity) {
set({ notice: "Stat profiles cannot change during a live online group activity." });
return false;
}
const updated = updateCharacterStatRuleset(
session.ownerId,
selectedCharacterId,
ruleset,
);
if (!updated) {
set({ notice: "Stat profile could not be saved. Check device storage and try again." });
return false;
}
set((state) => ({
characters: state.characters.map((character) => character.id === updated.id ? updated : character),
activeCharacter: state.activeCharacter?.id === updated.id ? updated : state.activeCharacter,
notice: "Stat profile saved.",
}));
return true;
},
saveActiveSettings: (settings) => {
const { session, activeCharacter } = get();
if (!session || !activeCharacter) return;
+6
View File
@@ -6,6 +6,8 @@ import type { GameplaySettings } from "../game/combatStore";
import type { ManastormProgress } from "../game/manastormProgress";
import type { DungeonRunCheckpoint } from "../game/dungeonRunCheckpoint";
import type { BossCardCollection } from "../game/bossCards";
import type { Wow335HunterPetFamily } from "../game/wow335HunterPetFamilySelection";
import type { CharacterStatRuleset } from "../game/characterStatRuleset";
export type ShellPhase = "login" | "characters" | "create-character" | "main-menu" | "class-hall" | "dungeons" | "manastorms" | "arenas" | "arena-game" | "gm-admin" | "bite-sized-editor" | "game";
export type SessionKind = "account" | "offline";
@@ -32,6 +34,10 @@ export interface CharacterProfile {
categoryId?: ContentCategoryId;
/** RoM's inactive class. The active primary remains `classId`. */
secondaryClassId?: RomClassId | null;
/** Optional for legacy profile inputs; repository-normalized profiles always include a value. */
statRuleset?: CharacterStatRuleset;
/** Persisted choice for a Hunter's current pet family; legacy profiles default to Wolf. */
hunterPetFamily?: Wow335HunterPetFamily;
gender: GenderId;
appearance: CharacterAppearance;
level: number;