diablo2-web/src/game/affix-generator.ts

526 lines
15 KiB
TypeScript

/**
* 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 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<number>,
usedAffixes: ReadonlySet<MagicAffix>,
): 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
}
/**
* 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 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,
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).
* @returns Generated magic item affixes and metadata.
*/
export function rollMagicAffixes(
base: ItemBase,
ilvl: number,
affixes: { prefixes: AffixTable; suffixes: AffixTable },
isA: Function,
rng: D2Rng,
): 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 usedGroups = new Set<number>()
const usedAffixes = new Set<MagicAffix>()
// 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,
{ alvl, isRare: false },
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,
{ alvl, isRare: false },
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).
* @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,
): 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)
// 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]
}
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:
// Total affixes: 3 to 6 (at least 1 prefix and 1 suffix, max 3 each)
const totalAffixes = rng.randRange(3, 6)
const minPrefix = Math.max(1, totalAffixes - 3)
const maxPrefix = Math.min(3, totalAffixes - 1)
const targetPrefixCount = rng.randRange(minPrefix, maxPrefix)
const targetSuffixCount = totalAffixes - targetPrefixCount
const usedGroups = new Set<number>()
const usedAffixes = new Set<MagicAffix>()
const rolledPrefixes: RolledAffix[] = []
const rolledSuffixes: RolledAffix[] = []
// Roll target prefix slots
for (let i = 0; i < targetPrefixCount; i++) {
const candidates = getEligibleCandidates(
affixes.prefixes,
itemTypeCode,
predicate,
{ alvl, isRare: true },
usedGroups,
usedAffixes,
)
const picked = pickWeightedAffix(candidates, rng)
if (!picked) break
usedAffixes.add(picked)
if (picked.group > 0) {
usedGroups.add(picked.group)
}
rolledPrefixes.push(createRolledAffix(picked, rng))
}
// Roll target suffix slots
for (let i = 0; i < targetSuffixCount; i++) {
const candidates = getEligibleCandidates(
affixes.suffixes,
itemTypeCode,
predicate,
{ alvl, isRare: true },
usedGroups,
usedAffixes,
)
const picked = pickWeightedAffix(candidates, rng)
if (!picked) break
usedAffixes.add(picked)
if (picked.group > 0) {
usedGroups.add(picked.group)
}
rolledSuffixes.push(createRolledAffix(picked, rng))
}
// If pool exhaustion prevented filling target count, try filling remaining slots from either category (up to 3 each)
while (rolledPrefixes.length + rolledSuffixes.length < totalAffixes) {
let rolledAny = false
if (rolledPrefixes.length < 3) {
const candidates = getEligibleCandidates(
affixes.prefixes,
itemTypeCode,
predicate,
{ alvl, isRare: true },
usedGroups,
usedAffixes,
)
const picked = pickWeightedAffix(candidates, rng)
if (picked) {
usedAffixes.add(picked)
if (picked.group > 0) {
usedGroups.add(picked.group)
}
rolledPrefixes.push(createRolledAffix(picked, rng))
rolledAny = true
if (rolledPrefixes.length + rolledSuffixes.length >= totalAffixes) break
}
}
if (rolledSuffixes.length < 3) {
const candidates = getEligibleCandidates(
affixes.suffixes,
itemTypeCode,
predicate,
{ alvl, isRare: true },
usedGroups,
usedAffixes,
)
const picked = pickWeightedAffix(candidates, rng)
if (picked) {
usedAffixes.add(picked)
if (picked.group > 0) {
usedGroups.add(picked.group)
}
rolledSuffixes.push(createRolledAffix(picked, rng))
rolledAny = true
if (rolledPrefixes.length + rolledSuffixes.length >= totalAffixes) break
}
}
if (!rolledAny) break
}
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,
}
}