270 lines
8.2 KiB
TypeScript
270 lines
8.2 KiB
TypeScript
/**
|
||
* 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'
|
||
}
|
||
}
|