From 390816c4cc507fde0b7828708bd2f2e4c69b98d5 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 18 Sep 2026 11:48:25 +0000 Subject: [PATCH] =?UTF-8?q?feat(items):=20=E5=AE=9E=E7=8E=B0=E8=AF=8D?= =?UTF-8?q?=E7=BC=80=E6=8A=BD=E5=8F=96=E4=B8=8E=20alvl=20=E8=AE=A1?= =?UTF-8?q?=E7=AE=97=20(Issue=20#104)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/game/affix-generator.ts | 518 +++++++++++++++++++++++++ tests/affix-generator.test.ts | 705 ++++++++++++++++++++++++++++++++++ 2 files changed, 1223 insertions(+) create mode 100644 src/game/affix-generator.ts create mode 100644 tests/affix-generator.test.ts diff --git a/src/game/affix-generator.ts b/src/game/affix-generator.ts new file mode 100644 index 0000000..80cbe62 --- /dev/null +++ b/src/game/affix-generator.ts @@ -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, + 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 +} + +/** + * 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() + 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, + { 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() + const usedAffixes = new Set() + 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, + } +} diff --git a/tests/affix-generator.test.ts b/tests/affix-generator.test.ts new file mode 100644 index 0000000..3e15527 --- /dev/null +++ b/tests/affix-generator.test.ts @@ -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() + 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() + for (const affix of rareSword.affixes) { + if (affix.group > 0) { + expect(seenGroups.has(affix.group)).toBe(false) + seenGroups.add(affix.group) + } + } + }) +})