Merge branch 'fix/issue-128' into main (fixes #128)

This commit is contained in:
troytt 2026-09-19 10:10:05 +00:00
commit 23a4a6867a
4 changed files with 363 additions and 26 deletions

View File

@ -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<string> = 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))

View File

@ -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
}
}

View File

@ -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,
})
}

View File

@ -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)')
})
})