From b31a199e6b181ce52c7433a59f558ac4f715d4e3 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Sat, 19 Sep 2026 09:52:01 +0000 Subject: [PATCH] =?UTF-8?q?fix(drop):=20=E8=A1=A5=E9=BD=90Boss=E6=8E=89?= =?UTF-8?q?=E8=90=BDmonsterType=3D4=E4=BD=BF=E5=BA=95=E6=A8=A1=E5=8D=87?= =?UTF-8?q?=E7=BA=A7=E8=B5=B0Good=E6=A1=A3=E5=B9=B6=E5=BB=BA=E7=AB=8B1.13c?= =?UTF-8?q?=E5=9B=9E=E5=BD=92=E5=9F=BA=E7=BA=BF=20(fixes=20#126)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/audit-parity.ts | 225 +++++++++++++++++++++++++++++++++++++ src/boss.ts | 11 ++ tests/parity-1.13c.test.ts | 160 ++++++++++++++++++++++++++ 3 files changed, 396 insertions(+) create mode 100644 scripts/audit-parity.ts create mode 100644 tests/parity-1.13c.test.ts diff --git a/scripts/audit-parity.ts b/scripts/audit-parity.ts new file mode 100644 index 0000000..b4b84de --- /dev/null +++ b/scripts/audit-parity.ts @@ -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 + readonly ratios: Record + } + 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 + readonly rare: Record + } +} + +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 = { + 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 = { '1': 0, '2': 0 } + const rareAffixDist: Record = { '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 = {} + 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)) +} diff --git a/src/boss.ts b/src/boss.ts index 4bed057..03a5a8c 100644 --- a/src/boss.ts +++ b/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, diff --git a/tests/parity-1.13c.test.ts b/tests/parity-1.13c.test.ts new file mode 100644 index 0000000..2b1da74 --- /dev/null +++ b/tests/parity-1.13c.test.ts @@ -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) + }) +})