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

This commit is contained in:
troytt 2026-09-19 09:52:39 +00:00
commit 0c60c38a67
3 changed files with 396 additions and 0 deletions

225
scripts/audit-parity.ts Normal file
View File

@ -0,0 +1,225 @@
/**
* Diablo II 1.13c Drop Parity Statistical Audit CLI (Issue #126).
*
* Usage:
* npx tsx scripts/audit-parity.ts [kills=1000] [boss=mephisto] [difficulty=hell]
* or:
* npx tsx scripts/audit-parity.ts --kills=1000 --boss=mephisto --difficulty=hell --mf=250 --players=1
*/
import { BOSS_LIST, type Difficulty } from '../src/boss.ts'
import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts'
import { executeDropPipeline } from '../src/game/drop-pipeline.ts'
import type { UpgradableItemBase } from '../src/game/item-upgrade.ts'
import type { Item } from '../src/game/items.ts'
export interface ParityAuditSummary {
readonly config: {
readonly boss: string
readonly difficulty: Difficulty
readonly kills: number
readonly monsterType: number
readonly mlvl: number
readonly tcName: string
readonly playerMf: number
readonly gamePlayers: number
readonly isQuestKill: boolean
}
readonly totalItems: number
readonly qualityDistribution: {
readonly counts: Record<string, number>
readonly ratios: Record<string, number>
}
readonly baseTierUpgrade: {
readonly totalEquipment: number
readonly normalCount: number
readonly exceptionalCount: number
readonly eliteCount: number
readonly exceptionalRatio: number
readonly eliteRatio: number
readonly upgradedRatio: number
}
readonly affixCountDistribution: {
readonly magic: Record<string, number>
readonly rare: Record<string, number>
}
}
function classifyBaseTier(item: Item): 'normal' | 'exceptional' | 'elite' | null {
if (item.base.kind !== 'weapon' && item.base.kind !== 'armor') {
return null
}
const b = item.base as UpgradableItemBase
const id = (b.id ?? '').trim().toLowerCase()
const uber = (b.ubercode ?? '').trim().toLowerCase()
const ultra = (b.ultracode ?? '').trim().toLowerCase()
if (ultra && ultra !== 'xxx' && id === ultra) {
return 'elite'
}
if (uber && uber !== 'xxx' && id === uber) {
return 'exceptional'
}
return 'normal'
}
export function runParityAudit(options?: {
kills?: number
bossId?: string
difficulty?: Difficulty
playerMf?: number
gamePlayers?: number
isQuestKill?: boolean
baseSeed?: number
}): ParityAuditSummary {
const kills = options?.kills ?? 1000
const bossId = (options?.bossId ?? 'mephisto').toLowerCase()
const difficulty: Difficulty = options?.difficulty ?? 'hell'
const playerMf = options?.playerMf ?? 250
const gamePlayers = options?.gamePlayers ?? 1
const isQuestKill = options?.isQuestKill ?? true
const baseSeed = options?.baseSeed ?? 0x12600001
const boss = BOSS_LIST.find(b => b.id === bossId) ?? BOSS_LIST[2]!
const monsterType = boss.monsterType ?? 4
const mlvl = boss.mlvl[difficulty]
const tcName = isQuestKill ? boss.questTc[difficulty] : boss.tc[difficulty]
const dropTables = getEmbeddedDropTables()
let totalItems = 0
const qualityCounts: Record<string, number> = {
unique: 0,
set: 0,
rare: 0,
magic: 0,
superior: 0,
normal: 0,
low: 0,
}
let totalEquipment = 0
let normalCount = 0
let exceptionalCount = 0
let eliteCount = 0
const magicAffixDist: Record<string, number> = { '1': 0, '2': 0 }
const rareAffixDist: Record<string, number> = { '3': 0, '4': 0, '5': 0, '6': 0 }
for (let i = 0; i < kills; i++) {
const seed = (baseSeed + i * 104729) >>> 0
const drops = executeDropPipeline({
tcName,
nLevel: mlvl,
monsterType,
dropTables,
difficulty,
playerMf,
gamePlayers,
partyPlayers: 1,
monsterRng: seed,
})
for (const item of drops) {
totalItems++
const rarity = (item.rarity || 'normal').toLowerCase()
qualityCounts[rarity] = (qualityCounts[rarity] ?? 0) + 1
const tier = classifyBaseTier(item)
if (tier !== null) {
totalEquipment++
if (tier === 'elite') eliteCount++
else if (tier === 'exceptional') exceptionalCount++
else normalCount++
}
if (rarity === 'magic') {
const count = (item.prefix ? 1 : 0) + (item.suffix ? 1 : 0)
const key = String(Math.max(1, count))
magicAffixDist[key] = (magicAffixDist[key] ?? 0) + 1
} else if (rarity === 'rare') {
const rareAffixes = (item as any).rolledRareAffixes?.affixes
const count = Array.isArray(rareAffixes) ? rareAffixes.length : 0
if (count > 0) {
const key = String(count)
rareAffixDist[key] = (rareAffixDist[key] ?? 0) + 1
}
}
}
}
const qualityRatios: Record<string, number> = {}
for (const [k, v] of Object.entries(qualityCounts)) {
qualityRatios[k] = totalItems > 0 ? Number((v / totalItems).toFixed(4)) : 0
}
const exceptionalRatio = totalEquipment > 0 ? Number((exceptionalCount / totalEquipment).toFixed(4)) : 0
const eliteRatio = totalEquipment > 0 ? Number((eliteCount / totalEquipment).toFixed(4)) : 0
const upgradedRatio =
totalEquipment > 0 ? Number(((exceptionalCount + eliteCount) / totalEquipment).toFixed(4)) : 0
return {
config: {
boss: boss.id,
difficulty,
kills,
monsterType,
mlvl,
tcName,
playerMf,
gamePlayers,
isQuestKill,
},
totalItems,
qualityDistribution: {
counts: qualityCounts,
ratios: qualityRatios,
},
baseTierUpgrade: {
totalEquipment,
normalCount,
exceptionalCount,
eliteCount,
exceptionalRatio,
eliteRatio,
upgradedRatio,
},
affixCountDistribution: {
magic: magicAffixDist,
rare: rareAffixDist,
},
}
}
// CLI entry point
const isMain =
typeof process !== 'undefined' &&
Array.isArray(process.argv) &&
process.argv[1] &&
process.argv[1].includes('audit-parity')
if (isMain) {
const args = process.argv.slice(2)
let kills = 1000
let bossId = 'mephisto'
let difficulty: Difficulty = 'hell'
let playerMf = 250
let gamePlayers = 1
const positional: string[] = []
for (const arg of args) {
if (arg.startsWith('--kills=')) kills = Number(arg.slice('--kills='.length)) || 1000
else if (arg.startsWith('--boss=')) bossId = arg.slice('--boss='.length)
else if (arg.startsWith('--difficulty=')) difficulty = arg.slice('--difficulty='.length) as Difficulty
else if (arg.startsWith('--mf=')) playerMf = Number(arg.slice('--mf='.length)) || 250
else if (arg.startsWith('--players=')) gamePlayers = Number(arg.slice('--players='.length)) || 1
else if (!arg.startsWith('-')) positional.push(arg)
}
if (positional[0]) kills = Number(positional[0]) || kills
if (positional[1]) bossId = positional[1]
if (positional[2]) difficulty = positional[2] as Difficulty
const summary = runParityAudit({ kills, bossId, difficulty, playerMf, gamePlayers })
console.log(JSON.stringify(summary, null, 2))
}

View File

@ -31,10 +31,13 @@ export interface BossPreset {
readonly questTc: { normal: string; nightmare: string; hell: string }
readonly baseNoDrop: number
readonly picks: number
readonly monsterType?: number
readonly descCn: string
readonly highlightDrops: string
}
export type BossDefinition = BossPreset
export const ACT_BOSSES: readonly BossPreset[] = [
{
id: 'andariel',
@ -49,6 +52,7 @@ export const ACT_BOSSES: readonly BossPreset[] = [
questTc: { normal: 'Andarielq', nightmare: 'Andarielq (N)', hell: 'Andarielq (H)' },
baseNoDrop: 19,
picks: 7,
monsterType: 4,
descCn: '第一幕关底首领,弱火(-50%火抗)。暗黑2历史上著名的Bug首杀核心首领,乔丹之石 (SOJ) 掉率全游戏最高!',
highlightDrops: '乔丹之石 (SOJ)、暗金戒指/项链、海蛇皮甲',
},
@ -65,6 +69,7 @@ export const ACT_BOSSES: readonly BossPreset[] = [
questTc: { normal: 'Durielq', nightmare: 'Durielq (N)', hell: 'Durielq (H)' },
baseNoDrop: 19,
picks: 7,
monsterType: 4,
descCn: '第二幕关底首领,神圣冰冻光环。回城卷轴掉落特化,首杀可获得丰富的中级暗金装备与首饰。',
highlightDrops: '督瑞尔的壳、暗金首饰、高级符文',
},
@ -81,6 +86,7 @@ export const ACT_BOSSES: readonly BossPreset[] = [
questTc: { normal: 'Mephistoq', nightmare: 'Mephistoq (N)', hell: 'Mephistoq (H)' },
baseNoDrop: 15,
picks: 7,
monsterType: 4,
descCn: '第三幕关底首领,全暗黑2玩家公认最受爱戴的「劳模」。地形隔河击杀简单,全游戏刷军帽、眼球、暴风盾等核心日用品效率之王!',
highlightDrops: '军帽 (Shako)、暴风之盾、眼球、海蛇皮、亚瑞特的面容',
},
@ -97,6 +103,7 @@ export const ACT_BOSSES: readonly BossPreset[] = [
questTc: { normal: 'Diabloq', nightmare: 'Diabloq (N)', hell: 'Diabloq (H)' },
baseNoDrop: 15,
picks: 7,
monsterType: 4,
descCn: '第四幕关底首领,红色闪电地狱火。mlvl高达94,能掉落大君之怒、马拉的万花筒等全系列高级暗金护身符与首饰。',
highlightDrops: '马拉的万花筒、大君之怒、丧钟、风之锤',
},
@ -113,11 +120,14 @@ export const ACT_BOSSES: readonly BossPreset[] = [
questTc: { normal: 'Baalq', nightmare: 'Baalq (N)', hell: 'Baalq (H)' },
baseNoDrop: 15,
picks: 7,
monsterType: 4,
descCn: '终局最高首领,地狱难度怪物等级 mlvl 99!能掉落全暗黑2所有最高等级的宝藏(tc87/qlvl85全部封顶物品:风之力、泰瑞尔的力量、祖父等)。',
highlightDrops: '风之力 (Windforce)、泰瑞尔的力量、祖父、死亡之网、全符文',
},
]
export const BOSS_LIST: readonly BossDefinition[] = ACT_BOSSES
// State
let dropTables: DropTables | null = null
let currentBoss: BossPreset = ACT_BOSSES[2]! // Default: Mephisto
@ -394,6 +404,7 @@ export function executeBossKill(times: number = 1): void {
const drops = executeDropPipeline({
tcName: effectiveTc,
nLevel,
monsterType: currentBoss.monsterType ?? 4,
dropTables,
difficulty: currentDifficulty,
playerMf: currentMf,

160
tests/parity-1.13c.test.ts Normal file
View File

@ -0,0 +1,160 @@
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 type { UpgradableItemBase } from '../src/game/item-upgrade.ts'
import type { Item } from '../src/game/items.ts'
import { runParityAudit } from '../scripts/audit-parity.ts'
export type BaseTier = 'normal' | 'exceptional' | 'elite'
export function classifyItemBaseTier(item: Item): BaseTier | null {
if (item.base.kind !== 'weapon' && item.base.kind !== 'armor') {
return null
}
const b = item.base as UpgradableItemBase
const id = (b.id ?? '').trim().toLowerCase()
const uber = (b.ubercode ?? '').trim().toLowerCase()
const ultra = (b.ultracode ?? '').trim().toLowerCase()
if (ultra && ultra !== 'xxx' && id === ultra) {
return 'elite'
}
if (uber && uber !== 'xxx' && id === uber) {
return 'exceptional'
}
return 'normal'
}
describe('Diablo II 1.13c Parity Baseline & Boss monsterType=4 Upgrade Odds (Issue #126)', () => {
let dropTables: DropTables
beforeAll(() => {
dropTables = getEmbeddedDropTables()
})
it('1. BOSS_LIST and ACT_BOSSES define monsterType === 4 for all 5 Act Bosses', () => {
expect(BOSS_LIST).toBeDefined()
expect(BOSS_LIST).toHaveLength(5)
expect(BOSS_LIST).toBe(ACT_BOSSES)
for (const boss of BOSS_LIST) {
const def: BossDefinition = boss
expect(def.monsterType, `Boss ${def.id} must explicitly set monsterType: 4`).toBe(4)
}
})
it('2. Difficultylevels.txt Good odds (UberCodeOddsGood / UltraCodeOddsGood) exceed Normal odds in Nightmare and Hell', () => {
const nmOdds = dropTables.difficultyLevels.get('Nightmare')!
const hellOdds = dropTables.difficultyLevels.get('Hell')!
expect(nmOdds.uberCodeOddsGood).toBeGreaterThan(nmOdds.uberCodeOddsNormal)
expect(hellOdds.uberCodeOddsGood).toBeGreaterThan(hellOdds.uberCodeOddsNormal)
expect(hellOdds.ultraCodeOddsGood).toBeGreaterThan(hellOdds.ultraCodeOddsNormal)
})
it('3. monsterType=4 (Boss) yields significantly higher Exceptional/Elite base upgrades than monsterType=1 (Normal) over identical seeds', () => {
const sampleKills = 400
let normalMonsterUpgraded = 0
let normalMonsterEquipCount = 0
let bossMonsterUpgraded = 0
let bossMonsterEquipCount = 0
for (let i = 0; i < sampleKills; i++) {
const seed = (0x12600000 + i * 7919) >>> 0
// Run with monsterType = 1 (Normal monster: uses uberCodeOddsNormal=20, ultraCodeOddsNormal=30)
const dropsNormalType = executeDropPipeline({
tcName: 'Mephistoq (H)',
nLevel: 87,
monsterType: 1,
dropTables,
difficulty: 'hell',
playerMf: 250,
gamePlayers: 1,
partyPlayers: 1,
monsterRng: seed,
})
for (const item of dropsNormalType) {
const tier = classifyItemBaseTier(item)
if (tier !== null) {
normalMonsterEquipCount++
if (tier === 'exceptional' || tier === 'elite') {
normalMonsterUpgraded++
}
}
}
// Run with monsterType = 4 (Act Boss: uses uberCodeOddsGood=40, ultraCodeOddsGood=40)
const dropsBossType = executeDropPipeline({
tcName: 'Mephistoq (H)',
nLevel: 87,
monsterType: 4,
dropTables,
difficulty: 'hell',
playerMf: 250,
gamePlayers: 1,
partyPlayers: 1,
monsterRng: seed,
})
for (const item of dropsBossType) {
const tier = classifyItemBaseTier(item)
if (tier !== null) {
bossMonsterEquipCount++
if (tier === 'exceptional' || tier === 'elite') {
bossMonsterUpgraded++
}
}
}
}
expect(normalMonsterEquipCount).toBeGreaterThan(500)
expect(bossMonsterEquipCount).toBe(normalMonsterEquipCount)
const normalUpgradeRate = normalMonsterUpgraded / normalMonsterEquipCount
const bossUpgradeRate = bossMonsterUpgraded / bossMonsterEquipCount
// Boss (Good odds: 40/1024 + 40/1024) must strictly exceed Normal (20/1024 + 30/1024)
expect(bossMonsterUpgraded).toBeGreaterThan(normalMonsterUpgraded)
expect(bossUpgradeRate).toBeGreaterThan(normalUpgradeRate)
})
it('4. executeDropPipeline works properly with monsterType: 4 across all 5 Act Bosses in Hell', () => {
for (const boss of BOSS_LIST) {
const drops = executeDropPipeline({
tcName: boss.questTc.hell,
nLevel: boss.mlvl.hell,
monsterType: boss.monsterType ?? 4,
dropTables,
difficulty: 'hell',
playerMf: 250,
gamePlayers: 1,
partyPlayers: 1,
monsterRng: 1337 + boss.act,
})
expect(drops.length).toBeGreaterThan(0)
for (const item of drops) {
expect(item.ilvl).toBe(boss.mlvl.hell)
}
}
})
it('5. runParityAudit generates structured JSON summary of quality, base upgrade, and affix distributions', () => {
const summary = runParityAudit({
kills: 100,
bossId: 'mephisto',
difficulty: 'hell',
playerMf: 250,
gamePlayers: 1,
})
expect(summary.config.boss).toBe('mephisto')
expect(summary.config.monsterType).toBe(4)
expect(summary.totalItems).toBeGreaterThan(0)
expect(summary.baseTierUpgrade.totalEquipment).toBeGreaterThan(0)
expect(summary.baseTierUpgrade.upgradedRatio).toBeGreaterThan(0)
})
})