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

This commit is contained in:
troytt 2026-09-19 10:02:37 +00:00
commit 22bd5591ca
3 changed files with 267 additions and 11 deletions

View File

@ -283,6 +283,113 @@ export function canItemBeRare(
return true return true
} }
/**
* Resolved ItemTypes.txt quality and class flags for an item base (D2 1.13c Ground Truth).
*/
export interface ItemTypeFlags {
readonly isNormalOnly: boolean
readonly isAlwaysMagic: boolean
readonly canBeRare: boolean
readonly isClassSpecific: boolean
}
const ALWAYS_MAGIC_BASE_IDS = new Set(['rin', 'amu', 'jew', 'cm1', 'cm2', 'cm3'])
const ALWAYS_MAGIC_TYPE_CODES = new Set(['ring', 'amul', 'jewl', 'char', 'scha', 'mcha', 'lcha', 'mboq', 'mxbq'])
const CLASS_SPECIFIC_TYPE_CODES = new Set([
'clas',
'amaz',
'barb',
'necr',
'pala',
'sorc',
'assn',
'drui',
'abow',
'aspe',
'ajav',
'orb',
'head',
'voodoo',
'ashd',
'phlm',
'pelt',
'cloa',
'h2h',
'h2h2',
])
/**
* Resolves quality availability and class-specific flags from `ItemTypes.txt` for a given item base.
*
* Ground truth rules (ItemTypes.txt & D2Game!6FC2FC40):
* - `isNormalOnly`: `def.normal === true` (and not ring/amu/jew/cm1..3/equippable weapon/armor).
* - `isAlwaysMagic`: `def.magic === true` or `base.id` in `rin`/`amu`/`jew`/`cm1`/`cm2`/`cm3`.
* Minimum quality for these items is always Magic (never Superior/Normal/Low).
* - `canBeRare`: delegates to `canItemBeRare(base, itemTypes, isAPredicate)`.
* - `isClassSpecific`: `def.class !== ''` or any `equiv1`/`equiv2` ancestor has `class !== ''`.
*/
export function resolveItemTypeFlags(
base: ItemBase,
itemTypes?: ItemTypeTable,
isAPredicate?: (type: string, target: string) => boolean,
): ItemTypeFlags {
const baseId = (base.id ?? '').trim().toLowerCase()
const typeCode = getItemTypeCode(base).trim().toLowerCase()
const def = itemTypes?.byCode.get(typeCode)
const isAlwaysMagic = Boolean(
def?.magic ||
def?.charm ||
ALWAYS_MAGIC_BASE_IDS.has(baseId) ||
ALWAYS_MAGIC_TYPE_CODES.has(typeCode),
)
const isEquippableWeaponOrArmor =
(base.kind === 'weapon' || base.kind === 'armor') &&
typeCode !== 'tpot' &&
typeCode !== 'bowq' &&
typeCode !== 'xboq'
const isNormalOnly = !isAlwaysMagic && !isEquippableWeaponOrArmor && Boolean(def ? def.normal : true)
const canBeRare = canItemBeRare(base, itemTypes, isAPredicate)
let isClassSpecific = CLASS_SPECIFIC_TYPE_CODES.has(typeCode)
if (!isClassSpecific && itemTypes) {
const visited = new Set<string>()
const queue: string[] = [typeCode]
while (queue.length > 0) {
const curr = queue.shift()!.trim().toLowerCase()
if (!curr || visited.has(curr)) continue
visited.add(curr)
if (CLASS_SPECIFIC_TYPE_CODES.has(curr)) {
isClassSpecific = true
break
}
const currDef = itemTypes.byCode.get(curr)
if (!currDef) continue
if (currDef.class && currDef.class.trim() !== '') {
isClassSpecific = true
break
}
if (currDef.equiv1) queue.push(currDef.equiv1)
if (currDef.equiv2) queue.push(currDef.equiv2)
}
}
if (!isClassSpecific && isAPredicate) {
if (isAPredicate(typeCode, 'clas')) {
isClassSpecific = true
}
}
return {
isNormalOnly,
isAlwaysMagic,
canBeRare,
isClassSpecific,
}
}
/** /**
* Creates a concrete Item instance for a dropped leaf item. * Creates a concrete Item instance for a dropped leaf item.
*/ */
@ -510,6 +617,22 @@ export function executeDropPipeline(
partyPlayers, partyPlayers,
isExpansion, isExpansion,
}) })
if (
dropLeaves.length === 0 &&
!dropTables.tcTable.get(effectiveTcName) &&
!dropTables.autoTcTable.get(effectiveTcName) &&
dropTables.getBase(effectiveTcName)
) {
dropLeaves.push({
code: effectiveTcName,
item: effectiveTcName,
isGold: false,
qualityFactors: [0, 0, 0, 0],
flags: 0,
forcedUnique: false,
forcedSet: false,
})
}
// 4. Resolve difficulty odds for base upgrades // 4. Resolve difficulty odds for base upgrades
const diffKey = difficulty === 'hell' ? 'Hell' : difficulty === 'nightmare' ? 'Nightmare' : 'Normal' const diffKey = difficulty === 'hell' ? 'Hell' : difficulty === 'nightmare' ? 'Nightmare' : 'Normal'
@ -544,16 +667,15 @@ export function executeDropPipeline(
const dwInitSeed = (rng.rand(0xFFFFFFFF) >>> 0) const dwInitSeed = (rng.rand(0xFFFFFFFF) >>> 0)
const itemRng = new D2Rng(dwInitSeed) const itemRng = new D2Rng(dwInitSeed)
// Check if the item can roll magical qualities (Weapons, Armor, Rings, Amulets, Jewels, Charms) // Resolve ItemTypes.txt quality & class-specific flags
const typeFlags = resolveItemTypeFlags(effectiveBase, dropTables.itemTypes, dropTables.isA)
// Check if the item can roll magical qualities
const canHaveQuality = const canHaveQuality =
effectiveBase.kind === 'weapon' || !typeFlags.isNormalOnly &&
effectiveBase.kind === 'armor' || (effectiveBase.kind === 'weapon' ||
effectiveBase.id === 'rin' || effectiveBase.kind === 'armor' ||
effectiveBase.id === 'amu' || typeFlags.isAlwaysMagic)
effectiveBase.id === 'jew' ||
effectiveBase.id === 'cm1' ||
effectiveBase.id === 'cm2' ||
effectiveBase.id === 'cm3'
// Roll quality // Roll quality
let quality: ItemQualityTier let quality: ItemQualityTier
@ -577,8 +699,16 @@ export function executeDropPipeline(
table: dropTables.itemRatio, table: dropTables.itemRatio,
rng: itemRng, rng: itemRng,
uber: isUber ? 1 : 0, uber: isUber ? 1 : 0,
classSpecific: 0, classSpecific: typeFlags.isClassSpecific ? 1 : 0,
allowRare: typeFlags.canBeRare,
allowSuperior: !typeFlags.isAlwaysMagic,
}) })
if (
typeFlags.isAlwaysMagic &&
(quality === 'normal' || quality === 'low' || quality === 'superior')
) {
quality = 'magic'
}
} }
let durabilityMultiplier = 1 let durabilityMultiplier = 1

