Compare commits
No commits in common. "e3ab760ce1bd2ca18d6284db64439441b5d4f988" and "9b980b8218980946c38aa4ff6864eee11316fa0e" have entirely different histories.
e3ab760ce1
...
9b980b8218
|
|
@ -1,375 +0,0 @@
|
||||||
/**
|
|
||||||
* 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<string> = 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<V> extends Map<string, V> {
|
|
||||||
private readonly lowerMap = new Map<string, string>()
|
|
||||||
|
|
||||||
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<string, TreasureClassNode>
|
|
||||||
/** Map of treasure classes by group (1..18), sorted by level ascending. */
|
|
||||||
readonly byGroup: Map<number, TreasureClassNode[]>
|
|
||||||
/** Set of all 218 unique dangling references (100 auto-TC + 118 base item codes). */
|
|
||||||
readonly danglingReferences: ReadonlySet<string>
|
|
||||||
/** Set of all 100 unique auto-TC virtual node references. */
|
|
||||||
readonly autoTCDanglingReferences: ReadonlySet<string>
|
|
||||||
/** Set of all 118 unique base item and special references. */
|
|
||||||
readonly baseItemDanglingReferences: ReadonlySet<string>
|
|
||||||
/** 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<string, TreasureClassNode>,
|
|
||||||
readonly byGroup: Map<number, TreasureClassNode[]>,
|
|
||||||
readonly danglingReferences: ReadonlySet<string>,
|
|
||||||
readonly autoTCDanglingReferences: ReadonlySet<string>,
|
|
||||||
readonly baseItemDanglingReferences: ReadonlySet<string>,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
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<string>()
|
|
||||||
for (const row of validRows) {
|
|
||||||
tcNames.add(cell(table, row, 'Treasure Class').trim())
|
|
||||||
}
|
|
||||||
|
|
||||||
const all: TreasureClassNode[] = []
|
|
||||||
const byName = new CaseInsensitiveMap<TreasureClassNode>()
|
|
||||||
const byGroup = new Map<number, TreasureClassNode[]>()
|
|
||||||
|
|
||||||
const danglingReferences = new Set<string>()
|
|
||||||
const autoTCDanglingReferences = new Set<string>()
|
|
||||||
const baseItemDanglingReferences = new Set<string>()
|
|
||||||
|
|
||||||
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<TreasureClassTable> {
|
|
||||||
const bytes = await archives.read(TREASURE_CLASS_EX_TABLE_PATH)
|
|
||||||
const d2Table = parseTable(bytes)
|
|
||||||
return parseTreasureClassTable(d2Table)
|
|
||||||
}
|
|
||||||
|
|
@ -184,10 +184,10 @@ function stampBreachedBarricade(
|
||||||
})
|
})
|
||||||
breachFloorCount += 1
|
breachFloorCount += 1
|
||||||
} else if (y === breachY - 1 || y === breachY + breachHeight) {
|
} else if (y === breachY - 1 || y === breachY + breachHeight) {
|
||||||
// Splintered end-cap post at the breach edge (fence.dt1 type 7 pillar, seq 2)
|
// Splintered end-cap post at the breach edge (fence.dt1 type 7 pillar)
|
||||||
setWall(canvas, x, y, {
|
setWall(canvas, x, y, {
|
||||||
style: 2,
|
style: 2,
|
||||||
sequence: 2,
|
sequence: 10,
|
||||||
prop1: 129,
|
prop1: 129,
|
||||||
type: 7,
|
type: 7,
|
||||||
unknown1: 0,
|
unknown1: 0,
|
||||||
|
|
@ -196,7 +196,7 @@ function stampBreachedBarricade(
|
||||||
})
|
})
|
||||||
wallCount += 1
|
wallCount += 1
|
||||||
} else {
|
} else {
|
||||||
// Intact spiked wooden palisade (fence.dt1 type 1 left wall, seq 10)
|
// Intact spiked wooden palisade (fence.dt1 type 1 left wall)
|
||||||
setWall(canvas, x, y, {
|
setWall(canvas, x, y, {
|
||||||
style: 2,
|
style: 2,
|
||||||
sequence: 10,
|
sequence: 10,
|
||||||
|
|
@ -249,7 +249,7 @@ function stampCatapultPlatform(
|
||||||
setWall(canvas, x, y, {
|
setWall(canvas, x, y, {
|
||||||
style: 2,
|
style: 2,
|
||||||
sequence: 10,
|
sequence: 10,
|
||||||
prop1: 2,
|
prop1: 129,
|
||||||
type: 1,
|
type: 1,
|
||||||
unknown1: 0,
|
unknown1: 0,
|
||||||
unknown2: 0,
|
unknown2: 0,
|
||||||
|
|
|
||||||
|
|
@ -1,313 +0,0 @@
|
||||||
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<number, number> = {
|
|
||||||
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<string>()
|
|
||||||
const allAutoTCs = new Set<string>()
|
|
||||||
const allGolds = new Set<string>()
|
|
||||||
const allGoldMuls = new Set<string>()
|
|
||||||
const allUniqueItems = new Set<string>()
|
|
||||||
const allBaseItems = new Set<string>()
|
|
||||||
|
|
||||||
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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -267,8 +267,8 @@ describe('Wilderness Outdoor Initializers for Acts 2-5 (Issue #51)', () => {
|
||||||
for (let y = 0; y < result.level.height; y += 1) {
|
for (let y = 0; y < result.level.height; y += 1) {
|
||||||
for (let x = 0; x < result.level.width; x += 1) {
|
for (let x = 0; x < result.level.width; x += 1) {
|
||||||
const cell = result.level.cells[y]![x]!
|
const cell = result.level.cells[y]![x]!
|
||||||
if (cell.floors.some(f => (f.style === 5 || f.style === 20) && f.prop1 === 194)) foundWalkway = true
|
if (cell.floors.some(f => f.style === 5 && f.prop1 === 194)) foundWalkway = true
|
||||||
if (cell.walls.some(w => (w.style === 1 || w.style === 20) && w.prop1 === 129)) foundBalustrade = true
|
if (cell.walls.some(w => w.style === 1 && w.prop1 === 129)) foundBalustrade = true
|
||||||
if (cell.floors.some(f => f.style === 2 && f.prop1 === 2)) foundCanalWater = true
|
if (cell.floors.some(f => f.style === 2 && f.prop1 === 2)) foundCanalWater = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -359,7 +359,7 @@ describe('Wilderness Outdoor Initializers for Acts 2-5 (Issue #51)', () => {
|
||||||
for (let y = 0; y < result.level.height; y += 1) {
|
for (let y = 0; y < result.level.height; y += 1) {
|
||||||
for (let x = 0; x < result.level.width; x += 1) {
|
for (let x = 0; x < result.level.width; x += 1) {
|
||||||
const cell = result.level.cells[y]![x]!
|
const cell = result.level.cells[y]![x]!
|
||||||
if (cell.floors.some(f => (f.style === 20 || f.style === 10) && f.prop1 === 2)) foundLava = true
|
if (cell.floors.some(f => f.style === 20 && f.sequence === 3 && f.prop1 === 2)) foundLava = true
|
||||||
if (cell.floors.some(f => f.style === 10 && f.sequence === 0 && f.prop1 === 2)) foundRockyBridge = true
|
if (cell.floors.some(f => f.style === 10 && f.sequence === 0 && f.prop1 === 2)) foundRockyBridge = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -331,8 +331,8 @@ describe('Multi-Act Outdoor Roads & Boundary Divider Networks', () => {
|
||||||
for (let x = 8; x < 72; x += 1) {
|
for (let x = 8; x < 72; x += 1) {
|
||||||
const cell = result.level.cells[y]?.[x]
|
const cell = result.level.cells[y]?.[x]
|
||||||
if (!cell) continue
|
if (!cell) continue
|
||||||
if (cell.floors.some(f => (f.style === 1 || f.style === 5 || f.style === 20) && f.prop1 === 194)) avenues += 1
|
if (cell.floors.some(f => (f.style === 1 || f.style === 5) && f.prop1 === 194)) avenues += 1
|
||||||
if (cell.walls.some(w => (w.style === 1 || w.style === 20) && w.prop1 === 129)) balustrades += 1
|
if (cell.walls.some(w => w.style === 1 && w.prop1 === 129)) balustrades += 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -286,7 +286,7 @@ describe("Act 5 Bloody Foothills Dynamic Siege Generation (DRLGOUTSIEGE)", () =>
|
||||||
if (cell.floors.some(f => f.style === 4 && f.prop1 === 2)) {
|
if (cell.floors.some(f => f.style === 4 && f.prop1 === 2)) {
|
||||||
platformFloorCount += 1
|
platformFloorCount += 1
|
||||||
}
|
}
|
||||||
if (cell.walls.some(w => !w.hidden && (w.style === 4 || w.style === 2) && w.prop1 === 2)) {
|
if (cell.walls.some(w => !w.hidden && w.style === 4 && w.prop1 === 2)) {
|
||||||
platformParapetCount += 1
|
platformParapetCount += 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -314,17 +314,17 @@ describe("Act 5 Bloody Foothills Dynamic Siege Generation (DRLGOUTSIEGE)", () =>
|
||||||
const stats = result.stats.siegeTopography as SiegeTopographyStats
|
const stats = result.stats.siegeTopography as SiegeTopographyStats
|
||||||
expect(stats.fortRuins).toBe(4)
|
expect(stats.fortRuins).toBe(4)
|
||||||
|
|
||||||
// Scan for fort ruin flagstone floors (style: 1 or 18, prop1: 2) and stone walls (style: 1 or 18, prop1: 129)
|
// Scan for fort ruin flagstone floors (style: 1, prop1: 2) and stone walls (style: 1, prop1: 129)
|
||||||
let ruinFloorCount = 0
|
let ruinFloorCount = 0
|
||||||
let ruinWallCount = 0
|
let ruinWallCount = 0
|
||||||
|
|
||||||
for (let y = 0; y < result.level.height; y += 1) {
|
for (let y = 0; y < result.level.height; y += 1) {
|
||||||
for (let x = 0; x < result.level.width; x += 1) {
|
for (let x = 0; x < result.level.width; x += 1) {
|
||||||
const cell = result.level.cells[y]![x]!
|
const cell = result.level.cells[y]![x]!
|
||||||
if (cell.floors.some(f => (f.style === 1 || f.style === 18) && f.prop1 === 2)) {
|
if (cell.floors.some(f => f.style === 1 && f.prop1 === 2)) {
|
||||||
ruinFloorCount += 1
|
ruinFloorCount += 1
|
||||||
}
|
}
|
||||||
if (cell.walls.some(w => !w.hidden && (w.style === 1 || w.style === 18) && w.prop1 === 129)) {
|
if (cell.walls.some(w => !w.hidden && w.style === 1 && w.prop1 === 129)) {
|
||||||
ruinWallCount += 1
|
ruinWallCount += 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue