Guard source gear namespaces and retain recoverable legacy records (#9)

This commit is contained in:
warren committed 2026-10-10 11:36:10 -04:00
1 parent ab37006932
commit e567910c04
23 files changed
+1203 -66

No files matched your search

+166 -5
View File
@@ -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
View File
@@ -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;
+62
View File
@@ -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);
});
});
+32 -1
View File
@@ -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;
+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(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", () => {
+24
View File
@@ -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. */
+73
View File
@@ -30,6 +30,8 @@ import { subscribeCombatPresentation, type CombatPresentationEvent } from "./com
import { loadCanonicalBenchmarkBuilds } from "./benchmark/benchmarkBuilds";
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
import { talentModifiersForRanks } from "./talentModifiers";
import type { InventoryItem } from "./lootTypes";
import { resolvedEquipmentStats } from "./heirloomEquipment";
import {
ROGUE_DEADLY_POISON_DOSE_TAG,
ROGUE_ENVENOM_BUFF_TAG,
@@ -63,6 +65,31 @@ function stableUnit(seed: string): number {
return (hash >>> 0) / 0x1_0000_0000;
}
function categoryGearFixture(
instanceId: string,
contentCategory: InventoryItem["contentCategory"],
romNative?: InventoryItem["stats"]["romNative"],
): InventoryItem {
return {
contentCategory,
instanceId,
itemId: 900001,
itemKey: instanceId,
name: instanceId,
quality: "rare",
inventorySlot: "back",
armorType: null,
weaponType: null,
itemLevel: 40,
sourceItemLevel: 40,
stats: { ...EMPTY_ITEM_STATS, stamina: 100, ...(romNative ? { romNative } : {}) },
quantity: 1,
sourceBossKey: "test-source",
sourceBossName: "Test Source",
acquiredAt: 1,
};
}
function paladinVindicationProcTime(
sourceActorId: string,
abilityId: string,
@@ -1215,6 +1242,52 @@ describe("combat store", () => {
expect(useCombatStore.getState().bossCards.counts["wow:lady anacondra"]?.normal).toBe(1);
});
it("derives the owner namespace from the source class and accepts only validated native gear rewards", () => {
const foreignWow = categoryGearFixture("foreign-wow", "wow");
const missingNative = categoryGearFixture("rom-without-native", "rom");
const missingCategory = categoryGearFixture("legacy-without-category", undefined);
const nullCategory = categoryGearFixture("explicit-null-category", null);
const nativeRom = categoryGearFixture("rom-native", "rom", { stamina: 4 });
const sourceProfile = {
classId: "rom-priest",
categoryId: "wow",
level: 40,
inventory: [foreignWow, missingNative, missingCategory, nullCategory, nativeRom],
equipment: { player: { back: foreignWow.instanceId, chest: missingNative.instanceId } },
} as const;
useCombatStore.getState().initializeCharacter(sourceProfile);
expect(useCombatStore.getState().contentCategoryId).toBe("rom");
expect(useCombatStore.getState().inventory.map((item) => item.instanceId)).toEqual([nativeRom.instanceId]);
expect(useCombatStore.getState().equipment).toEqual({});
expect(sourceProfile.inventory.map((item) => item.contentCategory)).toEqual(["wow", "rom", undefined, null, "rom"]);
const expectedTrainingOnlyStats = resolvedEquipmentStats({}, [], {
ownerId: "player",
ownerKind: "player",
classId: "rom-priest",
secondaryClassId: null,
specializationId: null,
role: "healer",
level: 40,
});
expect(useCombatStore.getState().gearStats).toEqual(expectedTrainingOnlyStats);
useCombatStore.getState().registerMob("gear-boundary", { name: "Gear Boundary", maxHealth: 1, hasLoot: true });
const nativeLoot = categoryGearFixture("rom-loot", "rom", { agility: 2 });
useCombatStore.setState((state) => {
const mob = state.mobs["gear-boundary"]!;
return { mobs: { ...state.mobs, "gear-boundary": {
...mob,
dead: true,
health: 0,
pendingLoot: [foreignWow, missingNative, missingCategory, nullCategory, nativeLoot],
} } };
});
expect(useCombatStore.getState().lootMob("gear-boundary", 10_000)).toBe(true);
expect(useCombatStore.getState().inventory.map((item) => item.instanceId))
.toEqual([nativeRom.instanceId, nativeLoot.instanceId]);
});
it("aggros every surviving packmate even when the pulled mob dies to the opening hit", () => {
for (const [id, maxHealth] of [["one-shot:a", 1], ["one-shot:b", 100], ["outsider", 100]] as const) {
useCombatStore.getState().registerMob(id, { name: id, maxHealth, xpReward: 0 });
+49 -18
View File
@@ -64,11 +64,13 @@ import { useDungeonSimulationStore } from "./dungeonSimulationStore";
import { useGameStore } from "./store";
import {
baseClassFor,
contentCategoryForClass,
isRomClassId,
type ClassId,
type GenderId,
type RaceId,
} from "../app/characterCatalog";
import type { ContentCategoryId } from "../app/contentCategories";
import { normalizeCharacterStatRuleset, type CharacterStatRuleset } from "./characterStatRuleset";
import { applyRomNativeStatModifiers, type RomNativeAdditiveChannel, type RomNativeAttributeKey,
type RomNativeDerivedStats, type RomNativeRateChannel, type RomNativeStatModifiers } from "./romNativeStats";
@@ -455,7 +457,7 @@ import { bossCardForSource } from "./bossCardCatalog";
import { collectBossCardDrop, emptyBossCardCollection, normalizeBossCardCollection, rollBossCards, type BossCardCollection, type BossCardDrop } from "./bossCards";
import { adaptRunewakerEnemyDamage } from "./runewakerCombatFormula";
import { channelAmountEffects, channelPulseAt, channelPulseCount } from "./combatChannelAmounts";
import { normalizeInventory, type InventoryItem } from "./lootTypes";
import { inventoryItemUsableForCategory, normalizeInventory, type InventoryItem } from "./lootTypes";
import {
PLAYER_EQUIPMENT_OWNER_ID,
activeEquipmentStatsForClass,
@@ -1405,6 +1407,7 @@ export interface ActorHealingRequest {
export interface CharacterCombatSeed {
readonly classId: ClassId;
readonly categoryId?: ContentCategoryId;
readonly statRuleset?: CharacterStatRuleset;
readonly hunterPetFamily?: Wow335HunterPetFamily;
readonly specializationId?: string | null;
@@ -1570,6 +1573,7 @@ export interface EnemyPeriodicCombatEffect {
export interface CombatState {
classId: ClassId;
statRuleset: CharacterStatRuleset;
contentCategoryId: ContentCategoryId;
readonly romNativeRecoveryClock?: RomNativeRecoveryClock;
hunterPetFamily: Wow335HunterPetFamily;
specializationId: string | null;
@@ -7799,6 +7803,7 @@ function damageMobInWork(
const testing = useGameStore.getState().gmTestMode;
const canAward = killed && !mob.experienceGranted && !testing;
const beforeLevel = work.progression.level;
const ownerCategory = useCombatStore.getState().contentCategoryId;
const hasTreasureHunter = work.auras.some((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& (aura.expiresAt === null || aura.expiresAt > now)
@@ -7807,9 +7812,10 @@ function damageMobInWork(
const pendingLoot = testing ? [] : killed && mob.lootSourceId
? rollBossLoot(mob.lootSourceId, beforeLevel, {
now,
ownerCategory,
bonusItemChance: romRogueTreasureHunterLootChance(mob.bonusLootChance, hasTreasureHunter),
})
: mob.pendingLoot;
: mob.pendingLoot.filter((item) => inventoryItemUsableForCategory(item, ownerCategory));
const card = killed && mob.boss && !testing
? bossCardForSource(mob.lootSourceId ?? mob.id, useGameStore.getState().activeDungeonId, mob.name)
: null;
@@ -11786,6 +11792,7 @@ function combatActorSnapshots(state: CombatState): Readonly<Record<string, Comba
function initialState() {
const classId: ClassId = "priest";
const statRuleset: CharacterStatRuleset = "wow";
const contentCategoryId: ContentCategoryId = "wow";
const raceId: RaceId = "human";
const gender: GenderId = "male";
const level = 1;
@@ -11805,6 +11812,7 @@ function initialState() {
return {
classId,
statRuleset,
contentCategoryId,
hunterPetFamily: "wolf" as const,
specializationId: null,
gearRole: "healer" as PartyRole,
@@ -11893,9 +11901,13 @@ export const useCombatStore = create<CombatState>((set, get) => ({
: [];
const talentModifiers = characterStatModifiers(classId, progression.level, talentRanks);
const abilities = abilitiesForCharacter(classId, secondaryClassId);
const inventory = normalizeInventory(profile.inventory);
const equipment = normalizeEquipment(profile.equipment, inventory);
const gearStats = specializationId !== null || classId.startsWith("rom-") ? resolvedEquipmentStats(equipment, inventory, {
// The selected source class owns the character namespace; dungeon and
// untrusted seed metadata cannot change which game's items are usable.
const contentCategoryId = contentCategoryForClass(classId);
const inventory = normalizeInventory(profile.inventory, { ownerCategory: contentCategoryId });
const categoryInventory = inventory.filter((item) => inventoryItemUsableForCategory(item, contentCategoryId));
const equipment = normalizeEquipment(profile.equipment, inventory, contentCategoryId);
const gearStats = specializationId !== null || classId.startsWith("rom-") ? resolvedEquipmentStats(equipment, categoryInventory, {
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
ownerKind: "player",
classId,
@@ -11903,7 +11915,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
specializationId,
role: gearRole,
level: progression.level,
}) : activeEquipmentStatsForClass(equipment, inventory, PLAYER_EQUIPMENT_OWNER_ID, classId);
}) : activeEquipmentStatsForClass(equipment, inventory, PLAYER_EQUIPMENT_OWNER_ID, classId, contentCategoryId);
const maxHealth = talentAdjustedMaxHealth(
classId,
progression.level,
@@ -11952,6 +11964,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
raceId,
gender,
statRuleset,
contentCategoryId,
romNativeRecoveryClock: undefined,
level: progression.level,
xp: progression.xp,
@@ -11978,7 +11991,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
abilities,
talentRanks,
),
inventory,
inventory: categoryInventory,
bossCards: normalizeBossCardCollection(profile.bossCards),
equipment,
gearStats,
@@ -11988,6 +12001,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
},
refreshResolvedGear: () => set((state) => {
const categoryInventory = state.inventory.filter((item) => inventoryItemUsableForCategory(item, state.contentCategoryId));
const context: HeirloomContext = {
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
ownerKind: "player",
@@ -11998,8 +12012,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
level: state.level,
};
const gearStats = state.specializationId !== null || state.classId.startsWith("rom-")
? resolvedEquipmentStats(state.equipment, state.inventory, context)
: activeEquipmentStatsForClass(state.equipment, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
? resolvedEquipmentStats(state.equipment, categoryInventory, context)
: activeEquipmentStatsForClass(state.equipment, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId, state.contentCategoryId);
const maxHealth = auraAdjustedPlayerMaxHealth(
state.classId,
state.level,
@@ -12259,9 +12273,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
lootMob: (id, now = combatNow()) => {
const mob = get().mobs[id];
if (!mob || !mob.dead || !mob.hasLoot || mob.looted || mob.despawned) return false;
const ownerCategory = get().contentCategoryId;
const acceptedLoot = mob.pendingLoot.filter((item) => inventoryItemUsableForCategory(item, ownerCategory));
const corpseDespawnAt = Math.max(now, (mob.diedAt ?? now) + MINIMUM_CORPSE_VISIBLE_MS);
const lootMessage = mob.pendingLoot.length
? `Looted ${mob.pendingLoot.map((item) => `${item.name}: ${itemStatSummary(item.stats)}`).join(", ")}.`
const lootMessage = acceptedLoot.length
? `Looted ${acceptedLoot.map((item) => `${item.name}: ${itemStatSummary(item.stats)}`).join(", ")}.`
: `Looted ${mob.name}.`;
set((state) => {
const bossReward = mob.boss
@@ -12269,13 +12285,13 @@ export const useCombatStore = create<CombatState>((set, get) => ({
id: ++bossLootRewardSequence,
bossId: id,
bossName: mob.name,
items: mob.pendingLoot,
items: acceptedLoot,
...(mob.bossCardDrop ? { cards: mob.bossCardDrop } : {}),
acquiredAt: now,
} satisfies BossLootReward
: null;
return {
inventory: [...state.inventory, ...mob.pendingLoot],
inventory: [...state.inventory, ...acceptedLoot],
bossCards: mob.bossCardDrop ? collectBossCardDrop(state.bossCards, mob.bossCardDrop) : state.bossCards,
mobs: {
...state.mobs,
@@ -12332,6 +12348,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
{
secondaryClassId: ownerId === PLAYER_EQUIPMENT_OWNER_ID ? state.secondaryClassId : ownerMember?.secondaryClassId,
level: ownerId === PLAYER_EQUIPMENT_OWNER_ID ? state.level : ownerMember?.level,
ownerCategory: ownerId === PLAYER_EQUIPMENT_OWNER_ID
? state.contentCategoryId
: usePartyStore.getState().categoryId ?? contentCategoryForClass(ownerClassId),
},
);
if (!change.ok) {
@@ -12342,7 +12361,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const ownerName = ownerId === PLAYER_EQUIPMENT_OWNER_ID
? "You"
: ownerMember?.name ?? "Companion";
const gearStats = state.specializationId !== null || state.classId.startsWith("rom-") ? resolvedEquipmentStats(change.assignments, state.inventory, {
const categoryInventory = state.inventory.filter((candidate) => inventoryItemUsableForCategory(
candidate,
state.contentCategoryId,
));
const gearStats = state.specializationId !== null || state.classId.startsWith("rom-") ? resolvedEquipmentStats(change.assignments, categoryInventory, {
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
ownerKind: "player",
classId: state.classId,
@@ -12350,7 +12373,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
specializationId: state.specializationId,
role: state.gearRole,
level: state.level,
}) : activeEquipmentStatsForClass(change.assignments, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
}) : activeEquipmentStatsForClass(change.assignments, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId, state.contentCategoryId);
const maxHealth = auraAdjustedPlayerMaxHealth(
state.classId,
state.level,
@@ -12403,7 +12426,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
),
feedback: nextFeedback("success", `${ownerName} equipped ${item?.name ?? "the item"}.`),
});
usePartyStore.getState().syncEquipment(change.assignments, state.inventory);
const partyCategory = usePartyStore.getState().categoryId;
usePartyStore.getState().syncEquipment(
change.assignments,
state.inventory.filter((candidate) => inventoryItemUsableForCategory(candidate, partyCategory)),
);
return true;
},
@@ -12411,7 +12438,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const state = get();
const next = unequipInventorySlot(state.equipment, ownerId, slot);
if (next === state.equipment) return false;
const gearStats = state.specializationId !== null || state.classId.startsWith("rom-") ? resolvedEquipmentStats(next, state.inventory, {
const categoryInventory = state.inventory.filter((candidate) => inventoryItemUsableForCategory(
candidate,
state.contentCategoryId,
));
const gearStats = state.specializationId !== null || state.classId.startsWith("rom-") ? resolvedEquipmentStats(next, categoryInventory, {
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
ownerKind: "player",
classId: state.classId,
@@ -12419,7 +12450,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
specializationId: state.specializationId,
role: state.gearRole,
level: state.level,
}) : activeEquipmentStatsForClass(next, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
}) : activeEquipmentStatsForClass(next, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId, state.contentCategoryId);
const maxHealth = auraAdjustedPlayerMaxHealth(
state.classId,
state.level,
+69
View File
@@ -6,6 +6,7 @@ import {
equipInventoryItem,
equipmentItemLevel,
equipmentStats,
activeEquipmentStatsForClass,
normalizeEquipment,
unequipInventorySlot,
} from "./equipment";
@@ -26,6 +27,7 @@ function item(
itemId: 1,
itemKey: instanceId,
name: instanceId,
contentCategory: "wow",
quality: "uncommon",
inventorySlot,
armorType: null,
@@ -66,6 +68,56 @@ describe("equipment assignments", () => {
expect(classCanEquipItem("warlock", bow)).toBe(false);
});
it("rejects foreign, unresolved, and non-native RoM gear before changing assignments", () => {
const wowDagger = item("wow-dagger", "one-hand", { weaponType: "dagger" });
const nativeRomDagger = item("rom-dagger", "one-hand", {
contentCategory: "rom",
weaponType: "dagger",
stats: stats({ strength: 12, romNative: { strength: 12 } }),
});
const unresolvedRomDagger = item("unresolved-rom-dagger", "one-hand", {
contentCategory: null,
weaponType: "dagger",
stats: stats({ strength: 12 }),
});
const foreign = equipInventoryItem({ player: { back: "kept" } }, [wowDagger], "player", "rom-priest", wowDagger.instanceId);
expect(foreign).toEqual({
ok: false,
assignments: { player: { back: "kept" } },
reason: "That item belongs to a different game category.",
});
expect(equipInventoryItem({}, [nativeRomDagger], "player", "rom-priest", nativeRomDagger.instanceId).ok).toBe(true);
expect(equipInventoryItem({}, [unresolvedRomDagger], "player", "rom-priest", unresolvedRomDagger.instanceId).ok).toBe(false);
expect(normalizeEquipment({ player: { "main-hand": wowDagger.instanceId } }, [wowDagger], "rom")).toEqual({});
const forgedCategory = equipInventoryItem(
{ player: { back: "kept" } },
[wowDagger],
"player",
"rom-priest",
wowDagger.instanceId,
{ ownerCategory: "wow" },
);
expect(forgedCategory.ok).toBe(false);
expect(forgedCategory.assignments).toEqual({ player: { back: "kept" } });
expect(classCanEquipItem("rom-priest", wowDagger, { ownerCategory: "wow" })).toBe(false);
});
it("does not apply foreign or unresolved items in active equipment stat reads", () => {
const romItem = item("rom-native", "back", {
contentCategory: "rom",
stats: stats({ stamina: 20, romNative: { stamina: 20 } }),
});
const wowItem = item("wow-foreign", "head", { stats: stats({ stamina: 90 }) });
const assignments = { player: { back: romItem.instanceId, head: wowItem.instanceId } };
expect(activeEquipmentStatsForClass(assignments, [romItem, wowItem], "player", "rom-priest", "rom"))
.toMatchObject({ stamina: 20 });
expect(activeEquipmentStatsForClass(assignments, [romItem, wowItem], "player", "rom-priest", "coa"))
.toMatchObject({ stamina: 0 });
expect(activeEquipmentStatsForClass(assignments, [romItem, wowItem], "player", "rom-priest", "wow"))
.toEqual(EMPTY_ITEM_STATS);
});
it("handles two-handed conflicts, unequipping, normalization, and item level", () => {
const staff = item("staff", "two-hand", { weaponType: "staff", itemLevel: 18 });
const shield = item("shield", "off-hand", { armorType: "shield", itemLevel: 14 });
@@ -126,4 +178,21 @@ describe("equipment assignments", () => {
expect(deriveRomNativeStats({ classId: "rom-warrior", level: 1, gear: unequipped.romNative }).health.max)
.toBe(withoutGear.health.max);
});
it("keeps CoA-tagged direct stats out of the RoM native projection", () => {
const coaGear = item("coa-gear", "chest", {
contentCategory: "coa",
armorType: "plate",
stats: stats({ stamina: 7, strength: 3 }),
});
const active = activeEquipmentStatsForClass(
{ player: { chest: coaGear.instanceId } },
[coaGear],
"player",
"guardian",
"coa",
);
expect(active).toMatchObject({ stamina: 7, strength: 3 });
expect(active.romNative).toBeUndefined();
});
});
+42 -4
View File
@@ -1,9 +1,11 @@
import {
baseClassFor,
classById,
contentCategoryForClass,
type BaseClassId,
type ClassId,
} from "../app/characterCatalog";
import type { ContentCategoryId } from "../app/contentCategories";
import {
EMPTY_ITEM_STATS,
ITEM_STAT_KEYS,
@@ -11,7 +13,7 @@ import {
type NumericItemStatKey,
type WeaponStats,
} from "./itemStats";
import type { InventoryItem } from "./lootTypes";
import { inventoryItemUsableForCategory, type InventoryItem } from "./lootTypes";
import { romNativeGearFromSharedDirectStats, sumRomNativeGearStats } from "./romNativeStatsTypes";
export const PLAYER_EQUIPMENT_OWNER_ID = "player";
@@ -48,6 +50,8 @@ export interface EquipmentChange {
export interface EquipmentClassContext {
readonly secondaryClassId?: ClassId | null;
readonly level?: number;
/** Saved owner category; falls back to the owner's class category when absent. */
readonly ownerCategory?: ContentCategoryId;
}
const equipmentSlotSet = new Set<string>(EQUIPMENT_SLOTS);
@@ -82,7 +86,15 @@ export function equipmentSlotForItem(item: InventoryItem): EquipmentSlot | null
return equipmentSlotSet.has(slot) ? slot as EquipmentSlot : null;
}
export function classCanEquipItem(classId: ClassId, item: InventoryItem, context: EquipmentClassContext = {}): boolean {
/**
* Internal shape-only predicate for synthetic catalog scoring. Callers must
* still use classCanEquipItem before assigning or applying an item.
*/
export function classItemShapeFitsEquipmentRules(
classId: ClassId,
item: InventoryItem,
context: EquipmentClassContext = {},
): boolean {
const slot = equipmentSlotForItem(item);
if (!slot) return false;
const level = context.level ?? Number.POSITIVE_INFINITY;
@@ -111,6 +123,13 @@ export function classCanEquipItem(classId: ClassId, item: InventoryItem, context
return true;
}
export function classCanEquipItem(classId: ClassId, item: InventoryItem, context: EquipmentClassContext = {}): boolean {
const classCategory = contentCategoryForClass(classId);
if (context.ownerCategory !== undefined && context.ownerCategory !== classCategory) return false;
return inventoryItemUsableForCategory(item, classCategory)
&& classItemShapeFitsEquipmentRules(classId, item, context);
}
function mutableAssignments(assignments: EquipmentAssignments): Record<string, EquipmentLoadout> {
return Object.fromEntries(
Object.entries(assignments).map(([ownerId, loadout]) => [ownerId, { ...loadout }]),
@@ -141,6 +160,14 @@ export function equipInventoryItem(
): EquipmentChange {
const item = inventory.find((candidate) => candidate.instanceId === instanceId);
if (!item) return { ok: false, assignments, reason: "That item is no longer in your inventory." };
const ownerCategory = contentCategoryForClass(ownerClassId);
if ((classContext.ownerCategory !== undefined && classContext.ownerCategory !== ownerCategory)
|| !inventoryItemUsableForCategory(item, ownerCategory)) {
const reason = ownerCategory === "rom" && item.contentCategory === "rom"
? "That item has no validated RuneWaker-native gear values."
: "That item belongs to a different game category.";
return { ok: false, assignments, reason };
}
const slot = equipmentSlotForItem(item);
if (!slot) return { ok: false, assignments, reason: `${item.name} does not use an equippable slot.` };
if (!classCanEquipItem(ownerClassId, item, classContext)) {
@@ -206,6 +233,7 @@ export function equipmentItemsForOwner(
export function normalizeEquipment(
value: unknown,
inventory: readonly InventoryItem[],
ownerCategory?: ContentCategoryId,
): EquipmentAssignments {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
const itemById = new Map(inventory.map((item) => [item.instanceId, item]));
@@ -219,6 +247,7 @@ export function normalizeEquipment(
if (!equipmentSlotSet.has(rawSlot) || typeof rawInstanceId !== "string" || assigned.has(rawInstanceId)) continue;
const item = itemById.get(rawInstanceId);
if (!item || equipmentSlotForItem(item) !== rawSlot) continue;
if (ownerCategory !== undefined && !inventoryItemUsableForCategory(item, ownerCategory)) continue;
loadout[rawSlot as EquipmentSlot] = rawInstanceId;
assigned.add(rawInstanceId);
}
@@ -260,7 +289,11 @@ export function equipmentStats(
const stats = instanceId ? itemById.get(instanceId)?.stats : null;
if (!stats) continue;
for (const key of ITEM_STAT_KEYS) numeric[key] += stats[key];
const nativePayload = stats.romNative ?? romNativeGearFromSharedDirectStats(stats);
const contentCategory = itemById.get(instanceId)?.contentCategory;
const nativePayload = stats.romNative
?? (contentCategory === "rom" || contentCategory === "coa"
? undefined
: romNativeGearFromSharedDirectStats(stats));
if (nativePayload) nativePayloads.push(nativePayload);
if (stats.meleeWeapon) meleeWeapon = stats.meleeWeapon;
if (stats.rangedWeapon) rangedWeapon = stats.rangedWeapon;
@@ -275,14 +308,19 @@ export function activeEquipmentStatsForClass(
inventory: readonly InventoryItem[],
ownerId: string,
classId: ClassId,
ownerCategory?: ContentCategoryId,
): ItemStats {
const classCategory = contentCategoryForClass(classId);
if (ownerCategory !== undefined && ownerCategory !== classCategory) return EMPTY_ITEM_STATS;
const loadout = assignments[ownerId];
if (!loadout) return EMPTY_ITEM_STATS;
const itemById = new Map(inventory.map((item) => [item.instanceId, item]));
const activeAssignments: EquipmentAssignments = {
[ownerId]: Object.fromEntries(Object.entries(loadout).filter(([, instanceId]) => {
const item = instanceId ? itemById.get(instanceId) : null;
return Boolean(item && classCanEquipItem(classId, item));
return Boolean(item
&& inventoryItemUsableForCategory(item, classCategory)
&& classCanEquipItem(classId, item));
})),
};
return equipmentStats(activeAssignments, inventory, ownerId);
+65 -4
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import type { ClassId } from "../app/characterCatalog";
import { contentCategoryForClass, type ClassId } from "../app/characterCatalog";
import { classCanEquipItem, equipmentSlotForItem } from "./equipment";
import {
compareEquipmentUpgrade,
@@ -7,6 +7,7 @@ import {
resolvedAverageItemLevel,
resolvedEquipmentItems,
resolvedEquipmentLoadout,
resolvedEquipmentStats,
type HeirloomContext,
} from "./heirloomEquipment";
import { EMPTY_ITEM_STATS, itemStatsFor } from "./itemStats";
@@ -33,8 +34,14 @@ function context(profile: SpecializationDefinition, level: number, ownerKind: "p
};
}
function catalogItem(template: LootCatalogItem, itemLevel: number, suffix = "test"): InventoryItem {
function catalogItem(
template: LootCatalogItem,
itemLevel: number,
suffix = "test",
overrides: Partial<InventoryItem> = {},
): InventoryItem {
return {
contentCategory: "wow",
instanceId: `${template.itemId}:${itemLevel}:${suffix}`,
itemId: template.itemId,
itemKey: template.itemKey,
@@ -50,6 +57,7 @@ function catalogItem(template: LootCatalogItem, itemLevel: number, suffix = "tes
sourceBossKey: "test-boss",
sourceBossName: "Test Boss",
acquiredAt: 1,
...overrides,
};
}
@@ -77,6 +85,59 @@ describe("specialization-aware heirlooms", () => {
expect(loadout["off-hand"]?.armorType).toBe("shield");
});
it("keeps RoM training defaults when foreign, unresolved, or non-native assigned items are present", () => {
const romContext: HeirloomContext = {
ownerId: "rom-owner",
ownerKind: "player",
classId: "rom-priest",
specializationId: null,
role: "healer",
level: 40,
};
const chestTemplate = templates.find((template) => template.inventorySlot === "chest" && template.armorType === "cloth")!;
const training = resolvedEquipmentLoadout({}, [], romContext).chest!;
expect(training).toMatchObject({
contentCategory: "rom",
quality: "heirloom",
sourceBossKey: "heirloom",
sourceBossName: "Training Armory",
});
expect(training.stats.romNative).toBeDefined();
const trainingSlotWithoutReference = resolvedEquipmentLoadout({}, [], romContext)["off-hand"]!;
expect(trainingSlotWithoutReference.stats.romNative).toBeUndefined();
expect(equipmentStatsForItems([trainingSlotWithoutReference]).romNative).toBeDefined();
const copiedTraining = JSON.parse(JSON.stringify(trainingSlotWithoutReference)) as InventoryItem;
expect(copiedTraining).toMatchObject({
contentCategory: "rom",
instanceId: trainingSlotWithoutReference.instanceId,
sourceBossName: "Training Armory",
});
expect(equipmentStatsForItems([copiedTraining]).romNative).toBeUndefined();
expect(classCanEquipItem("rom-priest", copiedTraining)).toBe(false);
const badAssignedItems = [
catalogItem(chestTemplate, 40, "foreign-wow"),
catalogItem(chestTemplate, 40, "unresolved", { contentCategory: null }),
catalogItem(chestTemplate, 40, "rom-without-native", { contentCategory: "rom" }),
];
for (const item of badAssignedItems) {
expect(classCanEquipItem("rom-priest", item)).toBe(false);
const loadout = resolvedEquipmentLoadout({ [romContext.ownerId]: { chest: item.instanceId } }, [item], romContext);
expect(loadout.chest).toMatchObject({ sourceBossName: "Training Armory", contentCategory: "rom" });
expect(resolvedEquipmentStats({ [romContext.ownerId]: { chest: item.instanceId } }, [item], romContext))
.toEqual(resolvedEquipmentStats({}, [], romContext));
}
const nativeItem = catalogItem(chestTemplate, 40, "rom-native", {
contentCategory: "rom",
stats: itemStatsFor({ ...chestTemplate, itemLevel: 40, romNative: { stamina: 3 } }),
});
expect(classCanEquipItem("rom-priest", nativeItem)).toBe(true);
expect(resolvedEquipmentLoadout({ [romContext.ownerId]: { chest: nativeItem.instanceId } }, [nativeItem], romContext).chest)
.toBe(nativeItem);
});
it("allocates only useful archetype stats", () => {
const protection = SPECIALIZATION_DEFINITIONS.find((definition) => definition.id === "warrior:protection")!;
const holy = SPECIALIZATION_DEFINITIONS.find((definition) => definition.id === "priest:holy")!;
@@ -118,10 +179,10 @@ describe("specialization-aware heirlooms", () => {
}
});
it("opens a real item-level-85 upgrade path for every gear profile at level 80", () => {
it("opens an item-level-85 catalog upgrade path for every WoW gear profile at level 80", () => {
expect(scaledLootItemLevel(79)).toBe(79);
expect(scaledLootItemLevel(80)).toBe(85);
for (const profile of SPECIALIZATION_DEFINITIONS) {
for (const profile of SPECIALIZATION_DEFINITIONS.filter((candidate) => contentCategoryForClass(candidate.classId) === "wow")) {
const loadout = resolvedEquipmentLoadout({}, [], context(profile, 80));
const upgrades = compatibleItems(profile.classId, 85)
.filter((item) => compareEquipmentUpgrade(item, loadout, profile, context(profile, 80)).isUpgrade);
+19 -2
View File
@@ -1,10 +1,12 @@
import {
contentCategoryForClass,
classById,
isRomClassId,
type ClassId,
} from "../app/characterCatalog";
import {
classCanEquipItem,
classItemShapeFitsEquipmentRules,
EQUIPMENT_SLOTS,
equipmentSlotForItem,
type EquipmentAssignments,
@@ -94,6 +96,11 @@ const ARMOR_SLOTS = new Set<EquipmentSlot>([
const CATALOG_ITEMS = LOOT_CATALOG.bosses.flatMap((boss) => boss.items);
const itemCache = new Map<string, InventoryItem>();
// Training placeholders are generated internally and are never serialized as
// earned inventory. Keep their legacy simulator projection private so it can
// preserve the existing RoM training profile without making direct WoW stats
// valid on a caller-supplied RoM item.
const romTrainingItems = new WeakSet<InventoryItem>();
function effectiveLevel(context: HeirloomContext): number {
const cap = context.ownerKind === "party" ? PARTY_HEIRLOOM_LEVEL_CAP : PLAYER_HEIRLOOM_LEVEL_CAP;
@@ -181,6 +188,7 @@ function weaponStats(slot: EquipmentSlot, inventorySlot: string, weaponType: str
function syntheticCatalogItem(template: LootCatalogItem, itemLevel: number): InventoryItem {
return {
contentCategory: "wow",
instanceId: `catalog:${template.itemId}`,
itemId: template.itemId,
itemKey: template.itemKey,
@@ -203,7 +211,9 @@ function highestCatalogScore(classId: ClassId, slot: EquipmentSlot, level: numbe
let highest = 0;
for (const template of CATALOG_ITEMS) {
const candidate = syntheticCatalogItem(template, level);
if (equipmentSlotForItem(candidate) !== slot || !classCanEquipItem(classId, candidate)) continue;
// Catalog comparison is an internal shape-only specialization baseline.
// These WoW templates never enter inventory or an equipment assignment.
if (equipmentSlotForItem(candidate) !== slot || !classItemShapeFitsEquipmentRules(classId, candidate)) continue;
highest = Math.max(highest, specializationScore(candidate.stats, profile));
}
return highest;
@@ -286,6 +296,7 @@ function heirloomItem(
// internal quality so this label change cannot alter saved builds or budgets.
const romTrainingGear = isRomClassId(context.classId);
const result: InventoryItem = Object.freeze({
contentCategory: contentCategoryForClass(context.classId),
instanceId: `heirloom:${context.ownerId}:${profile.id}:${slot}`,
itemId: 9_000_000 + stableNumber % 900_000,
itemKey: `heirloom-${profile.id.replace(/:/g, "-")}-${slot}`,
@@ -302,6 +313,7 @@ function heirloomItem(
sourceBossName: romTrainingGear ? "Training Armory" : "Heirloom Armory",
acquiredAt: 0,
});
if (romTrainingGear) romTrainingItems.add(result);
itemCache.set(cacheKey, result);
return result;
}
@@ -348,7 +360,12 @@ export function equipmentStatsForItems(items: readonly InventoryItem[]): ItemSta
const nativePayloads = [] as NonNullable<ItemStats["romNative"]>[];
for (const item of items) {
for (const key of ITEM_STAT_KEYS) numeric[key] += item.stats[key];
const nativePayload = item.stats.romNative ?? romNativeGearFromSharedDirectStats(item.stats);
const nativePayload = item.stats.romNative
?? (romTrainingItems.has(item)
|| item.contentCategory === undefined
|| item.contentCategory === "wow"
? romNativeGearFromSharedDirectStats(item.stats)
: undefined);
if (nativePayload) nativePayloads.push(nativePayload);
if (item.stats.meleeWeapon) meleeWeapon = item.stats.meleeWeapon;
if (item.stats.rangedWeapon) rangedWeapon = item.stats.rangedWeapon;
+8
View File
@@ -35,6 +35,7 @@ describe("SQL-generated boss loot catalog", () => {
expect(first).toHaveLength(1);
expect(first[0]).toMatchObject({
contentCategory: "wow",
itemId: 5404,
name: "Serpent's Shoulders",
itemLevel: 85,
@@ -61,6 +62,13 @@ describe("SQL-generated boss loot catalog", () => {
expect(rollBossLoot("not-a-boss", 20)).toEqual([]);
});
it("selects source-tagged WoW drops only for a WoW-owned character", () => {
expect(rollBossLoot("lady-anacondra", 80, { random: () => 0, ownerCategory: "wow" })[0].contentCategory)
.toBe("wow");
expect(rollBossLoot("lady-anacondra", 80, { random: () => 0, ownerCategory: "rom" })).toEqual([]);
expect(rollBossLoot("lady-anacondra", 80, { random: () => 0, ownerCategory: "coa" })).toEqual([]);
});
it("adds at most one bonus weighted item without changing normal rolls", () => {
const normal = rollBossLoot("lady-anacondra", 80, {
random: () => 0,
+6 -1
View File
@@ -1,4 +1,5 @@
import generatedLootCatalog from "./generated/lootCatalog.json";
import type { ContentCategoryId } from "../app/contentCategories";
import {
itemStatsFor,
type SourceItemStats,
@@ -48,6 +49,8 @@ export interface LootRollOptions {
readonly now?: number;
/** A single additional weighted item roll after the authored boss rolls. */
readonly bonusItemChance?: number;
/** Saved character/party category; source identity is never inferred from dungeon. */
readonly ownerCategory?: ContentCategoryId;
}
export const LOOT_CATALOG = generatedLootCatalog as LootCatalog;
@@ -104,6 +107,7 @@ export function rollBossLoot(
if (!table?.items.length) {
return rollRunewakerBossLoot(bossKey, playerLevel, options);
}
if (options.ownerCategory !== undefined && options.ownerCategory !== "wow") return [];
const random = options.random ?? Math.random;
const acquiredAt = options.now ?? Date.now();
const rolls = randomInteger(table.rollsMin, table.rollsMax, random);
@@ -117,6 +121,7 @@ export function rollBossLoot(
itemInstanceSequence += 1;
const scaledItemLevel = template.scalable ? itemLevel : template.sourceItemLevel;
drops.push({
contentCategory: "wow",
instanceId: `${bossKey}:${acquiredAt}:${itemInstanceSequence}`,
itemId: template.itemId,
itemKey: template.itemKey,
@@ -154,5 +159,5 @@ export function rollBossLoot(
addWeightedDrop();
}
return allowedLootDrops(drops, playerLevel);
return allowedLootDrops(drops, playerLevel, options.ownerCategory);
}
+10 -4
View File
@@ -1,8 +1,14 @@
import type { InventoryItem } from "./lootTypes";
import type { ContentCategoryId } from "../app/contentCategories";
import { inventoryItemUsableForCategory, type InventoryItem } from "./lootTypes";
/** Leveling dungeon bosses award cards through the separate boss-card path. */
export function allowedLootDrops(drops: readonly InventoryItem[], playerLevel: number): readonly InventoryItem[] {
return Number.isFinite(playerLevel) && playerLevel >= 80
export function allowedLootDrops(
drops: readonly InventoryItem[],
playerLevel: number,
ownerCategory?: ContentCategoryId,
): readonly InventoryItem[] {
if (!Number.isFinite(playerLevel) || playerLevel < 80) return [];
return ownerCategory === undefined
? drops
: [];
: drops.filter((item) => inventoryItemUsableForCategory(item, ownerCategory));
}
+55
View File
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { EMPTY_ITEM_STATS } from "./itemStats";
import { inventoryItemMatchesCategory, inventoryItemUsableForCategory, normalizeInventory } from "./lootTypes";
function savedItem(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
instanceId: "gear:1",
itemId: 1,
itemKey: "gear-1",
name: "Saved Gear",
quality: "rare",
inventorySlot: "back",
armorType: null,
weaponType: null,
itemLevel: 20,
sourceItemLevel: 20,
stats: EMPTY_ITEM_STATS,
quantity: 1,
sourceBossKey: "source-boss",
sourceBossName: "Source Boss",
acquiredAt: 1,
...overrides,
};
}
describe("inventory item game-category metadata", () => {
it("migrates only category-less WoW-owned legacy records", () => {
expect(normalizeInventory([savedItem()], { ownerCategory: "wow" })[0].contentCategory).toBe("wow");
expect(normalizeInventory([savedItem()], { ownerCategory: "rom" })[0].contentCategory).toBeNull();
expect(normalizeInventory([savedItem()], { ownerCategory: "coa" })[0].contentCategory).toBeNull();
expect(normalizeInventory([savedItem()])[0].contentCategory).toBeNull();
});
it("preserves explicit category identity, including mismatches and unresolved null", () => {
expect(normalizeInventory([savedItem({ contentCategory: "rom" })], { ownerCategory: "wow" })[0].contentCategory)
.toBe("rom");
expect(normalizeInventory([savedItem({ contentCategory: "coa" })], { ownerCategory: "rom" })[0].contentCategory)
.toBe("coa");
expect(normalizeInventory([savedItem({ contentCategory: null })], { ownerCategory: "wow" })[0].contentCategory)
.toBeNull();
});
it("requires an explicit category and validated native payload for usable RoM gear", () => {
const romNative = normalizeInventory([savedItem({
contentCategory: "rom",
stats: { ...EMPTY_ITEM_STATS, romNative: { strength: 7 } },
})])[0];
const romWithoutNative = normalizeInventory([savedItem({ contentCategory: "rom" })])[0];
expect(inventoryItemMatchesCategory(romNative, "rom")).toBe(true);
expect(inventoryItemMatchesCategory(romNative, undefined)).toBe(false);
expect(inventoryItemUsableForCategory(romNative, "rom")).toBe(true);
expect(inventoryItemUsableForCategory(romWithoutNative, "rom")).toBe(false);
expect(inventoryItemUsableForCategory(romNative, "wow")).toBe(false);
});
});
+40 -1
View File
@@ -4,11 +4,15 @@ import {
type ItemQuality,
type ItemStats,
} from "./itemStats";
import { normalizeRomNativeGearStats } from "./romNativeStatsTypes";
import type { ContentCategoryId } from "../app/contentCategories";
export type { ItemQuality, ItemStats } from "./itemStats";
/** A concrete item owned by a character, distinct from its SQL item template. */
export interface InventoryItem {
/** Source-game namespace. Missing legacy metadata is normalized with the owning profile. */
readonly contentCategory?: ContentCategoryId | null;
readonly instanceId: string;
readonly itemId: number;
readonly itemKey: string;
@@ -31,8 +35,37 @@ export interface InventoryItem {
const ITEM_QUALITIES = new Set<ItemQuality>(["common", "uncommon", "rare", "epic", "legendary"]);
export interface NormalizeInventoryOptions {
/** Saved character/party owner; dungeon and loot-source identities are not owner categories. */
readonly ownerCategory?: ContentCategoryId;
}
const CONTENT_CATEGORIES = new Set<ContentCategoryId>(["wow", "rom", "coa"]);
/** Returns true only for an explicit item/owner namespace match. */
export function inventoryItemMatchesCategory(
item: Pick<InventoryItem, "contentCategory">,
expectedCategory: ContentCategoryId | undefined,
): boolean {
return expectedCategory !== undefined && item.contentCategory === expectedCategory;
}
/** A RoM item is usable only when it carries nonempty validated native values. */
export function inventoryItemUsableForCategory(
item: Pick<InventoryItem, "contentCategory" | "stats">,
expectedCategory: ContentCategoryId | undefined,
): boolean {
if (!inventoryItemMatchesCategory(item, expectedCategory)) return false;
if (expectedCategory !== "rom") return true;
const native = normalizeRomNativeGearStats(item.stats?.romNative);
return Boolean(native && Object.keys(native).length > 0);
}
/** Safely migrates persisted profiles created before inventory existed. */
export function normalizeInventory(value: unknown): InventoryItem[] {
export function normalizeInventory(
value: unknown,
options: NormalizeInventoryOptions = {},
): InventoryItem[] {
if (!Array.isArray(value)) return [];
const normalized: InventoryItem[] = [];
for (const candidate of value) {
@@ -53,7 +86,13 @@ export function normalizeInventory(value: unknown): InventoryItem[] {
|| !Number.isFinite(item.acquiredAt)
) continue;
const itemLevel = Math.max(1, Math.min(MAX_PERSISTED_ITEM_LEVEL, Math.trunc(item.itemLevel!)));
const contentCategory = CONTENT_CATEGORIES.has(item.contentCategory as ContentCategoryId)
? item.contentCategory as ContentCategoryId
: item.contentCategory === undefined && options.ownerCategory === "wow"
? "wow"
: null;
normalized.push({
contentCategory,
instanceId: item.instanceId,
itemId: Math.max(1, Math.trunc(item.itemId!)),
itemKey: item.itemKey,
+11 -7
View File
@@ -145,7 +145,7 @@ describe("RoM five class validation scope", () => {
}
});
it("equips the role defining weapons in the manual cohort", () => {
it("keeps role-defined training slots separate from earned RoM gear", () => {
for (const { classId, role, layout } of CASES) {
expect(romGearProfile(classId, role).weaponLayout).toBe(layout);
const loadout = resolvedEquipmentLoadout({}, [], {
@@ -184,14 +184,18 @@ describe("RoM five class validation scope", () => {
expect(loadout.ranged).toBeUndefined();
}
expect(loadout.wrist).toBeUndefined();
for (const item of Object.values(loadout)) expect(classCanEquipItem(classId, item!, {
secondaryClassId: ROM_FIVE_IDENTITIES.find((identity) => identity.classId === classId)?.secondaryClassId,
level: 60,
})).toBe(true);
for (const item of Object.values(loadout)) {
expect(item!.contentCategory).toBe("rom");
const hasNativePayload = Boolean(item!.stats.romNative && Object.keys(item!.stats.romNative).length > 0);
expect(classCanEquipItem(classId, item!, {
secondaryClassId: ROM_FIVE_IDENTITIES.find((identity) => identity.classId === classId)?.secondaryClassId,
level: 60,
})).toBe(hasNativePayload);
}
}
});
it("opens the independent ranged and off-hand positions at the source training levels", () => {
it("opens training weapon slots without making placeholders equippable inventory", () => {
const loadout = (classId: "rom-knight" | "rom-scout" | "rom-rogue", secondaryClassId: "rom-scout" | "rom-rogue" | "rom-warrior", level: number) =>
resolvedEquipmentLoadout({}, [], {
ownerId: `rom-slots:${classId}`,
@@ -213,7 +217,7 @@ describe("RoM five class validation scope", () => {
const knightBow = loadout("rom-knight", "rom-scout", 60).ranged!;
expect(classCanEquipItem("rom-knight", knightBow, { level: 60 })).toBe(false);
expect(equipInventoryItem({}, [knightBow], "knight", "rom-knight", knightBow.instanceId,
{ secondaryClassId: "rom-scout", level: 60 }).ok).toBe(true);
{ secondaryClassId: "rom-scout", level: 60 }).ok).toBe(false);
expect(equipInventoryItem({}, [knightBow], "knight", "rom-knight", knightBow.instanceId,
{ secondaryClassId: "rom-priest", level: 60 }).ok).toBe(false);
});
+8
View File
@@ -20,6 +20,7 @@ describe("RuneWaker source loot catalog", () => {
now: 12_345,
});
expect(drops[0]).toMatchObject({
contentCategory: "rom",
itemId: 200004,
name: "Healing Sigil",
quantity: 1,
@@ -28,11 +29,18 @@ describe("RuneWaker source loot catalog", () => {
inventorySlot: "misc",
});
expect(drops[0].stats).toMatchObject({ stamina: 0, armor: 0, meleeWeapon: null });
expect(drops[0].stats.romNative).toBeUndefined();
expect(rollRunewakerBossLoot("rw-barren-caves-101462", 37, {
random: () => 0,
})).toEqual([]);
});
it("keeps source-tagged generic outcomes out of gear when an owner category is supplied", () => {
const options = { random: () => 0, now: 12_346, ownerCategory: "rom" as const };
expect(rollRunewakerBossLoot("rw-barren-caves-101462", 80, options)).toEqual([]);
expect(rollRunewakerBossLoot("rw-barren-caves-101462", 80, { ...options, ownerCategory: "wow" })).toEqual([]);
});
it("keeps no-source tables and unknown bosses fail closed", () => {
const noDrops = RUNEWAKER_LOOT_CATALOG.bosses.find((boss) => boss.status === "no-source-drops")!;
expect(runewakerBossHasLoot(noDrops.bossKey)).toBe(false);
+5 -1
View File
@@ -1,4 +1,5 @@
import generatedCatalog from "./generated/runewakerLootCatalog.json";
import type { ContentCategoryId } from "../app/contentCategories";
import { allowedLootDrops } from "./lootPolicy";
import { EMPTY_ITEM_STATS } from "./itemStats";
import type { InventoryItem } from "./lootTypes";
@@ -51,6 +52,8 @@ export interface RunewakerLootRollOptions {
readonly levelDelta?: number;
/** HealerMan gameplay modifier: one extra authored slot roll may be attempted. */
readonly bonusItemChance?: number;
/** Saved character/party category; dungeon category is not used as owner identity. */
readonly ownerCategory?: ContentCategoryId;
}
export const RUNEWAKER_LOOT_CATALOG = generatedCatalog as RunewakerLootCatalog;
@@ -101,6 +104,7 @@ function rollSourceSlot(
const itemLevel = Math.max(1, Math.min(255, Math.trunc(playerLevel)));
itemInstanceSequence += 1;
return Object.freeze({
contentCategory: "rom",
instanceId: `${boss.bossKey}:rw:${acquiredAt}:${itemInstanceSequence}`,
itemId: outcome.objectId,
itemKey: `runewaker-${outcome.objectId}`,
@@ -143,5 +147,5 @@ export function rollRunewakerBossLoot(
const item = rollSourceSlot(boss, bonusRoll, playerLevel, levelDelta, acquiredAt, random);
if (item) drops.push(item);
}
return allowedLootDrops(drops, playerLevel);
return allowedLootDrops(drops, playerLevel, options.ownerCategory);
}