Guard source gear namespaces and retain recoverable legacy records (#9)
This commit is contained in:
1 parent
ab37006932
commit
e567910c04
23 files changed
+1203
-66
No files matched your search
@@ -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!, {
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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