Compare commits
No commits in common. "0cd799cdcb59e01c471d34c74ab49a91de1cdfdb" and "8063c535ac6ae2c312dd30d72d80b2763df29674" have entirely different histories.
0cd799cdcb
...
8063c535ac
|
|
@ -36,9 +36,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
@ -262,9 +259,6 @@ 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,
|
||||||
}
|
}
|
||||||
|
|
@ -300,7 +294,6 @@ 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(
|
||||||
|
|
@ -309,7 +302,6 @@ 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
|
||||||
|
|
@ -317,13 +309,6 @@ 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>()
|
||||||
|
|
@ -342,7 +327,7 @@ export function rollMagicAffixes(
|
||||||
affixes.prefixes,
|
affixes.prefixes,
|
||||||
itemTypeCode,
|
itemTypeCode,
|
||||||
predicate,
|
predicate,
|
||||||
eligibilityOpts,
|
{ alvl, isRare: false },
|
||||||
usedGroups,
|
usedGroups,
|
||||||
usedAffixes,
|
usedAffixes,
|
||||||
)
|
)
|
||||||
|
|
@ -361,7 +346,7 @@ export function rollMagicAffixes(
|
||||||
affixes.suffixes,
|
affixes.suffixes,
|
||||||
itemTypeCode,
|
itemTypeCode,
|
||||||
predicate,
|
predicate,
|
||||||
eligibilityOpts,
|
{ alvl, isRare: false },
|
||||||
usedGroups,
|
usedGroups,
|
||||||
usedAffixes,
|
usedAffixes,
|
||||||
)
|
)
|
||||||
|
|
@ -417,7 +402,6 @@ 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(
|
||||||
|
|
@ -427,7 +411,6 @@ 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
|
||||||
|
|
@ -435,13 +418,6 @@ 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)
|
||||||
|
|
@ -480,7 +456,7 @@ export function rollRareAffixes(
|
||||||
affixes.prefixes,
|
affixes.prefixes,
|
||||||
itemTypeCode,
|
itemTypeCode,
|
||||||
predicate,
|
predicate,
|
||||||
eligibilityOpts,
|
{ alvl, isRare: true },
|
||||||
usedGroups,
|
usedGroups,
|
||||||
usedAffixes,
|
usedAffixes,
|
||||||
)
|
)
|
||||||
|
|
@ -498,7 +474,7 @@ export function rollRareAffixes(
|
||||||
affixes.suffixes,
|
affixes.suffixes,
|
||||||
itemTypeCode,
|
itemTypeCode,
|
||||||
predicate,
|
predicate,
|
||||||
eligibilityOpts,
|
{ alvl, isRare: true },
|
||||||
usedGroups,
|
usedGroups,
|
||||||
usedAffixes,
|
usedAffixes,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -84,41 +84,6 @@ 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 ''
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -156,14 +121,13 @@ 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. If `affix.classspecific` is non-empty, the item's effective class (`options.itemClass` or inferred via `isA`) must match `affix.classspecific`.
|
* 4. Item must NOT match any `etype` (`isA(itemTypeCode, etype) === true`).
|
||||||
* 5. Item must NOT match any `etype` (`isA(itemTypeCode, etype) === true`).
|
* 5. Item MUST match at least one `itype` (`isA(itemTypeCode, itype) === 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, isRare, itemClass).
|
* @param options - Optional filters (alvl and isRare).
|
||||||
* @returns `true` if the affix is eligible.
|
* @returns `true` if the affix is eligible.
|
||||||
*/
|
*/
|
||||||
export function isAffixEligible(
|
export function isAffixEligible(
|
||||||
|
|
@ -192,24 +156,12 @@ export function isAffixEligible(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Class-specific restriction check (1.13c MagicPrefix.txt / MagicSuffix.txt `classspecific` column)
|
// 4. Blacklist check: item must NOT match any etype
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Whitelist check: item MUST match at least one itype
|
// 5. 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,7 +78,6 @@ import {
|
||||||
import {
|
import {
|
||||||
type AffixTable,
|
type AffixTable,
|
||||||
loadMagicAffixes,
|
loadMagicAffixes,
|
||||||
inferItemClassFromType,
|
|
||||||
} from './affixes.ts'
|
} from './affixes.ts'
|
||||||
import {
|
import {
|
||||||
type RareNameTable,
|
type RareNameTable,
|
||||||
|
|
@ -328,7 +327,6 @@ 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'])
|
||||||
|
|
@ -365,7 +363,6 @@ 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,
|
||||||
|
|
@ -393,14 +390,8 @@ export function resolveItemTypeFlags(
|
||||||
|
|
||||||
const canBeRare = canItemBeRare(base, itemTypes, isAPredicate)
|
const canBeRare = canItemBeRare(base, itemTypes, isAPredicate)
|
||||||
|
|
||||||
let itemClass = ''
|
let isClassSpecific = CLASS_SPECIFIC_TYPE_CODES.has(typeCode)
|
||||||
const rawBaseClass = (base as any).itemClass ?? (base as any).class
|
if (!isClassSpecific && itemTypes) {
|
||||||
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) {
|
||||||
|
|
@ -409,26 +400,18 @@ 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
|
||||||
|
|
@ -440,7 +423,6 @@ export function resolveItemTypeFlags(
|
||||||
isAlwaysMagic,
|
isAlwaysMagic,
|
||||||
canBeRare,
|
canBeRare,
|
||||||
isClassSpecific,
|
isClassSpecific,
|
||||||
itemClass,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -925,7 +907,6 @@ 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 =
|
||||||
|
|
@ -1063,7 +1044,6 @@ 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)
|
||||||
|
|
@ -1112,7 +1092,6 @@ 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,10 +13,8 @@
|
||||||
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,
|
||||||
|
|
@ -1512,38 +1510,6 @@ 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.
|
||||||
*/
|
*/
|
||||||
|
|
@ -1551,7 +1517,6 @@ 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
|
||||||
|
|
@ -1664,8 +1629,7 @@ 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) {
|
||||||
const effReq = getAffixEffectiveLevelReq(aff, itemClass)
|
if (aff.levelreq > 0) affixReqLevel = Math.max(affixReqLevel, aff.levelreq)
|
||||||
if (effReq > 0) affixReqLevel = Math.max(affixReqLevel, effReq)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1700,19 +1664,11 @@ 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) {
|
if (magicDef.prefix && magicDef.prefix.levelreq > 0) {
|
||||||
const effReq = getAffixEffectiveLevelReq(magicDef.prefix, itemClass)
|
affixReqLevel = Math.max(affixReqLevel, magicDef.prefix.levelreq)
|
||||||
if (effReq > 0) affixReqLevel = Math.max(affixReqLevel, effReq)
|
|
||||||
}
|
|
||||||
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 (magicDef.suffix && magicDef.suffix.levelreq > 0) {
|
||||||
|
affixReqLevel = Math.max(affixReqLevel, magicDef.suffix.levelreq)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (item.rolledProps && item.rolledProps.length > 0) {
|
if (item.rolledProps && item.rolledProps.length > 0) {
|
||||||
|
|
|
||||||
|
|
@ -169,39 +169,6 @@ 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 { isAffixEligible, type MagicAffix, type AffixTable } from '../src/game/affixes.ts'
|
import type { MagicAffix, 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,120 +1010,4 @@ 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