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

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

No files matched your search

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