Compare commits
No commits in common. "ea6913950efd15f5f711493db9720c9277a5d042" and "0cd799cdcb59e01c471d34c74ab49a91de1cdfdb" have entirely different histories.
ea6913950e
...
0cd799cdcb
|
|
@ -457,20 +457,6 @@ export function rollRareAffixes(
|
||||||
if (eligibleRareSuffixes.length > 0) {
|
if (eligibleRareSuffixes.length > 0) {
|
||||||
const idx = rng.rand(eligibleRareSuffixes.length)
|
const idx = rng.rand(eligibleRareSuffixes.length)
|
||||||
rareSuffix = eligibleRareSuffixes[idx]
|
rareSuffix = eligibleRareSuffixes[idx]
|
||||||
if (
|
|
||||||
eligibleRareSuffixes.length > 1 &&
|
|
||||||
rarePrefix &&
|
|
||||||
rareSuffix &&
|
|
||||||
rareSuffix.name.trim().toLowerCase() === rarePrefix.name.trim().toLowerCase()
|
|
||||||
) {
|
|
||||||
for (let offset = 1; offset < eligibleRareSuffixes.length; offset++) {
|
|
||||||
const candidate = eligibleRareSuffixes[(idx + offset) % eligibleRareSuffixes.length]
|
|
||||||
if (candidate && candidate.name.trim().toLowerCase() !== rarePrefix.name.trim().toLowerCase()) {
|
|
||||||
rareSuffix = candidate
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const rareNameParts = [rarePrefix?.name, rareSuffix?.name].filter(
|
const rareNameParts = [rarePrefix?.name, rareSuffix?.name].filter(
|
||||||
|
|
|
||||||
|
|
@ -133,12 +133,6 @@ import {
|
||||||
ITEM_TYPE_STAFF_MODS_MAP,
|
ITEM_TYPE_STAFF_MODS_MAP,
|
||||||
STAFF_MOD_CLASS_SKILL_START,
|
STAFF_MOD_CLASS_SKILL_START,
|
||||||
} from './automagic.ts'
|
} from './automagic.ts'
|
||||||
import {
|
|
||||||
loadItemPropertiesAndStats,
|
|
||||||
type PropertiesTable,
|
|
||||||
type ItemStatCostTable,
|
|
||||||
} from './properties.ts'
|
|
||||||
import { setRuntimePropertyTables } from './item-tooltip.ts'
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
rollAutoMagic,
|
rollAutoMagic,
|
||||||
|
|
@ -168,8 +162,6 @@ export interface DropTables {
|
||||||
readonly magicAffixes: { readonly prefixes: AffixTable; readonly suffixes: AffixTable }
|
readonly magicAffixes: { readonly prefixes: AffixTable; readonly suffixes: AffixTable }
|
||||||
readonly rareNames: { readonly prefixes: RareNameTable; readonly suffixes: RareNameTable }
|
readonly rareNames: { readonly prefixes: RareNameTable; readonly suffixes: RareNameTable }
|
||||||
readonly autoMagic?: AutoMagicTable | undefined
|
readonly autoMagic?: AutoMagicTable | undefined
|
||||||
readonly properties?: PropertiesTable | undefined
|
|
||||||
readonly itemStatCost?: ItemStatCostTable | undefined
|
|
||||||
readonly itemTypes: ItemTypeTable
|
readonly itemTypes: ItemTypeTable
|
||||||
readonly monsterKinds: Map<string, MonsterKind>
|
readonly monsterKinds: Map<string, MonsterKind>
|
||||||
readonly superUniques: Map<string, SuperUnique>
|
readonly superUniques: Map<string, SuperUnique>
|
||||||
|
|
@ -198,7 +190,6 @@ export async function loadDropTables(archives: MountedArchives): Promise<DropTab
|
||||||
rareNames,
|
rareNames,
|
||||||
itemTypes,
|
itemTypes,
|
||||||
monstatsBytes,
|
monstatsBytes,
|
||||||
propAndStats,
|
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
loadTreasureClasses(archives),
|
loadTreasureClasses(archives),
|
||||||
loadWeapons(archives),
|
loadWeapons(archives),
|
||||||
|
|
@ -212,12 +203,8 @@ export async function loadDropTables(archives: MountedArchives): Promise<DropTab
|
||||||
loadRareNames(archives),
|
loadRareNames(archives),
|
||||||
loadItemTypes(archives),
|
loadItemTypes(archives),
|
||||||
archives.read('data\\global\\excel\\monstats.txt'),
|
archives.read('data\\global\\excel\\monstats.txt'),
|
||||||
loadItemPropertiesAndStats(archives),
|
|
||||||
])
|
])
|
||||||
|
|
||||||
const { properties, itemStatCost } = propAndStats
|
|
||||||
setRuntimePropertyTables(properties, itemStatCost)
|
|
||||||
|
|
||||||
let autoMagic: AutoMagicTable = CANONICAL_AUTOMAGIC_TABLE
|
let autoMagic: AutoMagicTable = CANONICAL_AUTOMAGIC_TABLE
|
||||||
try {
|
try {
|
||||||
autoMagic = await loadAutoMagic(archives)
|
autoMagic = await loadAutoMagic(archives)
|
||||||
|
|
@ -264,8 +251,6 @@ export async function loadDropTables(archives: MountedArchives): Promise<DropTab
|
||||||
magicAffixes,
|
magicAffixes,
|
||||||
rareNames,
|
rareNames,
|
||||||
autoMagic,
|
autoMagic,
|
||||||
properties,
|
|
||||||
itemStatCost,
|
|
||||||
itemTypes,
|
itemTypes,
|
||||||
monsterKinds,
|
monsterKinds,
|
||||||
superUniques,
|
superUniques,
|
||||||
|
|
@ -863,15 +848,8 @@ export function executeDropPipeline(
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dropTables.properties && dropTables.itemStatCost) {
|
|
||||||
setRuntimePropertyTables(dropTables.properties, dropTables.itemStatCost)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A8: Clamp effective ilvl to [1, 99] per 1.13c D2Game!6FC32D60
|
|
||||||
const effectiveIlvl = Math.max(1, Math.min(99, Math.trunc(nLevel)))
|
|
||||||
|
|
||||||
// 1. Top-level TC group upgrade (D2Game!6FC32D60)
|
// 1. Top-level TC group upgrade (D2Game!6FC32D60)
|
||||||
const resolvedNode = resolveTreasureClassGroup(dropTables.tcTable, tcName, effectiveIlvl)
|
const resolvedNode = resolveTreasureClassGroup(dropTables.tcTable, tcName, nLevel)
|
||||||
const effectiveTcName = resolvedNode?.name ?? tcName
|
const effectiveTcName = resolvedNode?.name ?? tcName
|
||||||
|
|
||||||
// 2. Setup RNG
|
// 2. Setup RNG
|
||||||
|
|
@ -924,10 +902,8 @@ export function executeDropPipeline(
|
||||||
// 5. Process each dropped leaf
|
// 5. Process each dropped leaf
|
||||||
for (const leaf of dropLeaves) {
|
for (const leaf of dropLeaves) {
|
||||||
if (leaf.isGold) {
|
if (leaf.isGold) {
|
||||||
// A6: 1.13c D2Game!6FC319D0 gold drop range [floor(clampedLvl / 2), clampedLvl * 6] * multiplier
|
|
||||||
const multiplier = leaf.goldMultiplier ?? 1
|
const multiplier = leaf.goldMultiplier ?? 1
|
||||||
const clampedLvl = effectiveIlvl
|
const baseGold = rng.randRange(Math.max(1, Math.trunc(nLevel / 2)), Math.max(5, nLevel * 5))
|
||||||
const baseGold = rng.randRange(Math.max(1, Math.trunc(clampedLvl / 2)), Math.max(6, clampedLvl * 6))
|
|
||||||
const goldAmount = Math.max(1, baseGold * multiplier)
|
const goldAmount = Math.max(1, baseGold * multiplier)
|
||||||
drops.push(goldItem(goldAmount))
|
drops.push(goldItem(goldAmount))
|
||||||
continue
|
continue
|
||||||
|
|
@ -968,8 +944,8 @@ export function executeDropPipeline(
|
||||||
quality = 'set'
|
quality = 'set'
|
||||||
} else {
|
} else {
|
||||||
quality = rollItemQuality({
|
quality = rollItemQuality({
|
||||||
ilvl: effectiveIlvl,
|
ilvl: nLevel,
|
||||||
qlvl: Math.min(effectiveIlvl, effectiveBase.level ?? 1),
|
qlvl: effectiveBase.level ?? 1,
|
||||||
magicFind: playerMf,
|
magicFind: playerMf,
|
||||||
tcFactors: {
|
tcFactors: {
|
||||||
unique: leaf.qualityFactors[0],
|
unique: leaf.qualityFactors[0],
|
||||||
|
|
@ -996,7 +972,7 @@ export function executeDropPipeline(
|
||||||
|
|
||||||
// Quality branches & fallbacks (D2 1.13c Ground Truth)
|
// Quality branches & fallbacks (D2 1.13c Ground Truth)
|
||||||
if (quality === 'unique') {
|
if (quality === 'unique') {
|
||||||
const uniqueCandidates = dropTables.uniques.getEnabledByCode(effectiveBase.id).filter(u => u.lvl <= effectiveIlvl)
|
const uniqueCandidates = dropTables.uniques.getEnabledByCode(effectiveBase.id).filter(u => u.lvl <= nLevel)
|
||||||
if (uniqueCandidates.length > 0) {
|
if (uniqueCandidates.length > 0) {
|
||||||
let chosenUnique = uniqueCandidates[0]!
|
let chosenUnique = uniqueCandidates[0]!
|
||||||
if (uniqueCandidates.length > 1) {
|
if (uniqueCandidates.length > 1) {
|
||||||
|
|
@ -1011,7 +987,7 @@ export function executeDropPipeline(
|
||||||
roll -= weight
|
roll -= weight
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const ethAndSock = rollItemEtherealAndSockets(effectiveBase, 'unique', effectiveIlvl, itemRng, {
|
const ethAndSock = rollItemEtherealAndSockets(effectiveBase, 'unique', nLevel, itemRng, {
|
||||||
uniqueItem: chosenUnique,
|
uniqueItem: chosenUnique,
|
||||||
itemTypes: dropTables.itemTypes,
|
itemTypes: dropTables.itemTypes,
|
||||||
})
|
})
|
||||||
|
|
@ -1020,7 +996,7 @@ export function executeDropPipeline(
|
||||||
ethereal: ethAndSock.ethereal,
|
ethereal: ethAndSock.ethereal,
|
||||||
sockets: ethAndSock.sockets,
|
sockets: ethAndSock.sockets,
|
||||||
itemTypes: dropTables.itemTypes,
|
itemTypes: dropTables.itemTypes,
|
||||||
ilvl: effectiveIlvl,
|
ilvl: nLevel,
|
||||||
dwInitSeed,
|
dwInitSeed,
|
||||||
itemRng,
|
itemRng,
|
||||||
}))
|
}))
|
||||||
|
|
@ -1041,7 +1017,7 @@ export function executeDropPipeline(
|
||||||
|
|
||||||
if (quality === 'set') {
|
if (quality === 'set') {
|
||||||
const setCandidates = dropTables.sets.items.entries.filter(
|
const setCandidates = dropTables.sets.items.entries.filter(
|
||||||
s => s.item.toLowerCase() === effectiveBase.id.toLowerCase() && s.lvl <= effectiveIlvl,
|
s => s.item.toLowerCase() === effectiveBase.id.toLowerCase() && s.lvl <= nLevel,
|
||||||
)
|
)
|
||||||
if (setCandidates.length > 0) {
|
if (setCandidates.length > 0) {
|
||||||
let chosenSet = setCandidates[0]!
|
let chosenSet = setCandidates[0]!
|
||||||
|
|
@ -1062,7 +1038,7 @@ export function executeDropPipeline(
|
||||||
setItem: chosenSet,
|
setItem: chosenSet,
|
||||||
ethereal: false,
|
ethereal: false,
|
||||||
itemTypes: dropTables.itemTypes,
|
itemTypes: dropTables.itemTypes,
|
||||||
ilvl: effectiveIlvl,
|
ilvl: nLevel,
|
||||||
dwInitSeed,
|
dwInitSeed,
|
||||||
itemRng,
|
itemRng,
|
||||||
}))
|
}))
|
||||||
|
|
@ -1082,7 +1058,7 @@ export function executeDropPipeline(
|
||||||
} else {
|
} else {
|
||||||
const rareAffixes = rollRareAffixes(
|
const rareAffixes = rollRareAffixes(
|
||||||
effectiveBase,
|
effectiveBase,
|
||||||
effectiveIlvl,
|
nLevel,
|
||||||
dropTables.magicAffixes,
|
dropTables.magicAffixes,
|
||||||
dropTables.rareNames,
|
dropTables.rareNames,
|
||||||
dropTables.isA,
|
dropTables.isA,
|
||||||
|
|
@ -1095,7 +1071,7 @@ export function executeDropPipeline(
|
||||||
autoPrefixGroup !== undefined
|
autoPrefixGroup !== undefined
|
||||||
? rollAutoMagic(
|
? rollAutoMagic(
|
||||||
effectiveBase,
|
effectiveBase,
|
||||||
effectiveIlvl,
|
nLevel,
|
||||||
dropTables.autoMagic ?? CANONICAL_AUTOMAGIC_TABLE,
|
dropTables.autoMagic ?? CANONICAL_AUTOMAGIC_TABLE,
|
||||||
dropTables.isA,
|
dropTables.isA,
|
||||||
itemRng,
|
itemRng,
|
||||||
|
|
@ -1104,9 +1080,9 @@ export function executeDropPipeline(
|
||||||
: null
|
: null
|
||||||
const staffMods =
|
const staffMods =
|
||||||
resolveStaffModsClass(effectiveBase, dropTables.itemTypes) !== undefined
|
resolveStaffModsClass(effectiveBase, dropTables.itemTypes) !== undefined
|
||||||
? rollStaffMods(effectiveBase, effectiveIlvl, dropTables.itemTypes, itemRng)
|
? rollStaffMods(effectiveBase, nLevel, dropTables.itemTypes, itemRng)
|
||||||
: undefined
|
: undefined
|
||||||
const ethAndSock = rollItemEtherealAndSockets(effectiveBase, 'rare', effectiveIlvl, itemRng, {
|
const ethAndSock = rollItemEtherealAndSockets(effectiveBase, 'rare', nLevel, itemRng, {
|
||||||
itemTypes: dropTables.itemTypes,
|
itemTypes: dropTables.itemTypes,
|
||||||
})
|
})
|
||||||
drops.push(createDroppedItem(effectiveBase, 'rare', {
|
drops.push(createDroppedItem(effectiveBase, 'rare', {
|
||||||
|
|
@ -1116,7 +1092,7 @@ export function executeDropPipeline(
|
||||||
ethereal: ethAndSock.ethereal,
|
ethereal: ethAndSock.ethereal,
|
||||||
sockets: ethAndSock.sockets,
|
sockets: ethAndSock.sockets,
|
||||||
itemTypes: dropTables.itemTypes,
|
itemTypes: dropTables.itemTypes,
|
||||||
ilvl: effectiveIlvl,
|
ilvl: nLevel,
|
||||||
dwInitSeed,
|
dwInitSeed,
|
||||||
durabilityMultiplier,
|
durabilityMultiplier,
|
||||||
itemRng,
|
itemRng,
|
||||||
|
|
@ -1132,7 +1108,7 @@ export function executeDropPipeline(
|
||||||
if (quality === 'magic') {
|
if (quality === 'magic') {
|
||||||
const magicAffixes = rollMagicAffixes(
|
const magicAffixes = rollMagicAffixes(
|
||||||
effectiveBase,
|
effectiveBase,
|
||||||
effectiveIlvl,
|
nLevel,
|
||||||
dropTables.magicAffixes,
|
dropTables.magicAffixes,
|
||||||
dropTables.isA,
|
dropTables.isA,
|
||||||
itemRng,
|
itemRng,
|
||||||
|
|
@ -1143,7 +1119,7 @@ export function executeDropPipeline(
|
||||||
autoPrefixGroup !== undefined
|
autoPrefixGroup !== undefined
|
||||||
? rollAutoMagic(
|
? rollAutoMagic(
|
||||||
effectiveBase,
|
effectiveBase,
|
||||||
effectiveIlvl,
|
nLevel,
|
||||||
dropTables.autoMagic ?? CANONICAL_AUTOMAGIC_TABLE,
|
dropTables.autoMagic ?? CANONICAL_AUTOMAGIC_TABLE,
|
||||||
dropTables.isA,
|
dropTables.isA,
|
||||||
itemRng,
|
itemRng,
|
||||||
|
|
@ -1152,9 +1128,9 @@ export function executeDropPipeline(
|
||||||
: null
|
: null
|
||||||
const staffMods =
|
const staffMods =
|
||||||
resolveStaffModsClass(effectiveBase, dropTables.itemTypes) !== undefined
|
resolveStaffModsClass(effectiveBase, dropTables.itemTypes) !== undefined
|
||||||
? rollStaffMods(effectiveBase, effectiveIlvl, dropTables.itemTypes, itemRng)
|
? rollStaffMods(effectiveBase, nLevel, dropTables.itemTypes, itemRng)
|
||||||
: undefined
|
: undefined
|
||||||
const ethAndSock = rollItemEtherealAndSockets(effectiveBase, 'magic', effectiveIlvl, itemRng, {
|
const ethAndSock = rollItemEtherealAndSockets(effectiveBase, 'magic', nLevel, itemRng, {
|
||||||
itemTypes: dropTables.itemTypes,
|
itemTypes: dropTables.itemTypes,
|
||||||
})
|
})
|
||||||
drops.push(createDroppedItem(effectiveBase, 'magic', {
|
drops.push(createDroppedItem(effectiveBase, 'magic', {
|
||||||
|
|
@ -1164,7 +1140,7 @@ export function executeDropPipeline(
|
||||||
ethereal: ethAndSock.ethereal,
|
ethereal: ethAndSock.ethereal,
|
||||||
sockets: ethAndSock.sockets,
|
sockets: ethAndSock.sockets,
|
||||||
itemTypes: dropTables.itemTypes,
|
itemTypes: dropTables.itemTypes,
|
||||||
ilvl: effectiveIlvl,
|
ilvl: nLevel,
|
||||||
dwInitSeed,
|
dwInitSeed,
|
||||||
durabilityMultiplier,
|
durabilityMultiplier,
|
||||||
itemRng,
|
itemRng,
|
||||||
|
|
@ -1177,7 +1153,7 @@ export function executeDropPipeline(
|
||||||
autoPrefixGroup !== undefined
|
autoPrefixGroup !== undefined
|
||||||
? rollAutoMagic(
|
? rollAutoMagic(
|
||||||
effectiveBase,
|
effectiveBase,
|
||||||
effectiveIlvl,
|
nLevel,
|
||||||
dropTables.autoMagic ?? CANONICAL_AUTOMAGIC_TABLE,
|
dropTables.autoMagic ?? CANONICAL_AUTOMAGIC_TABLE,
|
||||||
dropTables.isA,
|
dropTables.isA,
|
||||||
itemRng,
|
itemRng,
|
||||||
|
|
@ -1186,11 +1162,11 @@ export function executeDropPipeline(
|
||||||
: null
|
: null
|
||||||
const staffMods =
|
const staffMods =
|
||||||
resolveStaffModsClass(effectiveBase, dropTables.itemTypes) !== undefined
|
resolveStaffModsClass(effectiveBase, dropTables.itemTypes) !== undefined
|
||||||
? rollStaffMods(effectiveBase, effectiveIlvl, dropTables.itemTypes, itemRng)
|
? rollStaffMods(effectiveBase, nLevel, dropTables.itemTypes, itemRng)
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
if (quality === 'superior') {
|
if (quality === 'superior') {
|
||||||
const ethAndSock = rollItemEtherealAndSockets(effectiveBase, 'superior', effectiveIlvl, itemRng, {
|
const ethAndSock = rollItemEtherealAndSockets(effectiveBase, 'superior', nLevel, itemRng, {
|
||||||
itemTypes: dropTables.itemTypes,
|
itemTypes: dropTables.itemTypes,
|
||||||
})
|
})
|
||||||
drops.push(createDroppedItem(effectiveBase, 'superior', {
|
drops.push(createDroppedItem(effectiveBase, 'superior', {
|
||||||
|
|
@ -1199,7 +1175,7 @@ export function executeDropPipeline(
|
||||||
ethereal: ethAndSock.ethereal,
|
ethereal: ethAndSock.ethereal,
|
||||||
sockets: ethAndSock.sockets,
|
sockets: ethAndSock.sockets,
|
||||||
itemTypes: dropTables.itemTypes,
|
itemTypes: dropTables.itemTypes,
|
||||||
ilvl: effectiveIlvl,
|
ilvl: nLevel,
|
||||||
dwInitSeed,
|
dwInitSeed,
|
||||||
itemRng,
|
itemRng,
|
||||||
}))
|
}))
|
||||||
|
|
@ -1207,7 +1183,7 @@ export function executeDropPipeline(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (quality === 'low') {
|
if (quality === 'low') {
|
||||||
const ethAndSock = rollItemEtherealAndSockets(effectiveBase, 'low', effectiveIlvl, itemRng, {
|
const ethAndSock = rollItemEtherealAndSockets(effectiveBase, 'low', nLevel, itemRng, {
|
||||||
itemTypes: dropTables.itemTypes,
|
itemTypes: dropTables.itemTypes,
|
||||||
})
|
})
|
||||||
drops.push(createDroppedItem(effectiveBase, 'low', {
|
drops.push(createDroppedItem(effectiveBase, 'low', {
|
||||||
|
|
@ -1216,7 +1192,7 @@ export function executeDropPipeline(
|
||||||
ethereal: ethAndSock.ethereal,
|
ethereal: ethAndSock.ethereal,
|
||||||
sockets: ethAndSock.sockets,
|
sockets: ethAndSock.sockets,
|
||||||
itemTypes: dropTables.itemTypes,
|
itemTypes: dropTables.itemTypes,
|
||||||
ilvl: effectiveIlvl,
|
ilvl: nLevel,
|
||||||
dwInitSeed,
|
dwInitSeed,
|
||||||
itemRng,
|
itemRng,
|
||||||
}))
|
}))
|
||||||
|
|
@ -1224,7 +1200,7 @@ export function executeDropPipeline(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default: normal quality
|
// Default: normal quality
|
||||||
const ethAndSock = rollItemEtherealAndSockets(effectiveBase, 'normal', effectiveIlvl, itemRng, {
|
const ethAndSock = rollItemEtherealAndSockets(effectiveBase, 'normal', nLevel, itemRng, {
|
||||||
itemTypes: dropTables.itemTypes,
|
itemTypes: dropTables.itemTypes,
|
||||||
})
|
})
|
||||||
drops.push(createDroppedItem(effectiveBase, 'normal', {
|
drops.push(createDroppedItem(effectiveBase, 'normal', {
|
||||||
|
|
@ -1233,7 +1209,7 @@ export function executeDropPipeline(
|
||||||
ethereal: ethAndSock.ethereal,
|
ethereal: ethAndSock.ethereal,
|
||||||
sockets: ethAndSock.sockets,
|
sockets: ethAndSock.sockets,
|
||||||
itemTypes: dropTables.itemTypes,
|
itemTypes: dropTables.itemTypes,
|
||||||
ilvl: effectiveIlvl,
|
ilvl: nLevel,
|
||||||
dwInitSeed,
|
dwInitSeed,
|
||||||
itemRng,
|
itemRng,
|
||||||
}))
|
}))
|
||||||
|
|
|
||||||
|
|
@ -22,13 +22,6 @@ import {
|
||||||
type RolledAffix,
|
type RolledAffix,
|
||||||
type RolledMod,
|
type RolledMod,
|
||||||
} from './affix-generator.ts'
|
} from './affix-generator.ts'
|
||||||
import {
|
|
||||||
resolveProperty,
|
|
||||||
getDefaultItemPropertiesAndStats,
|
|
||||||
type PropertiesTable,
|
|
||||||
type ItemStatCostTable,
|
|
||||||
type ItemStatCostRecord,
|
|
||||||
} from './properties.ts'
|
|
||||||
|
|
||||||
export interface FormattedStatLine {
|
export interface FormattedStatLine {
|
||||||
text: string
|
text: string
|
||||||
|
|
@ -1165,400 +1158,10 @@ const PROP_DESC_PRIORITY: Record<string, number> = {
|
||||||
bloody: 0,
|
bloody: 0,
|
||||||
fade: 0,
|
fade: 0,
|
||||||
state: 0,
|
state: 0,
|
||||||
'res-mag': 41,
|
|
||||||
'res-mag-max': 46,
|
|
||||||
'abs-mag%': 26,
|
|
||||||
kick: 121,
|
|
||||||
throw: 5,
|
|
||||||
'dmg-fire/lvl': 100,
|
|
||||||
'dmg-ltng/lvl': 97,
|
|
||||||
'dmg-cold/lvl': 94,
|
|
||||||
'dmg-pois/lvl': 90,
|
|
||||||
'att-mon%': 108,
|
|
||||||
'dmg-mon%': 106,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function getPropertyPriority(code: string): number {
|
||||||
* Canonical 1.13c ItemStatCost.txt numeric Stat ID lookup table (0..358, plus 359 for ethereal).
|
return PROP_DESC_PRIORITY[code.trim().toLowerCase()] ?? 10
|
||||||
* Used as the secondary tie-breaker when two distinct properties share the same descpriority.
|
|
||||||
*/
|
|
||||||
export const PROP_STAT_ID: Record<string, number> = {
|
|
||||||
ac: 31,
|
|
||||||
'ac-miss': 32,
|
|
||||||
'ac-hth': 33,
|
|
||||||
'red-dmg': 34,
|
|
||||||
'dmag-ac': 34,
|
|
||||||
'red-mag': 35,
|
|
||||||
'mag-ac': 35,
|
|
||||||
'red-dmg%': 36,
|
|
||||||
dmag: 36,
|
|
||||||
'ac%': 16,
|
|
||||||
str: 0,
|
|
||||||
enr: 1,
|
|
||||||
dex: 2,
|
|
||||||
vit: 3,
|
|
||||||
hp: 7,
|
|
||||||
mana: 9,
|
|
||||||
stam: 11,
|
|
||||||
'dmg%': 17,
|
|
||||||
att: 19,
|
|
||||||
block: 20,
|
|
||||||
'dmg-min': 21,
|
|
||||||
'dmg-norm': 21,
|
|
||||||
'dmg-max': 22,
|
|
||||||
'regen-mana': 27,
|
|
||||||
manaregen: 27,
|
|
||||||
'regen-stam': 28,
|
|
||||||
'res-mag': 37,
|
|
||||||
'res-mag-max': 38,
|
|
||||||
'res-fire': 39,
|
|
||||||
fireresist: 39,
|
|
||||||
'res-all': 39,
|
|
||||||
'all-res': 39,
|
|
||||||
'res-fire-max': 40,
|
|
||||||
'res-all-max': 40,
|
|
||||||
'res-ltng': 41,
|
|
||||||
lightresist: 41,
|
|
||||||
'res-ltng-max': 42,
|
|
||||||
'res-cold': 43,
|
|
||||||
coldresist: 43,
|
|
||||||
'res-cold-max': 44,
|
|
||||||
'res-pois': 45,
|
|
||||||
poisonresist: 45,
|
|
||||||
'res-pois-max': 46,
|
|
||||||
'fire-min': 48,
|
|
||||||
'dmg-fire': 48,
|
|
||||||
'dmg-elem': 48,
|
|
||||||
'dmg-elem-min': 48,
|
|
||||||
'fire-max': 49,
|
|
||||||
'dmg-elem-max': 49,
|
|
||||||
'ltng-min': 50,
|
|
||||||
'dmg-ltng': 50,
|
|
||||||
'ltng-max': 51,
|
|
||||||
'dmg-mag': 52,
|
|
||||||
'cold-min': 54,
|
|
||||||
'dmg-cold': 54,
|
|
||||||
'cold-max': 55,
|
|
||||||
'cold-len': 56,
|
|
||||||
'pois-min': 57,
|
|
||||||
'dmg-pois': 57,
|
|
||||||
'pois-max': 58,
|
|
||||||
'pois-len': 59,
|
|
||||||
lifesteal: 60,
|
|
||||||
leech: 60,
|
|
||||||
manasteal: 62,
|
|
||||||
dur: 73,
|
|
||||||
regen: 74,
|
|
||||||
'rep-life': 74,
|
|
||||||
'dur%': 75,
|
|
||||||
'hp%': 76,
|
|
||||||
'mana%': 77,
|
|
||||||
thorns: 78,
|
|
||||||
'gold%': 79,
|
|
||||||
'mag%': 80,
|
|
||||||
knock: 81,
|
|
||||||
time: 82,
|
|
||||||
ama: 83,
|
|
||||||
pal: 83,
|
|
||||||
nec: 83,
|
|
||||||
sor: 83,
|
|
||||||
bar: 83,
|
|
||||||
dru: 83,
|
|
||||||
ass: 83,
|
|
||||||
randclassskill: 83,
|
|
||||||
classskills: 83,
|
|
||||||
addxp: 85,
|
|
||||||
'heal-kill': 86,
|
|
||||||
'hp-kill': 86,
|
|
||||||
cheap: 87,
|
|
||||||
herb: 88,
|
|
||||||
light: 89,
|
|
||||||
color: 90,
|
|
||||||
ease: 91,
|
|
||||||
levelreq: 92,
|
|
||||||
ias: 93,
|
|
||||||
swing1: 93,
|
|
||||||
swing2: 93,
|
|
||||||
swing3: 93,
|
|
||||||
frw: 96,
|
|
||||||
move1: 96,
|
|
||||||
move2: 96,
|
|
||||||
move3: 96,
|
|
||||||
oskill: 97,
|
|
||||||
state: 98,
|
|
||||||
fhr: 99,
|
|
||||||
balance1: 99,
|
|
||||||
balance2: 99,
|
|
||||||
balance3: 99,
|
|
||||||
fbr: 102,
|
|
||||||
block1: 102,
|
|
||||||
block2: 102,
|
|
||||||
block3: 102,
|
|
||||||
fcr: 105,
|
|
||||||
cast1: 105,
|
|
||||||
cast2: 105,
|
|
||||||
cast3: 105,
|
|
||||||
skill: 107,
|
|
||||||
'skill-rand': 107,
|
|
||||||
rip: 108,
|
|
||||||
'res-pois-len': 110,
|
|
||||||
dmg: 111,
|
|
||||||
howl: 112,
|
|
||||||
stupidity: 113,
|
|
||||||
blind: 113,
|
|
||||||
'dmg-to-mana': 114,
|
|
||||||
'ignore-ac': 115,
|
|
||||||
'reduce-ac': 116,
|
|
||||||
noheal: 117,
|
|
||||||
'no-heal': 117,
|
|
||||||
'half-freeze': 118,
|
|
||||||
'att%': 119,
|
|
||||||
'dmg-ac': 120,
|
|
||||||
'dmg-demon': 121,
|
|
||||||
'dmg-undead': 122,
|
|
||||||
'att-demon': 123,
|
|
||||||
'att-undead': 124,
|
|
||||||
throw: 125,
|
|
||||||
fireskill: 126,
|
|
||||||
allskills: 127,
|
|
||||||
'light-thorns': 128,
|
|
||||||
freeze: 134,
|
|
||||||
openwounds: 135,
|
|
||||||
crush: 136,
|
|
||||||
kick: 137,
|
|
||||||
'mana-kill': 138,
|
|
||||||
'demon-heal': 139,
|
|
||||||
bloody: 140,
|
|
||||||
deadly: 141,
|
|
||||||
'abs-fire%': 142,
|
|
||||||
'abs-fire': 143,
|
|
||||||
'abs-ltng%': 144,
|
|
||||||
'abs-ltng': 145,
|
|
||||||
'abs-mag%': 146,
|
|
||||||
'abs-mag': 147,
|
|
||||||
'abs-cold%': 148,
|
|
||||||
'abs-cold': 149,
|
|
||||||
slow: 150,
|
|
||||||
aura: 151,
|
|
||||||
indestruct: 152,
|
|
||||||
nofreeze: 153,
|
|
||||||
'no-freeze': 153,
|
|
||||||
stamdrain: 154,
|
|
||||||
reanimate: 155,
|
|
||||||
pierce: 156,
|
|
||||||
magicarrow: 157,
|
|
||||||
explosivearrow: 158,
|
|
||||||
'dmg-throw': 159,
|
|
||||||
'att-mon%': 179,
|
|
||||||
'dmg-mon%': 180,
|
|
||||||
fade: 181,
|
|
||||||
skilltab: 188,
|
|
||||||
sock: 194,
|
|
||||||
'att-skill': 195,
|
|
||||||
'kill-skill': 196,
|
|
||||||
'death-skill': 197,
|
|
||||||
'hit-skill': 198,
|
|
||||||
'levelup-skill': 199,
|
|
||||||
'gethit-skill': 201,
|
|
||||||
charged: 204,
|
|
||||||
'ac/lvl': 214,
|
|
||||||
'ac%/lvl': 215,
|
|
||||||
'hp/lvl': 216,
|
|
||||||
'mana/lvl': 217,
|
|
||||||
'dmg/lvl': 218,
|
|
||||||
'dmg%/lvl': 219,
|
|
||||||
'str/lvl': 220,
|
|
||||||
'dex/lvl': 221,
|
|
||||||
'enr/lvl': 222,
|
|
||||||
'vit/lvl': 223,
|
|
||||||
'att/lvl': 224,
|
|
||||||
'att%/lvl': 225,
|
|
||||||
'dmg-cold/lvl': 226,
|
|
||||||
'dmg-fire/lvl': 227,
|
|
||||||
'dmg-ltng/lvl': 228,
|
|
||||||
'dmg-pois/lvl': 229,
|
|
||||||
'res-cold/lvl': 230,
|
|
||||||
'res-fire/lvl': 231,
|
|
||||||
'res-ltng/lvl': 232,
|
|
||||||
'res-pois/lvl': 233,
|
|
||||||
'abs-cold/lvl': 234,
|
|
||||||
'abs-fire/lvl': 235,
|
|
||||||
'abs-ltng/lvl': 236,
|
|
||||||
'abs-pois/lvl': 237,
|
|
||||||
'thorns/lvl': 238,
|
|
||||||
'gold%/lvl': 239,
|
|
||||||
'mag%/lvl': 240,
|
|
||||||
'regen-stam/lvl': 241,
|
|
||||||
'stam/lvl': 242,
|
|
||||||
'dmg-dem/lvl': 243,
|
|
||||||
'dmg-und/lvl': 244,
|
|
||||||
'att-dem/lvl': 245,
|
|
||||||
'att-und/lvl': 246,
|
|
||||||
'crush/lvl': 247,
|
|
||||||
'wounds/lvl': 248,
|
|
||||||
'kick/lvl': 249,
|
|
||||||
'deadly/lvl': 250,
|
|
||||||
'rep-dur': 252,
|
|
||||||
'rep-quant': 253,
|
|
||||||
stack: 254,
|
|
||||||
'ac/time': 268,
|
|
||||||
'extra-fire': 329,
|
|
||||||
'extra-ltng': 330,
|
|
||||||
'extra-cold': 331,
|
|
||||||
'extra-pois': 332,
|
|
||||||
'pierce-fire': 333,
|
|
||||||
'pierce-ltng': 334,
|
|
||||||
'pierce-cold': 335,
|
|
||||||
'pierce-pois': 336,
|
|
||||||
'all-stats': 0,
|
|
||||||
ethereal: 359,
|
|
||||||
}
|
|
||||||
|
|
||||||
const PROP_CODE_TO_MPQ_PROP: Record<string, string> = {
|
|
||||||
ias: 'swing1',
|
|
||||||
fcr: 'cast1',
|
|
||||||
fhr: 'balance1',
|
|
||||||
frw: 'move1',
|
|
||||||
fbr: 'block1',
|
|
||||||
classskills: 'ama',
|
|
||||||
fireresist: 'res-fire',
|
|
||||||
coldresist: 'res-cold',
|
|
||||||
lightresist: 'res-ltng',
|
|
||||||
poisonresist: 'res-pois',
|
|
||||||
'all-res': 'res-all',
|
|
||||||
dmag: 'red-dmg%',
|
|
||||||
'dmag-ac': 'red-dmg',
|
|
||||||
'mag-ac': 'red-mag',
|
|
||||||
leech: 'lifesteal',
|
|
||||||
'rep-life': 'regen',
|
|
||||||
manaregen: 'regen-mana',
|
|
||||||
'hp-kill': 'heal-kill',
|
|
||||||
'no-freeze': 'nofreeze',
|
|
||||||
'no-heal': 'noheal',
|
|
||||||
blind: 'stupidity',
|
|
||||||
}
|
|
||||||
|
|
||||||
const COMPOSITE_OR_SPECIAL_PRIORITY_CODES = new Set([
|
|
||||||
'dmg-norm',
|
|
||||||
'dmg-min',
|
|
||||||
'dmg-max',
|
|
||||||
'res-all',
|
|
||||||
'all-res',
|
|
||||||
'all-stats',
|
|
||||||
'dur',
|
|
||||||
'cold-len',
|
|
||||||
'pois-len',
|
|
||||||
'levelreq',
|
|
||||||
])
|
|
||||||
|
|
||||||
let runtimePropertiesTable: PropertiesTable | undefined
|
|
||||||
let runtimeItemStatCostTable: ItemStatCostTable | undefined
|
|
||||||
|
|
||||||
export interface RuntimePropertyTables {
|
|
||||||
readonly properties?: PropertiesTable | undefined
|
|
||||||
readonly itemStatCost?: ItemStatCostTable | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Injects runtime MPQ-parsed Properties.txt and ItemStatCost.txt tables into the tooltip engine.
|
|
||||||
*/
|
|
||||||
export function setRuntimePropertyTables(
|
|
||||||
properties?: PropertiesTable,
|
|
||||||
itemStatCost?: ItemStatCostTable,
|
|
||||||
): void {
|
|
||||||
runtimePropertiesTable = properties
|
|
||||||
runtimeItemStatCostTable = itemStatCost
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves live metadata (descPriority, statId, descFunc, descVal, dgrp) for a property code
|
|
||||||
* from the active MPQ Properties.txt -> ItemStatCost.txt tables.
|
|
||||||
*/
|
|
||||||
export function getRuntimePropertyMetadata(
|
|
||||||
code: string,
|
|
||||||
tables?: RuntimePropertyTables,
|
|
||||||
): { descPriority: number; statId: number; descFunc?: number; descVal?: number; dgrp?: number } | undefined {
|
|
||||||
const defaults = getDefaultItemPropertiesAndStats()
|
|
||||||
const propsTable = tables?.properties ?? runtimePropertiesTable ?? defaults.properties
|
|
||||||
const iscTable = tables?.itemStatCost ?? runtimeItemStatCostTable ?? defaults.stats
|
|
||||||
|
|
||||||
if (!propsTable || !iscTable) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const norm = (code || '').trim().toLowerCase()
|
|
||||||
if (!norm) return undefined
|
|
||||||
|
|
||||||
const lookupCode = PROP_CODE_TO_MPQ_PROP[norm] ?? norm
|
|
||||||
const propRec = propsTable.byCode.get(norm) ?? propsTable.byCode.get(lookupCode)
|
|
||||||
|
|
||||||
let statRec: ItemStatCostRecord | undefined
|
|
||||||
if (propRec?.stat1) {
|
|
||||||
statRec = iscTable.byStat.get(propRec.stat1)
|
|
||||||
}
|
|
||||||
if (!statRec && propRec) {
|
|
||||||
try {
|
|
||||||
const resolved = resolveProperty(propRec.code, undefined, 1, 1, {
|
|
||||||
properties: propsTable,
|
|
||||||
stats: iscTable,
|
|
||||||
})
|
|
||||||
if (resolved[0]?.stat) {
|
|
||||||
statRec = iscTable.byStat.get(resolved[0].stat)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Fallback below if resolveProperty fails
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!statRec) {
|
|
||||||
statRec = iscTable.byStat.get(norm)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!propRec && !statRec && !(norm in PROP_DESC_PRIORITY) && !(norm in PROP_STAT_ID)) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const statId = statRec?.id ?? PROP_STAT_ID[norm] ?? PROP_STAT_ID[lookupCode] ?? -1
|
|
||||||
let descPriority: number
|
|
||||||
if (
|
|
||||||
COMPOSITE_OR_SPECIAL_PRIORITY_CODES.has(norm) ||
|
|
||||||
COMPOSITE_OR_SPECIAL_PRIORITY_CODES.has(lookupCode) ||
|
|
||||||
!statRec
|
|
||||||
) {
|
|
||||||
descPriority = PROP_DESC_PRIORITY[norm] ?? PROP_DESC_PRIORITY[lookupCode] ?? statRec?.descPriority ?? 10
|
|
||||||
} else {
|
|
||||||
descPriority = statRec.descPriority
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
descPriority,
|
|
||||||
statId,
|
|
||||||
...(statRec
|
|
||||||
? {
|
|
||||||
descFunc: statRec.descFunc,
|
|
||||||
descVal: statRec.descVal,
|
|
||||||
dgrp: statRec.dgrp,
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPropertyPriority(code: string, tables?: RuntimePropertyTables): number {
|
|
||||||
const norm = code.trim().toLowerCase()
|
|
||||||
const meta = getRuntimePropertyMetadata(norm, tables)
|
|
||||||
if (meta !== undefined) {
|
|
||||||
return meta.descPriority
|
|
||||||
}
|
|
||||||
return PROP_DESC_PRIORITY[norm] ?? 10
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPropertyStatId(code: string, tables?: RuntimePropertyTables): number {
|
|
||||||
const norm = code.trim().toLowerCase()
|
|
||||||
const meta = getRuntimePropertyMetadata(norm, tables)
|
|
||||||
if (meta !== undefined && meta.statId >= 0) {
|
|
||||||
return meta.statId
|
|
||||||
}
|
|
||||||
const lookup = PROP_CODE_TO_MPQ_PROP[norm] ?? norm
|
|
||||||
return PROP_STAT_ID[norm] ?? PROP_STAT_ID[lookup] ?? -1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALIAS_CODE_MAP: Record<string, string> = {
|
const ALIAS_CODE_MAP: Record<string, string> = {
|
||||||
|
|
@ -1595,18 +1198,15 @@ const ALIAS_CODE_MAP: Record<string, string> = {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Aggregates and sorts item properties per Diablo II 1.13c tooltip rules (Issue #125, #130, #133):
|
* Aggregates and sorts item properties per Diablo II 1.13c tooltip rules (Issue #125 & Issue #130):
|
||||||
* 1. Combines elemental min/max pairs (`fire-min` + `fire-max` -> `Adds X-Y Fire Damage`, etc.)
|
* 1. Combines elemental min/max pairs (`fire-min` + `fire-max` -> `Adds X-Y Fire Damage`, etc.)
|
||||||
* 2. Combines physical min/max pairs (`dmg-min` + `dmg-max` -> `Adds X-Y Damage`)
|
* 2. Combines physical min/max pairs (`dmg-min` + `dmg-max` -> `Adds X-Y Damage`)
|
||||||
* 3. Merges 4-element resistances (`dgrp=2`) into `All Resistances +X` ONLY when all 4 are positive and strictly equal
|
* 3. Merges 4-element resistances (`dgrp=2`) into `All Resistances +X` ONLY when all 4 are positive and strictly equal
|
||||||
* 4. Merges 4 core attributes (`dgrp=1`) into `+X to All Attributes` ONLY when all 4 are positive and strictly equal
|
* 4. Merges 4 core attributes (`dgrp=1`) into `+X to All Attributes` ONLY when all 4 are positive and strictly equal
|
||||||
* 5. Accumulates identical scalar stats across prefixes, suffixes, and base modifiers
|
* 5. Accumulates identical scalar stats across prefixes, suffixes, and base modifiers
|
||||||
* 6. Sorts all resulting properties descending by `ItemStatCost.txt` `descpriority`, with secondary ascending `statId` tie-breaker
|
* 6. Sorts all resulting properties descending by `ItemStatCost.txt` `descpriority`
|
||||||
*/
|
*/
|
||||||
export function aggregateAndSortProperties(
|
export function aggregateAndSortProperties(rawProps: readonly RolledItemProp[]): RolledItemProp[] {
|
||||||
rawProps: readonly RolledItemProp[],
|
|
||||||
tables?: RuntimePropertyTables,
|
|
||||||
): RolledItemProp[] {
|
|
||||||
let fireMin = 0
|
let fireMin = 0
|
||||||
let fireMax = 0
|
let fireMax = 0
|
||||||
let ltngMin = 0
|
let ltngMin = 0
|
||||||
|
|
@ -1905,28 +1505,10 @@ export function aggregateAndSortProperties(
|
||||||
if (poisRes !== 0) out.push({ code: 'res-pois', min: poisRes, max: poisRes, value: poisRes })
|
if (poisRes !== 0) out.push({ code: 'res-pois', min: poisRes, max: poisRes, value: poisRes })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort descending by ItemStatCost.txt descpriority, then ascending by 1.13c statId for distinct properties
|
// Sort descending by ItemStatCost.txt descpriority
|
||||||
return out
|
return out
|
||||||
.map((item, index) => ({
|
.map((item, index) => ({ item, index, prio: getPropertyPriority(item.code) }))
|
||||||
item,
|
.sort((a, b) => (b.prio !== a.prio ? b.prio - a.prio : a.index - b.index))
|
||||||
index,
|
|
||||||
prio: getPropertyPriority(item.code, tables),
|
|
||||||
statId: getPropertyStatId(item.code, tables),
|
|
||||||
}))
|
|
||||||
.sort((a, b) => {
|
|
||||||
if (b.prio !== a.prio) {
|
|
||||||
return b.prio - a.prio
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
a.statId >= 0 &&
|
|
||||||
b.statId >= 0 &&
|
|
||||||
a.statId !== b.statId &&
|
|
||||||
a.item.code.trim().toLowerCase() !== b.item.code.trim().toLowerCase()
|
|
||||||
) {
|
|
||||||
return a.statId - b.statId
|
|
||||||
}
|
|
||||||
return a.index - b.index
|
|
||||||
})
|
|
||||||
.map(x => x.item)
|
.map(x => x.item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -523,7 +523,7 @@ export function parsePropertiesTable(
|
||||||
*/
|
*/
|
||||||
export async function loadItemPropertiesAndStats(
|
export async function loadItemPropertiesAndStats(
|
||||||
archives: MountedArchives
|
archives: MountedArchives
|
||||||
): Promise<{ stats: ItemStatCostTable; itemStatCost: ItemStatCostTable; properties: PropertiesTable }> {
|
): Promise<{ stats: ItemStatCostTable; properties: PropertiesTable }> {
|
||||||
const statsRaw = await archives.read(ITEM_STAT_COST_PATH)
|
const statsRaw = await archives.read(ITEM_STAT_COST_PATH)
|
||||||
const stats = parseItemStatCostTable(parseTable(statsRaw))
|
const stats = parseItemStatCostTable(parseTable(statsRaw))
|
||||||
|
|
||||||
|
|
@ -531,7 +531,7 @@ export async function loadItemPropertiesAndStats(
|
||||||
const properties = parsePropertiesTable(parseTable(propertiesRaw), stats)
|
const properties = parsePropertiesTable(parseTable(propertiesRaw), stats)
|
||||||
|
|
||||||
setDefaultItemPropertiesAndStats({ stats, properties })
|
setDefaultItemPropertiesAndStats({ stats, properties })
|
||||||
return { stats, itemStatCost: stats, properties }
|
return { stats, properties }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,8 @@
|
||||||
import { describe, it, expect, beforeAll } from 'vitest'
|
import { describe, it, expect, beforeAll } from 'vitest'
|
||||||
import { ACT_BOSSES, BOSS_LIST, type BossDefinition } from '../src/boss.ts'
|
import { ACT_BOSSES, BOSS_LIST, type BossDefinition } from '../src/boss.ts'
|
||||||
import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts'
|
import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts'
|
||||||
import * as fs from 'fs'
|
|
||||||
import { MpqArchive } from '../src/mpq/archive.ts'
|
|
||||||
import { fileSource } from '../src/mpq/file-source.ts'
|
|
||||||
import { MountedArchives } from '../src/mpq/mount.ts'
|
|
||||||
import {
|
import {
|
||||||
executeDropPipeline,
|
executeDropPipeline,
|
||||||
loadDropTables,
|
|
||||||
resolveItemTypeFlags,
|
resolveItemTypeFlags,
|
||||||
createDroppedItem,
|
createDroppedItem,
|
||||||
rollAutoMagic,
|
rollAutoMagic,
|
||||||
|
|
@ -23,15 +18,7 @@ import {
|
||||||
rollMagicAffixes,
|
rollMagicAffixes,
|
||||||
rollRareAffixes,
|
rollRareAffixes,
|
||||||
} from '../src/game/affix-generator.ts'
|
} from '../src/game/affix-generator.ts'
|
||||||
import {
|
import { formatItemTooltip, aggregateAndSortProperties } from '../src/game/item-tooltip.ts'
|
||||||
formatItemTooltip,
|
|
||||||
aggregateAndSortProperties,
|
|
||||||
setRuntimePropertyTables,
|
|
||||||
getRuntimePropertyMetadata,
|
|
||||||
getPropertyPriority,
|
|
||||||
getPropertyStatId,
|
|
||||||
PROP_STAT_ID,
|
|
||||||
} from '../src/game/item-tooltip.ts'
|
|
||||||
import { D2Rng } from '../src/game/d2-rng.ts'
|
import { D2Rng } from '../src/game/d2-rng.ts'
|
||||||
import { isAffixEligible, type MagicAffix, type AffixTable } from '../src/game/affixes.ts'
|
import { isAffixEligible, type MagicAffix, type AffixTable } from '../src/game/affixes.ts'
|
||||||
import type { RareNameTable } from '../src/game/rare-names.ts'
|
import type { RareNameTable } from '../src/game/rare-names.ts'
|
||||||
|
|
@ -1139,204 +1126,4 @@ describe('Affix classspecific Restriction & classlevelreq Tooltip Level Requirem
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Data-Driven Tooltip Properties (C1/C3) & 1.13c Drop Pipeline Alignment (A6/A8/B7) (Issue #133)', () => {
|
|
||||||
const hasD2Mpq = fs.existsSync('samples/d2/d2data.mpq')
|
|
||||||
|
|
||||||
it.skipIf(!hasD2Mpq)('1. C1 — loadDropTables(archives) loads Properties.txt (268) & ItemStatCost.txt (359), activates setRuntimePropertyTables, and matches 100% of core descPriority values', 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 mpqDropTables = await loadDropTables(archives)
|
|
||||||
expect(mpqDropTables.properties).toBeDefined()
|
|
||||||
expect(mpqDropTables.itemStatCost).toBeDefined()
|
|
||||||
expect(mpqDropTables.properties!.records).toHaveLength(268)
|
|
||||||
expect(mpqDropTables.itemStatCost!.records).toHaveLength(359)
|
|
||||||
|
|
||||||
const corePropertyCodes = [
|
|
||||||
'ac',
|
|
||||||
'ac%',
|
|
||||||
'str',
|
|
||||||
'dex',
|
|
||||||
'vit',
|
|
||||||
'enr',
|
|
||||||
'hp',
|
|
||||||
'mana',
|
|
||||||
'fcr',
|
|
||||||
'ias',
|
|
||||||
'frw',
|
|
||||||
'fhr',
|
|
||||||
'fbr',
|
|
||||||
'allskills',
|
|
||||||
'skilltab',
|
|
||||||
'res-fire',
|
|
||||||
'res-cold',
|
|
||||||
'res-ltng',
|
|
||||||
'res-pois',
|
|
||||||
'res-all',
|
|
||||||
'all-stats',
|
|
||||||
'dmg%',
|
|
||||||
'dmg-min',
|
|
||||||
'dmg-max',
|
|
||||||
'dmg-fire',
|
|
||||||
'dmg-ltng',
|
|
||||||
'dmg-cold',
|
|
||||||
'dmg-pois',
|
|
||||||
'lifesteal',
|
|
||||||
'manasteal',
|
|
||||||
'mag%',
|
|
||||||
'gold%',
|
|
||||||
'crush',
|
|
||||||
'deadly',
|
|
||||||
'openwounds',
|
|
||||||
'indestruct',
|
|
||||||
'charged',
|
|
||||||
'rep-dur',
|
|
||||||
'ease',
|
|
||||||
'sock',
|
|
||||||
]
|
|
||||||
|
|
||||||
// Snapshot baseline priorities without runtime tables
|
|
||||||
setRuntimePropertyTables(undefined, undefined)
|
|
||||||
const baselinePriorities = new Map<string, number>()
|
|
||||||
for (const code of corePropertyCodes) {
|
|
||||||
baselinePriorities.set(code, getPropertyPriority(code))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-activate runtime tables loaded from MPQ
|
|
||||||
setRuntimePropertyTables(mpqDropTables.properties, mpqDropTables.itemStatCost)
|
|
||||||
|
|
||||||
for (const code of corePropertyCodes) {
|
|
||||||
const meta = getRuntimePropertyMetadata(code)
|
|
||||||
expect(meta, `getRuntimePropertyMetadata('${code}') must be defined`).toBeDefined()
|
|
||||||
expect(meta!.statId, `statId for '${code}' must be >= 0`).toBeGreaterThanOrEqual(0)
|
|
||||||
expect(meta!.descPriority, `descPriority for '${code}' must match baseline`).toBe(
|
|
||||||
baselinePriorities.get(code),
|
|
||||||
)
|
|
||||||
expect(getPropertyPriority(code)).toBe(baselinePriorities.get(code))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('2. C3 — aggregateAndSortProperties breaks descPriority ties using ascending 1.13c ItemStatCost statId while keeping identical properties stable', () => {
|
|
||||||
// 1) prio = 88: extra-fire (passive_fire_mastery, statId=329) vs pierce-fire (passive_fire_pierce, statId=333)
|
|
||||||
// 2) prio = 22: red-dmg (normal_damage_reduction, statId=34) vs red-dmg% (damageresist, statId=36)
|
|
||||||
// 3) prio = 16: heal-kill (item_healafterkill, statId=86) vs mana-kill (item_manaafterkill, statId=138)
|
|
||||||
// 4) prio = 3: dur (maxdurability, statId=73) vs dur% (item_maxdurability_percent, statId=75)
|
|
||||||
expect(getPropertyStatId('extra-fire')).toBe(329)
|
|
||||||
expect(getPropertyStatId('pierce-fire')).toBe(333)
|
|
||||||
expect(getPropertyStatId('red-dmg')).toBe(34)
|
|
||||||
expect(getPropertyStatId('red-dmg%')).toBe(36)
|
|
||||||
expect(getPropertyStatId('heal-kill')).toBe(86)
|
|
||||||
expect(getPropertyStatId('mana-kill')).toBe(138)
|
|
||||||
expect(getPropertyStatId('dur')).toBe(73)
|
|
||||||
expect(getPropertyStatId('dur%')).toBe(75)
|
|
||||||
|
|
||||||
// Pass reversed statId order within each tied descPriority group
|
|
||||||
const sorted = aggregateAndSortProperties([
|
|
||||||
{ code: 'dur%', min: 15, max: 15, value: 15 }, // prio 3, statId 75
|
|
||||||
{ code: 'dur', min: 10, max: 10, value: 10 }, // prio 3, statId 73
|
|
||||||
{ code: 'mana-kill', min: 3, max: 3, value: 3 }, // prio 16, statId 138
|
|
||||||
{ code: 'heal-kill', min: 5, max: 5, value: 5 }, // prio 16, statId 86
|
|
||||||
{ code: 'red-dmg%', min: 10, max: 10, value: 10 }, // prio 22, statId 36
|
|
||||||
{ code: 'red-dmg', min: 7, max: 7, value: 7 }, // prio 22, statId 34
|
|
||||||
{ code: 'pierce-fire', min: 5, max: 5, value: 5 }, // prio 88, statId 333
|
|
||||||
{ code: 'extra-fire', min: 5, max: 5, value: 5 }, // prio 88, statId 329
|
|
||||||
{ code: 'lifesteal', min: 8, max: 8, value: 8 }, // prio 88, statId 60
|
|
||||||
{ code: 'hit-skill', param: 197, min: 5, max: 10, value: 5 }, // prio 160, statId 198 (1st)
|
|
||||||
{ code: 'hit-skill', param: 53, min: 8, max: 12, value: 8 }, // prio 160, statId 198 (2nd)
|
|
||||||
])
|
|
||||||
|
|
||||||
expect(sorted.map(p => `${p.code}${p.param ? `:${p.param}` : ''}`)).toEqual([
|
|
||||||
'hit-skill:197',
|
|
||||||
'hit-skill:53',
|
|
||||||
'lifesteal',
|
|
||||||
'extra-fire',
|
|
||||||
'pierce-fire',
|
|
||||||
'red-dmg',
|
|
||||||
'red-dmg%',
|
|
||||||
'heal-kill',
|
|
||||||
'mana-kill',
|
|
||||||
'dur',
|
|
||||||
'dur%',
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('3. A6 & A8 — executeDropPipeline clamps effectiveIlvl to [1, 99], uses 1.13c [floor(lvl/2), lvl*6] gold range, and prevents qlvl > effectiveIlvl inflation', () => {
|
|
||||||
const dropTables = getEmbeddedDropTables()
|
|
||||||
|
|
||||||
// Test A6 gold range & A8 nLevel clamping at nLevel = 120 (clamped to 99 -> gold base [49, 594])
|
|
||||||
for (let i = 0; i < 40; i++) {
|
|
||||||
const dropsHigh = executeDropPipeline({
|
|
||||||
tcName: 'Act 1 H2H A',
|
|
||||||
nLevel: 120,
|
|
||||||
monsterType: 1,
|
|
||||||
dropTables,
|
|
||||||
difficulty: 'hell',
|
|
||||||
playerMf: 0,
|
|
||||||
monsterRng: (0x133000 + i * 37) >>> 0,
|
|
||||||
})
|
|
||||||
for (const item of dropsHigh) {
|
|
||||||
if (item.code?.trim() === 'gld' || item.name === 'Gold') {
|
|
||||||
const amt = item.stack ?? item.value ?? 0
|
|
||||||
expect(amt).toBeGreaterThanOrEqual(49)
|
|
||||||
} else {
|
|
||||||
expect(item.ilvl).toBe(99)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test A8 clamping at nLevel = 0 (clamped to 1) on high-qlvl base ('uit' Monarch, qlvl=72)
|
|
||||||
// Ensures ilvl === 1 and qlvl is clamped to 1 so D = ilvl - qlvl = 0 (not -71)
|
|
||||||
for (let i = 0; i < 30; i++) {
|
|
||||||
const dropsLow = executeDropPipeline({
|
|
||||||
tcName: 'uit',
|
|
||||||
nLevel: 0,
|
|
||||||
monsterType: 1,
|
|
||||||
dropTables,
|
|
||||||
difficulty: 'normal',
|
|
||||||
playerMf: 0,
|
|
||||||
monsterRng: (0x233000 + i * 41) >>> 0,
|
|
||||||
})
|
|
||||||
expect(dropsLow).toHaveLength(1)
|
|
||||||
expect(dropsLow[0]!.ilvl).toBe(1)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('4. B7 — rollRareAffixes prevents duplicate rarePrefix and rareSuffix names when eligibleRareSuffixes.length > 1', () => {
|
|
||||||
const dropTables = getEmbeddedDropTables()
|
|
||||||
const swordBase = dropTables.getBase('crs')!
|
|
||||||
|
|
||||||
const collidingRareNames: { prefixes: RareNameTable; suffixes: RareNameTable } = {
|
|
||||||
prefixes: {
|
|
||||||
entries: [],
|
|
||||||
getEligible: () => [
|
|
||||||
{ index: 0, name: 'Shadow', version: 100, itypes: ['weap'], etypes: [], add: 0, multiply: 0, divide: 0 },
|
|
||||||
],
|
|
||||||
} as unknown as RareNameTable,
|
|
||||||
suffixes: {
|
|
||||||
entries: [],
|
|
||||||
getEligible: () => [
|
|
||||||
{ index: 0, name: 'Shadow', version: 100, itypes: ['weap'], etypes: [], add: 0, multiply: 0, divide: 0 },
|
|
||||||
{ index: 1, name: 'Fang', version: 100, itypes: ['weap'], etypes: [], add: 0, multiply: 0, divide: 0 },
|
|
||||||
],
|
|
||||||
} as unknown as RareNameTable,
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let s = 1; s <= 30; s++) {
|
|
||||||
const rolled = rollRareAffixes(
|
|
||||||
swordBase,
|
|
||||||
50,
|
|
||||||
dropTables.magicAffixes,
|
|
||||||
collidingRareNames,
|
|
||||||
dropTables.isA,
|
|
||||||
new D2Rng(s),
|
|
||||||
)
|
|
||||||
expect(rolled.rarePrefix?.name).toBe('Shadow')
|
|
||||||
expect(rolled.rareSuffix?.name).toBe('Fang')
|
|
||||||
expect(rolled.name).toBe('Shadow Fang')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue