feat(tooltip/drop): 接线properties.ts实现显示层数据驱动化并完成Phase4 1.13c细节对齐 (fixes #133)
This commit is contained in:
parent
0cd799cdcb
commit
fc6388779e
|
|
@ -457,6 +457,20 @@ 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,6 +133,12 @@ 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,
|
||||||
|
|
@ -162,6 +168,8 @@ 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>
|
||||||
|
|
@ -190,6 +198,7 @@ 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),
|
||||||
|
|
@ -203,8 +212,12 @@ 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)
|
||||||
|
|
@ -251,6 +264,8 @@ export async function loadDropTables(archives: MountedArchives): Promise<DropTab
|
||||||
magicAffixes,
|
magicAffixes,
|
||||||
rareNames,
|
rareNames,
|
||||||
autoMagic,
|
autoMagic,
|
||||||
|
properties,
|
||||||
|
itemStatCost,
|
||||||
itemTypes,
|
itemTypes,
|
||||||
monsterKinds,
|
monsterKinds,
|
||||||
superUniques,
|
superUniques,
|
||||||
|
|
@ -848,8 +863,15 @@ 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, nLevel)
|
const resolvedNode = resolveTreasureClassGroup(dropTables.tcTable, tcName, effectiveIlvl)
|
||||||
const effectiveTcName = resolvedNode?.name ?? tcName
|
const effectiveTcName = resolvedNode?.name ?? tcName
|
||||||
|
|
||||||
// 2. Setup RNG
|
// 2. Setup RNG
|
||||||
|
|
@ -902,8 +924,10 @@ 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 baseGold = rng.randRange(Math.max(1, Math.trunc(nLevel / 2)), Math.max(5, nLevel * 5))
|
const clampedLvl = effectiveIlvl
|
||||||
|
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
|
||||||
|
|
@ -944,8 +968,8 @@ export function executeDropPipeline(
|
||||||
quality = 'set'
|
quality = 'set'
|
||||||
} else {
|
} else {
|
||||||
quality = rollItemQuality({
|
quality = rollItemQuality({
|
||||||
ilvl: nLevel,
|
ilvl: effectiveIlvl,
|
||||||
qlvl: effectiveBase.level ?? 1,
|
qlvl: Math.min(effectiveIlvl, effectiveBase.level ?? 1),
|
||||||
magicFind: playerMf,
|
magicFind: playerMf,
|
||||||
tcFactors: {
|
tcFactors: {
|
||||||
unique: leaf.qualityFactors[0],
|
unique: leaf.qualityFactors[0],
|
||||||
|
|
@ -972,7 +996,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 <= nLevel)
|
const uniqueCandidates = dropTables.uniques.getEnabledByCode(effectiveBase.id).filter(u => u.lvl <= effectiveIlvl)
|
||||||
if (uniqueCandidates.length > 0) {
|
if (uniqueCandidates.length > 0) {
|
||||||
let chosenUnique = uniqueCandidates[0]!
|
let chosenUnique = uniqueCandidates[0]!
|
||||||
if (uniqueCandidates.length > 1) {
|
if (uniqueCandidates.length > 1) {
|
||||||
|
|
@ -987,7 +1011,7 @@ export function executeDropPipeline(
|
||||||
roll -= weight
|
roll -= weight
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const ethAndSock = rollItemEtherealAndSockets(effectiveBase, 'unique', nLevel, itemRng, {
|
const ethAndSock = rollItemEtherealAndSockets(effectiveBase, 'unique', effectiveIlvl, itemRng, {
|
||||||
uniqueItem: chosenUnique,
|
uniqueItem: chosenUnique,
|
||||||
itemTypes: dropTables.itemTypes,
|
itemTypes: dropTables.itemTypes,
|
||||||
})
|
})
|
||||||
|
|
@ -996,7 +1020,7 @@ export function executeDropPipeline(
|
||||||
ethereal: ethAndSock.ethereal,
|
ethereal: ethAndSock.ethereal,
|
||||||
sockets: ethAndSock.sockets,
|
sockets: ethAndSock.sockets,
|
||||||
itemTypes: dropTables.itemTypes,
|
itemTypes: dropTables.itemTypes,
|
||||||
ilvl: nLevel,
|
ilvl: effectiveIlvl,
|
||||||
dwInitSeed,
|
dwInitSeed,
|
||||||
itemRng,
|
itemRng,
|
||||||
}))
|
}))
|
||||||
|
|
@ -1017,7 +1041,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 <= nLevel,
|
s => s.item.toLowerCase() === effectiveBase.id.toLowerCase() && s.lvl <= effectiveIlvl,
|
||||||
)
|
)
|
||||||
if (setCandidates.length > 0) {
|
if (setCandidates.length > 0) {
|
||||||
let chosenSet = setCandidates[0]!
|
let chosenSet = setCandidates[0]!
|
||||||
|
|
@ -1038,7 +1062,7 @@ export function executeDropPipeline(
|
||||||
setItem: chosenSet,
|
setItem: chosenSet,
|
||||||
ethereal: false,
|
ethereal: false,
|
||||||
itemTypes: dropTables.itemTypes,
|
itemTypes: dropTables.itemTypes,
|
||||||
ilvl: nLevel,
|
ilvl: effectiveIlvl,
|
||||||
dwInitSeed,
|
dwInitSeed,
|
||||||
itemRng,
|
itemRng,
|
||||||
}))
|
}))
|
||||||
|
|
@ -1058,7 +1082,7 @@ export function executeDropPipeline(
|
||||||
} else {
|
} else {
|
||||||
const rareAffixes = rollRareAffixes(
|
const rareAffixes = rollRareAffixes(
|
||||||
effectiveBase,
|
effectiveBase,
|
||||||
nLevel,
|
effectiveIlvl,
|
||||||
dropTables.magicAffixes,
|
dropTables.magicAffixes,
|
||||||
dropTables.rareNames,
|
dropTables.rareNames,
|
||||||
dropTables.isA,
|
dropTables.isA,
|
||||||
|
|
@ -1071,7 +1095,7 @@ export function executeDropPipeline(
|
||||||
autoPrefixGroup !== undefined
|
autoPrefixGroup !== undefined
|
||||||
? rollAutoMagic(
|
? rollAutoMagic(
|
||||||
effectiveBase,
|
effectiveBase,
|
||||||
nLevel,
|
effectiveIlvl,
|
||||||
dropTables.autoMagic ?? CANONICAL_AUTOMAGIC_TABLE,
|
dropTables.autoMagic ?? CANONICAL_AUTOMAGIC_TABLE,
|
||||||
dropTables.isA,
|
dropTables.isA,
|
||||||
itemRng,
|
itemRng,
|
||||||
|
|
@ -1080,9 +1104,9 @@ export function executeDropPipeline(
|
||||||
: null
|
: null
|
||||||
const staffMods =
|
const staffMods =
|
||||||
resolveStaffModsClass(effectiveBase, dropTables.itemTypes) !== undefined
|
resolveStaffModsClass(effectiveBase, dropTables.itemTypes) !== undefined
|
||||||
? rollStaffMods(effectiveBase, nLevel, dropTables.itemTypes, itemRng)
|
? rollStaffMods(effectiveBase, effectiveIlvl, dropTables.itemTypes, itemRng)
|
||||||
: undefined
|
: undefined
|
||||||
const ethAndSock = rollItemEtherealAndSockets(effectiveBase, 'rare', nLevel, itemRng, {
|
const ethAndSock = rollItemEtherealAndSockets(effectiveBase, 'rare', effectiveIlvl, itemRng, {
|
||||||
itemTypes: dropTables.itemTypes,
|
itemTypes: dropTables.itemTypes,
|
||||||
})
|
})
|
||||||
drops.push(createDroppedItem(effectiveBase, 'rare', {
|
drops.push(createDroppedItem(effectiveBase, 'rare', {
|
||||||
|
|
@ -1092,7 +1116,7 @@ export function executeDropPipeline(
|
||||||
ethereal: ethAndSock.ethereal,
|
ethereal: ethAndSock.ethereal,
|
||||||
sockets: ethAndSock.sockets,
|
sockets: ethAndSock.sockets,
|
||||||
itemTypes: dropTables.itemTypes,
|
itemTypes: dropTables.itemTypes,
|
||||||
ilvl: nLevel,
|
ilvl: effectiveIlvl,
|
||||||
dwInitSeed,
|
dwInitSeed,
|
||||||
durabilityMultiplier,
|
durabilityMultiplier,
|
||||||
itemRng,
|
itemRng,
|
||||||
|
|
@ -1108,7 +1132,7 @@ export function executeDropPipeline(
|
||||||
if (quality === 'magic') {
|
if (quality === 'magic') {
|
||||||
const magicAffixes = rollMagicAffixes(
|
const magicAffixes = rollMagicAffixes(
|
||||||
effectiveBase,
|
effectiveBase,
|
||||||
nLevel,
|
effectiveIlvl,
|
||||||
dropTables.magicAffixes,
|
dropTables.magicAffixes,
|
||||||
dropTables.isA,
|
dropTables.isA,
|
||||||
itemRng,
|
itemRng,
|
||||||
|
|
@ -1119,7 +1143,7 @@ export function executeDropPipeline(
|
||||||
autoPrefixGroup !== undefined
|
autoPrefixGroup !== undefined
|
||||||
? rollAutoMagic(
|
? rollAutoMagic(
|
||||||
effectiveBase,
|
effectiveBase,
|
||||||
nLevel,
|
effectiveIlvl,
|
||||||
dropTables.autoMagic ?? CANONICAL_AUTOMAGIC_TABLE,
|
dropTables.autoMagic ?? CANONICAL_AUTOMAGIC_TABLE,
|
||||||
dropTables.isA,
|
dropTables.isA,
|
||||||
itemRng,
|
itemRng,
|
||||||
|
|
@ -1128,9 +1152,9 @@ export function executeDropPipeline(
|
||||||
: null
|
: null
|
||||||
const staffMods =
|
const staffMods =
|
||||||
resolveStaffModsClass(effectiveBase, dropTables.itemTypes) !== undefined
|
resolveStaffModsClass(effectiveBase, dropTables.itemTypes) !== undefined
|
||||||
? rollStaffMods(effectiveBase, nLevel, dropTables.itemTypes, itemRng)
|
? rollStaffMods(effectiveBase, effectiveIlvl, dropTables.itemTypes, itemRng)
|
||||||
: undefined
|
: undefined
|
||||||
const ethAndSock = rollItemEtherealAndSockets(effectiveBase, 'magic', nLevel, itemRng, {
|
const ethAndSock = rollItemEtherealAndSockets(effectiveBase, 'magic', effectiveIlvl, itemRng, {
|
||||||
itemTypes: dropTables.itemTypes,
|
itemTypes: dropTables.itemTypes,
|
||||||
})
|
})
|
||||||
drops.push(createDroppedItem(effectiveBase, 'magic', {
|
drops.push(createDroppedItem(effectiveBase, 'magic', {
|
||||||
|
|
@ -1140,7 +1164,7 @@ export function executeDropPipeline(
|
||||||
ethereal: ethAndSock.ethereal,
|
ethereal: ethAndSock.ethereal,
|
||||||
sockets: ethAndSock.sockets,
|
sockets: ethAndSock.sockets,
|
||||||
itemTypes: dropTables.itemTypes,
|
itemTypes: dropTables.itemTypes,
|
||||||
ilvl: nLevel,
|
ilvl: effectiveIlvl,
|
||||||
dwInitSeed,
|
dwInitSeed,
|
||||||
durabilityMultiplier,
|
durabilityMultiplier,
|
||||||
itemRng,
|
itemRng,
|
||||||
|
|
@ -1153,7 +1177,7 @@ export function executeDropPipeline(
|
||||||
autoPrefixGroup !== undefined
|
autoPrefixGroup !== undefined
|
||||||
? rollAutoMagic(
|
? rollAutoMagic(
|
||||||
effectiveBase,
|
effectiveBase,
|
||||||
nLevel,
|
effectiveIlvl,
|
||||||
dropTables.autoMagic ?? CANONICAL_AUTOMAGIC_TABLE,
|
dropTables.autoMagic ?? CANONICAL_AUTOMAGIC_TABLE,
|
||||||
dropTables.isA,
|
dropTables.isA,
|
||||||
itemRng,
|
itemRng,
|
||||||
|
|
@ -1162,11 +1186,11 @@ export function executeDropPipeline(
|
||||||
: null
|
: null
|
||||||
const staffMods =
|
const staffMods =
|
||||||
resolveStaffModsClass(effectiveBase, dropTables.itemTypes) !== undefined
|
resolveStaffModsClass(effectiveBase, dropTables.itemTypes) !== undefined
|
||||||
? rollStaffMods(effectiveBase, nLevel, dropTables.itemTypes, itemRng)
|
? rollStaffMods(effectiveBase, effectiveIlvl, dropTables.itemTypes, itemRng)
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
if (quality === 'superior') {
|
if (quality === 'superior') {
|
||||||
const ethAndSock = rollItemEtherealAndSockets(effectiveBase, 'superior', nLevel, itemRng, {
|
const ethAndSock = rollItemEtherealAndSockets(effectiveBase, 'superior', effectiveIlvl, itemRng, {
|
||||||
itemTypes: dropTables.itemTypes,
|
itemTypes: dropTables.itemTypes,
|
||||||
})
|
})
|
||||||
drops.push(createDroppedItem(effectiveBase, 'superior', {
|
drops.push(createDroppedItem(effectiveBase, 'superior', {
|
||||||
|
|
@ -1175,7 +1199,7 @@ export function executeDropPipeline(
|
||||||
ethereal: ethAndSock.ethereal,
|
ethereal: ethAndSock.ethereal,
|
||||||
sockets: ethAndSock.sockets,
|
sockets: ethAndSock.sockets,
|
||||||
itemTypes: dropTables.itemTypes,
|
itemTypes: dropTables.itemTypes,
|
||||||
ilvl: nLevel,
|
ilvl: effectiveIlvl,
|
||||||
dwInitSeed,
|
dwInitSeed,
|
||||||
itemRng,
|
itemRng,
|
||||||
}))
|
}))
|
||||||
|
|
@ -1183,7 +1207,7 @@ export function executeDropPipeline(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (quality === 'low') {
|
if (quality === 'low') {
|
||||||
const ethAndSock = rollItemEtherealAndSockets(effectiveBase, 'low', nLevel, itemRng, {
|
const ethAndSock = rollItemEtherealAndSockets(effectiveBase, 'low', effectiveIlvl, itemRng, {
|
||||||
itemTypes: dropTables.itemTypes,
|
itemTypes: dropTables.itemTypes,
|
||||||
})
|
})
|
||||||
drops.push(createDroppedItem(effectiveBase, 'low', {
|
drops.push(createDroppedItem(effectiveBase, 'low', {
|
||||||
|
|
@ -1192,7 +1216,7 @@ export function executeDropPipeline(
|
||||||
ethereal: ethAndSock.ethereal,
|
ethereal: ethAndSock.ethereal,
|
||||||
sockets: ethAndSock.sockets,
|
sockets: ethAndSock.sockets,
|
||||||
itemTypes: dropTables.itemTypes,
|
itemTypes: dropTables.itemTypes,
|
||||||
ilvl: nLevel,
|
ilvl: effectiveIlvl,
|
||||||
dwInitSeed,
|
dwInitSeed,
|
||||||
itemRng,
|
itemRng,
|
||||||
}))
|
}))
|
||||||
|
|
@ -1200,7 +1224,7 @@ export function executeDropPipeline(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default: normal quality
|
// Default: normal quality
|
||||||
const ethAndSock = rollItemEtherealAndSockets(effectiveBase, 'normal', nLevel, itemRng, {
|
const ethAndSock = rollItemEtherealAndSockets(effectiveBase, 'normal', effectiveIlvl, itemRng, {
|
||||||
itemTypes: dropTables.itemTypes,
|
itemTypes: dropTables.itemTypes,
|
||||||
})
|
})
|
||||||
drops.push(createDroppedItem(effectiveBase, 'normal', {
|
drops.push(createDroppedItem(effectiveBase, 'normal', {
|
||||||
|
|
@ -1209,7 +1233,7 @@ export function executeDropPipeline(
|
||||||
ethereal: ethAndSock.ethereal,
|
ethereal: ethAndSock.ethereal,
|
||||||
sockets: ethAndSock.sockets,
|
sockets: ethAndSock.sockets,
|
||||||
itemTypes: dropTables.itemTypes,
|
itemTypes: dropTables.itemTypes,
|
||||||
ilvl: nLevel,
|
ilvl: effectiveIlvl,
|
||||||
dwInitSeed,
|
dwInitSeed,
|
||||||
itemRng,
|
itemRng,
|
||||||
}))
|
}))
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,13 @@ 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
|
||||||
|
|
@ -1158,10 +1165,400 @@ 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 {
|
/**
|
||||||
return PROP_DESC_PRIORITY[code.trim().toLowerCase()] ?? 10
|
* Canonical 1.13c ItemStatCost.txt numeric Stat ID lookup table (0..358, plus 359 for ethereal).
|
||||||
|
* 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> = {
|
||||||
|
|
@ -1198,15 +1595,18 @@ const ALIAS_CODE_MAP: Record<string, string> = {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Aggregates and sorts item properties per Diablo II 1.13c tooltip rules (Issue #125 & Issue #130):
|
* Aggregates and sorts item properties per Diablo II 1.13c tooltip rules (Issue #125, #130, #133):
|
||||||
* 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`
|
* 6. Sorts all resulting properties descending by `ItemStatCost.txt` `descpriority`, with secondary ascending `statId` tie-breaker
|
||||||
*/
|
*/
|
||||||
export function aggregateAndSortProperties(rawProps: readonly RolledItemProp[]): RolledItemProp[] {
|
export function aggregateAndSortProperties(
|
||||||
|
rawProps: readonly RolledItemProp[],
|
||||||
|
tables?: RuntimePropertyTables,
|
||||||
|
): RolledItemProp[] {
|
||||||
let fireMin = 0
|
let fireMin = 0
|
||||||
let fireMax = 0
|
let fireMax = 0
|
||||||
let ltngMin = 0
|
let ltngMin = 0
|
||||||
|
|
@ -1505,10 +1905,28 @@ export function aggregateAndSortProperties(rawProps: readonly RolledItemProp[]):
|
||||||
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
|
// Sort descending by ItemStatCost.txt descpriority, then ascending by 1.13c statId for distinct properties
|
||||||
return out
|
return out
|
||||||
.map((item, index) => ({ item, index, prio: getPropertyPriority(item.code) }))
|
.map((item, index) => ({
|
||||||
.sort((a, b) => (b.prio !== a.prio ? b.prio - a.prio : a.index - b.index))
|
item,
|
||||||
|
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; properties: PropertiesTable }> {
|
): Promise<{ stats: ItemStatCostTable; itemStatCost: 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, properties }
|
return { stats, itemStatCost: stats, properties }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,13 @@
|
||||||
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,
|
||||||
|
|
@ -18,7 +23,15 @@ import {
|
||||||
rollMagicAffixes,
|
rollMagicAffixes,
|
||||||
rollRareAffixes,
|
rollRareAffixes,
|
||||||
} from '../src/game/affix-generator.ts'
|
} from '../src/game/affix-generator.ts'
|
||||||
import { formatItemTooltip, aggregateAndSortProperties } from '../src/game/item-tooltip.ts'
|
import {
|
||||||
|
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'
|
||||||
|
|
@ -1126,4 +1139,204 @@ 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