feat(items): 实现 exceptional / elite 基底升级 (Issue #103)
This commit is contained in:
commit
f0100b6374
|
|
@ -0,0 +1,334 @@
|
|||
/**
|
||||
* Diablo II Exceptional / Elite Item Base Upgrade System.
|
||||
*
|
||||
* Implements base item quality upgrades during TreasureClass drop generation:
|
||||
* - Reads difficulty parameters from `Difficultylevels.txt` (UberCodeOddsNormal, UberCodeOddsGood,
|
||||
* UltraCodeOddsNormal, UltraCodeOddsGood for Normal, Nightmare, Hell).
|
||||
* - Implements 1.13c disassembly logic from D2Game!6FC32380 and D2Game!6FC31880:
|
||||
* - qf[4] (uberCodeOdds) and qf[5] (ultraCodeOdds) checks against rng.rand(1024).
|
||||
* - Flags 0x04 (exceptional) and 0x10 (elite).
|
||||
* - Selection priority: elite ultracode -> exceptional ubercode -> original base (normcode).
|
||||
* - When upgraded, the item base is replaced with the upgraded base, and `isUber` is set to true.
|
||||
*/
|
||||
import type { D2Table } from './acts.ts'
|
||||
import { parseTable } from './acts.ts'
|
||||
import type { MountedArchives } from '../mpq/mount.ts'
|
||||
import type { ItemBase } from './items.ts'
|
||||
import type { D2Rng } from './d2-rng.ts'
|
||||
|
||||
/** Canonical MPQ path of `Difficultylevels.txt`. */
|
||||
export const DIFFICULTY_LEVELS_TABLE_PATH = 'data\\global\\excel\\Difficultylevels.txt'
|
||||
|
||||
/** Bitflag set when exceptional upgrade succeeds (genFlags |= 0x04). */
|
||||
export const ITEM_UPGRADE_FLAG_EXCEPTIONAL = 0x04
|
||||
|
||||
/** Bitflag set when elite upgrade succeeds (genFlags |= 0x10). */
|
||||
export const ITEM_UPGRADE_FLAG_ELITE = 0x10
|
||||
|
||||
/**
|
||||
* Difficulty level odds parsed from `Difficultylevels.txt`.
|
||||
*/
|
||||
export interface DifficultyLevelOdds {
|
||||
readonly name: string
|
||||
readonly uberCodeOddsNormal: number
|
||||
readonly uberCodeOddsGood: number
|
||||
readonly ultraCodeOddsNormal: number
|
||||
readonly ultraCodeOddsGood: number
|
||||
// PascalCase aliases matching exact table column names
|
||||
readonly UberCodeOddsNormal: number
|
||||
readonly UberCodeOddsGood: number
|
||||
readonly UltraCodeOddsNormal: number
|
||||
readonly UltraCodeOddsGood: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Case-insensitive, whitespace-tolerant Map for looking up items/difficulties by key.
|
||||
*/
|
||||
export class CaseInsensitiveMap<V> extends Map<string, V> {
|
||||
private normalize(key: string): string {
|
||||
return typeof key === 'string' ? key.trim().toLowerCase() : String(key).trim().toLowerCase()
|
||||
}
|
||||
|
||||
override get(key: string): V | undefined {
|
||||
return super.get(this.normalize(key))
|
||||
}
|
||||
|
||||
override has(key: string): boolean {
|
||||
return super.has(this.normalize(key))
|
||||
}
|
||||
|
||||
override set(key: string, value: V): this {
|
||||
return super.set(this.normalize(key), value)
|
||||
}
|
||||
|
||||
override delete(key: string): boolean {
|
||||
return super.delete(this.normalize(key))
|
||||
}
|
||||
}
|
||||
|
||||
/** Default canonical odds for standard Diablo II difficulties. */
|
||||
export const DEFAULT_DIFFICULTY_LEVEL_ODDS: Record<string, DifficultyLevelOdds> = {
|
||||
Normal: {
|
||||
name: 'Normal',
|
||||
uberCodeOddsNormal: 0,
|
||||
uberCodeOddsGood: 0,
|
||||
ultraCodeOddsNormal: 0,
|
||||
ultraCodeOddsGood: 0,
|
||||
UberCodeOddsNormal: 0,
|
||||
UberCodeOddsGood: 0,
|
||||
UltraCodeOddsNormal: 0,
|
||||
UltraCodeOddsGood: 0,
|
||||
},
|
||||
Nightmare: {
|
||||
name: 'Nightmare',
|
||||
uberCodeOddsNormal: 10,
|
||||
uberCodeOddsGood: 20,
|
||||
ultraCodeOddsNormal: 0,
|
||||
ultraCodeOddsGood: 0,
|
||||
UberCodeOddsNormal: 10,
|
||||
UberCodeOddsGood: 20,
|
||||
UltraCodeOddsNormal: 0,
|
||||
UltraCodeOddsGood: 0,
|
||||
},
|
||||
Hell: {
|
||||
name: 'Hell',
|
||||
uberCodeOddsNormal: 20,
|
||||
uberCodeOddsGood: 40,
|
||||
ultraCodeOddsNormal: 30,
|
||||
ultraCodeOddsGood: 40,
|
||||
UberCodeOddsNormal: 20,
|
||||
UberCodeOddsGood: 40,
|
||||
UltraCodeOddsNormal: 30,
|
||||
UltraCodeOddsGood: 40,
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed collection of difficulty level upgrade odds from `Difficultylevels.txt`.
|
||||
*/
|
||||
export class DifficultyLevelsTable {
|
||||
readonly all: readonly DifficultyLevelOdds[]
|
||||
readonly byName: Map<string, DifficultyLevelOdds>
|
||||
readonly normal: DifficultyLevelOdds
|
||||
readonly nightmare: DifficultyLevelOdds
|
||||
readonly hell: DifficultyLevelOdds
|
||||
readonly Normal: DifficultyLevelOdds
|
||||
readonly Nightmare: DifficultyLevelOdds
|
||||
readonly Hell: DifficultyLevelOdds
|
||||
|
||||
constructor(all: DifficultyLevelOdds[], byName: Map<string, DifficultyLevelOdds>) {
|
||||
this.all = all
|
||||
this.byName = byName
|
||||
|
||||
const normal = byName.get('Normal') ?? all[0] ?? DEFAULT_DIFFICULTY_LEVEL_ODDS.Normal!
|
||||
const nightmare = byName.get('Nightmare') ?? all[1] ?? DEFAULT_DIFFICULTY_LEVEL_ODDS.Nightmare!
|
||||
const hell = byName.get('Hell') ?? all[2] ?? DEFAULT_DIFFICULTY_LEVEL_ODDS.Hell!
|
||||
|
||||
this.normal = normal
|
||||
this.nightmare = nightmare
|
||||
this.hell = hell
|
||||
this.Normal = normal
|
||||
this.Nightmare = nightmare
|
||||
this.Hell = hell
|
||||
}
|
||||
|
||||
get(name: string): DifficultyLevelOdds | undefined {
|
||||
return this.byName.get(name)
|
||||
}
|
||||
|
||||
[Symbol.iterator](): IterableIterator<DifficultyLevelOdds> {
|
||||
return this.all[Symbol.iterator]()
|
||||
}
|
||||
|
||||
get length(): number {
|
||||
return this.all.length
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses `Difficultylevels.txt` table rows into {@link DifficultyLevelsTable}.
|
||||
*
|
||||
* @param table - Raw parsed tab-separated D2Table.
|
||||
* @returns {@link DifficultyLevelsTable} instance.
|
||||
*/
|
||||
export function parseDifficultyLevelsTable(table: D2Table): DifficultyLevelsTable {
|
||||
const colMap = new Map<string, number>()
|
||||
const lowerMap = new Map<string, number>()
|
||||
|
||||
table.header.forEach((name, idx) => {
|
||||
const trimmed = name.trim()
|
||||
if (!colMap.has(trimmed)) {
|
||||
colMap.set(trimmed, idx)
|
||||
}
|
||||
const lower = trimmed.toLowerCase()
|
||||
if (!lowerMap.has(lower)) {
|
||||
lowerMap.set(lower, idx)
|
||||
}
|
||||
})
|
||||
|
||||
const strAt = (row: readonly string[], colName: string): string => {
|
||||
const idx = colMap.get(colName) ?? lowerMap.get(colName.toLowerCase())
|
||||
return idx !== undefined ? (row[idx] ?? '').trim() : ''
|
||||
}
|
||||
|
||||
const numAt = (row: readonly string[], colName: string, fallback = 0): number => {
|
||||
const raw = strAt(row, colName)
|
||||
if (!raw) return fallback
|
||||
const val = Number(raw)
|
||||
return Number.isFinite(val) ? val : fallback
|
||||
}
|
||||
|
||||
const all: DifficultyLevelOdds[] = []
|
||||
const byName = new CaseInsensitiveMap<DifficultyLevelOdds>()
|
||||
|
||||
for (const row of table.rows) {
|
||||
const name = strAt(row, 'Name')
|
||||
if (!name || name === 'Expansion') {
|
||||
continue
|
||||
}
|
||||
|
||||
const uberCodeOddsNormal = numAt(row, 'UberCodeOddsNormal', 0)
|
||||
const uberCodeOddsGood = numAt(row, 'UberCodeOddsGood', 0)
|
||||
const ultraCodeOddsNormal = numAt(row, 'UltraCodeOddsNormal', 0)
|
||||
const ultraCodeOddsGood = numAt(row, 'UltraCodeOddsGood', 0)
|
||||
|
||||
const entry: DifficultyLevelOdds = {
|
||||
name,
|
||||
uberCodeOddsNormal,
|
||||
uberCodeOddsGood,
|
||||
ultraCodeOddsNormal,
|
||||
ultraCodeOddsGood,
|
||||
UberCodeOddsNormal: uberCodeOddsNormal,
|
||||
UberCodeOddsGood: uberCodeOddsGood,
|
||||
UltraCodeOddsNormal: ultraCodeOddsNormal,
|
||||
UltraCodeOddsGood: ultraCodeOddsGood,
|
||||
}
|
||||
|
||||
all.push(entry)
|
||||
byName.set(name, entry)
|
||||
}
|
||||
|
||||
return new DifficultyLevelsTable(all, byName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads and parses `Difficultylevels.txt` from mounted MPQ archives.
|
||||
*
|
||||
* @param archives - Mounted MPQ archives.
|
||||
* @param path - Optional override MPQ path (defaults to {@link DIFFICULTY_LEVELS_TABLE_PATH}).
|
||||
* @returns Promise resolving to {@link DifficultyLevelsTable}.
|
||||
*/
|
||||
export async function loadDifficultyLevels(
|
||||
archives: MountedArchives,
|
||||
path = DIFFICULTY_LEVELS_TABLE_PATH,
|
||||
): Promise<DifficultyLevelsTable> {
|
||||
const bytes = await archives.read(path)
|
||||
const d2Table = parseTable(bytes)
|
||||
return parseDifficultyLevelsTable(d2Table)
|
||||
}
|
||||
|
||||
/**
|
||||
* ItemBase with optional tier code linkages (normcode, ubercode, ultracode).
|
||||
*/
|
||||
export interface UpgradableItemBase extends ItemBase {
|
||||
readonly normcode?: string | undefined
|
||||
readonly ubercode?: string | undefined
|
||||
readonly ultracode?: string | undefined
|
||||
readonly code?: string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of rolling an item base upgrade.
|
||||
*/
|
||||
export interface BaseUpgradeResult {
|
||||
/** The resulting item base (original or exceptional/elite). */
|
||||
readonly base: ItemBase
|
||||
/** Whether the base was upgraded to exceptional or elite tier. */
|
||||
readonly isUber: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a base code is valid for upgrade (non-empty and not 'xxx').
|
||||
*/
|
||||
function isValidUpgradeCode(code: unknown): code is string {
|
||||
if (typeof code !== 'string') {
|
||||
return false
|
||||
}
|
||||
const trimmed = code.trim()
|
||||
return trimmed.length > 0 && trimmed.toLowerCase() !== 'xxx'
|
||||
}
|
||||
|
||||
/**
|
||||
* Rolls exceptional / elite base upgrade for an item base.
|
||||
*
|
||||
* Disassembly reference:
|
||||
* - D2Game!6FC32380 (drop roll loop):
|
||||
* `if (qf[4] && (rng.rand(1024) < qf[4])) genFlags |= 0x04`
|
||||
* `if (qf[5] && (rng.rand(1024) < qf[5])) genFlags |= 0x10`
|
||||
* - D2Game!6FC31880 (base upgrade dispatch):
|
||||
* - If genFlags & 0x10 (elite): check base `ultracode`. If valid and !== 'xxx', use ultracode.
|
||||
* - Else if genFlags & 0x04 (exceptional): check base `ubercode`. If valid and !== 'xxx', use ubercode.
|
||||
* - Otherwise keep original `normcode`.
|
||||
* - When upgraded:
|
||||
* - Replaces item base with the exceptional/elite base (providing the new base's `qlvl` / `level`).
|
||||
* - `isUber` flag is set to true.
|
||||
*
|
||||
* @param base - Original item base (usually normal tier).
|
||||
* @param qf4 - Uber code odds (qf[4], e.g. UberCodeOddsNormal or UberCodeOddsGood).
|
||||
* @param qf5 - Ultra code odds (qf[5], e.g. UltraCodeOddsNormal or UltraCodeOddsGood).
|
||||
* @param rng - Diablo II PRNG instance.
|
||||
* @param lookupBase - Lookup function resolving a 3-4 letter item code to an {@link ItemBase}.
|
||||
* @returns {@link BaseUpgradeResult} containing the new or original base and `isUber`.
|
||||
*/
|
||||
export function rollBaseUpgrade(
|
||||
base: ItemBase,
|
||||
qf4: number,
|
||||
qf5: number,
|
||||
rng: D2Rng,
|
||||
lookupBase: (code: string) => ItemBase | undefined,
|
||||
): BaseUpgradeResult {
|
||||
let genFlags = 0
|
||||
|
||||
if (qf4 > 0 && rng.rand(1024) < qf4) {
|
||||
genFlags |= ITEM_UPGRADE_FLAG_EXCEPTIONAL
|
||||
}
|
||||
|
||||
if (qf5 > 0 && rng.rand(1024) < qf5) {
|
||||
genFlags |= ITEM_UPGRADE_FLAG_ELITE
|
||||
}
|
||||
|
||||
const uBase = base as Partial<UpgradableItemBase>
|
||||
const rawUltra = uBase.ultracode
|
||||
const rawUber = uBase.ubercode
|
||||
|
||||
const ultracode = isValidUpgradeCode(rawUltra) ? rawUltra.trim() : undefined
|
||||
const ubercode = isValidUpgradeCode(rawUber) ? rawUber.trim() : undefined
|
||||
|
||||
let targetCode: string | undefined
|
||||
if ((genFlags & ITEM_UPGRADE_FLAG_ELITE) && ultracode) {
|
||||
targetCode = ultracode
|
||||
} else if ((genFlags & ITEM_UPGRADE_FLAG_EXCEPTIONAL) && ubercode) {
|
||||
targetCode = ubercode
|
||||
}
|
||||
|
||||
if (targetCode) {
|
||||
const upgraded = lookupBase(targetCode) ?? lookupBase(targetCode.toLowerCase())
|
||||
if (upgraded) {
|
||||
return { base: upgraded, isUber: true }
|
||||
}
|
||||
// If elite was selected but lookup failed, fall back to exceptional if rolled and valid
|
||||
if (
|
||||
(genFlags & ITEM_UPGRADE_FLAG_ELITE) &&
|
||||
(genFlags & ITEM_UPGRADE_FLAG_EXCEPTIONAL) &&
|
||||
ubercode &&
|
||||
targetCode !== ubercode
|
||||
) {
|
||||
const fallbackUber = lookupBase(ubercode) ?? lookupBase(ubercode.toLowerCase())
|
||||
if (fallbackUber) {
|
||||
return { base: fallbackUber, isUber: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { base, isUber: false }
|
||||
}
|
||||
|
|
@ -0,0 +1,438 @@
|
|||
import { describe, it, expect, beforeAll, vi } from 'vitest'
|
||||
import * as fs from 'fs'
|
||||
import { MountedArchives } from '../src/mpq/mount.ts'
|
||||
import { MpqArchive } from '../src/mpq/archive.ts'
|
||||
import { fileSource } from '../src/mpq/file-source.ts'
|
||||
import type { D2Table } from '../src/game/acts.ts'
|
||||
import { parseTable } from '../src/game/acts.ts'
|
||||
import type { ItemBase } from '../src/game/items.ts'
|
||||
import { D2Rng } from '../src/game/d2-rng.ts'
|
||||
import { loadArmor, ArmorTable } from '../src/game/armor.ts'
|
||||
import { loadWeapons, type WeaponTable } from '../src/game/weapons.ts'
|
||||
import {
|
||||
DIFFICULTY_LEVELS_TABLE_PATH,
|
||||
ITEM_UPGRADE_FLAG_EXCEPTIONAL,
|
||||
ITEM_UPGRADE_FLAG_ELITE,
|
||||
DifficultyLevelsTable,
|
||||
parseDifficultyLevelsTable,
|
||||
loadDifficultyLevels,
|
||||
rollBaseUpgrade,
|
||||
type UpgradableItemBase,
|
||||
} from '../src/game/item-upgrade.ts'
|
||||
|
||||
const hasD2 = fs.existsSync('samples/d2/d2data.mpq')
|
||||
|
||||
/**
|
||||
* Creates a minimal fallback D2Table for Difficultylevels.txt
|
||||
* matching the canonical 1.13c table values.
|
||||
*/
|
||||
function createMinimalDifficultyLevelsTable(): D2Table {
|
||||
const header = [
|
||||
'Name',
|
||||
'ResistPenalty',
|
||||
'DeathExpPenalty',
|
||||
'UberCodeOddsNormal',
|
||||
'UberCodeOddsGood',
|
||||
'UltraCodeOddsNormal',
|
||||
'UltraCodeOddsGood',
|
||||
]
|
||||
const rows = [
|
||||
['Normal', '0', '0', '0', '0', '0', '0'],
|
||||
['Nightmare', '-40', '5', '10', '20', '0', '0'],
|
||||
['Hell', '-100', '10', '20', '40', '30', '40'],
|
||||
]
|
||||
return { header, rows }
|
||||
}
|
||||
|
||||
describe('Item Base Upgrade System (Issue #103)', () => {
|
||||
let diffTable: DifficultyLevelsTable
|
||||
let armorTable: ArmorTable | undefined
|
||||
let weaponTable: WeaponTable | undefined
|
||||
|
||||
beforeAll(async () => {
|
||||
if (hasD2) {
|
||||
const archives = new MountedArchives()
|
||||
for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
|
||||
archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`)))
|
||||
}
|
||||
diffTable = await loadDifficultyLevels(archives)
|
||||
armorTable = await loadArmor(archives)
|
||||
weaponTable = await loadWeapons(archives)
|
||||
} else {
|
||||
diffTable = parseDifficultyLevelsTable(createMinimalDifficultyLevelsTable())
|
||||
}
|
||||
})
|
||||
|
||||
describe('Difficultylevels.txt loader & parser', () => {
|
||||
it('defines canonical MPQ path', () => {
|
||||
expect(DIFFICULTY_LEVELS_TABLE_PATH).toBe('data\\global\\excel\\Difficultylevels.txt')
|
||||
})
|
||||
|
||||
it('loads and parses DifficultyLevelsTable with correct values', () => {
|
||||
expect(diffTable).toBeInstanceOf(DifficultyLevelsTable)
|
||||
expect(diffTable.length).toBeGreaterThanOrEqual(3)
|
||||
|
||||
// Normal: 0, 0, 0, 0
|
||||
const normal = diffTable.normal
|
||||
expect(normal).toBeDefined()
|
||||
expect(normal.name).toBe('Normal')
|
||||
expect(normal.uberCodeOddsNormal).toBe(0)
|
||||
expect(normal.uberCodeOddsGood).toBe(0)
|
||||
expect(normal.ultraCodeOddsNormal).toBe(0)
|
||||
expect(normal.ultraCodeOddsGood).toBe(0)
|
||||
expect(normal.UberCodeOddsNormal).toBe(0)
|
||||
expect(normal.UberCodeOddsGood).toBe(0)
|
||||
expect(normal.UltraCodeOddsNormal).toBe(0)
|
||||
expect(normal.UltraCodeOddsGood).toBe(0)
|
||||
|
||||
// Nightmare: 10, 20, 0, 0
|
||||
const nightmare = diffTable.nightmare
|
||||
expect(nightmare).toBeDefined()
|
||||
expect(nightmare.name).toBe('Nightmare')
|
||||
expect(nightmare.uberCodeOddsNormal).toBe(10)
|
||||
expect(nightmare.uberCodeOddsGood).toBe(20)
|
||||
expect(nightmare.ultraCodeOddsNormal).toBe(0)
|
||||
expect(nightmare.ultraCodeOddsGood).toBe(0)
|
||||
expect(nightmare.UberCodeOddsNormal).toBe(10)
|
||||
expect(nightmare.UberCodeOddsGood).toBe(20)
|
||||
expect(nightmare.UltraCodeOddsNormal).toBe(0)
|
||||
expect(nightmare.UltraCodeOddsGood).toBe(0)
|
||||
|
||||
// Hell: 20, 40, 30, 40
|
||||
const hell = diffTable.hell
|
||||
expect(hell).toBeDefined()
|
||||
expect(hell.name).toBe('Hell')
|
||||
expect(hell.uberCodeOddsNormal).toBe(20)
|
||||
expect(hell.uberCodeOddsGood).toBe(40)
|
||||
expect(hell.ultraCodeOddsNormal).toBe(30)
|
||||
expect(hell.ultraCodeOddsGood).toBe(40)
|
||||
expect(hell.UberCodeOddsNormal).toBe(20)
|
||||
expect(hell.UberCodeOddsGood).toBe(40)
|
||||
expect(hell.UltraCodeOddsNormal).toBe(30)
|
||||
expect(hell.UltraCodeOddsGood).toBe(40)
|
||||
})
|
||||
|
||||
it('supports case-insensitive lookup by difficulty name', () => {
|
||||
expect(diffTable.get('normal')?.uberCodeOddsNormal).toBe(0)
|
||||
expect(diffTable.get('Nightmare')?.uberCodeOddsGood).toBe(20)
|
||||
expect(diffTable.get('HELL')?.ultraCodeOddsGood).toBe(40)
|
||||
})
|
||||
|
||||
it('exposes direct properties for Normal, Nightmare, and Hell', () => {
|
||||
expect(diffTable.Normal.name).toBe('Normal')
|
||||
expect(diffTable.Nightmare.name).toBe('Nightmare')
|
||||
expect(diffTable.Hell.name).toBe('Hell')
|
||||
})
|
||||
})
|
||||
|
||||
describe('rollBaseUpgrade logic', () => {
|
||||
// Mock base items for isolated testing
|
||||
const capNormal: UpgradableItemBase = {
|
||||
id: 'cap',
|
||||
name: 'Cap',
|
||||
kind: 'armor',
|
||||
invWidth: 2,
|
||||
invHeight: 2,
|
||||
maxStack: 1,
|
||||
value: 12,
|
||||
damage: 0,
|
||||
defense: 5,
|
||||
tags: ['helm'],
|
||||
level: 1, // qlvl 1
|
||||
normcode: 'cap',
|
||||
ubercode: 'xap',
|
||||
ultracode: 'uap',
|
||||
}
|
||||
|
||||
const warHatExceptional: UpgradableItemBase = {
|
||||
id: 'xap',
|
||||
name: 'War Hat',
|
||||
kind: 'armor',
|
||||
invWidth: 2,
|
||||
invHeight: 2,
|
||||
maxStack: 1,
|
||||
value: 800,
|
||||
damage: 0,
|
||||
defense: 53,
|
||||
tags: ['helm'],
|
||||
level: 34, // qlvl 34
|
||||
normcode: 'cap',
|
||||
ubercode: 'xap',
|
||||
ultracode: 'uap',
|
||||
}
|
||||
|
||||
const shakoElite: UpgradableItemBase = {
|
||||
id: 'uap',
|
||||
name: 'Shako',
|
||||
kind: 'armor',
|
||||
invWidth: 2,
|
||||
invHeight: 2,
|
||||
maxStack: 1,
|
||||
value: 2400,
|
||||
damage: 0,
|
||||
defense: 141,
|
||||
tags: ['helm'],
|
||||
level: 58, // qlvl 58
|
||||
normcode: 'cap',
|
||||
ubercode: 'xap',
|
||||
ultracode: 'uap',
|
||||
}
|
||||
|
||||
const mockLookup = (code: string): ItemBase | undefined => {
|
||||
const normalized = code.trim().toLowerCase()
|
||||
if (normalized === 'cap') return capNormal
|
||||
if (normalized === 'xap') return warHatExceptional
|
||||
if (normalized === 'uap') return shakoElite
|
||||
return undefined
|
||||
}
|
||||
|
||||
it('Normal difficulty: odds are 0 -> base never upgrades and consumes 0 RNG rolls', () => {
|
||||
const rng = new D2Rng(12345)
|
||||
const randSpy = vi.spyOn(rng, 'rand')
|
||||
|
||||
// Test with Normal difficulty odds (0, 0)
|
||||
const result = rollBaseUpgrade(capNormal, 0, 0, rng, mockLookup)
|
||||
expect(result.base).toBe(capNormal)
|
||||
expect(result.base.id).toBe('cap')
|
||||
expect(result.base.level).toBe(1)
|
||||
expect(result.isUber).toBe(false)
|
||||
expect(randSpy).not.toHaveBeenCalled()
|
||||
|
||||
// Repeat multiple times with different seeds
|
||||
for (let seed = 1; seed <= 50; seed++) {
|
||||
const testRng = new D2Rng(seed)
|
||||
const res = rollBaseUpgrade(
|
||||
capNormal,
|
||||
diffTable.normal.uberCodeOddsNormal,
|
||||
diffTable.normal.ultraCodeOddsNormal,
|
||||
testRng,
|
||||
mockLookup,
|
||||
)
|
||||
expect(res.base.id).toBe('cap')
|
||||
expect(res.isUber).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('Test odds = 1024 -> base 100% upgrades to ubercode / ultracode', () => {
|
||||
// 1. Exceptional 100% upgrade (qf4 = 1024, qf5 = 0)
|
||||
const rng1 = new D2Rng(999)
|
||||
const resultUber = rollBaseUpgrade(capNormal, 1024, 0, rng1, mockLookup)
|
||||
expect(resultUber.base).toBe(warHatExceptional)
|
||||
expect(resultUber.base.id).toBe('xap')
|
||||
expect(resultUber.isUber).toBe(true)
|
||||
|
||||
// 2. Elite 100% upgrade (qf4 = 0, qf5 = 1024)
|
||||
const rng2 = new D2Rng(888)
|
||||
const resultUltra = rollBaseUpgrade(capNormal, 0, 1024, rng2, mockLookup)
|
||||
expect(resultUltra.base).toBe(shakoElite)
|
||||
expect(resultUltra.base.id).toBe('uap')
|
||||
expect(resultUltra.isUber).toBe(true)
|
||||
|
||||
// 3. Both 100% (qf4 = 1024, qf5 = 1024) -> elite takes priority
|
||||
const rng3 = new D2Rng(777)
|
||||
const resultBoth = rollBaseUpgrade(capNormal, 1024, 1024, rng3, mockLookup)
|
||||
expect(resultBoth.base).toBe(shakoElite)
|
||||
expect(resultBoth.base.id).toBe('uap')
|
||||
expect(resultBoth.isUber).toBe(true)
|
||||
})
|
||||
|
||||
it('Handle empty or "xxx" gracefully without upgrading', () => {
|
||||
const rng = new D2Rng(42)
|
||||
|
||||
// Base with 'xxx' for both ubercode and ultracode
|
||||
const xxxBase: UpgradableItemBase = {
|
||||
id: 'xxxitem',
|
||||
name: 'No Upgrades Item',
|
||||
kind: 'armor',
|
||||
invWidth: 1,
|
||||
invHeight: 1,
|
||||
maxStack: 1,
|
||||
value: 10,
|
||||
damage: 0,
|
||||
defense: 1,
|
||||
tags: [],
|
||||
level: 5,
|
||||
normcode: 'xxxitem',
|
||||
ubercode: 'xxx',
|
||||
ultracode: 'XXX',
|
||||
}
|
||||
|
||||
// Even with 100% odds, it must not upgrade
|
||||
const resXxx = rollBaseUpgrade(xxxBase, 1024, 1024, rng, () => undefined)
|
||||
expect(resXxx.base).toBe(xxxBase)
|
||||
expect(resXxx.isUber).toBe(false)
|
||||
|
||||
// Base with empty strings
|
||||
const emptyBase: UpgradableItemBase = {
|
||||
...xxxBase,
|
||||
ubercode: '',
|
||||
ultracode: ' ',
|
||||
}
|
||||
const resEmpty = rollBaseUpgrade(emptyBase, 1024, 1024, rng, () => undefined)
|
||||
expect(resEmpty.base).toBe(emptyBase)
|
||||
expect(resEmpty.isUber).toBe(false)
|
||||
|
||||
// Base without ubercode or ultracode fields
|
||||
const regularItemBase: ItemBase = {
|
||||
id: 'gold',
|
||||
name: 'Gold',
|
||||
kind: 'misc',
|
||||
invWidth: 1,
|
||||
invHeight: 1,
|
||||
maxStack: 50000,
|
||||
value: 1,
|
||||
damage: 0,
|
||||
defense: 0,
|
||||
tags: [],
|
||||
level: 1,
|
||||
}
|
||||
const resReg = rollBaseUpgrade(regularItemBase, 1024, 1024, rng, mockLookup)
|
||||
expect(resReg.base).toBe(regularItemBase)
|
||||
expect(resReg.isUber).toBe(false)
|
||||
|
||||
// Ultracode is 'xxx' but ubercode is valid: falls back to exceptional
|
||||
const mixedBase: UpgradableItemBase = {
|
||||
...capNormal,
|
||||
ubercode: 'xap',
|
||||
ultracode: 'xxx',
|
||||
}
|
||||
const resMixed = rollBaseUpgrade(mixedBase, 1024, 1024, rng, mockLookup)
|
||||
expect(resMixed.base).toBe(warHatExceptional)
|
||||
expect(resMixed.base.id).toBe('xap')
|
||||
expect(resMixed.isUber).toBe(true)
|
||||
|
||||
// Lookup returns undefined (unrecognized code): gracefully keeps original base
|
||||
const unresolvableBase: UpgradableItemBase = {
|
||||
...capNormal,
|
||||
ubercode: 'nonexistent',
|
||||
ultracode: 'unknown',
|
||||
}
|
||||
const resUnresolved = rollBaseUpgrade(unresolvableBase, 1024, 1024, rng, () => undefined)
|
||||
expect(resUnresolved.base).toBe(unresolvableBase)
|
||||
expect(resUnresolved.isUber).toBe(false)
|
||||
})
|
||||
|
||||
it('RNG consumption: exactly two rand(1024) rolls', () => {
|
||||
const rng = new D2Rng(100)
|
||||
const randSpy = vi.spyOn(rng, 'rand')
|
||||
|
||||
// Call with non-zero qf4 and qf5
|
||||
rollBaseUpgrade(capNormal, 20, 40, rng, mockLookup)
|
||||
|
||||
expect(randSpy).toHaveBeenCalledTimes(2)
|
||||
expect(randSpy).toHaveBeenNthCalledWith(1, 1024)
|
||||
expect(randSpy).toHaveBeenNthCalledWith(2, 1024)
|
||||
})
|
||||
|
||||
it('Switch to new base qlvl and sets isUber = true', () => {
|
||||
const rng = new D2Rng(123)
|
||||
|
||||
// 1. Exceptional upgrade changes base to War Hat (qlvl 34, isUber = true)
|
||||
const resExceptional = rollBaseUpgrade(capNormal, 1024, 0, rng, mockLookup)
|
||||
expect(resExceptional.isUber).toBe(true)
|
||||
expect(resExceptional.base.level).toBe(34) // Switches to War Hat qlvl
|
||||
expect(resExceptional.base.defense).toBe(53)
|
||||
|
||||
// 2. Elite upgrade changes base to Shako (qlvl 58, isUber = true)
|
||||
const resElite = rollBaseUpgrade(capNormal, 0, 1024, rng, mockLookup)
|
||||
expect(resElite.isUber).toBe(true)
|
||||
expect(resElite.base.level).toBe(58) // Switches to Shako qlvl
|
||||
expect(resElite.base.defense).toBe(141)
|
||||
|
||||
// 3. Non-upgraded base retains original qlvl and isUber = false
|
||||
const resNormal = rollBaseUpgrade(capNormal, 0, 0, rng, mockLookup)
|
||||
expect(resNormal.isUber).toBe(false)
|
||||
expect(resNormal.base.level).toBe(1) // Cap qlvl
|
||||
})
|
||||
|
||||
if (hasD2) {
|
||||
it('integrates with real Armor.txt bases and verifies tier progression', () => {
|
||||
expect(armorTable).toBeDefined()
|
||||
const cap = armorTable!.get('cap')
|
||||
expect(cap).toBeDefined()
|
||||
expect(cap?.ubercode).toBe('xap')
|
||||
expect(cap?.ultracode).toBe('uap')
|
||||
expect(cap?.level).toBe(1)
|
||||
|
||||
const lookup = (code: string) => armorTable!.get(code)
|
||||
const rng = new D2Rng(54321)
|
||||
|
||||
// Upgrade to exceptional
|
||||
const uberResult = rollBaseUpgrade(cap!, 1024, 0, rng, lookup)
|
||||
expect(uberResult.isUber).toBe(true)
|
||||
expect(uberResult.base.id).toBe('xap')
|
||||
expect(uberResult.base.level).toBe(34) // War Hat qlvl
|
||||
|
||||
// Upgrade to elite
|
||||
const eliteResult = rollBaseUpgrade(cap!, 0, 1024, rng, lookup)
|
||||
expect(eliteResult.isUber).toBe(true)
|
||||
expect(eliteResult.base.id).toBe('uap')
|
||||
expect(eliteResult.base.level).toBe(58) // Shako qlvl
|
||||
})
|
||||
|
||||
it('integrates with real Weapons.txt bases and verifies tier progression', () => {
|
||||
expect(weaponTable).toBeDefined()
|
||||
const handAxe = weaponTable!.get('hax')
|
||||
expect(handAxe).toBeDefined()
|
||||
expect(handAxe?.ubercode).toBe('9ha')
|
||||
expect(handAxe?.ultracode).toBe('7ha')
|
||||
expect(handAxe?.level).toBe(3)
|
||||
|
||||
const lookup = (code: string) => weaponTable!.get(code)
|
||||
const rng = new D2Rng(65432)
|
||||
|
||||
// Upgrade to exceptional (Hatchet)
|
||||
const uberResult = rollBaseUpgrade(handAxe!, 1024, 0, rng, lookup)
|
||||
expect(uberResult.isUber).toBe(true)
|
||||
expect(uberResult.base.id).toBe('9ha')
|
||||
expect(uberResult.base.level).toBe(31) // Hatchet qlvl
|
||||
|
||||
// Upgrade to elite (Tomahawk)
|
||||
const eliteResult = rollBaseUpgrade(handAxe!, 0, 1024, rng, lookup)
|
||||
expect(eliteResult.isUber).toBe(true)
|
||||
expect(eliteResult.base.id).toBe('7ha')
|
||||
expect(eliteResult.base.level).toBe(54) // Tomahawk qlvl
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('Disassembly alignment verification (D2Game!6FC32380 & 6FC31880)', () => {
|
||||
it('defines bitflags 0x04 for exceptional and 0x10 for elite', () => {
|
||||
expect(ITEM_UPGRADE_FLAG_EXCEPTIONAL).toBe(0x04)
|
||||
expect(ITEM_UPGRADE_FLAG_ELITE).toBe(0x10)
|
||||
})
|
||||
|
||||
it('verifies rand < odds threshold condition with specific seed', () => {
|
||||
const base: UpgradableItemBase = {
|
||||
id: 'tst',
|
||||
name: 'Test',
|
||||
kind: 'armor',
|
||||
invWidth: 1,
|
||||
invHeight: 1,
|
||||
maxStack: 1,
|
||||
value: 10,
|
||||
damage: 0,
|
||||
defense: 1,
|
||||
tags: [],
|
||||
level: 1,
|
||||
ubercode: 'xtst',
|
||||
ultracode: 'utst',
|
||||
}
|
||||
const xtst: ItemBase = { ...base, id: 'xtst', level: 25 }
|
||||
const utst: ItemBase = { ...base, id: 'utst', level: 50 }
|
||||
const lookup = (c: string) => (c === 'xtst' ? xtst : c === 'utst' ? utst : undefined)
|
||||
|
||||
// Test seed where we know exact rolls
|
||||
const rng = new D2Rng(0)
|
||||
const roll1 = rng.rand(1024)
|
||||
const roll2 = rng.rand(1024)
|
||||
|
||||
// Reset seed to replay exact rolls
|
||||
const testRng = new D2Rng(0)
|
||||
// Set qf4 to roll1 (so roll1 < qf4 is false) and qf5 to roll2 + 1 (so roll2 < qf5 is true)
|
||||
const res = rollBaseUpgrade(base, roll1, roll2 + 1, testRng, lookup)
|
||||
expect(res.base.id).toBe('utst')
|
||||
expect(res.isUber).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue