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 group: number
|
||||||
readonly level: number
|
readonly level: number
|
||||||
readonly levelreq: number
|
readonly levelreq: number
|
||||||
|
readonly classspecific?: string | undefined
|
||||||
|
readonly class?: string | undefined
|
||||||
|
readonly classlevelreq?: number | undefined
|
||||||
readonly mods: readonly RolledMod[]
|
readonly mods: readonly RolledMod[]
|
||||||
readonly id?: number | undefined
|
readonly id?: number | undefined
|
||||||
}
|
}
|
||||||
|
|
@ -259,6 +262,9 @@ function createRolledAffix(affix: MagicAffix, rng: D2Rng): RolledAffix {
|
||||||
group: affix.group,
|
group: affix.group,
|
||||||
level: affix.level,
|
level: affix.level,
|
||||||
levelreq: affix.levelreq,
|
levelreq: affix.levelreq,
|
||||||
|
...(affix.classspecific !== undefined ? { classspecific: affix.classspecific } : {}),
|
||||||
|
...(affix.class !== undefined ? { class: affix.class } : {}),
|
||||||
|
...(affix.classlevelreq !== undefined ? { classlevelreq: affix.classlevelreq } : {}),
|
||||||
mods,
|
mods,
|
||||||
id: (affix as any).id,
|
id: (affix as any).id,
|
||||||
}
|
}
|
||||||
|
|
@ -294,6 +300,7 @@ function getEligibleRareNames(
|
||||||
* @param affixes - Tables containing prefix and suffix tables.
|
* @param affixes - Tables containing prefix and suffix tables.
|
||||||
* @param isA - Item type inheritance predicate.
|
* @param isA - Item type inheritance predicate.
|
||||||
* @param rng - Unit PRNG instance (D2Rng).
|
* @param rng - Unit PRNG instance (D2Rng).
|
||||||
|
* @param options - Optional eligibility overrides (e.g. itemClass).
|
||||||
* @returns Generated magic item affixes and metadata.
|
* @returns Generated magic item affixes and metadata.
|
||||||
*/
|
*/
|
||||||
export function rollMagicAffixes(
|
export function rollMagicAffixes(
|
||||||
|
|
@ -302,6 +309,7 @@ export function rollMagicAffixes(
|
||||||
affixes: { prefixes: AffixTable; suffixes: AffixTable },
|
affixes: { prefixes: AffixTable; suffixes: AffixTable },
|
||||||
isA: Function,
|
isA: Function,
|
||||||
rng: D2Rng,
|
rng: D2Rng,
|
||||||
|
options?: { readonly itemClass?: string | undefined },
|
||||||
): GeneratedAffixes {
|
): GeneratedAffixes {
|
||||||
const predicate = isA as IsAFunction
|
const predicate = isA as IsAFunction
|
||||||
const initialSeed = rng.lo
|
const initialSeed = rng.lo
|
||||||
|
|
@ -309,6 +317,13 @@ export function rollMagicAffixes(
|
||||||
const magicLvl = getBaseMagicLvl(base)
|
const magicLvl = getBaseMagicLvl(base)
|
||||||
const alvl = calculateAlvl(ilvl, qlvl, magicLvl)
|
const alvl = calculateAlvl(ilvl, qlvl, magicLvl)
|
||||||
const itemTypeCode = getItemTypeCode(base)
|
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 usedGroups = new Set<number>()
|
||||||
const usedAffixes = new Set<MagicAffix>()
|
const usedAffixes = new Set<MagicAffix>()
|
||||||
|
|
@ -327,7 +342,7 @@ export function rollMagicAffixes(
|
||||||
affixes.prefixes,
|
affixes.prefixes,
|
||||||
itemTypeCode,
|
itemTypeCode,
|
||||||
predicate,
|
predicate,
|
||||||
{ alvl, isRare: false },
|
eligibilityOpts,
|
||||||
usedGroups,
|
usedGroups,
|
||||||
usedAffixes,
|
usedAffixes,
|
||||||
)
|
)
|
||||||
|
|
@ -346,7 +361,7 @@ export function rollMagicAffixes(
|
||||||
affixes.suffixes,
|
affixes.suffixes,
|
||||||
itemTypeCode,
|
itemTypeCode,
|
||||||
predicate,
|
predicate,
|
||||||
{ alvl, isRare: false },
|
eligibilityOpts,
|
||||||
usedGroups,
|
usedGroups,
|
||||||
usedAffixes,
|
usedAffixes,
|
||||||
)
|
)
|
||||||
|
|
@ -402,6 +417,7 @@ export function rollMagicAffixes(
|
||||||
* @param rareNames - Tables containing rare prefix and suffix name tables.
|
* @param rareNames - Tables containing rare prefix and suffix name tables.
|
||||||
* @param isA - Item type inheritance predicate.
|
* @param isA - Item type inheritance predicate.
|
||||||
* @param rng - Unit PRNG instance (D2Rng).
|
* @param rng - Unit PRNG instance (D2Rng).
|
||||||
|
* @param options - Optional eligibility overrides (e.g. itemClass).
|
||||||
* @returns Generated rare item affixes and metadata.
|
* @returns Generated rare item affixes and metadata.
|
||||||
*/
|
*/
|
||||||
export function rollRareAffixes(
|
export function rollRareAffixes(
|
||||||
|
|
@ -411,6 +427,7 @@ export function rollRareAffixes(
|
||||||
rareNames: { prefixes: RareNameTable; suffixes: RareNameTable },
|
rareNames: { prefixes: RareNameTable; suffixes: RareNameTable },
|
||||||
isA: Function,
|
isA: Function,
|
||||||
rng: D2Rng,
|
rng: D2Rng,
|
||||||
|
options?: { readonly itemClass?: string | undefined },
|
||||||
): GeneratedRareAffixes {
|
): GeneratedRareAffixes {
|
||||||
const predicate = isA as IsAFunction
|
const predicate = isA as IsAFunction
|
||||||
const initialSeed = rng.lo
|
const initialSeed = rng.lo
|
||||||
|
|
@ -418,6 +435,13 @@ export function rollRareAffixes(
|
||||||
const magicLvl = getBaseMagicLvl(base)
|
const magicLvl = getBaseMagicLvl(base)
|
||||||
const alvl = calculateAlvl(ilvl, qlvl, magicLvl)
|
const alvl = calculateAlvl(ilvl, qlvl, magicLvl)
|
||||||
const itemTypeCode = getItemTypeCode(base)
|
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
|
// 1. Pick Rare Name: 1 eligible RarePrefix + 1 eligible RareSuffix
|
||||||
const eligibleRarePrefixes = getEligibleRareNames(rareNames.prefixes, itemTypeCode, predicate)
|
const eligibleRarePrefixes = getEligibleRareNames(rareNames.prefixes, itemTypeCode, predicate)
|
||||||
|
|
@ -456,7 +480,7 @@ export function rollRareAffixes(
|
||||||
affixes.prefixes,
|
affixes.prefixes,
|
||||||
itemTypeCode,
|
itemTypeCode,
|
||||||
predicate,
|
predicate,
|
||||||
{ alvl, isRare: true },
|
eligibilityOpts,
|
||||||
usedGroups,
|
usedGroups,
|
||||||
usedAffixes,
|
usedAffixes,
|
||||||
)
|
)
|
||||||
|
|
@ -474,7 +498,7 @@ export function rollRareAffixes(
|
||||||
affixes.suffixes,
|
affixes.suffixes,
|
||||||
itemTypeCode,
|
itemTypeCode,
|
||||||
predicate,
|
predicate,
|
||||||
{ alvl, isRare: true },
|
eligibilityOpts,
|
||||||
usedGroups,
|
usedGroups,
|
||||||
usedAffixes,
|
usedAffixes,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,41 @@ export interface AffixEligibilityOptions {
|
||||||
alvl?: number
|
alvl?: number
|
||||||
/** Whether the target item is rare. */
|
/** Whether the target item is rare. */
|
||||||
isRare?: boolean
|
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.
|
* 1. `affix.spawnable` must be true.
|
||||||
* 2. If `options.isRare` is true, `affix.rare` 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`).
|
* 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`).
|
* 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 match at least one `itype` (`isA(itemTypeCode, itype) === true`).
|
* 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 affix - The magic affix to test.
|
||||||
* @param itemTypeCode - Target item type code (e.g. 'swor', 'armo').
|
* @param itemTypeCode - Target item type code (e.g. 'swor', 'armo').
|
||||||
* @param isA - Item type hierarchy predicate: `(type, target) => boolean`.
|
* @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.
|
* @returns `true` if the affix is eligible.
|
||||||
*/
|
*/
|
||||||
export function isAffixEligible(
|
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))) {
|
if (affix.etypes.length > 0 && affix.etypes.some(etype => isA(itemTypeCode, etype))) {
|
||||||
return false
|
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))) {
|
if (affix.itypes.length === 0 || !affix.itypes.some(itype => isA(itemTypeCode, itype))) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,7 @@ import {
|
||||||
import {
|
import {
|
||||||
type AffixTable,
|
type AffixTable,
|
||||||
loadMagicAffixes,
|
loadMagicAffixes,
|
||||||
|
inferItemClassFromType,
|
||||||
} from './affixes.ts'
|
} from './affixes.ts'
|
||||||
import {
|
import {
|
||||||
type RareNameTable,
|
type RareNameTable,
|
||||||
|
|
@ -327,6 +328,7 @@ export interface ItemTypeFlags {
|
||||||
readonly isAlwaysMagic: boolean
|
readonly isAlwaysMagic: boolean
|
||||||
readonly canBeRare: boolean
|
readonly canBeRare: boolean
|
||||||
readonly isClassSpecific: boolean
|
readonly isClassSpecific: boolean
|
||||||
|
readonly itemClass: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALWAYS_MAGIC_BASE_IDS = new Set(['rin', 'amu', 'jew', 'cm1', 'cm2', 'cm3'])
|
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).
|
* Minimum quality for these items is always Magic (never Superior/Normal/Low).
|
||||||
* - `canBeRare`: delegates to `canItemBeRare(base, itemTypes, isAPredicate)`.
|
* - `canBeRare`: delegates to `canItemBeRare(base, itemTypes, isAPredicate)`.
|
||||||
* - `isClassSpecific`: `def.class !== ''` or any `equiv1`/`equiv2` ancestor has `class !== ''`.
|
* - `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(
|
export function resolveItemTypeFlags(
|
||||||
base: ItemBase,
|
base: ItemBase,
|
||||||
|
|
@ -390,8 +393,14 @@ export function resolveItemTypeFlags(
|
||||||
|
|
||||||
const canBeRare = canItemBeRare(base, itemTypes, isAPredicate)
|
const canBeRare = canItemBeRare(base, itemTypes, isAPredicate)
|
||||||
|
|
||||||
let isClassSpecific = CLASS_SPECIFIC_TYPE_CODES.has(typeCode)
|
let itemClass = ''
|
||||||
if (!isClassSpecific && itemTypes) {
|
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 visited = new Set<string>()
|
||||||
const queue: string[] = [typeCode]
|
const queue: string[] = [typeCode]
|
||||||
while (queue.length > 0) {
|
while (queue.length > 0) {
|
||||||
|
|
@ -400,18 +409,26 @@ export function resolveItemTypeFlags(
|
||||||
visited.add(curr)
|
visited.add(curr)
|
||||||
if (CLASS_SPECIFIC_TYPE_CODES.has(curr)) {
|
if (CLASS_SPECIFIC_TYPE_CODES.has(curr)) {
|
||||||
isClassSpecific = true
|
isClassSpecific = true
|
||||||
break
|
|
||||||
}
|
}
|
||||||
const currDef = itemTypes.byCode.get(curr)
|
const currDef = itemTypes.byCode.get(curr)
|
||||||
if (!currDef) continue
|
if (!currDef) continue
|
||||||
if (currDef.class && currDef.class.trim() !== '') {
|
if (currDef.class && currDef.class.trim() !== '') {
|
||||||
isClassSpecific = true
|
isClassSpecific = true
|
||||||
|
if (!itemClass) {
|
||||||
|
itemClass = currDef.class.trim().toLowerCase()
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if (currDef.equiv1) queue.push(currDef.equiv1)
|
if (currDef.equiv1) queue.push(currDef.equiv1)
|
||||||
if (currDef.equiv2) queue.push(currDef.equiv2)
|
if (currDef.equiv2) queue.push(currDef.equiv2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!itemClass) {
|
||||||
|
itemClass = inferItemClassFromType(typeCode, isAPredicate)
|
||||||
|
if (itemClass) {
|
||||||
|
isClassSpecific = true
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!isClassSpecific && isAPredicate) {
|
if (!isClassSpecific && isAPredicate) {
|
||||||
if (isAPredicate(typeCode, 'clas')) {
|
if (isAPredicate(typeCode, 'clas')) {
|
||||||
isClassSpecific = true
|
isClassSpecific = true
|
||||||
|
|
@ -423,6 +440,7 @@ export function resolveItemTypeFlags(
|
||||||
isAlwaysMagic,
|
isAlwaysMagic,
|
||||||
canBeRare,
|
canBeRare,
|
||||||
isClassSpecific,
|
isClassSpecific,
|
||||||
|
itemClass,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -907,6 +925,7 @@ export function executeDropPipeline(
|
||||||
|
|
||||||
// Resolve ItemTypes.txt quality & class-specific flags
|
// Resolve ItemTypes.txt quality & class-specific flags
|
||||||
const typeFlags = resolveItemTypeFlags(effectiveBase, dropTables.itemTypes, dropTables.isA)
|
const typeFlags = resolveItemTypeFlags(effectiveBase, dropTables.itemTypes, dropTables.isA)
|
||||||
|
;(effectiveBase as any).itemClass = typeFlags.itemClass
|
||||||
|
|
||||||
// Check if the item can roll magical qualities
|
// Check if the item can roll magical qualities
|
||||||
const canHaveQuality =
|
const canHaveQuality =
|
||||||
|
|
@ -1044,6 +1063,7 @@ export function executeDropPipeline(
|
||||||
dropTables.rareNames,
|
dropTables.rareNames,
|
||||||
dropTables.isA,
|
dropTables.isA,
|
||||||
itemRng,
|
itemRng,
|
||||||
|
{ itemClass: typeFlags.itemClass },
|
||||||
)
|
)
|
||||||
if (rareAffixes && rareAffixes.affixes && rareAffixes.affixes.length > 0) {
|
if (rareAffixes && rareAffixes.affixes && rareAffixes.affixes.length > 0) {
|
||||||
const autoPrefixGroup = resolveAutoPrefixGroup(effectiveBase, dropTables.itemTypes, dropTables.isA)
|
const autoPrefixGroup = resolveAutoPrefixGroup(effectiveBase, dropTables.itemTypes, dropTables.isA)
|
||||||
|
|
@ -1092,6 +1112,7 @@ export function executeDropPipeline(
|
||||||
dropTables.magicAffixes,
|
dropTables.magicAffixes,
|
||||||
dropTables.isA,
|
dropTables.isA,
|
||||||
itemRng,
|
itemRng,
|
||||||
|
{ itemClass: typeFlags.itemClass },
|
||||||
)
|
)
|
||||||
const autoPrefixGroup = resolveAutoPrefixGroup(effectiveBase, dropTables.itemTypes, dropTables.isA)
|
const autoPrefixGroup = resolveAutoPrefixGroup(effectiveBase, dropTables.itemTypes, dropTables.isA)
|
||||||
const autoMagicAffix =
|
const autoMagicAffix =
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,10 @@
|
||||||
import type { Item, ItemBase, RolledItemProp } from './items.ts'
|
import type { Item, ItemBase, RolledItemProp } from './items.ts'
|
||||||
import type { UniqueItem, UniqueItemProp } from './unique-items.ts'
|
import type { UniqueItem, UniqueItemProp } from './unique-items.ts'
|
||||||
import type { SetItem, SetItemProp } from './set-items.ts'
|
import type { SetItem, SetItemProp } from './set-items.ts'
|
||||||
|
import { inferItemClassFromType } from './affixes.ts'
|
||||||
import {
|
import {
|
||||||
NON_RANDOM_RANGE_CODES,
|
NON_RANDOM_RANGE_CODES,
|
||||||
|
getItemTypeCode,
|
||||||
type GeneratedAffixes,
|
type GeneratedAffixes,
|
||||||
type GeneratedRareAffixes,
|
type GeneratedRareAffixes,
|
||||||
type RolledAffix,
|
type RolledAffix,
|
||||||
|
|
@ -1510,6 +1512,38 @@ export function aggregateAndSortProperties(rawProps: readonly RolledItemProp[]):
|
||||||
.map(x => x.item)
|
.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.
|
* 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 base = item.base
|
||||||
const quality = String(item.rarity || item.quality || 'normal').toLowerCase()
|
const quality = String(item.rarity || item.quality || 'normal').toLowerCase()
|
||||||
const ilvl = item.level ?? item.ilvl ?? 1
|
const ilvl = item.level ?? item.ilvl ?? 1
|
||||||
|
const itemClass = resolveItemClass(item)
|
||||||
|
|
||||||
let title = item.name
|
let title = item.name
|
||||||
let subTitle: string | undefined = undefined
|
let subTitle: string | undefined = undefined
|
||||||
|
|
@ -1629,7 +1664,8 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip {
|
||||||
if (rareDef.name) title = rareDef.name
|
if (rareDef.name) title = rareDef.name
|
||||||
if (rareDef.affixes) {
|
if (rareDef.affixes) {
|
||||||
for (const aff of 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
|
const magicDef = item.rolledMagicAffixes as GeneratedAffixes | undefined
|
||||||
if (magicDef) {
|
if (magicDef) {
|
||||||
if (magicDef.name) title = magicDef.name
|
if (magicDef.name) title = magicDef.name
|
||||||
if (magicDef.prefix && magicDef.prefix.levelreq > 0) {
|
if (magicDef.prefix) {
|
||||||
affixReqLevel = Math.max(affixReqLevel, magicDef.prefix.levelreq)
|
const effReq = getAffixEffectiveLevelReq(magicDef.prefix, itemClass)
|
||||||
|
if (effReq > 0) affixReqLevel = Math.max(affixReqLevel, effReq)
|
||||||
}
|
}
|
||||||
if (magicDef.suffix && magicDef.suffix.levelreq > 0) {
|
if (magicDef.suffix) {
|
||||||
affixReqLevel = Math.max(affixReqLevel, magicDef.suffix.levelreq)
|
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) {
|
if (item.rolledProps && item.rolledProps.length > 0) {
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,39 @@ describe('isAffixEligible helper with mock isA', () => {
|
||||||
const noItypes = { ...sampleAffix, itypes: [] }
|
const noItypes = { ...sampleAffix, itypes: [] }
|
||||||
expect(isAffixEligible(noItypes, 'swor', mockIsA, { alvl: 30 })).toBe(false)
|
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', () => {
|
describe('Magic Affixes Integration with 1.13c MPQ Archives', () => {
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ import {
|
||||||
} from '../src/game/affix-generator.ts'
|
} from '../src/game/affix-generator.ts'
|
||||||
import { formatItemTooltip, aggregateAndSortProperties } from '../src/game/item-tooltip.ts'
|
import { formatItemTooltip, aggregateAndSortProperties } from '../src/game/item-tooltip.ts'
|
||||||
import { D2Rng } from '../src/game/d2-rng.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 { RareNameTable } from '../src/game/rare-names.ts'
|
||||||
import type { UpgradableItemBase } from '../src/game/item-upgrade.ts'
|
import type { UpgradableItemBase } from '../src/game/item-upgrade.ts'
|
||||||
import type { Item, ItemBase } from '../src/game/items.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