View File

@ -8,6 +8,7 @@ import {
executeDropPipeline, executeDropPipeline,
createDroppedItem, createDroppedItem,
canItemBeRare, canItemBeRare,
resolveItemTypeFlags,
type DropTables, type DropTables,
} from '../src/game/drop-pipeline.ts' } from '../src/game/drop-pipeline.ts'
import { formatItemTooltip } from '../src/game/item-tooltip.ts' import { formatItemTooltip } from '../src/game/item-tooltip.ts'
@ -411,5 +412,28 @@ describe('TreasureClass Drop Pipeline (Issue #107)', () => {
} }
} }
}) })
it('7.6 resolveItemTypeFlags and Jewelry A drop pipeline never produce normal/superior/low rings, amulets, jewels, or charms', () => {
for (const code of ['rin', 'amu', 'jew', 'cm1', 'cm2', 'cm3']) {
const base = dropTables.getBase(code)!
const flags = resolveItemTypeFlags(base, dropTables.itemTypes, dropTables.isA)
expect(flags.isAlwaysMagic).toBe(true)
expect(flags.isNormalOnly).toBe(false)
}
for (let i = 0; i < 100; i++) {
const drops = executeDropPipeline(dropTables, {
tcName: 'Jewelry A',
nLevel: 1,
monsterType: 1,
difficulty: 'normal',
playerMf: -100,
monsterRng: (0xabc0000 + i * 997) >>> 0,
})
for (const item of drops) {
expect(['magic', 'rare', 'set', 'unique']).toContain(item.rarity)
}
}
})
}) })
}) })

View File

