Update class mechanics, stat rulesets, and CoA reference catalogs
This commit is contained in:
1 parent
b16821e92e
commit
39458d5de6
221 files changed
+2584962
-568
No files matched your search
@@ -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({
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }] });
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
Reference in new issue
Block a user