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

+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,