/** * M3 item verification against real 1.13c MPQ data and TreasureClass tree invariants. * * Asserts against real Blizzard MPQ tables mounted from samples/d2: * - Data scales (base items, affixes, item types, TCs, uniques, sets, runes, gems, stats, ratio) * - TreasureClass tree invariants (acyclicity, max depth <= 64, resolving dangling references, NoDrop+prob > 0, magic factor 1024) * - Fixed probability points (Act 1 H2H A, Cow (H), exact branch probabilities, Act 5 (H) Equip B comment consistency) * - Cross-table mappings (cap stats, Weapons col 18 hazard, Armor mindam/maxdam duplicate, properties -> stat, affix -> property, itype/etype -> itemTypes) * - Localization coverage (ENG 202/306/143, CHI UTF-8 cap -> 帽子, LoD fallbacks: r33, Windforce, Annihilus) * - RNG invariants (64-bit LCG D2Rng vector reproducibility, power-of-2 AND vs modulo) * - Monte Carlo vs Analytic (100,000 runs of Act 1 H2H A within 99% CI) * - Real item simulation (inventory grid placement, stack overflow, gold stacking, derived stats) * * Usage: npx tsx scripts/verify-items.ts [directory] */ import * as fs from 'node:fs' import { join } from 'node:path' import { MpqArchive } from '../src/mpq/archive.ts' import { MountedArchives } from '../src/mpq/mount.ts' import { fileSource } from '../src/mpq/file-source.ts' import { parseTable, cell } from '../src/game/acts.ts' import { loadArmor, ArmorTable } from '../src/game/armor.ts' import { loadWeapons, getWeaponCell } from '../src/game/weapons.ts' import { loadMisc } from '../src/game/misc-items.ts' import { loadItemTypes } from '../src/game/item-types.ts' import { loadTreasureClasses } from '../src/game/treasure-class.ts' import { generateAutoTreasureClasses, mergeAutoTreasureClasses, AUTO_TC_SERIES, AUTO_TC_TOTAL_NODES, } from '../src/game/auto-tc.ts' import { loadUniqueItems } from '../src/game/unique-items.ts' import { loadSetData } from '../src/game/set-items.ts' import { loadRunewords } from '../src/game/runeword.ts' import { loadGems } from '../src/game/gems.ts' import { loadItemPropertiesAndStats } from '../src/game/properties.ts' import { loadItemRatio } from '../src/game/item-ratio.ts' import { loadMagicAffixes } from '../src/game/affixes.ts' import type { MagicAffix } from '../src/game/affixes.ts' import { loadItemNameChains, resolveArmorName, resolveWeaponName, resolveMiscName, resolveUniqueName, resolveRuneName, StringTableChain, } from '../src/game/item-names.ts' import { D2Rng, D2_PRNG_MULTIPLIER } from '../src/game/d2-rng.ts' import { Rng } from '../src/game/rng.ts' import { Inventory, createItem, goldItem, totalStat } from '../src/game/items.ts' import type { Affix } from '../src/game/items.ts' const problems: string[] = [] let checks = 0 /** * Assert one expectation. * * @param condition - whether the condition holds. * @param description - what was asserted. */ function expect(condition: boolean, description: string): void { checks += 1 if (!condition) { problems.push(description) console.log(` FAIL ${description}`) } else { console.log(` ok ${description}`) } } // ----------------------------------------------------------------------------- // 1. Mount real MPQ archives from samples/d2 // ----------------------------------------------------------------------------- const mpqDir = process.argv[2] ?? 'samples/d2' const requiredArchives = ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] const missingArchives = requiredArchives.filter(name => !fs.existsSync(join(mpqDir, name))) if (missingArchives.length > 0) { console.warn( `WARNING: MPQ archive(s) missing in ${mpqDir}: ${missingArchives.join(', ')}. ` + 'Skipping verify-items gracefully for CI/headless environment without game files.', ) process.exit(0) } const archives = new MountedArchives() for (const name of requiredArchives) { const fullPath = join(mpqDir, name) try { archives.add(name, await MpqArchive.open(await fileSource(fullPath))) } catch (err) { console.warn(`WARNING: Failed to open ${fullPath}: ${String(err)}. Skipping gracefully.`) process.exit(0) } } console.log(`== Mounted ${String(archives.size)} MPQ archives from ${mpqDir} ==\n`) // ----------------------------------------------------------------------------- // Load all real tables // ----------------------------------------------------------------------------- console.log('-- Loading real 1.13c game tables...') const armor = await loadArmor(archives) const weapons = await loadWeapons(archives) const misc = await loadMisc(archives) const itemTypes = await loadItemTypes(archives) const tcTable = await loadTreasureClasses(archives) const autoTcs = generateAutoTreasureClasses(itemTypes, armor, weapons) const affixes = await loadMagicAffixes(archives) const { prefixes, suffixes } = affixes const uniques = await loadUniqueItems(archives) const setData = await loadSetData(archives) const runes = await loadRunewords(archives) const gems = await loadGems(archives) const propStats = await loadItemPropertiesAndStats(archives) const itemRatio = await loadItemRatio(archives) const nameChains = await loadItemNameChains(archives) // Read raw tables for structural header/row checks const rawArmor = parseTable(await archives.read('data\\global\\excel\\Armor.txt')) const rawWeapons = parseTable(await archives.read('data\\global\\excel\\Weapons.txt')) const rawMisc = parseTable(await archives.read('data\\global\\excel\\Misc.txt')) const rawItemTypes = parseTable(await archives.read('data\\global\\excel\\ItemTypes.txt')) const rawTc = parseTable(await archives.read('data\\global\\excel\\TreasureClassEx.txt')) // ----------------------------------------------------------------------------- // 2. Data Scales Assertions // ----------------------------------------------------------------------------- console.log('\n-- Data Scales --') // Base items: exactly 659 (Armor 202 + Weapons 306 + Misc 151, all without Expansion rows) expect(armor.all.length === 202, 'Armor has exactly 202 base item rows') expect(weapons.all.length === 306, 'Weapons has exactly 306 base item rows') expect(misc.all.length === 151, 'Misc has exactly 151 base item rows') const totalBaseItems = armor.all.length + weapons.all.length + misc.all.length expect(totalBaseItems === 659, `Base items total exactly 659 (Armor 202 + Weapons 306 + Misc 151): found ${String(totalBaseItems)}`) // Confirm Expansion separator row filtered out from each base item table expect( rawArmor.rows.length === 203 && rawArmor.rows.some(r => cell(rawArmor, r, 'name').trim() === 'Expansion'), 'Armor.txt filters 1 Expansion row from 203 raw rows to yield 202 data rows', ) expect( rawWeapons.rows.length === 307 && rawWeapons.rows.some(r => cell(rawWeapons, r, 'name').trim() === 'Expansion'), 'Weapons.txt filters 1 Expansion row from 307 raw rows to yield 306 data rows', ) expect( rawMisc.rows.length === 152 && rawMisc.rows.some(r => cell(rawMisc, r, 'name').trim() === 'Expansion'), 'Misc.txt filters 1 Expansion row from 152 raw rows to yield 151 data rows', ) // Magic affixes: exactly 1378 (637 prefixes + 741 suffixes), 1162 spawnable=1 expect(prefixes.length === 637, `Magic prefixes total exactly 637: found ${String(prefixes.length)}`) expect(suffixes.length === 741, `Magic suffixes total exactly 741: found ${String(suffixes.length)}`) const totalAffixes = prefixes.length + suffixes.length expect(totalAffixes === 1378, `Magic affixes total exactly 1378 (637 prefixes + 741 suffixes): found ${String(totalAffixes)}`) const spawnablePrefixes = prefixes.all.filter(a => a.spawnable).length const spawnableSuffixes = suffixes.all.filter(a => a.spawnable).length const totalSpawnableAffixes = spawnablePrefixes + spawnableSuffixes expect( totalSpawnableAffixes === 1162, `Magic affixes spawnable=1 count is exactly 1162 (587 prefixes + 575 suffixes): found ${String(totalSpawnableAffixes)}`, ) // ItemTypes: exactly 98 with valid Code expect(itemTypes.types.length === 98, `ItemTypes has exactly 98 valid types: found ${String(itemTypes.types.length)}`) expect(itemTypes.byCode.size === 98, `ItemTypes indexed by Code has exactly 98 entries: found ${String(itemTypes.byCode.size)}`) expect( itemTypes.types.every(t => t.code.trim().length > 0 && t.name !== 'None' && t.name !== 'Not Used' && t.name !== 'Expansion'), 'All 98 ItemTypes possess valid non-empty Codes and exclude placeholders', ) // TreasureClass=1 ItemTypes: exactly 5 (bow, weap, mele, armo, abow) const tcSeeds = itemTypes.types.filter(t => t.treasureClass === 1).map(t => t.code) expect(tcSeeds.length === 5, `TreasureClass=1 ItemTypes count is exactly 5: found ${String(tcSeeds.length)}`) const expectedTcSeeds = ['bow', 'weap', 'mele', 'armo', 'abow'].sort() expect( [...tcSeeds].sort().join(',') === expectedTcSeeds.join(','), `TreasureClass=1 ItemTypes codes match exactly (bow, weap, mele, armo, abow): found [${tcSeeds.join(', ')}]`, ) // TreasureClassEx: exactly 852 rows expect(tcTable.all.length === 852, `TreasureClassEx has exactly 852 rows: found ${String(tcTable.all.length)}`) // auto-TC: exactly 100 virtual nodes (weap3..87, armo3..87, bow3..87, mele3..39) expect(AUTO_TC_TOTAL_NODES === 100, `AUTO_TC_TOTAL_NODES constant is 100: found ${String(AUTO_TC_TOTAL_NODES)}`) expect(autoTcs.size === 100, `auto-TC generated exactly 100 virtual nodes: found ${String(autoTcs.size)}`) const weapCount = AUTO_TC_SERIES.find(s => s.typeCode === 'weap')?.count ?? 0 const armoCount = AUTO_TC_SERIES.find(s => s.typeCode === 'armo')?.count ?? 0 const bowCount = AUTO_TC_SERIES.find(s => s.typeCode === 'bow')?.count ?? 0 const meleCount = AUTO_TC_SERIES.find(s => s.typeCode === 'mele')?.count ?? 0 expect( weapCount === 29 && armoCount === 29 && bowCount === 29 && meleCount === 13, `auto-TC series breakdown matches: weap 29 (3..87), armo 29 (3..87), bow 29 (3..87), mele 13 (3..39)`, ) // UniqueItems: 401 rows, 385 enabled=1 expect(uniques.all.length === 401, `UniqueItems has exactly 401 rows: found ${String(uniques.all.length)}`) const enabledUniques = uniques.all.filter(u => u.enabled).length expect(enabledUniques === 385, `UniqueItems enabled=1 count is exactly 385: found ${String(enabledUniques)}`) // SetItems: 127, Sets: 32 expect(setData.items.length === 127, `SetItems has exactly 127 items: found ${String(setData.items.length)}`) expect(setData.sets.length === 32, `Sets has exactly 32 sets: found ${String(setData.sets.length)}`) // Runes: 169 rows, complete=1 78, server=1 23 expect(runes.all.length === 169, `Runes.txt has exactly 169 rows: found ${String(runes.all.length)}`) const completeRunewords = runes.all.filter(r => r.complete).length expect(completeRunewords === 78, `Runes complete=1 count is exactly 78: found ${String(completeRunewords)}`) const serverRunewords = runes.all.filter(r => r.server).length expect(serverRunewords === 23, `Runes server=1 (ladder) count is exactly 23: found ${String(serverRunewords)}`) // Gems: exactly 68 rows (35 gems + 33 runes) expect(gems.all.length === 68, `Gems.txt has exactly 68 rows: found ${String(gems.all.length)}`) expect(gems.gems.length === 35, `Gems.txt contains exactly 35 gem rows: found ${String(gems.gems.length)}`) expect(gems.runes.length === 33, `Gems.txt contains exactly 33 rune rows: found ${String(gems.runes.length)}`) // ItemStatCost: 359, Properties: 268 expect(propStats.stats.records.length === 359, `ItemStatCost has exactly 359 stats: found ${String(propStats.stats.records.length)}`) expect(propStats.properties.records.length === 268, `Properties has exactly 268 properties: found ${String(propStats.properties.records.length)}`) // ItemRatio: 6 rows expect(itemRatio.rows.length === 6, `ItemRatio has exactly 6 rows: found ${String(itemRatio.rows.length)}`) // ----------------------------------------------------------------------------- // 3. TC Tree Invariants Assertions // ----------------------------------------------------------------------------- console.log('\n-- TC Tree Invariants --') // 218 dangling references in TreasureClassEx all resolve (100 auto-TC + 118 real items, 0 unknown) expect( tcTable.danglingReferences.size === 218, `TreasureClassEx has exactly 218 dangling references: found ${String(tcTable.danglingReferences.size)}`, ) expect( tcTable.autoTCDanglingReferences.size === 100, `TreasureClassEx has exactly 100 auto-TC references: found ${String(tcTable.autoTCDanglingReferences.size)}`, ) expect( tcTable.baseItemDanglingReferences.size === 118, `TreasureClassEx has exactly 118 base item and special references: found ${String(tcTable.baseItemDanglingReferences.size)}`, ) // All 100 auto-TC dangling references exist in generated auto-TC map const autoTcsCovered = [...tcTable.autoTCDanglingReferences].every(ref => autoTcs.has(ref)) expect(autoTcsCovered, 'All 100 auto-TC dangling references resolve to generated auto-TC virtual nodes') // All 118 base item dangling references resolve (gold, Annihilus, armor, weapons, misc; 0 unknown) let unknownDanglingCount = 0 for (const ref of tcTable.baseItemDanglingReferences) { const isGold = ref === 'gld' || ref.startsWith('gld,mul=') const isAnnihilus = ref === 'Annihilus' const inArmor = armor.get(ref) !== undefined const inWeapons = weapons.get(ref) !== undefined const inMisc = misc.get(ref) !== undefined if (!isGold && !isAnnihilus && !inArmor && !inWeapons && !inMisc) { unknownDanglingCount += 1 } } expect(unknownDanglingCount === 0, `All 118 real item dangling references resolve (0 unknown): found ${String(unknownDanglingCount)} unknown`) // TC tree is acyclic, expands to leaves in <= 64 steps // Merge auto-TCs into TC table to check full tree expansion mergeAutoTreasureClasses(tcTable, autoTcs) function getTcMaxDepth(name: string, visited: Set): number { if (visited.has(name)) { throw new Error(`Cycle detected at TC "${name}"`) } const node = tcTable.get(name) if (!node || node.items.length === 0) return 0 visited.add(name) let maxSub = 0 for (const it of node.items) { if (tcTable.byName.has(it.item)) { const d = getTcMaxDepth(it.item, new Set(visited)) if (d > maxSub) maxSub = d } } return 1 + maxSub } let isAcyclic = true let maxTreeDepth = 0 try { for (const node of tcTable.all) { const depth = getTcMaxDepth(node.name, new Set()) if (depth > maxTreeDepth) maxTreeDepth = depth } } catch { isAcyclic = false } expect(isAcyclic, 'TreasureClass hierarchy is strictly acyclic') expect(maxTreeDepth <= 64, `TreasureClass tree expands to leaves in <= 64 steps: maximum depth is ${String(maxTreeDepth)}`) // Every node in TreasureClassEx has NoDrop + sum(Prob) > 0 const originalTcNodes = tcTable.all.filter(n => !autoTcs.has(n.name)) const allNodesHaveProb = originalTcNodes.every(node => { const sumProb = node.items.reduce((s, it) => s + it.prob, 0) return node.noDrop + sumProb > 0 }) expect(allNodesHaveProb, 'Every node in TreasureClassEx satisfies NoDrop + sum(Prob) > 0') // Magic quality factor column in TreasureClassEx: all 231 non-empty values equal 1024 const magicFactorNodes = originalTcNodes.filter(n => n.magic > 0) expect(magicFactorNodes.length === 231, `TreasureClassEx has exactly 231 non-empty Magic quality factor rows: found ${String(magicFactorNodes.length)}`) expect( magicFactorNodes.every(n => n.magic === 1024), 'All 231 non-empty Magic quality factor values strictly equal 1024', ) // ----------------------------------------------------------------------------- // 4. Fixed Probability Points Assertions // ----------------------------------------------------------------------------- console.log('\n-- Fixed Probability Points --') // Act 1 H2H A 1-player NoDrop = 100/160 = 62.5% const act1H2HA = tcTable.get('Act 1 H2H A')! expect(act1H2HA !== undefined, 'TreasureClass "Act 1 H2H A" exists') expect(act1H2HA.noDrop === 100, `Act 1 H2H A NoDrop is 100: found ${String(act1H2HA.noDrop)}`) expect(act1H2HA.totalProbExpansion === 60, `Act 1 H2H A totalProb is 60: found ${String(act1H2HA.totalProbExpansion)}`) const act1NoDropChance = act1H2HA.noDrop / (act1H2HA.noDrop + act1H2HA.totalProbExpansion) expect(act1NoDropChance === 0.625, `Act 1 H2H A 1-player NoDrop equals 100/160 = 62.5%: found ${(act1NoDropChance * 100).toFixed(3)}%`) // Cow (H) 1-player NoDrop = 100/160 = 62.5% const cowH = tcTable.get('Cow (H)')! expect(cowH !== undefined, 'TreasureClass "Cow (H)" exists') expect(cowH.noDrop === 100, `Cow (H) NoDrop is 100: found ${String(cowH.noDrop)}`) expect(cowH.totalProbExpansion === 60, `Cow (H) totalProb is 60: found ${String(cowH.totalProbExpansion)}`) const cowNoDropChance = cowH.noDrop / (cowH.noDrop + cowH.totalProbExpansion) expect(cowNoDropChance === 0.625, `Cow (H) 1-player NoDrop equals 100/160 = 62.5%: found ${(cowNoDropChance * 100).toFixed(3)}%`) // Act 1 H2H A exact branch probabilities: gld 13.125%, Act 1 Equip A 10.0%, Act 1 Junk 13.125%, Act 1 Good 1.25%, NoDrop 62.5% const denom = act1H2HA.noDrop + act1H2HA.totalProbExpansion // 160 const probGld = (act1H2HA.items.find(it => it.item === 'gld')?.prob ?? 0) / denom const probEquipA = (act1H2HA.items.find(it => it.item === 'Act 1 Equip A')?.prob ?? 0) / denom const probJunk = (act1H2HA.items.find(it => it.item === 'Act 1 Junk')?.prob ?? 0) / denom const probGood = (act1H2HA.items.find(it => it.item === 'Act 1 Good')?.prob ?? 0) / denom expect(probGld === 21 / 160 && probGld === 0.13125, `Act 1 H2H A gld branch is 21/160 = 13.125%: found ${(probGld * 100).toFixed(3)}%`) expect(probEquipA === 16 / 160 && probEquipA === 0.1, `Act 1 H2H A Act 1 Equip A branch is 16/160 = 10.0%: found ${(probEquipA * 100).toFixed(3)}%`) expect(probJunk === 21 / 160 && probJunk === 0.13125, `Act 1 H2H A Act 1 Junk branch is 21/160 = 13.125%: found ${(probJunk * 100).toFixed(3)}%`) expect(probGood === 2 / 160 && probGood === 0.0125, `Act 1 H2H A Act 1 Good branch is 2/160 = 1.25%: found ${(probGood * 100).toFixed(3)}%`) expect(act1NoDropChance === 100 / 160 && act1NoDropChance === 0.625, `Act 1 H2H A NoDrop branch is 100/160 = 62.5%: found ${(act1NoDropChance * 100).toFixed(3)}%`) // Act 5 (H) Equip B comment self-consistency: SumItems=36, TotalProb=1566, DropChance=0.9770 const rawAct5Row = rawTc.rows.find(r => cell(rawTc, r, 'Treasure Class').trim() === 'Act 5 (H) Equip B')! expect(rawAct5Row !== undefined, 'Raw TreasureClassEx.txt contains "Act 5 (H) Equip B"') const rawSumItems = cell(rawTc, rawAct5Row, 'SumItems').trim() const rawTotalProb = cell(rawTc, rawAct5Row, 'TotalProb').trim() const rawDropChance = cell(rawTc, rawAct5Row, 'DropChance').trim() expect(rawSumItems === '36', `Act 5 (H) Equip B raw SumItems column is "36": found "${rawSumItems}"`) expect(rawTotalProb === '1566', `Act 5 (H) Equip B raw TotalProb column is "1566": found "${rawTotalProb}"`) expect(rawDropChance === '0.9770', `Act 5 (H) Equip B raw DropChance column is "0.9770": found "${rawDropChance}"`) // Self-consistency mathematical check: // Sum of Item1..8 probs = 2 + 1 + 6 + 3 + 14 + 7 + 2 + 1 = 36 // Item9 (Act 5 (H) Equip A) prob = 1530 // TotalProb = 36 + 1530 = 1566 // DropChance = 1530 / 1566 = 0.9769987... -> "0.9770" const act5B = tcTable.get('Act 5 (H) Equip B')! const computedSumItems = act5B.items.slice(0, 8).reduce((s, it) => s + it.prob, 0) const computedTotalProb = act5B.totalProbExpansion const computedDropChance = (act5B.items[8]!.prob / computedTotalProb).toFixed(4) expect( computedSumItems === 36 && computedTotalProb === 1566 && computedDropChance === '0.9770', 'Act 5 (H) Equip B calculated item weights and recursion ratio match Blizzard comment columns exactly', ) // ----------------------------------------------------------------------------- // 5. Cross-table Mapping Assertions // ----------------------------------------------------------------------------- console.log('\n-- Cross-table Mapping --') // cap: level=1, minac=3, maxac=5, reqstr=0, durability=12, gemsockets=2, ubercode='xap', ultracode='uap' const cap = armor.get('cap')! expect(cap !== undefined, 'Armor base "cap" exists') expect(cap.level === 1, `cap level is 1: found ${String(cap.level)}`) expect(cap.minac === 3, `cap minac is 3: found ${String(cap.minac)}`) expect(cap.maxac === 5, `cap maxac is 5: found ${String(cap.maxac)}`) expect(cap.reqstr === 0, `cap reqstr is 0: found ${String(cap.reqstr)}`) expect(cap.durability === 12, `cap durability is 12: found ${String(cap.durability)}`) expect(cap.gemsockets === 2, `cap gemsockets is 2: found ${String(cap.gemsockets)}`) expect(cap.ubercode === 'xap', `cap ubercode is "xap": found "${cap.ubercode}"`) expect(cap.ultracode === 'uap', `cap ultracode is "uap": found "${cap.ultracode}"`) // Weapons.txt empty header at index 18 does not corrupt data expect(rawWeapons.header[18] === '', 'Weapons.txt raw header at index 18 is empty string ("")') const jav = weapons.get('jav')! expect(jav !== undefined, 'Weapons base "jav" (Javelin) exists') expect(jav.maxMisDam === 14, `jav maxmisdam (col 17) is 14: found ${String(jav.maxMisDam)}`) expect(jav.rangeadder === 2, `jav rangeadder (col 19) is 2 (not corrupted by col 18 value '15'): found ${String(jav.rangeadder)}`) expect(weapons.get('') === undefined, 'Weapons table does not index empty string header') const javRawRow = rawWeapons.rows.find(r => cell(rawWeapons, r, 'code').trim() === 'jav')! expect(getWeaponCell(rawWeapons, javRawRow, '') === '', 'getWeaponCell safely returns empty string when querying empty header column') // Armor.txt duplicate mindam/maxdam selects the correct pair const mindamIndices: number[] = [] rawArmor.header.forEach((h, i) => { if (h.toLowerCase() === 'mindam') mindamIndices.push(i) }) expect( mindamIndices.length === 2 && mindamIndices[0] === 63 && mindamIndices[1] === 161, 'Armor.txt contains duplicate mindam headers at index 63 and index 161', ) const lbtRawRow = rawArmor.rows.find(r => cell(rawArmor, r, 'code').trim() === 'lbt')! expect( lbtRawRow[63] === '3' && lbtRawRow[64] === '8' && lbtRawRow[161] === '0' && lbtRawRow[162] === '0', 'Armor.txt Leather Boots (lbt) has kick damage (3, 8) at index 63/64 and dummy zeroes at index 161/162', ) // Properties.statN -> ItemStatCost.Stat 0 dangling let danglingPropertyStats = 0 for (const prop of propStats.properties.records) { for (const s of prop.stats) { if (s.stat && propStats.stats.getByStat(s.stat) === undefined) { danglingPropertyStats += 1 } } } expect(danglingPropertyStats === 0, `Properties.statN -> ItemStatCost.Stat has 0 dangling references: found ${String(danglingPropertyStats)}`) // Affix modNcode -> Properties.code 0 dangling let danglingAffixMods = 0 const allAffixes = [...prefixes.all, ...suffixes.all] for (const affix of allAffixes) { for (const mod of affix.mods) { if (mod.code && propStats.properties.getByCode(mod.code) === undefined) { danglingAffixMods += 1 } } } expect(danglingAffixMods === 0, `Affix modNcode -> Properties.code has 0 dangling references: found ${String(danglingAffixMods)}`) // itype/etype -> ItemTypes.Code 0 dangling let danglingRuneTypes = 0 for (const r of runes.all) { for (const it of r.itypes) { if (it && !itemTypes.byCode.has(it)) danglingRuneTypes += 1 } for (const et of r.etypes) { if (et && !itemTypes.byCode.has(et)) danglingRuneTypes += 1 } } expect(danglingRuneTypes === 0, `Runes.txt itype/etype -> ItemTypes.Code has 0 dangling references: found ${String(danglingRuneTypes)}`) // Affix itype/etype check (with Blizzard 1.13c known 'staff' -> 'staf' normalization) let danglingAffixTypes = 0 for (const affix of allAffixes) { for (const it of affix.itypes) { const normalized = it === 'staff' ? 'staf' : it if (normalized && !itemTypes.byCode.has(normalized)) danglingAffixTypes += 1 } for (const et of affix.etypes) { const normalized = et === 'staff' ? 'staf' : et if (normalized && !itemTypes.byCode.has(normalized)) danglingAffixTypes += 1 } } expect(danglingAffixTypes === 0, `Affix itype/etype -> ItemTypes.Code has 0 dangling references (with staff->staf): found ${String(danglingAffixTypes)}`) // ----------------------------------------------------------------------------- // 6. Localization Coverage Assertions // ----------------------------------------------------------------------------- console.log('\n-- Localization Coverage --') const { eng, chi } = nameChains // ENG: Armor 202/202, Weapons 306/306, Misc 143/151 const engArmorCovered = armor.all.filter(a => eng.has(a.namestr)).length expect(engArmorCovered === 202, `ENG string tables cover Armor 202/202: found ${String(engArmorCovered)}`) const engWeaponsCovered = weapons.all.filter(w => eng.has(w.namestr)).length expect(engWeaponsCovered === 306, `ENG string tables cover Weapons 306/306: found ${String(engWeaponsCovered)}`) const engMiscCovered = misc.all.filter(m => eng.has(m.namestr)).length expect(engMiscCovered === 143, `ENG string tables cover Misc 143/151: found ${String(engMiscCovered)}`) // CHI UTF-8: cap -> '帽子' const chiCap = resolveArmorName('cap', chi, eng) expect(chiCap === '帽子', `CHI UTF-8 resolves "cap" to "帽子": found "${chiCap}"`) // CHI fallback to ENG for LoD items missing in CHI expansionstring: r33, Windforce, Annihilus expect(chi.get('r33') === undefined, 'CHI string table lacks LoD key "r33"') expect(resolveRuneName('r33', chi, eng) === 'Zod Rune', `resolveRuneName("r33") falls back to ENG "Zod Rune": found "${resolveRuneName('r33', chi, eng)}"`) expect(chi.get('Windforce') === undefined, 'CHI string table lacks LoD key "Windforce"') expect(resolveUniqueName('Windforce', chi, eng) === 'Windforce', `resolveUniqueName("Windforce") falls back to ENG "Windforce": found "${resolveUniqueName('Windforce', chi, eng)}"`) expect(chi.get('Annihilus') === undefined, 'CHI string table lacks LoD key "Annihilus"') expect(resolveUniqueName('Annihilus', chi, eng) === 'Annihilus', `resolveUniqueName("Annihilus") falls back to ENG "Annihilus": found "${resolveUniqueName('Annihilus', chi, eng)}"`) // ----------------------------------------------------------------------------- // 7. RNG Invariants Assertions // ----------------------------------------------------------------------------- console.log('\n-- RNG Invariants --') // 64-bit LCG D2Rng reproducibility with known seed vector expect(D2_PRNG_MULTIPLIER === 0x6ac690c5n, 'D2_PRNG_MULTIPLIER is canonical 1.13c constant 0x6AC690C5') const rngA = new D2Rng({ lo: 1, hi: 0 }) const knownSteps = [ { lo: 1791398085, hi: 0 }, { lo: 1721382809, hi: 747178471 }, { lo: 986833572, hi: 717975634 }, { lo: 2963478662, hi: 411600752 }, { lo: 930727566, hi: 1236044336 }, { lo: 3622188406, hi: 388199365 }, { lo: 4003526547, hi: 1510787143 }, { lo: 3772775526, hi: 1669840372 }, { lo: 3770833010, hi: 1573595882 }, { lo: 1095064228, hi: 1572785674 }, ] let rngStepsMatch = true for (const step of knownSteps) { rngA.step() const s = rngA.getSeed() if (s.lo !== step.lo || s.hi !== step.hi) { rngStepsMatch = false break } } expect(rngStepsMatch, '64-bit LCG D2Rng reproduces exact bitwise sequence across 10 steps from seed {lo: 1, hi: 0}') const rngB = new D2Rng({ lo: 100, hi: 666 }) rngB.step() expect( rngB.lo === 3046150030 && rngB.hi === 41, 'D2Rng matches SEED_InitLowSeed / rollD2Random vector {lo: 100, hi: 666} -> {lo: 3046150030, hi: 41}', ) // Power of 2 (1024) AND-branch vs non-power of 2 (1000) modulo-branch const rngBranchTest = new D2Rng(42, 0) const roll1024 = rngBranchTest.rand(1024) const expectedAnd1024 = (rngBranchTest.lo & 1023) >>> 0 expect(roll1024 === expectedAnd1024, `Power-of-2 (1024) branch uses bitwise AND (lo & 1023): ${String(roll1024)} === ${String(expectedAnd1024)}`) const roll1000 = rngBranchTest.rand(1000) const expectedMod1000 = (rngBranchTest.lo % 1000) >>> 0 expect(roll1000 === expectedMod1000, `Non-power-of-2 (1000) branch uses modulo (lo % 1000): ${String(roll1000)} === ${String(expectedMod1000)}`) // ----------------------------------------------------------------------------- // 8. Monte Carlo vs Analytic Assertions // ----------------------------------------------------------------------------- console.log('\n-- Monte Carlo vs Analytic (100,000 runs) --') const MC_RUNS = 100000 const mcRng = new D2Rng(987654321) const counts = { noDrop: 0, gld: 0, equipA: 0, junk: 0, good: 0 } for (let i = 0; i < MC_RUNS; i++) { const roll = mcRng.rand(160) if (roll < 100) { counts.noDrop += 1 } else { const target = roll - 100 if (target < 21) counts.gld += 1 else if (target < 37) counts.equipA += 1 else if (target < 58) counts.junk += 1 else counts.good += 1 } } const observedProbs = { noDrop: counts.noDrop / MC_RUNS, gld: counts.gld / MC_RUNS, equipA: counts.equipA / MC_RUNS, junk: counts.junk / MC_RUNS, good: counts.good / MC_RUNS, } const expectedProbs = { noDrop: 0.625, gld: 0.13125, equipA: 0.1, junk: 0.13125, good: 0.0125, } // 99% Confidence Interval: Z = 2.576, margin = Z * sqrt(p*(1-p)/N) const Z_99 = 2.576 for (const key of Object.keys(expectedProbs) as (keyof typeof expectedProbs)[]) { const p = expectedProbs[key] const se = Math.sqrt((p * (1 - p)) / MC_RUNS) const margin = Z_99 * se const obs = observedProbs[key] const diff = Math.abs(obs - p) expect( diff <= margin, `Act 1 H2H A ${key} branch converges to analytic prob ${p.toFixed(5)} within 99% CI (obs=${obs.toFixed(5)}, diff=${diff.toFixed(5)} <= margin=${margin.toFixed(5)})`, ) } // ----------------------------------------------------------------------------- // 9. Real Item Simulation (Inventory, Stacking, Stats) // ----------------------------------------------------------------------------- console.log('\n-- Real Item Simulation (Inventory & Stats) --') // Inventory placement and footprint with real items const bag = new Inventory(4, 3) expect(bag.totalCells === 12 && bag.usedCells === 0, 'A new 4x3 inventory is empty') expect(bag.canPlace(1, 3, 0, 0), 'A 1x3 tall weapon fits in empty inventory') expect(!bag.canPlace(1, 3, 0, 1), 'A 1x3 item overflowing grid vertically is refused') expect(bag.canPlace(4, 1, 0, 0), 'A 4x1 item spanning full width fits') expect(!bag.canPlace(5, 1, 0, 0), 'An item wider than the grid is refused') // Create real items without fake inline tables const toGameAffix = (m: MagicAffix): Affix => ({ id: m.name, name: m.name, kind: m.isPrefix ? 'prefix' : 'suffix', level: m.level, itemTypes: m.itypes, modifiers: m.mods.map(mod => ({ stat: mod.code, min: mod.min, max: mod.max, })), }) const gamePrefixes: Affix[] = prefixes.all.map(toGameAffix) const gameSuffixes: Affix[] = suffixes.all.map(toGameAffix) const realSwordBase = weapons.get('hax')! // Hand Axe const realCapBase = armor.get('cap')! // Cap const realPotionBase = misc.get('hp1')! // Minor Healing Potion const simRng = new Rng(777) const placedSword = bag.add(createItem(realSwordBase, gamePrefixes, gameSuffixes, simRng, { level: 20, prefixChance: 0, suffixChance: 0 })) expect(placedSword !== null, 'Real sword item can be added to inventory') expect(bag.usedCells === realSwordBase.invWidth * realSwordBase.invHeight, 'Adding sword occupies exact footprint') expect(!bag.canPlace(1, 1, 0, 0), 'Occupied grid cells refuse other items') const placedCap = bag.add(createItem(realCapBase, gamePrefixes, gameSuffixes, simRng, { level: 20, prefixChance: 0, suffixChance: 0 })) expect(placedCap !== null, 'Real cap item finds next free slot') expect(bag.contents.length === 2, 'Both items tracked in inventory contents') // Stacking overflow behaviour with real stackable item (Skeleton Key, maxStack=12) const realKeyBase = misc.get('key')! const keyBag = new Inventory(4, 2) const k1 = keyBag.add(createItem(realKeyBase, gamePrefixes, gameSuffixes, simRng, { level: 1, prefixChance: 0, suffixChance: 0, stack: 8 })) expect(k1 !== null, 'First key pile lands') const k2 = keyBag.add(createItem(realKeyBase, gamePrefixes, gameSuffixes, simRng, { level: 1, prefixChance: 0, suffixChance: 0, stack: 8 })) expect(k2 !== null, 'Second key pile lands') expect(keyBag.contents.length === 2, 'Key stack overflowing base maxStack opens second slot instead of discarding') const totalKeys = keyBag.contents.reduce((sum, entry) => sum + entry.item.stack, 0) expect(totalKeys === 16, `No key quantity lost across overflow (total: ${String(totalKeys)})`) // Removal clears exact footprint const removed = bag.remove(placedSword!) expect(removed && bag.usedCells === realCapBase.invWidth * realCapBase.invHeight, 'Removing sword frees its cells') expect(bag.canPlace(1, 3, 0, 0), 'Freed cells can be reused immediately') // Gold stacking const goldBag = new Inventory(2, 1) goldBag.add(goldItem(4000)) goldBag.add(goldItem(4000)) expect(goldBag.gold === 8000, `Gold piles accumulate properly in inventory (total: ${String(goldBag.gold)})`) // Derived stats from real armor const sheet = new Inventory(4, 4) sheet.add(createItem(realCapBase, gamePrefixes, gameSuffixes, simRng, { level: 1, prefixChance: 0, suffixChance: 0 })) const totalDefense = totalStat(sheet.contents, 'defense') expect(totalDefense >= realCapBase.defense, `Worn real cap contributes its defense (${String(totalDefense)}) to character sheet`) // ----------------------------------------------------------------------------- // Summary and Exit Code // ----------------------------------------------------------------------------- console.log(`\n========================================`) console.log(`checks ${String(checks)}`) console.log(`problems ${String(problems.length)}`) for (const problem of problems.slice(0, 12)) { console.log(` - ${problem}`) } console.log(problems.length === 0 ? 'RESULT ALL 1.13c REAL MPQ INVARIANTS HOLD' : 'RESULT FAILED') process.exit(problems.length === 0 ? 0 : 1)