Compare commits

..

6 Commits

6 changed files with 3286 additions and 0 deletions

518
src/game/affix-generator.ts Normal file
View File

@ -0,0 +1,518 @@
/**
* Diablo II Affix Generation System (alvl calculation, group mutex exclusion, magic & rare generation).
*
* Gold standard references:
* - 1.13c MagicPrefix.txt & MagicSuffix.txt (1162 spawnable affixes)
* - 1.13c RarePrefix.txt (46 rows) & RareSuffix.txt (155 rows)
* - D2Common!6FD77180 / D2Common!Items.cpp (affix generation, alvl formula, and slot constraints)
* - .d2s item bitstream specification (1 prefix + 1 suffix for Magic; up to 3 prefixes + 3 suffixes for Rare)
*/
import type { ItemBase } from './items.ts'
import type { MagicAffix, AffixTable, AffixEligibilityOptions } from './affixes.ts'
import { isAffixEligible } from './affixes.ts'
import type { RareNameEntry, RareNameTable } from './rare-names.ts'
import { isRareNameEligible } from './rare-names.ts'
import type { D2Rng } from './d2-rng.ts'
/**
* Rolled modifier entry with concrete rolled value.
*/
export interface RolledMod {
readonly code: string
readonly param?: string | undefined
readonly min: number
readonly max: number
readonly value: number
}
/**
* Concrete rolled affix instance on an item.
*/
export interface RolledAffix {
readonly affix: MagicAffix
readonly name: string
readonly isPrefix: boolean
readonly group: number
readonly level: number
readonly levelreq: number
readonly mods: readonly RolledMod[]
readonly id?: number | undefined
}
/**
* Result of rolling magic affixes on an item.
*/
export interface GeneratedAffixes {
readonly name: string
readonly alvl: number
readonly prefix: RolledAffix | null
readonly suffix: RolledAffix | null
readonly prefixes: readonly RolledAffix[]
readonly suffixes: readonly RolledAffix[]
readonly affixes: readonly RolledAffix[]
readonly baseName: string
readonly dwInitSeed?: number | undefined
readonly seed?: number | undefined
}
/**
* Result of rolling rare affixes on an item.
*/
export interface GeneratedRareAffixes extends GeneratedAffixes {
readonly rarePrefix?: RareNameEntry | undefined
readonly rareSuffix?: RareNameEntry | undefined
}
/**
* Type predicate for item type hierarchy testing.
*/
export type IsAFunction = (type: string, target: string) => boolean
/**
* Computes affix level (alvl) according to the 1.13c Diablo II algorithm.
*
* Formula:
* - if (magicLvl > 0) alvl = ilvl + magicLvl
* - else if (qlvl > Math.trunc(ilvl / 2)) alvl = ilvl - Math.trunc(qlvl / 2)
* - else alvl = ilvl
* - alvl = Math.min(alvl, 99)
*
* @param ilvl - Item drop level (from monster mlvl or area alvl).
* @param qlvl - Base item quality level.
* @param magicLvl - Optional magic level bonus on base item (e.g. circlets, wands, staves).
* @returns Calculated affix level (alvl, capped at 99).
*/
export function calculateAlvl(ilvl: number, qlvl: number, magicLvl = 0): number {
let alvl: number
if (magicLvl > 0) {
alvl = ilvl + magicLvl
} else if (qlvl > Math.trunc(ilvl / 2)) {
alvl = ilvl - Math.trunc(qlvl / 2)
} else {
alvl = ilvl
}
alvl = Math.min(alvl, 99)
return alvl
}
/**
* Resolves the primary item type code for an ItemBase.
*
* Checks:
* 1. `base.type` (if present, e.g. from ArmorEntry/WeaponEntry/MiscItemEntry)
* 2. Specific tag in `base.tags` (skipping generic categories 'weap', 'armo', 'misc')
* 3. First tag in `base.tags`
* 4. `base.code`
* 5. `base.id`
*
* @param base - The base item definition.
* @returns 3-4 character item type code (e.g. 'swor', 'helm', 'ring').
*/
export function getItemTypeCode(base: ItemBase): string {
if ((base as any).type) {
return (base as any).type
}
if (base.tags && base.tags.length > 0) {
const specific = base.tags.find(t => t !== 'weap' && t !== 'armo' && t !== 'misc')
if (specific) {
return specific
}
return base.tags[0]!
}
if ((base as any).code) {
return (base as any).code
}
return base.id
}
/**
* Extracts quality level (qlvl) from an ItemBase.
*/
export function getBaseQlvl(base: ItemBase): number {
return (base as any).qlvl ?? base.level ?? 1
}
/**
* Extracts magic level (magicLvl) from an ItemBase.
*/
export function getBaseMagicLvl(base: ItemBase): number {
return (base as any).magicLvl ?? (base as any).magicLevel ?? 0
}
/**
* Filters eligible candidate affixes from an AffixTable taking into account group mutex and prior selections.
*/
function getEligibleCandidates(
table: AffixTable,
itemTypeCode: string,
isA: IsAFunction,
options: AffixEligibilityOptions,
usedGroups: ReadonlySet<number>,
usedAffixes: ReadonlySet<MagicAffix>,
): MagicAffix[] {
const all = table.all ?? (Array.isArray(table) ? table : [])
return all.filter(affix => {
if (usedAffixes.has(affix)) {
return false
}
if (affix.group > 0 && usedGroups.has(affix.group)) {
return false
}
return isAffixEligible(affix, itemTypeCode, isA, options)
})
}
/**
* Selects an affix from candidate list weighted by `affix.frequency`.
*
* If all frequencies are 0 (or total weight <= 0), chooses uniformly at random.
*/
function pickWeightedAffix(candidates: readonly MagicAffix[], rng: D2Rng): MagicAffix | null {
if (candidates.length === 0) {
return null
}
let totalWeight = 0
for (const cand of candidates) {
totalWeight += Math.max(0, cand.frequency)
}
if (totalWeight <= 0) {
const index = rng.rand(candidates.length)
return candidates[index] ?? null
}
const roll = rng.rand(totalWeight)
let acc = 0
for (const cand of candidates) {
acc += Math.max(0, cand.frequency)
if (roll < acc) {
return cand
}
}
return candidates[candidates.length - 1] ?? null
}
/**
* Rolls concrete values for all modifiers of a chosen affix using `rng.randRange(min, max)`.
*/
function rollAffixMods(affix: MagicAffix, rng: D2Rng): RolledMod[] {
return affix.mods.map(mod => ({
code: mod.code,
...(mod.param !== undefined ? { param: mod.param } : {}),
min: mod.min,
max: mod.max,
value: rng.randRange(mod.min, mod.max),
}))
}
/**
* Creates a RolledAffix from a selected MagicAffix and rolled mods.
*/
function createRolledAffix(affix: MagicAffix, rng: D2Rng): RolledAffix {
const mods = rollAffixMods(affix, rng)
return {
affix,
name: affix.name,
isPrefix: affix.isPrefix,
group: affix.group,
level: affix.level,
levelreq: affix.levelreq,
mods,
id: (affix as any).id,
}
}
/**
* Retrieves eligible rare names from a RareNameTable.
*/
function getEligibleRareNames(
table: RareNameTable,
itemTypeCode: string,
isA: IsAFunction,
): readonly RareNameEntry[] {
if (typeof table.getEligible === 'function') {
return table.getEligible(itemTypeCode, isA)
}
if (Array.isArray(table.entries)) {
return table.entries.filter(entry => isRareNameEligible(entry, itemTypeCode, isA))
}
return []
}
/**
* Rolls magic affixes for an item:
* - 1 prefix slot + 1 suffix slot.
* - Randomly choose: {prefix only}, {suffix only}, or {prefix + suffix}.
* - Weighted selection by `frequency`.
* - Group mutex: when an affix with `group > 0` is chosen, that group is excluded from subsequent rolls.
* - Formats name as `[Prefix] [BaseName] [Suffix]`.
*
* @param base - Base item definition.
* @param ilvl - Item level.
* @param affixes - Tables containing prefix and suffix tables.
* @param isA - Item type inheritance predicate.
* @param rng - Unit PRNG instance (D2Rng).
* @returns Generated magic item affixes and metadata.
*/
export function rollMagicAffixes(
base: ItemBase,
ilvl: number,
affixes: { prefixes: AffixTable; suffixes: AffixTable },
isA: Function,
rng: D2Rng,
): GeneratedAffixes {
const predicate = isA as IsAFunction
const initialSeed = rng.lo
const qlvl = getBaseQlvl(base)
const magicLvl = getBaseMagicLvl(base)
const alvl = calculateAlvl(ilvl, qlvl, magicLvl)
const itemTypeCode = getItemTypeCode(base)
const usedGroups = new Set<number>()
const usedAffixes = new Set<MagicAffix>()
// Randomly choose {prefix only}, {suffix only}, or {prefix + suffix}
// 0 = prefix only, 1 = suffix only, 2 = prefix + suffix
const slotChoice = rng.rand(3)
const wantsPrefix = slotChoice === 0 || slotChoice === 2
const wantsSuffix = slotChoice === 1 || slotChoice === 2
let rolledPrefix: RolledAffix | null = null
let rolledSuffix: RolledAffix | null = null
if (wantsPrefix) {
const candidates = getEligibleCandidates(
affixes.prefixes,
itemTypeCode,
predicate,
{ alvl, isRare: false },
usedGroups,
usedAffixes,
)
const picked = pickWeightedAffix(candidates, rng)
if (picked) {
usedAffixes.add(picked)
if (picked.group > 0) {
usedGroups.add(picked.group)
}
rolledPrefix = createRolledAffix(picked, rng)
}
}
if (wantsSuffix) {
const candidates = getEligibleCandidates(
affixes.suffixes,
itemTypeCode,
predicate,
{ alvl, isRare: false },
usedGroups,
usedAffixes,
)
const picked = pickWeightedAffix(candidates, rng)
if (picked) {
usedAffixes.add(picked)
if (picked.group > 0) {
usedGroups.add(picked.group)
}
rolledSuffix = createRolledAffix(picked, rng)
}
}
// Name format: [Prefix] [BaseName] [Suffix]
const nameParts: string[] = []
if (rolledPrefix) {
nameParts.push(rolledPrefix.name)
}
nameParts.push(base.name)
if (rolledSuffix) {
nameParts.push(rolledSuffix.name)
}
const name = nameParts.filter(p => p.trim().length > 0).join(' ')
const prefixes = rolledPrefix ? [rolledPrefix] : []
const suffixes = rolledSuffix ? [rolledSuffix] : []
const allRolled = [...prefixes, ...suffixes]
return {
name,
alvl,
prefix: rolledPrefix,
suffix: rolledSuffix,
prefixes,
suffixes,
affixes: allRolled,
baseName: base.name,
dwInitSeed: initialSeed,
seed: initialSeed,
}
}
/**
* Rolls rare affixes for an item:
* - Up to 3 prefix + up to 3 suffix slots.
* - Total affixes: 3 to 6 (at least 1 prefix and 1 suffix, max 3 each).
* - Rare name: pick 1 eligible RarePrefix + 1 eligible RareSuffix.
* - Mod values rolled using `itemRng.randRange(min, max)`.
*
* @param base - Base item definition.
* @param ilvl - Item level.
* @param affixes - Tables containing prefix and suffix tables.
* @param rareNames - Tables containing rare prefix and suffix name tables.
* @param isA - Item type inheritance predicate.
* @param rng - Unit PRNG instance (D2Rng).
* @returns Generated rare item affixes and metadata.
*/
export function rollRareAffixes(
base: ItemBase,
ilvl: number,
affixes: { prefixes: AffixTable; suffixes: AffixTable },
rareNames: { prefixes: RareNameTable; suffixes: RareNameTable },
isA: Function,
rng: D2Rng,
): GeneratedRareAffixes {
const predicate = isA as IsAFunction
const initialSeed = rng.lo
const qlvl = getBaseQlvl(base)
const magicLvl = getBaseMagicLvl(base)
const alvl = calculateAlvl(ilvl, qlvl, magicLvl)
const itemTypeCode = getItemTypeCode(base)
// 1. Pick Rare Name: 1 eligible RarePrefix + 1 eligible RareSuffix
const eligibleRarePrefixes = getEligibleRareNames(rareNames.prefixes, itemTypeCode, predicate)
const eligibleRareSuffixes = getEligibleRareNames(rareNames.suffixes, itemTypeCode, predicate)
let rarePrefix: RareNameEntry | undefined
if (eligibleRarePrefixes.length > 0) {
const idx = rng.rand(eligibleRarePrefixes.length)
rarePrefix = eligibleRarePrefixes[idx]
}
let rareSuffix: RareNameEntry | undefined
if (eligibleRareSuffixes.length > 0) {
const idx = rng.rand(eligibleRareSuffixes.length)
rareSuffix = eligibleRareSuffixes[idx]
}
const rareNameParts = [rarePrefix?.name, rareSuffix?.name].filter(
(n): n is string => typeof n === 'string' && n.trim().length > 0,
)
const name = rareNameParts.length > 0 ? rareNameParts.join(' ') : base.name
// 2. Determine slot distribution:
// Total affixes: 3 to 6 (at least 1 prefix and 1 suffix, max 3 each)
const totalAffixes = rng.randRange(3, 6)
const minPrefix = Math.max(1, totalAffixes - 3)
const maxPrefix = Math.min(3, totalAffixes - 1)
const targetPrefixCount = rng.randRange(minPrefix, maxPrefix)
const targetSuffixCount = totalAffixes - targetPrefixCount
const usedGroups = new Set<number>()
const usedAffixes = new Set<MagicAffix>()
const rolledPrefixes: RolledAffix[] = []
const rolledSuffixes: RolledAffix[] = []
// Roll target prefix slots
for (let i = 0; i < targetPrefixCount; i++) {
const candidates = getEligibleCandidates(
affixes.prefixes,
itemTypeCode,
predicate,
{ alvl, isRare: true },
usedGroups,
usedAffixes,
)
const picked = pickWeightedAffix(candidates, rng)
if (!picked) break
usedAffixes.add(picked)
if (picked.group > 0) {
usedGroups.add(picked.group)
}
rolledPrefixes.push(createRolledAffix(picked, rng))
}
// Roll target suffix slots
for (let i = 0; i < targetSuffixCount; i++) {
const candidates = getEligibleCandidates(
affixes.suffixes,
itemTypeCode,
predicate,
{ alvl, isRare: true },
usedGroups,
usedAffixes,
)
const picked = pickWeightedAffix(candidates, rng)
if (!picked) break
usedAffixes.add(picked)
if (picked.group > 0) {
usedGroups.add(picked.group)
}
rolledSuffixes.push(createRolledAffix(picked, rng))
}
// If pool exhaustion prevented filling target count, try filling remaining slots from either category (up to 3 each)
while (rolledPrefixes.length + rolledSuffixes.length < totalAffixes) {
let rolledAny = false
if (rolledPrefixes.length < 3) {
const candidates = getEligibleCandidates(
affixes.prefixes,
itemTypeCode,
predicate,
{ alvl, isRare: true },
usedGroups,
usedAffixes,
)
const picked = pickWeightedAffix(candidates, rng)
if (picked) {
usedAffixes.add(picked)
if (picked.group > 0) {
usedGroups.add(picked.group)
}
rolledPrefixes.push(createRolledAffix(picked, rng))
rolledAny = true
if (rolledPrefixes.length + rolledSuffixes.length >= totalAffixes) break
}
}
if (rolledSuffixes.length < 3) {
const candidates = getEligibleCandidates(
affixes.suffixes,
itemTypeCode,
predicate,
{ alvl, isRare: true },
usedGroups,
usedAffixes,
)
const picked = pickWeightedAffix(candidates, rng)
if (picked) {
usedAffixes.add(picked)
if (picked.group > 0) {
usedGroups.add(picked.group)
}
rolledSuffixes.push(createRolledAffix(picked, rng))
rolledAny = true
if (rolledPrefixes.length + rolledSuffixes.length >= totalAffixes) break
}
}
if (!rolledAny) break
}
const allRolled = [...rolledPrefixes, ...rolledSuffixes]
return {
name,
alvl,
rarePrefix,
rareSuffix,
prefix: rolledPrefixes[0] ?? null,
suffix: rolledSuffixes[0] ?? null,
prefixes: rolledPrefixes,
suffixes: rolledSuffixes,
affixes: allRolled,
baseName: base.name,
dwInitSeed: initialSeed,
seed: initialSeed,
}
}

