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
|
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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue