diff --git a/PLAYTEST_NOTES.md b/PLAYTEST_NOTES.md index 7009865c..b6305486 100644 --- a/PLAYTEST_NOTES.md +++ b/PLAYTEST_NOTES.md @@ -5744,3 +5744,24 @@ unchanged; no commit, deployment or readiness promotion was performed. build; the actual prior dist has a separate exclusive backup. - Evidence: artifacts/playtest/rom-residual-review-20261006/; scope/limits and source/promotion/build proofs: docs/ROM_RESIDUAL_RENDERING_REVIEW_2026-10-06.md. + +## Gear namespace checkpoint — native #292 / shared #9 (2026-10-10) + +Character/class source category now governs item normalization, grants, equip +and active stats independently from dungeon source. Shared profile schema 8 +migrates missing-category WoW records only for explicit WoW owners and retains +foreign/unidentified/invalid RoM records plus assignments in recovery. Rejected +updates leave stored bytes unchanged. Native ordinary loot stays disabled; +boss cards and simulator reward policy retain their existing behavior. + +253 unique focused source tests, full-scope app typecheck and release hygiene +pass. The composed native bundle builds, 21 selected native cases pass, and five +public Ready WoW level-80 snapshots match the pre-change bundle with controlled +clock/RNG. This does not certify overall balance or equipped Ready gear. + +No dungeon was entered for this checkpoint, so no new Cellblock Rush boss, +GPU screenshot, browser roster/dungeon inspection, manual fight or listening +result is claimed. The source browser build stops at missing ignored Manastorm +assets; browser release/visual verification remains open. Native offline gear +persistence, recovery presentation and authenticated RoM progression remain +under #124. See docs/GAME_GEAR_NAMESPACES.md and gear-namespace-validation.json. diff --git a/docs/GAME_GEAR_NAMESPACES.md b/docs/GAME_GEAR_NAMESPACES.md new file mode 100644 index 00000000..c50c788b --- /dev/null +++ b/docs/GAME_GEAR_NAMESPACES.md @@ -0,0 +1,100 @@ +# Gear namespaces and recoverable legacy records + +The source runtime distinguishes an item's game namespace from the dungeon that +produced its source row. `InventoryItem.contentCategory` is `wow`, `rom`, or +`coa`; it stays optional in the TypeScript contract for legacy callers, while +new WoW and Runewaker factories explicitly write their own source category. +Saved character/class identity chooses the expected owner category. A dungeon +selection cannot change it or relabel another game's gear. + +## Item and equipment boundaries + +`lootTypes.ts` exposes an explicit category match and a usability check. +RoM usability additionally requires a nonempty payload accepted by the existing +`normalizeRomNativeGearStats` normalizer. This is structural validation, not +proof of an authenticated item association or authoritative stat ranges. +Unidentified and foreign records cannot be equipped or contribute active gear. +Contradictory owner-category hints cannot override the source class category. + +Owner-aware loot generation and combat grants filter foreign/unusable records. +An unavailable same-game item pool yields no borrowed gear. Boss-card identity +continues to use the boss/dungeon source. Native Godot still remaps both loot +catalogs to its cards-only adapter; this change does not enable ordinary loot. +The generic Runewaker item catalog has source-tagged records without native +gear payloads and is not promoted into a playable equipment progression pool. + +Synthetic equipment scoring and internal training gear remain simulation +scaffolding. They are not imported live loot. A private training-item identity +can preserve existing simulator fallback calculations without authorizing a +persisted or copied record as native RoM gear. + +## Profile migration and recovery + +`accountRepository.ts` reads local database versions 1–8 at the existing +`healer-man.profile-database.v1` key and writes schema 8. A missing item category +may migrate to `wow` only for an explicit WoW owner; the existing normalized +numeric item fields and equipment references retain their prior values. +Missing categories on RoM/CoA records are not guessed from names, IDs or dungeons. + +Normalized active inventories and assignments contain usable same-game items. +Rejected legacy records remain in character-local `gearRecovery.items` with +original item data and a reason (`missing-category`, `foreign-category`, or +`invalid-native-payload`). `gearRecovery.equipment` retains former owner/slot/ +instance references. Repeated load/cloud normalization merges recovery without +multiplying it, and ordinary gear updates preserve the existing recovery. +This schema retains data for a later explicit recovery operation; it does not +provide a recovery UI or invent a replacement item. + +Inventory/equipment mutations reject foreign or unusable incoming gear before +writing. Rejected mutations leave stored bytes unchanged. Other profiles and +owners retain separate inventories and recovery. Cloud serialization carries +full profile rows, and hydration queues a save when namespace/recovery migration +changes the roster; existing boss-card checkpoint and session-generation behavior +remain separate. + +## Native integration and remaining scope + +Shared source issue [#9](https://git.whoagland.com/phenom/healer-man/issues/9) +owns the source guards and profile schema. Native checkpoint +[#292](https://git.whoagland.com/Codex/healer-man-godot/issues/292) owns reviewed +source pins, bridge regressions and installation. Parent native +[#124](https://git.whoagland.com/Codex/healer-man-godot/issues/124) remains open: +native offline profiles currently store identity/appearance and do not persist +runtime item inventory/loadout or expose recovery. RoM entry retains empty gear. +Source-backed RoM progression and equipment presentation are not established by +the source association catalog. CoA is Ascension Conquest of Azeroth, distinct +from supplementary Chronicles of Arcadia references; no native CoA mode is added. + +RoM stat formulas, weights and allocations remain under the separate native +[#123](https://git.whoagland.com/Codex/healer-man-godot/issues/123) claim. The +existing possibility of non-finite native gear aggregates is tracked separately +in shared [#10](https://git.whoagland.com/phenom/healer-man/issues/10); namespace +metadata does not establish numeric bounds or source authenticity. + +## Verification + +Validation results and exact inputs are recorded with the owning issues. The +focused item/equipment suites pass 39 tests; account/migration/cloud suites pass +35; combat/stat regressions pass 120; selected training/slot checks pass three; +and the avatar suite passes 56 with verified copied inputs and explicit +AppleDouble exclusions. The native source-boundary suite passes seven tests, +and the candidate native bundle builds from the composed, pinned source. + +The required browser production build currently stops at the missing +`public/assets/game/manastorm` asset-audit input in the isolated review checkout. +This is retained as an unmet browser build/visual verification limit; a passing +native bundle does not certify the browser release. Source TypeScript and +combined native/card/simulator checks are recorded separately. The repository +release hygiene check passes. No new GPU capture, live browser roster/dungeon +check, manual playthrough or listening is claimed. + +The full source app TypeScript check passes with the original `src` scope and +task-local exclusions for AppleDouble companion files. All 21 selected native +cases pass across the unchanged 20-case run and the exact missing-catalog case +rerun. A serial public-runtime comparison matches all five Ready WoW profiles +at level 80 with identical clock and random inputs. Those profiles have empty +gear; equipped-item and migration behavior is covered by the focused source +suites. Earlier varying mana results came from run-ID-seeded party identities, +not a gear formula change; the superseded controls remain in the archive. + +See `gear-namespace-validation.json` for exact source and evidence identities. diff --git a/docs/gear-namespace-validation.json b/docs/gear-namespace-validation.json new file mode 100644 index 00000000..9cae4bbf --- /dev/null +++ b/docs/gear-namespace-validation.json @@ -0,0 +1,85 @@ +{ + "schemaVersion": 1, + "date": "2026-10-10", + "coordinator": "/root", + "session": "01a11cd3-fa7c-7510-ba8f-bc7a3d0310a6", + "issues": { + "native": 292, + "nativeParent": 124, + "shared": 9 + }, + "status": "tested-native-checkpoint-browser-verification-incomplete", + "sourceFiles": { + "src/game/lootTypes.ts": "36b11682b214bc479267075c720cb4afcdbb5c8ba7ac18c199d706c3f1313baa", + "src/game/equipment.ts": "76a9757a9727cb7fa543695d8f77426ab1328688a65f4f2212b6c144afb16f92", + "src/game/lootCatalog.ts": "7027dadc2f7f90f6e7b65603b38873b3ad32e09fb4807ceea541caa39278a5f5", + "src/game/runewakerLootCatalog.ts": "113008a954c27611e4cc28576290fc721f254dee41427285a355f8e30ba70b9a", + "src/game/lootPolicy.ts": "bded57df5de902ba01c365ac021709f572aeb692a7cc8a6c0edfd6494d8cad9d", + "src/game/combatStore.ts": "f49b7af337af0fb6e69f6df68ec5ec9ab558cc88fe819ae30c4cf9ad823cd4e5", + "src/game/heirloomEquipment.ts": "057da9166c2a29086fc8ef59afc81fb985ee52926b6cf2f91212809c987d0ff8", + "src/app/accountRepository.ts": "32f6f63537db2312e553dee4c00cbb24ec04b1b0dc5d3079227a8e30d932f8e3", + "src/app/types.ts": "41bb6aac2fd03b7c8c4c4c8e6b475bec4df5d3422627a61489077feada34b628", + "src/app/onlineAccountClient.ts": "04eac8950a503ffccebf29f73f981c1d01a9e558e4712200aaf4ff534279acfe" + }, + "composedNativeSourceFiles": { + "src/game/lootTypes.ts": "36b11682b214bc479267075c720cb4afcdbb5c8ba7ac18c199d706c3f1313baa", + "src/game/equipment.ts": "76a9757a9727cb7fa543695d8f77426ab1328688a65f4f2212b6c144afb16f92", + "src/game/lootCatalog.ts": "7027dadc2f7f90f6e7b65603b38873b3ad32e09fb4807ceea541caa39278a5f5", + "src/game/runewakerLootCatalog.ts": "113008a954c27611e4cc28576290fc721f254dee41427285a355f8e30ba70b9a", + "src/game/lootPolicy.ts": "bded57df5de902ba01c365ac021709f572aeb692a7cc8a6c0edfd6494d8cad9d", + "src/game/combatStore.ts": "5afefb58b9a02b564cf07140c19388163a8423d22695f3619d196a9d603eeb7d", + "src/game/heirloomEquipment.ts": "057da9166c2a29086fc8ef59afc81fb985ee52926b6cf2f91212809c987d0ff8", + "src/app/accountRepository.ts": "32f6f63537db2312e553dee4c00cbb24ec04b1b0dc5d3079227a8e30d932f8e3", + "src/app/types.ts": "41bb6aac2fd03b7c8c4c4c8e6b475bec4df5d3422627a61489077feada34b628", + "src/app/onlineAccountClient.ts": "04eac8950a503ffccebf29f73f981c1d01a9e558e4712200aaf4ff534279acfe" + }, + "nativeEngineSha256": "6f9a06c8fb46dbc25b5ff7aa1fee77fcc950a8481e2beb8eb9ed69e33d51e15d", + "checks": { + "sourceFocusedUniqueTests": 253, + "sourceGroups": { + "itemEquipment": 39, + "profileCloud": 35, + "combatStats": 120, + "targetedTraining": 3, + "avatar": 56 + }, + "sourceAppTypecheck": "pass: full original src scope; task-local AppleDouble exclusions only", + "sourceReleaseHygiene": "pass", + "sourceBrowserBuild": "blocked: missing public/assets/game/manastorm in isolated review checkout", + "nativeBundleBuild": "pass", + "nativeFocusedCases": 21, + "nativeCoverageMethod": "20 unchanged cases pass in attempt-03; the one missing-catalog case passes in the exact-case rerun after copying and pinning the installed boss-card catalog", + "readyWowParity": "5/5 level-80 public Ready profiles match with fixed clock/RNG and one fresh process per profile; empty gear only", + "liveBrowserRosterAndDungeon": "not performed", + "newGodotGpuCapture": "not performed" + }, + "artifacts": { + "diagnostics/source-worker/attempt-08/source-worker-receipt.json": "8bcae5d6e47e7e3547ac72d5f3d650fa2f7280993f96a77a3a597fd32e696a48", + "diagnostics/persistence-worker/final-receipt-attempt-04.json": "241b8423795b3596b3f77fcc93c0d50bf093f0dcfa6043ec9974c1c8050d1b2a", + "diagnostics/required-source-checks/combat-regressions-attempt-01/result.json": "8def574f70b9b19ff57f4413eb4382ecd1e29df126d1036173dd092974021e2d", + "diagnostics/required-source-checks/avatar-attempt-03/result.json": "74b511dc10bc233f4aaf49c3bf5a5eb6a626e5639b85332b1444a8d50ec08157", + "diagnostics/required-source-checks/typecheck-attempt-04/result.json": "cb2f6f49e3f8f2f3c3f44ce02e0a0f644bcb9d69dfe656ba04b78cfb2af517ca", + "diagnostics/required-source-checks/release-attempt-01/result.json": "1c0cd604233dfc3ed01ddca47b34770392e917cd5a589efee15a46d1ceb4c230", + "diagnostics/required-source-checks/build-attempt-02/result.json": "d068f38485c7a38af27586d1bbd1ad96db9630bf976092da0572fe0c3a7d5255", + "diagnostics/native-build/baseline-attempt-02/result.json": "92bf94d5b26fa99c502575d2fb9bd624518f9e5e21b362b697324efd6a6e6d63", + "diagnostics/native-build/candidate-attempt-01/result.json": "05bf46066a9429edb2405d4a60ca7937446f1cb978e71e504a1bf0a051b3dbfb", + "diagnostics/native-regressions/attempt-03/result.json": "7e31c04b48f9b01e1419c718023ad929a65cc0efdc3c4759e00a3b72e5d6775e", + "diagnostics/native-regressions/cards-catalog-rerun-attempt-01/result.json": "23b8d1b0216de53c462ca25ada0443aa9bdd62336dd554b10bc5f0b9dfa19fcf", + "diagnostics/persistence-worker/wow-preservation/attempt-10/acceptance-receipt.json": "8533744d44af288149572f7147c69961b50315101e3c7f86e90ee4d0bafc99ea", + "diagnostics/native-candidate-integration/attempt-02/result.json": "44d4672f6f1c174563e2524fffe38ab18f1d96b74486b7a0c198bad764990fbe", + "diagnostics/native-candidate-integration/attempt-02/hunk-preservation.json": "b2f6c6e236dc6484fd14ee9bada39fd60933b33320b927e721370feff8445e44", + "diagnostics/required-source-checks/release-attempt-02/result.json": "daf8d3a2bc4dd0d103127687d6fd44ba5ee30f7e2ff51f518fb88fe80004caa6" + }, + "preservedScopes": [ + "WoW rigs/animations/models/stats and installed short-GCD work", + "#123 RoM formulas/weights/allocation", + "native cards-only loot and simulator reward policy", + "character category independent from dungeon category" + ], + "remaining": [ + "Full browser build and live roster/in-dungeon screenshots", + "#124 native offline item/loadout persistence and recovery UI", + "#124 authenticated RoM item progression and equipment presentation", + "Shared #10 non-finite aggregate follow-up" + ] +} diff --git a/src/app/accountRepository.test.ts b/src/app/accountRepository.test.ts index f59f2395..d41aa461 100644 --- a/src/app/accountRepository.test.ts +++ b/src/app/accountRepository.test.ts @@ -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) => ({ + ...(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) => ({ + ...(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); diff --git a/src/app/accountRepository.ts b/src/app/accountRepository.ts index 44335290..c783fdc2 100644 --- a/src/app/accountRepository.ts +++ b/src/app/accountRepository.ts @@ -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; bossCardsByOwner: Record; @@ -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; + 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(); + const equipmentCounts = new Map(); + for (const value of values) { + const recovery = normalizeGearRecovery(value); + const currentItemCounts = new Map(); + 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, +): boolean { + for (const candidate of inventory as readonly unknown[]) { + if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) return false; + const item = candidate as Record; + const category = item.contentCategory; + if (!(category === undefined && ownerCategory === "wow") + && !inventoryItemMatchesCategory(item as Pick, 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(); + 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)) { + if (!loadout || typeof loadout !== "object" || Array.isArray(loadout)) return false; + for (const rawId of Object.values(loadout as Record)) { + 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, + existingRecovery: unknown, +): ProfileGearNormalization { + const usableInventory: InventoryItem[] = []; + const recoveredItems: GearRecoveryItem[] = []; + const rejectedInstanceIds = new Map(); + 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; + // 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)) { + if (!ownerId || !rawLoadout || typeof rawLoadout !== "object" || Array.isArray(rawLoadout)) continue; + for (const [slot, rawInstanceId] of Object.entries(rawLoadout as Record)) { + 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>; 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) } : 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; diff --git a/src/app/bossCardAccountSync.test.ts b/src/app/bossCardAccountSync.test.ts index 3a60dbb9..3c4caa8d 100644 --- a/src/app/bossCardAccountSync.test.ts +++ b/src/app/bossCardAccountSync.test.ts @@ -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(); @@ -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); + }); }); diff --git a/src/app/onlineAccountClient.ts b/src/app/onlineAccountClient.ts index 4e0a7481..40b61c3c 100644 --- a/src/app/onlineAccountClient.ts +++ b/src/app/onlineAccountClient.ts @@ -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; + 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; + 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 { if (!session.accessToken) return listCharacters(session.ownerId); activateOnlineSession(session); @@ -163,7 +189,12 @@ export async function hydrateOnlineSession(session: PlayerSession): Promise { "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", () => { diff --git a/src/app/types.ts b/src/app/types.ts index c44468f0..670bf73a 100644 --- a/src/app/types.ts +++ b/src/app/types.ts @@ -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>; 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. */ diff --git a/src/game/combatStore.test.ts b/src/game/combatStore.test.ts index c54129e6..0a81bd4e 100644 --- a/src/game/combatStore.test.ts +++ b/src/game/combatStore.test.ts @@ -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 }); diff --git a/src/game/combatStore.ts b/src/game/combatStore.ts index 7322a94b..05c2b450 100644 --- a/src/game/combatStore.ts +++ b/src/game/combatStore.ts @@ -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((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((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((set, get) => ({ raceId, gender, statRuleset, + contentCategoryId, romNativeRecoveryClock: undefined, level: progression.level, xp: progression.xp, @@ -11978,7 +11991,7 @@ export const useCombatStore = create((set, get) => ({ abilities, talentRanks, ), - inventory, + inventory: categoryInventory, bossCards: normalizeBossCardCollection(profile.bossCards), equipment, gearStats, @@ -11988,6 +12001,7 @@ export const useCombatStore = create((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((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((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((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((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((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((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((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((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((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, diff --git a/src/game/equipment.test.ts b/src/game/equipment.test.ts index 6dd0dba0..a3b02af4 100644 --- a/src/game/equipment.test.ts +++ b/src/game/equipment.test.ts @@ -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(); + }); }); diff --git a/src/game/equipment.ts b/src/game/equipment.ts index 824bc809..341f0f2e 100644 --- a/src/game/equipment.ts +++ b/src/game/equipment.ts @@ -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(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 { 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); diff --git a/src/game/heirloomEquipment.test.ts b/src/game/heirloomEquipment.test.ts index a65dde6c..e8d2b29c 100644 --- a/src/game/heirloomEquipment.test.ts +++ b/src/game/heirloomEquipment.test.ts @@ -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 { 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); diff --git a/src/game/heirloomEquipment.ts b/src/game/heirloomEquipment.ts index 3bc1f5d4..6a10409e 100644 --- a/src/game/heirloomEquipment.ts +++ b/src/game/heirloomEquipment.ts @@ -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([ const CATALOG_ITEMS = LOOT_CATALOG.bosses.flatMap((boss) => boss.items); const itemCache = new Map(); +// 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(); 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[]; 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; diff --git a/src/game/lootCatalog.test.ts b/src/game/lootCatalog.test.ts index 2da791e8..88496875 100644 --- a/src/game/lootCatalog.test.ts +++ b/src/game/lootCatalog.test.ts @@ -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, diff --git a/src/game/lootCatalog.ts b/src/game/lootCatalog.ts index ea0d9396..3e8aea70 100644 --- a/src/game/lootCatalog.ts +++ b/src/game/lootCatalog.ts @@ -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); } diff --git a/src/game/lootPolicy.ts b/src/game/lootPolicy.ts index 97051487..377b4966 100644 --- a/src/game/lootPolicy.ts +++ b/src/game/lootPolicy.ts @@ -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)); } diff --git a/src/game/lootTypes.test.ts b/src/game/lootTypes.test.ts new file mode 100644 index 00000000..8907c0c8 --- /dev/null +++ b/src/game/lootTypes.test.ts @@ -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 = {}): Record { + 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); + }); +}); diff --git a/src/game/lootTypes.ts b/src/game/lootTypes.ts index fbf1642c..87fa62f7 100644 --- a/src/game/lootTypes.ts +++ b/src/game/lootTypes.ts @@ -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(["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(["wow", "rom", "coa"]); + +/** Returns true only for an explicit item/owner namespace match. */ +export function inventoryItemMatchesCategory( + item: Pick, + 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, + 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, diff --git a/src/game/romFiveValidation.test.ts b/src/game/romFiveValidation.test.ts index 20aee8ff..8f6c55da 100644 --- a/src/game/romFiveValidation.test.ts +++ b/src/game/romFiveValidation.test.ts @@ -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); }); diff --git a/src/game/runewakerLootCatalog.test.ts b/src/game/runewakerLootCatalog.test.ts index 5152fc9f..da590f3e 100644 --- a/src/game/runewakerLootCatalog.test.ts +++ b/src/game/runewakerLootCatalog.test.ts @@ -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); diff --git a/src/game/runewakerLootCatalog.ts b/src/game/runewakerLootCatalog.ts index 60737a6c..f129523e 100644 --- a/src/game/runewakerLootCatalog.ts +++ b/src/game/runewakerLootCatalog.ts @@ -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); }