342
src/game/auto-tc.ts Normal file
View File

@ -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<string> = 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<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))
}
}
/**
* 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<string, AutoTcNode> {
const result = new CaseInsensitiveMap<AutoTcNode>()
// 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<string, AutoTcNode>,
): 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<string, AutoTcNode>,
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<string, AutoTcNode>,
name: string,
): AutoTcNode | undefined {
return autoTcs.get(name)
}

449
src/game/gems.ts Normal file
View File

@ -0,0 +1,449 @@
/**
* Diablo II gem and rune socketing loader and modifier dispatch (`Gems.txt`).
*
* Gold standard reference: 1.13c MPQ `data/global/excel/Gems.txt` and `ItemTypes.txt`.
*
* In Diablo II, socketable items (weapons, helms, body armor, and shields)
* can have gems (35 types: 7 gemstones × 5 quality tiers) and runes (33 runes:
* El (r01) to Zod (r33)) socketed into them.
*
* Key data and game mechanics:
* 1. `Gems.txt`:
* - 69 raw lines in 1.13c; minus the 'Expansion' section separator row = exactly 68 data rows × 41 columns.
* - 35 gems (indices 0..34) + 33 runes (indices 36..68, row 35 is 'Expansion').
* - Columns: `name`, `letter`, `transform`, `code`, `nummods`,
* `weaponMod1..3`, `helmMod1..3`, `shieldMod1..3` (each with Code, Param, Min, Max).
* 2. Socket modifier dispatch:
* - Weapon (`weap`, `mele`, `bow`, etc.) -> `weaponMod1..3`
* - Helm OR Body Armor (`tors`) -> `helmMod1..3`
* (CRITICAL: `helm` mods apply to both helmets AND body armor/torso!)
* - Shield (`shld`, `shie`, `ashd`, `head`) -> `shieldMod1..3`
* 3. Socket limit calculation (`ItemTypes.txt`):
* - `ilvl 1..24`: `maxSock1`
* - `ilvl 25..39`: `maxSock25`
* - `ilvl 40+`: `maxSock40`
* - Allowed sockets = `Math.min(base.gemsockets, itemType.maxSockN)`.
*/
import type { D2Table } from './acts.ts'
import { cell, parseTable } from './acts.ts'
import type { MountedArchives } from '../mpq/mount.ts'
import type { ItemTypeDefinition } from './item-types.ts'
/** File path of `Gems.txt` inside MPQ archives. */
export const GEMS_TABLE_PATH = 'data\\global\\excel\\Gems.txt'
/**
* Modifier descriptor for a gem or rune socketed into an item.
*/
export interface GemModifier {
/** Stat property code (e.g. 'att', 'str', 'ac', 'dmg-fire', 'thorns'). */
readonly code: string
/** Optional parameter (e.g. duration in frames like 125 or 75, or 0). */
readonly param?: number | string | undefined
/** Alias for param for compatibility with UniqueItemProp / SetBonusProp. */
readonly par?: number | string | undefined
/** Minimum roll value. */
readonly min: number
/** Maximum roll value. */
readonly max: number
}
/**
* Definition of an individual gem or rune entry from `Gems.txt`.
*/
export interface Gem {
/** Name of the gem or rune (e.g. 'Chipped Amethyst', 'El Rune'). */
readonly name: string
/** 3-letter item code (e.g. 'gcv', 'r01'). */
readonly code: string
/** Rune display letter (e.g. 'El', 'Eld') if present. */
readonly letter?: string | undefined
/** Color transform ID if present. */
readonly transform?: number | undefined
/** Number of modifier categories or mods specified in table. */
readonly nummods: number
/** Whether this record represents a rune (r01..r33). */
readonly isRune: boolean
/** Whether this record represents a gemstone (35 standard gems). */
readonly isGem: boolean
/** Modifiers applied when socketed into a weapon. */
readonly weaponMods: readonly GemModifier[]
/** Modifiers applied when socketed into a helm or torso (body armor). */
readonly helmMods: readonly GemModifier[]
/** Modifiers applied when socketed into a shield. */
readonly shieldMods: readonly GemModifier[]
/** Individual modifier accessors */
readonly weaponMod1?: GemModifier | undefined
readonly weaponMod2?: GemModifier | undefined
readonly weaponMod3?: GemModifier | undefined
readonly helmMod1?: GemModifier | undefined
readonly helmMod2?: GemModifier | undefined
readonly helmMod3?: GemModifier | undefined
readonly shieldMod1?: GemModifier | undefined
readonly shieldMod2?: GemModifier | undefined
readonly shieldMod3?: GemModifier | undefined
}
/**
* Case-insensitive, whitespace-tolerant Map for looking up gems by code or name.
*/
class CaseInsensitiveMap<V> extends Map<string, V> {
private normalize(key: string): string {
return typeof key === 'string' ? key.trim().toLowerCase() : String(key).trim().toLowerCase()
}
override get(key: string): V | undefined {
return super.get(this.normalize(key))
}
override has(key: string): boolean {
return super.has(this.normalize(key))
}
override set(key: string, value: V): this {
return super.set(this.normalize(key), value)
}
override delete(key: string): boolean {
return super.delete(this.normalize(key))
}
}
/**
* Parsed Gems.txt table providing collection and lookup access.
*/
export interface GemTable {
/** All parsed gem and rune records in file order (68 records in vanilla 1.13c). */
readonly all: readonly Gem[]
/** Alias for `all`. */
readonly rows: readonly Gem[]
/** 35 standard gemstone records. */
readonly gems: readonly Gem[]
/** 33 rune records. */
readonly runes: readonly Gem[]
/** Map of gems/runes indexed by 3-letter code (case-insensitive). */
readonly byCode: ReadonlyMap<string, Gem>
/** Map of gems/runes indexed by name (case-insensitive). */
readonly byName: ReadonlyMap<string, Gem>
/** Lookup a gem or rune by code or name (case-insensitive). */
get(codeOrName: string): Gem | undefined
/** Total count of records (68 in vanilla 1.13c). */
readonly length: number
/** Allows iteration over all gem and rune records. */
[Symbol.iterator](): IterableIterator<Gem>
/** Array index access. */
readonly [index: number]: Gem | undefined
}
/**
* Parses a parameter cell into number or string or undefined.
*/
export function parseParam(val: string | undefined): number | string | undefined {
if (val === undefined) return undefined
const trimmed = val.trim()
if (trimmed === '') return undefined
if (/^-?\d+$/.test(trimmed)) {
const num = Number(trimmed)
if (Number.isFinite(num)) return num
}
return trimmed
}
/**
* Parses a numeric cell with fallback.
*/
export function parseNumber(val: string | undefined, fallback = 0): number {
if (val === undefined) return fallback
const trimmed = val.trim()
if (trimmed === '') return fallback
const num = Number(trimmed)
return Number.isFinite(num) ? num : fallback
}
/**
* Predicate function to check if `typeCode` descends from `ancestorCode`.
*/
export type IsAPredicate = (typeCode: string, ancestorCode: string) => boolean
/**
* Parses `Gems.txt` from a raw tab-separated D2Table, Uint8Array, or string.
*
* In Diablo II 1.13c:
* - 69 raw rows minus 1 'Expansion' row = exactly 68 data rows.
* - 35 gems + 33 runes.
* - Columns: name, letter, transform, code, nummods, weaponMod1..3, helmMod1..3, shieldMod1..3.
*
* @param input - Raw D2Table, Uint8Array, or TSV string.
* @returns Parsed `GemTable`.
*/
export function parseGemsTable(table: D2Table): GemTable
export function parseGemsTable(bytes: Uint8Array): GemTable
export function parseGemsTable(tsv: string): GemTable
export function parseGemsTable(input: D2Table | Uint8Array | string): GemTable
export function parseGemsTable(input: D2Table | Uint8Array | string): GemTable {
let table: D2Table
if (typeof input === 'string') {
table = parseTable(new TextEncoder().encode(input))
} else if (input instanceof Uint8Array) {
table = parseTable(input)
} else {
table = input
}
const all: Gem[] = []
const gems: Gem[] = []
const runes: Gem[] = []
const byCode = new CaseInsensitiveMap<Gem>()
const byName = new CaseInsensitiveMap<Gem>()
for (const row of table.rows) {
const code = cell(table, row, 'code').trim()
const name = cell(table, row, 'name').trim()
// Filter out rows where code is empty, or placeholder / expansion separator rows
if (!code || name === 'Expansion' || name === 'None') {
continue
}
const parseMod = (prefix: 'weapon' | 'helm' | 'shield', slot: number): GemModifier | undefined => {
const modCode = cell(table, row, `${prefix}Mod${slot}Code`).trim()
if (!modCode) return undefined
const rawParam = cell(table, row, `${prefix}Mod${slot}Param`)
const param = parseParam(rawParam)
const min = parseNumber(cell(table, row, `${prefix}Mod${slot}Min`), 0)
const max = parseNumber(cell(table, row, `${prefix}Mod${slot}Max`), 0)
return {
code: modCode,
...(param !== undefined ? { param, par: param } : {}),
min,
max,
}
}
const weaponMod1 = parseMod('weapon', 1)
const weaponMod2 = parseMod('weapon', 2)
const weaponMod3 = parseMod('weapon', 3)
const helmMod1 = parseMod('helm', 1)
const helmMod2 = parseMod('helm', 2)
const helmMod3 = parseMod('helm', 3)
const shieldMod1 = parseMod('shield', 1)
const shieldMod2 = parseMod('shield', 2)
const shieldMod3 = parseMod('shield', 3)
const weaponMods = [weaponMod1, weaponMod2, weaponMod3].filter((m): m is GemModifier => m !== undefined)
const helmMods = [helmMod1, helmMod2, helmMod3].filter((m): m is GemModifier => m !== undefined)
const shieldMods = [shieldMod1, shieldMod2, shieldMod3].filter((m): m is GemModifier => m !== undefined)
const letterRaw = cell(table, row, 'letter').trim()
const letter = letterRaw !== '' ? letterRaw : undefined
const transformRaw = cell(table, row, 'transform').trim()
const transform = transformRaw !== '' ? parseNumber(transformRaw) : undefined
const nummods = parseNumber(cell(table, row, 'nummods'), 0)
const isRune = /^r\d{2}$/i.test(code) || (letter !== undefined && letter !== '')
const isGem = !isRune
const gem: Gem = {
name,
code,
letter,
transform,
nummods,
isRune,
isGem,
weaponMods,
helmMods,
shieldMods,
...(weaponMod1 ? { weaponMod1 } : {}),
...(weaponMod2 ? { weaponMod2 } : {}),
...(weaponMod3 ? { weaponMod3 } : {}),
...(helmMod1 ? { helmMod1 } : {}),
...(helmMod2 ? { helmMod2 } : {}),
...(helmMod3 ? { helmMod3 } : {}),
...(shieldMod1 ? { shieldMod1 } : {}),
...(shieldMod2 ? { shieldMod2 } : {}),
...(shieldMod3 ? { shieldMod3 } : {}),
}
all.push(gem)
if (isRune) {
runes.push(gem)
} else {
gems.push(gem)
}
byCode.set(code, gem)
byName.set(name, gem)
}
const tableObj: GemTable = {
all,
rows: all,
gems,
runes,
byCode,
byName,
get(codeOrName: string): Gem | undefined {
return byCode.get(codeOrName) ?? byName.get(codeOrName)
},
get length(): number {
return all.length
},
[Symbol.iterator](): IterableIterator<Gem> {
return all[Symbol.iterator]()
},
}
// Support array index access table[0]
return new Proxy(tableObj, {
get(target, prop, receiver) {
if (typeof prop === 'string' && /^\d+$/.test(prop)) {
return all[Number(prop)]
}
return Reflect.get(target, prop, receiver)
},
})
}
/**
* Loads and parses `Gems.txt` from mounted MPQ archives.
*
* @param archives - Mounted MPQ archives.
* @param tablePath - Optional path inside archive (defaults to GEMS_TABLE_PATH).
* @returns Promise resolving to `GemTable`.
*/
export async function loadGems(
archives: MountedArchives,
tablePath = GEMS_TABLE_PATH,
): Promise<GemTable> {
const bytes = await archives.read(tablePath)
const d2Table = parseTable(bytes)
return parseGemsTable(d2Table)
}
/**
* Dispatches gem modifiers based on target item type.
*
* Rules:
* - Weapon (`weap`, `mele`, `bow`, `swor`, etc.) -> `weaponMod1..3`
* - Helm OR Body Armor (`tors`) -> `helmMod1..3`
* (CRITICAL: In Diablo II, `helm` mods apply to both helmets AND body armor!)
* - Shield (`shld`, `shie`, `ashd`, `head`) -> `shieldMod1..3`
* - Other types -> `[]`
*
* @param gemCode - Gem or rune 3-letter code (e.g. 'gcv', 'r01').
* @param itemTypeCode - Item type code of socketed item (e.g. 'swor', 'helm', 'tors', 'shie').
* @param isA - Hierarchy predicate `(typeCode, ancestorCode) => boolean` or Function.
* @param gemTable - Parsed gem table.
* @returns Array of modifiers applied to the socketed item.
*/
export function getGemModifiers(
gemCode: string,
itemTypeCode: string,
isA: IsAPredicate | Function,
gemTable: GemTable,
): GemModifier[] {
const gem = gemTable.get(gemCode)
if (!gem) return []
const code = itemTypeCode.trim().toLowerCase()
const safeIsA = (child: string, parent: string): boolean => {
if (typeof isA !== 'function') return false
try {
return Boolean(isA(child, parent))
} catch {
return false
}
}
// 1. Weapon check
const isWeapon =
code === 'weap' ||
code === 'weapon' ||
code === 'swor' ||
code === 'sword' ||
safeIsA(code, 'weap') ||
safeIsA(code, 'mele') ||
safeIsA(code, 'bow') ||
safeIsA(code, 'miss') ||
safeIsA(code, 'rod')
if (isWeapon) {
return [...gem.weaponMods]
}
// 2. Helm OR Body Armor (torso) check
// CRITICAL: Helm mods apply to both helmets AND body armor ('tors')!
const isHelmOrTorso =
code === 'helm' ||
code === 'helmet' ||
code === 'tors' ||
code === 'torso' ||
code === 'body' ||
safeIsA(code, 'helm') ||
safeIsA(code, 'tors') ||
safeIsA(code, 'torso') ||
safeIsA(code, 'phlm') ||
safeIsA(code, 'pelt') ||
safeIsA(code, 'circ')
if (isHelmOrTorso) {
return [...gem.helmMods]
}
// 3. Shield check
const isShield =
code === 'shld' ||
code === 'shie' ||
code === 'shield' ||
safeIsA(code, 'shld') ||
safeIsA(code, 'shie') ||
safeIsA(code, 'shield') ||
safeIsA(code, 'ashd') ||
safeIsA(code, 'head')
if (isShield) {
return [...gem.shieldMods]
}
return []
}
/**
* Calculates maximum allowed sockets for an item based on base gemsockets,
* item type socket tier limits, and item level (ilvl).
*
* Tier thresholds from `ItemTypes.txt`:
* - `ilvl 1..24`: `maxSock1`
* - `ilvl 25..39`: `maxSock25`
* - `ilvl 40+`: `maxSock40`
* - Allowed sockets = `Math.min(base.gemsockets, itemType.maxSockN)`.
*
* @param baseGemsockets - Max sockets supported by base item definition (`base.gemsockets`).
* @param itemType - Item type definition containing `maxSock1`, `maxSock25`, `maxSock40`.
* @param ilvl - Item level (monster level / drop level).
* @returns Allowed maximum socket count for the given ilvl tier.
*/
export function getMaxSockets(
baseGemsockets: number,
itemType: ItemTypeDefinition,
ilvl: number,
): number {
if (baseGemsockets <= 0) return 0
let typeMax: number
if (ilvl <= 24) {
typeMax = itemType.maxSock1 ?? 0
} else if (ilvl <= 39) {
typeMax = itemType.maxSock25 ?? 0
} else {
typeMax = itemType.maxSock40 ?? 0
}
return Math.max(0, Math.min(baseGemsockets, typeMax))
}

View File

@ -0,0 +1,705 @@
import { describe, expect, it } from 'vitest'
import * as fs from 'node:fs'
import {
calculateAlvl,
rollMagicAffixes,
rollRareAffixes,
getItemTypeCode,
getBaseQlvl,
getBaseMagicLvl,
} from '../src/game/affix-generator.ts'
import type { ItemBase } from '../src/game/items.ts'
import type { MagicAffix, AffixTable } from '../src/game/affixes.ts'
import { parseMagicAffixesTable } from '../src/game/affixes.ts'
import type { RareNameEntry, RareNameTable } from '../src/game/rare-names.ts'
import { parseRareNamesTable } from '../src/game/rare-names.ts'
import { parseTable } from '../src/game/acts.ts'
import { D2Rng } from '../src/game/d2-rng.ts'
import { MpqArchive } from '../src/mpq/archive.ts'
import { MountedArchives } from '../src/mpq/mount.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { loadItemTypes, isA as realIsA } from '../src/game/item-types.ts'
describe('Affix Generator - alvl calculation (Issue #104)', () => {
it('calculates alvl with magicLvl bonus', () => {
// if (magicLvl > 0) alvl = ilvl + magicLvl
expect(calculateAlvl(10, 20, 5)).toBe(15)
expect(calculateAlvl(20, 15, 3)).toBe(23)
// Diadem (magicLvl = 18) capped at 99
expect(calculateAlvl(85, 85, 18)).toBe(99)
// Capped at 99
expect(calculateAlvl(95, 80, 10)).toBe(99)
})
it('calculates alvl when qlvl > Math.trunc(ilvl / 2) without magicLvl', () => {
// else if (qlvl > Math.trunc(ilvl / 2)) alvl = ilvl - Math.trunc(qlvl / 2)
// ilvl = 30, qlvl = 40 -> trunc(30/2)=15, 40 > 15 -> 30 - 20 = 10
expect(calculateAlvl(30, 40)).toBe(10)
expect(calculateAlvl(30, 40, 0)).toBe(10)
// ilvl = 20, qlvl = 16 -> trunc(20/2)=10, 16 > 10 -> 20 - 8 = 12
expect(calculateAlvl(20, 16)).toBe(12)
// ilvl = 50, qlvl = 26 -> trunc(50/2)=25, 26 > 25 -> 50 - 13 = 37
expect(calculateAlvl(50, 26)).toBe(37)
// ilvl = 15, qlvl = 10 -> trunc(15/2)=7, 10 > 7 -> 15 - 5 = 10
expect(calculateAlvl(15, 10)).toBe(10)
})
it('calculates alvl when qlvl <= Math.trunc(ilvl / 2) without magicLvl', () => {
// else alvl = ilvl
// ilvl = 50, qlvl = 20 -> trunc(50/2)=25, 20 <= 25 -> 50
expect(calculateAlvl(50, 20)).toBe(50)
// Exact boundary: ilvl = 50, qlvl = 25 -> trunc(50/2)=25, 25 <= 25 -> 50
expect(calculateAlvl(50, 25)).toBe(50)
// ilvl = 80, qlvl = 30 -> trunc(80/2)=40, 30 <= 40 -> 80
expect(calculateAlvl(80, 30)).toBe(80)
// ilvl = 105, qlvl = 20 -> 105 capped at 99
expect(calculateAlvl(105, 20)).toBe(99)
})
it('defaults magicLvl to 0 when omitted', () => {
expect(calculateAlvl(40, 50)).toBe(calculateAlvl(40, 50, 0))
expect(calculateAlvl(60, 20)).toBe(calculateAlvl(60, 20, 0))
})
})
describe('ItemBase helper functions', () => {
it('extracts itemTypeCode, qlvl, and magicLvl correctly', () => {
const baseWithExplicitType: any = {
id: 'hax',
name: 'Hand Axe',
kind: 'weapon',
invWidth: 1,
invHeight: 3,
maxStack: 1,
value: 100,
damage: 10,
defense: 0,
tags: ['weap', 'axe'],
type: 'axe',
level: 3,
magicLvl: 2,
}
expect(getItemTypeCode(baseWithExplicitType)).toBe('axe')
expect(getBaseQlvl(baseWithExplicitType)).toBe(3)
expect(getBaseMagicLvl(baseWithExplicitType)).toBe(2)
const baseWithTagsOnly: ItemBase = {
id: 'crs',
name: 'Crystal Sword',
kind: 'weapon',
invWidth: 2,
invHeight: 3,
maxStack: 1,
value: 500,
damage: 15,
defense: 0,
tags: ['weap', 'swor'],
level: 11,
}
expect(getItemTypeCode(baseWithTagsOnly)).toBe('swor')
expect(getBaseQlvl(baseWithTagsOnly)).toBe(11)
expect(getBaseMagicLvl(baseWithTagsOnly)).toBe(0)
const baseMinimal: ItemBase = {
id: 'rin',
name: 'Ring',
kind: 'misc',
invWidth: 1,
invHeight: 1,
maxStack: 1,
value: 100,
damage: 0,
defense: 0,
tags: ['ring'],
level: 1,
}
expect(getItemTypeCode(baseMinimal)).toBe('ring')
expect(getBaseQlvl(baseMinimal)).toBe(1)
})
})
describe('Affix Generator - Group exclusion & Rare Generation (Issue #104)', () => {
const mockIsA = (type: string, target: string): boolean => {
if (type === target) return true
if (type === 'swor' && target === 'weap') return true
if (type === 'swor' && target === 'mele') return true
return false
}
const swordBase: ItemBase = {
id: 'swor',
name: 'Broad Sword',
kind: 'weapon',
invWidth: 1,
invHeight: 3,
maxStack: 1,
value: 100,
damage: 10,
defense: 0,
tags: ['swor'],
level: 15,
}
// Helper to create mock MagicAffix
function createMockAffix(
name: string,
isPrefix: boolean,
group: number,
frequency: number,
level = 1,
mods = [{ code: 'att', min: 10, max: 20 }],
): MagicAffix {
return {
name,
isPrefix,
version: 100,
spawnable: true,
rare: true,
level,
maxlevel: 0,
levelreq: 1,
frequency,
group,
mods,
transform: false,
itypes: ['swor'],
etypes: [],
}
}
it('enforces group mutex: affixes with same group > 0 never co-occur on the same item', () => {
// 3 prefixes in group 10 ("Bronze", "Iron", "Steel"), 2 prefixes in group 20 ("Sharp", "Fine")
const prefixes: MagicAffix[] = [
createMockAffix('Bronze', true, 10, 5),
createMockAffix('Iron', true, 10, 5),
createMockAffix('Steel', true, 10, 5),
createMockAffix('Sharp', true, 20, 5),
createMockAffix('Fine', true, 20, 5),
createMockAffix('Godly', true, 30, 5),
]
// 2 suffixes in group 10 ("of the Titan", "of the Colossus"), 3 suffixes in group 40 ("of Leech", "of Blood", "of Bat")
const suffixes: MagicAffix[] = [
createMockAffix('of the Titan', false, 10, 5),
createMockAffix('of the Colossus', false, 10, 5),
createMockAffix('of Leech', false, 40, 5),
createMockAffix('of Blood', false, 40, 5),
createMockAffix('of Bat', false, 50, 5),
createMockAffix('of Speed', false, 60, 5),
]
const mockAffixes = {
prefixes: { all: prefixes } as unknown as AffixTable,
suffixes: { all: suffixes } as unknown as AffixTable,
}
const mockRareNames = {
prefixes: {
entries: [{ index: 0, name: 'Storm', version: 0, itypes: [], etypes: [], add: 0, multiply: 0, divide: 0 }],
getEligible: () => [{ index: 0, name: 'Storm', version: 0, itypes: [], etypes: [], add: 0, multiply: 0, divide: 0 }],
} as unknown as RareNameTable,
suffixes: {
entries: [{ index: 0, name: 'Spur', version: 0, itypes: [], etypes: [], add: 0, multiply: 0, divide: 0 }],
getEligible: () => [{ index: 0, name: 'Spur', version: 0, itypes: [], etypes: [], add: 0, multiply: 0, divide: 0 }],
} as unknown as RareNameTable,
}
// Run 50 rare rolls with varying seeds
for (let seed = 1000; seed < 1050; seed++) {
const rng = new D2Rng(seed)
const rare = rollRareAffixes(swordBase, 50, mockAffixes, mockRareNames, mockIsA, rng)
// Count occurrences of each group > 0
const groupCounts = new Map<number, number>()
for (const rolled of rare.affixes) {
if (rolled.group > 0) {
groupCounts.set(rolled.group, (groupCounts.get(rolled.group) ?? 0) + 1)
}
}
// Group mutex constraint: every group > 0 must appear at most once!
for (const [group, count] of groupCounts.entries()) {
expect(count, `Group ${group} appeared ${count} times on the same item!`).toBe(1)
}
// Explicit check for cross-prefix/suffix conflict on group 10
const group10Affixes = rare.affixes.filter(a => a.group === 10)
expect(group10Affixes.length).toBeLessThanOrEqual(1)
}
})
it('allows affixes with group 0 to co-occur if they are distinct affixes', () => {
const prefixes: MagicAffix[] = [
createMockAffix('SpecialPrefix1', true, 0, 10),
createMockAffix('SpecialPrefix2', true, 0, 10),
createMockAffix('SpecialPrefix3', true, 0, 10),
]
const suffixes: MagicAffix[] = [
createMockAffix('of Special1', false, 0, 10),
createMockAffix('of Special2', false, 0, 10),
createMockAffix('of Special3', false, 0, 10),
]
const mockAffixes = {
prefixes: { all: prefixes } as unknown as AffixTable,
suffixes: { all: suffixes } as unknown as AffixTable,
}
const mockRareNames = {
prefixes: {
getEligible: () => [{ index: 0, name: 'Shadow', itypes: [], etypes: [] }],
} as unknown as RareNameTable,
suffixes: {
getEligible: () => [{ index: 0, name: 'Bite', itypes: [], etypes: [] }],
} as unknown as RareNameTable,
}
const rng = new D2Rng(42)
const rare = rollRareAffixes(swordBase, 50, mockAffixes, mockRareNames, mockIsA, rng)
expect(rare.affixes.length).toBeGreaterThanOrEqual(3)
// All affixes have group 0, and they can co-occur
const group0Count = rare.affixes.filter(a => a.group === 0).length
expect(group0Count).toBeGreaterThanOrEqual(3)
// But no duplicate affix instance
const names = rare.affixes.map(a => a.name)
const uniqueNames = new Set(names)
expect(names.length).toBe(uniqueNames.size)
})
})
describe('Affix Generator - Quality specific rolls (Issue #104)', () => {
const mockIsA = (type: string, target: string): boolean => {
if (type === target) return true
if (type === 'swor' && target === 'weap') return true
return false
}
const swordBase: ItemBase = {
id: 'swor',
name: 'Broad Sword',
kind: 'weapon',
invWidth: 1,
invHeight: 3,
maxStack: 1,
value: 100,
damage: 10,
defense: 0,
tags: ['swor'],
level: 15,
}
function createMockAffix(name: string, isPrefix: boolean, group: number): MagicAffix {
return {
name,
isPrefix,
version: 100,
spawnable: true,
rare: true,
level: 1,
maxlevel: 0,
levelreq: 1,
frequency: 5,
group,
mods: [{ code: 'hp', min: 10, max: 20 }],
transform: false,
itypes: ['swor'],
etypes: [],
}
}
const prefixes = [
createMockAffix('Sturdy', true, 1),
createMockAffix('Strong', true, 2),
createMockAffix('Glorious', true, 3),
]
const suffixes = [
createMockAffix('of the Ox', false, 4),
createMockAffix('of the Giant', false, 5),
createMockAffix('of the Titan', false, 6),
]
const mockAffixes = {
prefixes: { all: prefixes } as unknown as AffixTable,
suffixes: { all: suffixes } as unknown as AffixTable,
}
const mockRareNames = {
prefixes: {
getEligible: () => [
{ index: 0, name: 'Doom', itypes: [], etypes: [] },
{ index: 1, name: 'Beast', itypes: [], etypes: [] },
],
} as unknown as RareNameTable,
suffixes: {
getEligible: () => [
{ index: 0, name: 'Bite', itypes: [], etypes: [] },
{ index: 1, name: 'Casque', itypes: [], etypes: [] },
],
} as unknown as RareNameTable,
}
describe('Magic items: 1 prefix slot + 1 suffix slot', () => {
it('generates at most 1 prefix and 1 suffix, correctly formatted', () => {
let sawPrefixOnly = false
let sawSuffixOnly = false
let sawBoth = false
for (let seed = 1; seed <= 60; seed++) {
const rng = new D2Rng(seed)
const magic = rollMagicAffixes(swordBase, 20, mockAffixes, mockIsA, rng)
// Slot constraints
expect(magic.prefixes.length).toBeLessThanOrEqual(1)
expect(magic.suffixes.length).toBeLessThanOrEqual(1)
expect(magic.affixes.length).toBeGreaterThanOrEqual(1)
expect(magic.affixes.length).toBeLessThanOrEqual(2)
if (magic.prefix && !magic.suffix) {
sawPrefixOnly = true
expect(magic.name).toBe(`${magic.prefix.name} ${swordBase.name}`)
expect(magic.prefixes).toEqual([magic.prefix])
expect(magic.suffixes).toEqual([])
} else if (!magic.prefix && magic.suffix) {
sawSuffixOnly = true
expect(magic.name).toBe(`${swordBase.name} ${magic.suffix.name}`)
expect(magic.prefixes).toEqual([])
expect(magic.suffixes).toEqual([magic.suffix])
} else if (magic.prefix && magic.suffix) {
sawBoth = true
expect(magic.name).toBe(`${magic.prefix.name} ${swordBase.name} ${magic.suffix.name}`)
expect(magic.prefixes).toEqual([magic.prefix])
expect(magic.suffixes).toEqual([magic.suffix])
}
}
// Verify all 3 random choices ({prefix only}, {suffix only}, {both}) occur
expect(sawPrefixOnly).toBe(true)
expect(sawSuffixOnly).toBe(true)
expect(sawBoth).toBe(true)
})
})
describe('Rare items: 3 to 6 affixes, <=3 prefixes, <=3 suffixes', () => {
it('satisfies 3..6 total affixes, 1..3 prefixes, 1..3 suffixes, and picks rare name', () => {
for (let seed = 1; seed <= 50; seed++) {
const rng = new D2Rng(seed * 77)
const rare = rollRareAffixes(swordBase, 30, mockAffixes, mockRareNames, mockIsA, rng)
// Total affixes: 3 to 6
expect(rare.affixes.length).toBeGreaterThanOrEqual(3)
expect(rare.affixes.length).toBeLessThanOrEqual(6)
// Up to 3 prefix + up to 3 suffix slots (at least 1 each)
expect(rare.prefixes.length).toBeGreaterThanOrEqual(1)
expect(rare.prefixes.length).toBeLessThanOrEqual(3)
expect(rare.suffixes.length).toBeGreaterThanOrEqual(1)
expect(rare.suffixes.length).toBeLessThanOrEqual(3)
// Total equals sum
expect(rare.prefixes.length + rare.suffixes.length).toBe(rare.affixes.length)
// Rare name
expect(rare.rarePrefix).toBeDefined()
expect(rare.rareSuffix).toBeDefined()
expect(rare.name).toBe(`${rare.rarePrefix!.name} ${rare.rareSuffix!.name}`)
}
})
})
})
describe('Mod Values Roll (Issue #104)', () => {
const mockIsA = (type: string, target: string): boolean => type === target
const swordBase: ItemBase = {
id: 'swor',
name: 'Broad Sword',
kind: 'weapon',
invWidth: 1,
invHeight: 3,
maxStack: 1,
value: 100,
damage: 10,
defense: 0,
tags: ['swor'],
level: 10,
}
it('rolls mod values within [min, max] using item rng', () => {
const affixWithRangedMod: MagicAffix = {
name: 'Vitality',
isPrefix: true,
version: 100,
spawnable: true,
rare: true,
level: 1,
maxlevel: 0,
levelreq: 1,
frequency: 1,
group: 1,
mods: [
{ code: 'hp', min: 10, max: 20 },
{ code: 'str', min: 5, max: 5 },
],
transform: false,
itypes: ['swor'],
etypes: [],
}
const mockAffixes = {
prefixes: { all: [affixWithRangedMod] } as unknown as AffixTable,
suffixes: { all: [] } as unknown as AffixTable,
}
for (let seed = 1; seed <= 30; seed++) {
const rng = new D2Rng(seed)
const result = rollMagicAffixes(swordBase, 20, mockAffixes, mockIsA, rng)
if (result.prefix) {
const hpMod = result.prefix.mods.find(m => m.code === 'hp')!
expect(hpMod).toBeDefined()
expect(hpMod.value).toBeGreaterThanOrEqual(10)
expect(hpMod.value).toBeLessThanOrEqual(20)
const strMod = result.prefix.mods.find(m => m.code === 'str')!
expect(strMod).toBeDefined()
expect(strMod.value).toBe(5)
}
}
})
it('weights affix selection by affix.frequency', () => {
const commonPrefix: MagicAffix = {
name: 'Common',
isPrefix: true,
version: 100,
spawnable: true,
rare: true,
level: 1,
maxlevel: 0,
levelreq: 1,
frequency: 90,
group: 1,
mods: [{ code: 'hp', min: 1, max: 1 }],
transform: false,
itypes: ['swor'],
etypes: [],
}
const rarePrefix: MagicAffix = {
name: 'UltraRare',
isPrefix: true,
version: 100,
spawnable: true,
rare: true,
level: 1,
maxlevel: 0,
levelreq: 1,
frequency: 10,
group: 2,
mods: [{ code: 'hp', min: 1, max: 1 }],
transform: false,
itypes: ['swor'],
etypes: [],
}
const mockAffixes = {
prefixes: { all: [commonPrefix, rarePrefix] } as unknown as AffixTable,
suffixes: { all: [] } as unknown as AffixTable,
}
let commonCount = 0
let rareCount = 0
const totalRuns = 200
for (let seed = 1; seed <= totalRuns; seed++) {
const rng = new D2Rng(seed)
const res = rollMagicAffixes(swordBase, 20, mockAffixes, mockIsA, rng)
if (res.prefix?.name === 'Common') commonCount++
if (res.prefix?.name === 'UltraRare') rareCount++
}
// Common (freq 90) should spawn much more often than UltraRare (freq 10)
expect(commonCount).toBeGreaterThan(rareCount * 3)
expect(commonCount + rareCount).toBeGreaterThan(0)
})
})
describe('Affix Generator - Determinism & Reproducibility (Issue #104)', () => {
const mockIsA = (type: string, target: string): boolean => type === target
const swordBase: ItemBase = {
id: 'swor',
name: 'Broad Sword',
kind: 'weapon',
invWidth: 1,
invHeight: 3,
maxStack: 1,
value: 100,
damage: 10,
defense: 0,
tags: ['swor'],
level: 25,
}
function createMockAffix(name: string, isPrefix: boolean, group: number): MagicAffix {
return {
name,
isPrefix,
version: 100,
spawnable: true,
rare: true,
level: 1,
maxlevel: 0,
levelreq: 1,
frequency: 5,
group,
mods: [{ code: 'att', min: 10, max: 50 }],
transform: false,
itypes: ['swor'],
etypes: [],
}
}
const mockAffixes = {
prefixes: {
all: [
createMockAffix('Bronze', true, 1),
createMockAffix('Iron', true, 2),
createMockAffix('Steel', true, 3),
],
} as unknown as AffixTable,
suffixes: {
all: [
createMockAffix('of Craft', false, 4),
createMockAffix('of Master', false, 5),
createMockAffix('of Titan', false, 6),
],
} as unknown as AffixTable,
}
const mockRareNames = {
prefixes: {
getEligible: () => [{ index: 0, name: 'Gale', itypes: [], etypes: [] }],
} as unknown as RareNameTable,
suffixes: {
getEligible: () => [{ index: 0, name: 'Claw', itypes: [], etypes: [] }],
} as unknown as RareNameTable,
}
it('reproduces exact identical magic rolls with same seed', () => {
const fixedSeed = 0x12345678
const rng1 = new D2Rng(fixedSeed)
const magic1 = rollMagicAffixes(swordBase, 30, mockAffixes, mockIsA, rng1)
const rng2 = new D2Rng(fixedSeed)
const magic2 = rollMagicAffixes(swordBase, 30, mockAffixes, mockIsA, rng2)
expect(magic1.name).toBe(magic2.name)
expect(magic1.alvl).toBe(magic2.alvl)
expect(magic1.prefixes).toEqual(magic2.prefixes)
expect(magic1.suffixes).toEqual(magic2.suffixes)
expect(magic1.affixes).toEqual(magic2.affixes)
expect(magic1.dwInitSeed).toBe(magic2.dwInitSeed)
})
it('reproduces exact identical rare rolls with same seed', () => {
const fixedSeed = 0x87654321
const rng1 = new D2Rng(fixedSeed)
const rare1 = rollRareAffixes(swordBase, 30, mockAffixes, mockRareNames, mockIsA, rng1)
const rng2 = new D2Rng(fixedSeed)
const rare2 = rollRareAffixes(swordBase, 30, mockAffixes, mockRareNames, mockIsA, rng2)
expect(rare1.name).toBe(rare2.name)
expect(rare1.alvl).toBe(rare2.alvl)
expect(rare1.rarePrefix).toEqual(rare2.rarePrefix)
expect(rare1.rareSuffix).toEqual(rare2.rareSuffix)
expect(rare1.prefixes).toEqual(rare2.prefixes)
expect(rare1.suffixes).toEqual(rare2.suffixes)
expect(rare1.affixes).toEqual(rare2.affixes)
expect(rare1.dwInitSeed).toBe(rare2.dwInitSeed)
})
})
describe('Affix Generator - Real MPQ Data Integration (when samples/d2 exists)', () => {
const hasMpqs = fs.existsSync('samples/d2/d2exp.mpq')
it.skipIf(!hasMpqs)('rolls realistic magic and rare items from vanilla 1.13c game data', async () => {
const archives = new MountedArchives()
archives.add('d2data.mpq', await MpqArchive.open(await fileSource('samples/d2/d2data.mpq')))
archives.add('d2exp.mpq', await MpqArchive.open(await fileSource('samples/d2/d2exp.mpq')))
// Load tables
const prefixBytes = await archives.read('data\\global\\excel\\MagicPrefix.txt')
const suffixBytes = await archives.read('data\\global\\excel\\MagicSuffix.txt')
const rarePrefixBytes = await archives.read('data\\global\\excel\\RarePrefix.txt')
const rareSuffixBytes = await archives.read('data\\global\\excel\\RareSuffix.txt')
const prefixes = parseMagicAffixesTable(prefixBytes, true)
const suffixes = parseMagicAffixesTable(suffixBytes, false)
const rarePrefixes = parseRareNamesTable(parseTable(rarePrefixBytes))
const rareSuffixes = parseRareNamesTable(parseTable(rareSuffixBytes))
const itemTypeTable = await loadItemTypes(archives)
const isAPredicate = (type: string, target: string) => realIsA(type, target, itemTypeTable)
const crystalSword: ItemBase = {
id: 'crs',
name: 'Crystal Sword',
kind: 'weapon',
invWidth: 2,
invHeight: 3,
maxStack: 1,
value: 1200,
damage: 15,
defense: 0,
tags: ['weap', 'swor'],
level: 11,
}
const affixes = { prefixes, suffixes }
const rareNames = { prefixes: rarePrefixes, suffixes: rareSuffixes }
// Roll a magic sword at ilvl 50
const rngMagic = new D2Rng(99999)
const magicSword = rollMagicAffixes(crystalSword, 50, affixes, isAPredicate, rngMagic)
expect(magicSword.alvl).toBe(50) // qlvl=11 <= trunc(50/2)=25 -> alvl=50
expect(magicSword.affixes.length).toBeGreaterThanOrEqual(1)
expect(magicSword.affixes.length).toBeLessThanOrEqual(2)
expect(magicSword.name).toContain('Crystal Sword')
// Roll a rare sword at ilvl 80
const rngRare = new D2Rng(88888)
const rareSword = rollRareAffixes(crystalSword, 80, affixes, rareNames, isAPredicate, rngRare)
expect(rareSword.alvl).toBe(80)
expect(rareSword.affixes.length).toBeGreaterThanOrEqual(3)
expect(rareSword.affixes.length).toBeLessThanOrEqual(6)
expect(rareSword.prefixes.length).toBeGreaterThanOrEqual(1)
expect(rareSword.prefixes.length).toBeLessThanOrEqual(3)
expect(rareSword.suffixes.length).toBeGreaterThanOrEqual(1)
expect(rareSword.suffixes.length).toBeLessThanOrEqual(3)
expect(rareSword.rarePrefix).toBeDefined()
expect(rareSword.rareSuffix).toBeDefined()
expect(rareSword.name).toBe(`${rareSword.rarePrefix!.name} ${rareSword.rareSuffix!.name}`)
// Verify group mutex holds across all rolled affixes
const seenGroups = new Set<number>()
for (const affix of rareSword.affixes) {
if (affix.group > 0) {
expect(seenGroups.has(affix.group)).toBe(false)
seenGroups.add(affix.group)
}
}
})
})

