diablo2-web/scripts/audit-parity.ts

226 lines
6.7 KiB
TypeScript

/**
* 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))
}