From 72eb7dea8b41818aa03f1383ffaeabd71fbf9731 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Sat, 19 Sep 2026 10:09:35 +0000 Subject: [PATCH] =?UTF-8?q?fix(affix/tooltip):=20=E4=B8=BA=E9=AD=94?= =?UTF-8?q?=E6=B3=95=E4=B8=8E=E7=A8=80=E6=9C=89=E8=AF=8D=E7=BC=80=E8=A1=A5?= =?UTF-8?q?=E9=BD=90NON=5FRANDOM=5FRANGE=5FCODES=E4=BF=9D=E6=8A=A4?= =?UTF-8?q?=EF=BC=8C=E4=BF=9D=E7=95=99=E5=85=83=E7=B4=A0=E4=BC=A4=E5=AE=B3?= =?UTF-8?q?=E5=8C=BA=E9=97=B4=E4=B8=8E=E8=A7=A6=E5=8F=91=E5=85=85=E8=83=BD?= =?UTF-8?q?=E6=8A=80=E8=83=BD=E5=8F=82=E6=95=B0=20(fixes=20#128)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/game/affix-generator.ts | 33 ++++ src/game/drop-pipeline.ts | 40 ++--- src/game/item-tooltip.ts | 23 ++- tests/parity-1.13c.test.ts | 293 +++++++++++++++++++++++++++++++++++- 4 files changed, 363 insertions(+), 26 deletions(-) diff --git a/src/game/affix-generator.ts b/src/game/affix-generator.ts index 2612839..f736bf4 100644 --- a/src/game/affix-generator.ts +++ b/src/game/affix-generator.ts @@ -194,11 +194,44 @@ function pickWeightedAffix(candidates: readonly MagicAffix[], rng: D2Rng): Magic return candidates[candidates.length - 1] ?? null } +/** + * Property codes whose (min, max) define a damage range (e.g. dmg-fire 1-6) + * or proc/charge parameters (e.g. hit-skill chance=min, slvl=max; charged charges=min, slvl=max) + * rather than a single random value rolled between min and max. + */ +export const NON_RANDOM_RANGE_CODES: ReadonlySet = new Set([ + 'dmg-fire', + 'dmg-cold', + 'dmg-ltng', + 'dmg-mag', + 'dmg-pois', + 'dmg-norm', + 'dmg-elem', + 'hit-skill', + 'att-skill', + 'gethit-skill', + 'kill-skill', + 'death-skill', + 'levelup-skill', + 'charged', + 'skill-rand', +]) + /** * Rolls concrete values for all modifiers of a chosen affix using `rng.randRange(min, max)`. */ function rollAffixMods(affix: MagicAffix, rng: D2Rng): RolledMod[] { return affix.mods.map(mod => { + const codeLower = mod.code.toLowerCase() + if (NON_RANDOM_RANGE_CODES.has(codeLower)) { + return { + code: mod.code, + ...(mod.param !== undefined ? { param: mod.param } : {}), + min: mod.min, + max: mod.max, + value: mod.min, + } + } const rolled = rng.randRange(mod.min, mod.max) const value = mod.min === 0 && mod.max === 0 && mod.param !== undefined && mod.param !== '' && !Number.isNaN(Number(mod.param)) diff --git a/src/game/drop-pipeline.ts b/src/game/drop-pipeline.ts index b7e480f..9452361 100644 --- a/src/game/drop-pipeline.ts +++ b/src/game/drop-pipeline.ts @@ -114,6 +114,7 @@ import { rollMagicAffixes, rollRareAffixes, getItemTypeCode, + NON_RANDOM_RANGE_CODES, type GeneratedAffixes, type GeneratedRareAffixes, } from './affix-generator.ts' @@ -421,24 +422,6 @@ export function createDroppedItem( const propRng = options.itemRng ?? new D2Rng(options.dwInitSeed) - const NON_RANDOM_RANGE_CODES = new Set([ - 'dmg-fire', - 'dmg-cold', - 'dmg-ltng', - 'dmg-mag', - 'dmg-pois', - 'dmg-norm', - 'dmg-elem', - 'hit-skill', - 'att-skill', - 'gethit-skill', - 'kill-skill', - 'death-skill', - 'levelup-skill', - 'charged', - 'skill-rand', - ]) - const resolveItemPropValue = (prop: { code: string; par?: string | number; min: number; max: number }): number => { const codeLower = prop.code.toLowerCase() if (NON_RANDOM_RANGE_CODES.has(codeLower)) { @@ -484,6 +467,13 @@ export function createDroppedItem( name = options.rareAffixes.name for (const affix of options.rareAffixes.affixes) { for (const mod of affix.mods) { + rolledProps.push({ + code: mod.code, + param: mod.param, + min: mod.min, + max: mod.max, + value: mod.value, + }) stats[mod.code] = (stats[mod.code] ?? 0) + mod.value } } @@ -500,6 +490,13 @@ export function createDroppedItem( modifiers: p.mods.map(m => ({ stat: m.code, min: m.min, max: m.max })), } for (const mod of p.mods) { + rolledProps.push({ + code: mod.code, + param: mod.param, + min: mod.min, + max: mod.max, + value: mod.value, + }) stats[mod.code] = (stats[mod.code] ?? 0) + mod.value } } @@ -514,6 +511,13 @@ export function createDroppedItem( modifiers: s.mods.map(m => ({ stat: m.code, min: m.min, max: m.max })), } for (const mod of s.mods) { + rolledProps.push({ + code: mod.code, + param: mod.param, + min: mod.min, + max: mod.max, + value: mod.value, + }) stats[mod.code] = (stats[mod.code] ?? 0) + mod.value } } diff --git a/src/game/item-tooltip.ts b/src/game/item-tooltip.ts index 3cbc4b9..5a45be8 100644 --- a/src/game/item-tooltip.ts +++ b/src/game/item-tooltip.ts @@ -13,7 +13,13 @@ import type { Item, ItemBase, RolledItemProp } from './items.ts' import type { UniqueItem, UniqueItemProp } from './unique-items.ts' import type { SetItem, SetItemProp } from './set-items.ts' -import type { GeneratedAffixes, GeneratedRareAffixes, RolledAffix, RolledMod } from './affix-generator.ts' +import { + NON_RANDOM_RANGE_CODES, + type GeneratedAffixes, + type GeneratedRareAffixes, + type RolledAffix, + type RolledMod, +} from './affix-generator.ts' export interface FormattedStatLine { text: string @@ -1625,11 +1631,12 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip { for (const aff of rareDef.affixes) { if (aff.levelreq > 0) affixReqLevel = Math.max(affixReqLevel, aff.levelreq) for (const mod of aff.mods) { + const keepRange = NON_RANDOM_RANGE_CODES.has(mod.code.toLowerCase()) || (mod.min === 0 && mod.max === 0) rawProps.push({ code: mod.code, param: mod.param, - min: mod.min, - max: mod.max, + min: keepRange ? mod.min : mod.value, + max: keepRange ? mod.max : mod.value, value: mod.value, }) } @@ -1646,11 +1653,12 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip { if (magicDef.prefix) { if (magicDef.prefix.levelreq > 0) affixReqLevel = Math.max(affixReqLevel, magicDef.prefix.levelreq) for (const m of magicDef.prefix.mods) { + const keepRange = NON_RANDOM_RANGE_CODES.has(m.code.toLowerCase()) || (m.min === 0 && m.max === 0) rawProps.push({ code: m.code, param: m.param, - min: m.min, - max: m.max, + min: keepRange ? m.min : m.value, + max: keepRange ? m.max : m.value, value: m.value, }) } @@ -1658,11 +1666,12 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip { if (magicDef.suffix) { if (magicDef.suffix.levelreq > 0) affixReqLevel = Math.max(affixReqLevel, magicDef.suffix.levelreq) for (const m of magicDef.suffix.mods) { + const keepRange = NON_RANDOM_RANGE_CODES.has(m.code.toLowerCase()) || (m.min === 0 && m.max === 0) rawProps.push({ code: m.code, param: m.param, - min: m.min, - max: m.max, + min: keepRange ? m.min : m.value, + max: keepRange ? m.max : m.value, value: m.value, }) } diff --git a/tests/parity-1.13c.test.ts b/tests/parity-1.13c.test.ts index 6b40d90..2181d8f 100644 --- a/tests/parity-1.13c.test.ts +++ b/tests/parity-1.13c.test.ts @@ -4,10 +4,20 @@ import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts' import { executeDropPipeline, resolveItemTypeFlags, + createDroppedItem, type DropTables, } from '../src/game/drop-pipeline.ts' +import { + NON_RANDOM_RANGE_CODES, + rollMagicAffixes, + rollRareAffixes, +} from '../src/game/affix-generator.ts' +import { formatItemTooltip } from '../src/game/item-tooltip.ts' +import { D2Rng } from '../src/game/d2-rng.ts' +import type { MagicAffix, AffixTable } from '../src/game/affixes.ts' +import type { RareNameTable } from '../src/game/rare-names.ts' import type { UpgradableItemBase } from '../src/game/item-upgrade.ts' -import type { Item } from '../src/game/items.ts' +import type { Item, ItemBase } from '../src/game/items.ts' import { runParityAudit } from '../scripts/audit-parity.ts' export type BaseTier = 'normal' | 'exceptional' | 'elite' @@ -260,3 +270,284 @@ describe('ItemTypes.txt Quality Flags & classSpecific Propagation (Issue #127 / } }) }) + +describe('Magic & Rare Affix NON_RANDOM_RANGE_CODES Range & Skill Parameter Protection (Issue #128)', () => { + const swordBase: ItemBase = { + id: 'crs', + name: 'Crystal Sword', + kind: 'weapon', + tags: ['swor', 'weap'], + damage: 15, + defense: 0, + value: 1200, + invWidth: 2, + invHeight: 3, + maxStack: 1, + level: 11, + } + + const mockIsA = (type: string, target: string): boolean => { + if (type === target) return true + if (type === 'swor' && (target === 'weap' || target === 'mele')) return true + return false + } + + const mockRareNames: { prefixes: RareNameTable; suffixes: RareNameTable } = { + prefixes: { + entries: [], + getEligible: () => [{ index: 0, name: 'Fiend', version: 100, itypes: ['weap'], etypes: [], add: 0, multiply: 0, divide: 0 }], + } as unknown as RareNameTable, + suffixes: { + entries: [], + getEligible: () => [{ index: 0, name: 'Fang', version: 100, itypes: ['weap'], etypes: [], add: 0, multiply: 0, divide: 0 }], + } as unknown as RareNameTable, + } + + function makeAffix( + name: string, + isPrefix: boolean, + group: number, + mods: { code: string; param?: string; min: number; max: number }[], + ): MagicAffix { + return { + name, + isPrefix, + version: 100, + spawnable: true, + rare: true, + level: 1, + maxlevel: 0, + levelreq: 12, + frequency: 10, + group, + mods, + transform: false, + itypes: ['swor'], + etypes: [], + } + } + + it('1. exports NON_RANDOM_RANGE_CODES covering all elemental damage ranges, event procs, and charged skills', () => { + const expectedCodes = [ + 'dmg-fire', + 'dmg-cold', + 'dmg-ltng', + 'dmg-mag', + 'dmg-pois', + 'dmg-norm', + 'dmg-elem', + 'hit-skill', + 'att-skill', + 'gethit-skill', + 'kill-skill', + 'death-skill', + 'levelup-skill', + 'charged', + 'skill-rand', + ] + for (const c of expectedCodes) { + expect(NON_RANDOM_RANGE_CODES.has(c), `NON_RANDOM_RANGE_CODES must include ${c}`).toBe(true) + } + }) + + it('2. preserves dmg-fire (min=1, max=6) across rollMagicAffixes / rollRareAffixes -> createDroppedItem -> formatItemTooltip as "Adds 1-6 Fire Damage"', () => { + const fieryPrefix = makeAffix('Fiery', true, 101, [{ code: 'dmg-fire', min: 1, max: 6 }]) + const auxPrefix1 = makeAffix('Bronze', true, 102, [{ code: 'att', min: 15, max: 15 }]) + const auxPrefix2 = makeAffix('Sharp', true, 103, [{ code: 'dmg%', min: 20, max: 20 }]) + const auxSuffix1 = makeAffix('of Craftsmanship', false, 201, [{ code: 'dmg-max', min: 1, max: 1 }]) + const auxSuffix2 = makeAffix('of Worth', false, 202, [{ code: 'dmg-min', min: 2, max: 2 }]) + const auxSuffix3 = makeAffix('of Strength', false, 203, [{ code: 'str', min: 5, max: 5 }]) + + // Magic roll with only Fiery prefix + const magicAffixes = rollMagicAffixes( + swordBase, + 30, + { + prefixes: { all: [fieryPrefix] } as unknown as AffixTable, + suffixes: { all: [] } as unknown as AffixTable, + }, + mockIsA, + new D2Rng(12345), + ) + + // Find a seed that selects prefix (slotChoice 0 or 2) + let magicResult = magicAffixes + for (let s = 1; s <= 20; s++) { + const candidate = rollMagicAffixes( + swordBase, + 30, + { + prefixes: { all: [fieryPrefix] } as unknown as AffixTable, + suffixes: { all: [] } as unknown as AffixTable, + }, + mockIsA, + new D2Rng(s), + ) + if (candidate.prefix) { + magicResult = candidate + break + } + } + + expect(magicResult.prefix).toBeDefined() + expect(magicResult.prefix!.mods[0]).toEqual({ + code: 'dmg-fire', + min: 1, + max: 6, + value: 1, + }) + + const magicItem = createDroppedItem(swordBase, 'magic', { + magicAffixes: magicResult, + ilvl: 30, + dwInitSeed: 777, + }) + expect(magicItem.rolledProps).toContainEqual({ + code: 'dmg-fire', + param: undefined, + min: 1, + max: 6, + value: 1, + }) + + const magicTooltip = formatItemTooltip(magicItem) + const magicLines = magicTooltip.lines.map(l => l.text) + expect(magicLines).toContain('Adds 1-6 Fire Damage') + + // Rare roll where Fiery is guaranteed to be chosen (3 prefixes, 3 suffixes) + const rareResult = rollRareAffixes( + swordBase, + 30, + { + prefixes: { all: [fieryPrefix] } as unknown as AffixTable, + suffixes: { all: [auxSuffix1, auxSuffix2] } as unknown as AffixTable, + }, + mockRareNames, + mockIsA, + new D2Rng(98765), + ) + + const fieryRolled = rareResult.affixes.find(a => a.name === 'Fiery') + expect(fieryRolled).toBeDefined() + expect(fieryRolled!.mods[0]).toEqual({ + code: 'dmg-fire', + min: 1, + max: 6, + value: 1, + }) + + const rareItem = createDroppedItem(swordBase, 'rare', { + rareAffixes: rareResult, + ilvl: 30, + dwInitSeed: 888, + }) + expect(rareItem.rolledProps).toContainEqual({ + code: 'dmg-fire', + param: undefined, + min: 1, + max: 6, + value: 1, + }) + + const rareTooltip = formatItemTooltip(rareItem) + const rareLines = rareTooltip.lines.map(l => l.text) + expect(rareLines).toContain('Adds 1-6 Fire Damage') + + // Also verify when only rolledProps is present (without rolledMagicAffixes / rolledRareAffixes) + const rarePropsOnlyItem = { ...rareItem, rolledRareAffixes: undefined } as Item + expect(formatItemTooltip(rarePropsOnlyItem).lines.map(l => l.text)).toContain('Adds 1-6 Fire Damage') + }) + + it('3. preserves hit-skill (param="Amplify Damage"/66, min=5, max=1) and charged (param="Teleport"/54, min=20, max=2) across Magic and Rare generation and tooltip rendering', () => { + const ampSuffix = makeAffix('of Amplify Damage', false, 301, [ + { code: 'hit-skill', param: '66', min: 5, max: 1 }, + ]) + const teleSuffix = makeAffix('of Teleportation', false, 302, [ + { code: 'charged', param: '54', min: 20, max: 2 }, + ]) + const bronzePrefix = makeAffix('Bronze', true, 101, [{ code: 'att', min: 10, max: 10 }]) + + // Magic item with prefix=Bronze (or hit-skill) and suffix=charged + const ampPrefix = makeAffix('Cursing', true, 102, [ + { code: 'hit-skill', param: 'Amplify Damage', min: 5, max: 1 }, + ]) + + let magicResult = rollMagicAffixes( + swordBase, + 40, + { + prefixes: { all: [ampPrefix] } as unknown as AffixTable, + suffixes: { all: [teleSuffix] } as unknown as AffixTable, + }, + mockIsA, + new D2Rng(1), + ) + for (let s = 1; s <= 30; s++) { + const candidate = rollMagicAffixes( + swordBase, + 40, + { + prefixes: { all: [ampPrefix] } as unknown as AffixTable, + suffixes: { all: [teleSuffix] } as unknown as AffixTable, + }, + mockIsA, + new D2Rng(s), + ) + if (candidate.prefix && candidate.suffix) { + magicResult = candidate + break + } + } + + expect(magicResult.prefix?.mods[0]).toEqual({ + code: 'hit-skill', + param: 'Amplify Damage', + min: 5, + max: 1, + value: 5, + }) + expect(magicResult.suffix?.mods[0]).toEqual({ + code: 'charged', + param: '54', + min: 20, + max: 2, + value: 20, + }) + + const magicItem = createDroppedItem(swordBase, 'magic', { + magicAffixes: magicResult, + ilvl: 40, + dwInitSeed: 555, + }) + + const magicTooltip = formatItemTooltip(magicItem) + const magicLines = magicTooltip.lines.map(l => l.text) + expect(magicLines.some(l => /^5% Chance to cast Level 1 Amplify Damage on striking$/i.test(l))).toBe(true) + expect(magicLines).toContain('Level 2 Teleport (20/20 Charges)') + + // Rare item with both suffixes (66 and 54) + prefix + const rareResult = rollRareAffixes( + swordBase, + 40, + { + prefixes: { all: [bronzePrefix] } as unknown as AffixTable, + suffixes: { all: [ampSuffix, teleSuffix] } as unknown as AffixTable, + }, + mockRareNames, + mockIsA, + new D2Rng(42), + ) + + const rareItem = createDroppedItem(swordBase, 'rare', { + rareAffixes: rareResult, + ilvl: 40, + dwInitSeed: 666, + }) + + const rareTooltip = formatItemTooltip(rareItem) + const rareLines = rareTooltip.lines.map(l => l.text) + expect(rareLines.some(l => /^5% Chance to cast Level 1 Amplify Damage on striking$/i.test(l))).toBe(true) + expect(rareLines).toContain('Level 2 Teleport (20/20 Charges)') + }) +}) +