737
tests/auto-tc.test.ts Normal file
View File

@ -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<string, AutoTcNode>
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<string, ItemTypeDefinition>()
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<string, ArmorBase>()
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<string, WeaponBase>()
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)
})
})
})

535
tests/gems.test.ts Normal file
View File

@ -0,0 +1,535 @@
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 type { D2Table } from '../src/game/acts.ts'
import { parseTable, cell } from '../src/game/acts.ts'
import {
parseGemsTable,
loadGems,
getGemModifiers,
getMaxSockets,
parseParam,
parseNumber,
GEMS_TABLE_PATH,
type GemTable,
type Gem,
} from '../src/game/gems.ts'
import {
loadItemTypes,
isA,
type ItemTypeTable,
type ItemTypeDefinition,
} from '../src/game/item-types.ts'
const hasD2 = fs.existsSync('samples/d2/d2data.mpq') && fs.existsSync('samples/d2/Patch_D2.mpq')
/**
* Minimal mock D2Table containing Chipped Amethyst, Expansion row, and El Rune.
*/
function createMinimalMockGemsTable(): D2Table {
const header = [
'name', 'letter', 'transform', 'code', 'nummods',
'weaponMod1Code', 'weaponMod1Param', 'weaponMod1Min', 'weaponMod1Max',
'weaponMod2Code', 'weaponMod2Param', 'weaponMod2Min', 'weaponMod2Max',
'weaponMod3Code', 'weaponMod3Param', 'weaponMod3Min', 'weaponMod3Max',
'helmMod1Code', 'helmMod1Param', 'helmMod1Min', 'helmMod1Max',
'helmMod2Code', 'helmMod2Param', 'helmMod2Min', 'helmMod2Max',
'helmMod3Code', 'helmMod3Param', 'helmMod3Min', 'helmMod3Max',
'shieldMod1Code', 'shieldMod1Param', 'shieldMod1Min', 'shieldMod1Max',
'shieldMod2Code', 'shieldMod2Param', 'shieldMod2Min', 'shieldMod2Max',
'shieldMod3Code', 'shieldMod3Param', 'shieldMod3Min', 'shieldMod3Max',
]
const rows: string[][] = [
// 0: Chipped Amethyst (gcv)
[
'Chipped Amethyst', '', '18', 'gcv', '3',
'att', '0', '40', '40',
'', '', '', '',
'', '0', '0', '0',
'str', '0', '3', '3',
'', '0', '0', '0',
'', '0', '0', '0',
'ac', '0', '8', '8',
'', '0', '0', '0',
'', '0', '0', '0',
],
// 1: Expansion row (separator to be filtered out)
[
'Expansion', '', '', '', '',
'', '', '', '',
'', '', '', '',
'', '', '', '',
'', '', '', '',
'', '', '', '',
'', '', '', '',
'', '', '', '',
'', '', '', '',
'', '', '', '',
],
// 2: El Rune (r01)
[
'El Rune', 'El', '18', 'r01', '1',
'light', '', '1', '1',
'att', '', '50', '50',
'', '', '', '',
'light', '', '1', '1',
'ac', '', '15', '15',
'', '', '', '',
'light', '', '1', '1',
'ac', '', '15', '15',
'', '', '', '',
],
]
return { header, rows }
}
describe('Gems and Runes Socketing (Issue #106)', () => {
describe('Helper functions: parseParam & parseNumber', () => {
it('parses empty and whitespace strings as undefined in parseParam', () => {
expect(parseParam(undefined)).toBeUndefined()
expect(parseParam('')).toBeUndefined()
expect(parseParam(' ')).toBeUndefined()
expect(parseParam('\t')).toBeUndefined()
})
it('parses numeric strings as numbers in parseParam', () => {
expect(parseParam('0')).toBe(0)
expect(parseParam('125')).toBe(125)
expect(parseParam('-20')).toBe(-20)
expect(typeof parseParam('125')).toBe('number')
})
it('preserves non-numeric strings in parseParam', () => {
expect(parseParam('Oak Sage')).toBe('Oak Sage')
expect(parseParam('cold')).toBe('cold')
})
it('parses numbers with fallback in parseNumber', () => {
expect(parseNumber(undefined)).toBe(0)
expect(parseNumber('')).toBe(0)
expect(parseNumber('', 10)).toBe(10)
expect(parseNumber('40')).toBe(40)
expect(parseNumber('-15')).toBe(-15)
expect(parseNumber('invalid', 99)).toBe(99)
})
})
describe('Standalone Table Parsing', () => {
it('parses D2Table and filters Expansion row', () => {
const mockTable = createMinimalMockGemsTable()
const gemTable = parseGemsTable(mockTable)
expect(gemTable.length).toBe(2)
expect(gemTable.all.length).toBe(2)
const gcv = gemTable.get('gcv')
expect(gcv).toBeDefined()
expect(gcv?.name).toBe('Chipped Amethyst')
expect(gcv?.isGem).toBe(true)
expect(gcv?.isRune).toBe(false)
expect(gcv?.transform).toBe(18)
expect(gcv?.nummods).toBe(3)
expect(gcv?.weaponMods).toHaveLength(1)
expect(gcv?.weaponMods[0]).toEqual({ code: 'att', param: 0, par: 0, min: 40, max: 40 })
expect(gcv?.helmMods).toHaveLength(1)
expect(gcv?.helmMods[0]).toEqual({ code: 'str', param: 0, par: 0, min: 3, max: 3 })
expect(gcv?.shieldMods).toHaveLength(1)
expect(gcv?.shieldMods[0]).toEqual({ code: 'ac', param: 0, par: 0, min: 8, max: 8 })
const r01 = gemTable.get('r01')
expect(r01).toBeDefined()
expect(r01?.name).toBe('El Rune')
expect(r01?.letter).toBe('El')
expect(r01?.isGem).toBe(false)
expect(r01?.isRune).toBe(true)
expect(r01?.weaponMods).toHaveLength(2)
})
it('supports TSV string and Uint8Array parsing', () => {
const tsv = [
'name\tletter\ttransform\tcode\tnummods\tweaponMod1Code\tweaponMod1Param\tweaponMod1Min\tweaponMod1Max\thelmMod1Code\thelmMod1Param\thelmMod1Min\thelmMod1Max\tshieldMod1Code\tshieldMod1Param\tshieldMod1Min\tshieldMod1Max',
'Chipped Ruby\t\t1\tgcr\t3\tfire-min\t0\t3\t3\thp\t0\t10\t10\tres-fire\t0\t12\t12',
].join('\n')
const fromString = parseGemsTable(tsv)
expect(fromString.length).toBe(1)
expect(fromString.get('gcr')?.name).toBe('Chipped Ruby')
const fromBytes = parseGemsTable(new TextEncoder().encode(tsv))
expect(fromBytes.length).toBe(1)
expect(fromBytes.get('gcr')?.name).toBe('Chipped Ruby')
})
it('supports case-insensitive code and name lookup, iteration, and indexing', () => {
const mockTable = createMinimalMockGemsTable()
const table = parseGemsTable(mockTable)
expect(table.get('GCV')?.name).toBe('Chipped Amethyst')
expect(table.get('chipped amethyst')?.code).toBe('gcv')
expect(table.byCode.get('R01')?.name).toBe('El Rune')
expect(table.byName.get('EL RUNE')?.code).toBe('r01')
const codes = [...table].map(g => g.code)
expect(codes).toEqual(['gcv', 'r01'])
expect(table[0]?.code).toBe('gcv')
expect(table[1]?.code).toBe('r01')
expect(table[2]).toBeUndefined()
})
})
describe('Socket Modifier Dispatch (getGemModifiers)', () => {
const mockTable = createMinimalMockGemsTable()
const gemTable = parseGemsTable(mockTable)
const mockIsA = (child: string, parent: string): boolean => {
if (child === 'swor' && parent === 'weap') return true
if (child === 'axe' && parent === 'weap') return true
if (child === 'phlm' && parent === 'helm') return true
if (child === 'circ' && parent === 'helm') return true
if (child === 'ashd' && parent === 'shld') return true
if (child === 'head' && parent === 'shld') return true
return false
}
it('dispatches weaponMod1..3 for weapons', () => {
const sworMods = getGemModifiers('gcv', 'swor', mockIsA, gemTable)
expect(sworMods).toHaveLength(1)
expect(sworMods[0]?.code).toBe('att')
expect(sworMods[0]?.min).toBe(40)
expect(sworMods[0]?.max).toBe(40)
const swordLiteralMods = getGemModifiers('gcv', 'sword', mockIsA, gemTable)
expect(swordLiteralMods).toEqual(sworMods)
const axeMods = getGemModifiers('gcv', 'axe', mockIsA, gemTable)
expect(axeMods).toEqual(sworMods)
})
it('dispatches helmMod1..3 for helmets AND body armor (tors)', () => {
// Direct helm
const helmMods = getGemModifiers('gcv', 'helm', mockIsA, gemTable)
expect(helmMods).toHaveLength(1)
expect(helmMods[0]?.code).toBe('str')
expect(helmMods[0]?.min).toBe(3)
expect(helmMods[0]?.max).toBe(3)
// Primal helm (barbarian)
const phlmMods = getGemModifiers('gcv', 'phlm', mockIsA, gemTable)
expect(phlmMods).toEqual(helmMods)
// CRITICAL: Body armor ('tors') uses helm modifiers!
const torsoMods = getGemModifiers('gcv', 'tors', mockIsA, gemTable)
expect(torsoMods).toHaveLength(1)
expect(torsoMods[0]?.code).toBe('str')
expect(torsoMods[0]?.min).toBe(3)
expect(torsoMods[0]?.max).toBe(3)
// Literal 'torso'
const torsoLiteralMods = getGemModifiers('gcv', 'torso', mockIsA, gemTable)
expect(torsoLiteralMods).toEqual(helmMods)
})
it('dispatches shieldMod1..3 for shields', () => {
const shieldMods = getGemModifiers('gcv', 'shie', mockIsA, gemTable)
expect(shieldMods).toHaveLength(1)
expect(shieldMods[0]?.code).toBe('ac')
expect(shieldMods[0]?.min).toBe(8)
expect(shieldMods[0]?.max).toBe(8)
const shldMods = getGemModifiers('gcv', 'shld', mockIsA, gemTable)
expect(shldMods).toEqual(shieldMods)
const ashdMods = getGemModifiers('gcv', 'ashd', mockIsA, gemTable)
expect(ashdMods).toEqual(shieldMods)
const headMods = getGemModifiers('gcv', 'head', mockIsA, gemTable)
expect(headMods).toEqual(shieldMods)
})
it('verifies Chipped Amethyst (gcv): att 40 on sword, str 3 on helm, str 3 on torso, ac 8 on shield', () => {
const sword = getGemModifiers('gcv', 'swor', mockIsA, gemTable)
expect(sword).toEqual([
expect.objectContaining({ code: 'att', min: 40, max: 40 }),
])
const helm = getGemModifiers('gcv', 'helm', mockIsA, gemTable)
expect(helm).toEqual([
expect.objectContaining({ code: 'str', min: 3, max: 3 }),
])
const torso = getGemModifiers('gcv', 'tors', mockIsA, gemTable)
expect(torso).toEqual([
expect.objectContaining({ code: 'str', min: 3, max: 3 }),
])
const shield = getGemModifiers('gcv', 'shie', mockIsA, gemTable)
expect(shield).toEqual([
expect.objectContaining({ code: 'ac', min: 8, max: 8 }),
])
})
it('returns empty array for non-socketable item types or unknown gems', () => {
expect(getGemModifiers('gcv', 'boot', mockIsA, gemTable)).toEqual([])
expect(getGemModifiers('gcv', 'glov', mockIsA, gemTable)).toEqual([])
expect(getGemModifiers('gcv', 'belt', mockIsA, gemTable)).toEqual([])
expect(getGemModifiers('gcv', 'ring', mockIsA, gemTable)).toEqual([])
expect(getGemModifiers('unknown', 'swor', mockIsA, gemTable)).toEqual([])
})
})
describe('Max Sockets Calculation (getMaxSockets)', () => {
const swordType: ItemTypeDefinition = {
code: 'swor',
name: 'Sword',
equiv1: 'weap',
equiv2: '',
treasureClass: 0,
rarity: 0,
maxSock1: 3,
maxSock25: 4,
maxSock40: 6,
magic: false,
rare: false,
normal: false,
charm: false,
gem: false,
staffMods: '',
body: true,
bodyLoc1: 'rarm',
bodyLoc2: 'larm',
beltable: false,
class: '',
}
const helmType: ItemTypeDefinition = {
code: 'helm',
name: 'Helm',
equiv1: 'armo',
equiv2: '',
treasureClass: 0,
rarity: 0,
maxSock1: 2,
maxSock25: 2,
maxSock40: 3,
magic: false,
rare: false,
normal: false,
charm: false,
gem: false,
staffMods: '',
body: true,
bodyLoc1: 'head',
bodyLoc2: '',
beltable: false,
class: '',
}
it('calculates max sockets correctly across ilvl tiers (ilvl 24, 25, 39, 40)', () => {
// Crystal Sword (base gemsockets = 6)
expect(getMaxSockets(6, swordType, 1)).toBe(3)
expect(getMaxSockets(6, swordType, 24)).toBe(3)
expect(getMaxSockets(6, swordType, 25)).toBe(4)
expect(getMaxSockets(6, swordType, 39)).toBe(4)
expect(getMaxSockets(6, swordType, 40)).toBe(6)
expect(getMaxSockets(6, swordType, 85)).toBe(6)
// Crown (base gemsockets = 3)
expect(getMaxSockets(3, helmType, 1)).toBe(2)
expect(getMaxSockets(3, helmType, 24)).toBe(2)
expect(getMaxSockets(3, helmType, 25)).toBe(2)
expect(getMaxSockets(3, helmType, 39)).toBe(2)
expect(getMaxSockets(3, helmType, 40)).toBe(3)
})
it('correctly caps allowed sockets by base item gemsockets', () => {
// Short Sword (base gemsockets = 2)
expect(getMaxSockets(2, swordType, 24)).toBe(2)
expect(getMaxSockets(2, swordType, 25)).toBe(2)
expect(getMaxSockets(2, swordType, 40)).toBe(2)
// Cap (base gemsockets = 2, helm maxSock40 = 3)
expect(getMaxSockets(2, helmType, 40)).toBe(2)
})
it('returns 0 for base gemsockets <= 0', () => {
expect(getMaxSockets(0, swordType, 50)).toBe(0)
expect(getMaxSockets(-1, swordType, 50)).toBe(0)
})
})
describe('Integration with samples/d2 MPQs', () => {
let rawTable: D2Table
let gemTable: GemTable
let itemTypes: ItemTypeTable
beforeAll(async () => {
if (!hasD2) return
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 bytes = await archives.read(GEMS_TABLE_PATH)
rawTable = parseTable(bytes)
gemTable = await loadGems(archives)
itemTypes = await loadItemTypes(archives)
})
it('verifies GEMS_TABLE_PATH is data\\global\\excel\\Gems.txt', () => {
expect(GEMS_TABLE_PATH).toBe('data\\global\\excel\\Gems.txt')
})
it('parses exactly 68 rows from 69 raw rows in 1.13c Gems.txt', () => {
if (!hasD2) return
expect(rawTable.rows.length).toBe(69)
expect(rawTable.header.length).toBe(41)
// Expansion separator row at index 35
const expansionRow = rawTable.rows[35]!
expect(cell(rawTable, expansionRow, 'name')).toBe('Expansion')
expect(cell(rawTable, expansionRow, 'code')).toBe('')
// Exactly 68 valid rows parsed
expect(gemTable.length).toBe(68)
expect(gemTable.all.length).toBe(68)
expect(gemTable.byCode.size).toBe(68)
})
it('verifies exactly 35 gems and 33 runes', () => {
if (!hasD2) return
expect(gemTable.gems.length).toBe(35)
expect(gemTable.runes.length).toBe(33)
expect(gemTable.gems.every(g => g.isGem && !g.isRune)).toBe(true)
expect(gemTable.runes.every(r => r.isRune && !r.isGem)).toBe(true)
// First gem is Chipped Amethyst (gcv), last gem is Perfect Skull (skz)
expect(gemTable.gems[0]?.code).toBe('gcv')
expect(gemTable.gems[0]?.name).toBe('Chipped Amethyst')
expect(gemTable.gems[34]?.code).toBe('skz')
expect(gemTable.gems[34]?.name).toBe('Perfect Skull')
// First rune is El Rune (r01), last rune is Zod Rune (r33)
expect(gemTable.runes[0]?.code).toBe('r01')
expect(gemTable.runes[0]?.name).toBe('El Rune')
expect(gemTable.runes[32]?.code).toBe('r33')
expect(gemTable.runes[32]?.name).toBe('Zod Rune')
})
it('verifies Chipped Amethyst (gcv) modifier values against MPQ data', () => {
if (!hasD2) return
const gcv = gemTable.get('gcv')!
expect(gcv).toBeDefined()
expect(gcv.name).toBe('Chipped Amethyst')
// Weapon: att 40
expect(gcv.weaponMods).toEqual([
expect.objectContaining({ code: 'att', min: 40, max: 40 }),
])
// Helm: str 3
expect(gcv.helmMods).toEqual([
expect.objectContaining({ code: 'str', min: 3, max: 3 }),
])
// Shield: ac 8
expect(gcv.shieldMods).toEqual([
expect.objectContaining({ code: 'ac', min: 8, max: 8 }),
])
})
it('verifies integration modifier dispatch using real ItemTypes isA', () => {
if (!hasD2) return
const isAPred = (type: string, target: string) => isA(type, target, itemTypes)
// Sword -> weaponMods
const swordMods = getGemModifiers('gcv', 'swor', isAPred, gemTable)
expect(swordMods).toEqual([
expect.objectContaining({ code: 'att', min: 40, max: 40 }),
])
// Helm -> helmMods
const helmMods = getGemModifiers('gcv', 'helm', isAPred, gemTable)
expect(helmMods).toEqual([
expect.objectContaining({ code: 'str', min: 3, max: 3 }),
])
// Torso -> helmMods (CRITICAL: body armor gets helm mods)
const torsoMods = getGemModifiers('gcv', 'tors', isAPred, gemTable)
expect(torsoMods).toEqual([
expect.objectContaining({ code: 'str', min: 3, max: 3 }),
])
// Shield -> shieldMods
const shieldMods = getGemModifiers('gcv', 'shie', isAPred, gemTable)
expect(shieldMods).toEqual([
expect.objectContaining({ code: 'ac', min: 8, max: 8 }),
])
})
it('verifies runes with duration params (Tal Rune r07, Thul Rune r10)', () => {
if (!hasD2) return
const isAPred = (type: string, target: string) => isA(type, target, itemTypes)
// Tal Rune: poison length 125 frames (5s) on weapon
const talWeapon = getGemModifiers('r07', 'swor', isAPred, gemTable)
expect(talWeapon).toEqual([
expect.objectContaining({ code: 'dmg-pois', param: 125, min: 154, max: 154 }),
])
// Thul Rune: cold length 75 frames (3s) on weapon
const thulWeapon = getGemModifiers('r10', 'swor', isAPred, gemTable)
expect(thulWeapon).toEqual([
expect.objectContaining({ code: 'dmg-cold', param: 75, min: 3, max: 14 }),
])
// Tal Rune on shield: res-pois 35
const talShield = getGemModifiers('r07', 'shie', isAPred, gemTable)
expect(talShield).toEqual([
expect.objectContaining({ code: 'res-pois', min: 35, max: 35 }),
])
})
it('verifies real ItemTypes socket tiers with getMaxSockets', () => {
if (!hasD2) return
const sworDef = itemTypes.byCode.get('swor')!
expect(sworDef).toBeDefined()
expect(sworDef.maxSock1).toBe(3)
expect(sworDef.maxSock25).toBe(4)
expect(sworDef.maxSock40).toBe(6)
// Crystal sword (base gemsockets = 6)
expect(getMaxSockets(6, sworDef, 24)).toBe(3)
expect(getMaxSockets(6, sworDef, 25)).toBe(4)
expect(getMaxSockets(6, sworDef, 39)).toBe(4)
expect(getMaxSockets(6, sworDef, 40)).toBe(6)
const helmDef = itemTypes.byCode.get('helm')!
expect(helmDef).toBeDefined()
expect(helmDef.maxSock1).toBe(2)
expect(helmDef.maxSock25).toBe(2)
expect(helmDef.maxSock40).toBe(3)
// Crown (base gemsockets = 3)
expect(getMaxSockets(3, helmDef, 24)).toBe(2)
expect(getMaxSockets(3, helmDef, 25)).toBe(2)
expect(getMaxSockets(3, helmDef, 39)).toBe(2)
expect(getMaxSockets(3, helmDef, 40)).toBe(3)
})
})
})