Merge branch 'fix/issue-126' into main (fixes #126)
This commit is contained in:
commit
0c60c38a67
|
|
@ -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))
|
||||
}
|
||||
11
src/boss.ts
11
src/boss.ts
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue