diff --git a/src/game/item-ratio.ts b/src/game/item-ratio.ts new file mode 100644 index 0000000..41f915e --- /dev/null +++ b/src/game/item-ratio.ts @@ -0,0 +1,733 @@ +/** + * Diablo II ItemRatio Quality Determination (`ItemRatio.txt`). + * + * Gold standard references: + * - D2Game!6FC2FC40 (Quality determination algorithm & roll sequence) + * - D2Game!6FC2E130 (Magic Find diminishing returns formula) + * - D2Common!6FD510B0 / D2Common!6FD51180 (PRNG roll logic) + * + * In Diablo II, when an item base is dropped, the engine resolves its quality tier + * through `ItemRatio.txt`. The row in `ItemRatio.txt` is looked up by: + * - Version (0 = Classic, 1 = Expansion) + * - Uber (0 = Normal, 1 = Exceptional / Elite) + * - Class Specific (0 = Generic, 1 = Class-specific item like Orbs, Pelts, etc.) + * + * CRITICAL ENGINE PROPERTY: + * The columns in `ItemRatio.txt` are listed as: + * Unique -> Rare -> Set -> Magic -> HiQuality -> Normal + * BUT the actual in-engine roll sequence at D2Game!6FC2FC40 is strictly: + * Unique(7) -> Set(5) -> Rare(6) -> Magic(4) -> Superior(3) -> Normal(2) / Low(1) + * + * Every division in the algorithm uses 32-bit signed integer truncation toward zero + * (replicated strictly via `Math.trunc`). + */ +import type { D2Table } from './acts.ts' +import { cell, parseTable } from './acts.ts' +import type { MountedArchives } from '../mpq/mount.ts' +import { D2Rng } from './d2-rng.ts' +import { ItemQuality } from './items.ts' + +/** File path of `ItemRatio.txt` inside MPQ archives. */ +export const ITEM_RATIO_TABLE_PATH = 'data\\global\\excel\\ItemRatio.txt' + +/** Magic Find diminishing returns divisor factors (D2Game!6FC2E130). */ +export const MF_FACTOR_UNIQUE = 250 +export const MF_FACTOR_SET = 500 +export const MF_FACTOR_RARE = 600 + +/** Quality tier names in strict roll order. */ +export type ItemQualityTier = + | 'unique' + | 'set' + | 'rare' + | 'magic' + | 'superior' + | 'normal' + | 'low' + +/** Mapping from ItemQualityTier string to numeric ItemQuality enum value. */ +export const QUALITY_TIER_TO_ITEM_QUALITY: Readonly> = { + unique: ItemQuality.UNIQUE, + set: ItemQuality.SET, + rare: ItemQuality.RARE, + magic: ItemQuality.MAGIC, + superior: ItemQuality.SUPERIOR, + normal: ItemQuality.NORMAL, + low: ItemQuality.LOW, +} + +/** One row from `ItemRatio.txt`. */ +export interface ItemRatioRow { + /** Optional function comment from table. */ + readonly function?: string | undefined + /** 0 = Classic, 1 = Expansion. */ + readonly version: number + /** 0 = Normal, 1 = Exceptional or Elite item. */ + readonly uber: number + /** 0 = Generic, 1 = Class-specific item. */ + readonly classSpecific: number + + /** Unique base chance, divisor, and minimum chance floor. */ + readonly unique: number + readonly uniqueDiv: number + readonly uniqueMin: number + + /** Rare base chance, divisor, and minimum chance floor. */ + readonly rare: number + readonly rareDiv: number + readonly rareMin: number + + /** Set base chance, divisor, and minimum chance floor. */ + readonly set: number + readonly setDiv: number + readonly setMin: number + + /** Magic base chance, divisor, and minimum chance floor. */ + readonly magic: number + readonly magicDiv: number + readonly magicMin: number + + /** High Quality (Superior) base chance and divisor (no MF, min, or TC factor). */ + readonly hiQual: number + readonly hiQualDiv: number + + /** Normal / Low quality base chance and divisor (no MF, min, or TC factor). */ + readonly normal: number + readonly normalDiv: number + + // Compatibility aliases + readonly uniqueDivisor?: number | undefined + readonly rareDivisor?: number | undefined + readonly setDivisor?: number | undefined + readonly magicDivisor?: number | undefined + readonly hiQuality?: number | undefined + readonly hiQualityDivisor?: number | undefined + readonly normalDivisor?: number | undefined +} + +/** + * Parsed ItemRatio table providing list and indexed lookup access. + */ +export interface ItemRatioTable { + /** All parsed rows. */ + readonly rows: readonly ItemRatioRow[] + /** + * Looks up the appropriate row by version, uber flag, and classSpecific flag. + */ + getRow( + version: number | boolean, + uber: number | boolean, + classSpecific: number | boolean, + ): ItemRatioRow | undefined + /** Number of parsed rows (6 in vanilla 1.13c). */ + readonly length: number + /** Iterates over all rows. */ + [Symbol.iterator](): IterableIterator +} + +/** PRNG source interface providing `rand(max: number): number`. */ +export interface QualityRollRng { + rand(max: number): number +} + +/** TreasureClass ratio factor modifiers (fractions of 1024, e.g. from TreasureClassEx.txt). */ +export interface QualityRatioFactors { + readonly unique?: number | undefined + readonly set?: number | undefined + readonly rare?: number | undefined + readonly magic?: number | undefined +} + +/** Context parameters passed to `rollItemQuality`. */ +export interface QualityRollContext { + /** Drop item level (e.g. monster level or area level). */ + readonly ilvl: number + /** Quality level of the base item (from weapons/armor/misc.txt). */ + readonly qlvl: number + /** Magic Find percentage (e.g. 0, 50, 100). */ + readonly magicFind?: number | undefined + /** Alternative alias for magicFind. */ + readonly mf?: number | undefined + + /** ItemRatio row to use directly. */ + readonly row?: ItemRatioRow | undefined + /** ItemRatio table to look up row from if `row` is not provided. */ + readonly table?: ItemRatioTable | undefined + + /** Game version: 0 = Classic, 1 = Expansion (LoD). Defaults to 1. */ + readonly version?: number | boolean | undefined + /** Whether the item is exceptional or elite. Defaults to 0/false. */ + readonly uber?: number | boolean | undefined + /** Whether the item is class-specific. Defaults to 0/false. */ + readonly classSpecific?: number | boolean | undefined + + /** TreasureClass quality modifiers (fractions of 1024). */ + readonly tcFactors?: QualityRatioFactors | undefined + /** Single TC factor fallback (applied to all if specific tier not given). */ + readonly tcFactor?: number | undefined + + /** Explicit TC modifiers (fractions of 1024). */ + readonly tcUnique?: number | undefined + readonly tcSet?: number | undefined + readonly tcRare?: number | undefined + readonly tcMagic?: number | undefined + + /** Optional quality filters: if false, that tier is bypassed during roll. */ + readonly allowUnique?: boolean | undefined + readonly allowSet?: boolean | undefined + readonly allowRare?: boolean | undefined + readonly allowMagic?: boolean | undefined + readonly allowSuperior?: boolean | undefined + + /** PRNG instance implementing `rand(max: number): number`. Defaults to new D2Rng(). */ + readonly rng?: QualityRollRng | undefined +} + +/** + * Calculates effective magic find with diminishing returns (D2Game!6FC2E130). + * + * For mf <= 10, no diminishing returns are applied: returns mf + 100. + * For mf > 10: returns 100 + Math.trunc((mf * factor) / (mf + factor)). + * + * @param mf - Player/minion magic find percentage. + * @param factor - Diminishing returns factor (250 for Unique, 500 for Set, 600 for Rare). + * @returns Diminished multiplier (100-based). + */ +export function effectiveMF(mf: number, factor: number): number { + const x = mf + 100 + if (x <= 110) { + return x + } + return 100 + Math.trunc((mf * factor) / (mf + factor)) +} + +/** + * Calculates final chance denominator for Unique quality (D2Game!6FC2FC40). + */ +export function calculateUniqueChance( + row: ItemRatioRow, + ilvl: number, + qlvl: number, + mf = 0, + tcFactor = 0, +): number { + const D = ilvl - qlvl + const divisor = row.uniqueDiv || 1 + let chance = (row.unique - Math.trunc(D / divisor)) * 128 + if (mf > 0) { + const e = effectiveMF(mf, MF_FACTOR_UNIQUE) + if (e > 0) { + chance = Math.trunc((chance * 100) / e) + } + } + if (chance < row.uniqueMin) { + chance = row.uniqueMin + } + chance -= Math.trunc((tcFactor * chance) / 1024) + return chance +} + +/** + * Calculates final chance denominator for Set quality (D2Game!6FC2FC40). + */ +export function calculateSetChance( + row: ItemRatioRow, + ilvl: number, + qlvl: number, + mf = 0, + tcFactor = 0, +): number { + const D = ilvl - qlvl + const divisor = row.setDiv || 1 + let chance = (row.set - Math.trunc(D / divisor)) * 128 + if (mf > 0) { + const e = effectiveMF(mf, MF_FACTOR_SET) + if (e > 0) { + chance = Math.trunc((chance * 100) / e) + } + } + if (chance < row.setMin) { + chance = row.setMin + } + chance -= Math.trunc((tcFactor * chance) / 1024) + return chance +} + +/** + * Calculates final chance denominator for Rare quality (D2Game!6FC2FC40). + */ +export function calculateRareChance( + row: ItemRatioRow, + ilvl: number, + qlvl: number, + mf = 0, + tcFactor = 0, +): number { + const D = ilvl - qlvl + const divisor = row.rareDiv || 1 + let chance = (row.rare - Math.trunc(D / divisor)) * 128 + if (mf > 0) { + const e = effectiveMF(mf, MF_FACTOR_RARE) + if (e > 0) { + chance = Math.trunc((chance * 100) / e) + } + } + if (chance < row.rareMin) { + chance = row.rareMin + } + chance -= Math.trunc((tcFactor * chance) / 1024) + return chance +} + +/** + * Calculates final chance denominator for Magic quality (D2Game!6FC2FC40). + * Magic has NO diminishing returns: divisor is `mf + 100`. + */ +export function calculateMagicChance( + row: ItemRatioRow, + ilvl: number, + qlvl: number, + mf = 0, + tcFactor = 0, +): number { + const D = ilvl - qlvl + const divisor = row.magicDiv || 1 + let chance = (row.magic - Math.trunc(D / divisor)) * 128 + if (mf > 0) { + chance = Math.trunc((chance * 100) / (mf + 100)) + } + if (chance < row.magicMin) { + chance = row.magicMin + } + chance -= Math.trunc((tcFactor * chance) / 1024) + return chance +} + +/** + * Calculates chance denominator for Superior (High Quality) quality. + * Superior does not take MF, has no min floor, and no TC factor. + */ +export function calculateSuperiorChance( + row: ItemRatioRow, + ilvl: number, + qlvl: number, +): number { + const D = ilvl - qlvl + const divisor = row.hiQualDiv || 1 + return (row.hiQual - Math.trunc(D / divisor)) * 128 +} + +/** + * Calculates chance denominator for Normal quality. + * Normal does not take MF, has no min floor, and no TC factor. + */ +export function calculateNormalChance( + row: ItemRatioRow, + ilvl: number, + qlvl: number, +): number { + const D = ilvl - qlvl + const divisor = row.normalDiv || 1 + return (row.normal - Math.trunc(D / divisor)) * 128 +} + +/** + * Executes ItemRatio quality determination roll in exact order: + * Unique(7) -> Set(5) -> Rare(6) -> Magic(4) -> Superior(3) -> Normal(2) / Low(1). + * + * @param context - Quality roll parameters (ilvl, qlvl, mf, factors, rng, etc.). + * @returns Rolled ItemQualityTier. + */ +export function rollItemQuality(context: QualityRollContext): ItemQualityTier { + const { ilvl, qlvl } = context + const mf = context.magicFind ?? context.mf ?? 0 + const rng = context.rng ?? new D2Rng() + + let row = context.row + if (!row) { + const table = context.table ?? DEFAULT_ITEM_RATIO_TABLE + row = table.getRow( + context.version ?? 1, + context.uber ?? 0, + context.classSpecific ?? 0, + ) + } + if (!row) { + throw new Error('rollItemQuality: no matching ItemRatio row found') + } + + const tcUnique = context.tcFactors?.unique ?? context.tcUnique ?? context.tcFactor ?? 0 + const tcSet = context.tcFactors?.set ?? context.tcSet ?? context.tcFactor ?? 0 + const tcRare = context.tcFactors?.rare ?? context.tcRare ?? context.tcFactor ?? 0 + const tcMagic = context.tcFactors?.magic ?? context.tcMagic ?? context.tcFactor ?? 0 + + // 1. Unique (7) + if (context.allowUnique !== false) { + const chance = calculateUniqueChance(row, ilvl, qlvl, mf, tcUnique) + if (chance <= 0 || rng.rand(chance) < 128) { + return 'unique' + } + } + + // 2. Set (5) + if (context.allowSet !== false) { + const chance = calculateSetChance(row, ilvl, qlvl, mf, tcSet) + if (chance <= 0 || rng.rand(chance) < 128) { + return 'set' + } + } + + // 3. Rare (6) + if (context.allowRare !== false) { + const chance = calculateRareChance(row, ilvl, qlvl, mf, tcRare) + if (chance <= 0 || rng.rand(chance) < 128) { + return 'rare' + } + } + + // 4. Magic (4) + if (context.allowMagic !== false) { + const chance = calculateMagicChance(row, ilvl, qlvl, mf, tcMagic) + if (chance <= 0 || rng.rand(chance) < 128) { + return 'magic' + } + } + + // 5. Superior (3) + if (context.allowSuperior !== false) { + const chance = calculateSuperiorChance(row, ilvl, qlvl) + if (chance <= 0 || rng.rand(chance) < 128) { + return 'superior' + } + } + + // 6. Normal (2) / Low (1) + const chance = calculateNormalChance(row, ilvl, qlvl) + if (chance <= 0) { + return 'normal' + } + return rng.rand(chance) < 128 ? 'normal' : 'low' +} + +/** + * Creates an ItemRatioTable from an array of ItemRatioRow records. + */ +export function createItemRatioTable(rows: readonly ItemRatioRow[]): ItemRatioTable { + return { + rows, + length: rows.length, + getRow( + version: number | boolean, + uber: number | boolean, + classSpecific: number | boolean, + ): ItemRatioRow | undefined { + const v = typeof version === 'boolean' ? (version ? 1 : 0) : version + const u = typeof uber === 'boolean' ? (uber ? 1 : 0) : uber + const cs = typeof classSpecific === 'boolean' ? (classSpecific ? 1 : 0) : classSpecific + + // 1. Exact match + let match = rows.find(r => r.version === v && r.uber === u && r.classSpecific === cs) + if (match) return match + + // 2. Classic (v=0) classSpecific fallback to Expansion (v=1) classSpecific + if (v === 0 && cs === 1) { + match = rows.find(r => r.version === 1 && r.uber === u && r.classSpecific === 1) + if (match) return match + } + + // 3. Fallback matching version and uber + match = rows.find(r => r.version === v && r.uber === u) + if (match) return match + + // 4. Default to first row + return rows[0] + }, + [Symbol.iterator]() { + return rows[Symbol.iterator]() + }, + } +} + +/** Parses cell value as integer. */ +function parseNum(val: string, fallback = 0): number { + const trimmed = val.trim() + if (trimmed === '') return fallback + const n = Number(trimmed) + return Number.isFinite(n) ? n : fallback +} + +/** + * Parses a D2Table (from ItemRatio.txt) into an ItemRatioTable. + * + * @param table - Parsed tab-separated table. + * @returns Structured ItemRatioTable. + */ +export function parseItemRatioTable(table: D2Table): ItemRatioTable { + const rows: ItemRatioRow[] = [] + for (const rawRow of table.rows) { + if (rawRow.length === 0 || rawRow.every(c => c.trim() === '')) { + continue + } + + const fn = cell(table, rawRow, 'Function') + const version = parseNum(cell(table, rawRow, 'Version')) + const uber = parseNum(cell(table, rawRow, 'Uber')) + const classSpecific = parseNum( + cell(table, rawRow, 'Class Specific') || + cell(table, rawRow, 'ClassSpecific') || + cell(table, rawRow, 'class specific'), + ) + + const unique = parseNum(cell(table, rawRow, 'Unique')) + const uniqueDiv = parseNum(cell(table, rawRow, 'UniqueDivisor') || cell(table, rawRow, 'UniqueDiv')) + const uniqueMin = parseNum(cell(table, rawRow, 'UniqueMin')) + + const rare = parseNum(cell(table, rawRow, 'Rare')) + const rareDiv = parseNum(cell(table, rawRow, 'RareDivisor') || cell(table, rawRow, 'RareDiv')) + const rareMin = parseNum(cell(table, rawRow, 'RareMin')) + + const set = parseNum(cell(table, rawRow, 'Set')) + const setDiv = parseNum(cell(table, rawRow, 'SetDivisor') || cell(table, rawRow, 'SetDiv')) + const setMin = parseNum(cell(table, rawRow, 'SetMin')) + + const magic = parseNum(cell(table, rawRow, 'Magic')) + const magicDiv = parseNum(cell(table, rawRow, 'MagicDivisor') || cell(table, rawRow, 'MagicDiv')) + const magicMin = parseNum(cell(table, rawRow, 'MagicMin')) + + const hiQual = parseNum(cell(table, rawRow, 'HiQuality') || cell(table, rawRow, 'HiQual')) + const hiQualDiv = parseNum(cell(table, rawRow, 'HiQualityDivisor') || cell(table, rawRow, 'HiQualDiv')) + + const normal = parseNum(cell(table, rawRow, 'Normal')) + const normalDiv = parseNum(cell(table, rawRow, 'NormalDivisor') || cell(table, rawRow, 'NormalDiv')) + + rows.push({ + function: fn || undefined, + version, + uber, + classSpecific, + unique, + uniqueDiv, + uniqueMin, + rare, + rareDiv, + rareMin, + set, + setDiv, + setMin, + magic, + magicDiv, + magicMin, + hiQual, + hiQualDiv, + normal, + normalDiv, + uniqueDivisor: uniqueDiv, + rareDivisor: rareDiv, + setDivisor: setDiv, + magicDivisor: magicDiv, + hiQuality: hiQual, + hiQualityDivisor: hiQualDiv, + normalDivisor: normalDiv, + }) + } + + return createItemRatioTable(rows) +} + +/** + * Loads and parses `ItemRatio.txt` from mounted MPQ archives. + * + * @param archives - Mounted MPQ archives. + * @param tablePath - Optional path inside archive (defaults to ITEM_RATIO_TABLE_PATH). + * @returns Promise resolving to `ItemRatioTable`. + */ +export async function loadItemRatio( + archives: MountedArchives, + tablePath = ITEM_RATIO_TABLE_PATH, +): Promise { + const bytes = await archives.read(tablePath) + const d2Table = parseTable(bytes) + return parseItemRatioTable(d2Table) +} + +/** + * Canonical 1.13c ItemRatio.txt rows embedded as fallback defaults. + */ +export const DEFAULT_ITEM_RATIO_ROWS: readonly ItemRatioRow[] = [ + { + function: 'Ratio - (Monster Level / Ratio Divisor)', + version: 0, + uber: 0, + classSpecific: 0, + unique: 400, + uniqueDiv: 2, + uniqueMin: 6400, + rare: 160, + rareDiv: 3, + rareMin: 3200, + set: 125, + setDiv: 6, + setMin: 5600, + magic: 30, + magicDiv: 16, + magicMin: 192, + hiQual: 12, + hiQualDiv: 16, + normal: 4, + normalDiv: 8, + uniqueDivisor: 2, + rareDivisor: 3, + setDivisor: 6, + magicDivisor: 16, + hiQuality: 12, + hiQualityDivisor: 16, + normalDivisor: 8, + }, + { + function: 'Uber', + version: 0, + uber: 1, + classSpecific: 0, + unique: 240, + uniqueDiv: 2, + uniqueMin: 6400, + rare: 96, + rareDiv: 3, + rareMin: 3200, + set: 96, + setDiv: 6, + setMin: 5600, + magic: 3, + magicDiv: 100, + magicMin: 192, + hiQual: 4, + hiQualDiv: 16, + normal: 1, + normalDiv: 8, + uniqueDivisor: 2, + rareDivisor: 3, + setDivisor: 6, + magicDivisor: 100, + hiQuality: 4, + hiQualityDivisor: 16, + normalDivisor: 8, + }, + { + function: 'Ratio - (Monster Level / Ratio Divisor)', + version: 1, + uber: 0, + classSpecific: 0, + unique: 400, + uniqueDiv: 1, + uniqueMin: 6400, + rare: 100, + rareDiv: 2, + rareMin: 3200, + set: 160, + setDiv: 2, + setMin: 5600, + magic: 34, + magicDiv: 3, + magicMin: 192, + hiQual: 12, + hiQualDiv: 8, + normal: 2, + normalDiv: 2, + uniqueDivisor: 1, + rareDivisor: 2, + setDivisor: 2, + magicDivisor: 3, + hiQuality: 12, + hiQualityDivisor: 8, + normalDivisor: 2, + }, + { + function: 'Uber', + version: 1, + uber: 1, + classSpecific: 0, + unique: 400, + uniqueDiv: 1, + uniqueMin: 6400, + rare: 100, + rareDiv: 2, + rareMin: 3200, + set: 160, + setDiv: 2, + setMin: 5600, + magic: 34, + magicDiv: 3, + magicMin: 192, + hiQual: 12, + hiQualDiv: 8, + normal: 1, + normalDiv: 1, + uniqueDivisor: 1, + rareDivisor: 2, + setDivisor: 2, + magicDivisor: 3, + hiQuality: 12, + hiQualityDivisor: 8, + normalDivisor: 1, + }, + { + function: 'Class Specific', + version: 1, + uber: 0, + classSpecific: 1, + unique: 240, + uniqueDiv: 3, + uniqueMin: 6400, + rare: 80, + rareDiv: 3, + rareMin: 3200, + set: 120, + setDiv: 3, + setMin: 5600, + magic: 17, + magicDiv: 6, + magicMin: 192, + hiQual: 9, + hiQualDiv: 8, + normal: 2, + normalDiv: 2, + uniqueDivisor: 3, + rareDivisor: 3, + setDivisor: 3, + magicDivisor: 6, + hiQuality: 9, + hiQualityDivisor: 8, + normalDivisor: 2, + }, + { + function: 'Class Specific Uber', + version: 1, + uber: 1, + classSpecific: 1, + unique: 240, + uniqueDiv: 3, + uniqueMin: 6400, + rare: 80, + rareDiv: 3, + rareMin: 3200, + set: 120, + setDiv: 3, + setMin: 5600, + magic: 17, + magicDiv: 6, + magicMin: 192, + hiQual: 9, + hiQualDiv: 8, + normal: 1, + normalDiv: 1, + uniqueDivisor: 3, + rareDivisor: 3, + setDivisor: 3, + magicDivisor: 6, + hiQuality: 9, + hiQualityDivisor: 8, + normalDivisor: 1, + }, +] + +export const DEFAULT_ITEM_RATIO_TABLE = createItemRatioTable(DEFAULT_ITEM_RATIO_ROWS) diff --git a/tests/item-ratio.test.ts b/tests/item-ratio.test.ts new file mode 100644 index 0000000..eefc29d --- /dev/null +++ b/tests/item-ratio.test.ts @@ -0,0 +1,446 @@ +import { describe, it, expect, beforeAll } from 'vitest' +import * as fs from 'node:fs' +import { MountedArchives } from '../src/mpq/mount.ts' +import { MpqArchive } from '../src/mpq/archive.ts' +import { fileSource } from '../src/mpq/file-source.ts' +import { parseTable } from '../src/game/acts.ts' +import { + ITEM_RATIO_TABLE_PATH, + MF_FACTOR_UNIQUE, + MF_FACTOR_SET, + MF_FACTOR_RARE, + DEFAULT_ITEM_RATIO_ROWS, + DEFAULT_ITEM_RATIO_TABLE, + effectiveMF, + calculateUniqueChance, + calculateSetChance, + calculateRareChance, + calculateMagicChance, + calculateSuperiorChance, + calculateNormalChance, + rollItemQuality, + parseItemRatioTable, + loadItemRatio, + type ItemRatioRow, + type ItemRatioTable, + type QualityRollRng, +} from '../src/game/item-ratio.ts' + +const hasD2 = fs.existsSync('samples/d2/d2data.mpq') + +describe('ItemRatio Quality Determination (Issue #102)', () => { + let archives: MountedArchives + let loadedTable: ItemRatioTable + + beforeAll(async () => { + if (hasD2) { + archives = new MountedArchives() + for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) { + archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`))) + } + loadedTable = await loadItemRatio(archives) + } + }) + + describe('1. Table Loading and Row Lookup', () => { + it('uses the canonical MPQ table path', () => { + expect(ITEM_RATIO_TABLE_PATH).toBe('data\\global\\excel\\ItemRatio.txt') + }) + + it('parses exactly 6 rows from MPQ table', () => { + if (!hasD2) return + expect(loadedTable.length).toBe(6) + expect(loadedTable.rows.length).toBe(6) + }) + + it('matches canonical 1.13c values in default and loaded table', () => { + const table = loadedTable ?? DEFAULT_ITEM_RATIO_TABLE + + // Row 0: Classic, Normal, Generic (version=0, uber=0, classSpecific=0) + const r0 = table.getRow(0, 0, 0)! + expect(r0).toBeDefined() + expect(r0.version).toBe(0) + expect(r0.uber).toBe(0) + expect(r0.classSpecific).toBe(0) + expect(r0.unique).toBe(400) + expect(r0.uniqueDiv).toBe(2) + expect(r0.uniqueMin).toBe(6400) + expect(r0.rare).toBe(160) + expect(r0.rareDiv).toBe(3) + expect(r0.rareMin).toBe(3200) + expect(r0.set).toBe(125) + expect(r0.setDiv).toBe(6) + expect(r0.setMin).toBe(5600) + expect(r0.magic).toBe(30) + expect(r0.magicDiv).toBe(16) + expect(r0.magicMin).toBe(192) + expect(r0.hiQual).toBe(12) + expect(r0.hiQualDiv).toBe(16) + expect(r0.normal).toBe(4) + expect(r0.normalDiv).toBe(8) + + // Row 2: Expansion, Normal, Generic (version=1, uber=0, classSpecific=0) + const r2 = table.getRow(1, 0, 0)! + expect(r2).toBeDefined() + expect(r2.version).toBe(1) + expect(r2.uber).toBe(0) + expect(r2.classSpecific).toBe(0) + expect(r2.unique).toBe(400) + expect(r2.uniqueDiv).toBe(1) + expect(r2.uniqueMin).toBe(6400) + expect(r2.rare).toBe(100) + expect(r2.rareDiv).toBe(2) + expect(r2.rareMin).toBe(3200) + expect(r2.set).toBe(160) + expect(r2.setDiv).toBe(2) + expect(r2.setMin).toBe(5600) + expect(r2.magic).toBe(34) + expect(r2.magicDiv).toBe(3) + expect(r2.magicMin).toBe(192) + expect(r2.hiQual).toBe(12) + expect(r2.hiQualDiv).toBe(8) + expect(r2.normal).toBe(2) + expect(r2.normalDiv).toBe(2) + + // Row 4: Expansion, Normal, ClassSpecific (version=1, uber=0, classSpecific=1) + const r4 = table.getRow(1, 0, 1)! + expect(r4).toBeDefined() + expect(r4.version).toBe(1) + expect(r4.uber).toBe(0) + expect(r4.classSpecific).toBe(1) + expect(r4.unique).toBe(240) + expect(r4.uniqueDiv).toBe(3) + expect(r4.magic).toBe(17) + expect(r4.magicDiv).toBe(6) + + // Row 5: Expansion, Uber, ClassSpecific (version=1, uber=1, classSpecific=1) + const r5 = table.getRow(1, 1, 1)! + expect(r5).toBeDefined() + expect(r5.version).toBe(1) + expect(r5.uber).toBe(1) + expect(r5.classSpecific).toBe(1) + expect(r5.normal).toBe(1) + expect(r5.normalDiv).toBe(1) + }) + + it('correctly handles boolean flags in getRow', () => { + const table = DEFAULT_ITEM_RATIO_TABLE + const row = table.getRow(true, false, true) + expect(row).toBeDefined() + expect(row?.version).toBe(1) + expect(row?.uber).toBe(0) + expect(row?.classSpecific).toBe(1) + }) + + it('falls back gracefully when querying classic class-specific row', () => { + // Classic has no class-specific items in vanilla, falls back to LoD class-specific + const table = DEFAULT_ITEM_RATIO_TABLE + const row = table.getRow(0, 0, 1) + expect(row).toBeDefined() + expect(row?.classSpecific).toBe(1) + }) + + it('parses D2Table via parseItemRatioTable with custom headers', () => { + const sampleText = + 'Function\tVersion\tUber\tClass Specific\tUnique\tUniqueDiv\tUniqueMin\tRare\tRareDiv\tRareMin\tSet\tSetDiv\tSetMin\tMagic\tMagicDiv\tMagicMin\tHiQual\tHiQualDiv\tNormal\tNormalDiv\n' + + 'Custom\t1\t0\t0\t500\t2\t6000\t120\t3\t3000\t180\t4\t5000\t40\t5\t200\t15\t10\t5\t3\n' + const rawBytes = new TextEncoder().encode(sampleText) + const d2t = parseTable(rawBytes) + const parsed = parseItemRatioTable(d2t) + expect(parsed.length).toBe(1) + const r = parsed.getRow(1, 0, 0)! + expect(r.unique).toBe(500) + expect(r.uniqueDiv).toBe(2) + expect(r.uniqueMin).toBe(6000) + expect(r.rare).toBe(120) + expect(r.set).toBe(180) + expect(r.magic).toBe(40) + expect(r.hiQual).toBe(15) + expect(r.normal).toBe(5) + }) + }) + + describe('2. Effective Magic Find Formula (D2Game!6FC2E130)', () => { + it('has NO diminishing returns when mf <= 10', () => { + // When x = mf + 100 <= 110, returns x + expect(effectiveMF(-20, MF_FACTOR_UNIQUE)).toBe(80) + expect(effectiveMF(0, MF_FACTOR_UNIQUE)).toBe(100) + expect(effectiveMF(1, MF_FACTOR_UNIQUE)).toBe(101) + expect(effectiveMF(5, MF_FACTOR_UNIQUE)).toBe(105) + expect(effectiveMF(10, MF_FACTOR_UNIQUE)).toBe(110) + expect(effectiveMF(10, MF_FACTOR_SET)).toBe(110) + expect(effectiveMF(10, MF_FACTOR_RARE)).toBe(110) + }) + + it('applies diminishing returns strictly when mf > 10', () => { + // mf = 11 for Unique (factor = 250): + // 100 + Math.trunc((11 * 250) / (11 + 250)) = 100 + Math.trunc(2750 / 261) = 100 + 10 = 110 + expect(effectiveMF(11, MF_FACTOR_UNIQUE)).toBe(110) + + // mf = 12 for Unique: + // 100 + Math.trunc((12 * 250) / (12 + 250)) = 100 + Math.trunc(3000 / 262) = 100 + 11 = 111 + expect(effectiveMF(12, MF_FACTOR_UNIQUE)).toBe(111) + + // mf = 100: + // Unique (250): 100 + Math.trunc((100 * 250) / 350) = 100 + Math.trunc(25000 / 350) = 100 + 71 = 171 + expect(effectiveMF(100, MF_FACTOR_UNIQUE)).toBe(171) + // Set (500): 100 + Math.trunc((100 * 500) / 600) = 100 + Math.trunc(50000 / 600) = 100 + 83 = 183 + expect(effectiveMF(100, MF_FACTOR_SET)).toBe(183) + // Rare (600): 100 + Math.trunc((100 * 600) / 700) = 100 + Math.trunc(60000 / 700) = 100 + 85 = 185 + expect(effectiveMF(100, MF_FACTOR_RARE)).toBe(185) + + // mf = 300: + // Unique (250): 100 + Math.trunc((300 * 250) / 550) = 100 + Math.trunc(75000 / 550) = 100 + 136 = 236 + expect(effectiveMF(300, MF_FACTOR_UNIQUE)).toBe(236) + // Set (500): 100 + Math.trunc((300 * 500) / 800) = 100 + Math.trunc(150000 / 800) = 100 + 187 = 287 + expect(effectiveMF(300, MF_FACTOR_SET)).toBe(287) + // Rare (600): 100 + Math.trunc((300 * 600) / 900) = 100 + Math.trunc(180000 / 900) = 100 + 200 = 300 + expect(effectiveMF(300, MF_FACTOR_RARE)).toBe(300) + }) + }) + + describe('3. Exact Integer Truncation at Each Step (D2Game!6FC2FC40)', () => { + const row = DEFAULT_ITEM_RATIO_ROWS[2] // Expansion, Normal, Generic + + it('strictly replicates Andariel Hell ring drop example from KB 320', () => { + // Ring (qlvl=1) dropped by Hell Andariel (ilvl=78), MF=300, TC factor=995 + // D = 78 - 1 = 77 + // divisor = 1 + // base chance = (400 - 77) * 128 = 323 * 128 = 41344 + // e = effectiveMF(300, 250) = 236 + // chance after MF = Math.trunc(41344 * 100 / 236) = Math.trunc(4134400 / 236) = 17518 + // uniqueMin = 6400 (17518 > 6400, no clamp) + // tcFactor reduction = Math.trunc(995 * 17518 / 1024) = Math.trunc(17430410 / 1024) = 17021 + // final chance = 17518 - 17021 = 497 + const chance = calculateUniqueChance(row, 78, 1, 300, 995) + expect(chance).toBe(497) + }) + + it('handles negative D (ilvl < qlvl) with strict signed integer truncation toward zero', () => { + // ilvl = 20, qlvl = 35 => D = -15 + // divisor = 2 (using Row 0: uniqueDiv = 2) + // Math.trunc(-15 / 2) = -7 (NOT Math.floor which would be -8) + // base = 400 => 400 - (-7) = 407 + // chance = 407 * 128 = 52096 + const classicRow = DEFAULT_ITEM_RATIO_ROWS[0] + const chance = calculateUniqueChance(classicRow, 20, 35, 0, 0) + expect(chance).toBe(52096) + }) + + it('clamps chance up to uniqueMin when calculated chance is lower', () => { + // If ilvl is very high and qlvl is 1, chance without clamp would drop below 6400 + // row.unique = 400, uniqueDiv = 1, ilvl = 95, qlvl = 1 => D = 94 + // chance = (400 - 94) * 128 = 306 * 128 = 39168 + // With high MF (e.g. 1000): + // e = 100 + Math.trunc(250000 / 1250) = 100 + 200 = 300 + // chance = Math.trunc(39168 * 100 / 300) = 13056 + // Now suppose base is modified or tcFactor is 0: + const customRow: ItemRatioRow = { + ...row, + unique: 50, + uniqueDiv: 1, + uniqueMin: 6400, + } + // D = 40, base - D = 10, 10 * 128 = 1280 < 6400 + const chance = calculateUniqueChance(customRow, 41, 1, 0, 0) + expect(chance).toBe(6400) // Clamped to uniqueMin! + }) + + it('Magic quality has NO diminishing returns', () => { + // Magic formula: chance = Math.trunc(chance * 100 / (mf + 100)) + // Row 2: magic = 34, magicDiv = 3, magicMin = 192 + // ilvl = 10, qlvl = 1 => D = 9 => Math.trunc(9 / 3) = 3 + // base chance = (34 - 3) * 128 = 31 * 128 = 3968 + // With MF = 300: divisor is mf + 100 = 400 (NOT diminished e = 236/287/300) + // chance = Math.trunc(3968 * 100 / 400) = Math.trunc(396800 / 400) = 992 + const chance = calculateMagicChance(row, 10, 1, 300, 0) + expect(chance).toBe(992) + }) + + it('Superior and Normal do NOT take MF', () => { + // Superior: (hiQual - Math.trunc(D / hiQualDiv)) * 128 + // Normal: (normal - Math.trunc(D / normalDiv)) * 128 + const sup0 = calculateSuperiorChance(row, 50, 10) + const sup1000 = calculateSuperiorChance(row, 50, 10) + expect(sup0).toBe(sup1000) + + const norm0 = calculateNormalChance(row, 50, 10) + const norm1000 = calculateNormalChance(row, 50, 10) + expect(norm0).toBe(norm1000) + + // Verify exact calculation for Superior: + // Row 2: hiQual = 12, hiQualDiv = 8 + // ilvl = 50, qlvl = 10 => D = 40 => Math.trunc(40 / 8) = 5 + // (12 - 5) * 128 = 7 * 128 = 896 + expect(sup0).toBe(896) + + // Verify exact calculation for Normal: + // Row 2: normal = 2, normalDiv = 2 + // D = 40 => Math.trunc(40 / 2) = 20 + // (2 - 20) * 128 = -18 * 128 = -2304 + expect(norm0).toBe(-2304) + }) + + it('tcFactor = 1024 guarantees hit (chance <= 0)', () => { + // tcFactor reduction: Math.trunc(1024 * chance / 1024) = chance + // chance - chance = 0 <= 0 + const uniqueChance = calculateUniqueChance(row, 10, 1, 0, 1024) + expect(uniqueChance).toBe(0) + + const setChance = calculateSetChance(row, 10, 1, 0, 1024) + expect(setChance).toBe(0) + + const rareChance = calculateRareChance(row, 10, 1, 0, 1024) + expect(rareChance).toBe(0) + + const magicChance = calculateMagicChance(row, 10, 1, 0, 1024) + expect(magicChance).toBe(0) + }) + }) + + describe('4. Quality Roll Sequence: Unique -> Set -> Rare -> Magic -> Superior -> Normal/Low', () => { + it('returns "unique" when unique roll succeeds', () => { + // Mock RNG that always returns 0 (< 128) + const mockRng: QualityRollRng = { rand: () => 0 } + const quality = rollItemQuality({ + ilvl: 80, + qlvl: 10, + mf: 100, + rng: mockRng, + }) + expect(quality).toBe('unique') + }) + + it('CRITICAL: rolls Set BEFORE Rare even though table columns are Unique->Rare->Set', () => { + // Sequence of RNG rolls: + // 1st roll (Unique): returns 200 (>= 128, fails) + // 2nd roll (Set): returns 50 (< 128, succeeds!) + // If order were Unique->Rare->Set, Rare would have consumed 50 and returned rare. + // Because order is Unique->Set->Rare, Set consumes 50 and returns 'set'! + const rolls = [200, 50, 200, 200] + let index = 0 + const mockRng: QualityRollRng = { + rand: () => rolls[index++] ?? 200, + } + + const quality = rollItemQuality({ + ilvl: 50, + qlvl: 10, + rng: mockRng, + }) + expect(quality).toBe('set') + expect(index).toBe(2) // Exactly 2 rolls were consumed: Unique, then Set! + }) + + it('rolls Rare if Unique and Set fail', () => { + const rolls = [200, 200, 50, 200] + let index = 0 + const mockRng: QualityRollRng = { + rand: () => rolls[index++] ?? 200, + } + + const quality = rollItemQuality({ + ilvl: 50, + qlvl: 10, + rng: mockRng, + }) + expect(quality).toBe('rare') + expect(index).toBe(3) // Unique, Set, Rare! + }) + + it('rolls Magic if Unique, Set, and Rare fail', () => { + const rolls = [200, 200, 200, 50] + let index = 0 + const mockRng: QualityRollRng = { + rand: () => rolls[index++] ?? 200, + } + + const quality = rollItemQuality({ + ilvl: 50, + qlvl: 10, + rng: mockRng, + }) + expect(quality).toBe('magic') + expect(index).toBe(4) // Unique, Set, Rare, Magic! + }) + + it('rolls Superior if Unique, Set, Rare, and Magic fail', () => { + const rolls = [200, 200, 200, 200, 50] + let index = 0 + const mockRng: QualityRollRng = { + rand: () => rolls[index++] ?? 200, + } + + const quality = rollItemQuality({ + ilvl: 50, + qlvl: 10, + rng: mockRng, + }) + expect(quality).toBe('superior') + expect(index).toBe(5) // Unique, Set, Rare, Magic, Superior! + }) + + it('rolls Normal if Superior fails and Normal roll succeeds', () => { + const rolls = [200, 200, 200, 200, 200, 50] + let index = 0 + const mockRng: QualityRollRng = { + rand: () => rolls[index++] ?? 200, + } + + const quality = rollItemQuality({ + ilvl: 5, + qlvl: 10, // D = -5 so Normal chance > 0 + rng: mockRng, + }) + expect(quality).toBe('normal') + expect(index).toBe(6) + }) + + it('rolls Low if Superior fails and Normal roll fails', () => { + const rolls = [200, 200, 200, 200, 200, 200] + let index = 0 + const mockRng: QualityRollRng = { + rand: () => rolls[index++] ?? 200, + } + + const quality = rollItemQuality({ + ilvl: 5, + qlvl: 10, + rng: mockRng, + }) + expect(quality).toBe('low') + expect(index).toBe(6) + }) + + it('guarantees quality without consuming RNG if tcFactor = 1024', () => { + let rngCalled = false + const mockRng: QualityRollRng = { + rand: () => { + rngCalled = true + return 999 + }, + } + + const quality = rollItemQuality({ + ilvl: 10, + qlvl: 10, + tcFactor: 1024, + rng: mockRng, + }) + expect(quality).toBe('unique') + expect(rngCalled).toBe(false) // chance <= 0 returns immediately without RNG + }) + + it('respects quality disable flags', () => { + // If allowUnique = false, jumps straight to Set + const mockRng: QualityRollRng = { rand: () => 0 } + const quality = rollItemQuality({ + ilvl: 50, + qlvl: 10, + allowUnique: false, + rng: mockRng, + }) + expect(quality).toBe('set') + }) + }) +})