Merge pull request 'Guard source gear namespaces and retain recoverable legacy records (#9)' (#13) from fix/9-game-gear-namespaces into main
Integrate tested gear namespace checkpoint; retain browser verification and native #124 remaining scope.
This commit was merged in pull request #13.
This commit is contained in:
commit
812162320d
23 files changed
+1199
-62
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -164,6 +164,7 @@ describe("local profile repository", () => {
|
||||
appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 },
|
||||
}, storage).character!;
|
||||
const item = {
|
||||
contentCategory: "rom" as const,
|
||||
instanceId: "kept-item",
|
||||
itemId: 10412,
|
||||
itemKey: "belt-of-the-fang",
|
||||
@@ -174,7 +175,7 @@ describe("local profile repository", () => {
|
||||
weaponType: null,
|
||||
itemLevel: 17,
|
||||
sourceItemLevel: 21,
|
||||
stats: { ...EMPTY_ITEM_STATS, agility: 4, stamina: 4, armor: 15 },
|
||||
stats: { ...EMPTY_ITEM_STATS, agility: 4, stamina: 4, armor: 15, romNative: { strength: 1 } },
|
||||
quantity: 1,
|
||||
sourceBossKey: "lady-anacondra",
|
||||
sourceBossName: "Lady Anacondra",
|
||||
@@ -483,7 +484,7 @@ describe("local profile repository", () => {
|
||||
}));
|
||||
expect(loadCombatPresentationSettings("offline-roster", storage))
|
||||
.toEqual(DEFAULT_COMBAT_PRESENTATION_SETTINGS);
|
||||
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(7);
|
||||
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(8);
|
||||
}
|
||||
|
||||
const legacyV3Storage = new MemoryStorage();
|
||||
@@ -642,16 +643,176 @@ describe("local profile repository", () => {
|
||||
acquiredAt: 5_000,
|
||||
};
|
||||
|
||||
expect(updateCharacterInventory(offline.ownerId, character.id, [item], storage)?.inventory).toEqual([item]);
|
||||
const normalizedItem = { ...item, contentCategory: "wow" as const };
|
||||
expect(updateCharacterInventory(offline.ownerId, character.id, [item], storage)?.inventory).toEqual([normalizedItem]);
|
||||
expect(updateCharacterEquipment(offline.ownerId, character.id, {
|
||||
player: { waist: item.instanceId },
|
||||
}, storage)?.equipment).toEqual({ player: { waist: item.instanceId } });
|
||||
expect(listCharacters(offline.ownerId, storage)[0]?.inventory).toEqual([item]);
|
||||
expect(listCharacters(offline.ownerId, storage)[0]?.inventory).toEqual([normalizedItem]);
|
||||
expect(listCharacters(offline.ownerId, storage)[0]?.equipment).toEqual({
|
||||
player: { waist: item.instanceId },
|
||||
});
|
||||
});
|
||||
|
||||
it("migrates foreign and unresolved gear into character-scoped recovery without changing owner identity", () => {
|
||||
const storage = new MemoryStorage();
|
||||
const makeItem = (instanceId: string, contentCategory?: "wow" | "rom" | "coa", romNative?: Record<string, number>) => ({
|
||||
...(contentCategory ? { contentCategory } : {}),
|
||||
instanceId,
|
||||
itemId: 10412,
|
||||
itemKey: `gear:${instanceId}`,
|
||||
name: `Gear ${instanceId}`,
|
||||
quality: "rare" as const,
|
||||
inventorySlot: "waist",
|
||||
armorType: "leather",
|
||||
weaponType: null,
|
||||
itemLevel: 17,
|
||||
sourceItemLevel: 21,
|
||||
stats: {
|
||||
...EMPTY_ITEM_STATS,
|
||||
agility: 4,
|
||||
...(romNative === undefined ? {} : { romNative }),
|
||||
},
|
||||
quantity: 1,
|
||||
sourceBossKey: "source-boss",
|
||||
sourceBossName: "Source Boss",
|
||||
acquiredAt: 5_000,
|
||||
});
|
||||
const legacyProfile = (
|
||||
id: string,
|
||||
ownerId: string,
|
||||
classId: string,
|
||||
raceId: string,
|
||||
inventory: unknown[],
|
||||
equipment: unknown = {},
|
||||
) => ({
|
||||
id, ownerId, name: id, classId, raceId, gender: "female", appearance: {},
|
||||
inventory, equipment, location: "Dungeon Entrance", createdAt: 1, lastPlayedAt: 2,
|
||||
});
|
||||
const wowItem = makeItem("wow-legacy");
|
||||
const romMissingCategory = makeItem("rom-missing-category");
|
||||
const romForeign = makeItem("rom-foreign", "wow");
|
||||
const romInvalidNative = makeItem("rom-invalid-native", "rom", {});
|
||||
const coaMissingCategory = makeItem("coa-missing-category");
|
||||
const untouchedWow = makeItem("other-owner-item", "wow");
|
||||
const wowProfile = legacyProfile("wow-profile", "owner-one", "priest", "human", [wowItem], {
|
||||
player: { waist: wowItem.instanceId },
|
||||
});
|
||||
const romProfile = legacyProfile("rom-profile", "owner-one", "rom-priest", "rom-human", [
|
||||
romMissingCategory, romForeign, romInvalidNative,
|
||||
], {
|
||||
player: { waist: romMissingCategory.instanceId },
|
||||
companion: { waist: romForeign.instanceId },
|
||||
reserve: { waist: romInvalidNative.instanceId },
|
||||
});
|
||||
const coaProfile = legacyProfile("coa-profile", "owner-one", "barbarian", "human", [coaMissingCategory]);
|
||||
const otherOwnerProfile = legacyProfile("other-owner-profile", "owner-two", "priest", "human", [untouchedWow]);
|
||||
storage.setItem(PROFILE_STORAGE_KEYS.database, JSON.stringify({
|
||||
version: 7,
|
||||
accounts: [],
|
||||
rosters: { "owner-one": [wowProfile, romProfile, coaProfile], "owner-two": [otherOwnerProfile] },
|
||||
bossCardsByOwner: {},
|
||||
presentationSettingsByOwner: {},
|
||||
}));
|
||||
|
||||
const ownerOne = listCharacters("owner-one", storage);
|
||||
const wow = ownerOne.find((character) => character.id === "wow-profile")!;
|
||||
const rom = ownerOne.find((character) => character.id === "rom-profile")!;
|
||||
const coa = ownerOne.find((character) => character.id === "coa-profile")!;
|
||||
expect(wow.inventory).toMatchObject([{ instanceId: "wow-legacy", contentCategory: "wow", stats: { agility: 4 } }]);
|
||||
expect(wow.equipment).toEqual({ player: { waist: "wow-legacy" } });
|
||||
expect(rom.inventory).toEqual([]);
|
||||
expect(rom.equipment).toEqual({});
|
||||
expect(rom.gearRecovery?.items).toEqual([
|
||||
{ item: romMissingCategory, reason: "missing-category" },
|
||||
{ item: romForeign, reason: "foreign-category" },
|
||||
{ item: romInvalidNative, reason: "invalid-native-payload" },
|
||||
]);
|
||||
expect(rom.gearRecovery?.equipment).toEqual([
|
||||
{ ownerId: "player", slot: "waist", instanceId: "rom-missing-category", reason: "missing-category" },
|
||||
{ ownerId: "companion", slot: "waist", instanceId: "rom-foreign", reason: "foreign-category" },
|
||||
{ ownerId: "reserve", slot: "waist", instanceId: "rom-invalid-native", reason: "invalid-native-payload" },
|
||||
]);
|
||||
expect(coa.inventory).toEqual([]);
|
||||
expect(coa.gearRecovery?.items).toEqual([{ item: coaMissingCategory, reason: "missing-category" }]);
|
||||
expect(listCharacters("owner-two", storage)[0]?.inventory).toMatchObject([
|
||||
{ instanceId: "other-owner-item", contentCategory: "wow" },
|
||||
]);
|
||||
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(8);
|
||||
|
||||
const validRomReplacement = makeItem("valid-rom-replacement", "rom", { strength: 2 });
|
||||
const ordinarySave = updateCharacterInventory("owner-one", rom.id, [validRomReplacement], storage)!;
|
||||
expect(ordinarySave.inventory).toMatchObject([{ instanceId: "valid-rom-replacement", contentCategory: "rom" }]);
|
||||
expect(ordinarySave.gearRecovery).toEqual(rom.gearRecovery);
|
||||
|
||||
const migratedBytes = storage.getItem(PROFILE_STORAGE_KEYS.database);
|
||||
expect(listCharacters("owner-one", storage).find((character) => character.id === "rom-profile")?.gearRecovery)
|
||||
.toEqual(rom.gearRecovery);
|
||||
expect(storage.getItem(PROFILE_STORAGE_KEYS.database)).toBe(migratedBytes);
|
||||
|
||||
const withoutRemoteRecovery = { ...rom, gearRecovery: undefined };
|
||||
const replaced = replaceCharactersForOwner("owner-one", [withoutRemoteRecovery], storage);
|
||||
expect(replaced[0]?.gearRecovery).toEqual(rom.gearRecovery);
|
||||
const repeated = replaceCharactersForOwner("owner-one", [withoutRemoteRecovery], storage);
|
||||
expect(repeated[0]?.gearRecovery).toEqual(rom.gearRecovery);
|
||||
});
|
||||
|
||||
it("rejects foreign, unresolved, and unusable RoM inventory updates without writing partial state", () => {
|
||||
const storage = new MemoryStorage();
|
||||
const rom = createCharacter({
|
||||
ownerId: "offline-rom",
|
||||
name: "Runesworn",
|
||||
categoryId: "rom",
|
||||
raceId: "rom-human",
|
||||
classId: "rom-priest",
|
||||
gender: "female",
|
||||
appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 },
|
||||
}, storage).character!;
|
||||
const item = (instanceId: string, contentCategory?: "wow" | "rom" | "coa", romNative?: Record<string, number>) => ({
|
||||
...(contentCategory ? { contentCategory } : {}),
|
||||
instanceId,
|
||||
itemId: 10412,
|
||||
itemKey: `gear:${instanceId}`,
|
||||
name: `Gear ${instanceId}`,
|
||||
quality: "rare" as const,
|
||||
inventorySlot: "waist",
|
||||
armorType: "leather",
|
||||
weaponType: null,
|
||||
itemLevel: 17,
|
||||
sourceItemLevel: 21,
|
||||
stats: {
|
||||
...EMPTY_ITEM_STATS,
|
||||
agility: 4,
|
||||
...(romNative === undefined ? {} : { romNative }),
|
||||
},
|
||||
quantity: 1,
|
||||
sourceBossKey: "source-boss",
|
||||
sourceBossName: "Source Boss",
|
||||
acquiredAt: 5_000,
|
||||
});
|
||||
const foreign = item("foreign", "wow");
|
||||
const unresolved = item("unresolved");
|
||||
const invalidNative = item("invalid-native", "rom", {});
|
||||
|
||||
for (const candidate of [foreign, unresolved, invalidNative]) {
|
||||
const before = storage.getItem(PROFILE_STORAGE_KEYS.database);
|
||||
expect(updateCharacterInventory(rom.ownerId, rom.id, [candidate], storage)).toBeNull();
|
||||
expect(storage.getItem(PROFILE_STORAGE_KEYS.database)).toBe(before);
|
||||
}
|
||||
|
||||
const valid = item("valid-rom", "rom", { strength: 3 });
|
||||
const updated = updateCharacterInventory(rom.ownerId, rom.id, [valid], storage)!;
|
||||
expect(updated.inventory).toMatchObject([{ instanceId: "valid-rom", contentCategory: "rom" }]);
|
||||
const beforeForeignEquipment = storage.getItem(PROFILE_STORAGE_KEYS.database);
|
||||
expect(updateCharacterEquipment(rom.ownerId, rom.id, {
|
||||
player: { waist: "foreign" },
|
||||
}, storage)).toBeNull();
|
||||
expect(storage.getItem(PROFILE_STORAGE_KEYS.database)).toBe(beforeForeignEquipment);
|
||||
expect(updateCharacterEquipment(rom.ownerId, rom.id, {
|
||||
player: { waist: "valid-rom" },
|
||||
}, storage)?.equipment).toEqual({ player: { waist: "valid-rom" } });
|
||||
});
|
||||
|
||||
it("persists account card copies independently of character creation and deletion", () => {
|
||||
const storage = new MemoryStorage();
|
||||
const offline = createOfflineSession();
|
||||
@@ -691,7 +852,7 @@ describe("local profile repository", () => {
|
||||
expect(loadAccountBossCards("one", storage)).toEqual(expected);
|
||||
expect(loadAccountBossCards("two", storage).counts.boss?.normal).toBe(4);
|
||||
const database = JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!);
|
||||
expect(database.version).toBe(7);
|
||||
expect(database.version).toBe(8);
|
||||
expect(database.rosters.one.every((entry: { bossCards?: unknown }) => entry.bossCards === undefined)).toBe(true);
|
||||
// An old cloud roster can be downloaded again without repeating the migration.
|
||||
replaceCharactersForOwner("one", legacyCharacters, storage);
|
||||
|
||||
+250
-15
@@ -1,4 +1,11 @@
|
||||
import type { CharacterProfile, PlayerSession } from "./types";
|
||||
import type {
|
||||
CharacterGearRecovery,
|
||||
CharacterProfile,
|
||||
GearRecoveryEquipmentReference,
|
||||
GearRecoveryItem,
|
||||
GearRecoveryReason,
|
||||
PlayerSession,
|
||||
} from "./types";
|
||||
import { mergeBossCardCollections, normalizeBossCardCollection, type BossCardCollection } from "../game/bossCards";
|
||||
import { normalizeUiPreferences, type UiPreferences } from "../game/uiLayout";
|
||||
import {
|
||||
@@ -11,7 +18,12 @@ import {
|
||||
orderedClassLoadoutKey,
|
||||
ROM_SECONDARY_UNLOCK_LEVEL,
|
||||
} from "./romDualClass";
|
||||
import { normalizeInventory, type InventoryItem } from "../game/lootTypes";
|
||||
import {
|
||||
inventoryItemMatchesCategory,
|
||||
inventoryItemUsableForCategory,
|
||||
normalizeInventory,
|
||||
type InventoryItem,
|
||||
} from "../game/lootTypes";
|
||||
import { normalizeEquipment, type EquipmentAssignments } from "../game/equipment";
|
||||
import type { GameplaySettings } from "../game/combatStore";
|
||||
import {
|
||||
@@ -70,7 +82,7 @@ interface LocalAccount {
|
||||
}
|
||||
|
||||
interface ProfileDatabase {
|
||||
version: 7;
|
||||
version: 8;
|
||||
accounts: LocalAccount[];
|
||||
rosters: Record<string, CharacterProfile[]>;
|
||||
bossCardsByOwner: Record<string, BossCardCollection>;
|
||||
@@ -114,7 +126,196 @@ function browserSessionStorage(): StorageLike | null {
|
||||
}
|
||||
|
||||
function emptyDatabase(): ProfileDatabase {
|
||||
return { version: 7, accounts: [], rosters: {}, bossCardsByOwner: {}, presentationSettingsByOwner: {} };
|
||||
return { version: 8, accounts: [], rosters: {}, bossCardsByOwner: {}, presentationSettingsByOwner: {} };
|
||||
}
|
||||
|
||||
const CONTENT_CATEGORY_IDS = new Set(["wow", "rom", "coa"]);
|
||||
|
||||
function isGearRecoveryReason(value: unknown): value is GearRecoveryReason {
|
||||
return value === "missing-category" || value === "foreign-category" || value === "invalid-native-payload";
|
||||
}
|
||||
|
||||
function normalizeGearRecovery(value: unknown): CharacterGearRecovery {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return { items: [], equipment: [] };
|
||||
const record = value as { items?: unknown; equipment?: unknown };
|
||||
const items: GearRecoveryItem[] = Array.isArray(record.items)
|
||||
? record.items.flatMap((entry): GearRecoveryItem[] => {
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)
|
||||
|| !Object.prototype.hasOwnProperty.call(entry, "item")) return [];
|
||||
const row = entry as { item: unknown; reason?: unknown };
|
||||
return [{
|
||||
item: row.item,
|
||||
reason: isGearRecoveryReason(row.reason) ? row.reason : "missing-category",
|
||||
}];
|
||||
})
|
||||
: [];
|
||||
const equipment: GearRecoveryEquipmentReference[] = Array.isArray(record.equipment)
|
||||
? record.equipment.flatMap((entry): GearRecoveryEquipmentReference[] => {
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return [];
|
||||
const row = entry as Partial<GearRecoveryEquipmentReference>;
|
||||
if (typeof row.ownerId !== "string" || typeof row.slot !== "string" || typeof row.instanceId !== "string") return [];
|
||||
return [{
|
||||
ownerId: row.ownerId,
|
||||
slot: row.slot,
|
||||
instanceId: row.instanceId,
|
||||
reason: isGearRecoveryReason(row.reason) ? row.reason : "missing-category",
|
||||
}];
|
||||
})
|
||||
: [];
|
||||
return { items, equipment };
|
||||
}
|
||||
|
||||
function jsonIdentity(value: unknown): string {
|
||||
try { return JSON.stringify(value) ?? "undefined"; } catch { return "[unserializable]"; }
|
||||
}
|
||||
|
||||
/** Merges repeat cloud/profile normalization without duplicating the same recovered source row. */
|
||||
function mergeGearRecovery(
|
||||
...values: readonly unknown[]
|
||||
): CharacterGearRecovery | undefined {
|
||||
const mergedItems: GearRecoveryItem[] = [];
|
||||
const mergedEquipment: GearRecoveryEquipmentReference[] = [];
|
||||
const itemCounts = new Map<string, number>();
|
||||
const equipmentCounts = new Map<string, number>();
|
||||
for (const value of values) {
|
||||
const recovery = normalizeGearRecovery(value);
|
||||
const currentItemCounts = new Map<string, number>();
|
||||
for (const entry of recovery.items) {
|
||||
const key = `${entry.reason}\n${jsonIdentity(entry.item)}`;
|
||||
const count = (currentItemCounts.get(key) ?? 0) + 1;
|
||||
currentItemCounts.set(key, count);
|
||||
if (count > (itemCounts.get(key) ?? 0)) mergedItems.push(entry);
|
||||
}
|
||||
for (const [key, count] of currentItemCounts) {
|
||||
itemCounts.set(key, Math.max(itemCounts.get(key) ?? 0, count));
|
||||
}
|
||||
for (const entry of recovery.equipment) {
|
||||
const key = jsonIdentity(entry);
|
||||
if (equipmentCounts.has(key)) continue;
|
||||
equipmentCounts.set(key, 1);
|
||||
mergedEquipment.push(entry);
|
||||
}
|
||||
}
|
||||
return mergedItems.length || mergedEquipment.length
|
||||
? { items: mergedItems, equipment: mergedEquipment }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function recoveryReasonForCategory(
|
||||
category: unknown,
|
||||
ownerCategory: CharacterProfile["categoryId"],
|
||||
normalizedItem?: InventoryItem,
|
||||
): GearRecoveryReason {
|
||||
if (CONTENT_CATEGORY_IDS.has(String(category)) && category !== ownerCategory) return "foreign-category";
|
||||
if (category === "rom" && ownerCategory === "rom" && normalizedItem
|
||||
&& !inventoryItemUsableForCategory(normalizedItem, ownerCategory)) return "invalid-native-payload";
|
||||
return "missing-category";
|
||||
}
|
||||
|
||||
function incomingInventoryMatchesCategory(
|
||||
inventory: readonly InventoryItem[],
|
||||
ownerCategory: NonNullable<CharacterProfile["categoryId"]>,
|
||||
): boolean {
|
||||
for (const candidate of inventory as readonly unknown[]) {
|
||||
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) return false;
|
||||
const item = candidate as Record<string, unknown>;
|
||||
const category = item.contentCategory;
|
||||
if (!(category === undefined && ownerCategory === "wow")
|
||||
&& !inventoryItemMatchesCategory(item as Pick<InventoryItem, "contentCategory">, ownerCategory)) return false;
|
||||
const normalized = normalizeInventory([candidate], { ownerCategory })[0];
|
||||
if (!normalized || !inventoryItemUsableForCategory(normalized, ownerCategory)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function equipmentReferencesRecoveredItem(
|
||||
assignments: EquipmentAssignments,
|
||||
recovery: CharacterGearRecovery | undefined,
|
||||
): boolean {
|
||||
if (!assignments || typeof assignments !== "object" || Array.isArray(assignments)) return false;
|
||||
if (!recovery?.items.length && !recovery?.equipment.length) return false;
|
||||
const recoveredIds = new Set<string>();
|
||||
for (const entry of recovery.items) {
|
||||
if (entry.item && typeof entry.item === "object" && !Array.isArray(entry.item)) {
|
||||
const instanceId = (entry.item as { instanceId?: unknown }).instanceId;
|
||||
if (typeof instanceId === "string") recoveredIds.add(instanceId);
|
||||
}
|
||||
}
|
||||
for (const entry of recovery.equipment) recoveredIds.add(entry.instanceId);
|
||||
return Object.values(assignments).some((loadout) => (
|
||||
loadout && typeof loadout === "object"
|
||||
&& Object.values(loadout).some((instanceId) => typeof instanceId === "string" && recoveredIds.has(instanceId))
|
||||
));
|
||||
}
|
||||
|
||||
function equipmentReferencesUsableInventory(
|
||||
assignments: EquipmentAssignments,
|
||||
profile: CharacterProfile,
|
||||
): boolean {
|
||||
if (!assignments || typeof assignments !== "object" || Array.isArray(assignments)) return false;
|
||||
const itemById = new Map(profile.inventory.map((item) => [item.instanceId, item]));
|
||||
for (const loadout of Object.values(assignments as Record<string, unknown>)) {
|
||||
if (!loadout || typeof loadout !== "object" || Array.isArray(loadout)) return false;
|
||||
for (const rawId of Object.values(loadout as Record<string, unknown>)) {
|
||||
if (typeof rawId !== "string") continue;
|
||||
const item = itemById.get(rawId);
|
||||
if (!item || !inventoryItemUsableForCategory(item, profile.categoryId)) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
interface ProfileGearNormalization {
|
||||
inventory: InventoryItem[];
|
||||
equipment: EquipmentAssignments;
|
||||
gearRecovery?: CharacterGearRecovery;
|
||||
}
|
||||
|
||||
function normalizeProfileGear(
|
||||
rawInventory: unknown,
|
||||
rawEquipment: unknown,
|
||||
ownerCategory: NonNullable<CharacterProfile["categoryId"]>,
|
||||
existingRecovery: unknown,
|
||||
): ProfileGearNormalization {
|
||||
const usableInventory: InventoryItem[] = [];
|
||||
const recoveredItems: GearRecoveryItem[] = [];
|
||||
const rejectedInstanceIds = new Map<string, GearRecoveryReason>();
|
||||
const candidates = Array.isArray(rawInventory) ? rawInventory : [];
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) continue;
|
||||
const normalized = normalizeInventory([candidate], { ownerCategory })[0];
|
||||
if (normalized && inventoryItemUsableForCategory(normalized, ownerCategory)) {
|
||||
usableInventory.push(normalized);
|
||||
continue;
|
||||
}
|
||||
const raw = candidate as Record<string, unknown>;
|
||||
// Keep the old invalid-row behavior for an item that already claims this
|
||||
// profile's namespace but cannot be normalized as an InventoryItem.
|
||||
if (!normalized && raw.contentCategory === ownerCategory) continue;
|
||||
const reason = recoveryReasonForCategory(raw.contentCategory, ownerCategory, normalized);
|
||||
recoveredItems.push({ item: candidate, reason });
|
||||
if (typeof raw.instanceId === "string" && !rejectedInstanceIds.has(raw.instanceId)) {
|
||||
rejectedInstanceIds.set(raw.instanceId, reason);
|
||||
}
|
||||
}
|
||||
|
||||
const recoveredEquipment: GearRecoveryEquipmentReference[] = [];
|
||||
if (rawEquipment && typeof rawEquipment === "object" && !Array.isArray(rawEquipment)) {
|
||||
for (const [ownerId, rawLoadout] of Object.entries(rawEquipment as Record<string, unknown>)) {
|
||||
if (!ownerId || !rawLoadout || typeof rawLoadout !== "object" || Array.isArray(rawLoadout)) continue;
|
||||
for (const [slot, rawInstanceId] of Object.entries(rawLoadout as Record<string, unknown>)) {
|
||||
if (typeof rawInstanceId !== "string") continue;
|
||||
const reason = rejectedInstanceIds.get(rawInstanceId);
|
||||
if (reason) recoveredEquipment.push({ ownerId, slot, instanceId: rawInstanceId, reason });
|
||||
}
|
||||
}
|
||||
}
|
||||
const equipment = normalizeEquipment(rawEquipment, usableInventory, ownerCategory);
|
||||
return {
|
||||
inventory: usableInventory,
|
||||
equipment,
|
||||
gearRecovery: mergeGearRecovery(existingRecovery, { items: recoveredItems, equipment: recoveredEquipment }),
|
||||
};
|
||||
}
|
||||
|
||||
function isCharacterProfile(value: unknown): value is CharacterProfile {
|
||||
@@ -147,6 +348,7 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
|
||||
level?: unknown;
|
||||
inventory?: unknown;
|
||||
equipment?: unknown;
|
||||
gearRecovery?: unknown;
|
||||
manastormProgress?: unknown;
|
||||
settings?: unknown;
|
||||
secondaryClassId?: unknown;
|
||||
@@ -196,14 +398,19 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
|
||||
})) as Record<string, Record<string, string | null>>;
|
||||
const activeLoadoutKey = orderedClassLoadoutKey(value.classId, secondaryClassId);
|
||||
actionLoadouts[activeLoadoutKey] = { ...(actionLoadouts[activeLoadoutKey] ?? actionBindings) };
|
||||
const inventory = normalizeInventory(legacy.inventory);
|
||||
const equipment = normalizeEquipment(legacy.equipment, inventory);
|
||||
const normalizedGear = normalizeProfileGear(
|
||||
legacy.inventory,
|
||||
legacy.equipment,
|
||||
categoryId,
|
||||
legacy.gearRecovery,
|
||||
);
|
||||
const { inventory, equipment, gearRecovery } = normalizedGear;
|
||||
const manastormProgress = normalizeManastormProgress(legacy.manastormProgress);
|
||||
const settings = legacy.settings && typeof legacy.settings === "object" && !Array.isArray(legacy.settings)
|
||||
? { ...(legacy.settings as Partial<GameplaySettings>) }
|
||||
: undefined;
|
||||
const dungeonRunCheckpoint = normalizeDungeonRunCheckpoint(legacy.dungeonRunCheckpoint);
|
||||
return {
|
||||
const normalizedProfile: CharacterProfile = {
|
||||
...profile,
|
||||
level,
|
||||
experience,
|
||||
@@ -220,9 +427,15 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
|
||||
dungeonRunCheckpoint,
|
||||
settings,
|
||||
};
|
||||
if (gearRecovery) normalizedProfile.gearRecovery = gearRecovery;
|
||||
else delete normalizedProfile.gearRecovery;
|
||||
return normalizedProfile;
|
||||
}
|
||||
|
||||
function readDatabase(storage: StorageLike | null = browserLocalStorage()): ProfileDatabase {
|
||||
function readDatabase(
|
||||
storage: StorageLike | null = browserLocalStorage(),
|
||||
persistMigrations = true,
|
||||
): ProfileDatabase {
|
||||
if (!storage) return emptyDatabase();
|
||||
try {
|
||||
const parsed = JSON.parse(storage.getItem(DATABASE_KEY) ?? "null") as ({
|
||||
@@ -233,7 +446,7 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
|
||||
presentationSettingsByOwner?: unknown;
|
||||
uiPreferencesByOwner?: unknown;
|
||||
}) | null;
|
||||
if (!parsed || ![1, 2, 3, 4, 5, 6, 7].includes(parsed.version ?? 0) || !Array.isArray(parsed.accounts) || !parsed.rosters || typeof parsed.rosters !== "object") {
|
||||
if (!parsed || ![1, 2, 3, 4, 5, 6, 7, 8].includes(parsed.version ?? 0) || !Array.isArray(parsed.accounts) || !parsed.rosters || typeof parsed.rosters !== "object") {
|
||||
return emptyDatabase();
|
||||
}
|
||||
const accounts = parsed.accounts.filter((account): account is LocalAccount => Boolean(
|
||||
@@ -296,8 +509,8 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
|
||||
);
|
||||
const rawUi = parsed.uiPreferencesByOwner && typeof parsed.uiPreferencesByOwner === "object" ? parsed.uiPreferencesByOwner : {};
|
||||
const uiPreferencesByOwner = Object.fromEntries(Object.entries(rawUi).map(([owner, value]) => [owner, normalizeUiPreferences(value)]));
|
||||
const database: ProfileDatabase = { version: 7, accounts, rosters, bossCardsByOwner, presentationSettingsByOwner, uiPreferencesByOwner };
|
||||
if (parsed.version !== 7 || presentationSettingsMigrated || cardsMigrated || statRulesetsMigrated) {
|
||||
const database: ProfileDatabase = { version: 8, accounts, rosters, bossCardsByOwner, presentationSettingsByOwner, uiPreferencesByOwner };
|
||||
if (persistMigrations && (parsed.version !== 8 || presentationSettingsMigrated || cardsMigrated || statRulesetsMigrated)) {
|
||||
// A full or read-only device must still expose its existing roster.
|
||||
// Retry migration on the next read when persistence is available.
|
||||
try { writeDatabase(database, storage); } catch { /* Keep the normalized in-memory profiles. */ }
|
||||
@@ -534,10 +747,20 @@ export function replaceCharactersForOwner(
|
||||
? mergeBossCardCollections(roster.map((character) => character.bossCards), "sum")
|
||||
: normalizeBossCardCollection(accountBossCards);
|
||||
database.bossCardsByOwner[ownerId] = mergeBossCardCollections([database.bossCardsByOwner[ownerId], importedCards]);
|
||||
database.rosters[ownerId] = roster.map((character) => normalizeCharacterProfile({
|
||||
const existingRecoveryByCharacterId = new Map(
|
||||
(database.rosters[ownerId] ?? []).map((character) => [character.id, character.gearRecovery]),
|
||||
);
|
||||
database.rosters[ownerId] = roster.map((character) => {
|
||||
const gearRecovery = mergeGearRecovery(
|
||||
existingRecoveryByCharacterId.get(character.id),
|
||||
character.gearRecovery,
|
||||
);
|
||||
return normalizeCharacterProfile({
|
||||
...character,
|
||||
ownerId,
|
||||
}));
|
||||
...(gearRecovery ? { gearRecovery } : {}),
|
||||
});
|
||||
});
|
||||
writeDatabase(database, storage);
|
||||
return listCharacters(ownerId, storage);
|
||||
}
|
||||
@@ -800,7 +1023,11 @@ export function updateCharacterInventory(
|
||||
storage: StorageLike | null = browserLocalStorage(),
|
||||
): CharacterProfile | null {
|
||||
if (!storage) return null;
|
||||
const database = readDatabase(storage);
|
||||
// Do not persist a migration if this mutation is rejected. Existing profile
|
||||
// bytes (including legacy gear and its quarantine state) remain untouched.
|
||||
const database = readDatabase(storage, false);
|
||||
const current = (database.rosters[ownerId] ?? []).find((character) => character.id === characterId);
|
||||
if (!current?.categoryId || !incomingInventoryMatchesCategory(inventory, current.categoryId)) return null;
|
||||
let updated: CharacterProfile | null = null;
|
||||
database.rosters[ownerId] = (database.rosters[ownerId] ?? []).map((character) => {
|
||||
if (character.id !== characterId) return character;
|
||||
@@ -821,7 +1048,15 @@ export function updateCharacterEquipment(
|
||||
storage: StorageLike | null = browserLocalStorage(),
|
||||
): CharacterProfile | null {
|
||||
if (!storage) return null;
|
||||
const database = readDatabase(storage);
|
||||
// Equipment updates are all-or-nothing: never silently erase assignments to
|
||||
// foreign or quarantined inventory during normalization.
|
||||
const database = readDatabase(storage, false);
|
||||
const current = (database.rosters[ownerId] ?? []).find((character) => character.id === characterId);
|
||||
if (
|
||||
!current
|
||||
|| equipmentReferencesRecoveredItem(equipment, current.gearRecovery)
|
||||
|| !equipmentReferencesUsableInventory(equipment, current)
|
||||
) return null;
|
||||
let updated: CharacterProfile | null = null;
|
||||
database.rosters[ownerId] = (database.rosters[ownerId] ?? []).map((character) => {
|
||||
if (character.id !== characterId) return character;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PlayerSession } from "./types";
|
||||
import { EMPTY_ITEM_STATS } from "../game/itemStats";
|
||||
|
||||
function storage() {
|
||||
const values = new Map<string, string>();
|
||||
@@ -101,4 +102,65 @@ describe("account boss card integration", () => {
|
||||
expect(repository.loadAccountBossCards(session.ownerId).counts["wow:lady anacondra"]?.normal).toBe(10);
|
||||
await client.logoutOnlineSession(session);
|
||||
});
|
||||
|
||||
it("uploads cloud gear namespace quarantine once without changing card synchronization", async () => {
|
||||
const device = storage();
|
||||
vi.stubGlobal("window", { localStorage: device });
|
||||
const repository = await import("./accountRepository");
|
||||
const character = repository.createCharacter(draft).character!;
|
||||
const foreignItem = {
|
||||
contentCategory: "rom",
|
||||
instanceId: "foreign-rom-item",
|
||||
itemId: 10412,
|
||||
itemKey: "rom:foreign-item",
|
||||
name: "Foreign RoM item",
|
||||
quality: "rare",
|
||||
inventorySlot: "waist",
|
||||
armorType: "leather",
|
||||
weaponType: null,
|
||||
itemLevel: 17,
|
||||
sourceItemLevel: 21,
|
||||
stats: { ...EMPTY_ITEM_STATS, agility: 4 },
|
||||
quantity: 1,
|
||||
sourceBossKey: "rom:source-boss",
|
||||
sourceBossName: "Source Boss",
|
||||
acquiredAt: 5_000,
|
||||
};
|
||||
let cloudData: unknown = {
|
||||
version: 2,
|
||||
characters: [{
|
||||
...character,
|
||||
inventory: [foreignItem],
|
||||
equipment: { player: { waist: foreignItem.instanceId } },
|
||||
}],
|
||||
uiPreferences: {},
|
||||
bossCards: { counts: {}, lastClaims: {} },
|
||||
};
|
||||
const fetchMock = vi.fn(async (_url: string, init: RequestInit) => {
|
||||
if (init.method === "PUT") cloudData = JSON.parse(String(init.body)).data;
|
||||
return new Response(JSON.stringify({ revision: 1, updatedAt: 1, data: cloudData }), { status: 200 });
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const client = await import("./onlineAccountClient");
|
||||
|
||||
const hydrated = await client.hydrateOnlineSession(session);
|
||||
expect(hydrated[0]?.inventory).toEqual([]);
|
||||
expect(hydrated[0]?.equipment).toEqual({});
|
||||
expect(hydrated[0]?.gearRecovery).toMatchObject({
|
||||
items: [{ item: foreignItem, reason: "foreign-category" }],
|
||||
equipment: [{ ownerId: "player", slot: "waist", instanceId: foreignItem.instanceId, reason: "foreign-category" }],
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(501);
|
||||
const firstPut = fetchMock.mock.calls.filter(([, init]) => init.method === "PUT");
|
||||
expect(firstPut).toHaveLength(1);
|
||||
expect(cloudData).toMatchObject({
|
||||
bossCards: { counts: {}, lastClaims: {} },
|
||||
characters: [{ inventory: [], gearRecovery: { items: [{ reason: "foreign-category" }] } }],
|
||||
});
|
||||
|
||||
await client.hydrateOnlineSession(session);
|
||||
await vi.advanceTimersByTimeAsync(501);
|
||||
expect(fetchMock.mock.calls.filter(([, init]) => init.method === "PUT")).toHaveLength(1);
|
||||
await client.logoutOnlineSession(session);
|
||||
});
|
||||
});
|
||||
@@ -150,6 +150,32 @@ export function retryCloudSave(): void {
|
||||
if (activeSession) queueCloudSave(activeSession.ownerId, listCharacters(activeSession.ownerId));
|
||||
}
|
||||
|
||||
function gearNamespaceSnapshot(value: unknown): string {
|
||||
if (!Array.isArray(value)) return "[]";
|
||||
return JSON.stringify(value.map((candidate) => {
|
||||
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) return candidate;
|
||||
const character = candidate as Record<string, unknown>;
|
||||
const rawInventory = character.inventory;
|
||||
const inventory = Array.isArray(rawInventory)
|
||||
? rawInventory.map((rawItem) => {
|
||||
if (!rawItem || typeof rawItem !== "object" || Array.isArray(rawItem)) return rawItem;
|
||||
const item = rawItem as Record<string, unknown>;
|
||||
return {
|
||||
instanceId: item.instanceId,
|
||||
contentCategory: Object.prototype.hasOwnProperty.call(item, "contentCategory")
|
||||
? item.contentCategory
|
||||
: "[missing-content-category]",
|
||||
};
|
||||
})
|
||||
: rawInventory == null ? [] : rawInventory;
|
||||
return {
|
||||
id: character.id,
|
||||
inventory,
|
||||
gearRecovery: character.gearRecovery ?? null,
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
export async function hydrateOnlineSession(session: PlayerSession): Promise<CharacterProfile[]> {
|
||||
if (!session.accessToken) return listCharacters(session.ownerId);
|
||||
activateOnlineSession(session);
|
||||
@@ -163,7 +189,12 @@ export async function hydrateOnlineSession(session: PlayerSession): Promise<Char
|
||||
suppressUpload = false;
|
||||
}
|
||||
// Publish migrated logs even if the player has not earned another card yet.
|
||||
if (JSON.stringify(cloud.data?.bossCards) !== JSON.stringify(loadAccountBossCards(session.ownerId))) {
|
||||
const gearNamespaceMigrated = gearNamespaceSnapshot(cloud.data?.characters)
|
||||
!== gearNamespaceSnapshot(characters);
|
||||
if (
|
||||
gearNamespaceMigrated
|
||||
|| JSON.stringify(cloud.data?.bossCards) !== JSON.stringify(loadAccountBossCards(session.ownerId))
|
||||
) {
|
||||
queueCloudSave(session.ownerId, characters);
|
||||
}
|
||||
return characters;
|
||||
|
||||
@@ -58,7 +58,7 @@ describe("RoM dual-class persistence", () => {
|
||||
"priest:none": { "primary:face-bottom": "priest-flash-heal" },
|
||||
},
|
||||
});
|
||||
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(7);
|
||||
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(8);
|
||||
});
|
||||
|
||||
it("locks selection to level ten and preserves separate bars through atomic swaps", () => {
|
||||
|
||||
@@ -22,6 +22,28 @@ export interface PlayerSession {
|
||||
accessToken?: string;
|
||||
}
|
||||
|
||||
export type GearRecoveryReason = "missing-category" | "foreign-category" | "invalid-native-payload";
|
||||
|
||||
/** Original rejected gear data kept with its character so migrations never erase it. */
|
||||
export interface GearRecoveryItem {
|
||||
readonly item: unknown;
|
||||
readonly reason: GearRecoveryReason;
|
||||
}
|
||||
|
||||
/** A former loadout reference for an item that is quarantined from active gear. */
|
||||
export interface GearRecoveryEquipmentReference {
|
||||
readonly ownerId: string;
|
||||
readonly slot: string;
|
||||
readonly instanceId: string;
|
||||
readonly reason: GearRecoveryReason;
|
||||
}
|
||||
|
||||
/** Character-scoped recovery for legacy or foreign-namespace gear. */
|
||||
export interface CharacterGearRecovery {
|
||||
readonly items: readonly GearRecoveryItem[];
|
||||
readonly equipment: readonly GearRecoveryEquipmentReference[];
|
||||
}
|
||||
|
||||
export interface CharacterProfile {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
@@ -50,6 +72,8 @@ export interface CharacterProfile {
|
||||
actionLoadouts?: Record<string, Record<string, string | null>>;
|
||||
inventory: InventoryItem[];
|
||||
equipment: EquipmentAssignments;
|
||||
/** Rejected gear remains recoverable here and is excluded from active inventory/loadouts. */
|
||||
gearRecovery?: CharacterGearRecovery;
|
||||
/** Legacy character log, read only during migration to the account collection. */
|
||||
bossCards?: BossCardCollection;
|
||||
/** Present on normalized and newly created profiles; optional for legacy callers. */
|
||||
|
||||
@@ -30,6 +30,8 @@ import { subscribeCombatPresentation, type CombatPresentationEvent } from "./com
|
||||
import { loadCanonicalBenchmarkBuilds } from "./benchmark/benchmarkBuilds";
|
||||
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
|
||||
import { talentModifiersForRanks } from "./talentModifiers";
|
||||
import type { InventoryItem } from "./lootTypes";
|
||||
import { resolvedEquipmentStats } from "./heirloomEquipment";
|
||||
import {
|
||||
ROGUE_DEADLY_POISON_DOSE_TAG,
|
||||
ROGUE_ENVENOM_BUFF_TAG,
|
||||
@@ -63,6 +65,31 @@ function stableUnit(seed: string): number {
|
||||
return (hash >>> 0) / 0x1_0000_0000;
|
||||
}
|
||||
|
||||
function categoryGearFixture(
|
||||
instanceId: string,
|
||||
contentCategory: InventoryItem["contentCategory"],
|
||||
romNative?: InventoryItem["stats"]["romNative"],
|
||||
): InventoryItem {
|
||||
return {
|
||||
contentCategory,
|
||||
instanceId,
|
||||
itemId: 900001,
|
||||
itemKey: instanceId,
|
||||
name: instanceId,
|
||||
quality: "rare",
|
||||
inventorySlot: "back",
|
||||
armorType: null,
|
||||
weaponType: null,
|
||||
itemLevel: 40,
|
||||
sourceItemLevel: 40,
|
||||
stats: { ...EMPTY_ITEM_STATS, stamina: 100, ...(romNative ? { romNative } : {}) },
|
||||
quantity: 1,
|
||||
sourceBossKey: "test-source",
|
||||
sourceBossName: "Test Source",
|
||||
acquiredAt: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function paladinVindicationProcTime(
|
||||
sourceActorId: string,
|
||||
abilityId: string,
|
||||
@@ -1215,6 +1242,52 @@ describe("combat store", () => {
|
||||
expect(useCombatStore.getState().bossCards.counts["wow:lady anacondra"]?.normal).toBe(1);
|
||||
});
|
||||
|
||||
it("derives the owner namespace from the source class and accepts only validated native gear rewards", () => {
|
||||
const foreignWow = categoryGearFixture("foreign-wow", "wow");
|
||||
const missingNative = categoryGearFixture("rom-without-native", "rom");
|
||||
const missingCategory = categoryGearFixture("legacy-without-category", undefined);
|
||||
const nullCategory = categoryGearFixture("explicit-null-category", null);
|
||||
const nativeRom = categoryGearFixture("rom-native", "rom", { stamina: 4 });
|
||||
const sourceProfile = {
|
||||
classId: "rom-priest",
|
||||
categoryId: "wow",
|
||||
level: 40,
|
||||
inventory: [foreignWow, missingNative, missingCategory, nullCategory, nativeRom],
|
||||
equipment: { player: { back: foreignWow.instanceId, chest: missingNative.instanceId } },
|
||||
} as const;
|
||||
useCombatStore.getState().initializeCharacter(sourceProfile);
|
||||
|
||||
expect(useCombatStore.getState().contentCategoryId).toBe("rom");
|
||||
expect(useCombatStore.getState().inventory.map((item) => item.instanceId)).toEqual([nativeRom.instanceId]);
|
||||
expect(useCombatStore.getState().equipment).toEqual({});
|
||||
expect(sourceProfile.inventory.map((item) => item.contentCategory)).toEqual(["wow", "rom", undefined, null, "rom"]);
|
||||
const expectedTrainingOnlyStats = resolvedEquipmentStats({}, [], {
|
||||
ownerId: "player",
|
||||
ownerKind: "player",
|
||||
classId: "rom-priest",
|
||||
secondaryClassId: null,
|
||||
specializationId: null,
|
||||
role: "healer",
|
||||
level: 40,
|
||||
});
|
||||
expect(useCombatStore.getState().gearStats).toEqual(expectedTrainingOnlyStats);
|
||||
|
||||
useCombatStore.getState().registerMob("gear-boundary", { name: "Gear Boundary", maxHealth: 1, hasLoot: true });
|
||||
const nativeLoot = categoryGearFixture("rom-loot", "rom", { agility: 2 });
|
||||
useCombatStore.setState((state) => {
|
||||
const mob = state.mobs["gear-boundary"]!;
|
||||
return { mobs: { ...state.mobs, "gear-boundary": {
|
||||
...mob,
|
||||
dead: true,
|
||||
health: 0,
|
||||
pendingLoot: [foreignWow, missingNative, missingCategory, nullCategory, nativeLoot],
|
||||
} } };
|
||||
});
|
||||
expect(useCombatStore.getState().lootMob("gear-boundary", 10_000)).toBe(true);
|
||||
expect(useCombatStore.getState().inventory.map((item) => item.instanceId))
|
||||
.toEqual([nativeRom.instanceId, nativeLoot.instanceId]);
|
||||
});
|
||||
|
||||
it("aggros every surviving packmate even when the pulled mob dies to the opening hit", () => {
|
||||
for (const [id, maxHealth] of [["one-shot:a", 1], ["one-shot:b", 100], ["outsider", 100]] as const) {
|
||||
useCombatStore.getState().registerMob(id, { name: id, maxHealth, xpReward: 0 });
|
||||
|
||||
+49
-18
@@ -64,11 +64,13 @@ import { useDungeonSimulationStore } from "./dungeonSimulationStore";
|
||||
import { useGameStore } from "./store";
|
||||
import {
|
||||
baseClassFor,
|
||||
contentCategoryForClass,
|
||||
isRomClassId,
|
||||
type ClassId,
|
||||
type GenderId,
|
||||
type RaceId,
|
||||
} from "../app/characterCatalog";
|
||||
import type { ContentCategoryId } from "../app/contentCategories";
|
||||
import { normalizeCharacterStatRuleset, type CharacterStatRuleset } from "./characterStatRuleset";
|
||||
import { applyRomNativeStatModifiers, type RomNativeAdditiveChannel, type RomNativeAttributeKey,
|
||||
type RomNativeDerivedStats, type RomNativeRateChannel, type RomNativeStatModifiers } from "./romNativeStats";
|
||||
@@ -455,7 +457,7 @@ import { bossCardForSource } from "./bossCardCatalog";
|
||||
import { collectBossCardDrop, emptyBossCardCollection, normalizeBossCardCollection, rollBossCards, type BossCardCollection, type BossCardDrop } from "./bossCards";
|
||||
import { adaptRunewakerEnemyDamage } from "./runewakerCombatFormula";
|
||||
import { channelAmountEffects, channelPulseAt, channelPulseCount } from "./combatChannelAmounts";
|
||||
import { normalizeInventory, type InventoryItem } from "./lootTypes";
|
||||
import { inventoryItemUsableForCategory, normalizeInventory, type InventoryItem } from "./lootTypes";
|
||||
import {
|
||||
PLAYER_EQUIPMENT_OWNER_ID,
|
||||
activeEquipmentStatsForClass,
|
||||
@@ -1405,6 +1407,7 @@ export interface ActorHealingRequest {
|
||||
|
||||
export interface CharacterCombatSeed {
|
||||
readonly classId: ClassId;
|
||||
readonly categoryId?: ContentCategoryId;
|
||||
readonly statRuleset?: CharacterStatRuleset;
|
||||
readonly hunterPetFamily?: Wow335HunterPetFamily;
|
||||
readonly specializationId?: string | null;
|
||||
@@ -1570,6 +1573,7 @@ export interface EnemyPeriodicCombatEffect {
|
||||
export interface CombatState {
|
||||
classId: ClassId;
|
||||
statRuleset: CharacterStatRuleset;
|
||||
contentCategoryId: ContentCategoryId;
|
||||
readonly romNativeRecoveryClock?: RomNativeRecoveryClock;
|
||||
hunterPetFamily: Wow335HunterPetFamily;
|
||||
specializationId: string | null;
|
||||
@@ -7799,6 +7803,7 @@ function damageMobInWork(
|
||||
const testing = useGameStore.getState().gmTestMode;
|
||||
const canAward = killed && !mob.experienceGranted && !testing;
|
||||
const beforeLevel = work.progression.level;
|
||||
const ownerCategory = useCombatStore.getState().contentCategoryId;
|
||||
const hasTreasureHunter = work.auras.some((aura) => (
|
||||
aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& (aura.expiresAt === null || aura.expiresAt > now)
|
||||
@@ -7807,9 +7812,10 @@ function damageMobInWork(
|
||||
const pendingLoot = testing ? [] : killed && mob.lootSourceId
|
||||
? rollBossLoot(mob.lootSourceId, beforeLevel, {
|
||||
now,
|
||||
ownerCategory,
|
||||
bonusItemChance: romRogueTreasureHunterLootChance(mob.bonusLootChance, hasTreasureHunter),
|
||||
})
|
||||
: mob.pendingLoot;
|
||||
: mob.pendingLoot.filter((item) => inventoryItemUsableForCategory(item, ownerCategory));
|
||||
const card = killed && mob.boss && !testing
|
||||
? bossCardForSource(mob.lootSourceId ?? mob.id, useGameStore.getState().activeDungeonId, mob.name)
|
||||
: null;
|
||||
@@ -11786,6 +11792,7 @@ function combatActorSnapshots(state: CombatState): Readonly<Record<string, Comba
|
||||
function initialState() {
|
||||
const classId: ClassId = "priest";
|
||||
const statRuleset: CharacterStatRuleset = "wow";
|
||||
const contentCategoryId: ContentCategoryId = "wow";
|
||||
const raceId: RaceId = "human";
|
||||
const gender: GenderId = "male";
|
||||
const level = 1;
|
||||
@@ -11805,6 +11812,7 @@ function initialState() {
|
||||
return {
|
||||
classId,
|
||||
statRuleset,
|
||||
contentCategoryId,
|
||||
hunterPetFamily: "wolf" as const,
|
||||
specializationId: null,
|
||||
gearRole: "healer" as PartyRole,
|
||||
@@ -11893,9 +11901,13 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
: [];
|
||||
const talentModifiers = characterStatModifiers(classId, progression.level, talentRanks);
|
||||
const abilities = abilitiesForCharacter(classId, secondaryClassId);
|
||||
const inventory = normalizeInventory(profile.inventory);
|
||||
const equipment = normalizeEquipment(profile.equipment, inventory);
|
||||
const gearStats = specializationId !== null || classId.startsWith("rom-") ? resolvedEquipmentStats(equipment, inventory, {
|
||||
// The selected source class owns the character namespace; dungeon and
|
||||
// untrusted seed metadata cannot change which game's items are usable.
|
||||
const contentCategoryId = contentCategoryForClass(classId);
|
||||
const inventory = normalizeInventory(profile.inventory, { ownerCategory: contentCategoryId });
|
||||
const categoryInventory = inventory.filter((item) => inventoryItemUsableForCategory(item, contentCategoryId));
|
||||
const equipment = normalizeEquipment(profile.equipment, inventory, contentCategoryId);
|
||||
const gearStats = specializationId !== null || classId.startsWith("rom-") ? resolvedEquipmentStats(equipment, categoryInventory, {
|
||||
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
|
||||
ownerKind: "player",
|
||||
classId,
|
||||
@@ -11903,7 +11915,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
specializationId,
|
||||
role: gearRole,
|
||||
level: progression.level,
|
||||
}) : activeEquipmentStatsForClass(equipment, inventory, PLAYER_EQUIPMENT_OWNER_ID, classId);
|
||||
}) : activeEquipmentStatsForClass(equipment, inventory, PLAYER_EQUIPMENT_OWNER_ID, classId, contentCategoryId);
|
||||
const maxHealth = talentAdjustedMaxHealth(
|
||||
classId,
|
||||
progression.level,
|
||||
@@ -11952,6 +11964,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
raceId,
|
||||
gender,
|
||||
statRuleset,
|
||||
contentCategoryId,
|
||||
romNativeRecoveryClock: undefined,
|
||||
level: progression.level,
|
||||
xp: progression.xp,
|
||||
@@ -11978,7 +11991,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
abilities,
|
||||
talentRanks,
|
||||
),
|
||||
inventory,
|
||||
inventory: categoryInventory,
|
||||
bossCards: normalizeBossCardCollection(profile.bossCards),
|
||||
equipment,
|
||||
gearStats,
|
||||
@@ -11988,6 +12001,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
},
|
||||
|
||||
refreshResolvedGear: () => set((state) => {
|
||||
const categoryInventory = state.inventory.filter((item) => inventoryItemUsableForCategory(item, state.contentCategoryId));
|
||||
const context: HeirloomContext = {
|
||||
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
|
||||
ownerKind: "player",
|
||||
@@ -11998,8 +12012,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
level: state.level,
|
||||
};
|
||||
const gearStats = state.specializationId !== null || state.classId.startsWith("rom-")
|
||||
? resolvedEquipmentStats(state.equipment, state.inventory, context)
|
||||
: activeEquipmentStatsForClass(state.equipment, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
|
||||
? resolvedEquipmentStats(state.equipment, categoryInventory, context)
|
||||
: activeEquipmentStatsForClass(state.equipment, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId, state.contentCategoryId);
|
||||
const maxHealth = auraAdjustedPlayerMaxHealth(
|
||||
state.classId,
|
||||
state.level,
|
||||
@@ -12259,9 +12273,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
lootMob: (id, now = combatNow()) => {
|
||||
const mob = get().mobs[id];
|
||||
if (!mob || !mob.dead || !mob.hasLoot || mob.looted || mob.despawned) return false;
|
||||
const ownerCategory = get().contentCategoryId;
|
||||
const acceptedLoot = mob.pendingLoot.filter((item) => inventoryItemUsableForCategory(item, ownerCategory));
|
||||
const corpseDespawnAt = Math.max(now, (mob.diedAt ?? now) + MINIMUM_CORPSE_VISIBLE_MS);
|
||||
const lootMessage = mob.pendingLoot.length
|
||||
? `Looted ${mob.pendingLoot.map((item) => `${item.name}: ${itemStatSummary(item.stats)}`).join(", ")}.`
|
||||
const lootMessage = acceptedLoot.length
|
||||
? `Looted ${acceptedLoot.map((item) => `${item.name}: ${itemStatSummary(item.stats)}`).join(", ")}.`
|
||||
: `Looted ${mob.name}.`;
|
||||
set((state) => {
|
||||
const bossReward = mob.boss
|
||||
@@ -12269,13 +12285,13 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
id: ++bossLootRewardSequence,
|
||||
bossId: id,
|
||||
bossName: mob.name,
|
||||
items: mob.pendingLoot,
|
||||
items: acceptedLoot,
|
||||
...(mob.bossCardDrop ? { cards: mob.bossCardDrop } : {}),
|
||||
acquiredAt: now,
|
||||
} satisfies BossLootReward
|
||||
: null;
|
||||
return {
|
||||
inventory: [...state.inventory, ...mob.pendingLoot],
|
||||
inventory: [...state.inventory, ...acceptedLoot],
|
||||
bossCards: mob.bossCardDrop ? collectBossCardDrop(state.bossCards, mob.bossCardDrop) : state.bossCards,
|
||||
mobs: {
|
||||
...state.mobs,
|
||||
@@ -12332,6 +12348,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
{
|
||||
secondaryClassId: ownerId === PLAYER_EQUIPMENT_OWNER_ID ? state.secondaryClassId : ownerMember?.secondaryClassId,
|
||||
level: ownerId === PLAYER_EQUIPMENT_OWNER_ID ? state.level : ownerMember?.level,
|
||||
ownerCategory: ownerId === PLAYER_EQUIPMENT_OWNER_ID
|
||||
? state.contentCategoryId
|
||||
: usePartyStore.getState().categoryId ?? contentCategoryForClass(ownerClassId),
|
||||
},
|
||||
);
|
||||
if (!change.ok) {
|
||||
@@ -12342,7 +12361,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const ownerName = ownerId === PLAYER_EQUIPMENT_OWNER_ID
|
||||
? "You"
|
||||
: ownerMember?.name ?? "Companion";
|
||||
const gearStats = state.specializationId !== null || state.classId.startsWith("rom-") ? resolvedEquipmentStats(change.assignments, state.inventory, {
|
||||
const categoryInventory = state.inventory.filter((candidate) => inventoryItemUsableForCategory(
|
||||
candidate,
|
||||
state.contentCategoryId,
|
||||
));
|
||||
const gearStats = state.specializationId !== null || state.classId.startsWith("rom-") ? resolvedEquipmentStats(change.assignments, categoryInventory, {
|
||||
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
|
||||
ownerKind: "player",
|
||||
classId: state.classId,
|
||||
@@ -12350,7 +12373,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
specializationId: state.specializationId,
|
||||
role: state.gearRole,
|
||||
level: state.level,
|
||||
}) : activeEquipmentStatsForClass(change.assignments, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
|
||||
}) : activeEquipmentStatsForClass(change.assignments, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId, state.contentCategoryId);
|
||||
const maxHealth = auraAdjustedPlayerMaxHealth(
|
||||
state.classId,
|
||||
state.level,
|
||||
@@ -12403,7 +12426,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
),
|
||||
feedback: nextFeedback("success", `${ownerName} equipped ${item?.name ?? "the item"}.`),
|
||||
});
|
||||
usePartyStore.getState().syncEquipment(change.assignments, state.inventory);
|
||||
const partyCategory = usePartyStore.getState().categoryId;
|
||||
usePartyStore.getState().syncEquipment(
|
||||
change.assignments,
|
||||
state.inventory.filter((candidate) => inventoryItemUsableForCategory(candidate, partyCategory)),
|
||||
);
|
||||
return true;
|
||||
},
|
||||
|
||||
@@ -12411,7 +12438,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const state = get();
|
||||
const next = unequipInventorySlot(state.equipment, ownerId, slot);
|
||||
if (next === state.equipment) return false;
|
||||
const gearStats = state.specializationId !== null || state.classId.startsWith("rom-") ? resolvedEquipmentStats(next, state.inventory, {
|
||||
const categoryInventory = state.inventory.filter((candidate) => inventoryItemUsableForCategory(
|
||||
candidate,
|
||||
state.contentCategoryId,
|
||||
));
|
||||
const gearStats = state.specializationId !== null || state.classId.startsWith("rom-") ? resolvedEquipmentStats(next, categoryInventory, {
|
||||
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
|
||||
ownerKind: "player",
|
||||
classId: state.classId,
|
||||
@@ -12419,7 +12450,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
specializationId: state.specializationId,
|
||||
role: state.gearRole,
|
||||
level: state.level,
|
||||
}) : activeEquipmentStatsForClass(next, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
|
||||
}) : activeEquipmentStatsForClass(next, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId, state.contentCategoryId);
|
||||
const maxHealth = auraAdjustedPlayerMaxHealth(
|
||||
state.classId,
|
||||
state.level,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
equipInventoryItem,
|
||||
equipmentItemLevel,
|
||||
equipmentStats,
|
||||
activeEquipmentStatsForClass,
|
||||
normalizeEquipment,
|
||||
unequipInventorySlot,
|
||||
} from "./equipment";
|
||||
@@ -26,6 +27,7 @@ function item(
|
||||
itemId: 1,
|
||||
itemKey: instanceId,
|
||||
name: instanceId,
|
||||
contentCategory: "wow",
|
||||
quality: "uncommon",
|
||||
inventorySlot,
|
||||
armorType: null,
|
||||
@@ -66,6 +68,56 @@ describe("equipment assignments", () => {
|
||||
expect(classCanEquipItem("warlock", bow)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects foreign, unresolved, and non-native RoM gear before changing assignments", () => {
|
||||
const wowDagger = item("wow-dagger", "one-hand", { weaponType: "dagger" });
|
||||
const nativeRomDagger = item("rom-dagger", "one-hand", {
|
||||
contentCategory: "rom",
|
||||
weaponType: "dagger",
|
||||
stats: stats({ strength: 12, romNative: { strength: 12 } }),
|
||||
});
|
||||
const unresolvedRomDagger = item("unresolved-rom-dagger", "one-hand", {
|
||||
contentCategory: null,
|
||||
weaponType: "dagger",
|
||||
stats: stats({ strength: 12 }),
|
||||
});
|
||||
const foreign = equipInventoryItem({ player: { back: "kept" } }, [wowDagger], "player", "rom-priest", wowDagger.instanceId);
|
||||
expect(foreign).toEqual({
|
||||
ok: false,
|
||||
assignments: { player: { back: "kept" } },
|
||||
reason: "That item belongs to a different game category.",
|
||||
});
|
||||
expect(equipInventoryItem({}, [nativeRomDagger], "player", "rom-priest", nativeRomDagger.instanceId).ok).toBe(true);
|
||||
expect(equipInventoryItem({}, [unresolvedRomDagger], "player", "rom-priest", unresolvedRomDagger.instanceId).ok).toBe(false);
|
||||
expect(normalizeEquipment({ player: { "main-hand": wowDagger.instanceId } }, [wowDagger], "rom")).toEqual({});
|
||||
|
||||
const forgedCategory = equipInventoryItem(
|
||||
{ player: { back: "kept" } },
|
||||
[wowDagger],
|
||||
"player",
|
||||
"rom-priest",
|
||||
wowDagger.instanceId,
|
||||
{ ownerCategory: "wow" },
|
||||
);
|
||||
expect(forgedCategory.ok).toBe(false);
|
||||
expect(forgedCategory.assignments).toEqual({ player: { back: "kept" } });
|
||||
expect(classCanEquipItem("rom-priest", wowDagger, { ownerCategory: "wow" })).toBe(false);
|
||||
});
|
||||
|
||||
it("does not apply foreign or unresolved items in active equipment stat reads", () => {
|
||||
const romItem = item("rom-native", "back", {
|
||||
contentCategory: "rom",
|
||||
stats: stats({ stamina: 20, romNative: { stamina: 20 } }),
|
||||
});
|
||||
const wowItem = item("wow-foreign", "head", { stats: stats({ stamina: 90 }) });
|
||||
const assignments = { player: { back: romItem.instanceId, head: wowItem.instanceId } };
|
||||
expect(activeEquipmentStatsForClass(assignments, [romItem, wowItem], "player", "rom-priest", "rom"))
|
||||
.toMatchObject({ stamina: 20 });
|
||||
expect(activeEquipmentStatsForClass(assignments, [romItem, wowItem], "player", "rom-priest", "coa"))
|
||||
.toMatchObject({ stamina: 0 });
|
||||
expect(activeEquipmentStatsForClass(assignments, [romItem, wowItem], "player", "rom-priest", "wow"))
|
||||
.toEqual(EMPTY_ITEM_STATS);
|
||||
});
|
||||
|
||||
it("handles two-handed conflicts, unequipping, normalization, and item level", () => {
|
||||
const staff = item("staff", "two-hand", { weaponType: "staff", itemLevel: 18 });
|
||||
const shield = item("shield", "off-hand", { armorType: "shield", itemLevel: 14 });
|
||||
@@ -126,4 +178,21 @@ describe("equipment assignments", () => {
|
||||
expect(deriveRomNativeStats({ classId: "rom-warrior", level: 1, gear: unequipped.romNative }).health.max)
|
||||
.toBe(withoutGear.health.max);
|
||||
});
|
||||
|
||||
it("keeps CoA-tagged direct stats out of the RoM native projection", () => {
|
||||
const coaGear = item("coa-gear", "chest", {
|
||||
contentCategory: "coa",
|
||||
armorType: "plate",
|
||||
stats: stats({ stamina: 7, strength: 3 }),
|
||||
});
|
||||
const active = activeEquipmentStatsForClass(
|
||||
{ player: { chest: coaGear.instanceId } },
|
||||
[coaGear],
|
||||
"player",
|
||||
"guardian",
|
||||
"coa",
|
||||
);
|
||||
expect(active).toMatchObject({ stamina: 7, strength: 3 });
|
||||
expect(active.romNative).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+42
-4
@@ -1,9 +1,11 @@
|
||||
import {
|
||||
baseClassFor,
|
||||
classById,
|
||||
contentCategoryForClass,
|
||||
type BaseClassId,
|
||||
type ClassId,
|
||||
} from "../app/characterCatalog";
|
||||
import type { ContentCategoryId } from "../app/contentCategories";
|
||||
import {
|
||||
EMPTY_ITEM_STATS,
|
||||
ITEM_STAT_KEYS,
|
||||
@@ -11,7 +13,7 @@ import {
|
||||
type NumericItemStatKey,
|
||||
type WeaponStats,
|
||||
} from "./itemStats";
|
||||
import type { InventoryItem } from "./lootTypes";
|
||||
import { inventoryItemUsableForCategory, type InventoryItem } from "./lootTypes";
|
||||
import { romNativeGearFromSharedDirectStats, sumRomNativeGearStats } from "./romNativeStatsTypes";
|
||||
|
||||
export const PLAYER_EQUIPMENT_OWNER_ID = "player";
|
||||
@@ -48,6 +50,8 @@ export interface EquipmentChange {
|
||||
export interface EquipmentClassContext {
|
||||
readonly secondaryClassId?: ClassId | null;
|
||||
readonly level?: number;
|
||||
/** Saved owner category; falls back to the owner's class category when absent. */
|
||||
readonly ownerCategory?: ContentCategoryId;
|
||||
}
|
||||
|
||||
const equipmentSlotSet = new Set<string>(EQUIPMENT_SLOTS);
|
||||
@@ -82,7 +86,15 @@ export function equipmentSlotForItem(item: InventoryItem): EquipmentSlot | null
|
||||
return equipmentSlotSet.has(slot) ? slot as EquipmentSlot : null;
|
||||
}
|
||||
|
||||
export function classCanEquipItem(classId: ClassId, item: InventoryItem, context: EquipmentClassContext = {}): boolean {
|
||||
/**
|
||||
* Internal shape-only predicate for synthetic catalog scoring. Callers must
|
||||
* still use classCanEquipItem before assigning or applying an item.
|
||||
*/
|
||||
export function classItemShapeFitsEquipmentRules(
|
||||
classId: ClassId,
|
||||
item: InventoryItem,
|
||||
context: EquipmentClassContext = {},
|
||||
): boolean {
|
||||
const slot = equipmentSlotForItem(item);
|
||||
if (!slot) return false;
|
||||
const level = context.level ?? Number.POSITIVE_INFINITY;
|
||||
@@ -111,6 +123,13 @@ export function classCanEquipItem(classId: ClassId, item: InventoryItem, context
|
||||
return true;
|
||||
}
|
||||
|
||||
export function classCanEquipItem(classId: ClassId, item: InventoryItem, context: EquipmentClassContext = {}): boolean {
|
||||
const classCategory = contentCategoryForClass(classId);
|
||||
if (context.ownerCategory !== undefined && context.ownerCategory !== classCategory) return false;
|
||||
return inventoryItemUsableForCategory(item, classCategory)
|
||||
&& classItemShapeFitsEquipmentRules(classId, item, context);
|
||||
}
|
||||
|
||||
function mutableAssignments(assignments: EquipmentAssignments): Record<string, EquipmentLoadout> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(assignments).map(([ownerId, loadout]) => [ownerId, { ...loadout }]),
|
||||
@@ -141,6 +160,14 @@ export function equipInventoryItem(
|
||||
): EquipmentChange {
|
||||
const item = inventory.find((candidate) => candidate.instanceId === instanceId);
|
||||
if (!item) return { ok: false, assignments, reason: "That item is no longer in your inventory." };
|
||||
const ownerCategory = contentCategoryForClass(ownerClassId);
|
||||
if ((classContext.ownerCategory !== undefined && classContext.ownerCategory !== ownerCategory)
|
||||
|| !inventoryItemUsableForCategory(item, ownerCategory)) {
|
||||
const reason = ownerCategory === "rom" && item.contentCategory === "rom"
|
||||
? "That item has no validated RuneWaker-native gear values."
|
||||
: "That item belongs to a different game category.";
|
||||
return { ok: false, assignments, reason };
|
||||
}
|
||||
const slot = equipmentSlotForItem(item);
|
||||
if (!slot) return { ok: false, assignments, reason: `${item.name} does not use an equippable slot.` };
|
||||
if (!classCanEquipItem(ownerClassId, item, classContext)) {
|
||||
@@ -206,6 +233,7 @@ export function equipmentItemsForOwner(
|
||||
export function normalizeEquipment(
|
||||
value: unknown,
|
||||
inventory: readonly InventoryItem[],
|
||||
ownerCategory?: ContentCategoryId,
|
||||
): EquipmentAssignments {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
||||
const itemById = new Map(inventory.map((item) => [item.instanceId, item]));
|
||||
@@ -219,6 +247,7 @@ export function normalizeEquipment(
|
||||
if (!equipmentSlotSet.has(rawSlot) || typeof rawInstanceId !== "string" || assigned.has(rawInstanceId)) continue;
|
||||
const item = itemById.get(rawInstanceId);
|
||||
if (!item || equipmentSlotForItem(item) !== rawSlot) continue;
|
||||
if (ownerCategory !== undefined && !inventoryItemUsableForCategory(item, ownerCategory)) continue;
|
||||
loadout[rawSlot as EquipmentSlot] = rawInstanceId;
|
||||
assigned.add(rawInstanceId);
|
||||
}
|
||||
@@ -260,7 +289,11 @@ export function equipmentStats(
|
||||
const stats = instanceId ? itemById.get(instanceId)?.stats : null;
|
||||
if (!stats) continue;
|
||||
for (const key of ITEM_STAT_KEYS) numeric[key] += stats[key];
|
||||
const nativePayload = stats.romNative ?? romNativeGearFromSharedDirectStats(stats);
|
||||
const contentCategory = itemById.get(instanceId)?.contentCategory;
|
||||
const nativePayload = stats.romNative
|
||||
?? (contentCategory === "rom" || contentCategory === "coa"
|
||||
? undefined
|
||||
: romNativeGearFromSharedDirectStats(stats));
|
||||
if (nativePayload) nativePayloads.push(nativePayload);
|
||||
if (stats.meleeWeapon) meleeWeapon = stats.meleeWeapon;
|
||||
if (stats.rangedWeapon) rangedWeapon = stats.rangedWeapon;
|
||||
@@ -275,14 +308,19 @@ export function activeEquipmentStatsForClass(
|
||||
inventory: readonly InventoryItem[],
|
||||
ownerId: string,
|
||||
classId: ClassId,
|
||||
ownerCategory?: ContentCategoryId,
|
||||
): ItemStats {
|
||||
const classCategory = contentCategoryForClass(classId);
|
||||
if (ownerCategory !== undefined && ownerCategory !== classCategory) return EMPTY_ITEM_STATS;
|
||||
const loadout = assignments[ownerId];
|
||||
if (!loadout) return EMPTY_ITEM_STATS;
|
||||
const itemById = new Map(inventory.map((item) => [item.instanceId, item]));
|
||||
const activeAssignments: EquipmentAssignments = {
|
||||
[ownerId]: Object.fromEntries(Object.entries(loadout).filter(([, instanceId]) => {
|
||||
const item = instanceId ? itemById.get(instanceId) : null;
|
||||
return Boolean(item && classCanEquipItem(classId, item));
|
||||
return Boolean(item
|
||||
&& inventoryItemUsableForCategory(item, classCategory)
|
||||
&& classCanEquipItem(classId, item));
|
||||
})),
|
||||
};
|
||||
return equipmentStats(activeAssignments, inventory, ownerId);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ClassId } from "../app/characterCatalog";
|
||||
import { contentCategoryForClass, type ClassId } from "../app/characterCatalog";
|
||||
import { classCanEquipItem, equipmentSlotForItem } from "./equipment";
|
||||
import {
|
||||
compareEquipmentUpgrade,
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
resolvedAverageItemLevel,
|
||||
resolvedEquipmentItems,
|
||||
resolvedEquipmentLoadout,
|
||||
resolvedEquipmentStats,
|
||||
type HeirloomContext,
|
||||
} from "./heirloomEquipment";
|
||||
import { EMPTY_ITEM_STATS, itemStatsFor } from "./itemStats";
|
||||
@@ -33,8 +34,14 @@ function context(profile: SpecializationDefinition, level: number, ownerKind: "p
|
||||
};
|
||||
}
|
||||
|
||||
function catalogItem(template: LootCatalogItem, itemLevel: number, suffix = "test"): InventoryItem {
|
||||
function catalogItem(
|
||||
template: LootCatalogItem,
|
||||
itemLevel: number,
|
||||
suffix = "test",
|
||||
overrides: Partial<InventoryItem> = {},
|
||||
): InventoryItem {
|
||||
return {
|
||||
contentCategory: "wow",
|
||||
instanceId: `${template.itemId}:${itemLevel}:${suffix}`,
|
||||
itemId: template.itemId,
|
||||
itemKey: template.itemKey,
|
||||
@@ -50,6 +57,7 @@ function catalogItem(template: LootCatalogItem, itemLevel: number, suffix = "tes
|
||||
sourceBossKey: "test-boss",
|
||||
sourceBossName: "Test Boss",
|
||||
acquiredAt: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -77,6 +85,59 @@ describe("specialization-aware heirlooms", () => {
|
||||
expect(loadout["off-hand"]?.armorType).toBe("shield");
|
||||
});
|
||||
|
||||
it("keeps RoM training defaults when foreign, unresolved, or non-native assigned items are present", () => {
|
||||
const romContext: HeirloomContext = {
|
||||
ownerId: "rom-owner",
|
||||
ownerKind: "player",
|
||||
classId: "rom-priest",
|
||||
specializationId: null,
|
||||
role: "healer",
|
||||
level: 40,
|
||||
};
|
||||
const chestTemplate = templates.find((template) => template.inventorySlot === "chest" && template.armorType === "cloth")!;
|
||||
const training = resolvedEquipmentLoadout({}, [], romContext).chest!;
|
||||
expect(training).toMatchObject({
|
||||
contentCategory: "rom",
|
||||
quality: "heirloom",
|
||||
sourceBossKey: "heirloom",
|
||||
sourceBossName: "Training Armory",
|
||||
});
|
||||
expect(training.stats.romNative).toBeDefined();
|
||||
|
||||
const trainingSlotWithoutReference = resolvedEquipmentLoadout({}, [], romContext)["off-hand"]!;
|
||||
expect(trainingSlotWithoutReference.stats.romNative).toBeUndefined();
|
||||
expect(equipmentStatsForItems([trainingSlotWithoutReference]).romNative).toBeDefined();
|
||||
const copiedTraining = JSON.parse(JSON.stringify(trainingSlotWithoutReference)) as InventoryItem;
|
||||
expect(copiedTraining).toMatchObject({
|
||||
contentCategory: "rom",
|
||||
instanceId: trainingSlotWithoutReference.instanceId,
|
||||
sourceBossName: "Training Armory",
|
||||
});
|
||||
expect(equipmentStatsForItems([copiedTraining]).romNative).toBeUndefined();
|
||||
expect(classCanEquipItem("rom-priest", copiedTraining)).toBe(false);
|
||||
|
||||
const badAssignedItems = [
|
||||
catalogItem(chestTemplate, 40, "foreign-wow"),
|
||||
catalogItem(chestTemplate, 40, "unresolved", { contentCategory: null }),
|
||||
catalogItem(chestTemplate, 40, "rom-without-native", { contentCategory: "rom" }),
|
||||
];
|
||||
for (const item of badAssignedItems) {
|
||||
expect(classCanEquipItem("rom-priest", item)).toBe(false);
|
||||
const loadout = resolvedEquipmentLoadout({ [romContext.ownerId]: { chest: item.instanceId } }, [item], romContext);
|
||||
expect(loadout.chest).toMatchObject({ sourceBossName: "Training Armory", contentCategory: "rom" });
|
||||
expect(resolvedEquipmentStats({ [romContext.ownerId]: { chest: item.instanceId } }, [item], romContext))
|
||||
.toEqual(resolvedEquipmentStats({}, [], romContext));
|
||||
}
|
||||
|
||||
const nativeItem = catalogItem(chestTemplate, 40, "rom-native", {
|
||||
contentCategory: "rom",
|
||||
stats: itemStatsFor({ ...chestTemplate, itemLevel: 40, romNative: { stamina: 3 } }),
|
||||
});
|
||||
expect(classCanEquipItem("rom-priest", nativeItem)).toBe(true);
|
||||
expect(resolvedEquipmentLoadout({ [romContext.ownerId]: { chest: nativeItem.instanceId } }, [nativeItem], romContext).chest)
|
||||
.toBe(nativeItem);
|
||||
});
|
||||
|
||||
it("allocates only useful archetype stats", () => {
|
||||
const protection = SPECIALIZATION_DEFINITIONS.find((definition) => definition.id === "warrior:protection")!;
|
||||
const holy = SPECIALIZATION_DEFINITIONS.find((definition) => definition.id === "priest:holy")!;
|
||||
@@ -118,10 +179,10 @@ describe("specialization-aware heirlooms", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("opens a real item-level-85 upgrade path for every gear profile at level 80", () => {
|
||||
it("opens an item-level-85 catalog upgrade path for every WoW gear profile at level 80", () => {
|
||||
expect(scaledLootItemLevel(79)).toBe(79);
|
||||
expect(scaledLootItemLevel(80)).toBe(85);
|
||||
for (const profile of SPECIALIZATION_DEFINITIONS) {
|
||||
for (const profile of SPECIALIZATION_DEFINITIONS.filter((candidate) => contentCategoryForClass(candidate.classId) === "wow")) {
|
||||
const loadout = resolvedEquipmentLoadout({}, [], context(profile, 80));
|
||||
const upgrades = compatibleItems(profile.classId, 85)
|
||||
.filter((item) => compareEquipmentUpgrade(item, loadout, profile, context(profile, 80)).isUpgrade);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import {
|
||||
contentCategoryForClass,
|
||||
classById,
|
||||
isRomClassId,
|
||||
type ClassId,
|
||||
} from "../app/characterCatalog";
|
||||
import {
|
||||
classCanEquipItem,
|
||||
classItemShapeFitsEquipmentRules,
|
||||
EQUIPMENT_SLOTS,
|
||||
equipmentSlotForItem,
|
||||
type EquipmentAssignments,
|
||||
@@ -94,6 +96,11 @@ const ARMOR_SLOTS = new Set<EquipmentSlot>([
|
||||
|
||||
const CATALOG_ITEMS = LOOT_CATALOG.bosses.flatMap((boss) => boss.items);
|
||||
const itemCache = new Map<string, InventoryItem>();
|
||||
// Training placeholders are generated internally and are never serialized as
|
||||
// earned inventory. Keep their legacy simulator projection private so it can
|
||||
// preserve the existing RoM training profile without making direct WoW stats
|
||||
// valid on a caller-supplied RoM item.
|
||||
const romTrainingItems = new WeakSet<InventoryItem>();
|
||||
|
||||
function effectiveLevel(context: HeirloomContext): number {
|
||||
const cap = context.ownerKind === "party" ? PARTY_HEIRLOOM_LEVEL_CAP : PLAYER_HEIRLOOM_LEVEL_CAP;
|
||||
@@ -181,6 +188,7 @@ function weaponStats(slot: EquipmentSlot, inventorySlot: string, weaponType: str
|
||||
|
||||
function syntheticCatalogItem(template: LootCatalogItem, itemLevel: number): InventoryItem {
|
||||
return {
|
||||
contentCategory: "wow",
|
||||
instanceId: `catalog:${template.itemId}`,
|
||||
itemId: template.itemId,
|
||||
itemKey: template.itemKey,
|
||||
@@ -203,7 +211,9 @@ function highestCatalogScore(classId: ClassId, slot: EquipmentSlot, level: numbe
|
||||
let highest = 0;
|
||||
for (const template of CATALOG_ITEMS) {
|
||||
const candidate = syntheticCatalogItem(template, level);
|
||||
if (equipmentSlotForItem(candidate) !== slot || !classCanEquipItem(classId, candidate)) continue;
|
||||
// Catalog comparison is an internal shape-only specialization baseline.
|
||||
// These WoW templates never enter inventory or an equipment assignment.
|
||||
if (equipmentSlotForItem(candidate) !== slot || !classItemShapeFitsEquipmentRules(classId, candidate)) continue;
|
||||
highest = Math.max(highest, specializationScore(candidate.stats, profile));
|
||||
}
|
||||
return highest;
|
||||
@@ -286,6 +296,7 @@ function heirloomItem(
|
||||
// internal quality so this label change cannot alter saved builds or budgets.
|
||||
const romTrainingGear = isRomClassId(context.classId);
|
||||
const result: InventoryItem = Object.freeze({
|
||||
contentCategory: contentCategoryForClass(context.classId),
|
||||
instanceId: `heirloom:${context.ownerId}:${profile.id}:${slot}`,
|
||||
itemId: 9_000_000 + stableNumber % 900_000,
|
||||
itemKey: `heirloom-${profile.id.replace(/:/g, "-")}-${slot}`,
|
||||
@@ -302,6 +313,7 @@ function heirloomItem(
|
||||
sourceBossName: romTrainingGear ? "Training Armory" : "Heirloom Armory",
|
||||
acquiredAt: 0,
|
||||
});
|
||||
if (romTrainingGear) romTrainingItems.add(result);
|
||||
itemCache.set(cacheKey, result);
|
||||
return result;
|
||||
}
|
||||
@@ -348,7 +360,12 @@ export function equipmentStatsForItems(items: readonly InventoryItem[]): ItemSta
|
||||
const nativePayloads = [] as NonNullable<ItemStats["romNative"]>[];
|
||||
for (const item of items) {
|
||||
for (const key of ITEM_STAT_KEYS) numeric[key] += item.stats[key];
|
||||
const nativePayload = item.stats.romNative ?? romNativeGearFromSharedDirectStats(item.stats);
|
||||
const nativePayload = item.stats.romNative
|
||||
?? (romTrainingItems.has(item)
|
||||
|| item.contentCategory === undefined
|
||||
|| item.contentCategory === "wow"
|
||||
? romNativeGearFromSharedDirectStats(item.stats)
|
||||
: undefined);
|
||||
if (nativePayload) nativePayloads.push(nativePayload);
|
||||
if (item.stats.meleeWeapon) meleeWeapon = item.stats.meleeWeapon;
|
||||
if (item.stats.rangedWeapon) rangedWeapon = item.stats.rangedWeapon;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import generatedLootCatalog from "./generated/lootCatalog.json";
|
||||
import type { ContentCategoryId } from "../app/contentCategories";
|
||||
import {
|
||||
itemStatsFor,
|
||||
type SourceItemStats,
|
||||
@@ -48,6 +49,8 @@ export interface LootRollOptions {
|
||||
readonly now?: number;
|
||||
/** A single additional weighted item roll after the authored boss rolls. */
|
||||
readonly bonusItemChance?: number;
|
||||
/** Saved character/party category; source identity is never inferred from dungeon. */
|
||||
readonly ownerCategory?: ContentCategoryId;
|
||||
}
|
||||
|
||||
export const LOOT_CATALOG = generatedLootCatalog as LootCatalog;
|
||||
@@ -104,6 +107,7 @@ export function rollBossLoot(
|
||||
if (!table?.items.length) {
|
||||
return rollRunewakerBossLoot(bossKey, playerLevel, options);
|
||||
}
|
||||
if (options.ownerCategory !== undefined && options.ownerCategory !== "wow") return [];
|
||||
const random = options.random ?? Math.random;
|
||||
const acquiredAt = options.now ?? Date.now();
|
||||
const rolls = randomInteger(table.rollsMin, table.rollsMax, random);
|
||||
@@ -117,6 +121,7 @@ export function rollBossLoot(
|
||||
itemInstanceSequence += 1;
|
||||
const scaledItemLevel = template.scalable ? itemLevel : template.sourceItemLevel;
|
||||
drops.push({
|
||||
contentCategory: "wow",
|
||||
instanceId: `${bossKey}:${acquiredAt}:${itemInstanceSequence}`,
|
||||
itemId: template.itemId,
|
||||
itemKey: template.itemKey,
|
||||
@@ -154,5 +159,5 @@ export function rollBossLoot(
|
||||
addWeightedDrop();
|
||||
}
|
||||
|
||||
return allowedLootDrops(drops, playerLevel);
|
||||
return allowedLootDrops(drops, playerLevel, options.ownerCategory);
|
||||
}
|
||||
+10
-4
@@ -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));
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { EMPTY_ITEM_STATS } from "./itemStats";
|
||||
import { inventoryItemMatchesCategory, inventoryItemUsableForCategory, normalizeInventory } from "./lootTypes";
|
||||
|
||||
function savedItem(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
instanceId: "gear:1",
|
||||
itemId: 1,
|
||||
itemKey: "gear-1",
|
||||
name: "Saved Gear",
|
||||
quality: "rare",
|
||||
inventorySlot: "back",
|
||||
armorType: null,
|
||||
weaponType: null,
|
||||
itemLevel: 20,
|
||||
sourceItemLevel: 20,
|
||||
stats: EMPTY_ITEM_STATS,
|
||||
quantity: 1,
|
||||
sourceBossKey: "source-boss",
|
||||
sourceBossName: "Source Boss",
|
||||
acquiredAt: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("inventory item game-category metadata", () => {
|
||||
it("migrates only category-less WoW-owned legacy records", () => {
|
||||
expect(normalizeInventory([savedItem()], { ownerCategory: "wow" })[0].contentCategory).toBe("wow");
|
||||
expect(normalizeInventory([savedItem()], { ownerCategory: "rom" })[0].contentCategory).toBeNull();
|
||||
expect(normalizeInventory([savedItem()], { ownerCategory: "coa" })[0].contentCategory).toBeNull();
|
||||
expect(normalizeInventory([savedItem()])[0].contentCategory).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves explicit category identity, including mismatches and unresolved null", () => {
|
||||
expect(normalizeInventory([savedItem({ contentCategory: "rom" })], { ownerCategory: "wow" })[0].contentCategory)
|
||||
.toBe("rom");
|
||||
expect(normalizeInventory([savedItem({ contentCategory: "coa" })], { ownerCategory: "rom" })[0].contentCategory)
|
||||
.toBe("coa");
|
||||
expect(normalizeInventory([savedItem({ contentCategory: null })], { ownerCategory: "wow" })[0].contentCategory)
|
||||
.toBeNull();
|
||||
});
|
||||
|
||||
it("requires an explicit category and validated native payload for usable RoM gear", () => {
|
||||
const romNative = normalizeInventory([savedItem({
|
||||
contentCategory: "rom",
|
||||
stats: { ...EMPTY_ITEM_STATS, romNative: { strength: 7 } },
|
||||
})])[0];
|
||||
const romWithoutNative = normalizeInventory([savedItem({ contentCategory: "rom" })])[0];
|
||||
expect(inventoryItemMatchesCategory(romNative, "rom")).toBe(true);
|
||||
expect(inventoryItemMatchesCategory(romNative, undefined)).toBe(false);
|
||||
expect(inventoryItemUsableForCategory(romNative, "rom")).toBe(true);
|
||||
expect(inventoryItemUsableForCategory(romWithoutNative, "rom")).toBe(false);
|
||||
expect(inventoryItemUsableForCategory(romNative, "wow")).toBe(false);
|
||||
});
|
||||
});
|
||||
+40
-1
@@ -4,11 +4,15 @@ import {
|
||||
type ItemQuality,
|
||||
type ItemStats,
|
||||
} from "./itemStats";
|
||||
import { normalizeRomNativeGearStats } from "./romNativeStatsTypes";
|
||||
import type { ContentCategoryId } from "../app/contentCategories";
|
||||
|
||||
export type { ItemQuality, ItemStats } from "./itemStats";
|
||||
|
||||
/** A concrete item owned by a character, distinct from its SQL item template. */
|
||||
export interface InventoryItem {
|
||||
/** Source-game namespace. Missing legacy metadata is normalized with the owning profile. */
|
||||
readonly contentCategory?: ContentCategoryId | null;
|
||||
readonly instanceId: string;
|
||||
readonly itemId: number;
|
||||
readonly itemKey: string;
|
||||
@@ -31,8 +35,37 @@ export interface InventoryItem {
|
||||
|
||||
const ITEM_QUALITIES = new Set<ItemQuality>(["common", "uncommon", "rare", "epic", "legendary"]);
|
||||
|
||||
export interface NormalizeInventoryOptions {
|
||||
/** Saved character/party owner; dungeon and loot-source identities are not owner categories. */
|
||||
readonly ownerCategory?: ContentCategoryId;
|
||||
}
|
||||
|
||||
const CONTENT_CATEGORIES = new Set<ContentCategoryId>(["wow", "rom", "coa"]);
|
||||
|
||||
/** Returns true only for an explicit item/owner namespace match. */
|
||||
export function inventoryItemMatchesCategory(
|
||||
item: Pick<InventoryItem, "contentCategory">,
|
||||
expectedCategory: ContentCategoryId | undefined,
|
||||
): boolean {
|
||||
return expectedCategory !== undefined && item.contentCategory === expectedCategory;
|
||||
}
|
||||
|
||||
/** A RoM item is usable only when it carries nonempty validated native values. */
|
||||
export function inventoryItemUsableForCategory(
|
||||
item: Pick<InventoryItem, "contentCategory" | "stats">,
|
||||
expectedCategory: ContentCategoryId | undefined,
|
||||
): boolean {
|
||||
if (!inventoryItemMatchesCategory(item, expectedCategory)) return false;
|
||||
if (expectedCategory !== "rom") return true;
|
||||
const native = normalizeRomNativeGearStats(item.stats?.romNative);
|
||||
return Boolean(native && Object.keys(native).length > 0);
|
||||
}
|
||||
|
||||
/** Safely migrates persisted profiles created before inventory existed. */
|
||||
export function normalizeInventory(value: unknown): InventoryItem[] {
|
||||
export function normalizeInventory(
|
||||
value: unknown,
|
||||
options: NormalizeInventoryOptions = {},
|
||||
): InventoryItem[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const normalized: InventoryItem[] = [];
|
||||
for (const candidate of value) {
|
||||
@@ -53,7 +86,13 @@ export function normalizeInventory(value: unknown): InventoryItem[] {
|
||||
|| !Number.isFinite(item.acquiredAt)
|
||||
) continue;
|
||||
const itemLevel = Math.max(1, Math.min(MAX_PERSISTED_ITEM_LEVEL, Math.trunc(item.itemLevel!)));
|
||||
const contentCategory = CONTENT_CATEGORIES.has(item.contentCategory as ContentCategoryId)
|
||||
? item.contentCategory as ContentCategoryId
|
||||
: item.contentCategory === undefined && options.ownerCategory === "wow"
|
||||
? "wow"
|
||||
: null;
|
||||
normalized.push({
|
||||
contentCategory,
|
||||
instanceId: item.instanceId,
|
||||
itemId: Math.max(1, Math.trunc(item.itemId!)),
|
||||
itemKey: item.itemKey,
|
||||
|
||||
@@ -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!, {
|
||||
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(true);
|
||||
})).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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user