From 52c8161755576a7c1c9689f13ac8e2ebd965701f Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 18 Sep 2026 11:35:05 +0000 Subject: [PATCH] =?UTF-8?q?feat(items):=20=E5=8A=A0=E8=BD=BD=20TreasureCla?= =?UTF-8?q?ssEx.txt=20(Issue=20#99)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/game/treasure-class.ts | 375 +++++++++++++++++++++++++++++++++++ tests/treasure-class.test.ts | 313 +++++++++++++++++++++++++++++ 2 files changed, 688 insertions(+) create mode 100644 src/game/treasure-class.ts create mode 100644 tests/treasure-class.test.ts diff --git a/src/game/treasure-class.ts b/src/game/treasure-class.ts new file mode 100644 index 0000000..afb6c19 --- /dev/null +++ b/src/game/treasure-class.ts @@ -0,0 +1,375 @@ +/** + * Diablo II TreasureClassEx loader and indexer (`TreasureClassEx.txt`). + * + * `TreasureClassEx.txt` defines the loot drop hierarchy: + * - Table structure: 852 data rows across 33 columns. + * - Columns include TC name, group (1..18), level (0..96), picks (signed int), + * quality ratio modifiers (Unique, Set, Rare, Magic), NoDrop weight, + * and up to 10 item/probability pairs (Item1..10, Prob1..10). + * - Prefix sums: precomputes `totalProbClassic`, `totalProbExpansion`, + * and `cumulativeProbExpansion` for O(1) interval sampling during drops. + * - Item classification: classifies each item reference into sub-TCs (recursion), + * auto-TC virtual nodes, literal gold, gold multipliers, direct unique items, + * and base item codes. + * - Group index: maps groups 1..18 with TCs sorted by level ascending. + */ +import type { D2Table } from './acts.ts' +import { cell, parseTable } from './acts.ts' +import type { MountedArchives } from '../mpq/mount.ts' + +/** File path of `TreasureClassEx.txt` inside MPQ archives. */ +export const TREASURE_CLASS_EX_TABLE_PATH = 'data\\global\\excel\\TreasureClassEx.txt' + +/** Regex matching auto-TreasureClass virtual nodes (e.g. weap3..87, armo3..87, bow3..87, mele3..39). */ +export const AUTO_TC_REGEX = /^(?:weap|armo|bow|mele)\d+$/ + +/** + * Set of base items and TC names that only exist in Expansion (Lord of Destruction) + * and cannot drop in Classic Diablo II mode. + */ +export const EXPANSION_ONLY_ITEMS: ReadonlySet = new Set([ + // Jewels & Charms + 'jew', + 'cm1', + 'cm2', + 'cm3', + 'Annihilus', + // Essences & Keys (v1.11+ / 1.13c Pandemonium Event & Respec) + 'bet', + 'ceh', + 'dhn', + 'fed', + 'mbr', + 'pk1', + 'pk2', + 'pk3', + 'tes', + 'bey', + // Elite base weapon explicitly present in table + '7sm', + // Expansion-only quest/event TCs + 'Countess Rune', + 'Countess Rune (N)', + 'Countess Rune (H)', + 'Pandemonium Key A', + 'Pandemonium Key B', + 'Pandemonium Key C', + 'Uber Andariel', + 'Uber Duriel', + 'Uber Izual', + 'ROP (H)', +]) + +/** + * Tests whether an item or sub-TC is an auto-TreasureClass virtual node. + */ +export function isAutoTC(item: string): boolean { + return AUTO_TC_REGEX.test(item) +} + +/** + * Checks whether an item or sub-TC reference is valid in Classic Diablo II mode. + * + * Excludes: + * - Runes (`r01`..`r33`) and rune TCs (`Runes 1`..`Runes 17`, `Countess Rune*`) + * - Charms (`cm1`..`cm3`) and jewels (`jew`) + * - Unique charm `Annihilus` + * - Pandemonium event keys and essences + * - Elite item `7sm` + */ +export function isClassicValidItem(item: string): boolean { + if (EXPANSION_ONLY_ITEMS.has(item)) return false + // Runes r01..r33 + if (/^r(?:0[1-9]|[12]\d|3[0-3])$/.test(item)) return false + // Rune treasure classes Runes 1..17 + if (/^Runes (?:1[0-7]|[1-9])$/.test(item)) return false + return true +} + +/** + * Semantic classification of an item entry in a TreasureClass node. + */ +export type TreasureClassItemKind = + | 'tc' + | 'gold' + | 'gold-multiplier' + | 'auto-tc' + | 'base-item' + | 'unique-item' + +/** + * An individual item or sub-TC candidate within a TreasureClass row. + */ +export interface TreasureClassItem { + /** The item code or sub-TC name (surrounding quotes stripped). */ + readonly item: string + /** Probability weight assigned to this item (Prob1..10 column). */ + readonly prob: number + /** Semantic classification of this item. */ + readonly kind: TreasureClassItemKind + /** Whether this reference is dangling (does not exist as a row in TreasureClassEx.txt). */ + readonly isDangling: boolean + /** Whether this item can drop in Classic Diablo II. */ + readonly isClassic: boolean + /** Multiplier for gold drops if kind is 'gold-multiplier' (e.g. 1280, 1536, 2048). */ + readonly multiplier?: number | undefined +} + +/** + * Definition of a TreasureClass row from `TreasureClassEx.txt`. + */ +export interface TreasureClassNode { + /** Identifier name of the treasure class. */ + readonly name: string + /** Group index (1..18), or undefined if this TC is ungrouped. */ + readonly group?: number | undefined + /** Level requirement (0..96). */ + readonly level: number + /** Number of picks (signed integer: -4, -3, -2, 1, 2, 3, 4, 5, 7). */ + readonly picks: number + /** Unique item ratio modifier (fractions of 1024). */ + readonly unique: number + /** Set item ratio modifier (fractions of 1024). */ + readonly set: number + /** Rare item ratio modifier (fractions of 1024). */ + readonly rare: number + /** Magic item ratio modifier (fractions of 1024). */ + readonly magic: number + /** Probability weight for dropping nothing. */ + readonly noDrop: number + /** All non-empty item candidates defined for this TC. */ + readonly items: readonly TreasureClassItem[] + /** Sum of probability weights for items valid in Classic Diablo II. */ + readonly totalProbClassic: number + /** Sum of probability weights for items in Expansion (LoD). */ + readonly totalProbExpansion: number + /** Prefix sums of item probabilities: [Prob1, Prob1+Prob2, ...]. */ + readonly cumulativeProbExpansion: readonly number[] +} + +/** + * Case-insensitive, whitespace-tolerant Map for TreasureClass 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)) + } +} + +/** + * Parsed and indexed collection of all TreasureClasses. + */ +export interface TreasureClassTable { + /** All parsed treasure classes in file order (852 valid rows). */ + readonly all: TreasureClassNode[] + /** Map of treasure classes keyed by name (supports case-insensitive lookup). */ + readonly byName: Map + /** Map of treasure classes by group (1..18), sorted by level ascending. */ + readonly byGroup: Map + /** Set of all 218 unique dangling references (100 auto-TC + 118 base item codes). */ + readonly danglingReferences: ReadonlySet + /** Set of all 100 unique auto-TC virtual node references. */ + readonly autoTCDanglingReferences: ReadonlySet + /** Set of all 118 unique base item and special references. */ + readonly baseItemDanglingReferences: ReadonlySet + /** Lookup a treasure class by name. */ + get(name: string): TreasureClassNode | undefined +} + +/** + * Concrete implementation of `TreasureClassTable`. + */ +class TreasureClassTableImpl implements TreasureClassTable { + constructor( + readonly all: TreasureClassNode[], + readonly byName: Map, + readonly byGroup: Map, + readonly danglingReferences: ReadonlySet, + readonly autoTCDanglingReferences: ReadonlySet, + readonly baseItemDanglingReferences: ReadonlySet, + ) {} + + get(name: string): TreasureClassNode | undefined { + return this.byName.get(name) + } +} + +/** + * Helper to extract an integer cell with a default fallback. + */ +function intCell(table: D2Table, row: readonly string[], column: string, fallback = 0): number { + const value = cell(table, row, column).trim() + if (value === '') return fallback + const parsed = parseInt(value, 10) + return isNaN(parsed) ? fallback : parsed +} + +/** + * Parses a `D2Table` representing `TreasureClassEx.txt` into an indexed `TreasureClassTable`. + * + * @param table - The parsed tab-separated table. + * @returns The structured `TreasureClassTable`. + */ +export function parseTreasureClassTable(table: D2Table): TreasureClassTable { + // Filter out blank rows (the table has 853 raw lines - 1 blank row = 852 real data rows) + const validRows = table.rows.filter(row => cell(table, row, 'Treasure Class').trim() !== '') + + // First pass: collect all defined TC names for recursion resolution + const tcNames = new Set() + for (const row of validRows) { + tcNames.add(cell(table, row, 'Treasure Class').trim()) + } + + const all: TreasureClassNode[] = [] + const byName = new CaseInsensitiveMap() + const byGroup = new Map() + + const danglingReferences = new Set() + const autoTCDanglingReferences = new Set() + const baseItemDanglingReferences = new Set() + + for (const row of validRows) { + const name = cell(table, row, 'Treasure Class').trim() + const groupRaw = cell(table, row, 'group').trim() + const group = groupRaw !== '' ? parseInt(groupRaw, 10) || undefined : undefined + const level = intCell(table, row, 'level', 0) + const picks = intCell(table, row, 'Picks', 0) + const unique = intCell(table, row, 'Unique', 0) + const set = intCell(table, row, 'Set', 0) + const rare = intCell(table, row, 'Rare', 0) + const magic = intCell(table, row, 'Magic', 0) + const noDrop = intCell(table, row, 'NoDrop', 0) + + const items: TreasureClassItem[] = [] + let totalProbClassic = 0 + let totalProbExpansion = 0 + const cumulativeProbExpansion: number[] = [] + + for (let i = 1; i <= 10; i++) { + let rawItem = cell(table, row, `Item${i}`).trim() + if (rawItem === '') continue + + // Strip surrounding double quotes if present (e.g. "gld,mul=1280") + if (rawItem.startsWith('"') && rawItem.endsWith('"')) { + rawItem = rawItem.slice(1, -1).trim() + } + + const prob = intCell(table, row, `Prob${i}`, 0) + + let kind: TreasureClassItemKind + let isDangling = false + let multiplier: number | undefined + + if (tcNames.has(rawItem)) { + kind = 'tc' + isDangling = false + } else { + isDangling = true + danglingReferences.add(rawItem) + + if (AUTO_TC_REGEX.test(rawItem)) { + kind = 'auto-tc' + autoTCDanglingReferences.add(rawItem) + } else { + baseItemDanglingReferences.add(rawItem) + if (rawItem === 'gld') { + kind = 'gold' + } else if (rawItem.startsWith('gld,mul=')) { + kind = 'gold-multiplier' + const parsedMul = parseInt(rawItem.slice('gld,mul='.length), 10) + multiplier = isNaN(parsedMul) ? undefined : parsedMul + } else if (rawItem === 'Annihilus') { + kind = 'unique-item' + } else { + kind = 'base-item' + } + } + } + + const isClassic = isClassicValidItem(rawItem) + if (isClassic) { + totalProbClassic += prob + } + totalProbExpansion += prob + cumulativeProbExpansion.push(totalProbExpansion) + + items.push({ + item: rawItem, + prob, + kind, + isDangling, + isClassic, + multiplier, + }) + } + + const node: TreasureClassNode = { + name, + group, + level, + picks, + unique, + set, + rare, + magic, + noDrop, + items, + totalProbClassic, + totalProbExpansion, + cumulativeProbExpansion, + } + + all.push(node) + byName.set(name, node) + + if (group !== undefined) { + let groupList = byGroup.get(group) + if (!groupList) { + groupList = [] + byGroup.set(group, groupList) + } + groupList.push(node) + } + } + + // Sort each group ascending by level (Group index requirement) + for (const groupList of byGroup.values()) { + groupList.sort((a, b) => a.level - b.level) + } + + return new TreasureClassTableImpl( + all, + byName, + byGroup, + danglingReferences, + autoTCDanglingReferences, + baseItemDanglingReferences, + ) +} + +/** + * Loads and parses `TreasureClassEx.txt` from mounted MPQ archives. + * + * @param archives - Mounted MPQ archives. + * @returns Promise resolving to `TreasureClassTable`. + */ +export async function loadTreasureClasses(archives: MountedArchives): Promise { + const bytes = await archives.read(TREASURE_CLASS_EX_TABLE_PATH) + const d2Table = parseTable(bytes) + return parseTreasureClassTable(d2Table) +} diff --git a/tests/treasure-class.test.ts b/tests/treasure-class.test.ts new file mode 100644 index 0000000..6ab556b --- /dev/null +++ b/tests/treasure-class.test.ts @@ -0,0 +1,313 @@ +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 { + loadTreasureClasses, + parseTreasureClassTable, + TREASURE_CLASS_EX_TABLE_PATH, + AUTO_TC_REGEX, + isAutoTC, + isClassicValidItem, + type TreasureClassTable, + type TreasureClassNode, +} from '../src/game/treasure-class.ts' + +const hasD2 = fs.existsSync('samples/d2/d2data.mpq') + +describe('TreasureClassEx Loader & Indexer (Issue #99)', () => { + let archives: MountedArchives + let table: TreasureClassTable + + beforeAll(async () => { + if (!hasD2) { + throw new Error('samples/d2 MPQ files required for TreasureClassEx test') + } + 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 bytes = await archives.read(TREASURE_CLASS_EX_TABLE_PATH) + const d2Table = parseTable(bytes) + table = parseTreasureClassTable(d2Table) + }) + + it('correctly uses the canonical MPQ table path', () => { + expect(TREASURE_CLASS_EX_TABLE_PATH).toBe('data\\global\\excel\\TreasureClassEx.txt') + }) + + it('loads treasure classes via loadTreasureClasses async helper', async () => { + const loaded = await loadTreasureClasses(archives) + expect(loaded.all.length).toBe(852) + expect(loaded.byName.size).toBe(852) + expect(loaded.get('Gold')?.name).toBe('Gold') + }) + + it('parses exactly 852 valid rows (853 raw data lines minus 1 blank line)', () => { + expect(table.all.length).toBe(852) + expect(table.byName.size).toBe(852) + + // First row is 'Gold' + expect(table.all[0]?.name).toBe('Gold') + + // No blank row retained + for (const node of table.all) { + expect(node.name.trim()).not.toBe('') + } + }) + + it('verifies group 1..18 sizes match the 1.13c gold standard', () => { + const expectedGroupSizes: Record = { + 1: 44, + 2: 36, + 3: 36, + 4: 36, + 5: 15, + 6: 45, + 7: 42, + 8: 42, + 9: 42, + 10: 42, + 11: 42, + 12: 42, + 13: 43, + 14: 42, + 15: 42, + 16: 15, + 17: 6, + 18: 90, + } + + expect(table.byGroup.size).toBe(18) + + let totalGrouped = 0 + for (let g = 1; g <= 18; g++) { + const groupList = table.byGroup.get(g) + expect(groupList, `Group ${g} should exist`).toBeDefined() + expect(groupList?.length, `Group ${g} size`).toBe(expectedGroupSizes[g]) + totalGrouped += groupList?.length ?? 0 + } + + expect(totalGrouped).toBe(702) + + // Ungrouped count: 852 - 702 = 150 + const ungrouped = table.all.filter(node => node.group === undefined) + expect(ungrouped.length).toBe(150) + }) + + it('verifies each group is indexed with TCs sorted by level ascending', () => { + for (let g = 1; g <= 18; g++) { + const groupList = table.byGroup.get(g)! + expect(groupList.length).toBeGreaterThan(0) + for (let i = 1; i < groupList.length; i++) { + const prev = groupList[i - 1]! + const curr = groupList[i]! + expect( + curr.level, + `Group ${g} index ${i} (${curr.name}, lvl ${curr.level}) should be >= index ${i - 1} (${prev.name}, lvl ${prev.level})`, + ).toBeGreaterThanOrEqual(prev.level) + } + } + + // Special check for group 18 (which was unsorted in raw file: lvl 0 Ax..Cx after lvl 96) + const g18 = table.byGroup.get(18)! + expect(g18[0]?.level).toBe(0) + expect(g18[g18.length - 1]?.level).toBe(96) + }) + + it('verifies item classification and exactly 218 dangling references (100 auto-TC + 118 base item codes)', () => { + expect(table.danglingReferences.size).toBe(218) + expect(table.autoTCDanglingReferences.size).toBe(100) + expect(table.baseItemDanglingReferences.size).toBe(118) + + const allRecursiveTCs = new Set() + const allAutoTCs = new Set() + const allGolds = new Set() + const allGoldMuls = new Set() + const allUniqueItems = new Set() + const allBaseItems = new Set() + + for (const node of table.all) { + for (const it of node.items) { + switch (it.kind) { + case 'tc': + allRecursiveTCs.add(it.item) + expect(it.isDangling).toBe(false) + expect(table.byName.has(it.item)).toBe(true) + break + case 'auto-tc': + allAutoTCs.add(it.item) + expect(it.isDangling).toBe(true) + expect(isAutoTC(it.item)).toBe(true) + break + case 'gold': + allGolds.add(it.item) + expect(it.isDangling).toBe(true) + expect(it.item).toBe('gld') + break + case 'gold-multiplier': + allGoldMuls.add(it.item) + expect(it.isDangling).toBe(true) + expect(it.item.startsWith('gld,mul=')).toBe(true) + break + case 'unique-item': + allUniqueItems.add(it.item) + expect(it.isDangling).toBe(true) + expect(it.item).toBe('Annihilus') + break + case 'base-item': + allBaseItems.add(it.item) + expect(it.isDangling).toBe(true) + break + } + } + } + + expect(allRecursiveTCs.size).toBe(357) + expect(allAutoTCs.size).toBe(100) + expect(allGolds.size).toBe(1) + expect(allGoldMuls.size).toBe(3) + expect(allUniqueItems.size).toBe(1) + expect(allBaseItems.size).toBe(113) + + // 100 auto-TC + 118 other (1 gold + 3 gold muls + 1 Annihilus + 113 base items) = 218 dangling + expect(allGolds.size + allGoldMuls.size + allUniqueItems.size + allBaseItems.size).toBe(118) + expect(allAutoTCs.size + 118).toBe(218) + + // Total unique referenced item names: 357 TC + 218 dangling = 575 + const totalReferenced = new Set([...allRecursiveTCs, ...table.danglingReferences]) + expect(totalReferenced.size).toBe(575) + }) + + it('verifies gold multipliers are parsed correctly (gld,mul=...)', () => { + const goldMulEntries = table.all + .flatMap(node => node.items) + .filter(it => it.kind === 'gold-multiplier') + + expect(goldMulEntries.length).toBeGreaterThan(0) + + for (const entry of goldMulEntries) { + expect(entry.multiplier).toBeDefined() + expect([1280, 1536, 2048]).toContain(entry.multiplier) + expect(entry.item).toBe(`gld,mul=${entry.multiplier}`) + } + + // Specific known boss drops + const andarielNorm = table.get('Andariel')! + expect(andarielNorm.items[0]?.item).toBe('gld,mul=1280') + expect(andarielNorm.items[0]?.multiplier).toBe(1280) + + const andarielNightmare = table.get('Andariel (N)')! + expect(andarielNightmare.items[0]?.item).toBe('gld,mul=1536') + expect(andarielNightmare.items[0]?.multiplier).toBe(1536) + + const andarielHell = table.get('Andariel (H)')! + expect(andarielHell.items[0]?.item).toBe('gld,mul=2048') + expect(andarielHell.items[0]?.multiplier).toBe(2048) + + const durielHell = table.get('Duriel (H) - Base')! + expect(durielHell.items[0]?.item).toBe('gld,mul=2048') + expect(durielHell.items[0]?.multiplier).toBe(2048) + }) + + it('verifies Magic column is 1024 on all 231 non-empty rows', () => { + const magicNodes = table.all.filter(node => node.magic > 0) + expect(magicNodes.length).toBe(231) + + for (const node of magicNodes) { + expect(node.magic).toBe(1024) + } + + const nonMagicNodes = table.all.filter(node => node.magic === 0) + expect(nonMagicNodes.length).toBe(852 - 231) + }) + + it('verifies prefix sums and classic vs expansion total probabilities', () => { + // Jewelry A: rin:8, amu:4, jew:2, cm3:2, cm2:2, cm1:2 + const jewelryA = table.get('Jewelry A')! + expect(jewelryA).toBeDefined() + expect(jewelryA.items.length).toBe(6) + expect(jewelryA.totalProbExpansion).toBe(20) + expect(jewelryA.totalProbClassic).toBe(12) + expect(jewelryA.cumulativeProbExpansion).toEqual([8, 12, 14, 16, 18, 20]) + + // Act 2 Good: Jewelry B:20, Chipped Gem:12, Flawed Gem:4, Runes 1:3, Runes 2:3 + const act2Good = table.get('Act 2 Good')! + expect(act2Good).toBeDefined() + expect(act2Good.totalProbExpansion).toBe(42) + expect(act2Good.totalProbClassic).toBe(36) // 42 - 3 (Runes 1) - 3 (Runes 2) + expect(act2Good.cumulativeProbExpansion).toEqual([20, 32, 36, 39, 42]) + + // Flying Scimitar (H): Act 2 (H) Good:3, scm:7, 9sm:18, 7sm:32 + const flyingScimitarH = table.get('Flying Scimitar (H)')! + expect(flyingScimitarH).toBeDefined() + expect(flyingScimitarH.totalProbExpansion).toBe(60) + expect(flyingScimitarH.totalProbClassic).toBe(28) // 60 - 32 (7sm elite) + + // Check invariants across all 852 nodes + for (const node of table.all) { + expect(node.cumulativeProbExpansion.length).toBe(node.items.length) + if (node.items.length > 0) { + expect(node.cumulativeProbExpansion[node.cumulativeProbExpansion.length - 1]).toBe( + node.totalProbExpansion, + ) + } + expect(node.totalProbClassic).toBeLessThanOrEqual(node.totalProbExpansion) + } + }) + + it('verifies picks values match the 9 distinct signed values in 1.13c', () => { + const distinctPicks = new Set(table.all.map(node => node.picks)) + expect(Array.from(distinctPicks).sort((a, b) => a - b)).toEqual([ + -4, -3, -2, 1, 2, 3, 4, 5, 7, + ]) + + // Countess has negative picks: -2 + expect(table.get('Countess')?.picks).toBe(-2) + // Mephisto has positive picks: 7 + expect(table.get('Mephisto')?.picks).toBe(7) + }) + + it('supports case-insensitive lookup via .get() and .byName.get()', () => { + expect(table.get('Gold')).toBeDefined() + expect(table.get('gold')).toBeDefined() + expect(table.get(' GOLD ')).toBeDefined() + expect(table.byName.get('gold')).toBeDefined() + expect(table.byName.get('Act 1 Equip A')).toBeDefined() + expect(table.byName.get('act 1 equip a')).toBeDefined() + expect(table.get('NonExistentTC')).toBeUndefined() + }) + + it('verifies isClassicValidItem and isAutoTC helpers', () => { + expect(isAutoTC('weap3')).toBe(true) + expect(isAutoTC('armo87')).toBe(true) + expect(isAutoTC('bow15')).toBe(true) + expect(isAutoTC('mele39')).toBe(true) + expect(isAutoTC('rin')).toBe(false) + expect(isAutoTC('Gold')).toBe(false) + + // Classic valid items + expect(isClassicValidItem('rin')).toBe(true) + expect(isClassicValidItem('amu')).toBe(true) + expect(isClassicValidItem('hp1')).toBe(true) + expect(isClassicValidItem('gld')).toBe(true) + expect(isClassicValidItem('scm')).toBe(true) + expect(isClassicValidItem('9sm')).toBe(true) + + // Expansion-only items + expect(isClassicValidItem('r01')).toBe(false) + expect(isClassicValidItem('r33')).toBe(false) + expect(isClassicValidItem('Runes 1')).toBe(false) + expect(isClassicValidItem('jew')).toBe(false) + expect(isClassicValidItem('cm1')).toBe(false) + expect(isClassicValidItem('cm2')).toBe(false) + expect(isClassicValidItem('cm3')).toBe(false) + expect(isClassicValidItem('Annihilus')).toBe(false) + expect(isClassicValidItem('7sm')).toBe(false) + expect(isClassicValidItem('Countess Rune')).toBe(false) + expect(isClassicValidItem('pk1')).toBe(false) + expect(isClassicValidItem('tes')).toBe(false) + }) +})