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
@@ -164,6 +164,7 @@ describe("local profile repository", () => {
|
||||
appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 },
|
||||
}, storage).character!;
|
||||
const item = {
|
||||
contentCategory: "rom" as const,
|
||||
instanceId: "kept-item",
|
||||
itemId: 10412,
|
||||
itemKey: "belt-of-the-fang",
|
||||
@@ -174,7 +175,7 @@ describe("local profile repository", () => {
|
||||
weaponType: null,
|
||||
itemLevel: 17,
|
||||
sourceItemLevel: 21,
|
||||
stats: { ...EMPTY_ITEM_STATS, agility: 4, stamina: 4, armor: 15 },
|
||||
stats: { ...EMPTY_ITEM_STATS, agility: 4, stamina: 4, armor: 15, romNative: { strength: 1 } },
|
||||
quantity: 1,
|
||||
sourceBossKey: "lady-anacondra",
|
||||
sourceBossName: "Lady Anacondra",
|
||||
@@ -483,7 +484,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(7);
|
||||
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(8);
|
||||
}
|
||||
|
||||
const legacyV3Storage = new MemoryStorage();
|
||||
@@ -642,16 +643,176 @@ describe("local profile repository", () => {
|
||||
acquiredAt: 5_000,
|
||||
};
|
||||
|
||||
expect(updateCharacterInventory(offline.ownerId, character.id, [item], storage)?.inventory).toEqual([item]);
|
||||
const normalizedItem = { ...item, contentCategory: "wow" as const };
|
||||
expect(updateCharacterInventory(offline.ownerId, character.id, [item], storage)?.inventory).toEqual([normalizedItem]);
|
||||
expect(updateCharacterEquipment(offline.ownerId, character.id, {
|
||||
player: { waist: item.instanceId },
|
||||
}, storage)?.equipment).toEqual({ player: { waist: item.instanceId } });
|
||||
expect(listCharacters(offline.ownerId, storage)[0]?.inventory).toEqual([item]);
|
||||
expect(listCharacters(offline.ownerId, storage)[0]?.inventory).toEqual([normalizedItem]);
|
||||
expect(listCharacters(offline.ownerId, storage)[0]?.equipment).toEqual({
|
||||
player: { waist: item.instanceId },
|
||||
});
|
||||
});
|
||||
|
||||
it("migrates foreign and unresolved gear into character-scoped recovery without changing owner identity", () => {
|
||||
const storage = new MemoryStorage();
|
||||
const makeItem = (instanceId: string, contentCategory?: "wow" | "rom" | "coa", romNative?: Record<string, number>) => ({
|
||||
...(contentCategory ? { contentCategory } : {}),
|
||||
instanceId,
|
||||
itemId: 10412,
|
||||
itemKey: `gear:${instanceId}`,
|
||||
name: `Gear ${instanceId}`,
|
||||
quality: "rare" as const,
|
||||
inventorySlot: "waist",
|
||||
armorType: "leather",
|
||||
weaponType: null,
|
||||
itemLevel: 17,
|
||||
sourceItemLevel: 21,
|
||||
stats: {
|
||||
...EMPTY_ITEM_STATS,
|
||||
agility: 4,
|
||||
...(romNative === undefined ? {} : { romNative }),
|
||||
},
|
||||
quantity: 1,
|
||||
sourceBossKey: "source-boss",
|
||||
sourceBossName: "Source Boss",
|
||||
acquiredAt: 5_000,
|
||||
});
|
||||
const legacyProfile = (
|
||||
id: string,
|
||||
ownerId: string,
|
||||
classId: string,
|
||||
raceId: string,
|
||||
inventory: unknown[],
|
||||
equipment: unknown = {},
|
||||
) => ({
|
||||
id, ownerId, name: id, classId, raceId, gender: "female", appearance: {},
|
||||
inventory, equipment, location: "Dungeon Entrance", createdAt: 1, lastPlayedAt: 2,
|
||||
});
|
||||
const wowItem = makeItem("wow-legacy");
|
||||
const romMissingCategory = makeItem("rom-missing-category");
|
||||
const romForeign = makeItem("rom-foreign", "wow");
|
||||
const romInvalidNative = makeItem("rom-invalid-native", "rom", {});
|
||||
const coaMissingCategory = makeItem("coa-missing-category");
|
||||
const untouchedWow = makeItem("other-owner-item", "wow");
|
||||
const wowProfile = legacyProfile("wow-profile", "owner-one", "priest", "human", [wowItem], {
|
||||
player: { waist: wowItem.instanceId },
|
||||
});
|
||||
const romProfile = legacyProfile("rom-profile", "owner-one", "rom-priest", "rom-human", [
|
||||
romMissingCategory, romForeign, romInvalidNative,
|
||||
], {
|
||||
player: { waist: romMissingCategory.instanceId },
|
||||
companion: { waist: romForeign.instanceId },
|
||||
reserve: { waist: romInvalidNative.instanceId },
|
||||
});
|
||||
const coaProfile = legacyProfile("coa-profile", "owner-one", "barbarian", "human", [coaMissingCategory]);
|
||||
const otherOwnerProfile = legacyProfile("other-owner-profile", "owner-two", "priest", "human", [untouchedWow]);
|
||||
storage.setItem(PROFILE_STORAGE_KEYS.database, JSON.stringify({
|
||||
version: 7,
|
||||
accounts: [],
|
||||
rosters: { "owner-one": [wowProfile, romProfile, coaProfile], "owner-two": [otherOwnerProfile] },
|
||||
bossCardsByOwner: {},
|
||||
presentationSettingsByOwner: {},
|
||||
}));
|
||||
|
||||
const ownerOne = listCharacters("owner-one", storage);
|
||||
const wow = ownerOne.find((character) => character.id === "wow-profile")!;
|
||||
const rom = ownerOne.find((character) => character.id === "rom-profile")!;
|
||||
const coa = ownerOne.find((character) => character.id === "coa-profile")!;
|
||||
expect(wow.inventory).toMatchObject([{ instanceId: "wow-legacy", contentCategory: "wow", stats: { agility: 4 } }]);
|
||||
expect(wow.equipment).toEqual({ player: { waist: "wow-legacy" } });
|
||||
expect(rom.inventory).toEqual([]);
|
||||
expect(rom.equipment).toEqual({});
|
||||
expect(rom.gearRecovery?.items).toEqual([
|
||||
{ item: romMissingCategory, reason: "missing-category" },
|
||||
{ item: romForeign, reason: "foreign-category" },
|
||||
{ item: romInvalidNative, reason: "invalid-native-payload" },
|
||||
]);
|
||||
expect(rom.gearRecovery?.equipment).toEqual([
|
||||
{ ownerId: "player", slot: "waist", instanceId: "rom-missing-category", reason: "missing-category" },
|
||||
{ ownerId: "companion", slot: "waist", instanceId: "rom-foreign", reason: "foreign-category" },
|
||||
{ ownerId: "reserve", slot: "waist", instanceId: "rom-invalid-native", reason: "invalid-native-payload" },
|
||||
]);
|
||||
expect(coa.inventory).toEqual([]);
|
||||
expect(coa.gearRecovery?.items).toEqual([{ item: coaMissingCategory, reason: "missing-category" }]);
|
||||
expect(listCharacters("owner-two", storage)[0]?.inventory).toMatchObject([
|
||||
{ instanceId: "other-owner-item", contentCategory: "wow" },
|
||||
]);
|
||||
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(8);
|
||||
|
||||
const validRomReplacement = makeItem("valid-rom-replacement", "rom", { strength: 2 });
|
||||
const ordinarySave = updateCharacterInventory("owner-one", rom.id, [validRomReplacement], storage)!;
|
||||
expect(ordinarySave.inventory).toMatchObject([{ instanceId: "valid-rom-replacement", contentCategory: "rom" }]);
|
||||
expect(ordinarySave.gearRecovery).toEqual(rom.gearRecovery);
|
||||
|
||||
const migratedBytes = storage.getItem(PROFILE_STORAGE_KEYS.database);
|
||||
expect(listCharacters("owner-one", storage).find((character) => character.id === "rom-profile")?.gearRecovery)
|
||||
.toEqual(rom.gearRecovery);
|
||||
expect(storage.getItem(PROFILE_STORAGE_KEYS.database)).toBe(migratedBytes);
|
||||
|
||||
const withoutRemoteRecovery = { ...rom, gearRecovery: undefined };
|
||||
const replaced = replaceCharactersForOwner("owner-one", [withoutRemoteRecovery], storage);
|
||||
expect(replaced[0]?.gearRecovery).toEqual(rom.gearRecovery);
|
||||
const repeated = replaceCharactersForOwner("owner-one", [withoutRemoteRecovery], storage);
|
||||
expect(repeated[0]?.gearRecovery).toEqual(rom.gearRecovery);
|
||||
});
|
||||
|
||||
it("rejects foreign, unresolved, and unusable RoM inventory updates without writing partial state", () => {
|
||||
const storage = new MemoryStorage();
|
||||
const rom = createCharacter({
|
||||
ownerId: "offline-rom",
|
||||
name: "Runesworn",
|
||||
categoryId: "rom",
|
||||
raceId: "rom-human",
|
||||
classId: "rom-priest",
|
||||
gender: "female",
|
||||
appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 },
|
||||
}, storage).character!;
|
||||
const item = (instanceId: string, contentCategory?: "wow" | "rom" | "coa", romNative?: Record<string, number>) => ({
|
||||
...(contentCategory ? { contentCategory } : {}),
|
||||
instanceId,
|
||||
itemId: 10412,
|
||||
itemKey: `gear:${instanceId}`,
|
||||
name: `Gear ${instanceId}`,
|
||||
quality: "rare" as const,
|
||||
inventorySlot: "waist",
|
||||
armorType: "leather",
|
||||
weaponType: null,
|
||||
itemLevel: 17,
|
||||
sourceItemLevel: 21,
|
||||
stats: {
|
||||
...EMPTY_ITEM_STATS,
|
||||
agility: 4,
|
||||
...(romNative === undefined ? {} : { romNative }),
|
||||
},
|
||||
quantity: 1,
|
||||
sourceBossKey: "source-boss",
|
||||
sourceBossName: "Source Boss",
|
||||
acquiredAt: 5_000,
|
||||
});
|
||||
const foreign = item("foreign", "wow");
|
||||
const unresolved = item("unresolved");
|
||||
const invalidNative = item("invalid-native", "rom", {});
|
||||
|
||||
for (const candidate of [foreign, unresolved, invalidNative]) {
|
||||
const before = storage.getItem(PROFILE_STORAGE_KEYS.database);
|
||||
expect(updateCharacterInventory(rom.ownerId, rom.id, [candidate], storage)).toBeNull();
|
||||
expect(storage.getItem(PROFILE_STORAGE_KEYS.database)).toBe(before);
|
||||
}
|
||||
|
||||
const valid = item("valid-rom", "rom", { strength: 3 });
|
||||
const updated = updateCharacterInventory(rom.ownerId, rom.id, [valid], storage)!;
|
||||
expect(updated.inventory).toMatchObject([{ instanceId: "valid-rom", contentCategory: "rom" }]);
|
||||
const beforeForeignEquipment = storage.getItem(PROFILE_STORAGE_KEYS.database);
|
||||
expect(updateCharacterEquipment(rom.ownerId, rom.id, {
|
||||
player: { waist: "foreign" },
|
||||
}, storage)).toBeNull();
|
||||
expect(storage.getItem(PROFILE_STORAGE_KEYS.database)).toBe(beforeForeignEquipment);
|
||||
expect(updateCharacterEquipment(rom.ownerId, rom.id, {
|
||||
player: { waist: "valid-rom" },
|
||||
}, storage)?.equipment).toEqual({ player: { waist: "valid-rom" } });
|
||||
});
|
||||
|
||||
it("persists account card copies independently of character creation and deletion", () => {
|
||||
const storage = new MemoryStorage();
|
||||
const offline = createOfflineSession();
|
||||
@@ -691,7 +852,7 @@ describe("local profile repository", () => {
|
||||
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.version).toBe(8);
|
||||
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);
|
||||
|
||||
+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;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PlayerSession } from "./types";
|
||||
import { EMPTY_ITEM_STATS } from "../game/itemStats";
|
||||
|
||||
function storage() {
|
||||
const values = new Map<string, string>();
|
||||
@@ -101,4 +102,65 @@ describe("account boss card integration", () => {
|
||||
expect(repository.loadAccountBossCards(session.ownerId).counts["wow:lady anacondra"]?.normal).toBe(10);
|
||||
await client.logoutOnlineSession(session);
|
||||
});
|
||||
|
||||
it("uploads cloud gear namespace quarantine once without changing card synchronization", async () => {
|
||||
const device = storage();
|
||||
vi.stubGlobal("window", { localStorage: device });
|
||||
const repository = await import("./accountRepository");
|
||||
const character = repository.createCharacter(draft).character!;
|
||||
const foreignItem = {
|
||||
contentCategory: "rom",
|
||||
instanceId: "foreign-rom-item",
|
||||
itemId: 10412,
|
||||
itemKey: "rom:foreign-item",
|
||||
name: "Foreign RoM item",
|
||||
quality: "rare",
|
||||
inventorySlot: "waist",
|
||||
armorType: "leather",
|
||||
weaponType: null,
|
||||
itemLevel: 17,
|
||||
sourceItemLevel: 21,
|
||||
stats: { ...EMPTY_ITEM_STATS, agility: 4 },
|
||||
quantity: 1,
|
||||
sourceBossKey: "rom:source-boss",
|
||||
sourceBossName: "Source Boss",
|
||||
acquiredAt: 5_000,
|
||||
};
|
||||
let cloudData: unknown = {
|
||||
version: 2,
|
||||
characters: [{
|
||||
...character,
|
||||
inventory: [foreignItem],
|
||||
equipment: { player: { waist: foreignItem.instanceId } },
|
||||
}],
|
||||
uiPreferences: {},
|
||||
bossCards: { counts: {}, lastClaims: {} },
|
||||
};
|
||||
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 client = await import("./onlineAccountClient");
|
||||
|
||||
const hydrated = await client.hydrateOnlineSession(session);
|
||||
expect(hydrated[0]?.inventory).toEqual([]);
|
||||
expect(hydrated[0]?.equipment).toEqual({});
|
||||
expect(hydrated[0]?.gearRecovery).toMatchObject({
|
||||
items: [{ item: foreignItem, reason: "foreign-category" }],
|
||||
equipment: [{ ownerId: "player", slot: "waist", instanceId: foreignItem.instanceId, reason: "foreign-category" }],
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(501);
|
||||
const firstPut = fetchMock.mock.calls.filter(([, init]) => init.method === "PUT");
|
||||
expect(firstPut).toHaveLength(1);
|
||||
expect(cloudData).toMatchObject({
|
||||
bossCards: { counts: {}, lastClaims: {} },
|
||||
characters: [{ inventory: [], gearRecovery: { items: [{ reason: "foreign-category" }] } }],
|
||||
});
|
||||
|
||||
await client.hydrateOnlineSession(session);
|
||||
await vi.advanceTimersByTimeAsync(501);
|
||||
expect(fetchMock.mock.calls.filter(([, init]) => init.method === "PUT")).toHaveLength(1);
|
||||
await client.logoutOnlineSession(session);
|
||||
});
|
||||
});
|
||||
@@ -150,6 +150,32 @@ export function retryCloudSave(): void {
|
||||
if (activeSession) queueCloudSave(activeSession.ownerId, listCharacters(activeSession.ownerId));
|
||||
}
|
||||
|
||||
function gearNamespaceSnapshot(value: unknown): string {
|
||||
if (!Array.isArray(value)) return "[]";
|
||||
return JSON.stringify(value.map((candidate) => {
|
||||
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) return candidate;
|
||||
const character = candidate as Record<string, unknown>;
|
||||
const rawInventory = character.inventory;
|
||||
const inventory = Array.isArray(rawInventory)
|
||||
? rawInventory.map((rawItem) => {
|
||||
if (!rawItem || typeof rawItem !== "object" || Array.isArray(rawItem)) return rawItem;
|
||||
const item = rawItem as Record<string, unknown>;
|
||||
return {
|
||||
instanceId: item.instanceId,
|
||||
contentCategory: Object.prototype.hasOwnProperty.call(item, "contentCategory")
|
||||
? item.contentCategory
|
||||
: "[missing-content-category]",
|
||||
};
|
||||
})
|
||||
: rawInventory == null ? [] : rawInventory;
|
||||
return {
|
||||
id: character.id,
|
||||
inventory,
|
||||
gearRecovery: character.gearRecovery ?? null,
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
export async function hydrateOnlineSession(session: PlayerSession): Promise<CharacterProfile[]> {
|
||||
if (!session.accessToken) return listCharacters(session.ownerId);
|
||||
activateOnlineSession(session);
|
||||
@@ -163,7 +189,12 @@ export async function hydrateOnlineSession(session: PlayerSession): Promise<Char
|
||||
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))) {
|
||||
const gearNamespaceMigrated = gearNamespaceSnapshot(cloud.data?.characters)
|
||||
!== gearNamespaceSnapshot(characters);
|
||||
if (
|
||||
gearNamespaceMigrated
|
||||
|| JSON.stringify(cloud.data?.bossCards) !== JSON.stringify(loadAccountBossCards(session.ownerId))
|
||||
) {
|
||||
queueCloudSave(session.ownerId, characters);
|
||||
}
|
||||
return characters;
|
||||
|
||||
@@ -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(7);
|
||||
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(8);
|
||||
});
|
||||
|
||||
it("locks selection to level ten and preserves separate bars through atomic swaps", () => {
|
||||
|
||||
@@ -22,6 +22,28 @@ export interface PlayerSession {
|
||||
accessToken?: string;
|
||||
}
|
||||
|
||||
export type GearRecoveryReason = "missing-category" | "foreign-category" | "invalid-native-payload";
|
||||
|
||||
/** Original rejected gear data kept with its character so migrations never erase it. */
|
||||
export interface GearRecoveryItem {
|
||||
readonly item: unknown;
|
||||
readonly reason: GearRecoveryReason;
|
||||
}
|
||||
|
||||
/** A former loadout reference for an item that is quarantined from active gear. */
|
||||
export interface GearRecoveryEquipmentReference {
|
||||
readonly ownerId: string;
|
||||
readonly slot: string;
|
||||
readonly instanceId: string;
|
||||
readonly reason: GearRecoveryReason;
|
||||
}
|
||||
|
||||
/** Character-scoped recovery for legacy or foreign-namespace gear. */
|
||||
export interface CharacterGearRecovery {
|
||||
readonly items: readonly GearRecoveryItem[];
|
||||
readonly equipment: readonly GearRecoveryEquipmentReference[];
|
||||
}
|
||||
|
||||
export interface CharacterProfile {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
@@ -50,6 +72,8 @@ export interface CharacterProfile {
|
||||
actionLoadouts?: Record<string, Record<string, string | null>>;
|
||||
inventory: InventoryItem[];
|
||||
equipment: EquipmentAssignments;
|
||||
/** Rejected gear remains recoverable here and is excluded from active inventory/loadouts. */
|
||||
gearRecovery?: CharacterGearRecovery;
|
||||
/** Legacy character log, read only during migration to the account collection. */
|
||||
bossCards?: BossCardCollection;
|
||||
/** Present on normalized and newly created profiles; optional for legacy callers. */
|
||||
|
||||
Reference in new issue
Block a user