From fc6388779e39084a4d2be4c385647dfddd2bd909 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Sat, 19 Sep 2026 11:06:06 +0000 Subject: [PATCH] =?UTF-8?q?feat(tooltip/drop):=20=E6=8E=A5=E7=BA=BFpropert?= =?UTF-8?q?ies.ts=E5=AE=9E=E7=8E=B0=E6=98=BE=E7=A4=BA=E5=B1=82=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E9=A9=B1=E5=8A=A8=E5=8C=96=E5=B9=B6=E5=AE=8C=E6=88=90?= =?UTF-8?q?Phase4=201.13c=E7=BB=86=E8=8A=82=E5=AF=B9=E9=BD=90=20(fixes=20#?= =?UTF-8?q?133)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/game/affix-generator.ts | 14 ++ src/game/drop-pipeline.ts | 78 ++++--- src/game/item-tooltip.ts | 434 +++++++++++++++++++++++++++++++++++- src/game/properties.ts | 4 +- tests/parity-1.13c.test.ts | 215 +++++++++++++++++- 5 files changed, 707 insertions(+), 38 deletions(-) diff --git a/src/game/affix-generator.ts b/src/game/affix-generator.ts index 4a5343f..a932783 100644 --- a/src/game/affix-generator.ts +++ b/src/game/affix-generator.ts @@ -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( diff --git a/src/game/drop-pipeline.ts b/src/game/drop-pipeline.ts index 04238de..1e36aee 100644 --- a/src/game/drop-pipeline.ts +++ b/src/game/drop-pipeline.ts @@ -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 readonly superUniques: Map @@ -190,6 +198,7 @@ export async function loadDropTables(archives: MountedArchives): Promise 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, })) diff --git a/src/game/item-tooltip.ts b/src/game/item-tooltip.ts index c8dd4e8..ac8e609 100644 --- a/src/game/item-tooltip.ts +++ b/src/game/item-tooltip.ts @@ -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 = { 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 = { + 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 = { + 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 = { @@ -1198,15 +1595,18 @@ const ALIAS_CODE_MAP: Record = { } /** - * 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) } diff --git a/src/game/properties.ts b/src/game/properties.ts index cc7ad76..a07aa8b 100644 --- a/src/game/properties.ts +++ b/src/game/properties.ts @@ -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 } } /** diff --git a/tests/parity-1.13c.test.ts b/tests/parity-1.13c.test.ts index ccd1d53..91c3706 100644 --- a/tests/parity-1.13c.test.ts +++ b/tests/parity-1.13c.test.ts @@ -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() + 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') + } + }) +}) +