import catalogJson from "./generated/romNativeNpcEquipment.generated.json"; export interface RomNativeNpcEquipmentItem { readonly sourceItemId: number; readonly sourceCatalog: "armorobject.db" | "weaponobject.db"; readonly sourceRecordIndex: number; readonly objectKind: "equipment-item"; readonly objectType: number; readonly quality: number; readonly itemLevel: number; readonly gearType: number; readonly gearPosition: number; readonly eqTypeValuePairs: readonly { readonly slot: number; readonly eqType: number; readonly value: number }[]; /** GameObjectItemStruct::DropItemAbility rows; these are drop metadata, not equipment affixes. */ readonly dropAbilityIdRatePairs: readonly { readonly slot: number; readonly attributeId: number; readonly rate: number }[]; readonly durable: number; readonly refineTableId: number; readonly attackRange: number | null; readonly attackSpeed: number | null; readonly refineLevel: number; readonly addPowerQuality: number; readonly collectionNpcId: number; } export interface RomNativeNpcEquipmentCandidate { readonly candidateIndex: number; readonly sourceItemId: number; readonly selectionWeightPercent: number; } export interface RomNativeNpcEquipmentRecord { readonly npcId: number; readonly sourceRecordIndex: number; readonly mainHandCandidates: readonly RomNativeNpcEquipmentCandidate[]; readonly offHandCandidates: readonly RomNativeNpcEquipmentCandidate[]; readonly bowEquipmentId: number; readonly ornamentEquipmentId: number; } export interface RomNativeNpcEquipmentSelection { readonly mainHandCandidateIndex?: number; readonly offHandCandidateIndex?: number; /** Stable seed for the simulator's weighted candidate choice; defaults to npcId. */ readonly selectionSeed?: string | number; } export interface RomNativeNpcRefineAbility { readonly refineObjectId: number; readonly sourceRecordIndex: number; readonly refineType: number; readonly refineLevel: number; readonly eqTypeValuePairs: readonly { readonly slot: number; readonly eqType: number; readonly value: number }[]; readonly exEqPowerRate: number; } export interface RomNativeNpcWornItem { readonly slot: "mainHand" | "offHand" | "bow" | "ornament"; readonly item: RomNativeNpcEquipmentItem; readonly fieldQuality: number; readonly fieldDurability: number; readonly fieldPowerQuality: number; readonly fieldRare: number; readonly effectiveRefineLevel: number; readonly durabilityPowerRate: number; readonly wearPower: number; readonly refineAbilities: readonly RomNativeNpcRefineAbility[]; readonly inherentAttributeIds: readonly number[]; readonly runeIds: readonly number[]; } export interface RomNativeResolvedNpcEquipment { readonly mainHand: RomNativeNpcEquipmentItem | null; readonly offHand: RomNativeNpcEquipmentItem | null; readonly bow: RomNativeNpcEquipmentItem | null; readonly ornament: RomNativeNpcEquipmentItem | null; readonly wornItems: readonly RomNativeNpcWornItem[]; readonly selectedCandidateIndices: { readonly mainHand: number | null; readonly offHand: number | null; }; } export type RomNativeNpcEquipmentResult = | { readonly status: "supported"; readonly value: RomNativeResolvedNpcEquipment } | { readonly status: "unsupported"; readonly reason: string }; interface GeneratedCatalog { readonly npcs: readonly RomNativeNpcEquipmentRecord[]; readonly itemsById: Readonly>; readonly refineItemsById: Readonly>; readonly source: { readonly npcWearDefaults: { readonly fieldDurable: number; readonly fieldQuality: number; readonly fieldPowerQuality: number; readonly fieldRare: number; readonly inherentIds: readonly number[]; readonly runeIds: readonly number[]; }; }; } const catalog = catalogJson as unknown as GeneratedCatalog; const npcById = new Map(catalog.npcs.map((npc) => [npc.npcId, npc])); export function romNativeNpcEquipmentRecord(npcId: number): RomNativeNpcEquipmentRecord | undefined { return npcById.get(npcId); } /** All exported source NPC equipment rows, in generated source order. */ export function romNativeNpcEquipmentRecords(): readonly RomNativeNpcEquipmentRecord[] { return catalog.npcs; } function deterministicBucket(seed: string | number, npcId: number, slot: "mainHand" | "offHand"): number { const key = `rom-native-npc-equipment/v1:${npcId}:${slot}:${String(seed)}`; let hash = 0x811c9dc5; for (let index = 0; index < key.length; index += 1) { hash ^= key.charCodeAt(index); hash = Math.imul(hash, 0x01000193); } return Math.floor((hash >>> 0) * 100 / 0x1_0000_0000); } function deterministicCandidate( npcId: number, slot: "mainHand" | "offHand", candidates: readonly RomNativeNpcEquipmentCandidate[], seed: string | number, ): RomNativeNpcEquipmentCandidate | undefined { const weights = candidates.map((candidate) => candidate.selectionWeightPercent); if (weights.some((weight) => !Number.isFinite(weight) || weight < 0) || Math.abs(weights.reduce((sum, weight) => sum + weight, 0) - 100) > 0.00001) return undefined; const bucket = deterministicBucket(seed, npcId, slot); let cumulative = 0; for (const candidate of candidates) { cumulative += candidate.selectionWeightPercent; if (bucket < cumulative) return candidate; } return undefined; } function resolveCandidate( npcId: number, slot: "mainHand" | "offHand", candidates: readonly RomNativeNpcEquipmentCandidate[], requestedIndex: number | undefined, seed: string | number, ): { readonly status: "supported"; readonly item: RomNativeNpcEquipmentItem | null; readonly index: number } | { readonly status: "unsupported"; readonly reason: string } { if (!candidates.length) return { status: "unsupported", reason: `missing-${slot}-candidates` }; let candidate: RomNativeNpcEquipmentCandidate | undefined; if (requestedIndex !== undefined) { if (!Number.isInteger(requestedIndex) || requestedIndex < 0) { return { status: "unsupported", reason: `invalid-${slot}-candidate-index` }; } candidate = candidates.find((entry) => entry.candidateIndex === requestedIndex); if (!candidate) return { status: "unsupported", reason: `missing-${slot}-candidate` }; } else { candidate = deterministicCandidate(npcId, slot, candidates, seed); if (!candidate) return { status: "unsupported", reason: `invalid-${slot}-candidate-weights` }; } if (candidate.sourceItemId === 0) return { status: "supported", item: null, index: candidate.candidateIndex }; const item = catalog.itemsById[String(candidate.sourceItemId)]; if (!item) return { status: "unsupported", reason: `missing-${slot}-source-item-${candidate.sourceItemId}` }; return { status: "supported", item, index: candidate.candidateIndex }; } function resolveFixedItem( slot: "bow" | "ornament", itemId: number, ): { readonly status: "supported"; readonly item: RomNativeNpcEquipmentItem | null } | { readonly status: "unsupported"; readonly reason: string } { if (itemId === 0) return { status: "supported", item: null }; const item = catalog.itemsById[String(itemId)]; if (!item) return { status: "unsupported", reason: `missing-${slot}-source-item-${itemId}` }; return { status: "supported", item }; } function sourceDurabilityPowerRate(item: RomNativeNpcEquipmentItem, fieldQuality: number, fieldDurability: number): number { const orgDurability = Math.trunc(item.durable * fieldQuality / 100); const fieldDurabilityHundreds = Math.trunc(fieldDurability / 100); if (fieldDurabilityHundreds > 100 || fieldDurabilityHundreds > orgDurability) return 1.2; if (fieldDurabilityHundreds > Math.trunc(orgDurability / 2)) return 1; if (fieldDurabilityHundreds > Math.trunc(orgDurability / 5)) return 0.8; return 0.2; } function wornItem( slot: RomNativeNpcWornItem["slot"], item: RomNativeNpcEquipmentItem, ): RomNativeNpcWornItem | { readonly reason: string } { const defaults = catalog.source.npcWearDefaults; const refineLevel = Math.min(item.refineLevel, 20); const refineAbilities: RomNativeNpcRefineAbility[] = []; if (refineLevel > 0) { if (!Number.isSafeInteger(item.refineTableId) || item.refineTableId <= 0) { return { reason: `missing-${slot}-refine-table-id-${item.sourceItemId}` }; } for (let level = 1; level <= refineLevel; level += 1) { const refineId = item.refineTableId + level - 1; const refine = catalog.refineItemsById[String(refineId)]; if (!refine) return { reason: `missing-${slot}-refine-ability-${refineId}` }; refineAbilities.push(refine); } } const durabilityPowerRate = sourceDurabilityPowerRate(item, defaults.fieldQuality, defaults.fieldDurable); return Object.freeze({ slot, item, fieldQuality: defaults.fieldQuality, fieldDurability: defaults.fieldDurable, fieldPowerQuality: defaults.fieldPowerQuality, fieldRare: defaults.fieldRare, effectiveRefineLevel: refineLevel, durabilityPowerRate, wearPower: defaults.fieldPowerQuality / 10 * durabilityPowerRate, refineAbilities: Object.freeze(refineAbilities), inherentAttributeIds: defaults.inherentIds, runeIds: defaults.runeIds, }); } /** * Resolves one stable weighted NPC equipment outcome. The source's five * candidate slots and 20-percent weights stay intact; a caller seed selects * a reproducible outcome without averaging candidate stats. */ export function resolveRomNativeNpcEquipment( npcId: number, selection: RomNativeNpcEquipmentSelection = {}, ): RomNativeNpcEquipmentResult { const npc = npcById.get(npcId); if (!npc) return { status: "unsupported", reason: "missing-source-npc" }; const seed = selection.selectionSeed ?? npcId; const mainHand = resolveCandidate(npcId, "mainHand", npc.mainHandCandidates, selection.mainHandCandidateIndex, seed); if (mainHand.status !== "supported") return mainHand; const offHand = resolveCandidate(npcId, "offHand", npc.offHandCandidates, selection.offHandCandidateIndex, seed); if (offHand.status !== "supported") return offHand; const bow = resolveFixedItem("bow", npc.bowEquipmentId); if (bow.status !== "supported") return bow; const ornament = resolveFixedItem("ornament", npc.ornamentEquipmentId); if (ornament.status !== "supported") return ornament; const items = [ ["mainHand", mainHand.item], ["offHand", offHand.item], ["bow", bow.item], ["ornament", ornament.item], ] as const; const wornItems: RomNativeNpcWornItem[] = []; for (const [slot, item] of items) { if (!item) continue; const worn = wornItem(slot, item); if ("reason" in worn) return { status: "unsupported", reason: worn.reason }; wornItems.push(worn); } return { status: "supported", value: { mainHand: mainHand.item, offHand: offHand.item, bow: bow.item, ornament: ornament.item, wornItems: Object.freeze(wornItems), selectedCandidateIndices: { mainHand: mainHand.index, offHand: offHand.index }, }, }; }