diff --git a/src/game/auto-tc.ts b/src/game/auto-tc.ts new file mode 100644 index 0000000..175b2cf --- /dev/null +++ b/src/game/auto-tc.ts @@ -0,0 +1,342 @@ +/** + * Diablo II runtime auto-TreasureClass generation (`armo3..87`, `weap3..87`, `bow3..87`, `mele3..39`). + * + * Gold standard reference: + * - 1.13c MPQ ItemTypes.txt, Armor.txt, Weapons.txt, and D2Mods KB #368. + * + * In Diablo II 1.10+, item types with `TreasureClass === 1` in `ItemTypes.txt` define auto-TC seeds: + * - `bow` (Bow, rarity multiplier 3) + * - `weap` (Weapon, rarity multiplier 3) + * - `mele` (Melee Weapon, rarity multiplier 3) + * - `armo` (Any Armor, rarity multiplier 3) + * - `abow` (Amazon Bow, rarity multiplier 1) + * + * In 1.13c, `abow` is not referenced in TreasureClassEx rows. The referenced auto-TCs are: + * - `weap3, weap6, ... weap87` (29 tiers, step 3) + * - `armo3, armo6, ... armo87` (29 tiers, step 3) + * - `bow3, bow6, ... bow87` (29 tiers, step 3) + * - `mele3, mele6, ... mele39` (13 tiers, step 3) + * Total: 29 + 29 + 29 + 13 = exactly 100 virtual auto-TC nodes. + * + * Bucket placement rule: + * - For tier N, an item of matching type (using `isA` along Equiv chain from `item-types.ts`) belongs to bucket N if its `qlvl` (level in Armor/Weapons.txt) satisfies: + * `(N - 3) < qlvl <= N`. For tier 3, items have `1 <= qlvl <= 3`. + * - ONLY items with `rarity > 0` are placed into auto-TC buckets! + * - Item probability in the auto-TC bucket = `item.rarity`. + */ + +import { isA, type ItemTypeTable } from './item-types.ts' +import type { ArmorTable } from './armor.ts' +import type { WeaponTable } from './weapons.ts' +import { + isClassicValidItem, + type TreasureClassItem, + type TreasureClassNode, + type TreasureClassTable, +} from './treasure-class.ts' + +/** Supported auto-TC item type codes referenced in Diablo II 1.13c TreasureClassEx.txt. */ +export type AutoTcTypeCode = 'weap' | 'armo' | 'bow' | 'mele' + +/** Specification for an auto-TC series. */ +export interface AutoTcSeriesSpec { + /** Item type code seed. */ + readonly typeCode: AutoTcTypeCode + /** Minimum tier level (always 3). */ + readonly minTier: number + /** Maximum tier level (87 for weap/armo/bow, 39 for mele). */ + readonly maxTier: number + /** Step increment between tiers (always 3). */ + readonly step: number + /** Number of tiers in this series. */ + readonly count: number +} + +/** The 4 auto-TC series referenced in Diablo II 1.13c TreasureClassEx.txt. */ +export const AUTO_TC_SERIES: readonly AutoTcSeriesSpec[] = [ + { typeCode: 'weap', minTier: 3, maxTier: 87, step: 3, count: 29 }, + { typeCode: 'armo', minTier: 3, maxTier: 87, step: 3, count: 29 }, + { typeCode: 'bow', minTier: 3, maxTier: 87, step: 3, count: 29 }, + { typeCode: 'mele', minTier: 3, maxTier: 39, step: 3, count: 13 }, +] + +/** Total number of virtual auto-TreasureClass nodes in 1.13c (29 + 29 + 29 + 13 = 100). */ +export const AUTO_TC_TOTAL_NODES = 100 + +/** + * Precomputed set of all 100 canonical auto-TC node names. + */ +export const AUTO_TC_NAMES: ReadonlySet = new Set( + AUTO_TC_SERIES.flatMap(series => { + const names: string[] = [] + for (let tier = series.minTier; tier <= series.maxTier; tier += series.step) { + names.push(`${series.typeCode}${tier}`) + } + return names + }), +) + +/** + * Case-insensitive, whitespace-tolerant Map for auto-TC lookups. + */ +class CaseInsensitiveMap extends Map { + private readonly lowerMap = new Map() + + override set(key: string, value: V): this { + super.set(key, value) + this.lowerMap.set(key.trim().toLowerCase(), key) + return this + } + + override get(key: string): V | undefined { + const direct = super.get(key) + if (direct !== undefined) return direct + const orig = this.lowerMap.get(typeof key === 'string' ? key.trim().toLowerCase() : String(key)) + return orig !== undefined ? super.get(orig) : undefined + } + + override has(key: string): boolean { + return super.has(key) || this.lowerMap.has(typeof key === 'string' ? key.trim().toLowerCase() : String(key)) + } +} + +/** + * An item candidate entry inside an auto-TreasureClass node. + */ +export interface AutoTcEntry { + /** 3-4 character base item code (e.g. 'cap', 'hax'). */ + readonly itemCode: string + /** Probability weight in the auto-TC bucket, equal to item.rarity. */ + readonly prob: number +} + +/** + * A virtual auto-TreasureClass node. + */ +export interface AutoTcNode { + /** The auto-TC name (e.g. 'armo3', 'weap87'). */ + readonly name: string + /** Group index, always 0 for auto-TC nodes. */ + readonly group: 0 + /** Level / tier requirement (e.g. 3, 6, ..., 87). */ + readonly level: number + /** Number of item picks, always 1 for auto-TC nodes. */ + readonly picks: 1 + /** All eligible item entries in this tier bucket. */ + readonly entries: readonly AutoTcEntry[] + /** Sum of all entry probabilities (weights). */ + readonly totalProb: number +} + +/** + * Parses an auto-TC name into its type code and tier. + * Returns undefined if name is not one of the 100 valid auto-TC virtual node names. + * Case-insensitive and trimmed. + * + * @param name - Auto-TC candidate name (e.g. 'armo3', 'weap87', 'mele39'). + * @returns Parsed { typeCode, tier } or undefined. + */ +export function parseAutoTcName(name: string): { typeCode: string; tier: number } | undefined { + if (typeof name !== 'string') return undefined + const trimmed = name.trim().toLowerCase() + const match = trimmed.match(/^(weap|armo|bow|mele)(\d+)$/) + if (!match) return undefined + const typeCode = match[1]! + const tier = parseInt(match[2]!, 10) + const canonicalName = `${typeCode}${tier}` + if (!AUTO_TC_NAMES.has(canonicalName)) { + return undefined + } + return { typeCode, tier } +} + +/** + * Checks whether a given identifier is a valid auto-TreasureClass virtual node name + * among the 100 canonical auto-TCs in Diablo II 1.13c. + * + * @param name - Name to test. + */ +export function isAutoTcName(name: string): boolean { + return parseAutoTcName(name) !== undefined +} + +/** + * Generates all 100 virtual auto-TreasureClass nodes from ItemTypes, Armor, and Weapons tables. + * + * Rules: + * - 4 types: weap (3..87), armo (3..87), bow (3..87), mele (3..39) in steps of 3 = 100 nodes. + * - An item matches typeCode if `isA(item.type, typeCode, types)` or `(item.type2 && isA(item.type2, typeCode, types))`. + * - For tier N: items must have `(N - 3) < qlvl <= N` (for tier 3, `1 <= qlvl <= 3`). + * - Only items with `rarity > 0` are placed in the bucket. + * - Each item's probability = `item.rarity`. + * - Returned Map is keyed by auto-TC name and supports case-insensitive lookup. + * + * @param types - Parsed item types table (`ItemTypes.txt`). + * @param armor - Parsed armor table (`Armor.txt`). + * @param weapons - Parsed weapons table (`Weapons.txt`). + * @returns Map of all 100 auto-TC nodes keyed by name. + */ +export function generateAutoTreasureClasses( + types: ItemTypeTable, + armor: ArmorTable, + weapons: WeaponTable, +): Map { + const result = new CaseInsensitiveMap() + + // Combine items in canonical table order: armor first, then weapons + const allItems = [...armor.all, ...weapons.all] + + for (const series of AUTO_TC_SERIES) { + for (let tier = series.minTier; tier <= series.maxTier; tier += series.step) { + const name = `${series.typeCode}${tier}` + const entries: AutoTcEntry[] = [] + let totalProb = 0 + + for (const item of allItems) { + if (item.rarity <= 0) continue + const qlvl = item.level + if (qlvl <= tier - 3 || qlvl > tier) continue + + const matches = + isA(item.type, series.typeCode, types) || + (item.type2 ? isA(item.type2, series.typeCode, types) : false) + + if (matches) { + entries.push({ + itemCode: item.code, + prob: item.rarity, + }) + totalProb += item.rarity + } + } + + const node: AutoTcNode = { + name, + group: 0, + level: tier, + picks: 1, + entries, + totalProb, + } + + result.set(name, node) + } + } + + return result +} + +/** + * Converts an `AutoTcNode` into a full `TreasureClassNode` conforming to `TreasureClassEx.txt` structure. + * + * @param autoTc - The auto-TC virtual node to convert. + * @returns Fully constructed `TreasureClassNode`. + */ +export function autoTcToTreasureClassNode(autoTc: AutoTcNode): TreasureClassNode { + const items: TreasureClassItem[] = [] + let totalProbClassic = 0 + let totalProbExpansion = 0 + const cumulativeProbExpansion: number[] = [] + + for (const entry of autoTc.entries) { + const isClassic = isClassicValidItem(entry.itemCode) + if (isClassic) { + totalProbClassic += entry.prob + } + totalProbExpansion += entry.prob + cumulativeProbExpansion.push(totalProbExpansion) + + items.push({ + item: entry.itemCode, + prob: entry.prob, + kind: 'base-item', + isDangling: false, + isClassic, + }) + } + + return { + name: autoTc.name, + group: undefined, + level: autoTc.level, + picks: autoTc.picks, + unique: 0, + set: 0, + rare: 0, + magic: 0, + noDrop: 0, + items, + totalProbClassic, + totalProbExpansion, + cumulativeProbExpansion, + } +} + +/** + * Merges generated auto-TC virtual nodes into a `TreasureClassTable`, allowing seamless + * resolution of auto-TC references via `table.get(name)` and `table.byName.get(name)`. + * + * @param tcTable - Target `TreasureClassTable` to enrich. + * @param autoTcs - Map of generated `AutoTcNode`s. + * @returns The mutated and enriched `TreasureClassTable`. + */ +export function mergeAutoTreasureClasses( + tcTable: TreasureClassTable, + autoTcs: Map, +): TreasureClassTable { + for (const autoNode of autoTcs.values()) { + const tcNode = autoTcToTreasureClassNode(autoNode) + const existing = tcTable.byName.get(tcNode.name) + if (existing) { + const idx = tcTable.all.indexOf(existing) + if (idx !== -1) { + tcTable.all[idx] = tcNode + } else { + tcTable.all.push(tcNode) + } + } else { + tcTable.all.push(tcNode) + } + tcTable.byName.set(tcNode.name, tcNode) + } + return tcTable +} + +/** + * Queries a treasure class by name, checking regular TCs first and falling back + * to auto-TC virtual nodes (converted to `TreasureClassNode`). + * + * @param tcTable - Base `TreasureClassTable`. + * @param autoTcs - Map of generated `AutoTcNode`s. + * @param name - Treasure class name to query. + * @returns Found `TreasureClassNode` or undefined. + */ +export function queryTreasureClass( + tcTable: TreasureClassTable, + autoTcs: Map, + name: string, +): TreasureClassNode | undefined { + const direct = tcTable.get(name) + if (direct) return direct + + const autoNode = autoTcs.get(name) + if (autoNode) { + return autoTcToTreasureClassNode(autoNode) + } + return undefined +} + +/** + * Queries an auto-TC node directly from the auto-TC map. + * Case-insensitive and trimmed. + * + * @param autoTcs - Map of generated `AutoTcNode`s. + * @param name - Auto-TC name (e.g. 'armo3'). + * @returns Found `AutoTcNode` or undefined. + */ +export function queryAutoTreasureClass( + autoTcs: Map, + name: string, +): AutoTcNode | undefined { + return autoTcs.get(name) +} diff --git a/tests/auto-tc.test.ts b/tests/auto-tc.test.ts new file mode 100644 index 0000000..38fe909 --- /dev/null +++ b/tests/auto-tc.test.ts @@ -0,0 +1,737 @@ +import { describe, it, expect, beforeAll } from 'vitest' +import * as fs from 'fs' +import { MountedArchives } from '../src/mpq/mount.ts' +import { MpqArchive } from '../src/mpq/archive.ts' +import { fileSource } from '../src/mpq/file-source.ts' +import { loadItemTypes, type ItemTypeTable, type ItemTypeDefinition } from '../src/game/item-types.ts' +import { loadArmor, ArmorTable, type ArmorBase } from '../src/game/armor.ts' +import { loadWeapons, type WeaponTable, type WeaponBase } from '../src/game/weapons.ts' +import { + loadTreasureClasses, + type TreasureClassTable, +} from '../src/game/treasure-class.ts' +import { + AUTO_TC_NAMES, + AUTO_TC_SERIES, + AUTO_TC_TOTAL_NODES, + isAutoTcName, + parseAutoTcName, + generateAutoTreasureClasses, + autoTcToTreasureClassNode, + mergeAutoTreasureClasses, + queryTreasureClass, + queryAutoTreasureClass, + type AutoTcNode, +} from '../src/game/auto-tc.ts' + +const hasD2 = fs.existsSync('samples/d2/d2data.mpq') + +describe('Auto-TC Runtime Generation (Issue #100)', () => { + describe('Constants and Specifications', () => { + it('defines exactly 100 auto-TC nodes across 4 series', () => { + expect(AUTO_TC_TOTAL_NODES).toBe(100) + expect(AUTO_TC_NAMES.size).toBe(100) + + const weapSeries = AUTO_TC_SERIES.find(s => s.typeCode === 'weap')! + expect(weapSeries).toBeDefined() + expect(weapSeries.count).toBe(29) + expect(weapSeries.minTier).toBe(3) + expect(weapSeries.maxTier).toBe(87) + + const armoSeries = AUTO_TC_SERIES.find(s => s.typeCode === 'armo')! + expect(armoSeries).toBeDefined() + expect(armoSeries.count).toBe(29) + expect(armoSeries.minTier).toBe(3) + expect(armoSeries.maxTier).toBe(87) + + const bowSeries = AUTO_TC_SERIES.find(s => s.typeCode === 'bow')! + expect(bowSeries).toBeDefined() + expect(bowSeries.count).toBe(29) + expect(bowSeries.minTier).toBe(3) + expect(bowSeries.maxTier).toBe(87) + + const meleSeries = AUTO_TC_SERIES.find(s => s.typeCode === 'mele')! + expect(meleSeries).toBeDefined() + expect(meleSeries.count).toBe(13) + expect(meleSeries.minTier).toBe(3) + expect(meleSeries.maxTier).toBe(39) + + // Total count across all series: 29 + 29 + 29 + 13 = 100 + const totalCount = AUTO_TC_SERIES.reduce((sum, s) => sum + s.count, 0) + expect(totalCount).toBe(100) + }) + + it('contains all expected 100 canonical names', () => { + // Boundaries + expect(AUTO_TC_NAMES.has('weap3')).toBe(true) + expect(AUTO_TC_NAMES.has('weap87')).toBe(true) + expect(AUTO_TC_NAMES.has('armo3')).toBe(true) + expect(AUTO_TC_NAMES.has('armo87')).toBe(true) + expect(AUTO_TC_NAMES.has('bow3')).toBe(true) + expect(AUTO_TC_NAMES.has('bow87')).toBe(true) + expect(AUTO_TC_NAMES.has('mele3')).toBe(true) + expect(AUTO_TC_NAMES.has('mele39')).toBe(true) + + // Excluded / Out of bounds + expect(AUTO_TC_NAMES.has('mele42')).toBe(false) + expect(AUTO_TC_NAMES.has('armo90')).toBe(false) + expect(AUTO_TC_NAMES.has('weap90')).toBe(false) + expect(AUTO_TC_NAMES.has('abow3')).toBe(false) + expect(AUTO_TC_NAMES.has('weap0')).toBe(false) + }) + }) + + describe('isAutoTcName & parseAutoTcName APIs', () => { + it('correctly parses valid auto-TC names', () => { + expect(parseAutoTcName('weap3')).toEqual({ typeCode: 'weap', tier: 3 }) + expect(parseAutoTcName('weap87')).toEqual({ typeCode: 'weap', tier: 87 }) + expect(parseAutoTcName('armo3')).toEqual({ typeCode: 'armo', tier: 3 }) + expect(parseAutoTcName('armo87')).toEqual({ typeCode: 'armo', tier: 87 }) + expect(parseAutoTcName('bow15')).toEqual({ typeCode: 'bow', tier: 15 }) + expect(parseAutoTcName('bow87')).toEqual({ typeCode: 'bow', tier: 87 }) + expect(parseAutoTcName('mele3')).toEqual({ typeCode: 'mele', tier: 3 }) + expect(parseAutoTcName('mele39')).toEqual({ typeCode: 'mele', tier: 39 }) + }) + + it('supports case-insensitivity and whitespace padding', () => { + expect(parseAutoTcName('ARMO3')).toEqual({ typeCode: 'armo', tier: 3 }) + expect(parseAutoTcName(' weap6 ')).toEqual({ typeCode: 'weap', tier: 6 }) + expect(parseAutoTcName('MeLe39')).toEqual({ typeCode: 'mele', tier: 39 }) + expect(isAutoTcName(' ARMO87 ')).toBe(true) + expect(isAutoTcName('BoW3')).toBe(true) + }) + + it('rejects invalid or non-auto-TC names', () => { + // Non-existent prefixes or item codes + expect(parseAutoTcName('abow3')).toBeUndefined() + expect(parseAutoTcName('cap')).toBeUndefined() + expect(parseAutoTcName('hax')).toBeUndefined() + expect(parseAutoTcName('Gold')).toBeUndefined() + expect(parseAutoTcName('')).toBeUndefined() + + // Invalid steps (not multiple of 3) + expect(parseAutoTcName('armo4')).toBeUndefined() + expect(parseAutoTcName('weap1')).toBeUndefined() + expect(parseAutoTcName('bow2')).toBeUndefined() + + // Tiers out of 1.13c range + expect(parseAutoTcName('mele42')).toBeUndefined() + expect(parseAutoTcName('armo90')).toBeUndefined() + expect(parseAutoTcName('weap90')).toBeUndefined() + expect(parseAutoTcName('weap0')).toBeUndefined() + + expect(isAutoTcName('abow3')).toBe(false) + expect(isAutoTcName('mele42')).toBe(false) + expect(isAutoTcName('Gold')).toBe(false) + expect(isAutoTcName('armo4')).toBe(false) + }) + }) + + describe('Integration with 1.13c MPQ data (samples/d2)', () => { + let types: ItemTypeTable + let armor: ArmorTable + let weapons: WeaponTable + let autoTcs: Map + + beforeAll(async () => { + if (!hasD2) { + throw new Error('samples/d2 MPQ files required for Auto-TC test') + } + const 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}`))) + } + types = await loadItemTypes(archives) + armor = await loadArmor(archives) + weapons = await loadWeapons(archives) + autoTcs = generateAutoTreasureClasses(types, armor, weapons) + }) + + it('generates exactly 100 auto-TC nodes', () => { + expect(autoTcs.size).toBe(100) + + for (const name of AUTO_TC_NAMES) { + expect(autoTcs.has(name)).toBe(true) + const node = autoTcs.get(name) + expect(node).toBeDefined() + expect(node?.name).toBe(name) + } + }) + + it('verifies AutoTcNode structure invariants across all 100 nodes', () => { + for (const [name, node] of autoTcs) { + const parsed = parseAutoTcName(name)! + expect(parsed).toBeDefined() + + expect(node.name).toBe(name) + expect(node.group).toBe(0) + expect(node.level).toBe(parsed.tier) + expect(node.picks).toBe(1) + expect(Array.isArray(node.entries)).toBe(true) + + // totalProb must strictly equal sum of entry probabilities + const calculatedProb = node.entries.reduce((sum, e) => sum + e.prob, 0) + expect(node.totalProb).toBe(calculatedProb) + } + }) + + it('verifies all items in each bucket have rarity > 0', () => { + let totalItemEntries = 0 + for (const [, node] of autoTcs) { + for (const entry of node.entries) { + totalItemEntries++ + expect(entry.prob).toBeGreaterThan(0) + + const base = armor.get(entry.itemCode) ?? weapons.get(entry.itemCode) + expect(base, `Item ${entry.itemCode} should exist in armor or weapons`).toBeDefined() + expect(base!.rarity).toBeGreaterThan(0) + expect(entry.prob).toBe(base!.rarity) + } + } + expect(totalItemEntries).toBe(637) + }) + + it('verifies tier N items strictly satisfy (N - 3) < qlvl <= N', () => { + for (const [name, node] of autoTcs) { + const tier = node.level + for (const entry of node.entries) { + const base = armor.get(entry.itemCode) ?? weapons.get(entry.itemCode) + expect(base).toBeDefined() + const qlvl = base!.level + + expect( + qlvl, + `In node ${name}, item ${base!.code} with qlvl ${qlvl} must be > ${tier - 3}`, + ).toBeGreaterThan(tier - 3) + expect( + qlvl, + `In node ${name}, item ${base!.code} with qlvl ${qlvl} must be <= ${tier}`, + ).toBeLessThanOrEqual(tier) + + if (tier === 3) { + expect(qlvl).toBeGreaterThanOrEqual(1) + expect(qlvl).toBeLessThanOrEqual(3) + } + } + } + }) + + it('sample checks: armo3 contains cap (qlvl 1, rarity 1); weap3 contains hax (qlvl 3, rarity 3)', () => { + // 1. armo3 check + const armo3 = autoTcs.get('armo3')! + expect(armo3).toBeDefined() + expect(armo3.name).toBe('armo3') + expect(armo3.level).toBe(3) + expect(armo3.group).toBe(0) + expect(armo3.picks).toBe(1) + + const capEntry = armo3.entries.find(e => e.itemCode === 'cap') + expect(capEntry).toBeDefined() + expect(capEntry?.itemCode).toBe('cap') + expect(capEntry?.prob).toBe(1) + + const capBase = armor.get('cap')! + expect(capBase).toBeDefined() + expect(capBase.level).toBe(1) + expect(capBase.rarity).toBe(1) + + // Verify all items in armo3: cap, qui, lea, buc, lgl, lbt, lbl + const armo3Codes = armo3.entries.map(e => e.itemCode) + expect(armo3Codes).toEqual(['cap', 'qui', 'lea', 'buc', 'lgl', 'lbt', 'lbl']) + expect(armo3.totalProb).toBe(9) + + // 2. weap3 check + const weap3 = autoTcs.get('weap3')! + expect(weap3).toBeDefined() + expect(weap3.name).toBe('weap3') + expect(weap3.level).toBe(3) + + const haxEntry = weap3.entries.find(e => e.itemCode === 'hax') + expect(haxEntry).toBeDefined() + expect(haxEntry?.itemCode).toBe('hax') + + const haxBase = weapons.get('hax')! + expect(haxBase).toBeDefined() + expect(haxBase.level).toBe(3) + expect(haxBase.rarity).toBe(3) + expect(haxEntry?.prob).toBe(haxBase.rarity) + + // Also verify wand and club in weap3 have rarity 1 + const wndEntry = weap3.entries.find(e => e.itemCode === 'wnd')! + expect(wndEntry).toBeDefined() + expect(wndEntry.prob).toBe(1) + + const clbEntry = weap3.entries.find(e => e.itemCode === 'clb')! + expect(clbEntry).toBeDefined() + expect(clbEntry.prob).toBe(1) + + expect(weap3.entries.length).toBe(12) + expect(weap3.totalProb).toBe(26) + }) + + it('sample checks: mele3 and bow3 buckets', () => { + // mele3 contains melee weapons (excludes short bow sbw and eagle orb ob1) + const mele3 = autoTcs.get('mele3')! + expect(mele3).toBeDefined() + expect(mele3.level).toBe(3) + expect(mele3.entries.length).toBe(10) + expect(mele3.entries.some(e => e.itemCode === 'hax')).toBe(true) + expect(mele3.entries.some(e => e.itemCode === 'sbw')).toBe(false) + expect(mele3.entries.some(e => e.itemCode === 'ob1')).toBe(false) + expect(mele3.totalProb).toBe(22) + + // bow3 contains Short Bow + const bow3 = autoTcs.get('bow3')! + expect(bow3).toBeDefined() + expect(bow3.level).toBe(3) + expect(bow3.entries.length).toBe(1) + expect(bow3.entries[0]?.itemCode).toBe('sbw') + expect(bow3.entries[0]?.prob).toBe(2) + expect(bow3.totalProb).toBe(2) + }) + + it('correctly handles sparse/empty buckets (e.g. bow15)', () => { + const bow15 = autoTcs.get('bow15')! + expect(bow15).toBeDefined() + expect(bow15.name).toBe('bow15') + expect(bow15.level).toBe(15) + expect(bow15.entries.length).toBe(0) + expect(bow15.totalProb).toBe(0) + + // Converted to TreasureClassNode + const tcNode = autoTcToTreasureClassNode(bow15) + expect(tcNode.name).toBe('bow15') + expect(tcNode.items.length).toBe(0) + expect(tcNode.totalProbExpansion).toBe(0) + expect(tcNode.totalProbClassic).toBe(0) + expect(tcNode.cumulativeProbExpansion).toEqual([]) + }) + + it('verifies 1:1 match with TreasureClassEx.txt autoTCDanglingReferences', async () => { + const 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}`))) + } + const tcTable = await loadTreasureClasses(archives) + + // TreasureClassEx.txt references exactly 100 auto-TCs + expect(tcTable.autoTCDanglingReferences.size).toBe(100) + + // Every referenced auto-TC must exist in the generated map + for (const ref of tcTable.autoTCDanglingReferences) { + expect(autoTcs.has(ref), `Auto-TC ${ref} should be generated`).toBe(true) + } + + // Every generated auto-TC must be in tcTable.autoTCDanglingReferences + for (const key of autoTcs.keys()) { + expect(tcTable.autoTCDanglingReferences.has(key)).toBe(true) + } + }) + + it('supports autoTcToTreasureClassNode conversion', () => { + const armo3 = autoTcs.get('armo3')! + const tcNode = autoTcToTreasureClassNode(armo3) + + expect(tcNode.name).toBe('armo3') + expect(tcNode.level).toBe(3) + expect(tcNode.picks).toBe(1) + expect(tcNode.unique).toBe(0) + expect(tcNode.set).toBe(0) + expect(tcNode.rare).toBe(0) + expect(tcNode.magic).toBe(0) + expect(tcNode.noDrop).toBe(0) + expect(tcNode.items.length).toBe(armo3.entries.length) + expect(tcNode.totalProbExpansion).toBe(armo3.totalProb) + expect(tcNode.totalProbClassic).toBe(armo3.totalProb) // all armo3 items are classic + expect(tcNode.cumulativeProbExpansion.length).toBe(armo3.entries.length) + + const capItem = tcNode.items.find(it => it.item === 'cap') + expect(capItem).toBeDefined() + expect(capItem?.kind).toBe('base-item') + expect(capItem?.prob).toBe(1) + expect(capItem?.isDangling).toBe(false) + expect(capItem?.isClassic).toBe(true) + }) + + it('supports mergeAutoTreasureClasses into TreasureClassTable', async () => { + const 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}`))) + } + const tcTable = await loadTreasureClasses(archives) + + // Before merge: auto-TCs return undefined + expect(tcTable.get('armo3')).toBeUndefined() + expect(tcTable.get('weap87')).toBeUndefined() + + const initialTotal = tcTable.all.length + expect(initialTotal).toBe(852) + + // Merge + const merged = mergeAutoTreasureClasses(tcTable, autoTcs) + expect(merged).toBe(tcTable) + expect(tcTable.all.length).toBe(852 + 100) + + // After merge: auto-TCs are accessible via .get() and .byName.get() + const armo3 = tcTable.get('armo3') + expect(armo3).toBeDefined() + expect(armo3?.name).toBe('armo3') + expect(armo3?.level).toBe(3) + expect(armo3?.items.length).toBe(7) + + const armo3Upper = tcTable.get('ARMO3') + expect(armo3Upper).toBe(armo3) + + const weap87 = tcTable.get('weap87') + expect(weap87).toBeDefined() + expect(weap87?.level).toBe(87) + + const mele39 = tcTable.get('mele39') + expect(mele39).toBeDefined() + expect(mele39?.level).toBe(39) + + // Merging a second time is idempotent (doesn't duplicate nodes in all) + mergeAutoTreasureClasses(tcTable, autoTcs) + expect(tcTable.all.length).toBe(852 + 100) + }) + + it('supports queryTreasureClass and queryAutoTreasureClass helpers', async () => { + const 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}`))) + } + const tcTable = await loadTreasureClasses(archives) + + // Direct TC query + const gold = queryTreasureClass(tcTable, autoTcs, 'Gold') + expect(gold).toBeDefined() + expect(gold?.name).toBe('Gold') + + // Auto TC fallback query + const armo3 = queryTreasureClass(tcTable, autoTcs, 'armo3') + expect(armo3).toBeDefined() + expect(armo3?.name).toBe('armo3') + expect(armo3?.items.length).toBe(7) + + // Non-existent query + expect(queryTreasureClass(tcTable, autoTcs, 'NonExistent')).toBeUndefined() + + // queryAutoTreasureClass + expect(queryAutoTreasureClass(autoTcs, 'armo3')?.name).toBe('armo3') + expect(queryAutoTreasureClass(autoTcs, 'ARMO3')?.name).toBe('armo3') + expect(queryAutoTreasureClass(autoTcs, 'Gold')).toBeUndefined() + }) + }) + + describe('Synthetic Hermetic Unit Tests (no MPQs required)', () => { + function createMockTypes(): ItemTypeTable { + const defs: ItemTypeDefinition[] = [ + { + code: 'armo', + name: 'Any Armor', + equiv1: '', + equiv2: '', + treasureClass: 1, + rarity: 3, + maxSock1: 0, + maxSock25: 0, + maxSock40: 0, + magic: false, + rare: true, + normal: false, + charm: false, + gem: false, + staffMods: '', + body: true, + bodyLoc1: 'tors', + bodyLoc2: '', + beltable: false, + class: '', + }, + { + code: 'helm', + name: 'Helm', + equiv1: 'armo', + equiv2: '', + treasureClass: 0, + rarity: 3, + maxSock1: 2, + maxSock25: 3, + maxSock40: 3, + magic: false, + rare: true, + normal: false, + charm: false, + gem: false, + staffMods: '', + body: true, + bodyLoc1: 'head', + bodyLoc2: '', + beltable: false, + class: '', + }, + { + code: 'weap', + name: 'Weapon', + equiv1: '', + equiv2: '', + treasureClass: 1, + rarity: 3, + maxSock1: 0, + maxSock25: 0, + maxSock40: 0, + magic: false, + rare: true, + normal: false, + charm: false, + gem: false, + staffMods: '', + body: true, + bodyLoc1: 'rarm', + bodyLoc2: '', + beltable: false, + class: '', + }, + { + code: 'axe', + name: 'Axe', + equiv1: 'mele', + equiv2: '', + treasureClass: 0, + rarity: 3, + maxSock1: 2, + maxSock25: 4, + maxSock40: 6, + magic: false, + rare: true, + normal: false, + charm: false, + gem: false, + staffMods: '', + body: true, + bodyLoc1: 'rarm', + bodyLoc2: '', + beltable: false, + class: '', + }, + { + code: 'mele', + name: 'Melee Weapon', + equiv1: 'weap', + equiv2: '', + treasureClass: 1, + rarity: 3, + maxSock1: 0, + maxSock25: 0, + maxSock40: 0, + magic: false, + rare: true, + normal: false, + charm: false, + gem: false, + staffMods: '', + body: true, + bodyLoc1: 'rarm', + bodyLoc2: '', + beltable: false, + class: '', + }, + { + code: 'bow', + name: 'Bow', + equiv1: 'miss', + equiv2: '', + treasureClass: 1, + rarity: 3, + maxSock1: 3, + maxSock25: 4, + maxSock40: 6, + magic: false, + rare: true, + normal: false, + charm: false, + gem: false, + staffMods: '', + body: true, + bodyLoc1: 'rarm', + bodyLoc2: 'larm', + beltable: false, + class: '', + }, + { + code: 'miss', + name: 'Missile Weapon', + equiv1: 'weap', + equiv2: '', + treasureClass: 0, + rarity: 3, + maxSock1: 0, + maxSock25: 0, + maxSock40: 0, + magic: false, + rare: true, + normal: false, + charm: false, + gem: false, + staffMods: '', + body: true, + bodyLoc1: 'rarm', + bodyLoc2: '', + beltable: false, + class: '', + }, + ] + + const byCode = new Map() + for (const d of defs) { + byCode.set(d.code, d) + } + return { types: defs, byCode, _isACache: new Map() } + } + + function createMockArmor(): ArmorTable { + const items: ArmorBase[] = [ + { + id: 'cap', + code: 'cap', + name: 'Cap', + namestr: 'cap', + version: 0, + kind: 'armor', + spawnable: true, + rarity: 1, + level: 1, + levelreq: 0, + minac: 3, + maxac: 5, + reqstr: 0, + durability: 12, + cost: 12, + gambleCost: 100, + type: 'helm', + gemsockets: 2, + gemapplytype: 0, + normcode: 'cap', + ubercode: 'xap', + ultracode: 'uap', + invWidth: 1, + invHeight: 2, + tags: ['helm'], + defense: 5, + maxStack: 1, + value: 12, + damage: 0, + }, + // Excluded: rarity 0 + { + id: 'zer', + code: 'zer', + name: 'Zero Armor', + namestr: 'zer', + version: 0, + kind: 'armor', + spawnable: true, + rarity: 0, + level: 2, + minac: 0, + maxac: 0, + reqstr: 0, + durability: 0, + cost: 0, + gambleCost: 0, + type: 'helm', + gemsockets: 0, + gemapplytype: 0, + normcode: '', + ubercode: '', + ultracode: '', + invWidth: 1, + invHeight: 1, + tags: ['helm'], + defense: 0, + maxStack: 1, + value: 0, + damage: 0, + levelreq: 0, + }, + ] + const byCode = new Map() + for (const it of items) byCode.set(it.code, it) + return new ArmorTable(items, byCode) + } + + function createMockWeapons(): WeaponTable { + const items: WeaponBase[] = [ + { + id: 'hax', + code: 'hax', + name: 'Hand Axe', + namestr: 'hax', + version: 0, + kind: 'weapon', + spawnable: true, + rarity: 3, + level: 3, + levelreq: 0, + mindam: 3, + maxdam: 6, + twoHanded: false, + speed: 0, + reqstr: 0, + reqdex: 0, + durability: 28, + cost: 170, + gambleCost: 4510, + type: 'axe', + wclass: '1hs', + stackable: false, + rangeadder: 0, + normcode: 'hax', + ubercode: '9ha', + ultracode: '7ha', + gemsockets: 2, + invWidth: 1, + invHeight: 3, + tags: ['axe'], + damage: 6, + value: 170, + defense: 0, + maxStack: 1, + }, + ] + const byCode = new Map() + for (const it of items) byCode.set(it.code, it) + return { + all: items, + byCode, + get: (c: string) => byCode.get(c.trim().toLowerCase()), + length: items.length, + } as unknown as WeaponTable + } + + it('filters out rarity === 0 items and populates armo3 and weap3', () => { + const mockTypes = createMockTypes() + const mockArmor = createMockArmor() + const mockWeapons = createMockWeapons() + + const result = generateAutoTreasureClasses(mockTypes, mockArmor, mockWeapons) + expect(result.size).toBe(100) + + const armo3 = result.get('armo3')! + expect(armo3.entries).toEqual([{ itemCode: 'cap', prob: 1 }]) + expect(armo3.totalProb).toBe(1) + + const weap3 = result.get('weap3')! + expect(weap3.entries).toEqual([{ itemCode: 'hax', prob: 3 }]) + expect(weap3.totalProb).toBe(3) + + const mele3 = result.get('mele3')! + expect(mele3.entries).toEqual([{ itemCode: 'hax', prob: 3 }]) + expect(mele3.totalProb).toBe(3) + + const bow3 = result.get('bow3')! + expect(bow3.entries).toEqual([]) + expect(bow3.totalProb).toBe(0) + }) + }) +})