@ -1,7 +1,11 @@
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 { executeDropPipeline, type DropTables } from '../src/game/drop-pipeline.ts' import {
executeDropPipeline,
resolveItemTypeFlags,
type DropTables,
} from '../src/game/drop-pipeline.ts'
import type { UpgradableItemBase } from '../src/game/item-upgrade.ts' import type { UpgradableItemBase } from '../src/game/item-upgrade.ts'
import type { Item } from '../src/game/items.ts' import type { Item } from '../src/game/items.ts'
import { runParityAudit } from '../scripts/audit-parity.ts' import { runParityAudit } from '../scripts/audit-parity.ts'
@ -158,3 +162,101 @@ describe('Diablo II 1.13c Parity Baseline & Boss monsterType=4 Upgrade Odds (Iss
expect(summary.baseTierUpgrade.upgradedRatio).toBeGreaterThan(0) expect(summary.baseTierUpgrade.upgradedRatio).toBeGreaterThan(0)
}) })
}) })
describe('ItemTypes.txt Quality Flags & classSpecific Propagation (Issue #127 / A2)', () => {
let dropTables: DropTables
beforeAll(() => {
dropTables = getEmbeddedDropTables()
})
it('1. resolveItemTypeFlags returns isAlwaysMagic: true for rin, amu, jew, cm1, cm2, cm3', () => {
const alwaysMagicCodes = ['rin', 'amu', 'jew', 'cm1', 'cm2', 'cm3']
for (const code of alwaysMagicCodes) {
const base = dropTables.getBase(code)
expect(base, `Base ${code} must exist`).toBeDefined()
const flags = resolveItemTypeFlags(base!, dropTables.itemTypes, dropTables.isA)
expect(flags.isAlwaysMagic, `${code} must have isAlwaysMagic === true`).toBe(true)
expect(flags.isNormalOnly, `${code} must have isNormalOnly === false`).toBe(false)
if (code === 'cm1' || code === 'cm2' || code === 'cm3') {
expect(flags.canBeRare, `${code} charm must have canBeRare === false`).toBe(false)
} else {
expect(flags.canBeRare, `${code} must have canBeRare === true`).toBe(true)
}
}
})
it('2. 200 rolls of Ring / Amulet / Small Charm at playerMf: -100 and 0, nLevel: 1 NEVER produce normal/superior/low (and cm1 NEVER produces rare)', () => {
const validQualities = new Set(['magic', 'rare', 'set', 'unique'])
const forbiddenQualities = new Set(['normal', 'superior', 'low'])
for (const mf of [-100, 0]) {
for (const code of ['rin', 'amu', 'cm1']) {
let countNormalOrSuperiorOrLow = 0
let countRare = 0
let totalGenerated = 0
for (let i = 0; i < 200; i++) {
const drops = executeDropPipeline({
tcName: code,
nLevel: 1,
monsterType: 1,
dropTables,
difficulty: 'normal',
playerMf: mf,
gamePlayers: 1,
partyPlayers: 1,
monsterRng: (0x12700000 + i * 1009 + (mf === 0 ? 500000 : 0)) >>> 0,
})
expect(drops.length).toBe(1)
const item = drops[0]!
totalGenerated++
const q = item.rarity ?? ''
if (forbiddenQualities.has(q)) {
countNormalOrSuperiorOrLow++
}
if (q === 'rare') {
countRare++
}
expect(validQualities.has(q), `Item ${item.name} (${code}) rolled invalid quality ${q} at MF=${mf}`).toBe(true)
expect(item.name).not.toMatch(/^Cracked /)
expect(item.name).not.toMatch(/^Superior /)
}
expect(totalGenerated).toBe(200)
expect(countNormalOrSuperiorOrLow).toBe(0)
if (code === 'cm1') {
expect(countRare).toBe(0)
}
}
}
})
it('3. resolveItemTypeFlags identifies class-specific equipment bases (Sorceress Orb, Paladin Shield, Amazon Bow, etc.) with isClassSpecific === true', () => {
// Sorceress Orb: ob1 (Eagle Orb), obc (Eldritch Orb), obf (Dimensional Shard)
// Paladin Auric Shield: pa1 (Targe), paa (Akaran Targe), paf (Vortex Shield)
// Amazon Bow: am1 (Stag Bow), am6 (Ashwood Bow), amb (Matriarchal Bow)
// Barbarian Primal Helm: ba1 (Jawbone Cap)
// Druid Pelt: dr1 (Wolf Head)
// Necromancer Voodoo Head: ne1 (Preserved Head)
// Assassin Katar: ktr (Katar)
const classSpecificCodes = ['ob1', 'obf', 'pa1', 'paf', 'am1', 'amb', 'ba1', 'dr1', 'ne1', 'ktr']
for (const code of classSpecificCodes) {
const base = dropTables.getBase(code)
expect(base, `Class-specific base ${code} must exist`).toBeDefined()
const flags = resolveItemTypeFlags(base!, dropTables.itemTypes, dropTables.isA)
expect(flags.isClassSpecific, `${code} (${base!.name}) must have isClassSpecific === true`).toBe(true)
}
// Generic equipment bases must have isClassSpecific === false
const genericCodes = ['cap', 'uap', 'uit', 'lsd', 'crn', 'rin', 'amu', 'cm1']
for (const code of genericCodes) {
const base = dropTables.getBase(code)
expect(base, `Generic base ${code} must exist`).toBeDefined()
const flags = resolveItemTypeFlags(base!, dropTables.itemTypes, dropTables.isA)
expect(flags.isClassSpecific, `${code} (${base!.name}) must have isClassSpecific === false`).toBe(false)
}
})
})