feat(tooltip/drop): 接线properties.ts实现显示层数据驱动化并完成Phase4 1.13c细节对齐 (fixes #133)

This commit is contained in:
troytt 2026-09-19 11:06:06 +00:00
parent 0cd799cdcb
commit fc6388779e
5 changed files with 707 additions and 38 deletions

View File

@ -457,6 +457,20 @@ export function rollRareAffixes(
if (eligibleRareSuffixes.length > 0) {
const idx = rng.rand(eligibleRareSuffixes.length)
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(

View File

@ -133,6 +133,12 @@ import {
ITEM_TYPE_STAFF_MODS_MAP,
STAFF_MOD_CLASS_SKILL_START,
} from './automagic.ts'
import {
loadItemPropertiesAndStats,
type PropertiesTable,
type ItemStatCostTable,
} from './properties.ts'
import { setRuntimePropertyTables } from './item-tooltip.ts'
export {
rollAutoMagic,
@ -162,6 +168,8 @@ export interface DropTables {
readonly magicAffixes: { readonly prefixes: AffixTable; readonly suffixes: AffixTable }
readonly rareNames: { readonly prefixes: RareNameTable; readonly suffixes: RareNameTable }
readonly autoMagic?: AutoMagicTable | undefined
readonly properties?: PropertiesTable | undefined
readonly itemStatCost?: ItemStatCostTable | undefined
readonly itemTypes: ItemTypeTable
readonly monsterKinds: Map<string, MonsterKind>
readonly superUniques: Map<string, SuperUnique>
@ -190,6 +198,7 @@ export async function loadDropTables(archives: MountedArchives): Promise<DropTab
rareNames,
itemTypes,
monstatsBytes,
propAndStats,
] = await Promise.all([
loadTreasureClasses(archives),
loadWeapons(archives),
@ -203,8 +212,12 @@ export async function loadDropTables(archives: MountedArchives): Promise<DropTab
loadRareNames(archives),
loadItemTypes(archives),
archives.read('data\\global\\excel\\monstats.txt'),
loadItemPropertiesAndStats(archives),
])
const { properties, itemStatCost } = propAndStats
setRuntimePropertyTables(properties, itemStatCost)
let autoMagic: AutoMagicTable = CANONICAL_AUTOMAGIC_TABLE
try {
autoMagic = await loadAutoMagic(archives)
@ -251,6 +264,8 @@ export async function loadDropTables(archives: MountedArchives): Promise<DropTab
magicAffixes,
rareNames,
autoMagic,
properties,
itemStatCost,
itemTypes,
monsterKinds,
superUniques,
@ -848,8 +863,15 @@ export function executeDropPipeline(
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)
const resolvedNode = resolveTreasureClassGroup(dropTables.tcTable, tcName, nLevel)
const resolvedNode = resolveTreasureClassGroup(dropTables.tcTable, tcName, effectiveIlvl)
const effectiveTcName = resolvedNode?.name ?? tcName
// 2. Setup RNG
@ -902,8 +924,10 @@ export function executeDropPipeline(
// 5. Process each dropped leaf
for (const leaf of dropLeaves) {
if (leaf.isGold) {
// A6: 1.13c D2Game!6FC319D0 gold drop range [floor(clampedLvl / 2), clampedLvl * 6] * multiplier
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)
drops.push(goldItem(goldAmount))
continue
@ -944,8 +968,8 @@ export function executeDropPipeline(
quality = 'set'
} else {
quality = rollItemQuality({
ilvl: nLevel,
qlvl: effectiveBase.level ?? 1,
ilvl: effectiveIlvl,
qlvl: Math.min(effectiveIlvl, effectiveBase.level ?? 1),
magicFind: playerMf,
tcFactors: {
unique: leaf.qualityFactors[0],
@ -972,7 +996,7 @@ export function executeDropPipeline(
// Quality branches & fallbacks (D2 1.13c Ground Truth)
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) {
let chosenUnique = uniqueCandidates[0]!
if (uniqueCandidates.length > 1) {
@ -987,7 +1011,7 @@ export function executeDropPipeline(
roll -= weight
}
}
const ethAndSock = rollItemEtherealAndSockets(effectiveBase, 'unique', nLevel, itemRng, {
const ethAndSock = rollItemEtherealAndSockets(effectiveBase, 'unique', effectiveIlvl, itemRng, {
uniqueItem: chosenUnique,
itemTypes: dropTables.itemTypes,
})
@ -996,7 +1020,7 @@ export function executeDropPipeline(
ethereal: ethAndSock.ethereal,
sockets: ethAndSock.sockets,
itemTypes: dropTables.itemTypes,
ilvl: nLevel,
ilvl: effectiveIlvl,
dwInitSeed,
itemRng,
}))
@ -1017,7 +1041,7 @@ export function executeDropPipeline(
if (quality === 'set') {
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) {
let chosenSet = setCandidates[0]!
@ -1038,7 +1062,7 @@ export function executeDropPipeline(
setItem: chosenSet,
ethereal: false,
itemTypes: dropTables.itemTypes,
ilvl: nLevel,
ilvl: effectiveIlvl,
dwInitSeed,
itemRng,
}))
@ -1058,7 +1082,7 @@ export function executeDropPipeline(
} else {
const rareAffixes = rollRareAffixes(
effectiveBase,
nLevel,
effectiveIlvl,
dropTables.magicAffixes,
dropTables.rareNames,
dropTables.isA,
@ -1071,7 +1095,7 @@ export function executeDropPipeline(
autoPrefixGroup !== undefined
? rollAutoMagic(
effectiveBase,
nLevel,
effectiveIlvl,
dropTables.autoMagic ?? CANONICAL_AUTOMAGIC_TABLE,
dropTables.isA,
itemRng,
@ -1080,9 +1104,9 @@ export function executeDropPipeline(
: null
const staffMods =
resolveStaffModsClass(effectiveBase, dropTables.itemTypes) !== undefined
? rollStaffMods(effectiveBase, nLevel, dropTables.itemTypes, itemRng)
? rollStaffMods(effectiveBase, effectiveIlvl, dropTables.itemTypes, itemRng)
: undefined
const ethAndSock = rollItemEtherealAndSockets(effectiveBase, 'rare', nLevel, itemRng, {
const ethAndSock = rollItemEtherealAndSockets(effectiveBase, 'rare', effectiveIlvl, itemRng, {
itemTypes: dropTables.itemTypes,
})
drops.push(createDroppedItem(effectiveBase, 'rare', {
@ -1092,7 +1116,7 @@ export function executeDropPipeline(
ethereal: ethAndSock.ethereal,
sockets: ethAndSock.sockets,
itemTypes: dropTables.itemTypes,
ilvl: nLevel,
ilvl: effectiveIlvl,
dwInitSeed,
durabilityMultiplier,
itemRng,
@ -1108,7 +1132,7 @@ export function executeDropPipeline(
if (quality === 'magic') {
const magicAffixes = rollMagicAffixes(
effectiveBase,
nLevel,
effectiveIlvl,
dropTables.magicAffixes,
dropTables.isA,
itemRng,
@ -1119,7 +1143,7 @@ export function executeDropPipeline(
autoPrefixGroup !== undefined
? rollAutoMagic(
effectiveBase,
nLevel,
effectiveIlvl,
dropTables.autoMagic ?? CANONICAL_AUTOMAGIC_TABLE,
dropTables.isA,
itemRng,
@ -1128,9 +1152,9 @@ export function executeDropPipeline(
: null
const staffMods =
resolveStaffModsClass(effectiveBase, dropTables.itemTypes) !== undefined
? rollStaffMods(effectiveBase, nLevel, dropTables.itemTypes, itemRng)
? rollStaffMods(effectiveBase, effectiveIlvl, dropTables.itemTypes, itemRng)
: undefined
const ethAndSock = rollItemEtherealAndSockets(effectiveBase, 'magic', nLevel, itemRng, {
const ethAndSock = rollItemEtherealAndSockets(effectiveBase, 'magic', effectiveIlvl, itemRng, {
itemTypes: dropTables.itemTypes,
})
drops.push(createDroppedItem(effectiveBase, 'magic', {
@ -1140,7 +1164,7 @@ export function executeDropPipeline(
ethereal: ethAndSock.ethereal,
sockets: ethAndSock.sockets,
itemTypes: dropTables.itemTypes,
ilvl: nLevel,
ilvl: effectiveIlvl,
dwInitSeed,
durabilityMultiplier,
itemRng,
@ -1153,7 +1177,7 @@ export function executeDropPipeline(
autoPrefixGroup !== undefined
? rollAutoMagic(
effectiveBase,
nLevel,
effectiveIlvl,
dropTables.autoMagic ?? CANONICAL_AUTOMAGIC_TABLE,
dropTables.isA,
itemRng,
@ -1162,11 +1186,11 @@ export function executeDropPipeline(
: null
const staffMods =
resolveStaffModsClass(effectiveBase, dropTables.itemTypes) !== undefined
? rollStaffMods(effectiveBase, nLevel, dropTables.itemTypes, itemRng)
? rollStaffMods(effectiveBase, effectiveIlvl, dropTables.itemTypes, itemRng)
: undefined
if (quality === 'superior') {
const ethAndSock = rollItemEtherealAndSockets(effectiveBase, 'superior', nLevel, itemRng, {
const ethAndSock = rollItemEtherealAndSockets(effectiveBase, 'superior', effectiveIlvl, itemRng, {
itemTypes: dropTables.itemTypes,
})
drops.push(createDroppedItem(effectiveBase, 'superior', {
@ -1175,7 +1199,7 @@ export function executeDropPipeline(
ethereal: ethAndSock.ethereal,
sockets: ethAndSock.sockets,
itemTypes: dropTables.itemTypes,
ilvl: nLevel,
ilvl: effectiveIlvl,
dwInitSeed,
itemRng,
}))
@ -1183,7 +1207,7 @@ export function executeDropPipeline(
}
if (quality === 'low') {
const ethAndSock = rollItemEtherealAndSockets(effectiveBase, 'low', nLevel, itemRng, {
const ethAndSock = rollItemEtherealAndSockets(effectiveBase, 'low', effectiveIlvl, itemRng, {
itemTypes: dropTables.itemTypes,
})
drops.push(createDroppedItem(effectiveBase, 'low', {
@ -1192,7 +1216,7 @@ export function executeDropPipeline(
ethereal: ethAndSock.ethereal,
sockets: ethAndSock.sockets,
itemTypes: dropTables.itemTypes,
ilvl: nLevel,
ilvl: effectiveIlvl,
dwInitSeed,
itemRng,
}))
@ -1200,7 +1224,7 @@ export function executeDropPipeline(
}
// Default: normal quality
const ethAndSock = rollItemEtherealAndSockets(effectiveBase, 'normal', nLevel, itemRng, {
const ethAndSock = rollItemEtherealAndSockets(effectiveBase, 'normal', effectiveIlvl, itemRng, {
itemTypes: dropTables.itemTypes,
})
drops.push(createDroppedItem(effectiveBase, 'normal', {
@ -1209,7 +1233,7 @@ export function executeDropPipeline(
ethereal: ethAndSock.ethereal,
sockets: ethAndSock.sockets,
itemTypes: dropTables.itemTypes,
ilvl: nLevel,
ilvl: effectiveIlvl,
dwInitSeed,
itemRng,
}))

View File

@ -22,6 +22,13 @@ import {
type RolledAffix,
type RolledMod,
} from './affix-generator.ts'
import {
resolveProperty,
getDefaultItemPropertiesAndStats,
type PropertiesTable,
type ItemStatCostTable,
type ItemStatCostRecord,
} from './properties.ts'
export interface FormattedStatLine {
text: string
@ -1158,10 +1165,400 @@ const PROP_DESC_PRIORITY: Record<string, number> = {
bloody: 0,
fade: 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> = {
@ -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.)
* 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
* 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
* 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 fireMax = 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 })
}
// Sort descending by ItemStatCost.txt descpriority
// Sort descending by ItemStatCost.txt descpriority, then ascending by 1.13c statId for distinct properties
return out
.map((item, index) => ({ item, index, prio: getPropertyPriority(item.code) }))
.sort((a, b) => (b.prio !== a.prio ? b.prio - a.prio : a.index - b.index))
.map((item, 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)
}

View File

@ -523,7 +523,7 @@ export function parsePropertiesTable(
*/
export async function loadItemPropertiesAndStats(
archives: MountedArchives
): Promise<{ stats: ItemStatCostTable; properties: PropertiesTable }> {
): Promise<{ stats: ItemStatCostTable; itemStatCost: ItemStatCostTable; properties: PropertiesTable }> {
const statsRaw = await archives.read(ITEM_STAT_COST_PATH)
const stats = parseItemStatCostTable(parseTable(statsRaw))
@ -531,7 +531,7 @@ export async function loadItemPropertiesAndStats(
const properties = parsePropertiesTable(parseTable(propertiesRaw), stats)
setDefaultItemPropertiesAndStats({ stats, properties })
return { stats, properties }
return { stats, itemStatCost: stats, properties }
}
/**

View File

@ -1,8 +1,13 @@
import { describe, it, expect, beforeAll } from 'vitest'
import { ACT_BOSSES, BOSS_LIST, type BossDefinition } from '../src/boss.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 {
executeDropPipeline,
loadDropTables,
resolveItemTypeFlags,
createDroppedItem,
rollAutoMagic,
@ -18,7 +23,15 @@ import {
rollMagicAffixes,
rollRareAffixes,
} 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 { isAffixEligible, type MagicAffix, type AffixTable } from '../src/game/affixes.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')
}
})
})