fix(affix): 校验词缀classspecific职业限制并支持职业专属装备classlevelreq等级需求 (fixes #132)
This commit is contained in:
parent
8063c535ac
commit
308e16f955
|
|
@ -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<number>()
|
||||
const usedAffixes = new Set<MagicAffix>()
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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<Record<string, readonly string[]>> = {
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string>()
|
||||
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 =
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue