feat(items): 实现词缀抽取与 alvl 计算 (Issue #104)

This commit is contained in:
troytt 2026-09-18 11:48:25 +00:00
parent ca3512f195
commit 390816c4cc
2 changed files with 1223 additions and 0 deletions

518
src/game/affix-generator.ts Normal file
View File

@ -0,0 +1,518 @@
/**
* 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 => ({
code: mod.code,
...(mod.param !== undefined ? { param: mod.param } : {}),
min: mod.min,
max: mod.max,
value: rng.randRange(mod.min, mod.max),
}))
}
/**
* 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,
}
}

View File

@ -0,0 +1,705 @@
import { describe, expect, it } from 'vitest'
import * as fs from 'node:fs'
import {
calculateAlvl,
rollMagicAffixes,
rollRareAffixes,
getItemTypeCode,
getBaseQlvl,
getBaseMagicLvl,
} from '../src/game/affix-generator.ts'
import type { ItemBase } from '../src/game/items.ts'
import type { MagicAffix, AffixTable } from '../src/game/affixes.ts'
import { parseMagicAffixesTable } from '../src/game/affixes.ts'
import type { RareNameEntry, RareNameTable } from '../src/game/rare-names.ts'
import { parseRareNamesTable } from '../src/game/rare-names.ts'
import { parseTable } from '../src/game/acts.ts'
import { D2Rng } from '../src/game/d2-rng.ts'
import { MpqArchive } from '../src/mpq/archive.ts'
import { MountedArchives } from '../src/mpq/mount.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { loadItemTypes, isA as realIsA } from '../src/game/item-types.ts'
describe('Affix Generator - alvl calculation (Issue #104)', () => {
it('calculates alvl with magicLvl bonus', () => {
// if (magicLvl > 0) alvl = ilvl + magicLvl
expect(calculateAlvl(10, 20, 5)).toBe(15)
expect(calculateAlvl(20, 15, 3)).toBe(23)
// Diadem (magicLvl = 18) capped at 99
expect(calculateAlvl(85, 85, 18)).toBe(99)
// Capped at 99
expect(calculateAlvl(95, 80, 10)).toBe(99)
})
it('calculates alvl when qlvl > Math.trunc(ilvl / 2) without magicLvl', () => {
// else if (qlvl > Math.trunc(ilvl / 2)) alvl = ilvl - Math.trunc(qlvl / 2)
// ilvl = 30, qlvl = 40 -> trunc(30/2)=15, 40 > 15 -> 30 - 20 = 10
expect(calculateAlvl(30, 40)).toBe(10)
expect(calculateAlvl(30, 40, 0)).toBe(10)
// ilvl = 20, qlvl = 16 -> trunc(20/2)=10, 16 > 10 -> 20 - 8 = 12
expect(calculateAlvl(20, 16)).toBe(12)
// ilvl = 50, qlvl = 26 -> trunc(50/2)=25, 26 > 25 -> 50 - 13 = 37
expect(calculateAlvl(50, 26)).toBe(37)
// ilvl = 15, qlvl = 10 -> trunc(15/2)=7, 10 > 7 -> 15 - 5 = 10
expect(calculateAlvl(15, 10)).toBe(10)
})
it('calculates alvl when qlvl <= Math.trunc(ilvl / 2) without magicLvl', () => {
// else alvl = ilvl
// ilvl = 50, qlvl = 20 -> trunc(50/2)=25, 20 <= 25 -> 50
expect(calculateAlvl(50, 20)).toBe(50)
// Exact boundary: ilvl = 50, qlvl = 25 -> trunc(50/2)=25, 25 <= 25 -> 50
expect(calculateAlvl(50, 25)).toBe(50)
// ilvl = 80, qlvl = 30 -> trunc(80/2)=40, 30 <= 40 -> 80
expect(calculateAlvl(80, 30)).toBe(80)
// ilvl = 105, qlvl = 20 -> 105 capped at 99
expect(calculateAlvl(105, 20)).toBe(99)
})
it('defaults magicLvl to 0 when omitted', () => {
expect(calculateAlvl(40, 50)).toBe(calculateAlvl(40, 50, 0))
expect(calculateAlvl(60, 20)).toBe(calculateAlvl(60, 20, 0))
})
})
describe('ItemBase helper functions', () => {
it('extracts itemTypeCode, qlvl, and magicLvl correctly', () => {
const baseWithExplicitType: any = {
id: 'hax',
name: 'Hand Axe',
kind: 'weapon',
invWidth: 1,
invHeight: 3,
maxStack: 1,
value: 100,
damage: 10,
defense: 0,
tags: ['weap', 'axe'],
type: 'axe',
level: 3,
magicLvl: 2,
}
expect(getItemTypeCode(baseWithExplicitType)).toBe('axe')
expect(getBaseQlvl(baseWithExplicitType)).toBe(3)
expect(getBaseMagicLvl(baseWithExplicitType)).toBe(2)
const baseWithTagsOnly: ItemBase = {
id: 'crs',
name: 'Crystal Sword',
kind: 'weapon',
invWidth: 2,
invHeight: 3,
maxStack: 1,
value: 500,
damage: 15,
defense: 0,
tags: ['weap', 'swor'],
level: 11,
}
expect(getItemTypeCode(baseWithTagsOnly)).toBe('swor')
expect(getBaseQlvl(baseWithTagsOnly)).toBe(11)
expect(getBaseMagicLvl(baseWithTagsOnly)).toBe(0)
const baseMinimal: ItemBase = {
id: 'rin',
name: 'Ring',
kind: 'misc',
invWidth: 1,
invHeight: 1,
maxStack: 1,
value: 100,
damage: 0,
defense: 0,
tags: ['ring'],
level: 1,
}
expect(getItemTypeCode(baseMinimal)).toBe('ring')
expect(getBaseQlvl(baseMinimal)).toBe(1)
})
})
describe('Affix Generator - Group exclusion & Rare Generation (Issue #104)', () => {
const mockIsA = (type: string, target: string): boolean => {
if (type === target) return true
if (type === 'swor' && target === 'weap') return true
if (type === 'swor' && target === 'mele') return true
return false
}
const swordBase: ItemBase = {
id: 'swor',
name: 'Broad Sword',
kind: 'weapon',
invWidth: 1,
invHeight: 3,
maxStack: 1,
value: 100,
damage: 10,
defense: 0,
tags: ['swor'],
level: 15,
}
// Helper to create mock MagicAffix
function createMockAffix(
name: string,
isPrefix: boolean,
group: number,
frequency: number,
level = 1,
mods = [{ code: 'att', min: 10, max: 20 }],
): MagicAffix {
return {
name,
isPrefix,
version: 100,
spawnable: true,
rare: true,
level,
maxlevel: 0,
levelreq: 1,
frequency,
group,
mods,
transform: false,
itypes: ['swor'],
etypes: [],
}
}
it('enforces group mutex: affixes with same group > 0 never co-occur on the same item', () => {
// 3 prefixes in group 10 ("Bronze", "Iron", "Steel"), 2 prefixes in group 20 ("Sharp", "Fine")
const prefixes: MagicAffix[] = [
createMockAffix('Bronze', true, 10, 5),
createMockAffix('Iron', true, 10, 5),
createMockAffix('Steel', true, 10, 5),
createMockAffix('Sharp', true, 20, 5),
createMockAffix('Fine', true, 20, 5),
createMockAffix('Godly', true, 30, 5),
]
// 2 suffixes in group 10 ("of the Titan", "of the Colossus"), 3 suffixes in group 40 ("of Leech", "of Blood", "of Bat")
const suffixes: MagicAffix[] = [
createMockAffix('of the Titan', false, 10, 5),
createMockAffix('of the Colossus', false, 10, 5),
createMockAffix('of Leech', false, 40, 5),
createMockAffix('of Blood', false, 40, 5),
createMockAffix('of Bat', false, 50, 5),
createMockAffix('of Speed', false, 60, 5),
]
const mockAffixes = {
prefixes: { all: prefixes } as unknown as AffixTable,
suffixes: { all: suffixes } as unknown as AffixTable,
}
const mockRareNames = {
prefixes: {
entries: [{ index: 0, name: 'Storm', version: 0, itypes: [], etypes: [], add: 0, multiply: 0, divide: 0 }],
getEligible: () => [{ index: 0, name: 'Storm', version: 0, itypes: [], etypes: [], add: 0, multiply: 0, divide: 0 }],
} as unknown as RareNameTable,
suffixes: {
entries: [{ index: 0, name: 'Spur', version: 0, itypes: [], etypes: [], add: 0, multiply: 0, divide: 0 }],
getEligible: () => [{ index: 0, name: 'Spur', version: 0, itypes: [], etypes: [], add: 0, multiply: 0, divide: 0 }],
} as unknown as RareNameTable,
}
// Run 50 rare rolls with varying seeds
for (let seed = 1000; seed < 1050; seed++) {
const rng = new D2Rng(seed)
const rare = rollRareAffixes(swordBase, 50, mockAffixes, mockRareNames, mockIsA, rng)
// Count occurrences of each group > 0
const groupCounts = new Map<number, number>()
for (const rolled of rare.affixes) {
if (rolled.group > 0) {
groupCounts.set(rolled.group, (groupCounts.get(rolled.group) ?? 0) + 1)
}
}
// Group mutex constraint: every group > 0 must appear at most once!
for (const [group, count] of groupCounts.entries()) {
expect(count, `Group ${group} appeared ${count} times on the same item!`).toBe(1)
}
// Explicit check for cross-prefix/suffix conflict on group 10
const group10Affixes = rare.affixes.filter(a => a.group === 10)
expect(group10Affixes.length).toBeLessThanOrEqual(1)
}
})
it('allows affixes with group 0 to co-occur if they are distinct affixes', () => {
const prefixes: MagicAffix[] = [
createMockAffix('SpecialPrefix1', true, 0, 10),
createMockAffix('SpecialPrefix2', true, 0, 10),
createMockAffix('SpecialPrefix3', true, 0, 10),
]
const suffixes: MagicAffix[] = [
createMockAffix('of Special1', false, 0, 10),
createMockAffix('of Special2', false, 0, 10),
createMockAffix('of Special3', false, 0, 10),
]
const mockAffixes = {
prefixes: { all: prefixes } as unknown as AffixTable,
suffixes: { all: suffixes } as unknown as AffixTable,
}
const mockRareNames = {
prefixes: {
getEligible: () => [{ index: 0, name: 'Shadow', itypes: [], etypes: [] }],
} as unknown as RareNameTable,
suffixes: {
getEligible: () => [{ index: 0, name: 'Bite', itypes: [], etypes: [] }],
} as unknown as RareNameTable,
}
const rng = new D2Rng(42)
const rare = rollRareAffixes(swordBase, 50, mockAffixes, mockRareNames, mockIsA, rng)
expect(rare.affixes.length).toBeGreaterThanOrEqual(3)
// All affixes have group 0, and they can co-occur
const group0Count = rare.affixes.filter(a => a.group === 0).length
expect(group0Count).toBeGreaterThanOrEqual(3)
// But no duplicate affix instance
const names = rare.affixes.map(a => a.name)
const uniqueNames = new Set(names)
expect(names.length).toBe(uniqueNames.size)
})
})
describe('Affix Generator - Quality specific rolls (Issue #104)', () => {
const mockIsA = (type: string, target: string): boolean => {
if (type === target) return true
if (type === 'swor' && target === 'weap') return true
return false
}
const swordBase: ItemBase = {
id: 'swor',
name: 'Broad Sword',
kind: 'weapon',
invWidth: 1,
invHeight: 3,
maxStack: 1,
value: 100,
damage: 10,
defense: 0,
tags: ['swor'],
level: 15,
}
function createMockAffix(name: string, isPrefix: boolean, group: number): MagicAffix {
return {
name,
isPrefix,
version: 100,
spawnable: true,
rare: true,
level: 1,
maxlevel: 0,
levelreq: 1,
frequency: 5,
group,
mods: [{ code: 'hp', min: 10, max: 20 }],
transform: false,
itypes: ['swor'],
etypes: [],
}
}
const prefixes = [
createMockAffix('Sturdy', true, 1),
createMockAffix('Strong', true, 2),
createMockAffix('Glorious', true, 3),
]
const suffixes = [
createMockAffix('of the Ox', false, 4),
createMockAffix('of the Giant', false, 5),
createMockAffix('of the Titan', false, 6),
]
const mockAffixes = {
prefixes: { all: prefixes } as unknown as AffixTable,
suffixes: { all: suffixes } as unknown as AffixTable,
}
const mockRareNames = {
prefixes: {
getEligible: () => [
{ index: 0, name: 'Doom', itypes: [], etypes: [] },
{ index: 1, name: 'Beast', itypes: [], etypes: [] },
],
} as unknown as RareNameTable,
suffixes: {
getEligible: () => [
{ index: 0, name: 'Bite', itypes: [], etypes: [] },
{ index: 1, name: 'Casque', itypes: [], etypes: [] },
],
} as unknown as RareNameTable,
}
describe('Magic items: 1 prefix slot + 1 suffix slot', () => {
it('generates at most 1 prefix and 1 suffix, correctly formatted', () => {
let sawPrefixOnly = false
let sawSuffixOnly = false
let sawBoth = false
for (let seed = 1; seed <= 60; seed++) {
const rng = new D2Rng(seed)
const magic = rollMagicAffixes(swordBase, 20, mockAffixes, mockIsA, rng)
// Slot constraints
expect(magic.prefixes.length).toBeLessThanOrEqual(1)
expect(magic.suffixes.length).toBeLessThanOrEqual(1)
expect(magic.affixes.length).toBeGreaterThanOrEqual(1)
expect(magic.affixes.length).toBeLessThanOrEqual(2)
if (magic.prefix && !magic.suffix) {
sawPrefixOnly = true
expect(magic.name).toBe(`${magic.prefix.name} ${swordBase.name}`)
expect(magic.prefixes).toEqual([magic.prefix])
expect(magic.suffixes).toEqual([])
} else if (!magic.prefix && magic.suffix) {
sawSuffixOnly = true
expect(magic.name).toBe(`${swordBase.name} ${magic.suffix.name}`)
expect(magic.prefixes).toEqual([])
expect(magic.suffixes).toEqual([magic.suffix])
} else if (magic.prefix && magic.suffix) {
sawBoth = true
expect(magic.name).toBe(`${magic.prefix.name} ${swordBase.name} ${magic.suffix.name}`)
expect(magic.prefixes).toEqual([magic.prefix])
expect(magic.suffixes).toEqual([magic.suffix])
}
}
// Verify all 3 random choices ({prefix only}, {suffix only}, {both}) occur
expect(sawPrefixOnly).toBe(true)
expect(sawSuffixOnly).toBe(true)
expect(sawBoth).toBe(true)
})
})
describe('Rare items: 3 to 6 affixes, <=3 prefixes, <=3 suffixes', () => {
it('satisfies 3..6 total affixes, 1..3 prefixes, 1..3 suffixes, and picks rare name', () => {
for (let seed = 1; seed <= 50; seed++) {
const rng = new D2Rng(seed * 77)
const rare = rollRareAffixes(swordBase, 30, mockAffixes, mockRareNames, mockIsA, rng)
// Total affixes: 3 to 6
expect(rare.affixes.length).toBeGreaterThanOrEqual(3)
expect(rare.affixes.length).toBeLessThanOrEqual(6)
// Up to 3 prefix + up to 3 suffix slots (at least 1 each)
expect(rare.prefixes.length).toBeGreaterThanOrEqual(1)
expect(rare.prefixes.length).toBeLessThanOrEqual(3)
expect(rare.suffixes.length).toBeGreaterThanOrEqual(1)
expect(rare.suffixes.length).toBeLessThanOrEqual(3)
// Total equals sum
expect(rare.prefixes.length + rare.suffixes.length).toBe(rare.affixes.length)
// Rare name
expect(rare.rarePrefix).toBeDefined()
expect(rare.rareSuffix).toBeDefined()
expect(rare.name).toBe(`${rare.rarePrefix!.name} ${rare.rareSuffix!.name}`)
}
})
})
})
describe('Mod Values Roll (Issue #104)', () => {
const mockIsA = (type: string, target: string): boolean => type === target
const swordBase: ItemBase = {
id: 'swor',
name: 'Broad Sword',
kind: 'weapon',
invWidth: 1,
invHeight: 3,
maxStack: 1,
value: 100,
damage: 10,
defense: 0,
tags: ['swor'],
level: 10,
}
it('rolls mod values within [min, max] using item rng', () => {
const affixWithRangedMod: MagicAffix = {
name: 'Vitality',
isPrefix: true,
version: 100,
spawnable: true,
rare: true,
level: 1,
maxlevel: 0,
levelreq: 1,
frequency: 1,
group: 1,
mods: [
{ code: 'hp', min: 10, max: 20 },
{ code: 'str', min: 5, max: 5 },
],
transform: false,
itypes: ['swor'],
etypes: [],
}
const mockAffixes = {
prefixes: { all: [affixWithRangedMod] } as unknown as AffixTable,
suffixes: { all: [] } as unknown as AffixTable,
}
for (let seed = 1; seed <= 30; seed++) {
const rng = new D2Rng(seed)
const result = rollMagicAffixes(swordBase, 20, mockAffixes, mockIsA, rng)
if (result.prefix) {
const hpMod = result.prefix.mods.find(m => m.code === 'hp')!
expect(hpMod).toBeDefined()
expect(hpMod.value).toBeGreaterThanOrEqual(10)
expect(hpMod.value).toBeLessThanOrEqual(20)
const strMod = result.prefix.mods.find(m => m.code === 'str')!
expect(strMod).toBeDefined()
expect(strMod.value).toBe(5)
}
}
})
it('weights affix selection by affix.frequency', () => {
const commonPrefix: MagicAffix = {
name: 'Common',
isPrefix: true,
version: 100,
spawnable: true,
rare: true,
level: 1,
maxlevel: 0,
levelreq: 1,
frequency: 90,
group: 1,
mods: [{ code: 'hp', min: 1, max: 1 }],
transform: false,
itypes: ['swor'],
etypes: [],
}
const rarePrefix: MagicAffix = {
name: 'UltraRare',
isPrefix: true,
version: 100,
spawnable: true,
rare: true,
level: 1,
maxlevel: 0,
levelreq: 1,
frequency: 10,
group: 2,
mods: [{ code: 'hp', min: 1, max: 1 }],
transform: false,
itypes: ['swor'],
etypes: [],
}
const mockAffixes = {
prefixes: { all: [commonPrefix, rarePrefix] } as unknown as AffixTable,
suffixes: { all: [] } as unknown as AffixTable,
}
let commonCount = 0
let rareCount = 0
const totalRuns = 200
for (let seed = 1; seed <= totalRuns; seed++) {
const rng = new D2Rng(seed)
const res = rollMagicAffixes(swordBase, 20, mockAffixes, mockIsA, rng)
if (res.prefix?.name === 'Common') commonCount++
if (res.prefix?.name === 'UltraRare') rareCount++
}
// Common (freq 90) should spawn much more often than UltraRare (freq 10)
expect(commonCount).toBeGreaterThan(rareCount * 3)
expect(commonCount + rareCount).toBeGreaterThan(0)
})
})
describe('Affix Generator - Determinism & Reproducibility (Issue #104)', () => {
const mockIsA = (type: string, target: string): boolean => type === target
const swordBase: ItemBase = {
id: 'swor',
name: 'Broad Sword',
kind: 'weapon',
invWidth: 1,
invHeight: 3,
maxStack: 1,
value: 100,
damage: 10,
defense: 0,
tags: ['swor'],
level: 25,
}
function createMockAffix(name: string, isPrefix: boolean, group: number): MagicAffix {
return {
name,
isPrefix,
version: 100,
spawnable: true,
rare: true,
level: 1,
maxlevel: 0,
levelreq: 1,
frequency: 5,
group,
mods: [{ code: 'att', min: 10, max: 50 }],
transform: false,
itypes: ['swor'],
etypes: [],
}
}
const mockAffixes = {
prefixes: {
all: [
createMockAffix('Bronze', true, 1),
createMockAffix('Iron', true, 2),
createMockAffix('Steel', true, 3),
],
} as unknown as AffixTable,
suffixes: {
all: [
createMockAffix('of Craft', false, 4),
createMockAffix('of Master', false, 5),
createMockAffix('of Titan', false, 6),
],
} as unknown as AffixTable,
}
const mockRareNames = {
prefixes: {
getEligible: () => [{ index: 0, name: 'Gale', itypes: [], etypes: [] }],
} as unknown as RareNameTable,
suffixes: {
getEligible: () => [{ index: 0, name: 'Claw', itypes: [], etypes: [] }],
} as unknown as RareNameTable,
}
it('reproduces exact identical magic rolls with same seed', () => {
const fixedSeed = 0x12345678
const rng1 = new D2Rng(fixedSeed)
const magic1 = rollMagicAffixes(swordBase, 30, mockAffixes, mockIsA, rng1)
const rng2 = new D2Rng(fixedSeed)
const magic2 = rollMagicAffixes(swordBase, 30, mockAffixes, mockIsA, rng2)
expect(magic1.name).toBe(magic2.name)
expect(magic1.alvl).toBe(magic2.alvl)
expect(magic1.prefixes).toEqual(magic2.prefixes)
expect(magic1.suffixes).toEqual(magic2.suffixes)
expect(magic1.affixes).toEqual(magic2.affixes)
expect(magic1.dwInitSeed).toBe(magic2.dwInitSeed)
})
it('reproduces exact identical rare rolls with same seed', () => {
const fixedSeed = 0x87654321
const rng1 = new D2Rng(fixedSeed)
const rare1 = rollRareAffixes(swordBase, 30, mockAffixes, mockRareNames, mockIsA, rng1)
const rng2 = new D2Rng(fixedSeed)
const rare2 = rollRareAffixes(swordBase, 30, mockAffixes, mockRareNames, mockIsA, rng2)
expect(rare1.name).toBe(rare2.name)
expect(rare1.alvl).toBe(rare2.alvl)
expect(rare1.rarePrefix).toEqual(rare2.rarePrefix)
expect(rare1.rareSuffix).toEqual(rare2.rareSuffix)
expect(rare1.prefixes).toEqual(rare2.prefixes)
expect(rare1.suffixes).toEqual(rare2.suffixes)
expect(rare1.affixes).toEqual(rare2.affixes)
expect(rare1.dwInitSeed).toBe(rare2.dwInitSeed)
})
})
describe('Affix Generator - Real MPQ Data Integration (when samples/d2 exists)', () => {
const hasMpqs = fs.existsSync('samples/d2/d2exp.mpq')
it.skipIf(!hasMpqs)('rolls realistic magic and rare items from vanilla 1.13c game data', async () => {
const archives = new MountedArchives()
archives.add('d2data.mpq', await MpqArchive.open(await fileSource('samples/d2/d2data.mpq')))
archives.add('d2exp.mpq', await MpqArchive.open(await fileSource('samples/d2/d2exp.mpq')))
// Load tables
const prefixBytes = await archives.read('data\\global\\excel\\MagicPrefix.txt')
const suffixBytes = await archives.read('data\\global\\excel\\MagicSuffix.txt')
const rarePrefixBytes = await archives.read('data\\global\\excel\\RarePrefix.txt')
const rareSuffixBytes = await archives.read('data\\global\\excel\\RareSuffix.txt')
const prefixes = parseMagicAffixesTable(prefixBytes, true)
const suffixes = parseMagicAffixesTable(suffixBytes, false)
const rarePrefixes = parseRareNamesTable(parseTable(rarePrefixBytes))
const rareSuffixes = parseRareNamesTable(parseTable(rareSuffixBytes))
const itemTypeTable = await loadItemTypes(archives)
const isAPredicate = (type: string, target: string) => realIsA(type, target, itemTypeTable)
const crystalSword: ItemBase = {
id: 'crs',
name: 'Crystal Sword',
kind: 'weapon',
invWidth: 2,
invHeight: 3,
maxStack: 1,
value: 1200,
damage: 15,
defense: 0,
tags: ['weap', 'swor'],
level: 11,
}
const affixes = { prefixes, suffixes }
const rareNames = { prefixes: rarePrefixes, suffixes: rareSuffixes }
// Roll a magic sword at ilvl 50
const rngMagic = new D2Rng(99999)
const magicSword = rollMagicAffixes(crystalSword, 50, affixes, isAPredicate, rngMagic)
expect(magicSword.alvl).toBe(50) // qlvl=11 <= trunc(50/2)=25 -> alvl=50
expect(magicSword.affixes.length).toBeGreaterThanOrEqual(1)
expect(magicSword.affixes.length).toBeLessThanOrEqual(2)
expect(magicSword.name).toContain('Crystal Sword')
// Roll a rare sword at ilvl 80
const rngRare = new D2Rng(88888)
const rareSword = rollRareAffixes(crystalSword, 80, affixes, rareNames, isAPredicate, rngRare)
expect(rareSword.alvl).toBe(80)
expect(rareSword.affixes.length).toBeGreaterThanOrEqual(3)
expect(rareSword.affixes.length).toBeLessThanOrEqual(6)
expect(rareSword.prefixes.length).toBeGreaterThanOrEqual(1)
expect(rareSword.prefixes.length).toBeLessThanOrEqual(3)
expect(rareSword.suffixes.length).toBeGreaterThanOrEqual(1)
expect(rareSword.suffixes.length).toBeLessThanOrEqual(3)
expect(rareSword.rarePrefix).toBeDefined()
expect(rareSword.rareSuffix).toBeDefined()
expect(rareSword.name).toBe(`${rareSword.rarePrefix!.name} ${rareSword.rareSuffix!.name}`)
// Verify group mutex holds across all rolled affixes
const seenGroups = new Set<number>()
for (const affix of rareSword.affixes) {
if (affix.group > 0) {
expect(seenGroups.has(affix.group)).toBe(false)
seenGroups.add(affix.group)
}
}
})
})