From 308e16f955900767f1eb88851c056346793d5a1e Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Sat, 19 Sep 2026 10:51:55 +0000 Subject: [PATCH] =?UTF-8?q?fix(affix):=20=E6=A0=A1=E9=AA=8C=E8=AF=8D?= =?UTF-8?q?=E7=BC=80classspecific=E8=81=8C=E4=B8=9A=E9=99=90=E5=88=B6?= =?UTF-8?q?=E5=B9=B6=E6=94=AF=E6=8C=81=E8=81=8C=E4=B8=9A=E4=B8=93=E5=B1=9E?= =?UTF-8?q?=E8=A3=85=E5=A4=87classlevelreq=E7=AD=89=E7=BA=A7=E9=9C=80?= =?UTF-8?q?=E6=B1=82=20(fixes=20#132)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/game/affix-generator.ts | 32 ++++++++-- src/game/affixes.ts | 58 ++++++++++++++++-- src/game/drop-pipeline.ts | 27 ++++++++- src/game/item-tooltip.ts | 54 +++++++++++++++-- tests/affixes.test.ts | 33 ++++++++++ tests/parity-1.13c.test.ts | 118 +++++++++++++++++++++++++++++++++++- 6 files changed, 304 insertions(+), 18 deletions(-) diff --git a/src/game/affix-generator.ts b/src/game/affix-generator.ts index 790dcf4..4a5343f 100644 --- a/src/game/affix-generator.ts +++ b/src/game/affix-generator.ts @@ -36,6 +36,9 @@ export interface RolledAffix { readonly group: number readonly level: number readonly levelreq: number + readonly classspecific?: string | undefined + readonly class?: string | undefined + readonly classlevelreq?: number | undefined readonly mods: readonly RolledMod[] readonly id?: number | undefined } @@ -259,6 +262,9 @@ function createRolledAffix(affix: MagicAffix, rng: D2Rng): RolledAffix { group: affix.group, level: affix.level, levelreq: affix.levelreq, + ...(affix.classspecific !== undefined ? { classspecific: affix.classspecific } : {}), + ...(affix.class !== undefined ? { class: affix.class } : {}), + ...(affix.classlevelreq !== undefined ? { classlevelreq: affix.classlevelreq } : {}), mods, id: (affix as any).id, } @@ -294,6 +300,7 @@ function getEligibleRareNames( * @param affixes - Tables containing prefix and suffix tables. * @param isA - Item type inheritance predicate. * @param rng - Unit PRNG instance (D2Rng). + * @param options - Optional eligibility overrides (e.g. itemClass). * @returns Generated magic item affixes and metadata. */ export function rollMagicAffixes( @@ -302,6 +309,7 @@ export function rollMagicAffixes( affixes: { prefixes: AffixTable; suffixes: AffixTable }, isA: Function, rng: D2Rng, + options?: { readonly itemClass?: string | undefined }, ): GeneratedAffixes { const predicate = isA as IsAFunction const initialSeed = rng.lo @@ -309,6 +317,13 @@ export function rollMagicAffixes( const magicLvl = getBaseMagicLvl(base) const alvl = calculateAlvl(ilvl, qlvl, magicLvl) const itemTypeCode = getItemTypeCode(base) + const rawItemClass = options?.itemClass !== undefined ? options.itemClass : (base as any).itemClass ?? (base as any).class + const itemClass = typeof rawItemClass === 'string' ? rawItemClass.trim().toLowerCase() : undefined + const eligibilityOpts: AffixEligibilityOptions = { + alvl, + isRare: false, + ...(itemClass !== undefined ? { itemClass } : {}), + } const usedGroups = new Set() const usedAffixes = new Set() @@ -327,7 +342,7 @@ export function rollMagicAffixes( affixes.prefixes, itemTypeCode, predicate, - { alvl, isRare: false }, + eligibilityOpts, usedGroups, usedAffixes, ) @@ -346,7 +361,7 @@ export function rollMagicAffixes( affixes.suffixes, itemTypeCode, predicate, - { alvl, isRare: false }, + eligibilityOpts, usedGroups, usedAffixes, ) @@ -402,6 +417,7 @@ export function rollMagicAffixes( * @param rareNames - Tables containing rare prefix and suffix name tables. * @param isA - Item type inheritance predicate. * @param rng - Unit PRNG instance (D2Rng). + * @param options - Optional eligibility overrides (e.g. itemClass). * @returns Generated rare item affixes and metadata. */ export function rollRareAffixes( @@ -411,6 +427,7 @@ export function rollRareAffixes( rareNames: { prefixes: RareNameTable; suffixes: RareNameTable }, isA: Function, rng: D2Rng, + options?: { readonly itemClass?: string | undefined }, ): GeneratedRareAffixes { const predicate = isA as IsAFunction const initialSeed = rng.lo @@ -418,6 +435,13 @@ export function rollRareAffixes( const magicLvl = getBaseMagicLvl(base) const alvl = calculateAlvl(ilvl, qlvl, magicLvl) const itemTypeCode = getItemTypeCode(base) + const rawItemClass = options?.itemClass !== undefined ? options.itemClass : (base as any).itemClass ?? (base as any).class + const itemClass = typeof rawItemClass === 'string' ? rawItemClass.trim().toLowerCase() : undefined + const eligibilityOpts: AffixEligibilityOptions = { + alvl, + isRare: true, + ...(itemClass !== undefined ? { itemClass } : {}), + } // 1. Pick Rare Name: 1 eligible RarePrefix + 1 eligible RareSuffix const eligibleRarePrefixes = getEligibleRareNames(rareNames.prefixes, itemTypeCode, predicate) @@ -456,7 +480,7 @@ export function rollRareAffixes( affixes.prefixes, itemTypeCode, predicate, - { alvl, isRare: true }, + eligibilityOpts, usedGroups, usedAffixes, ) @@ -474,7 +498,7 @@ export function rollRareAffixes( affixes.suffixes, itemTypeCode, predicate, - { alvl, isRare: true }, + eligibilityOpts, usedGroups, usedAffixes, ) diff --git a/src/game/affixes.ts b/src/game/affixes.ts index 9191ba1..0cdb92f 100644 --- a/src/game/affixes.ts +++ b/src/game/affixes.ts @@ -84,6 +84,41 @@ export interface AffixEligibilityOptions { alvl?: number /** Whether the target item is rare. */ isRare?: boolean + /** Class-specific 3-letter class code for the target item (e.g. 'ama', 'sor', 'nec', 'pal', 'bar', 'dru', 'ass'; '' for generic). */ + readonly itemClass?: string | undefined +} + +/** + * Canonical 1.13c mapping from 3-letter class code to ItemTypes.txt class-specific type codes. + */ +export const CLASS_SPECIFIC_ITEM_TYPE_MAP: Readonly> = { + ama: ['amaz', 'abow', 'aspe', 'ajav'], + sor: ['sorc', 'orb'], + nec: ['necr', 'head'], + pal: ['pala', 'ashd'], + bar: ['barb', 'phlm'], + dru: ['drui', 'pelt'], + ass: ['assn', 'h2h', 'h2h2'], +} + +/** + * Infers the 3-letter class code ('ama' | 'sor' | 'nec' | 'pal' | 'bar' | 'dru' | 'ass' | '') + * for an `itemTypeCode` using `isA` hierarchy checks. + */ +export function inferItemClassFromType( + itemTypeCode: string, + isA?: (type: string, target: string) => boolean, +): string { + const code = itemTypeCode.trim().toLowerCase() + if (!code) return '' + for (const [cls, targets] of Object.entries(CLASS_SPECIFIC_ITEM_TYPE_MAP)) { + for (const target of targets) { + if (code === target || (isA && isA(itemTypeCode, target))) { + return cls + } + } + } + return '' } /** @@ -121,13 +156,14 @@ export interface AffixTable { * 1. `affix.spawnable` must be true. * 2. If `options.isRare` is true, `affix.rare` must be true. * 3. If `options.alvl` is provided, `affix.level <= options.alvl` and (`affix.maxlevel === 0 || options.alvl <= affix.maxlevel`). - * 4. Item must NOT match any `etype` (`isA(itemTypeCode, etype) === true`). - * 5. Item MUST match at least one `itype` (`isA(itemTypeCode, itype) === true`). + * 4. If `affix.classspecific` is non-empty, the item's effective class (`options.itemClass` or inferred via `isA`) must match `affix.classspecific`. + * 5. Item must NOT match any `etype` (`isA(itemTypeCode, etype) === true`). + * 6. Item MUST match at least one `itype` (`isA(itemTypeCode, itype) === true`). * * @param affix - The magic affix to test. * @param itemTypeCode - Target item type code (e.g. 'swor', 'armo'). * @param isA - Item type hierarchy predicate: `(type, target) => boolean`. - * @param options - Optional filters (alvl and isRare). + * @param options - Optional filters (alvl, isRare, itemClass). * @returns `true` if the affix is eligible. */ export function isAffixEligible( @@ -156,12 +192,24 @@ export function isAffixEligible( } } - // 4. Blacklist check: item must NOT match any etype + // 4. Class-specific restriction check (1.13c MagicPrefix.txt / MagicSuffix.txt `classspecific` column) + const requiredClass = affix.classspecific?.trim().toLowerCase() + if (requiredClass) { + const effectiveItemClass = + options?.itemClass !== undefined + ? options.itemClass.trim().toLowerCase() + : inferItemClassFromType(itemTypeCode, isA) + if (effectiveItemClass !== requiredClass) { + return false + } + } + + // 5. Blacklist check: item must NOT match any etype if (affix.etypes.length > 0 && affix.etypes.some(etype => isA(itemTypeCode, etype))) { return false } - // 5. Whitelist check: item MUST match at least one itype + // 6. Whitelist check: item MUST match at least one itype if (affix.itypes.length === 0 || !affix.itypes.some(itype => isA(itemTypeCode, itype))) { return false } diff --git a/src/game/drop-pipeline.ts b/src/game/drop-pipeline.ts index 0b02593..04238de 100644 --- a/src/game/drop-pipeline.ts +++ b/src/game/drop-pipeline.ts @@ -78,6 +78,7 @@ import { import { type AffixTable, loadMagicAffixes, + inferItemClassFromType, } from './affixes.ts' import { type RareNameTable, @@ -327,6 +328,7 @@ export interface ItemTypeFlags { readonly isAlwaysMagic: boolean readonly canBeRare: boolean readonly isClassSpecific: boolean + readonly itemClass: string } const ALWAYS_MAGIC_BASE_IDS = new Set(['rin', 'amu', 'jew', 'cm1', 'cm2', 'cm3']) @@ -363,6 +365,7 @@ const CLASS_SPECIFIC_TYPE_CODES = new Set([ * Minimum quality for these items is always Magic (never Superior/Normal/Low). * - `canBeRare`: delegates to `canItemBeRare(base, itemTypes, isAPredicate)`. * - `isClassSpecific`: `def.class !== ''` or any `equiv1`/`equiv2` ancestor has `class !== ''`. + * - `itemClass`: First non-empty `def.class` along the `equiv1`/`equiv2` parent chain (or inferred via `isA`), '' for generic items. */ export function resolveItemTypeFlags( base: ItemBase, @@ -390,8 +393,14 @@ export function resolveItemTypeFlags( const canBeRare = canItemBeRare(base, itemTypes, isAPredicate) - let isClassSpecific = CLASS_SPECIFIC_TYPE_CODES.has(typeCode) - if (!isClassSpecific && itemTypes) { + let itemClass = '' + const rawBaseClass = (base as any).itemClass ?? (base as any).class + if (typeof rawBaseClass === 'string' && rawBaseClass.trim() !== '') { + itemClass = rawBaseClass.trim().toLowerCase() + } + + let isClassSpecific = Boolean(itemClass) || CLASS_SPECIFIC_TYPE_CODES.has(typeCode) + if (itemTypes) { const visited = new Set() const queue: string[] = [typeCode] while (queue.length > 0) { @@ -400,18 +409,26 @@ export function resolveItemTypeFlags( visited.add(curr) if (CLASS_SPECIFIC_TYPE_CODES.has(curr)) { isClassSpecific = true - break } const currDef = itemTypes.byCode.get(curr) if (!currDef) continue if (currDef.class && currDef.class.trim() !== '') { isClassSpecific = true + if (!itemClass) { + itemClass = currDef.class.trim().toLowerCase() + } break } if (currDef.equiv1) queue.push(currDef.equiv1) if (currDef.equiv2) queue.push(currDef.equiv2) } } + if (!itemClass) { + itemClass = inferItemClassFromType(typeCode, isAPredicate) + if (itemClass) { + isClassSpecific = true + } + } if (!isClassSpecific && isAPredicate) { if (isAPredicate(typeCode, 'clas')) { isClassSpecific = true @@ -423,6 +440,7 @@ export function resolveItemTypeFlags( isAlwaysMagic, canBeRare, isClassSpecific, + itemClass, } } @@ -907,6 +925,7 @@ export function executeDropPipeline( // Resolve ItemTypes.txt quality & class-specific flags const typeFlags = resolveItemTypeFlags(effectiveBase, dropTables.itemTypes, dropTables.isA) + ;(effectiveBase as any).itemClass = typeFlags.itemClass // Check if the item can roll magical qualities const canHaveQuality = @@ -1044,6 +1063,7 @@ export function executeDropPipeline( dropTables.rareNames, dropTables.isA, itemRng, + { itemClass: typeFlags.itemClass }, ) if (rareAffixes && rareAffixes.affixes && rareAffixes.affixes.length > 0) { const autoPrefixGroup = resolveAutoPrefixGroup(effectiveBase, dropTables.itemTypes, dropTables.isA) @@ -1092,6 +1112,7 @@ export function executeDropPipeline( dropTables.magicAffixes, dropTables.isA, itemRng, + { itemClass: typeFlags.itemClass }, ) const autoPrefixGroup = resolveAutoPrefixGroup(effectiveBase, dropTables.itemTypes, dropTables.isA) const autoMagicAffix = diff --git a/src/game/item-tooltip.ts b/src/game/item-tooltip.ts index 2e68a13..c8dd4e8 100644 --- a/src/game/item-tooltip.ts +++ b/src/game/item-tooltip.ts @@ -13,8 +13,10 @@ import type { Item, ItemBase, RolledItemProp } from './items.ts' import type { UniqueItem, UniqueItemProp } from './unique-items.ts' import type { SetItem, SetItemProp } from './set-items.ts' +import { inferItemClassFromType } from './affixes.ts' import { NON_RANDOM_RANGE_CODES, + getItemTypeCode, type GeneratedAffixes, type GeneratedRareAffixes, type RolledAffix, @@ -1510,6 +1512,38 @@ export function aggregateAndSortProperties(rawProps: readonly RolledItemProp[]): .map(x => x.item) } +function resolveItemClass(item: Item): string { + const base = item.base + const explicit = (item as any).itemClass ?? (base as any).itemClass ?? (base as any).class + if (typeof explicit === 'string' && explicit.trim() !== '') { + return explicit.trim().toLowerCase() + } + const fromType = inferItemClassFromType(getItemTypeCode(base)) + if (fromType) return fromType + if (base.tags && base.tags.length > 0) { + for (const tag of base.tags) { + const fromTag = inferItemClassFromType(tag) + if (fromTag) return fromTag + } + } + return '' +} + +function getAffixEffectiveLevelReq(aff: RolledAffix, itemClass: string): number { + const affClass = (aff.class ?? aff.affix?.class ?? '').trim().toLowerCase() + const affClassLevelReq = aff.classlevelreq ?? aff.affix?.classlevelreq + if ( + itemClass && + affClass && + affClass === itemClass && + affClassLevelReq !== undefined && + affClassLevelReq > 0 + ) { + return affClassLevelReq + } + return aff.levelreq +} + /** * Generates formatted tooltip structure for any dropped Diablo II Item. */ @@ -1517,6 +1551,7 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip { const base = item.base const quality = String(item.rarity || item.quality || 'normal').toLowerCase() const ilvl = item.level ?? item.ilvl ?? 1 + const itemClass = resolveItemClass(item) let title = item.name let subTitle: string | undefined = undefined @@ -1629,7 +1664,8 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip { if (rareDef.name) title = rareDef.name if (rareDef.affixes) { for (const aff of rareDef.affixes) { - if (aff.levelreq > 0) affixReqLevel = Math.max(affixReqLevel, aff.levelreq) + const effReq = getAffixEffectiveLevelReq(aff, itemClass) + if (effReq > 0) affixReqLevel = Math.max(affixReqLevel, effReq) } } } @@ -1664,11 +1700,19 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip { const magicDef = item.rolledMagicAffixes as GeneratedAffixes | undefined if (magicDef) { if (magicDef.name) title = magicDef.name - if (magicDef.prefix && magicDef.prefix.levelreq > 0) { - affixReqLevel = Math.max(affixReqLevel, magicDef.prefix.levelreq) + if (magicDef.prefix) { + const effReq = getAffixEffectiveLevelReq(magicDef.prefix, itemClass) + if (effReq > 0) affixReqLevel = Math.max(affixReqLevel, effReq) } - if (magicDef.suffix && magicDef.suffix.levelreq > 0) { - affixReqLevel = Math.max(affixReqLevel, magicDef.suffix.levelreq) + if (magicDef.suffix) { + const effReq = getAffixEffectiveLevelReq(magicDef.suffix, itemClass) + if (effReq > 0) affixReqLevel = Math.max(affixReqLevel, effReq) + } + if (magicDef.affixes) { + for (const aff of magicDef.affixes) { + const effReq = getAffixEffectiveLevelReq(aff, itemClass) + if (effReq > 0) affixReqLevel = Math.max(affixReqLevel, effReq) + } } } if (item.rolledProps && item.rolledProps.length > 0) { diff --git a/tests/affixes.test.ts b/tests/affixes.test.ts index d12359c..58e2d5a 100644 --- a/tests/affixes.test.ts +++ b/tests/affixes.test.ts @@ -169,6 +169,39 @@ describe('isAffixEligible helper with mock isA', () => { const noItypes = { ...sampleAffix, itypes: [] } expect(isAffixEligible(noItypes, 'swor', mockIsA, { alvl: 30 })).toBe(false) }) + + it('enforces classspecific restriction against explicit options.itemClass and inferred itemTypeCode class', () => { + const classIsA = (type: string, target: string): boolean => { + if (type === target) return true + if ((type === 'swor' || type === 'abow' || type === 'orb') && target === 'weap') return true + if (type === 'abow' && target === 'amaz') return true + if (type === 'orb' && target === 'sorc') return true + return false + } + + const amaSpecificAffix: MagicAffix = { + ...sampleAffix, + name: 'Maiden\'s', + classspecific: 'ama', + class: 'ama', + levelreq: 36, + classlevelreq: 27, + itypes: ['weap'], + etypes: [], + } + + // Generic sword ('swor'): must be false (both inferred and explicit itemClass: '') + expect(isAffixEligible(amaSpecificAffix, 'swor', classIsA, { alvl: 30 })).toBe(false) + expect(isAffixEligible(amaSpecificAffix, 'swor', classIsA, { alvl: 30, itemClass: '' })).toBe(false) + + // Sorceress orb ('orb' / itemClass: 'sor'): must be false + expect(isAffixEligible(amaSpecificAffix, 'orb', classIsA, { alvl: 30 })).toBe(false) + expect(isAffixEligible(amaSpecificAffix, 'orb', classIsA, { alvl: 30, itemClass: 'sor' })).toBe(false) + + // Amazon bow ('abow' / itemClass: 'ama'): must be true + expect(isAffixEligible(amaSpecificAffix, 'abow', classIsA, { alvl: 30 })).toBe(true) + expect(isAffixEligible(amaSpecificAffix, 'abow', classIsA, { alvl: 30, itemClass: 'ama' })).toBe(true) + }) }) describe('Magic Affixes Integration with 1.13c MPQ Archives', () => { diff --git a/tests/parity-1.13c.test.ts b/tests/parity-1.13c.test.ts index dc0ff81..ccd1d53 100644 --- a/tests/parity-1.13c.test.ts +++ b/tests/parity-1.13c.test.ts @@ -20,7 +20,7 @@ import { } from '../src/game/affix-generator.ts' import { formatItemTooltip, aggregateAndSortProperties } from '../src/game/item-tooltip.ts' import { D2Rng } from '../src/game/d2-rng.ts' -import type { MagicAffix, AffixTable } from '../src/game/affixes.ts' +import { isAffixEligible, type MagicAffix, type AffixTable } from '../src/game/affixes.ts' import type { RareNameTable } from '../src/game/rare-names.ts' import type { UpgradableItemBase } from '../src/game/item-upgrade.ts' import type { Item, ItemBase } from '../src/game/items.ts' @@ -1010,4 +1010,120 @@ describe('Superior, Low Quality, Ethereal & Sockets, and Rare Slot-by-Slot Alloc }) }) +describe('Affix classspecific Restriction & classlevelreq Tooltip Level Requirement (Issue #132)', () => { + let dropTables: DropTables + + beforeAll(() => { + dropTables = getEmbeddedDropTables() + }) + + it('1. isAffixEligible blocks classspecific="ama" on generic sword (swor) and other class items (orb / sor), allowing only Amazon items (abow / ama)', () => { + const amaPrefix: MagicAffix = { + name: 'Valkyrie\'s', + isPrefix: true, + version: 100, + spawnable: true, + rare: true, + level: 50, + maxlevel: 0, + levelreq: 42, + classspecific: 'ama', + class: 'ama', + classlevelreq: 42, + frequency: 5, + group: 125, + mods: [{ code: 'ama', min: 2, max: 2 }], + transform: false, + itypes: ['weap'], + etypes: [], + } + + // Generic weapon ('swor'): blocked both via inferred type and explicit itemClass: '' + expect(isAffixEligible(amaPrefix, 'swor', dropTables.isA, { alvl: 60 })).toBe(false) + expect(isAffixEligible(amaPrefix, 'swor', dropTables.isA, { alvl: 60, itemClass: '' })).toBe(false) + + // Sorceress orb ('orb'): blocked both via inferred type and explicit itemClass: 'sor' + expect(isAffixEligible(amaPrefix, 'orb', dropTables.isA, { alvl: 60 })).toBe(false) + expect(isAffixEligible(amaPrefix, 'orb', dropTables.isA, { alvl: 60, itemClass: 'sor' })).toBe(false) + + // Amazon bow ('abow'): allowed both via inferred type and explicit itemClass: 'ama' + expect(isAffixEligible(amaPrefix, 'abow', dropTables.isA, { alvl: 60 })).toBe(true) + expect(isAffixEligible(amaPrefix, 'abow', dropTables.isA, { alvl: 60, itemClass: 'ama' })).toBe(true) + + // Verify resolveItemTypeFlags returns exact 3-letter itemClass for all 7 classes and '' for generic items + expect(resolveItemTypeFlags(dropTables.getBase('am1')!, dropTables.itemTypes, dropTables.isA).itemClass).toBe('ama') + expect(resolveItemTypeFlags(dropTables.getBase('ob1')!, dropTables.itemTypes, dropTables.isA).itemClass).toBe('sor') + expect(resolveItemTypeFlags(dropTables.getBase('ne1')!, dropTables.itemTypes, dropTables.isA).itemClass).toBe('nec') + expect(resolveItemTypeFlags(dropTables.getBase('pa1')!, dropTables.itemTypes, dropTables.isA).itemClass).toBe('pal') + expect(resolveItemTypeFlags(dropTables.getBase('ba1')!, dropTables.itemTypes, dropTables.isA).itemClass).toBe('bar') + expect(resolveItemTypeFlags(dropTables.getBase('dr1')!, dropTables.itemTypes, dropTables.isA).itemClass).toBe('dru') + expect(resolveItemTypeFlags(dropTables.getBase('ktr')!, dropTables.itemTypes, dropTables.isA).itemClass).toBe('ass') + expect(resolveItemTypeFlags(dropTables.getBase('crs')!, dropTables.itemTypes, dropTables.isA).itemClass).toBe('') + }) + + it('2. formatItemTooltip uses classlevelreq when affix.class matches the equipment itemClass, and levelreq on generic items', () => { + // Skilltab affix with levelreq=40 on generic items (e.g. gloves/circlet) but classlevelreq=30 on Amazon items (e.g. Stag Bow) + const bowSkillPrefix: MagicAffix = { + name: 'Fletcher\'s', + isPrefix: true, + version: 100, + spawnable: true, + rare: true, + level: 20, + maxlevel: 0, + levelreq: 40, + class: 'ama', + classlevelreq: 30, + frequency: 10, + group: 125, + mods: [{ code: 'skilltab', param: '0', min: 1, max: 1 }], + transform: false, + itypes: ['weap', 'miss', 'abow', 'swor'], + etypes: [], + } + + const stagBowBase = { ...dropTables.getBase('am1')!, levelreq: 14, itemClass: 'ama' } as ItemBase + const crystalSwordBase = { ...dropTables.getBase('crs')!, levelreq: 11, itemClass: '' } as ItemBase + + const prefixesTable = { all: [bowSkillPrefix] } as unknown as AffixTable + const suffixesTable = { all: [] } as unknown as AffixTable + + // Find seed that rolls prefix + let rolledOnAmaBow = rollMagicAffixes(stagBowBase, 50, { prefixes: prefixesTable, suffixes: suffixesTable }, dropTables.isA, new D2Rng(1)) + let rolledOnGenericSword = rollMagicAffixes(crystalSwordBase, 50, { prefixes: prefixesTable, suffixes: suffixesTable }, dropTables.isA, new D2Rng(1)) + for (let s = 1; s <= 20; s++) { + const c1 = rollMagicAffixes(stagBowBase, 50, { prefixes: prefixesTable, suffixes: suffixesTable }, dropTables.isA, new D2Rng(s)) + const c2 = rollMagicAffixes(crystalSwordBase, 50, { prefixes: prefixesTable, suffixes: suffixesTable }, dropTables.isA, new D2Rng(s)) + if (c1.prefix && c2.prefix) { + rolledOnAmaBow = c1 + rolledOnGenericSword = c2 + break + } + } + + expect(rolledOnAmaBow.prefix?.class).toBe('ama') + expect(rolledOnAmaBow.prefix?.classlevelreq).toBe(30) + expect(rolledOnAmaBow.prefix?.levelreq).toBe(40) + + const amaBowItem = createDroppedItem(stagBowBase, 'magic', { + magicAffixes: rolledOnAmaBow, + ilvl: 50, + dwInitSeed: 13201, + }) + const genericSwordItem = createDroppedItem(crystalSwordBase, 'magic', { + magicAffixes: rolledOnGenericSword, + ilvl: 50, + dwInitSeed: 13202, + }) + + // On Amazon Stag Bow (itemClass: 'ama'), affix.class === 'ama' -> uses classlevelreq (30) instead of levelreq (40) + const amaTooltip = formatItemTooltip(amaBowItem) + expect(amaTooltip.reqLevel).toBe(30) + + // On generic Crystal Sword (itemClass: ''), uses standard levelreq (40) + const swordTooltip = formatItemTooltip(genericSwordItem) + expect(swordTooltip.reqLevel).toBe(40) + }) +}) +