diablo2-web/tests/e2e-drop-parity/oracle.ts

270 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Authoritative Ground Truth Oracle for Diablo II v1.13c Item Drop Parity Tests (Issues #412–#428).
*
* Direct MPQ sources:
* - /usr/local/google/home/taodao/d2-data (d2data.mpq, d2exp.mpq, Patch_D2.mpq, D2Common.dll, D2Game.dll)
* - Fallback: Embedded 1.13c canonical tables in src/data/canonical-drop-data.ts
*/
import { existsSync } from 'node:fs'
import { MpqArchive } from '../../src/mpq/archive.ts'
import { fileSource } from '../../src/mpq/file-source.ts'
import { MountedArchives } from '../../src/mpq/mount.ts'
import { type DataTable, numberCell, parseTable, textCell } from '../../src/game/tables.ts'
import {
RAW_MONSTATS,
RAW_SUPER_UNIQUES,
RAW_TC_EX,
RAW_ITEM_RATIO,
} from '../../src/data/canonical-drop-data.ts'
export const D2_DATA_DIR = '/usr/local/google/home/taodao/d2-data'
export interface DropOracleStack {
mounted: MountedArchives | null
diffs: DataTable
monstats: DataTable
superuniques: DataTable
tcEx: DataTable
itemRatio: DataTable
weapons: DataTable
armor: DataTable
misc: DataTable
levels: DataTable
monstatsById: Map<string, Readonly<Record<string, string>>>
superuniquesByName: Map<string, Readonly<Record<string, string>>>
tcExByName: Map<string, Readonly<Record<string, string>>>
weaponsByCode: Map<string, Readonly<Record<string, string>>>
armorByCode: Map<string, Readonly<Record<string, string>>>
miscByCode: Map<string, Readonly<Record<string, string>>>
}
let cachedOracle: DropOracleStack | null = null
export async function getDropOracle(): Promise<DropOracleStack> {
const g = globalThis as Record<string, unknown>
if (g.__d2e2eDropOracle) {
return g.__d2e2eDropOracle as DropOracleStack
}
if (cachedOracle !== null) {
g.__d2e2eDropOracle = cachedOracle
return cachedOracle
}
let mounted: MountedArchives | null = null
let diffs: DataTable
let monstats: DataTable
let superuniques: DataTable
let tcEx: DataTable
let itemRatio: DataTable
let weapons: DataTable
let armor: DataTable
let misc: DataTable
let levels: DataTable
if (existsSync(`${D2_DATA_DIR}/Patch_D2.mpq`)) {
mounted = new MountedArchives()
for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
const fullPath = `${D2_DATA_DIR}/${name}`
if (existsSync(fullPath)) {
mounted.add(name, await MpqArchive.open(await fileSource(fullPath)))
}
}
const readTable = async (memberPath: string): Promise<DataTable> => {
const bytes = await mounted!.read(memberPath)
return parseTable(new TextDecoder('latin1').decode(bytes))
}
;[diffs, monstats, superuniques, tcEx, itemRatio, weapons, armor, misc, levels] =
await Promise.all([
readTable('data\\global\\excel\\difficultylevels.txt'),
readTable('data\\global\\excel\\monstats.txt'),
readTable('data\\global\\excel\\superuniques.txt'),
readTable('data\\global\\excel\\TreasureClassEx.txt'),
readTable('data\\global\\excel\\ItemRatio.txt'),
readTable('data\\global\\excel\\weapons.txt'),
readTable('data\\global\\excel\\armor.txt'),
readTable('data\\global\\excel\\misc.txt'),
readTable('data\\global\\excel\\Levels.txt'),
])
} else {
// Hermetic fallback from embedded canonical strings
tcEx = parseTable(RAW_TC_EX)
monstats = parseTable(RAW_MONSTATS)
superuniques = parseTable(RAW_SUPER_UNIQUES)
itemRatio = parseTable(RAW_ITEM_RATIO)
diffs = parseTable(
'Name\tUberCodeOddsNormal\tUberCodeOddsGood\tUltraCodeOddsNormal\tUltraCodeOddsGood\r\n' +
'Normal\t0\t0\t0\t0\r\n' +
'Nightmare\t10\t20\t0\t0\r\n' +
'Hell\t20\t40\t30\t40\r\n',
)
weapons = parseTable('code\tdropsound\tdropsfxframe\r\n')
armor = parseTable('code\tdropsound\tdropsfxframe\r\n')
misc = parseTable('code\tdropsound\tdropsfxframe\r\n')
levels = parseTable('Id\tLevelName\r\n')
}
const monstatsById = new Map<string, Readonly<Record<string, string>>>()
for (const row of monstats.rows) {
if (row.Id) monstatsById.set(row.Id, row)
}
const superuniquesByName = new Map<string, Readonly<Record<string, string>>>()
for (const row of superuniques.rows) {
if (row.Superunique) superuniquesByName.set(row.Superunique, row)
}
const tcExByName = new Map<string, Readonly<Record<string, string>>>()
for (const row of tcEx.rows) {
const name = row['Treasure Class']
if (name) tcExByName.set(name, row)
}
const weaponsByCode = new Map<string, Readonly<Record<string, string>>>()
for (const row of weapons.rows) {
if (row.code) weaponsByCode.set(row.code, row)
}
const armorByCode = new Map<string, Readonly<Record<string, string>>>()
for (const row of armor.rows) {
if (row.code) armorByCode.set(row.code, row)
}
const miscByCode = new Map<string, Readonly<Record<string, string>>>()
for (const row of misc.rows) {
if (row.code) miscByCode.set(row.code, row)
}
cachedOracle = {
mounted,
diffs,
monstats,
superuniques,
tcEx,
itemRatio,
weapons,
armor,
misc,
levels,
monstatsById,
superuniquesByName,
tcExByName,
weaponsByCode,
armorByCode,
miscByCode,
}
g.__d2e2eDropOracle = cachedOracle
return cachedOracle
}
/**
* 1.13c Magic Find diminishing returns (D2Game/src/ITEMS/Items.cpp lines 2346-2433).
*/
export function compute113cEffectiveMf(
mf: number,
quality: 'unique' | 'set' | 'rare' | 'magic',
): number {
if (mf <= 0) return 0
switch (quality) {
case 'unique':
return Math.floor((mf * 250) / (mf + 250))
case 'set':
return Math.floor((mf * 500) / (mf + 500))
case 'rare':
return Math.floor((mf * 600) / (mf + 600))
case 'magic':
return mf
default:
return 0
}
}
/**
* 1.13c NoDrop exponential dampening formula (D2Game/src/ITEMS/Items.cpp lines 2142-2166).
*/
export function compute113cScaledNoDrop(
baseNoDrop: number,
totalProb: number,
gamePlayers = 1,
partyPlayers = 1,
): number {
if (baseNoDrop <= 0 || totalProb <= 0) return 0
const g = Math.max(1, Math.min(64, Math.floor(gamePlayers)))
const p = Math.max(1, Math.min(g, Math.floor(partyPlayers)))
const effectivePlayers = p + Math.floor((g - p) / 2)
if (effectivePlayers <= 1) return baseNoDrop
const noDropRatio = Math.pow(baseNoDrop / (baseNoDrop + totalProb), effectivePlayers)
if (noDropRatio >= 1) return baseNoDrop
return Math.floor((totalProb * noDropRatio) / (1 - noDropRatio))
}
/**
* 1.13c Character inventory gold capacity limit (D2Common/src/Units/Units.cpp line 3103).
* UNITS_GetInventoryGoldLimit: 10,000 * level.
*/
export function compute113cGoldLimit(characterLevel: number): number {
const lvl = Math.max(1, Math.min(99, Math.floor(characterLevel)))
return lvl * 10_000
}
/**
* 1.13c Physical gold graphic tier (3 tiers: 1-49 small, 50-499 medium, 500+ large).
*/
export function compute113cGoldGraphicTier(amount: number): 'small' | 'medium' | 'large' {
if (amount >= 500) return 'large'
if (amount >= 50) return 'medium'
return 'small'
}
/**
* 1.13c Monster level modifiers for elites (D2Game/src/MONSTER/MonsterUnique.cpp lines 269, 294).
*/
export function compute113cEliteMlvl(
baseLevel: number,
rank: 'normal' | 'champion' | 'unique' | 'minion' | 'boss',
): number {
let bonus = 0
if (rank === 'champion') bonus = 2
else if (rank === 'unique' || rank === 'minion') bonus = 3
return Math.max(1, Math.min(99, baseLevel + bonus))
}
/**
* 1.13c Ground item label text color rules:
* Gray (#808080) for ethereal or socketed normal/superior items.
*/
export function compute113cGroundLabelColor(
quality: string,
isEthereal = false,
isSocketed = false,
): string {
const q = quality.toLowerCase()
if (q === 'normal' || q === 'superior' || q === 'inferior' || q === 'low') {
if (isEthereal || isSocketed) {
return '#808080'
}
if (q === 'superior') return '#ffffff'
if (q === 'inferior' || q === 'low') return '#808080'
return '#d8d8d8'
}
switch (q) {
case 'magic':
return '#6868ff'
case 'rare':
return '#ffff64'
case 'set':
return '#00fc00'
case 'unique':
return '#c8a15a'
case 'craft':
case 'rune':
return '#ff9c18'
case 'gold':
return '#d8b420'
default:
return '#d8d8d8'
}
}