Release Healer Man 0.1.43

This commit is contained in:
phenom committed 2026-09-14 13:20:20 -04:00
1 parent c7e8163790
commit ccc01396cd
86 files changed
+2951 -198

No files matched your search

+49 -6
View File
@@ -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", () => {
+50 -33
View File
@@ -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(
+104
View File
@@ -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);
});
});
+30
View File
@@ -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 });
});
});
+76
View File
@@ -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; }
},
}));
+27
View File
@@ -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/);
});
});
+37
View File
@@ -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;
}
+5 -2
View File
@@ -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;
}
+113
View File
@@ -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({});
});
});
+52
View File
@@ -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.");
}
});
}
+11 -3
View File
@@ -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> {
+1 -1
View File
@@ -58,7 +58,7 @@ describe("RoM dual-class persistence", () => {
"priest:none": { "primary:face-bottom": "priest-flash-heal" },
},
});
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(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
View File
@@ -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,
+1
View File
@@ -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;