Release Healer Man 0.1.43
This commit is contained in:
1 parent
c7e8163790
commit
ccc01396cd
86 files changed
+2951
-198
No files matched your search
@@ -5,6 +5,10 @@ import {
|
||||
createLocalAccount,
|
||||
createOfflineSession,
|
||||
listCharacters,
|
||||
loadAccountBossCards,
|
||||
saveAccountBossCards,
|
||||
replaceCharactersForOwner,
|
||||
deleteCharacter,
|
||||
loadCombatPresentationSettings,
|
||||
loginLocalAccount,
|
||||
restoreSession,
|
||||
@@ -12,7 +16,6 @@ import {
|
||||
updateCharacterEquipment,
|
||||
updateCharacterDungeonRunCheckpoint,
|
||||
updateCharacterInventory,
|
||||
updateCharacterBossCards,
|
||||
updateCharacterManastormProgress,
|
||||
updateCharacterProgression,
|
||||
updateCharacterSettings,
|
||||
@@ -213,7 +216,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(6);
|
||||
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(7);
|
||||
}
|
||||
|
||||
const legacyV3Storage = new MemoryStorage();
|
||||
@@ -382,18 +385,58 @@ describe("local profile repository", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("persists card copies and claim receipts per character across roster reloads", () => {
|
||||
it("persists account card copies independently of character creation and deletion", () => {
|
||||
const storage = new MemoryStorage();
|
||||
const offline = createOfflineSession();
|
||||
const draft = { ownerId: offline.ownerId, name: "Collector", raceId: "human" as const, classId: "priest" as const, gender: "female" as const, appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 } };
|
||||
const character = createCharacter(draft, storage).character!;
|
||||
const other = createCharacter({ ...draft, name: "Other" }, storage).character!;
|
||||
const bossCards = { counts: { "wow:lady anacondra": { normal: 7, epic: 1 } }, lastClaims: { spawn: "kill:7" } };
|
||||
expect(updateCharacterBossCards(offline.ownerId, character.id, bossCards, storage)?.bossCards).toEqual(bossCards);
|
||||
expect(saveAccountBossCards(offline.ownerId, bossCards, storage)).toEqual(bossCards);
|
||||
expect(loadAccountBossCards(offline.ownerId, storage)).toEqual(bossCards);
|
||||
expect(loadAccountBossCards("different-account", storage)).toEqual({ counts: {}, lastClaims: {} });
|
||||
const roster = listCharacters(offline.ownerId, storage);
|
||||
expect(roster.find((entry) => entry.id === character.id)?.bossCards).toEqual(bossCards);
|
||||
expect(roster.find((entry) => entry.id === other.id)?.bossCards).toEqual({ counts: {}, lastClaims: {} });
|
||||
expect(roster.find((entry) => entry.id === character.id)?.bossCards).toBeUndefined();
|
||||
expect(roster.find((entry) => entry.id === other.id)?.bossCards).toBeUndefined();
|
||||
expect(roster.find((entry) => entry.id === character.id)?.inventory).toEqual([]);
|
||||
deleteCharacter(offline.ownerId, character.id, storage);
|
||||
deleteCharacter(offline.ownerId, other.id, storage);
|
||||
expect(loadAccountBossCards(offline.ownerId, storage)).toEqual(bossCards);
|
||||
createCharacter({ ...draft, name: "Newcomer" }, storage);
|
||||
expect(loadAccountBossCards(offline.ownerId, storage)).toEqual(bossCards);
|
||||
});
|
||||
|
||||
it("migrates and sums all legacy character logs once while retaining the newest receipts", () => {
|
||||
const storage = new MemoryStorage();
|
||||
const draft = { ownerId: "one", name: "Collector", raceId: "human" as const, classId: "priest" as const, gender: "female" as const, appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 } };
|
||||
const first = createCharacter(draft, storage).character!;
|
||||
const second = createCharacter({ ...draft, name: "Other" }, storage).character!;
|
||||
const separate = createCharacter({ ...draft, ownerId: "two" }, storage).character!;
|
||||
const legacyCharacters = [
|
||||
{ ...first, bossCards: { counts: { boss: { normal: 7, epic: 1 } }, lastClaims: { spawn: "100:1:spawn" } } },
|
||||
{ ...second, bossCards: { counts: { boss: { normal: 3, rare: 2 } }, lastClaims: { spawn: "200:2:spawn" } } },
|
||||
];
|
||||
storage.setItem(PROFILE_STORAGE_KEYS.database, JSON.stringify({
|
||||
version: 6, accounts: [], rosters: { one: legacyCharacters, two: [{ ...separate, bossCards: { counts: { boss: { normal: 4 } }, lastClaims: {} } }] },
|
||||
}));
|
||||
const expected = { counts: { boss: { normal: 10, rare: 2, epic: 1 } }, lastClaims: { spawn: "200:2:spawn" } };
|
||||
expect(loadAccountBossCards("one", storage)).toEqual(expected);
|
||||
expect(loadAccountBossCards("one", storage)).toEqual(expected);
|
||||
expect(loadAccountBossCards("two", storage).counts.boss?.normal).toBe(4);
|
||||
const database = JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!);
|
||||
expect(database.version).toBe(7);
|
||||
expect(database.rosters.one.every((entry: { bossCards?: unknown }) => entry.bossCards === undefined)).toBe(true);
|
||||
// An old cloud roster can be downloaded again without repeating the migration.
|
||||
replaceCharactersForOwner("one", legacyCharacters, storage);
|
||||
replaceCharactersForOwner("one", legacyCharacters, storage);
|
||||
expect(loadAccountBossCards("one", storage)).toEqual(expected);
|
||||
});
|
||||
|
||||
it("restores account logs from cloud without overwriting higher cached counts", () => {
|
||||
const storage = new MemoryStorage();
|
||||
saveAccountBossCards("one", { counts: { boss: { normal: 8 } }, lastClaims: { spawn: "200:2:spawn" } }, storage);
|
||||
replaceCharactersForOwner("one", [], storage, { counts: { boss: { normal: 7, rare: 1 } }, lastClaims: { spawn: "100:1:spawn" } });
|
||||
expect(loadAccountBossCards("one", storage)).toEqual({ counts: { boss: { normal: 8, rare: 1 } }, lastClaims: { spawn: "200:2:spawn" } });
|
||||
});
|
||||
|
||||
it("persists threat-meter visibility and position per character", () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { CharacterProfile, PlayerSession } from "./types";
|
||||
import { normalizeBossCardCollection, type BossCardCollection } from "../game/bossCards";
|
||||
import { mergeBossCardCollections, normalizeBossCardCollection, type BossCardCollection } from "../game/bossCards";
|
||||
import { normalizeUiPreferences, type UiPreferences } from "../game/uiLayout";
|
||||
import {
|
||||
contentCategoryForClass,
|
||||
@@ -62,9 +62,10 @@ interface LocalAccount {
|
||||
}
|
||||
|
||||
interface ProfileDatabase {
|
||||
version: 6;
|
||||
version: 7;
|
||||
accounts: LocalAccount[];
|
||||
rosters: Record<string, CharacterProfile[]>;
|
||||
bossCardsByOwner: Record<string, BossCardCollection>;
|
||||
presentationSettingsByOwner: Record<string, CombatPresentationSettings>;
|
||||
uiPreferencesByOwner?: Record<string, UiPreferences>;
|
||||
}
|
||||
@@ -105,7 +106,7 @@ function browserSessionStorage(): StorageLike | null {
|
||||
}
|
||||
|
||||
function emptyDatabase(): ProfileDatabase {
|
||||
return { version: 6, accounts: [], rosters: {}, presentationSettingsByOwner: {} };
|
||||
return { version: 7, accounts: [], rosters: {}, bossCardsByOwner: {}, presentationSettingsByOwner: {} };
|
||||
}
|
||||
|
||||
function isCharacterProfile(value: unknown): value is CharacterProfile {
|
||||
@@ -121,6 +122,9 @@ function isCharacterProfile(value: unknown): value is CharacterProfile {
|
||||
}
|
||||
|
||||
function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
|
||||
const profile = { ...value };
|
||||
// Cards now belong to the owner; legacy fields are consumed before normalization.
|
||||
delete profile.bossCards;
|
||||
const legacy = value as CharacterProfile & {
|
||||
actionBindings?: unknown;
|
||||
experience?: unknown;
|
||||
@@ -180,7 +184,7 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
|
||||
: undefined;
|
||||
const dungeonRunCheckpoint = normalizeDungeonRunCheckpoint(legacy.dungeonRunCheckpoint);
|
||||
return {
|
||||
...value,
|
||||
...profile,
|
||||
level,
|
||||
experience,
|
||||
talentRanks,
|
||||
@@ -191,7 +195,6 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
|
||||
actionLoadouts,
|
||||
inventory,
|
||||
equipment,
|
||||
bossCards: normalizeBossCardCollection(value.bossCards),
|
||||
manastormProgress,
|
||||
dungeonRunCheckpoint,
|
||||
settings,
|
||||
@@ -205,10 +208,11 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
|
||||
version?: number;
|
||||
accounts?: unknown;
|
||||
rosters?: unknown;
|
||||
bossCardsByOwner?: unknown;
|
||||
presentationSettingsByOwner?: unknown;
|
||||
uiPreferencesByOwner?: unknown;
|
||||
}) | null;
|
||||
if (!parsed || ![1, 2, 3, 4, 5, 6].includes(parsed.version ?? 0) || !Array.isArray(parsed.accounts) || !parsed.rosters || typeof parsed.rosters !== "object") {
|
||||
if (!parsed || ![1, 2, 3, 4, 5, 6, 7].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(
|
||||
@@ -220,12 +224,19 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
|
||||
&& typeof account.verifier.salt === "string"
|
||||
&& typeof account.verifier.digest === "string",
|
||||
));
|
||||
const rosters = Object.fromEntries(Object.entries(parsed.rosters as Record<string, unknown>).map(([ownerId, roster]) => [
|
||||
ownerId,
|
||||
Array.isArray(roster)
|
||||
? roster.filter(isCharacterProfile).map(normalizeCharacterProfile).slice(0, MAX_CHARACTERS)
|
||||
: [],
|
||||
]));
|
||||
const rawCards = parsed.bossCardsByOwner && typeof parsed.bossCardsByOwner === "object" && !Array.isArray(parsed.bossCardsByOwner)
|
||||
? parsed.bossCardsByOwner : {};
|
||||
const bossCardsByOwner = Object.fromEntries(Object.entries(rawCards).map(([owner, cards]) => [owner, normalizeBossCardCollection(cards)]));
|
||||
let cardsMigrated = 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) => character.bossCards !== undefined)) {
|
||||
const legacyCards = mergeBossCardCollections(characters.map((character) => character.bossCards), "sum");
|
||||
bossCardsByOwner[ownerId] = mergeBossCardCollections([bossCardsByOwner[ownerId], legacyCards]);
|
||||
cardsMigrated = true;
|
||||
}
|
||||
return [ownerId, characters.map(normalizeCharacterProfile)];
|
||||
}));
|
||||
const rawPresentationSettings = parsed.presentationSettingsByOwner
|
||||
&& typeof parsed.presentationSettingsByOwner === "object"
|
||||
&& !Array.isArray(parsed.presentationSettingsByOwner)
|
||||
@@ -259,8 +270,8 @@ 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: 6, accounts, rosters, presentationSettingsByOwner, uiPreferencesByOwner };
|
||||
if (parsed.version !== 6 || presentationSettingsMigrated) writeDatabase(database, storage);
|
||||
const database: ProfileDatabase = { version: 7, accounts, rosters, bossCardsByOwner, presentationSettingsByOwner, uiPreferencesByOwner };
|
||||
if (parsed.version !== 7 || presentationSettingsMigrated || cardsMigrated) writeDatabase(database, storage);
|
||||
return database;
|
||||
} catch {
|
||||
return emptyDatabase();
|
||||
@@ -270,7 +281,7 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
|
||||
function writeDatabase(database: ProfileDatabase, storage: StorageLike | null = browserLocalStorage()): void {
|
||||
storage?.setItem(DATABASE_KEY, JSON.stringify(database));
|
||||
if (!storage || !rosterChangeListener) return;
|
||||
for (const ownerId of new Set([...Object.keys(database.rosters), ...Object.keys(database.uiPreferencesByOwner ?? {})])) {
|
||||
for (const ownerId of new Set([...Object.keys(database.rosters), ...Object.keys(database.bossCardsByOwner), ...Object.keys(database.uiPreferencesByOwner ?? {})])) {
|
||||
rosterChangeListener(ownerId, database.rosters[ownerId] ?? []);
|
||||
}
|
||||
}
|
||||
@@ -452,15 +463,19 @@ export function replaceCharactersForOwner(
|
||||
ownerId: string,
|
||||
characters: unknown,
|
||||
storage: StorageLike | null = browserLocalStorage(),
|
||||
accountBossCards?: unknown,
|
||||
): CharacterProfile[] {
|
||||
if (!storage) return [];
|
||||
const database = readDatabase(storage);
|
||||
database.rosters[ownerId] = Array.isArray(characters)
|
||||
? characters.filter(isCharacterProfile).slice(0, MAX_CHARACTERS).map((character) => normalizeCharacterProfile({
|
||||
...character,
|
||||
ownerId,
|
||||
}))
|
||||
: [];
|
||||
const roster = Array.isArray(characters) ? characters.filter(isCharacterProfile).slice(0, MAX_CHARACTERS) : [];
|
||||
const importedCards = accountBossCards === undefined
|
||||
? mergeBossCardCollections(roster.map((character) => character.bossCards), "sum")
|
||||
: normalizeBossCardCollection(accountBossCards);
|
||||
database.bossCardsByOwner[ownerId] = mergeBossCardCollections([database.bossCardsByOwner[ownerId], importedCards]);
|
||||
database.rosters[ownerId] = roster.map((character) => normalizeCharacterProfile({
|
||||
...character,
|
||||
ownerId,
|
||||
}));
|
||||
writeDatabase(database, storage);
|
||||
return listCharacters(ownerId, storage);
|
||||
}
|
||||
@@ -730,22 +745,24 @@ export function updateCharacterEquipment(
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function updateCharacterBossCards(
|
||||
export function loadAccountBossCards(
|
||||
ownerId: string,
|
||||
storage: StorageLike | null = browserLocalStorage(),
|
||||
): BossCardCollection {
|
||||
return normalizeBossCardCollection(readDatabase(storage).bossCardsByOwner[ownerId]);
|
||||
}
|
||||
|
||||
export function saveAccountBossCards(
|
||||
ownerId: string,
|
||||
characterId: string,
|
||||
bossCards: BossCardCollection,
|
||||
storage: StorageLike | null = browserLocalStorage(),
|
||||
): CharacterProfile | null {
|
||||
if (!storage) return null;
|
||||
): BossCardCollection {
|
||||
const normalized = normalizeBossCardCollection(bossCards);
|
||||
if (!storage) return normalized;
|
||||
const database = readDatabase(storage);
|
||||
let updated: CharacterProfile | null = null;
|
||||
database.rosters[ownerId] = (database.rosters[ownerId] ?? []).map((character) => {
|
||||
if (character.id !== characterId) return character;
|
||||
updated = normalizeCharacterProfile({ ...character, bossCards });
|
||||
return updated;
|
||||
});
|
||||
if (updated) writeDatabase(database, storage);
|
||||
return updated;
|
||||
database.bossCardsByOwner[ownerId] = normalized;
|
||||
writeDatabase(database, storage);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function updateCharacterSettings(
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PlayerSession } from "./types";
|
||||
|
||||
function storage() {
|
||||
const values = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => { values.set(key, value); },
|
||||
removeItem: (key: string) => { values.delete(key); },
|
||||
};
|
||||
}
|
||||
|
||||
const draft = {
|
||||
ownerId: "account-one", name: "Collector", raceId: "human" as const,
|
||||
classId: "priest" as const, specializationId: "priest:holy", gender: "female" as const,
|
||||
appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 },
|
||||
};
|
||||
const session: PlayerSession = { kind: "account", ownerId: draft.ownerId, displayName: "Collector", accessToken: "test-token" };
|
||||
const cards = { counts: { "wow:lady anacondra": { normal: 7, rare: 1 } }, lastClaims: { spawn: "200:1:spawn" } };
|
||||
|
||||
beforeEach(() => { vi.useFakeTimers(); });
|
||||
afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); });
|
||||
|
||||
describe("account boss card integration", () => {
|
||||
it("keeps earned cards when switching characters and isolates account sign-in", async () => {
|
||||
vi.stubGlobal("window", { localStorage: storage(), sessionStorage: storage() });
|
||||
const repository = await import("./accountRepository");
|
||||
const { useShellStore } = await import("./shellStore");
|
||||
const { useCombatStore } = await import("../game/combatStore");
|
||||
const first = repository.createCharacter(draft).character!;
|
||||
const second = repository.createCharacter({ ...draft, name: "Other" }).character!;
|
||||
const offlineSession: PlayerSession = { ...session, accessToken: undefined };
|
||||
const shell = useShellStore.getState();
|
||||
shell.beginSession(offlineSession);
|
||||
useShellStore.setState({ phase: "game", activeCharacter: first });
|
||||
shell.saveAccountBossCards(cards);
|
||||
shell.returnToCharacters();
|
||||
shell.selectCharacter(second.id);
|
||||
expect(useShellStore.getState().bossCards).toEqual(cards);
|
||||
useCombatStore.getState().initializeCharacter({ ...second, bossCards: useShellStore.getState().bossCards });
|
||||
useCombatStore.getState().collectSharedBossCards({ bossKey: "wow:lady anacondra", spawnId: "spawn", claimId: "300:1:spawn", rarities: ["normal"] }, "Lady Anacondra", 300);
|
||||
shell.saveAccountBossCards(useCombatStore.getState().bossCards);
|
||||
shell.selectCharacter(first.id);
|
||||
expect(useShellStore.getState().bossCards.counts["wow:lady anacondra"]?.normal).toBe(8);
|
||||
expect(repository.listCharacters(session.ownerId).every((character) => character.bossCards === undefined)).toBe(true);
|
||||
shell.signOut();
|
||||
expect(useShellStore.getState().bossCards.counts).toEqual({});
|
||||
shell.beginSession({ ...offlineSession, ownerId: "account-two" });
|
||||
expect(useShellStore.getState().bossCards.counts).toEqual({});
|
||||
shell.beginSession(offlineSession);
|
||||
expect(useShellStore.getState().bossCards.counts["wow:lady anacondra"]?.normal).toBe(8);
|
||||
});
|
||||
|
||||
it("uploads the account collection without characters and restores it on another device", async () => {
|
||||
const firstDevice = storage();
|
||||
const secondDevice = storage();
|
||||
vi.stubGlobal("window", { localStorage: firstDevice });
|
||||
let cloudData: unknown = { version: 1, characters: [] };
|
||||
const fetchMock = vi.fn(async (_url: string, init: RequestInit) => {
|
||||
if (init.method === "PUT") cloudData = JSON.parse(String(init.body)).data;
|
||||
return new Response(JSON.stringify({ revision: 1, updatedAt: 1, data: cloudData }), { status: 200 });
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const repository = await import("./accountRepository");
|
||||
const client = await import("./onlineAccountClient");
|
||||
client.activateOnlineSession(session);
|
||||
repository.saveAccountBossCards(session.ownerId, cards);
|
||||
await vi.advanceTimersByTimeAsync(501);
|
||||
expect(cloudData).toMatchObject({ version: 2, characters: [], bossCards: cards });
|
||||
expect(client.useCloudSaveStatus.getState().state).toBe("synced");
|
||||
vi.stubGlobal("window", { localStorage: secondDevice });
|
||||
await client.hydrateOnlineSession(session);
|
||||
expect(repository.loadAccountBossCards(session.ownerId)).toEqual(cards);
|
||||
expect(repository.loadAccountBossCards("different-account").counts).toEqual({});
|
||||
const { collectBossCardDrop } = await import("../game/bossCards");
|
||||
const restored = repository.loadAccountBossCards(session.ownerId);
|
||||
expect(collectBossCardDrop(restored, { bossKey: "wow:lady anacondra", spawnId: "spawn", claimId: "200:1:spawn", rarities: ["normal", "rare"] })).toBe(restored);
|
||||
await client.logoutOnlineSession(session);
|
||||
});
|
||||
|
||||
it("publishes legacy cloud migration once and preserves it through repeated downloads", async () => {
|
||||
vi.stubGlobal("window", { localStorage: storage() });
|
||||
const repository = await import("./accountRepository");
|
||||
const first = repository.createCharacter(draft).character!;
|
||||
const second = repository.createCharacter({ ...draft, name: "Other" }).character!;
|
||||
let cloudData: unknown = { version: 1, characters: [
|
||||
{ ...first, bossCards: cards },
|
||||
{ ...second, bossCards: { counts: { "wow:lady anacondra": { normal: 3 } }, lastClaims: {} } },
|
||||
] };
|
||||
vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => {
|
||||
if (init.method === "PUT") cloudData = JSON.parse(String(init.body)).data;
|
||||
return new Response(JSON.stringify({ revision: 1, updatedAt: 1, data: cloudData }), { status: 200 });
|
||||
}));
|
||||
const client = await import("./onlineAccountClient");
|
||||
await client.hydrateOnlineSession(session);
|
||||
expect(repository.loadAccountBossCards(session.ownerId).counts["wow:lady anacondra"]?.normal).toBe(10);
|
||||
await vi.advanceTimersByTimeAsync(501);
|
||||
expect(cloudData).toMatchObject({ version: 2, bossCards: { counts: { "wow:lady anacondra": { normal: 10, rare: 1 } } } });
|
||||
expect((cloudData as { characters: { bossCards?: unknown }[] }).characters.every((character) => character.bossCards === undefined)).toBe(true);
|
||||
await client.hydrateOnlineSession(session);
|
||||
expect(repository.loadAccountBossCards(session.ownerId).counts["wow:lady anacondra"]?.normal).toBe(10);
|
||||
await client.logoutOnlineSession(session);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { useBossTestingReviews } from "./bossTestingReviews";
|
||||
import { submitDungeonReport } from "./dungeonReports";
|
||||
|
||||
afterEach(() => { vi.unstubAllGlobals(); useBossTestingReviews.setState({ reviews: {} }); });
|
||||
|
||||
describe("boss feedback", () => {
|
||||
const boss = { key: "wailing-caverns/lady-anacondra", sourceDungeonId: "wailing-caverns", name: "Lady Anacondra" };
|
||||
it("retains offline readiness and syncs it before refreshing server labels", async () => {
|
||||
const storage = { setItem: vi.fn() };
|
||||
vi.stubGlobal("window", { localStorage: storage });
|
||||
const fetch = vi.fn().mockRejectedValueOnce(new Error("offline"));
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
expect(await useBossTestingReviews.getState().save(boss, "not-ready", null)).toBe(false);
|
||||
expect(useBossTestingReviews.getState().reviews[boss.key]).toMatchObject({ status: "not-ready", pending: true });
|
||||
expect(storage.setItem).toHaveBeenCalled();
|
||||
const review = { bossKey: boss.key, dungeonId: boss.sourceDungeonId, bossName: boss.name, status: "not-ready", reviewedAt: 1, reviewedBy: "Guest player" };
|
||||
fetch.mockResolvedValueOnce({ ok: true, json: async () => ({ review }) }).mockResolvedValueOnce({ ok: true, json: async () => ({ reviews: [review] }) });
|
||||
await useBossTestingReviews.getState().refresh(null);
|
||||
expect(useBossTestingReviews.getState().reviews[boss.key]).toEqual(review);
|
||||
expect(fetch.mock.calls[1][1].method).toBe("POST");
|
||||
expect(fetch.mock.calls[2][0]).toBe("/api/boss-testing/reviews");
|
||||
});
|
||||
it("submits boss identity as structured metadata without consuming report text", async () => {
|
||||
const fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ report: {} }) });
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
await submitDungeonReport(null, boss.sourceDungeonId, "x".repeat(500), { bossKey: boss.key, bossName: boss.name });
|
||||
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({ dungeonId: boss.sourceDungeonId, comment: "x".repeat(500), bossKey: boss.key, bossName: boss.name });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { create } from "zustand";
|
||||
import type { PlayerSession } from "./types";
|
||||
import { requestOnlineJson } from "./onlineAccountClient";
|
||||
|
||||
export const BOSS_TESTING_STATUSES = ["ready", "needs-testing", "not-ready"] as const;
|
||||
export type BossTestingStatus = (typeof BOSS_TESTING_STATUSES)[number];
|
||||
export const BOSS_TESTING_STATUS_LABELS: Record<BossTestingStatus, string> = {
|
||||
ready: "Ready", "needs-testing": "Needs testing", "not-ready": "Not ready",
|
||||
};
|
||||
export interface BossTestingReview {
|
||||
readonly bossKey: string;
|
||||
readonly dungeonId: string;
|
||||
readonly bossName: string;
|
||||
readonly status: BossTestingStatus;
|
||||
readonly reviewedBy: string;
|
||||
readonly reviewedAt: number;
|
||||
readonly pending?: boolean;
|
||||
}
|
||||
const CACHE_KEY = "healer-man.boss-testing-reviews.v1";
|
||||
function readReviews(): Record<string, BossTestingReview> {
|
||||
try {
|
||||
const reviews = JSON.parse(window.localStorage.getItem(CACHE_KEY) ?? "{}");
|
||||
if (!reviews || typeof reviews !== "object" || Array.isArray(reviews)) return {};
|
||||
return Object.fromEntries(Object.entries(reviews).filter(([key, value]) => {
|
||||
const review = value as BossTestingReview;
|
||||
return review?.bossKey === key && typeof review.dungeonId === "string" && typeof review.bossName === "string"
|
||||
&& BOSS_TESTING_STATUSES.includes(review.status) && Number.isFinite(review.reviewedAt);
|
||||
})) as Record<string, BossTestingReview>;
|
||||
} catch { return {}; }
|
||||
}
|
||||
function persist(reviews: Record<string, BossTestingReview>) {
|
||||
try { window.localStorage.setItem(CACHE_KEY, JSON.stringify(reviews)); } catch { /* Storage may be disabled. */ }
|
||||
}
|
||||
function sendReview(review: BossTestingReview, session: PlayerSession | null) {
|
||||
return requestOnlineJson<{ review: BossTestingReview }>("/api/boss-testing/reviews", {
|
||||
method: "POST", body: JSON.stringify({ bossKey: review.bossKey, dungeonId: review.dungeonId, bossName: review.bossName, status: review.status }),
|
||||
}, session?.kind === "account" ? session.accessToken : undefined);
|
||||
}
|
||||
interface ReviewState {
|
||||
reviews: Record<string, BossTestingReview>;
|
||||
refresh: (session: PlayerSession | null) => Promise<void>;
|
||||
save: (boss: { readonly key: string; readonly sourceDungeonId: string; readonly name: string }, status: BossTestingStatus,
|
||||
session: PlayerSession | null) => Promise<boolean>;
|
||||
}
|
||||
export const useBossTestingReviews = create<ReviewState>((set, get) => ({
|
||||
reviews: readReviews(),
|
||||
refresh: async (session) => {
|
||||
try {
|
||||
for (const pending of Object.values(get().reviews).filter((review) => review.pending)) {
|
||||
const { review } = await sendReview(pending, session);
|
||||
if (get().reviews[pending.bossKey] !== pending) continue;
|
||||
const reviews = { ...get().reviews, [review.bossKey]: review };
|
||||
persist(reviews); set({ reviews });
|
||||
}
|
||||
const payload = await requestOnlineJson<{ reviews: BossTestingReview[] }>("/api/boss-testing/reviews");
|
||||
const reviews = { ...get().reviews, ...Object.fromEntries(payload.reviews
|
||||
.filter((review) => !get().reviews[review.bossKey]?.pending
|
||||
&& review.reviewedAt >= (get().reviews[review.bossKey]?.reviewedAt ?? 0)).map((review) => [review.bossKey, review])) };
|
||||
persist(reviews); set({ reviews });
|
||||
} catch { /* Keep cached labels and sync pending reviews when the connection returns. */ }
|
||||
},
|
||||
save: async (boss, status, session) => {
|
||||
const pending: BossTestingReview = { bossKey: boss.key, dungeonId: boss.sourceDungeonId, bossName: boss.name, status,
|
||||
reviewedAt: Date.now(), reviewedBy: session?.displayName ?? "Guest player", pending: true };
|
||||
let reviews = { ...get().reviews, [boss.key]: pending };
|
||||
persist(reviews); set({ reviews });
|
||||
try {
|
||||
const { review } = await sendReview(pending, session);
|
||||
if (get().reviews[boss.key] === pending) {
|
||||
reviews = { ...get().reviews, [boss.key]: review };
|
||||
persist(reviews); set({ reviews });
|
||||
}
|
||||
return true;
|
||||
} catch { return false; }
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,27 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { prepareBossTesting } from "./bossTestingRuntime";
|
||||
import { BOSS_TESTING_CATALOG } from "../game/bossTesting";
|
||||
import * as content from "../content/contentManager";
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe("boss asset preparation", () => {
|
||||
const boss = BOSS_TESTING_CATALOG[0];
|
||||
it("automatically downloads and verifies only the selected boss's dungeon pack", async () => {
|
||||
const usable = vi.spyOn(content, "dungeonContentIsUsable").mockReturnValueOnce(false).mockReturnValueOnce(false).mockReturnValue(true);
|
||||
vi.spyOn(content, "initializeContentSystem").mockResolvedValue();
|
||||
const install = vi.spyOn(content, "installContentPack").mockResolvedValue();
|
||||
await prepareBossTesting(boss);
|
||||
expect(install).toHaveBeenCalledExactlyOnceWith(`dungeon:${boss.dungeonId}`);
|
||||
expect(usable).toHaveBeenCalledWith(boss.dungeonId);
|
||||
});
|
||||
it("reuses installed content and stops entry after failed verification", async () => {
|
||||
const usable = vi.spyOn(content, "dungeonContentIsUsable").mockReturnValue(true);
|
||||
vi.spyOn(content, "initializeContentSystem").mockResolvedValue();
|
||||
const install = vi.spyOn(content, "installContentPack").mockResolvedValue();
|
||||
await prepareBossTesting(boss);
|
||||
expect(install).not.toHaveBeenCalled();
|
||||
usable.mockReturnValue(false);
|
||||
await expect(prepareBossTesting(boss)).rejects.toThrow(/verified/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useShellStore } from "./shellStore";
|
||||
import { specializationById } from "../game/specializationCatalog";
|
||||
import { defaultPartyRoleForClass, type PartyRole } from "../game/partyRoles";
|
||||
import { createBossTestingRoom, type TestableBoss } from "../game/bossTesting";
|
||||
import { clearBossTestingDungeons, registerBossTestingDungeon, requireDungeonDefinition } from "../game/dungeonRegistry";
|
||||
import { activateDungeonSession } from "../game/dungeonSession";
|
||||
import { dungeonContentIsUsable, initializeContentSystem, installContentPack } from "../content/contentManager";
|
||||
|
||||
let preparationQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
export async function prepareBossTesting(boss: TestableBoss): Promise<void> {
|
||||
if (dungeonContentIsUsable(boss.dungeonId)) return;
|
||||
const preparation = preparationQueue.then(async () => {
|
||||
await initializeContentSystem();
|
||||
if (!dungeonContentIsUsable(boss.dungeonId)) await installContentPack(`dungeon:${boss.dungeonId}`);
|
||||
if (!dungeonContentIsUsable(boss.dungeonId)) throw new Error("The boss assets could not be verified. Please retry the download.");
|
||||
});
|
||||
preparationQueue = preparation.catch(() => undefined);
|
||||
await preparation;
|
||||
}
|
||||
|
||||
export function enterBossTesting(boss: TestableBoss, requestedRole?: PartyRole): boolean {
|
||||
const shell = useShellStore.getState();
|
||||
const character = shell.characters.find((entry) => entry.id === shell.selectedCharacterId);
|
||||
if (!shell.session || !character) throw new Error("Select a character first.");
|
||||
const specialization = specializationById(character.specializationId);
|
||||
if (character.categoryId !== "rom" && !specialization) throw new Error("Choose a specialization in the Class Hall first.");
|
||||
const { definition, session } = createBossTestingRoom(requireDungeonDefinition(boss.dungeonId), boss);
|
||||
registerBossTestingDungeon(definition, boss.dungeonId);
|
||||
if (!activateDungeonSession(definition.id, undefined, { gmTestMode: true, bossTest: session })) {
|
||||
clearBossTestingDungeons();
|
||||
throw new Error("The boss testing room could not be started. Check the downloaded assets and retry.");
|
||||
}
|
||||
useShellStore.setState({ phase: "game", activeCharacter: character, activeDungeonId: definition.id,
|
||||
activeDungeonRole: specialization?.role ?? requestedRole ?? defaultPartyRoleForClass(character.classId), notice: "" });
|
||||
return true;
|
||||
}
|
||||
@@ -16,6 +16,8 @@ export interface DungeonReport {
|
||||
readonly updatedAt: number;
|
||||
readonly updatedBy: string | null;
|
||||
readonly revision: number;
|
||||
readonly bossKey?: string;
|
||||
readonly bossName?: string;
|
||||
}
|
||||
|
||||
export interface DungeonReportSnapshot {
|
||||
@@ -40,10 +42,11 @@ export function fetchDungeonReports(): Promise<DungeonReportSnapshot> {
|
||||
return requestOnlineJson<DungeonReportSnapshot>("/api/dungeon-reports");
|
||||
}
|
||||
|
||||
export async function submitDungeonReport(session: PlayerSession | null, dungeonId: string, comment: string): Promise<DungeonReport> {
|
||||
export async function submitDungeonReport(session: PlayerSession | null, dungeonId: string, comment: string,
|
||||
boss?: { readonly bossKey: string; readonly bossName: string }): Promise<DungeonReport> {
|
||||
const response = await requestOnlineJson<{ report: DungeonReport }>("/api/dungeon-reports", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ dungeonId, comment }),
|
||||
body: JSON.stringify({ dungeonId, comment, ...boss }),
|
||||
}, session?.kind === "account" ? session.accessToken : undefined);
|
||||
return response.report;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createDefaultAppearance } from "./characterCatalog";
|
||||
import {
|
||||
createCharacter,
|
||||
listCharacters,
|
||||
updateCharacterProgression,
|
||||
type StorageLike,
|
||||
} from "./accountRepository";
|
||||
import { beginMainMenuCharacterEditing } from "./mainMenuCharacterEditing";
|
||||
import { selectedCharacter, useShellStore } from "./shellStore";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { talentNodesForTree } from "../game/talentCatalog";
|
||||
|
||||
class MemoryStorage implements StorageLike {
|
||||
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); }
|
||||
}
|
||||
|
||||
describe("main-menu character editing", () => {
|
||||
let storage: MemoryStorage;
|
||||
let stop: (() => void) | null;
|
||||
const holyTalent = talentNodesForTree("priest-holy")[0];
|
||||
|
||||
beforeEach(() => {
|
||||
storage = new MemoryStorage();
|
||||
stop = null;
|
||||
const created = createCharacter({
|
||||
ownerId: "offline-roster", name: "Menuhealer", classId: "priest",
|
||||
raceId: "human", gender: "female", appearance: createDefaultAppearance(),
|
||||
specializationId: "priest:holy",
|
||||
}, storage).character!;
|
||||
const character = updateCharacterProgression(created.ownerId, created.id, {
|
||||
level: 17, experience: 123, talentRanks: {},
|
||||
}, storage)!;
|
||||
useShellStore.setState({
|
||||
phase: "main-menu", activeCharacter: null, selectedCharacterId: character.id,
|
||||
session: { kind: "offline", ownerId: character.ownerId, displayName: "Offline" },
|
||||
characters: [character], notice: "",
|
||||
});
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 1 });
|
||||
});
|
||||
afterEach(() => { stop?.(); });
|
||||
|
||||
it("loads the selected character without rewriting their saved profile or starting a dungeon", () => {
|
||||
const before = [...storage.values];
|
||||
stop = beginMainMenuCharacterEditing(storage);
|
||||
expect(stop).not.toBeNull();
|
||||
expect(useCombatStore.getState()).toMatchObject({ classId: "priest", level: 17, xp: 123 });
|
||||
expect([...storage.values]).toEqual(before);
|
||||
expect(useShellStore.getState()).toMatchObject({ phase: "main-menu", activeCharacter: null });
|
||||
});
|
||||
|
||||
it("persists talent allocation and reset, and reloads the saved build when reopened", () => {
|
||||
stop = beginMainMenuCharacterEditing(storage);
|
||||
expect(useCombatStore.getState().allocateTalent(holyTalent.id)).toBe(true);
|
||||
expect(listCharacters("offline-roster", storage)[0].talentRanks[holyTalent.id]).toBe(1);
|
||||
expect(selectedCharacter(useShellStore.getState())?.talentRanks[holyTalent.id]).toBe(1);
|
||||
stop!();
|
||||
stop = beginMainMenuCharacterEditing(storage);
|
||||
expect(useCombatStore.getState().talentRanks[holyTalent.id]).toBe(1);
|
||||
useCombatStore.getState().resetTalents();
|
||||
expect(listCharacters("offline-roster", storage)[0].talentRanks).toEqual({});
|
||||
expect(listCharacters("offline-roster", storage)[0]).toMatchObject({ level: 17, experience: 123 });
|
||||
});
|
||||
|
||||
it("persists controller assignments and reloads them without touching progression or equipment", () => {
|
||||
const profile = selectedCharacter(useShellStore.getState())!;
|
||||
stop = beginMainMenuCharacterEditing(storage);
|
||||
expect(useCombatStore.getState().setActionBinding("primary", "face-bottom", "priest-flash-heal")).toBe(true);
|
||||
const saved = listCharacters("offline-roster", storage)[0];
|
||||
expect(saved.actionBindings["primary:face-bottom"]).toBe("priest-flash-heal");
|
||||
expect(saved).toMatchObject({ level: profile.level, experience: profile.experience,
|
||||
talentRanks: profile.talentRanks, inventory: profile.inventory, equipment: profile.equipment });
|
||||
stop!();
|
||||
stop = beginMainMenuCharacterEditing(storage);
|
||||
expect(useCombatStore.getState().actionBindings["primary:face-bottom"]).toBe("priest-flash-heal");
|
||||
});
|
||||
|
||||
it.each(["phase", "owner", "selection", "closed"])("stops saving after the editing context changes: %s", (change) => {
|
||||
stop = beginMainMenuCharacterEditing(storage);
|
||||
const before = [...storage.values];
|
||||
if (change === "phase") useShellStore.setState({ phase: "game" });
|
||||
if (change === "owner") useShellStore.setState({ session: { kind: "offline", ownerId: "another-owner", displayName: "Other" } });
|
||||
if (change === "selection") useShellStore.setState({ selectedCharacterId: null });
|
||||
if (change === "closed") stop!();
|
||||
useCombatStore.getState().allocateTalent(holyTalent.id);
|
||||
useCombatStore.getState().setActionBinding("primary", "face-bottom", "priest-flash-heal");
|
||||
expect([...storage.values]).toEqual(before);
|
||||
});
|
||||
|
||||
it("never saves combat XP as a side effect of a talent edit", () => {
|
||||
stop = beginMainMenuCharacterEditing(storage);
|
||||
useCombatStore.setState({ xp: 999 });
|
||||
useCombatStore.getState().allocateTalent(holyTalent.id);
|
||||
expect(listCharacters("offline-roster", storage)[0].experience).toBe(123);
|
||||
});
|
||||
|
||||
it("rejects editing without a selected, owned main-menu character", () => {
|
||||
useShellStore.setState({ selectedCharacterId: null });
|
||||
expect(beginMainMenuCharacterEditing(storage)).toBeNull();
|
||||
expect(useCombatStore.getState().classId).toBe("mage");
|
||||
});
|
||||
|
||||
it("reports storage failures instead of updating the roster as though the save succeeded", () => {
|
||||
stop = beginMainMenuCharacterEditing(storage);
|
||||
storage.setItem = () => { throw new Error("Storage full"); };
|
||||
useCombatStore.getState().allocateTalent(holyTalent.id);
|
||||
expect(useShellStore.getState().notice).toMatch(/could not save/i);
|
||||
expect(selectedCharacter(useShellStore.getState())?.talentRanks).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
updateCharacterActionBindings,
|
||||
updateCharacterProgression,
|
||||
type StorageLike,
|
||||
} from "./accountRepository";
|
||||
import { selectedCharacter, useShellStore } from "./shellStore";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
|
||||
// Reuse the character editors without mounting the dungeon's simulation bridge.
|
||||
export function beginMainMenuCharacterEditing(storage?: StorageLike): (() => void) | null {
|
||||
const shell = useShellStore.getState();
|
||||
const character = selectedCharacter(shell);
|
||||
if (shell.phase !== "main-menu" || !shell.session || !character
|
||||
|| character.ownerId !== shell.session.ownerId) return null;
|
||||
|
||||
const ownerId = shell.session.ownerId;
|
||||
const characterId = character.id;
|
||||
useCombatStore.getState().initializeCharacter(character);
|
||||
|
||||
return useCombatStore.subscribe((combat, previous) => {
|
||||
const current = useShellStore.getState();
|
||||
const profile = selectedCharacter(current);
|
||||
if (current.phase !== "main-menu" || current.session?.ownerId !== ownerId
|
||||
|| profile?.id !== characterId || profile.ownerId !== ownerId) return;
|
||||
if (combat.talentRanks === previous.talentRanks && combat.actionBindings === previous.actionBindings) return;
|
||||
|
||||
try {
|
||||
let updated = profile;
|
||||
if (combat.talentRanks !== previous.talentRanks) {
|
||||
const saved = updateCharacterProgression(ownerId, characterId, {
|
||||
// Menu edits cannot award experience or alter the character's level.
|
||||
level: profile.level,
|
||||
experience: profile.experience,
|
||||
talentRanks: combat.talentRanks,
|
||||
}, storage);
|
||||
if (!saved) throw new Error("Character storage is unavailable.");
|
||||
updated = saved;
|
||||
}
|
||||
if (combat.actionBindings !== previous.actionBindings) {
|
||||
const saved = updateCharacterActionBindings(ownerId, characterId, combat.actionBindings, storage);
|
||||
if (!saved) throw new Error("Character storage is unavailable.");
|
||||
updated = saved;
|
||||
}
|
||||
useShellStore.setState((state) => ({
|
||||
characters: state.characters.map((entry) => entry.id === characterId ? updated : entry),
|
||||
notice: "",
|
||||
}));
|
||||
} catch {
|
||||
useShellStore.getState().setNotice("Could not save character changes. Check that profile storage is available and try again.");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
replaceCharactersForOwner,
|
||||
setRosterChangeListener,
|
||||
loadUiPreferences,
|
||||
loadAccountBossCards,
|
||||
saveUiPreferences,
|
||||
type AccountResult,
|
||||
} from "./accountRepository";
|
||||
@@ -23,7 +24,7 @@ interface AuthResponse {
|
||||
interface CloudSaveResponse {
|
||||
revision: number;
|
||||
updatedAt: number | null;
|
||||
data: { version?: number; characters?: unknown; uiPreferences?: unknown };
|
||||
data: { version?: number; characters?: unknown; uiPreferences?: unknown; bossCards?: unknown };
|
||||
}
|
||||
|
||||
interface MeResponse {
|
||||
@@ -106,11 +107,12 @@ function flushCloudSave(): void {
|
||||
pendingCharacters = null;
|
||||
if (!session?.accessToken || !characters) return;
|
||||
const uiPreferences = loadUiPreferences(session.ownerId);
|
||||
const bossCards = loadAccountBossCards(session.ownerId);
|
||||
const sequence = saveSequence;
|
||||
saveQueue = saveQueue.then(async () => {
|
||||
await requestOnlineJson<CloudSaveResponse>("/api/cloud-save", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ data: { version: 1, characters, uiPreferences } }),
|
||||
body: JSON.stringify({ data: { version: 2, characters, uiPreferences, bossCards } }),
|
||||
}, session.accessToken);
|
||||
if (activeSession?.ownerId === session.ownerId && sequence === saveSequence) useCloudSaveStatus.setState({ state: "synced", message: "Saved to your account." });
|
||||
}).catch((error) => {
|
||||
@@ -149,12 +151,18 @@ export async function hydrateOnlineSession(session: PlayerSession): Promise<Char
|
||||
activateOnlineSession(session);
|
||||
const cloud = await requestOnlineJson<CloudSaveResponse>("/api/cloud-save", {}, session.accessToken);
|
||||
suppressUpload = true;
|
||||
let characters: CharacterProfile[];
|
||||
try {
|
||||
saveUiPreferences(session.ownerId, cloud.data?.uiPreferences);
|
||||
return replaceCharactersForOwner(session.ownerId, cloud.data?.characters ?? []);
|
||||
characters = replaceCharactersForOwner(session.ownerId, cloud.data?.characters ?? [], undefined, cloud.data?.bossCards);
|
||||
} finally {
|
||||
suppressUpload = false;
|
||||
}
|
||||
// Publish migrated logs even if the player has not earned another card yet.
|
||||
if (JSON.stringify(cloud.data?.bossCards) !== JSON.stringify(loadAccountBossCards(session.ownerId))) {
|
||||
queueCloudSave(session.ownerId, characters);
|
||||
}
|
||||
return characters;
|
||||
}
|
||||
|
||||
export async function refreshOnlineSession(session: PlayerSession): Promise<PlayerSession> {
|
||||
|
||||
@@ -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(6);
|
||||
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(7);
|
||||
});
|
||||
|
||||
it("locks selection to level ten and preserves separate bars through atomic swaps", () => {
|
||||
|
||||
+19
-15
@@ -5,13 +5,14 @@ import {
|
||||
createCharacter as persistCharacter,
|
||||
deleteCharacter as removeCharacter,
|
||||
listCharacters,
|
||||
loadAccountBossCards,
|
||||
loadCombatPresentationSettings,
|
||||
restoreSession as restoreStoredSession,
|
||||
saveSession,
|
||||
updateCharacterActionBindings,
|
||||
updateCharacterEquipment,
|
||||
updateCharacterInventory,
|
||||
updateCharacterBossCards,
|
||||
saveAccountBossCards as persistAccountBossCards,
|
||||
updateCharacterManastormProgress,
|
||||
updateCharacterDungeonRunCheckpoint,
|
||||
updateCharacterProgression,
|
||||
@@ -39,7 +40,7 @@ import type { CharacterDraft, CharacterProfile, PlayerSession, ShellPhase } from
|
||||
import type { DungeonId } from "../game/dungeonRegistry";
|
||||
import { DEFAULT_DUNGEON_ID } from "../game/dungeonDefaults";
|
||||
import type { InventoryItem } from "../game/lootTypes";
|
||||
import type { BossCardCollection } from "../game/bossCards";
|
||||
import { emptyBossCardCollection, type BossCardCollection } from "../game/bossCards";
|
||||
import type { EquipmentAssignments } from "../game/equipment";
|
||||
import type { GameplaySettings } from "../game/combatStore";
|
||||
import {
|
||||
@@ -70,6 +71,7 @@ export interface ShellState {
|
||||
phase: ShellPhase;
|
||||
session: PlayerSession | null;
|
||||
characters: CharacterProfile[];
|
||||
bossCards: BossCardCollection;
|
||||
selectedCharacterId: string | null;
|
||||
activeCharacter: CharacterProfile | null;
|
||||
draft: CharacterDraft;
|
||||
@@ -111,7 +113,7 @@ export interface ShellState {
|
||||
saveActiveProgression: (level: number, experience: number, talentRanks: Record<string, number>) => void;
|
||||
saveActiveActionBindings: (actionBindings: Readonly<Record<string, string | null>>) => void;
|
||||
saveActiveInventory: (inventory: readonly InventoryItem[]) => void;
|
||||
saveActiveBossCards: (bossCards: BossCardCollection) => void;
|
||||
saveAccountBossCards: (bossCards: BossCardCollection) => void;
|
||||
saveActiveEquipment: (equipment: EquipmentAssignments) => void;
|
||||
saveActiveSettings: (settings: GameplaySettings) => void;
|
||||
updateCombatPresentationSettings: (patch: Partial<CombatPresentationSettings>) => void;
|
||||
@@ -138,12 +140,13 @@ function defaultDraft(categoryId: ContentCategoryId = "wow"): CharacterDraft {
|
||||
};
|
||||
}
|
||||
|
||||
function restoredState(): Pick<ShellState, "phase" | "session" | "characters" | "selectedCharacterId" | "combatPresentationSettings"> {
|
||||
function restoredState(): Pick<ShellState, "phase" | "session" | "characters" | "bossCards" | "selectedCharacterId" | "combatPresentationSettings"> {
|
||||
const session = restoreStoredSession();
|
||||
if (!session) return {
|
||||
phase: "login",
|
||||
session: null,
|
||||
characters: [],
|
||||
bossCards: emptyBossCardCollection(),
|
||||
selectedCharacterId: null,
|
||||
combatPresentationSettings: DEFAULT_COMBAT_PRESENTATION_SETTINGS,
|
||||
};
|
||||
@@ -152,6 +155,7 @@ function restoredState(): Pick<ShellState, "phase" | "session" | "characters" |
|
||||
phase: "characters",
|
||||
session,
|
||||
characters,
|
||||
bossCards: loadAccountBossCards(session.ownerId),
|
||||
selectedCharacterId: characters[0]?.id ?? null,
|
||||
combatPresentationSettings: loadCombatPresentationSettings(session.ownerId),
|
||||
};
|
||||
@@ -174,6 +178,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
phase: "characters",
|
||||
session,
|
||||
characters,
|
||||
bossCards: loadAccountBossCards(session.ownerId),
|
||||
selectedCharacterId: characters[0]?.id ?? null,
|
||||
activeCharacter: null,
|
||||
combatPresentationSettings: loadCombatPresentationSettings(session.ownerId),
|
||||
@@ -189,6 +194,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
phase: "login",
|
||||
session: null,
|
||||
characters: [],
|
||||
bossCards: emptyBossCardCollection(),
|
||||
selectedCharacterId: null,
|
||||
activeCharacter: null,
|
||||
draft: defaultDraft(),
|
||||
@@ -276,6 +282,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
set({
|
||||
phase: "characters",
|
||||
characters,
|
||||
bossCards: loadAccountBossCards(session.ownerId),
|
||||
selectedCharacterId: result.character.id,
|
||||
draft: defaultDraft(),
|
||||
notice: `${result.character.name} is ready for the expedition.`,
|
||||
@@ -439,7 +446,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
return true;
|
||||
},
|
||||
returnToMainMenu: () => {
|
||||
useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false });
|
||||
useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false, bossTest: null });
|
||||
if (SHELVED_MODES_ENABLED) manastormShellRuntime()?.leaveActiveGame();
|
||||
const { session, characters, activeCharacter, selectedCharacterId } = get();
|
||||
if (!session) {
|
||||
@@ -477,7 +484,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
},
|
||||
enterWorld: () => get().enterDungeon(DEFAULT_DUNGEON_ID),
|
||||
returnToCharacters: () => {
|
||||
useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false });
|
||||
useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false, bossTest: null });
|
||||
const session = get().session;
|
||||
if (!session) {
|
||||
set({ phase: "login", activeCharacter: null });
|
||||
@@ -488,6 +495,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
set({
|
||||
phase: "characters",
|
||||
characters,
|
||||
bossCards: loadAccountBossCards(session.ownerId),
|
||||
selectedCharacterId: characters.some((character) => character.id === currentId)
|
||||
? currentId
|
||||
: characters[0]?.id ?? null,
|
||||
@@ -538,15 +546,10 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
characters: state.characters.map((character) => character.id === updated.id ? updated : character),
|
||||
}));
|
||||
},
|
||||
saveActiveBossCards: (bossCards) => {
|
||||
const { session, activeCharacter } = get();
|
||||
if (!session || !activeCharacter) return;
|
||||
const updated = updateCharacterBossCards(session.ownerId, activeCharacter.id, bossCards);
|
||||
if (!updated) return;
|
||||
set((state) => ({
|
||||
activeCharacter: updated,
|
||||
characters: state.characters.map((character) => character.id === updated.id ? updated : character),
|
||||
}));
|
||||
saveAccountBossCards: (bossCards) => {
|
||||
const { session } = get();
|
||||
if (!session) return;
|
||||
set({ bossCards: persistAccountBossCards(session.ownerId, bossCards) });
|
||||
},
|
||||
saveActiveEquipment: (equipment) => {
|
||||
const { session, activeCharacter } = get();
|
||||
@@ -651,6 +654,7 @@ if (restored.session?.accessToken) {
|
||||
useShellStore.setState({
|
||||
session: refreshedSession,
|
||||
characters,
|
||||
bossCards: loadAccountBossCards(refreshedSession.ownerId),
|
||||
selectedCharacterId: characters.some((character) => character.id === useShellStore.getState().selectedCharacterId)
|
||||
? useShellStore.getState().selectedCharacterId
|
||||
: characters[0]?.id ?? null,
|
||||
|
||||
@@ -42,6 +42,7 @@ export interface CharacterProfile {
|
||||
actionLoadouts?: Record<string, Record<string, string | null>>;
|
||||
inventory: InventoryItem[];
|
||||
equipment: EquipmentAssignments;
|
||||
/** Legacy character log, read only during migration to the account collection. */
|
||||
bossCards?: BossCardCollection;
|
||||
/** Present on normalized and newly created profiles; optional for legacy callers. */
|
||||
manastormProgress?: ManastormProgress;
|
||||
|
||||
Reference in new issue
Block a user