Compare commits
6 Commits
6754df22eb
...
8b61ed059f
| Author | SHA1 | Date |
|---|---|---|
|
|
8b61ed059f | |
|
|
57322cabad | |
|
|
27288156a8 | |
|
|
aff10832c3 | |
|
|
390816c4cc | |
|
|
ce07af1b9a |
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue