fix(drop): 基于ItemTypes.txt标志驱动品质判定与classSpecific传递,杜绝普通/劣质戒指护身符 (fixes #127)
This commit is contained in:
parent
0c60c38a67
commit
a618ed7501
|
|
@ -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<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.
|
||||
*/
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue