forked from phenom/healer-man
Guard source gear namespaces and retain recoverable legacy records (#9)
This commit is contained in:
1 parent
ab37006932
commit
e567910c04
23 files changed
+1203
-66
No files matched your search
+252
-17
@@ -1,4 +1,11 @@
|
||||
import type { CharacterProfile, PlayerSession } from "./types";
|
||||
import type {
|
||||
CharacterGearRecovery,
|
||||
CharacterProfile,
|
||||
GearRecoveryEquipmentReference,
|
||||
GearRecoveryItem,
|
||||
GearRecoveryReason,
|
||||
PlayerSession,
|
||||
} from "./types";
|
||||
import { mergeBossCardCollections, normalizeBossCardCollection, type BossCardCollection } from "../game/bossCards";
|
||||
import { normalizeUiPreferences, type UiPreferences } from "../game/uiLayout";
|
||||
import {
|
||||
@@ -11,7 +18,12 @@ import {
|
||||
orderedClassLoadoutKey,
|
||||
ROM_SECONDARY_UNLOCK_LEVEL,
|
||||
} from "./romDualClass";
|
||||
import { normalizeInventory, type InventoryItem } from "../game/lootTypes";
|
||||
import {
|
||||
inventoryItemMatchesCategory,
|
||||
inventoryItemUsableForCategory,
|
||||
normalizeInventory,
|
||||
type InventoryItem,
|
||||
} from "../game/lootTypes";
|
||||
import { normalizeEquipment, type EquipmentAssignments } from "../game/equipment";
|
||||
import type { GameplaySettings } from "../game/combatStore";
|
||||
import {
|
||||
@@ -70,7 +82,7 @@ interface LocalAccount {
|
||||
}
|
||||
|
||||
interface ProfileDatabase {
|
||||
version: 7;
|
||||
version: 8;
|
||||
accounts: LocalAccount[];
|
||||
rosters: Record<string, CharacterProfile[]>;
|
||||
bossCardsByOwner: Record<string, BossCardCollection>;
|
||||
@@ -114,7 +126,196 @@ function browserSessionStorage(): StorageLike | null {
|
||||
}
|
||||
|
||||
function emptyDatabase(): ProfileDatabase {
|
||||
return { version: 7, accounts: [], rosters: {}, bossCardsByOwner: {}, presentationSettingsByOwner: {} };
|
||||
return { version: 8, accounts: [], rosters: {}, bossCardsByOwner: {}, presentationSettingsByOwner: {} };
|
||||
}
|
||||
|
||||
const CONTENT_CATEGORY_IDS = new Set(["wow", "rom", "coa"]);
|
||||
|
||||
function isGearRecoveryReason(value: unknown): value is GearRecoveryReason {
|
||||
return value === "missing-category" || value === "foreign-category" || value === "invalid-native-payload";
|
||||
}
|
||||
|
||||
function normalizeGearRecovery(value: unknown): CharacterGearRecovery {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return { items: [], equipment: [] };
|
||||
const record = value as { items?: unknown; equipment?: unknown };
|
||||
const items: GearRecoveryItem[] = Array.isArray(record.items)
|
||||
? record.items.flatMap((entry): GearRecoveryItem[] => {
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)
|
||||
|| !Object.prototype.hasOwnProperty.call(entry, "item")) return [];
|
||||
const row = entry as { item: unknown; reason?: unknown };
|
||||
return [{
|
||||
item: row.item,
|
||||
reason: isGearRecoveryReason(row.reason) ? row.reason : "missing-category",
|
||||
}];
|
||||
})
|
||||
: [];
|
||||
const equipment: GearRecoveryEquipmentReference[] = Array.isArray(record.equipment)
|
||||
? record.equipment.flatMap((entry): GearRecoveryEquipmentReference[] => {
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return [];
|
||||
const row = entry as Partial<GearRecoveryEquipmentReference>;
|
||||
if (typeof row.ownerId !== "string" || typeof row.slot !== "string" || typeof row.instanceId !== "string") return [];
|
||||
return [{
|
||||
ownerId: row.ownerId,
|
||||
slot: row.slot,
|
||||
instanceId: row.instanceId,
|
||||
reason: isGearRecoveryReason(row.reason) ? row.reason : "missing-category",
|
||||
}];
|
||||
})
|
||||
: [];
|
||||
return { items, equipment };
|
||||
}
|
||||
|
||||
function jsonIdentity(value: unknown): string {
|
||||
try { return JSON.stringify(value) ?? "undefined"; } catch { return "[unserializable]"; }
|
||||
}
|
||||
|
||||
/** Merges repeat cloud/profile normalization without duplicating the same recovered source row. */
|
||||
function mergeGearRecovery(
|
||||
...values: readonly unknown[]
|
||||
): CharacterGearRecovery | undefined {
|
||||
const mergedItems: GearRecoveryItem[] = [];
|
||||
const mergedEquipment: GearRecoveryEquipmentReference[] = [];
|
||||
const itemCounts = new Map<string, number>();
|
||||
const equipmentCounts = new Map<string, number>();
|
||||
for (const value of values) {
|
||||
const recovery = normalizeGearRecovery(value);
|
||||
const currentItemCounts = new Map<string, number>();
|
||||
for (const entry of recovery.items) {
|
||||
const key = `${entry.reason}\n${jsonIdentity(entry.item)}`;
|
||||
const count = (currentItemCounts.get(key) ?? 0) + 1;
|
||||
currentItemCounts.set(key, count);
|
||||
if (count > (itemCounts.get(key) ?? 0)) mergedItems.push(entry);
|
||||
}
|
||||
for (const [key, count] of currentItemCounts) {
|
||||
itemCounts.set(key, Math.max(itemCounts.get(key) ?? 0, count));
|
||||
}
|
||||
for (const entry of recovery.equipment) {
|
||||
const key = jsonIdentity(entry);
|
||||
if (equipmentCounts.has(key)) continue;
|
||||
equipmentCounts.set(key, 1);
|
||||
mergedEquipment.push(entry);
|
||||
}
|
||||
}
|
||||
return mergedItems.length || mergedEquipment.length
|
||||
? { items: mergedItems, equipment: mergedEquipment }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function recoveryReasonForCategory(
|
||||
category: unknown,
|
||||
ownerCategory: CharacterProfile["categoryId"],
|
||||
normalizedItem?: InventoryItem,
|
||||
): GearRecoveryReason {
|
||||
if (CONTENT_CATEGORY_IDS.has(String(category)) && category !== ownerCategory) return "foreign-category";
|
||||
if (category === "rom" && ownerCategory === "rom" && normalizedItem
|
||||
&& !inventoryItemUsableForCategory(normalizedItem, ownerCategory)) return "invalid-native-payload";
|
||||
return "missing-category";
|
||||
}
|
||||
|
||||
function incomingInventoryMatchesCategory(
|
||||
inventory: readonly InventoryItem[],
|
||||
ownerCategory: NonNullable<CharacterProfile["categoryId"]>,
|
||||
): boolean {
|
||||
for (const candidate of inventory as readonly unknown[]) {
|
||||
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) return false;
|
||||
const item = candidate as Record<string, unknown>;
|
||||
const category = item.contentCategory;
|
||||
if (!(category === undefined && ownerCategory === "wow")
|
||||
&& !inventoryItemMatchesCategory(item as Pick<InventoryItem, "contentCategory">, ownerCategory)) return false;
|
||||
const normalized = normalizeInventory([candidate], { ownerCategory })[0];
|
||||
if (!normalized || !inventoryItemUsableForCategory(normalized, ownerCategory)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function equipmentReferencesRecoveredItem(
|
||||
assignments: EquipmentAssignments,
|
||||
recovery: CharacterGearRecovery | undefined,
|
||||
): boolean {
|
||||
if (!assignments || typeof assignments !== "object" || Array.isArray(assignments)) return false;
|
||||
if (!recovery?.items.length && !recovery?.equipment.length) return false;
|
||||
const recoveredIds = new Set<string>();
|
||||
for (const entry of recovery.items) {
|
||||
if (entry.item && typeof entry.item === "object" && !Array.isArray(entry.item)) {
|
||||
const instanceId = (entry.item as { instanceId?: unknown }).instanceId;
|
||||
if (typeof instanceId === "string") recoveredIds.add(instanceId);
|
||||
}
|
||||
}
|
||||
for (const entry of recovery.equipment) recoveredIds.add(entry.instanceId);
|
||||
return Object.values(assignments).some((loadout) => (
|
||||
loadout && typeof loadout === "object"
|
||||
&& Object.values(loadout).some((instanceId) => typeof instanceId === "string" && recoveredIds.has(instanceId))
|
||||
));
|
||||
}
|
||||
|
||||
function equipmentReferencesUsableInventory(
|
||||
assignments: EquipmentAssignments,
|
||||
profile: CharacterProfile,
|
||||
): boolean {
|
||||
if (!assignments || typeof assignments !== "object" || Array.isArray(assignments)) return false;
|
||||
const itemById = new Map(profile.inventory.map((item) => [item.instanceId, item]));
|
||||
for (const loadout of Object.values(assignments as Record<string, unknown>)) {
|
||||
if (!loadout || typeof loadout !== "object" || Array.isArray(loadout)) return false;
|
||||
for (const rawId of Object.values(loadout as Record<string, unknown>)) {
|
||||
if (typeof rawId !== "string") continue;
|
||||
const item = itemById.get(rawId);
|
||||
if (!item || !inventoryItemUsableForCategory(item, profile.categoryId)) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
interface ProfileGearNormalization {
|
||||
inventory: InventoryItem[];
|
||||
equipment: EquipmentAssignments;
|
||||
gearRecovery?: CharacterGearRecovery;
|
||||
}
|
||||
|
||||
function normalizeProfileGear(
|
||||
rawInventory: unknown,
|
||||
rawEquipment: unknown,
|
||||
ownerCategory: NonNullable<CharacterProfile["categoryId"]>,
|
||||
existingRecovery: unknown,
|
||||
): ProfileGearNormalization {
|
||||
const usableInventory: InventoryItem[] = [];
|
||||
const recoveredItems: GearRecoveryItem[] = [];
|
||||
const rejectedInstanceIds = new Map<string, GearRecoveryReason>();
|
||||
const candidates = Array.isArray(rawInventory) ? rawInventory : [];
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) continue;
|
||||
const normalized = normalizeInventory([candidate], { ownerCategory })[0];
|
||||
if (normalized && inventoryItemUsableForCategory(normalized, ownerCategory)) {
|
||||
usableInventory.push(normalized);
|
||||
continue;
|
||||
}
|
||||
const raw = candidate as Record<string, unknown>;
|
||||
// Keep the old invalid-row behavior for an item that already claims this
|
||||
// profile's namespace but cannot be normalized as an InventoryItem.
|
||||
if (!normalized && raw.contentCategory === ownerCategory) continue;
|
||||
const reason = recoveryReasonForCategory(raw.contentCategory, ownerCategory, normalized);
|
||||
recoveredItems.push({ item: candidate, reason });
|
||||
if (typeof raw.instanceId === "string" && !rejectedInstanceIds.has(raw.instanceId)) {
|
||||
rejectedInstanceIds.set(raw.instanceId, reason);
|
||||
}
|
||||
}
|
||||
|
||||
const recoveredEquipment: GearRecoveryEquipmentReference[] = [];
|
||||
if (rawEquipment && typeof rawEquipment === "object" && !Array.isArray(rawEquipment)) {
|
||||
for (const [ownerId, rawLoadout] of Object.entries(rawEquipment as Record<string, unknown>)) {
|
||||
if (!ownerId || !rawLoadout || typeof rawLoadout !== "object" || Array.isArray(rawLoadout)) continue;
|
||||
for (const [slot, rawInstanceId] of Object.entries(rawLoadout as Record<string, unknown>)) {
|
||||
if (typeof rawInstanceId !== "string") continue;
|
||||
const reason = rejectedInstanceIds.get(rawInstanceId);
|
||||
if (reason) recoveredEquipment.push({ ownerId, slot, instanceId: rawInstanceId, reason });
|
||||
}
|
||||
}
|
||||
}
|
||||
const equipment = normalizeEquipment(rawEquipment, usableInventory, ownerCategory);
|
||||
return {
|
||||
inventory: usableInventory,
|
||||
equipment,
|
||||
gearRecovery: mergeGearRecovery(existingRecovery, { items: recoveredItems, equipment: recoveredEquipment }),
|
||||
};
|
||||
}
|
||||
|
||||
function isCharacterProfile(value: unknown): value is CharacterProfile {
|
||||
@@ -147,6 +348,7 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
|
||||
level?: unknown;
|
||||
inventory?: unknown;
|
||||
equipment?: unknown;
|
||||
gearRecovery?: unknown;
|
||||
manastormProgress?: unknown;
|
||||
settings?: unknown;
|
||||
secondaryClassId?: unknown;
|
||||
@@ -196,14 +398,19 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
|
||||
})) as Record<string, Record<string, string | null>>;
|
||||
const activeLoadoutKey = orderedClassLoadoutKey(value.classId, secondaryClassId);
|
||||
actionLoadouts[activeLoadoutKey] = { ...(actionLoadouts[activeLoadoutKey] ?? actionBindings) };
|
||||
const inventory = normalizeInventory(legacy.inventory);
|
||||
const equipment = normalizeEquipment(legacy.equipment, inventory);
|
||||
const normalizedGear = normalizeProfileGear(
|
||||
legacy.inventory,
|
||||
legacy.equipment,
|
||||
categoryId,
|
||||
legacy.gearRecovery,
|
||||
);
|
||||
const { inventory, equipment, gearRecovery } = normalizedGear;
|
||||
const manastormProgress = normalizeManastormProgress(legacy.manastormProgress);
|
||||
const settings = legacy.settings && typeof legacy.settings === "object" && !Array.isArray(legacy.settings)
|
||||
? { ...(legacy.settings as Partial<GameplaySettings>) }
|
||||
: undefined;
|
||||
const dungeonRunCheckpoint = normalizeDungeonRunCheckpoint(legacy.dungeonRunCheckpoint);
|
||||
return {
|
||||
const normalizedProfile: CharacterProfile = {
|
||||
...profile,
|
||||
level,
|
||||
experience,
|
||||
@@ -220,9 +427,15 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
|
||||
dungeonRunCheckpoint,
|
||||
settings,
|
||||
};
|
||||
if (gearRecovery) normalizedProfile.gearRecovery = gearRecovery;
|
||||
else delete normalizedProfile.gearRecovery;
|
||||
return normalizedProfile;
|
||||
}
|
||||
|
||||
function readDatabase(storage: StorageLike | null = browserLocalStorage()): ProfileDatabase {
|
||||
function readDatabase(
|
||||
storage: StorageLike | null = browserLocalStorage(),
|
||||
persistMigrations = true,
|
||||
): ProfileDatabase {
|
||||
if (!storage) return emptyDatabase();
|
||||
try {
|
||||
const parsed = JSON.parse(storage.getItem(DATABASE_KEY) ?? "null") as ({
|
||||
@@ -233,7 +446,7 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
|
||||
presentationSettingsByOwner?: unknown;
|
||||
uiPreferencesByOwner?: unknown;
|
||||
}) | null;
|
||||
if (!parsed || ![1, 2, 3, 4, 5, 6, 7].includes(parsed.version ?? 0) || !Array.isArray(parsed.accounts) || !parsed.rosters || typeof parsed.rosters !== "object") {
|
||||
if (!parsed || ![1, 2, 3, 4, 5, 6, 7, 8].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(
|
||||
@@ -296,8 +509,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: 7, accounts, rosters, bossCardsByOwner, presentationSettingsByOwner, uiPreferencesByOwner };
|
||||
if (parsed.version !== 7 || presentationSettingsMigrated || cardsMigrated || statRulesetsMigrated) {
|
||||
const database: ProfileDatabase = { version: 8, accounts, rosters, bossCardsByOwner, presentationSettingsByOwner, uiPreferencesByOwner };
|
||||
if (persistMigrations && (parsed.version !== 8 || presentationSettingsMigrated || cardsMigrated || statRulesetsMigrated)) {
|
||||
// A full or read-only device must still expose its existing roster.
|
||||
// Retry migration on the next read when persistence is available.
|
||||
try { writeDatabase(database, storage); } catch { /* Keep the normalized in-memory profiles. */ }
|
||||
@@ -534,10 +747,20 @@ export function replaceCharactersForOwner(
|
||||
? 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,
|
||||
}));
|
||||
const existingRecoveryByCharacterId = new Map(
|
||||
(database.rosters[ownerId] ?? []).map((character) => [character.id, character.gearRecovery]),
|
||||
);
|
||||
database.rosters[ownerId] = roster.map((character) => {
|
||||
const gearRecovery = mergeGearRecovery(
|
||||
existingRecoveryByCharacterId.get(character.id),
|
||||
character.gearRecovery,
|
||||
);
|
||||
return normalizeCharacterProfile({
|
||||
...character,
|
||||
ownerId,
|
||||
...(gearRecovery ? { gearRecovery } : {}),
|
||||
});
|
||||
});
|
||||
writeDatabase(database, storage);
|
||||
return listCharacters(ownerId, storage);
|
||||
}
|
||||
@@ -800,7 +1023,11 @@ export function updateCharacterInventory(
|
||||
storage: StorageLike | null = browserLocalStorage(),
|
||||
): CharacterProfile | null {
|
||||
if (!storage) return null;
|
||||
const database = readDatabase(storage);
|
||||
// Do not persist a migration if this mutation is rejected. Existing profile
|
||||
// bytes (including legacy gear and its quarantine state) remain untouched.
|
||||
const database = readDatabase(storage, false);
|
||||
const current = (database.rosters[ownerId] ?? []).find((character) => character.id === characterId);
|
||||
if (!current?.categoryId || !incomingInventoryMatchesCategory(inventory, current.categoryId)) return null;
|
||||
let updated: CharacterProfile | null = null;
|
||||
database.rosters[ownerId] = (database.rosters[ownerId] ?? []).map((character) => {
|
||||
if (character.id !== characterId) return character;
|
||||
@@ -821,7 +1048,15 @@ export function updateCharacterEquipment(
|
||||
storage: StorageLike | null = browserLocalStorage(),
|
||||
): CharacterProfile | null {
|
||||
if (!storage) return null;
|
||||
const database = readDatabase(storage);
|
||||
// Equipment updates are all-or-nothing: never silently erase assignments to
|
||||
// foreign or quarantined inventory during normalization.
|
||||
const database = readDatabase(storage, false);
|
||||
const current = (database.rosters[ownerId] ?? []).find((character) => character.id === characterId);
|
||||
if (
|
||||
!current
|
||||
|| equipmentReferencesRecoveredItem(equipment, current.gearRecovery)
|
||||
|| !equipmentReferencesUsableInventory(equipment, current)
|
||||
) return null;
|
||||
let updated: CharacterProfile | null = null;
|
||||
database.rosters[ownerId] = (database.rosters[ownerId] ?? []).map((character) => {
|
||||
if (character.id !== characterId) return character;
|
||||
|
||||
Reference in new issue
Block a user