From a618ed75019b76c3834c3dd7192f0321165aa275 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Sat, 19 Sep 2026 10:01:46 +0000 Subject: [PATCH] =?UTF-8?q?fix(drop):=20=E5=9F=BA=E4=BA=8EItemTypes.txt?= =?UTF-8?q?=E6=A0=87=E5=BF=97=E9=A9=B1=E5=8A=A8=E5=93=81=E8=B4=A8=E5=88=A4?= =?UTF-8?q?=E5=AE=9A=E4=B8=8EclassSpecific=E4=BC=A0=E9=80=92=EF=BC=8C?= =?UTF-8?q?=E6=9D=9C=E7=BB=9D=E6=99=AE=E9=80=9A/=E5=8A=A3=E8=B4=A8?= =?UTF-8?q?=E6=88=92=E6=8C=87=E6=8A=A4=E8=BA=AB=E7=AC=A6=20(fixes=20#127)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/game/drop-pipeline.ts | 150 +++++++++++++++++++++++++++++++++--- tests/drop-pipeline.test.ts | 24 ++++++ tests/parity-1.13c.test.ts | 104 ++++++++++++++++++++++++- 3 files changed, 267 insertions(+), 11 deletions(-) diff --git a/src/game/drop-pipeline.ts b/src/game/drop-pipeline.ts index cc3abeb..b7e480f 100644 --- a/src/game/drop-pipeline.ts +++ b/src/game/drop-pipeline.ts @@ -283,6 +283,113 @@ export function canItemBeRare( 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() + 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. */ @@ -510,6 +617,22 @@ export function executeDropPipeline( partyPlayers, 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 const diffKey = difficulty === 'hell' ? 'Hell' : difficulty === 'nightmare' ? 'Nightmare' : 'Normal' @@ -544,16 +667,15 @@ export function executeDropPipeline( const dwInitSeed = (rng.rand(0xFFFFFFFF) >>> 0) 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 = - effectiveBase.kind === 'weapon' || - effectiveBase.kind === 'armor' || - effectiveBase.id === 'rin' || - effectiveBase.id === 'amu' || - effectiveBase.id === 'jew' || - effectiveBase.id === 'cm1' || - effectiveBase.id === 'cm2' || - effectiveBase.id === 'cm3' + !typeFlags.isNormalOnly && + (effectiveBase.kind === 'weapon' || + effectiveBase.kind === 'armor' || + typeFlags.isAlwaysMagic) // Roll quality let quality: ItemQualityTier @@ -577,8 +699,16 @@ export function executeDropPipeline( table: dropTables.itemRatio, rng: itemRng, 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 diff --git a/tests/drop-pipeline.test.ts b/tests/drop-pipeline.test.ts index 225f862..dc2e717 100644 --- a/tests/drop-pipeline.test.ts +++ b/tests/drop-pipeline.test.ts @@ -8,6 +8,7 @@ import { executeDropPipeline, createDroppedItem, canItemBeRare, + resolveItemTypeFlags, type DropTables, } from '../src/game/drop-pipeline.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) + } + } + }) }) }) diff --git a/tests/parity-1.13c.test.ts b/tests/parity-1.13c.test.ts index 2b1da74..6b40d90 100644 --- a/tests/parity-1.13c.test.ts +++ b/tests/parity-1.13c.test.ts @@ -1,7 +1,11 @@ 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 { 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 { Item } from '../src/game/items.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) }) }) + +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) + } + }) +})