/** * Diablo II Affix Generation System (alvl calculation, group mutex exclusion, magic & rare generation). * * Gold standard references: * - 1.13c MagicPrefix.txt & MagicSuffix.txt (1162 spawnable affixes) * - 1.13c RarePrefix.txt (46 rows) & RareSuffix.txt (155 rows) * - D2Common!6FD77180 / D2Common!Items.cpp (affix generation, alvl formula, and slot constraints) * - .d2s item bitstream specification (1 prefix + 1 suffix for Magic; up to 3 prefixes + 3 suffixes for Rare) */ import type { ItemBase } from './items.ts' import type { MagicAffix, AffixTable, AffixEligibilityOptions } from './affixes.ts' import { isAffixEligible } from './affixes.ts' import type { RareNameEntry, RareNameTable } from './rare-names.ts' import { isRareNameEligible } from './rare-names.ts' import type { D2Rng } from './d2-rng.ts' /** * Rolled modifier entry with concrete rolled value. */ export interface RolledMod { readonly code: string readonly param?: string | undefined readonly min: number readonly max: number readonly value: number } /** * Concrete rolled affix instance on an item. */ export interface RolledAffix { readonly affix: MagicAffix readonly name: string readonly isPrefix: boolean 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 } /** * Result of rolling magic affixes on an item. */ export interface GeneratedAffixes { readonly name: string readonly alvl: number readonly prefix: RolledAffix | null readonly suffix: RolledAffix | null readonly prefixes: readonly RolledAffix[] readonly suffixes: readonly RolledAffix[] readonly affixes: readonly RolledAffix[] readonly baseName: string readonly dwInitSeed?: number | undefined readonly seed?: number | undefined } /** * Result of rolling rare affixes on an item. */ export interface GeneratedRareAffixes extends GeneratedAffixes { readonly rarePrefix?: RareNameEntry | undefined readonly rareSuffix?: RareNameEntry | undefined } /** * Type predicate for item type hierarchy testing. */ export type IsAFunction = (type: string, target: string) => boolean /** * Computes affix level (alvl) according to the 1.13c Diablo II algorithm. * * Formula: * - if (magicLvl > 0) alvl = ilvl + magicLvl * - else if (qlvl > Math.trunc(ilvl / 2)) alvl = ilvl - Math.trunc(qlvl / 2) * - else alvl = ilvl * - alvl = Math.min(alvl, 99) * * @param ilvl - Item drop level (from monster mlvl or area alvl). * @param qlvl - Base item quality level. * @param magicLvl - Optional magic level bonus on base item (e.g. circlets, wands, staves). * @returns Calculated affix level (alvl, capped at 99). */ export function calculateAlvl(ilvl: number, qlvl: number, magicLvl = 0): number { let alvl: number if (magicLvl > 0) { alvl = ilvl + magicLvl } else if (qlvl > Math.trunc(ilvl / 2)) { alvl = ilvl - Math.trunc(qlvl / 2) } else { alvl = ilvl } alvl = Math.min(alvl, 99) return alvl } /** * Resolves the primary item type code for an ItemBase. * * Checks: * 1. `base.type` (if present, e.g. from ArmorEntry/WeaponEntry/MiscItemEntry) * 2. Specific tag in `base.tags` (skipping generic categories 'weap', 'armo', 'misc') * 3. First tag in `base.tags` * 4. `base.code` * 5. `base.id` * * @param base - The base item definition. * @returns 3-4 character item type code (e.g. 'swor', 'helm', 'ring'). */ export function getItemTypeCode(base: ItemBase): string { if ((base as any).type) { return (base as any).type } if (base.tags && base.tags.length > 0) { const specific = base.tags.find(t => t !== 'weap' && t !== 'armo' && t !== 'misc') if (specific) { return specific } return base.tags[0]! } if ((base as any).code) { return (base as any).code } return base.id } /** * Extracts quality level (qlvl) from an ItemBase. */ export function getBaseQlvl(base: ItemBase): number { return (base as any).qlvl ?? base.level ?? 1 } /** * Extracts magic level (magicLvl) from an ItemBase. */ export function getBaseMagicLvl(base: ItemBase): number { return (base as any).magicLvl ?? (base as any).magicLevel ?? 0 } /** * Filters eligible candidate affixes from an AffixTable taking into account group mutex and prior selections. */ function getEligibleCandidates( table: AffixTable, itemTypeCode: string, isA: IsAFunction, options: AffixEligibilityOptions, usedGroups: ReadonlySet, usedAffixes: ReadonlySet, ): MagicAffix[] { const all = table.all ?? (Array.isArray(table) ? table : []) return all.filter(affix => { if (usedAffixes.has(affix)) { return false } if (affix.group > 0 && usedGroups.has(affix.group)) { return false } return isAffixEligible(affix, itemTypeCode, isA, options) }) } /** * Selects an affix from candidate list weighted by `affix.frequency`. * * If all frequencies are 0 (or total weight <= 0), chooses uniformly at random. */ function pickWeightedAffix(candidates: readonly MagicAffix[], rng: D2Rng): MagicAffix | null { if (candidates.length === 0) { return null } let totalWeight = 0 for (const cand of candidates) { totalWeight += Math.max(0, cand.frequency) } if (totalWeight <= 0) { const index = rng.rand(candidates.length) return candidates[index] ?? null } const roll = rng.rand(totalWeight) let acc = 0 for (const cand of candidates) { acc += Math.max(0, cand.frequency) if (roll < acc) { return cand } } return candidates[candidates.length - 1] ?? null } /** * Property codes whose (min, max) define a damage range (e.g. dmg-fire 1-6) * or proc/charge parameters (e.g. hit-skill chance=min, slvl=max; charged charges=min, slvl=max) * rather than a single random value rolled between min and max. */ export const NON_RANDOM_RANGE_CODES: ReadonlySet = new Set([ 'dmg-fire', 'dmg-cold', 'dmg-ltng', 'dmg-mag', 'dmg-pois', 'dmg-norm', 'dmg-elem', 'hit-skill', 'att-skill', 'gethit-skill', 'kill-skill', 'death-skill', 'levelup-skill', 'charged', 'skill-rand', ]) /** * Rolls concrete values for all modifiers of a chosen affix using `rng.randRange(min, max)`. */ function rollAffixMods(affix: MagicAffix, rng: D2Rng): RolledMod[] { return affix.mods.map(mod => { const codeLower = mod.code.toLowerCase() if (NON_RANDOM_RANGE_CODES.has(codeLower)) { return { code: mod.code, ...(mod.param !== undefined ? { param: mod.param } : {}), min: mod.min, max: mod.max, value: mod.min, } } const rolled = rng.randRange(mod.min, mod.max) const value = mod.min === 0 && mod.max === 0 && mod.param !== undefined && mod.param !== '' && !Number.isNaN(Number(mod.param)) ? Number(mod.param) : rolled return { code: mod.code, ...(mod.param !== undefined ? { param: mod.param } : {}), min: mod.min, max: mod.max, value, } }) } /** * Creates a RolledAffix from a selected MagicAffix and rolled mods. */ function createRolledAffix(affix: MagicAffix, rng: D2Rng): RolledAffix { const mods = rollAffixMods(affix, rng) return { affix, name: affix.name, isPrefix: affix.isPrefix, 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, } } /** * Retrieves eligible rare names from a RareNameTable. */ function getEligibleRareNames( table: RareNameTable, itemTypeCode: string, isA: IsAFunction, ): readonly RareNameEntry[] { if (typeof table.getEligible === 'function') { return table.getEligible(itemTypeCode, isA) } if (Array.isArray(table.entries)) { return table.entries.filter(entry => isRareNameEligible(entry, itemTypeCode, isA)) } return [] } /** * Rolls magic affixes for an item: * - 1 prefix slot + 1 suffix slot. * - Randomly choose: {prefix only}, {suffix only}, or {prefix + suffix}. * - Weighted selection by `frequency`. * - Group mutex: when an affix with `group > 0` is chosen, that group is excluded from subsequent rolls. * - Formats name as `[Prefix] [BaseName] [Suffix]`. * * @param base - Base item definition. * @param ilvl - Item level. * @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( base: ItemBase, ilvl: number, affixes: { prefixes: AffixTable; suffixes: AffixTable }, isA: Function, rng: D2Rng, options?: { readonly itemClass?: string | undefined }, ): GeneratedAffixes { const predicate = isA as IsAFunction const initialSeed = rng.lo const qlvl = getBaseQlvl(base) 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() // Randomly choose {prefix only}, {suffix only}, or {prefix + suffix} // 0 = prefix only, 1 = suffix only, 2 = prefix + suffix const slotChoice = rng.rand(3) const wantsPrefix = slotChoice === 0 || slotChoice === 2 const wantsSuffix = slotChoice === 1 || slotChoice === 2 let rolledPrefix: RolledAffix | null = null let rolledSuffix: RolledAffix | null = null if (wantsPrefix) { const candidates = getEligibleCandidates( affixes.prefixes, itemTypeCode, predicate, eligibilityOpts, usedGroups, usedAffixes, ) const picked = pickWeightedAffix(candidates, rng) if (picked) { usedAffixes.add(picked) if (picked.group > 0) { usedGroups.add(picked.group) } rolledPrefix = createRolledAffix(picked, rng) } } if (wantsSuffix) { const candidates = getEligibleCandidates( affixes.suffixes, itemTypeCode, predicate, eligibilityOpts, usedGroups, usedAffixes, ) const picked = pickWeightedAffix(candidates, rng) if (picked) { usedAffixes.add(picked) if (picked.group > 0) { usedGroups.add(picked.group) } rolledSuffix = createRolledAffix(picked, rng) } } // Name format: [Prefix] [BaseName] [Suffix] const nameParts: string[] = [] if (rolledPrefix) { nameParts.push(rolledPrefix.name) } nameParts.push(base.name) if (rolledSuffix) { nameParts.push(rolledSuffix.name) } const name = nameParts.filter(p => p.trim().length > 0).join(' ') const prefixes = rolledPrefix ? [rolledPrefix] : [] const suffixes = rolledSuffix ? [rolledSuffix] : [] const allRolled = [...prefixes, ...suffixes] return { name, alvl, prefix: rolledPrefix, suffix: rolledSuffix, prefixes, suffixes, affixes: allRolled, baseName: base.name, dwInitSeed: initialSeed, seed: initialSeed, } } /** * Rolls rare affixes for an item: * - Up to 3 prefix + up to 3 suffix slots. * - Total affixes: 3 to 6 (at least 1 prefix and 1 suffix, max 3 each). * - Rare name: pick 1 eligible RarePrefix + 1 eligible RareSuffix. * - Mod values rolled using `itemRng.randRange(min, max)`. * * @param base - Base item definition. * @param ilvl - Item level. * @param affixes - Tables containing prefix and suffix tables. * @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( base: ItemBase, ilvl: number, affixes: { prefixes: AffixTable; suffixes: AffixTable }, rareNames: { prefixes: RareNameTable; suffixes: RareNameTable }, isA: Function, rng: D2Rng, options?: { readonly itemClass?: string | undefined }, ): GeneratedRareAffixes { const predicate = isA as IsAFunction const initialSeed = rng.lo const qlvl = getBaseQlvl(base) 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) const eligibleRareSuffixes = getEligibleRareNames(rareNames.suffixes, itemTypeCode, predicate) let rarePrefix: RareNameEntry | undefined if (eligibleRarePrefixes.length > 0) { const idx = rng.rand(eligibleRarePrefixes.length) rarePrefix = eligibleRarePrefixes[idx] } let rareSuffix: RareNameEntry | undefined if (eligibleRareSuffixes.length > 0) { const idx = rng.rand(eligibleRareSuffixes.length) rareSuffix = eligibleRareSuffixes[idx] if ( eligibleRareSuffixes.length > 1 && rarePrefix && rareSuffix && rareSuffix.name.trim().toLowerCase() === rarePrefix.name.trim().toLowerCase() ) { for (let offset = 1; offset < eligibleRareSuffixes.length; offset++) { const candidate = eligibleRareSuffixes[(idx + offset) % eligibleRareSuffixes.length] if (candidate && candidate.name.trim().toLowerCase() !== rarePrefix.name.trim().toLowerCase()) { rareSuffix = candidate break } } } } const rareNameParts = [rarePrefix?.name, rareSuffix?.name].filter( (n): n is string => typeof n === 'string' && n.trim().length > 0, ) const name = rareNameParts.length > 0 ? rareNameParts.join(' ') : base.name // 2. Determine slot distribution (1.13c D2Game!6FC31350): // Total affixes: 3 to 6, rolled slot-by-slot with rng.rand(2) and max 3 prefixes / 3 suffixes const totalAffixes = rng.randRange(3, 6) const usedGroups = new Set() const usedAffixes = new Set() const rolledPrefixes: RolledAffix[] = [] const rolledSuffixes: RolledAffix[] = [] const tryRollSide = (wantPrefix: boolean): boolean => { if (wantPrefix) { if (rolledPrefixes.length >= 3) return false const candidates = getEligibleCandidates( affixes.prefixes, itemTypeCode, predicate, eligibilityOpts, usedGroups, usedAffixes, ) const picked = pickWeightedAffix(candidates, rng) if (!picked) return false usedAffixes.add(picked) if (picked.group > 0) { usedGroups.add(picked.group) } rolledPrefixes.push(createRolledAffix(picked, rng)) return true } else { if (rolledSuffixes.length >= 3) return false const candidates = getEligibleCandidates( affixes.suffixes, itemTypeCode, predicate, eligibilityOpts, usedGroups, usedAffixes, ) const picked = pickWeightedAffix(candidates, rng) if (!picked) return false usedAffixes.add(picked) if (picked.group > 0) { usedGroups.add(picked.group) } rolledSuffixes.push(createRolledAffix(picked, rng)) return true } } for (let slot = 0; slot < totalAffixes; slot++) { let pickPrefix: boolean if (rolledPrefixes.length >= 3) { pickPrefix = false } else if (rolledSuffixes.length >= 3) { pickPrefix = true } else { pickPrefix = rng.rand(2) === 0 } if (!tryRollSide(pickPrefix)) { tryRollSide(!pickPrefix) } } const allRolled = [...rolledPrefixes, ...rolledSuffixes] return { name, alvl, rarePrefix, rareSuffix, prefix: rolledPrefixes[0] ?? null, suffix: rolledSuffixes[0] ?? null, prefixes: rolledPrefixes, suffixes: rolledSuffixes, affixes: allRolled, baseName: base.name, dwInitSeed: initialSeed, seed: initialSeed, } } export { rollAutoMagic, type RolledAutoMagic, type RolledAutoMagicMod, resolveAutoPrefixGroup, ITEM_TYPE_AUTO_PREFIX_MAP, rollStaffMods, resolveStaffModsClass, ITEM_TYPE_STAFF_MODS_MAP, STAFF_MOD_CLASS_SKILL_START, } from './automagic.ts'