feat(items): 实现 ItemRatio 品质判定 (Issue #102)
This commit is contained in:
parent
ca3512f195
commit
6991ffa249
|
|
@ -0,0 +1,733 @@
|
|||
/**
|
||||
* Diablo II ItemRatio Quality Determination (`ItemRatio.txt`).
|
||||
*
|
||||
* Gold standard references:
|
||||
* - D2Game!6FC2FC40 (Quality determination algorithm & roll sequence)
|
||||
* - D2Game!6FC2E130 (Magic Find diminishing returns formula)
|
||||
* - D2Common!6FD510B0 / D2Common!6FD51180 (PRNG roll logic)
|
||||
*
|
||||
* In Diablo II, when an item base is dropped, the engine resolves its quality tier
|
||||
* through `ItemRatio.txt`. The row in `ItemRatio.txt` is looked up by:
|
||||
* - Version (0 = Classic, 1 = Expansion)
|
||||
* - Uber (0 = Normal, 1 = Exceptional / Elite)
|
||||
* - Class Specific (0 = Generic, 1 = Class-specific item like Orbs, Pelts, etc.)
|
||||
*
|
||||
* CRITICAL ENGINE PROPERTY:
|
||||
* The columns in `ItemRatio.txt` are listed as:
|
||||
* Unique -> Rare -> Set -> Magic -> HiQuality -> Normal
|
||||
* BUT the actual in-engine roll sequence at D2Game!6FC2FC40 is strictly:
|
||||
* Unique(7) -> Set(5) -> Rare(6) -> Magic(4) -> Superior(3) -> Normal(2) / Low(1)
|
||||
*
|
||||
* Every division in the algorithm uses 32-bit signed integer truncation toward zero
|
||||
* (replicated strictly via `Math.trunc`).
|
||||
*/
|
||||
import type { D2Table } from './acts.ts'
|
||||
import { cell, parseTable } from './acts.ts'
|
||||
import type { MountedArchives } from '../mpq/mount.ts'
|
||||
import { D2Rng } from './d2-rng.ts'
|
||||
import { ItemQuality } from './items.ts'
|
||||
|
||||
/** File path of `ItemRatio.txt` inside MPQ archives. */
|
||||
export const ITEM_RATIO_TABLE_PATH = 'data\\global\\excel\\ItemRatio.txt'
|
||||
|
||||
/** Magic Find diminishing returns divisor factors (D2Game!6FC2E130). */
|
||||
export const MF_FACTOR_UNIQUE = 250
|
||||
export const MF_FACTOR_SET = 500
|
||||
export const MF_FACTOR_RARE = 600
|
||||
|
||||
/** Quality tier names in strict roll order. */
|
||||
export type ItemQualityTier =
|
||||
| 'unique'
|
||||
| 'set'
|
||||
| 'rare'
|
||||
| 'magic'
|
||||
| 'superior'
|
||||
| 'normal'
|
||||
| 'low'
|
||||
|
||||
/** Mapping from ItemQualityTier string to numeric ItemQuality enum value. */
|
||||
export const QUALITY_TIER_TO_ITEM_QUALITY: Readonly<Record<ItemQualityTier, ItemQuality>> = {
|
||||
unique: ItemQuality.UNIQUE,
|
||||
set: ItemQuality.SET,
|
||||
rare: ItemQuality.RARE,
|
||||
magic: ItemQuality.MAGIC,
|
||||
superior: ItemQuality.SUPERIOR,
|
||||
normal: ItemQuality.NORMAL,
|
||||
low: ItemQuality.LOW,
|
||||
}
|
||||
|
||||
/** One row from `ItemRatio.txt`. */
|
||||
export interface ItemRatioRow {
|
||||
/** Optional function comment from table. */
|
||||
readonly function?: string | undefined
|
||||
/** 0 = Classic, 1 = Expansion. */
|
||||
readonly version: number
|
||||
/** 0 = Normal, 1 = Exceptional or Elite item. */
|
||||
readonly uber: number
|
||||
/** 0 = Generic, 1 = Class-specific item. */
|
||||
readonly classSpecific: number
|
||||
|
||||
/** Unique base chance, divisor, and minimum chance floor. */
|
||||
readonly unique: number
|
||||
readonly uniqueDiv: number
|
||||
readonly uniqueMin: number
|
||||
|
||||
/** Rare base chance, divisor, and minimum chance floor. */
|
||||
readonly rare: number
|
||||
readonly rareDiv: number
|
||||
readonly rareMin: number
|
||||
|
||||
/** Set base chance, divisor, and minimum chance floor. */
|
||||
readonly set: number
|
||||
readonly setDiv: number
|
||||
readonly setMin: number
|
||||
|
||||
/** Magic base chance, divisor, and minimum chance floor. */
|
||||
readonly magic: number
|
||||
readonly magicDiv: number
|
||||
readonly magicMin: number
|
||||
|
||||
/** High Quality (Superior) base chance and divisor (no MF, min, or TC factor). */
|
||||
readonly hiQual: number
|
||||
readonly hiQualDiv: number
|
||||
|
||||
/** Normal / Low quality base chance and divisor (no MF, min, or TC factor). */
|
||||
readonly normal: number
|
||||
readonly normalDiv: number
|
||||
|
||||
// Compatibility aliases
|
||||
readonly uniqueDivisor?: number | undefined
|
||||
readonly rareDivisor?: number | undefined
|
||||
readonly setDivisor?: number | undefined
|
||||
readonly magicDivisor?: number | undefined
|
||||
readonly hiQuality?: number | undefined
|
||||
readonly hiQualityDivisor?: number | undefined
|
||||
readonly normalDivisor?: number | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed ItemRatio table providing list and indexed lookup access.
|
||||
*/
|
||||
export interface ItemRatioTable {
|
||||
/** All parsed rows. */
|
||||
readonly rows: readonly ItemRatioRow[]
|
||||
/**
|
||||
* Looks up the appropriate row by version, uber flag, and classSpecific flag.
|
||||
*/
|
||||
getRow(
|
||||
version: number | boolean,
|
||||
uber: number | boolean,
|
||||
classSpecific: number | boolean,
|
||||
): ItemRatioRow | undefined
|
||||
/** Number of parsed rows (6 in vanilla 1.13c). */
|
||||
readonly length: number
|
||||
/** Iterates over all rows. */
|
||||
[Symbol.iterator](): IterableIterator<ItemRatioRow>
|
||||
}
|
||||
|
||||
/** PRNG source interface providing `rand(max: number): number`. */
|
||||
export interface QualityRollRng {
|
||||
rand(max: number): number
|
||||
}
|
||||
|
||||
/** TreasureClass ratio factor modifiers (fractions of 1024, e.g. from TreasureClassEx.txt). */
|
||||
export interface QualityRatioFactors {
|
||||
readonly unique?: number | undefined
|
||||
readonly set?: number | undefined
|
||||
readonly rare?: number | undefined
|
||||
readonly magic?: number | undefined
|
||||
}
|
||||
|
||||
/** Context parameters passed to `rollItemQuality`. */
|
||||
export interface QualityRollContext {
|
||||
/** Drop item level (e.g. monster level or area level). */
|
||||
readonly ilvl: number
|
||||
/** Quality level of the base item (from weapons/armor/misc.txt). */
|
||||
readonly qlvl: number
|
||||
/** Magic Find percentage (e.g. 0, 50, 100). */
|
||||
readonly magicFind?: number | undefined
|
||||
/** Alternative alias for magicFind. */
|
||||
readonly mf?: number | undefined
|
||||
|
||||
/** ItemRatio row to use directly. */
|
||||
readonly row?: ItemRatioRow | undefined
|
||||
/** ItemRatio table to look up row from if `row` is not provided. */
|
||||
readonly table?: ItemRatioTable | undefined
|
||||
|
||||
/** Game version: 0 = Classic, 1 = Expansion (LoD). Defaults to 1. */
|
||||
readonly version?: number | boolean | undefined
|
||||
/** Whether the item is exceptional or elite. Defaults to 0/false. */
|
||||
readonly uber?: number | boolean | undefined
|
||||
/** Whether the item is class-specific. Defaults to 0/false. */
|
||||
readonly classSpecific?: number | boolean | undefined
|
||||
|
||||
/** TreasureClass quality modifiers (fractions of 1024). */
|
||||
readonly tcFactors?: QualityRatioFactors | undefined
|
||||
/** Single TC factor fallback (applied to all if specific tier not given). */
|
||||
readonly tcFactor?: number | undefined
|
||||
|
||||
/** Explicit TC modifiers (fractions of 1024). */
|
||||
readonly tcUnique?: number | undefined
|
||||
readonly tcSet?: number | undefined
|
||||
readonly tcRare?: number | undefined
|
||||
readonly tcMagic?: number | undefined
|
||||
|
||||
/** Optional quality filters: if false, that tier is bypassed during roll. */
|
||||
readonly allowUnique?: boolean | undefined
|
||||
readonly allowSet?: boolean | undefined
|
||||
readonly allowRare?: boolean | undefined
|
||||
readonly allowMagic?: boolean | undefined
|
||||
readonly allowSuperior?: boolean | undefined
|
||||
|
||||
/** PRNG instance implementing `rand(max: number): number`. Defaults to new D2Rng(). */
|
||||
readonly rng?: QualityRollRng | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates effective magic find with diminishing returns (D2Game!6FC2E130).
|
||||
*
|
||||
* For mf <= 10, no diminishing returns are applied: returns mf + 100.
|
||||
* For mf > 10: returns 100 + Math.trunc((mf * factor) / (mf + factor)).
|
||||
*
|
||||
* @param mf - Player/minion magic find percentage.
|
||||
* @param factor - Diminishing returns factor (250 for Unique, 500 for Set, 600 for Rare).
|
||||
* @returns Diminished multiplier (100-based).
|
||||
*/
|
||||
export function effectiveMF(mf: number, factor: number): number {
|
||||
const x = mf + 100
|
||||
if (x <= 110) {
|
||||
return x
|
||||
}
|
||||
return 100 + Math.trunc((mf * factor) / (mf + factor))
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates final chance denominator for Unique quality (D2Game!6FC2FC40).
|
||||
*/
|
||||
export function calculateUniqueChance(
|
||||
row: ItemRatioRow,
|
||||
ilvl: number,
|
||||
qlvl: number,
|
||||
mf = 0,
|
||||
tcFactor = 0,
|
||||
): number {
|
||||
const D = ilvl - qlvl
|
||||
const divisor = row.uniqueDiv || 1
|
||||
let chance = (row.unique - Math.trunc(D / divisor)) * 128
|
||||
if (mf > 0) {
|
||||
const e = effectiveMF(mf, MF_FACTOR_UNIQUE)
|
||||
if (e > 0) {
|
||||
chance = Math.trunc((chance * 100) / e)
|
||||
}
|
||||
}
|
||||
if (chance < row.uniqueMin) {
|
||||
chance = row.uniqueMin
|
||||
}
|
||||
chance -= Math.trunc((tcFactor * chance) / 1024)
|
||||
return chance
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates final chance denominator for Set quality (D2Game!6FC2FC40).
|
||||
*/
|
||||
export function calculateSetChance(
|
||||
row: ItemRatioRow,
|
||||
ilvl: number,
|
||||
qlvl: number,
|
||||
mf = 0,
|
||||
tcFactor = 0,
|
||||
): number {
|
||||
const D = ilvl - qlvl
|
||||
const divisor = row.setDiv || 1
|
||||
let chance = (row.set - Math.trunc(D / divisor)) * 128
|
||||
if (mf > 0) {
|
||||
const e = effectiveMF(mf, MF_FACTOR_SET)
|
||||
if (e > 0) {
|
||||
chance = Math.trunc((chance * 100) / e)
|
||||
}
|
||||
}
|
||||
if (chance < row.setMin) {
|
||||
chance = row.setMin
|
||||
}
|
||||
chance -= Math.trunc((tcFactor * chance) / 1024)
|
||||
return chance
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates final chance denominator for Rare quality (D2Game!6FC2FC40).
|
||||
*/
|
||||
export function calculateRareChance(
|
||||
row: ItemRatioRow,
|
||||
ilvl: number,
|
||||
qlvl: number,
|
||||
mf = 0,
|
||||
tcFactor = 0,
|
||||
): number {
|
||||
const D = ilvl - qlvl
|
||||
const divisor = row.rareDiv || 1
|
||||
let chance = (row.rare - Math.trunc(D / divisor)) * 128
|
||||
if (mf > 0) {
|
||||
const e = effectiveMF(mf, MF_FACTOR_RARE)
|
||||
if (e > 0) {
|
||||
chance = Math.trunc((chance * 100) / e)
|
||||
}
|
||||
}
|
||||
if (chance < row.rareMin) {
|
||||
chance = row.rareMin
|
||||
}
|
||||
chance -= Math.trunc((tcFactor * chance) / 1024)
|
||||
return chance
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates final chance denominator for Magic quality (D2Game!6FC2FC40).
|
||||
* Magic has NO diminishing returns: divisor is `mf + 100`.
|
||||
*/
|
||||
export function calculateMagicChance(
|
||||
row: ItemRatioRow,
|
||||
ilvl: number,
|
||||
qlvl: number,
|
||||
mf = 0,
|
||||
tcFactor = 0,
|
||||
): number {
|
||||
const D = ilvl - qlvl
|
||||
const divisor = row.magicDiv || 1
|
||||
let chance = (row.magic - Math.trunc(D / divisor)) * 128
|
||||
if (mf > 0) {
|
||||
chance = Math.trunc((chance * 100) / (mf + 100))
|
||||
}
|
||||
if (chance < row.magicMin) {
|
||||
chance = row.magicMin
|
||||
}
|
||||
chance -= Math.trunc((tcFactor * chance) / 1024)
|
||||
return chance
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates chance denominator for Superior (High Quality) quality.
|
||||
* Superior does not take MF, has no min floor, and no TC factor.
|
||||
*/
|
||||
export function calculateSuperiorChance(
|
||||
row: ItemRatioRow,
|
||||
ilvl: number,
|
||||
qlvl: number,
|
||||
): number {
|
||||
const D = ilvl - qlvl
|
||||
const divisor = row.hiQualDiv || 1
|
||||
return (row.hiQual - Math.trunc(D / divisor)) * 128
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates chance denominator for Normal quality.
|
||||
* Normal does not take MF, has no min floor, and no TC factor.
|
||||
*/
|
||||
export function calculateNormalChance(
|
||||
row: ItemRatioRow,
|
||||
ilvl: number,
|
||||
qlvl: number,
|
||||
): number {
|
||||
const D = ilvl - qlvl
|
||||
const divisor = row.normalDiv || 1
|
||||
return (row.normal - Math.trunc(D / divisor)) * 128
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes ItemRatio quality determination roll in exact order:
|
||||
* Unique(7) -> Set(5) -> Rare(6) -> Magic(4) -> Superior(3) -> Normal(2) / Low(1).
|
||||
*
|
||||
* @param context - Quality roll parameters (ilvl, qlvl, mf, factors, rng, etc.).
|
||||
* @returns Rolled ItemQualityTier.
|
||||
*/
|
||||
export function rollItemQuality(context: QualityRollContext): ItemQualityTier {
|
||||
const { ilvl, qlvl } = context
|
||||
const mf = context.magicFind ?? context.mf ?? 0
|
||||
const rng = context.rng ?? new D2Rng()
|
||||
|
||||
let row = context.row
|
||||
if (!row) {
|
||||
const table = context.table ?? DEFAULT_ITEM_RATIO_TABLE
|
||||
row = table.getRow(
|
||||
context.version ?? 1,
|
||||
context.uber ?? 0,
|
||||
context.classSpecific ?? 0,
|
||||
)
|
||||
}
|
||||
if (!row) {
|
||||
throw new Error('rollItemQuality: no matching ItemRatio row found')
|
||||
}
|
||||
|
||||
const tcUnique = context.tcFactors?.unique ?? context.tcUnique ?? context.tcFactor ?? 0
|
||||
const tcSet = context.tcFactors?.set ?? context.tcSet ?? context.tcFactor ?? 0
|
||||
const tcRare = context.tcFactors?.rare ?? context.tcRare ?? context.tcFactor ?? 0
|
||||
const tcMagic = context.tcFactors?.magic ?? context.tcMagic ?? context.tcFactor ?? 0
|
||||
|
||||
// 1. Unique (7)
|
||||
if (context.allowUnique !== false) {
|
||||
const chance = calculateUniqueChance(row, ilvl, qlvl, mf, tcUnique)
|
||||
if (chance <= 0 || rng.rand(chance) < 128) {
|
||||
return 'unique'
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Set (5)
|
||||
if (context.allowSet !== false) {
|
||||
const chance = calculateSetChance(row, ilvl, qlvl, mf, tcSet)
|
||||
if (chance <= 0 || rng.rand(chance) < 128) {
|
||||
return 'set'
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Rare (6)
|
||||
if (context.allowRare !== false) {
|
||||
const chance = calculateRareChance(row, ilvl, qlvl, mf, tcRare)
|
||||
if (chance <= 0 || rng.rand(chance) < 128) {
|
||||
return 'rare'
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Magic (4)
|
||||
if (context.allowMagic !== false) {
|
||||
const chance = calculateMagicChance(row, ilvl, qlvl, mf, tcMagic)
|
||||
if (chance <= 0 || rng.rand(chance) < 128) {
|
||||
return 'magic'
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Superior (3)
|
||||
if (context.allowSuperior !== false) {
|
||||
const chance = calculateSuperiorChance(row, ilvl, qlvl)
|
||||
if (chance <= 0 || rng.rand(chance) < 128) {
|
||||
return 'superior'
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Normal (2) / Low (1)
|
||||
const chance = calculateNormalChance(row, ilvl, qlvl)
|
||||
if (chance <= 0) {
|
||||
return 'normal'
|
||||
}
|
||||
return rng.rand(chance) < 128 ? 'normal' : 'low'
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an ItemRatioTable from an array of ItemRatioRow records.
|
||||
*/
|
||||
export function createItemRatioTable(rows: readonly ItemRatioRow[]): ItemRatioTable {
|
||||
return {
|
||||
rows,
|
||||
length: rows.length,
|
||||
getRow(
|
||||
version: number | boolean,
|
||||
uber: number | boolean,
|
||||
classSpecific: number | boolean,
|
||||
): ItemRatioRow | undefined {
|
||||
const v = typeof version === 'boolean' ? (version ? 1 : 0) : version
|
||||
const u = typeof uber === 'boolean' ? (uber ? 1 : 0) : uber
|
||||
const cs = typeof classSpecific === 'boolean' ? (classSpecific ? 1 : 0) : classSpecific
|
||||
|
||||
// 1. Exact match
|
||||
let match = rows.find(r => r.version === v && r.uber === u && r.classSpecific === cs)
|
||||
if (match) return match
|
||||
|
||||
// 2. Classic (v=0) classSpecific fallback to Expansion (v=1) classSpecific
|
||||
if (v === 0 && cs === 1) {
|
||||
match = rows.find(r => r.version === 1 && r.uber === u && r.classSpecific === 1)
|
||||
if (match) return match
|
||||
}
|
||||
|
||||
// 3. Fallback matching version and uber
|
||||
match = rows.find(r => r.version === v && r.uber === u)
|
||||
if (match) return match
|
||||
|
||||
// 4. Default to first row
|
||||
return rows[0]
|
||||
},
|
||||
[Symbol.iterator]() {
|
||||
return rows[Symbol.iterator]()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Parses cell value as integer. */
|
||||
function parseNum(val: string, fallback = 0): number {
|
||||
const trimmed = val.trim()
|
||||
if (trimmed === '') return fallback
|
||||
const n = Number(trimmed)
|
||||
return Number.isFinite(n) ? n : fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a D2Table (from ItemRatio.txt) into an ItemRatioTable.
|
||||
*
|
||||
* @param table - Parsed tab-separated table.
|
||||
* @returns Structured ItemRatioTable.
|
||||
*/
|
||||
export function parseItemRatioTable(table: D2Table): ItemRatioTable {
|
||||
const rows: ItemRatioRow[] = []
|
||||
for (const rawRow of table.rows) {
|
||||
if (rawRow.length === 0 || rawRow.every(c => c.trim() === '')) {
|
||||
continue
|
||||
}
|
||||
|
||||
const fn = cell(table, rawRow, 'Function')
|
||||
const version = parseNum(cell(table, rawRow, 'Version'))
|
||||
const uber = parseNum(cell(table, rawRow, 'Uber'))
|
||||
const classSpecific = parseNum(
|
||||
cell(table, rawRow, 'Class Specific') ||
|
||||
cell(table, rawRow, 'ClassSpecific') ||
|
||||
cell(table, rawRow, 'class specific'),
|
||||
)
|
||||
|
||||
const unique = parseNum(cell(table, rawRow, 'Unique'))
|
||||
const uniqueDiv = parseNum(cell(table, rawRow, 'UniqueDivisor') || cell(table, rawRow, 'UniqueDiv'))
|
||||
const uniqueMin = parseNum(cell(table, rawRow, 'UniqueMin'))
|
||||
|
||||
const rare = parseNum(cell(table, rawRow, 'Rare'))
|
||||
const rareDiv = parseNum(cell(table, rawRow, 'RareDivisor') || cell(table, rawRow, 'RareDiv'))
|
||||
const rareMin = parseNum(cell(table, rawRow, 'RareMin'))
|
||||
|
||||
const set = parseNum(cell(table, rawRow, 'Set'))
|
||||
const setDiv = parseNum(cell(table, rawRow, 'SetDivisor') || cell(table, rawRow, 'SetDiv'))
|
||||
const setMin = parseNum(cell(table, rawRow, 'SetMin'))
|
||||
|
||||
const magic = parseNum(cell(table, rawRow, 'Magic'))
|
||||
const magicDiv = parseNum(cell(table, rawRow, 'MagicDivisor') || cell(table, rawRow, 'MagicDiv'))
|
||||
const magicMin = parseNum(cell(table, rawRow, 'MagicMin'))
|
||||
|
||||
const hiQual = parseNum(cell(table, rawRow, 'HiQuality') || cell(table, rawRow, 'HiQual'))
|
||||
const hiQualDiv = parseNum(cell(table, rawRow, 'HiQualityDivisor') || cell(table, rawRow, 'HiQualDiv'))
|
||||
|
||||
const normal = parseNum(cell(table, rawRow, 'Normal'))
|
||||
const normalDiv = parseNum(cell(table, rawRow, 'NormalDivisor') || cell(table, rawRow, 'NormalDiv'))
|
||||
|
||||
rows.push({
|
||||
function: fn || undefined,
|
||||
version,
|
||||
uber,
|
||||
classSpecific,
|
||||
unique,
|
||||
uniqueDiv,
|
||||
uniqueMin,
|
||||
rare,
|
||||
rareDiv,
|
||||
rareMin,
|
||||
set,
|
||||
setDiv,
|
||||
setMin,
|
||||
magic,
|
||||
magicDiv,
|
||||
magicMin,
|
||||
hiQual,
|
||||
hiQualDiv,
|
||||
normal,
|
||||
normalDiv,
|
||||
uniqueDivisor: uniqueDiv,
|
||||
rareDivisor: rareDiv,
|
||||
setDivisor: setDiv,
|
||||
magicDivisor: magicDiv,
|
||||
hiQuality: hiQual,
|
||||
hiQualityDivisor: hiQualDiv,
|
||||
normalDivisor: normalDiv,
|
||||
})
|
||||
}
|
||||
|
||||
return createItemRatioTable(rows)
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads and parses `ItemRatio.txt` from mounted MPQ archives.
|
||||
*
|
||||
* @param archives - Mounted MPQ archives.
|
||||
* @param tablePath - Optional path inside archive (defaults to ITEM_RATIO_TABLE_PATH).
|
||||
* @returns Promise resolving to `ItemRatioTable`.
|
||||
*/
|
||||
export async function loadItemRatio(
|
||||
archives: MountedArchives,
|
||||
tablePath = ITEM_RATIO_TABLE_PATH,
|
||||
): Promise<ItemRatioTable> {
|
||||
const bytes = await archives.read(tablePath)
|
||||
const d2Table = parseTable(bytes)
|
||||
return parseItemRatioTable(d2Table)
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical 1.13c ItemRatio.txt rows embedded as fallback defaults.
|
||||
*/
|
||||
export const DEFAULT_ITEM_RATIO_ROWS: readonly ItemRatioRow[] = [
|
||||
{
|
||||
function: 'Ratio - (Monster Level / Ratio Divisor)',
|
||||
version: 0,
|
||||
uber: 0,
|
||||
classSpecific: 0,
|
||||
unique: 400,
|
||||
uniqueDiv: 2,
|
||||
uniqueMin: 6400,
|
||||
rare: 160,
|
||||
rareDiv: 3,
|
||||
rareMin: 3200,
|
||||
set: 125,
|
||||
setDiv: 6,
|
||||
setMin: 5600,
|
||||
magic: 30,
|
||||
magicDiv: 16,
|
||||
magicMin: 192,
|
||||
hiQual: 12,
|
||||
hiQualDiv: 16,
|
||||
normal: 4,
|
||||
normalDiv: 8,
|
||||
uniqueDivisor: 2,
|
||||
rareDivisor: 3,
|
||||
setDivisor: 6,
|
||||
magicDivisor: 16,
|
||||
hiQuality: 12,
|
||||
hiQualityDivisor: 16,
|
||||
normalDivisor: 8,
|
||||
},
|
||||
{
|
||||
function: 'Uber',
|
||||
version: 0,
|
||||
uber: 1,
|
||||
classSpecific: 0,
|
||||
unique: 240,
|
||||
uniqueDiv: 2,
|
||||
uniqueMin: 6400,
|
||||
rare: 96,
|
||||
rareDiv: 3,
|
||||
rareMin: 3200,
|
||||
set: 96,
|
||||
setDiv: 6,
|
||||
setMin: 5600,
|
||||
magic: 3,
|
||||
magicDiv: 100,
|
||||
magicMin: 192,
|
||||
hiQual: 4,
|
||||
hiQualDiv: 16,
|
||||
normal: 1,
|
||||
normalDiv: 8,
|
||||
uniqueDivisor: 2,
|
||||
rareDivisor: 3,
|
||||
setDivisor: 6,
|
||||
magicDivisor: 100,
|
||||
hiQuality: 4,
|
||||
hiQualityDivisor: 16,
|
||||
normalDivisor: 8,
|
||||
},
|
||||
{
|
||||
function: 'Ratio - (Monster Level / Ratio Divisor)',
|
||||
version: 1,
|
||||
uber: 0,
|
||||
classSpecific: 0,
|
||||
unique: 400,
|
||||
uniqueDiv: 1,
|
||||
uniqueMin: 6400,
|
||||
rare: 100,
|
||||
rareDiv: 2,
|
||||
rareMin: 3200,
|
||||
set: 160,
|
||||
setDiv: 2,
|
||||
setMin: 5600,
|
||||
magic: 34,
|
||||
magicDiv: 3,
|
||||
magicMin: 192,
|
||||
hiQual: 12,
|
||||
hiQualDiv: 8,
|
||||
normal: 2,
|
||||
normalDiv: 2,
|
||||
uniqueDivisor: 1,
|
||||
rareDivisor: 2,
|
||||
setDivisor: 2,
|
||||
magicDivisor: 3,
|
||||
hiQuality: 12,
|
||||
hiQualityDivisor: 8,
|
||||
normalDivisor: 2,
|
||||
},
|
||||
{
|
||||
function: 'Uber',
|
||||
version: 1,
|
||||
uber: 1,
|
||||
classSpecific: 0,
|
||||
unique: 400,
|
||||
uniqueDiv: 1,
|
||||
uniqueMin: 6400,
|
||||
rare: 100,
|
||||
rareDiv: 2,
|
||||
rareMin: 3200,
|
||||
set: 160,
|
||||
setDiv: 2,
|
||||
setMin: 5600,
|
||||
magic: 34,
|
||||
magicDiv: 3,
|
||||
magicMin: 192,
|
||||
hiQual: 12,
|
||||
hiQualDiv: 8,
|
||||
normal: 1,
|
||||
normalDiv: 1,
|
||||
uniqueDivisor: 1,
|
||||
rareDivisor: 2,
|
||||
setDivisor: 2,
|
||||
magicDivisor: 3,
|
||||
hiQuality: 12,
|
||||
hiQualityDivisor: 8,
|
||||
normalDivisor: 1,
|
||||
},
|
||||
{
|
||||
function: 'Class Specific',
|
||||
version: 1,
|
||||
uber: 0,
|
||||
classSpecific: 1,
|
||||
unique: 240,
|
||||
uniqueDiv: 3,
|
||||
uniqueMin: 6400,
|
||||
rare: 80,
|
||||
rareDiv: 3,
|
||||
rareMin: 3200,
|
||||
set: 120,
|
||||
setDiv: 3,
|
||||
setMin: 5600,
|
||||
magic: 17,
|
||||
magicDiv: 6,
|
||||
magicMin: 192,
|
||||
hiQual: 9,
|
||||
hiQualDiv: 8,
|
||||
normal: 2,
|
||||
normalDiv: 2,
|
||||
uniqueDivisor: 3,
|
||||
rareDivisor: 3,
|
||||
setDivisor: 3,
|
||||
magicDivisor: 6,
|
||||
hiQuality: 9,
|
||||
hiQualityDivisor: 8,
|
||||
normalDivisor: 2,
|
||||
},
|
||||
{
|
||||
function: 'Class Specific Uber',
|
||||
version: 1,
|
||||
uber: 1,
|
||||
classSpecific: 1,
|
||||
unique: 240,
|
||||
uniqueDiv: 3,
|
||||
uniqueMin: 6400,
|
||||
rare: 80,
|
||||
rareDiv: 3,
|
||||
rareMin: 3200,
|
||||
set: 120,
|
||||
setDiv: 3,
|
||||
setMin: 5600,
|
||||
magic: 17,
|
||||
magicDiv: 6,
|
||||
magicMin: 192,
|
||||
hiQual: 9,
|
||||
hiQualDiv: 8,
|
||||
normal: 1,
|
||||
normalDiv: 1,
|
||||
uniqueDivisor: 3,
|
||||
rareDivisor: 3,
|
||||
setDivisor: 3,
|
||||
magicDivisor: 6,
|
||||
hiQuality: 9,
|
||||
hiQualityDivisor: 8,
|
||||
normalDivisor: 1,
|
||||
},
|
||||
]
|
||||
|
||||
export const DEFAULT_ITEM_RATIO_TABLE = createItemRatioTable(DEFAULT_ITEM_RATIO_ROWS)
|
||||
|
|
@ -0,0 +1,446 @@
|
|||
import { describe, it, expect, beforeAll } from 'vitest'
|
||||
import * as fs from 'node:fs'
|
||||
import { MountedArchives } from '../src/mpq/mount.ts'
|
||||
import { MpqArchive } from '../src/mpq/archive.ts'
|
||||
import { fileSource } from '../src/mpq/file-source.ts'
|
||||
import { parseTable } from '../src/game/acts.ts'
|
||||
import {
|
||||
ITEM_RATIO_TABLE_PATH,
|
||||
MF_FACTOR_UNIQUE,
|
||||
MF_FACTOR_SET,
|
||||
MF_FACTOR_RARE,
|
||||
DEFAULT_ITEM_RATIO_ROWS,
|
||||
DEFAULT_ITEM_RATIO_TABLE,
|
||||
effectiveMF,
|
||||
calculateUniqueChance,
|
||||
calculateSetChance,
|
||||
calculateRareChance,
|
||||
calculateMagicChance,
|
||||
calculateSuperiorChance,
|
||||
calculateNormalChance,
|
||||
rollItemQuality,
|
||||
parseItemRatioTable,
|
||||
loadItemRatio,
|
||||
type ItemRatioRow,
|
||||
type ItemRatioTable,
|
||||
type QualityRollRng,
|
||||
} from '../src/game/item-ratio.ts'
|
||||
|
||||
const hasD2 = fs.existsSync('samples/d2/d2data.mpq')
|
||||
|
||||
describe('ItemRatio Quality Determination (Issue #102)', () => {
|
||||
let archives: MountedArchives
|
||||
let loadedTable: ItemRatioTable
|
||||
|
||||
beforeAll(async () => {
|
||||
if (hasD2) {
|
||||
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}`)))
|
||||
}
|
||||
loadedTable = await loadItemRatio(archives)
|
||||
}
|
||||
})
|
||||
|
||||
describe('1. Table Loading and Row Lookup', () => {
|
||||
it('uses the canonical MPQ table path', () => {
|
||||
expect(ITEM_RATIO_TABLE_PATH).toBe('data\\global\\excel\\ItemRatio.txt')
|
||||
})
|
||||
|
||||
it('parses exactly 6 rows from MPQ table', () => {
|
||||
if (!hasD2) return
|
||||
expect(loadedTable.length).toBe(6)
|
||||
expect(loadedTable.rows.length).toBe(6)
|
||||
})
|
||||
|
||||
it('matches canonical 1.13c values in default and loaded table', () => {
|
||||
const table = loadedTable ?? DEFAULT_ITEM_RATIO_TABLE
|
||||
|
||||
// Row 0: Classic, Normal, Generic (version=0, uber=0, classSpecific=0)
|
||||
const r0 = table.getRow(0, 0, 0)!
|
||||
expect(r0).toBeDefined()
|
||||
expect(r0.version).toBe(0)
|
||||
expect(r0.uber).toBe(0)
|
||||
expect(r0.classSpecific).toBe(0)
|
||||
expect(r0.unique).toBe(400)
|
||||
expect(r0.uniqueDiv).toBe(2)
|
||||
expect(r0.uniqueMin).toBe(6400)
|
||||
expect(r0.rare).toBe(160)
|
||||
expect(r0.rareDiv).toBe(3)
|
||||
expect(r0.rareMin).toBe(3200)
|
||||
expect(r0.set).toBe(125)
|
||||
expect(r0.setDiv).toBe(6)
|
||||
expect(r0.setMin).toBe(5600)
|
||||
expect(r0.magic).toBe(30)
|
||||
expect(r0.magicDiv).toBe(16)
|
||||
expect(r0.magicMin).toBe(192)
|
||||
expect(r0.hiQual).toBe(12)
|
||||
expect(r0.hiQualDiv).toBe(16)
|
||||
expect(r0.normal).toBe(4)
|
||||
expect(r0.normalDiv).toBe(8)
|
||||
|
||||
// Row 2: Expansion, Normal, Generic (version=1, uber=0, classSpecific=0)
|
||||
const r2 = table.getRow(1, 0, 0)!
|
||||
expect(r2).toBeDefined()
|
||||
expect(r2.version).toBe(1)
|
||||
expect(r2.uber).toBe(0)
|
||||
expect(r2.classSpecific).toBe(0)
|
||||
expect(r2.unique).toBe(400)
|
||||
expect(r2.uniqueDiv).toBe(1)
|
||||
expect(r2.uniqueMin).toBe(6400)
|
||||
expect(r2.rare).toBe(100)
|
||||
expect(r2.rareDiv).toBe(2)
|
||||
expect(r2.rareMin).toBe(3200)
|
||||
expect(r2.set).toBe(160)
|
||||
expect(r2.setDiv).toBe(2)
|
||||
expect(r2.setMin).toBe(5600)
|
||||
expect(r2.magic).toBe(34)
|
||||
expect(r2.magicDiv).toBe(3)
|
||||
expect(r2.magicMin).toBe(192)
|
||||
expect(r2.hiQual).toBe(12)
|
||||
expect(r2.hiQualDiv).toBe(8)
|
||||
expect(r2.normal).toBe(2)
|
||||
expect(r2.normalDiv).toBe(2)
|
||||
|
||||
// Row 4: Expansion, Normal, ClassSpecific (version=1, uber=0, classSpecific=1)
|
||||
const r4 = table.getRow(1, 0, 1)!
|
||||
expect(r4).toBeDefined()
|
||||
expect(r4.version).toBe(1)
|
||||
expect(r4.uber).toBe(0)
|
||||
expect(r4.classSpecific).toBe(1)
|
||||
expect(r4.unique).toBe(240)
|
||||
expect(r4.uniqueDiv).toBe(3)
|
||||
expect(r4.magic).toBe(17)
|
||||
expect(r4.magicDiv).toBe(6)
|
||||
|
||||
// Row 5: Expansion, Uber, ClassSpecific (version=1, uber=1, classSpecific=1)
|
||||
const r5 = table.getRow(1, 1, 1)!
|
||||
expect(r5).toBeDefined()
|
||||
expect(r5.version).toBe(1)
|
||||
expect(r5.uber).toBe(1)
|
||||
expect(r5.classSpecific).toBe(1)
|
||||
expect(r5.normal).toBe(1)
|
||||
expect(r5.normalDiv).toBe(1)
|
||||
})
|
||||
|
||||
it('correctly handles boolean flags in getRow', () => {
|
||||
const table = DEFAULT_ITEM_RATIO_TABLE
|
||||
const row = table.getRow(true, false, true)
|
||||
expect(row).toBeDefined()
|
||||
expect(row?.version).toBe(1)
|
||||
expect(row?.uber).toBe(0)
|
||||
expect(row?.classSpecific).toBe(1)
|
||||
})
|
||||
|
||||
it('falls back gracefully when querying classic class-specific row', () => {
|
||||
// Classic has no class-specific items in vanilla, falls back to LoD class-specific
|
||||
const table = DEFAULT_ITEM_RATIO_TABLE
|
||||
const row = table.getRow(0, 0, 1)
|
||||
expect(row).toBeDefined()
|
||||
expect(row?.classSpecific).toBe(1)
|
||||
})
|
||||
|
||||
it('parses D2Table via parseItemRatioTable with custom headers', () => {
|
||||
const sampleText =
|
||||
'Function\tVersion\tUber\tClass Specific\tUnique\tUniqueDiv\tUniqueMin\tRare\tRareDiv\tRareMin\tSet\tSetDiv\tSetMin\tMagic\tMagicDiv\tMagicMin\tHiQual\tHiQualDiv\tNormal\tNormalDiv\n' +
|
||||
'Custom\t1\t0\t0\t500\t2\t6000\t120\t3\t3000\t180\t4\t5000\t40\t5\t200\t15\t10\t5\t3\n'
|
||||
const rawBytes = new TextEncoder().encode(sampleText)
|
||||
const d2t = parseTable(rawBytes)
|
||||
const parsed = parseItemRatioTable(d2t)
|
||||
expect(parsed.length).toBe(1)
|
||||
const r = parsed.getRow(1, 0, 0)!
|
||||
expect(r.unique).toBe(500)
|
||||
expect(r.uniqueDiv).toBe(2)
|
||||
expect(r.uniqueMin).toBe(6000)
|
||||
expect(r.rare).toBe(120)
|
||||
expect(r.set).toBe(180)
|
||||
expect(r.magic).toBe(40)
|
||||
expect(r.hiQual).toBe(15)
|
||||
expect(r.normal).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('2. Effective Magic Find Formula (D2Game!6FC2E130)', () => {
|
||||
it('has NO diminishing returns when mf <= 10', () => {
|
||||
// When x = mf + 100 <= 110, returns x
|
||||
expect(effectiveMF(-20, MF_FACTOR_UNIQUE)).toBe(80)
|
||||
expect(effectiveMF(0, MF_FACTOR_UNIQUE)).toBe(100)
|
||||
expect(effectiveMF(1, MF_FACTOR_UNIQUE)).toBe(101)
|
||||
expect(effectiveMF(5, MF_FACTOR_UNIQUE)).toBe(105)
|
||||
expect(effectiveMF(10, MF_FACTOR_UNIQUE)).toBe(110)
|
||||
expect(effectiveMF(10, MF_FACTOR_SET)).toBe(110)
|
||||
expect(effectiveMF(10, MF_FACTOR_RARE)).toBe(110)
|
||||
})
|
||||
|
||||
it('applies diminishing returns strictly when mf > 10', () => {
|
||||
// mf = 11 for Unique (factor = 250):
|
||||
// 100 + Math.trunc((11 * 250) / (11 + 250)) = 100 + Math.trunc(2750 / 261) = 100 + 10 = 110
|
||||
expect(effectiveMF(11, MF_FACTOR_UNIQUE)).toBe(110)
|
||||
|
||||
// mf = 12 for Unique:
|
||||
// 100 + Math.trunc((12 * 250) / (12 + 250)) = 100 + Math.trunc(3000 / 262) = 100 + 11 = 111
|
||||
expect(effectiveMF(12, MF_FACTOR_UNIQUE)).toBe(111)
|
||||
|
||||
// mf = 100:
|
||||
// Unique (250): 100 + Math.trunc((100 * 250) / 350) = 100 + Math.trunc(25000 / 350) = 100 + 71 = 171
|
||||
expect(effectiveMF(100, MF_FACTOR_UNIQUE)).toBe(171)
|
||||
// Set (500): 100 + Math.trunc((100 * 500) / 600) = 100 + Math.trunc(50000 / 600) = 100 + 83 = 183
|
||||
expect(effectiveMF(100, MF_FACTOR_SET)).toBe(183)
|
||||
// Rare (600): 100 + Math.trunc((100 * 600) / 700) = 100 + Math.trunc(60000 / 700) = 100 + 85 = 185
|
||||
expect(effectiveMF(100, MF_FACTOR_RARE)).toBe(185)
|
||||
|
||||
// mf = 300:
|
||||
// Unique (250): 100 + Math.trunc((300 * 250) / 550) = 100 + Math.trunc(75000 / 550) = 100 + 136 = 236
|
||||
expect(effectiveMF(300, MF_FACTOR_UNIQUE)).toBe(236)
|
||||
// Set (500): 100 + Math.trunc((300 * 500) / 800) = 100 + Math.trunc(150000 / 800) = 100 + 187 = 287
|
||||
expect(effectiveMF(300, MF_FACTOR_SET)).toBe(287)
|
||||
// Rare (600): 100 + Math.trunc((300 * 600) / 900) = 100 + Math.trunc(180000 / 900) = 100 + 200 = 300
|
||||
expect(effectiveMF(300, MF_FACTOR_RARE)).toBe(300)
|
||||
})
|
||||
})
|
||||
|
||||
describe('3. Exact Integer Truncation at Each Step (D2Game!6FC2FC40)', () => {
|
||||
const row = DEFAULT_ITEM_RATIO_ROWS[2] // Expansion, Normal, Generic
|
||||
|
||||
it('strictly replicates Andariel Hell ring drop example from KB 320', () => {
|
||||
// Ring (qlvl=1) dropped by Hell Andariel (ilvl=78), MF=300, TC factor=995
|
||||
// D = 78 - 1 = 77
|
||||
// divisor = 1
|
||||
// base chance = (400 - 77) * 128 = 323 * 128 = 41344
|
||||
// e = effectiveMF(300, 250) = 236
|
||||
// chance after MF = Math.trunc(41344 * 100 / 236) = Math.trunc(4134400 / 236) = 17518
|
||||
// uniqueMin = 6400 (17518 > 6400, no clamp)
|
||||
// tcFactor reduction = Math.trunc(995 * 17518 / 1024) = Math.trunc(17430410 / 1024) = 17021
|
||||
// final chance = 17518 - 17021 = 497
|
||||
const chance = calculateUniqueChance(row, 78, 1, 300, 995)
|
||||
expect(chance).toBe(497)
|
||||
})
|
||||
|
||||
it('handles negative D (ilvl < qlvl) with strict signed integer truncation toward zero', () => {
|
||||
// ilvl = 20, qlvl = 35 => D = -15
|
||||
// divisor = 2 (using Row 0: uniqueDiv = 2)
|
||||
// Math.trunc(-15 / 2) = -7 (NOT Math.floor which would be -8)
|
||||
// base = 400 => 400 - (-7) = 407
|
||||
// chance = 407 * 128 = 52096
|
||||
const classicRow = DEFAULT_ITEM_RATIO_ROWS[0]
|
||||
const chance = calculateUniqueChance(classicRow, 20, 35, 0, 0)
|
||||
expect(chance).toBe(52096)
|
||||
})
|
||||
|
||||
it('clamps chance up to uniqueMin when calculated chance is lower', () => {
|
||||
// If ilvl is very high and qlvl is 1, chance without clamp would drop below 6400
|
||||
// row.unique = 400, uniqueDiv = 1, ilvl = 95, qlvl = 1 => D = 94
|
||||
// chance = (400 - 94) * 128 = 306 * 128 = 39168
|
||||
// With high MF (e.g. 1000):
|
||||
// e = 100 + Math.trunc(250000 / 1250) = 100 + 200 = 300
|
||||
// chance = Math.trunc(39168 * 100 / 300) = 13056
|
||||
// Now suppose base is modified or tcFactor is 0:
|
||||
const customRow: ItemRatioRow = {
|
||||
...row,
|
||||
unique: 50,
|
||||
uniqueDiv: 1,
|
||||
uniqueMin: 6400,
|
||||
}
|
||||
// D = 40, base - D = 10, 10 * 128 = 1280 < 6400
|
||||
const chance = calculateUniqueChance(customRow, 41, 1, 0, 0)
|
||||
expect(chance).toBe(6400) // Clamped to uniqueMin!
|
||||
})
|
||||
|
||||
it('Magic quality has NO diminishing returns', () => {
|
||||
// Magic formula: chance = Math.trunc(chance * 100 / (mf + 100))
|
||||
// Row 2: magic = 34, magicDiv = 3, magicMin = 192
|
||||
// ilvl = 10, qlvl = 1 => D = 9 => Math.trunc(9 / 3) = 3
|
||||
// base chance = (34 - 3) * 128 = 31 * 128 = 3968
|
||||
// With MF = 300: divisor is mf + 100 = 400 (NOT diminished e = 236/287/300)
|
||||
// chance = Math.trunc(3968 * 100 / 400) = Math.trunc(396800 / 400) = 992
|
||||
const chance = calculateMagicChance(row, 10, 1, 300, 0)
|
||||
expect(chance).toBe(992)
|
||||
})
|
||||
|
||||
it('Superior and Normal do NOT take MF', () => {
|
||||
// Superior: (hiQual - Math.trunc(D / hiQualDiv)) * 128
|
||||
// Normal: (normal - Math.trunc(D / normalDiv)) * 128
|
||||
const sup0 = calculateSuperiorChance(row, 50, 10)
|
||||
const sup1000 = calculateSuperiorChance(row, 50, 10)
|
||||
expect(sup0).toBe(sup1000)
|
||||
|
||||
const norm0 = calculateNormalChance(row, 50, 10)
|
||||
const norm1000 = calculateNormalChance(row, 50, 10)
|
||||
expect(norm0).toBe(norm1000)
|
||||
|
||||
// Verify exact calculation for Superior:
|
||||
// Row 2: hiQual = 12, hiQualDiv = 8
|
||||
// ilvl = 50, qlvl = 10 => D = 40 => Math.trunc(40 / 8) = 5
|
||||
// (12 - 5) * 128 = 7 * 128 = 896
|
||||
expect(sup0).toBe(896)
|
||||
|
||||
// Verify exact calculation for Normal:
|
||||
// Row 2: normal = 2, normalDiv = 2
|
||||
// D = 40 => Math.trunc(40 / 2) = 20
|
||||
// (2 - 20) * 128 = -18 * 128 = -2304
|
||||
expect(norm0).toBe(-2304)
|
||||
})
|
||||
|
||||
it('tcFactor = 1024 guarantees hit (chance <= 0)', () => {
|
||||
// tcFactor reduction: Math.trunc(1024 * chance / 1024) = chance
|
||||
// chance - chance = 0 <= 0
|
||||
const uniqueChance = calculateUniqueChance(row, 10, 1, 0, 1024)
|
||||
expect(uniqueChance).toBe(0)
|
||||
|
||||
const setChance = calculateSetChance(row, 10, 1, 0, 1024)
|
||||
expect(setChance).toBe(0)
|
||||
|
||||
const rareChance = calculateRareChance(row, 10, 1, 0, 1024)
|
||||
expect(rareChance).toBe(0)
|
||||
|
||||
const magicChance = calculateMagicChance(row, 10, 1, 0, 1024)
|
||||
expect(magicChance).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('4. Quality Roll Sequence: Unique -> Set -> Rare -> Magic -> Superior -> Normal/Low', () => {
|
||||
it('returns "unique" when unique roll succeeds', () => {
|
||||
// Mock RNG that always returns 0 (< 128)
|
||||
const mockRng: QualityRollRng = { rand: () => 0 }
|
||||
const quality = rollItemQuality({
|
||||
ilvl: 80,
|
||||
qlvl: 10,
|
||||
mf: 100,
|
||||
rng: mockRng,
|
||||
})
|
||||
expect(quality).toBe('unique')
|
||||
})
|
||||
|
||||
it('CRITICAL: rolls Set BEFORE Rare even though table columns are Unique->Rare->Set', () => {
|
||||
// Sequence of RNG rolls:
|
||||
// 1st roll (Unique): returns 200 (>= 128, fails)
|
||||
// 2nd roll (Set): returns 50 (< 128, succeeds!)
|
||||
// If order were Unique->Rare->Set, Rare would have consumed 50 and returned rare.
|
||||
// Because order is Unique->Set->Rare, Set consumes 50 and returns 'set'!
|
||||
const rolls = [200, 50, 200, 200]
|
||||
let index = 0
|
||||
const mockRng: QualityRollRng = {
|
||||
rand: () => rolls[index++] ?? 200,
|
||||
}
|
||||
|
||||
const quality = rollItemQuality({
|
||||
ilvl: 50,
|
||||
qlvl: 10,
|
||||
rng: mockRng,
|
||||
})
|
||||
expect(quality).toBe('set')
|
||||
expect(index).toBe(2) // Exactly 2 rolls were consumed: Unique, then Set!
|
||||
})
|
||||
|
||||
it('rolls Rare if Unique and Set fail', () => {
|
||||
const rolls = [200, 200, 50, 200]
|
||||
let index = 0
|
||||
const mockRng: QualityRollRng = {
|
||||
rand: () => rolls[index++] ?? 200,
|
||||
}
|
||||
|
||||
const quality = rollItemQuality({
|
||||
ilvl: 50,
|
||||
qlvl: 10,
|
||||
rng: mockRng,
|
||||
})
|
||||
expect(quality).toBe('rare')
|
||||
expect(index).toBe(3) // Unique, Set, Rare!
|
||||
})
|
||||
|
||||
it('rolls Magic if Unique, Set, and Rare fail', () => {
|
||||
const rolls = [200, 200, 200, 50]
|
||||
let index = 0
|
||||
const mockRng: QualityRollRng = {
|
||||
rand: () => rolls[index++] ?? 200,
|
||||
}
|
||||
|
||||
const quality = rollItemQuality({
|
||||
ilvl: 50,
|
||||
qlvl: 10,
|
||||
rng: mockRng,
|
||||
})
|
||||
expect(quality).toBe('magic')
|
||||
expect(index).toBe(4) // Unique, Set, Rare, Magic!
|
||||
})
|
||||
|
||||
it('rolls Superior if Unique, Set, Rare, and Magic fail', () => {
|
||||
const rolls = [200, 200, 200, 200, 50]
|
||||
let index = 0
|
||||
const mockRng: QualityRollRng = {
|
||||
rand: () => rolls[index++] ?? 200,
|
||||
}
|
||||
|
||||
const quality = rollItemQuality({
|
||||
ilvl: 50,
|
||||
qlvl: 10,
|
||||
rng: mockRng,
|
||||
})
|
||||
expect(quality).toBe('superior')
|
||||
expect(index).toBe(5) // Unique, Set, Rare, Magic, Superior!
|
||||
})
|
||||
|
||||
it('rolls Normal if Superior fails and Normal roll succeeds', () => {
|
||||
const rolls = [200, 200, 200, 200, 200, 50]
|
||||
let index = 0
|
||||
const mockRng: QualityRollRng = {
|
||||
rand: () => rolls[index++] ?? 200,
|
||||
}
|
||||
|
||||
const quality = rollItemQuality({
|
||||
ilvl: 5,
|
||||
qlvl: 10, // D = -5 so Normal chance > 0
|
||||
rng: mockRng,
|
||||
})
|
||||
expect(quality).toBe('normal')
|
||||
expect(index).toBe(6)
|
||||
})
|
||||
|
||||
it('rolls Low if Superior fails and Normal roll fails', () => {
|
||||
const rolls = [200, 200, 200, 200, 200, 200]
|
||||
let index = 0
|
||||
const mockRng: QualityRollRng = {
|
||||
rand: () => rolls[index++] ?? 200,
|
||||
}
|
||||
|
||||
const quality = rollItemQuality({
|
||||
ilvl: 5,
|
||||
qlvl: 10,
|
||||
rng: mockRng,
|
||||
})
|
||||
expect(quality).toBe('low')
|
||||
expect(index).toBe(6)
|
||||
})
|
||||
|
||||
it('guarantees quality without consuming RNG if tcFactor = 1024', () => {
|
||||
let rngCalled = false
|
||||
const mockRng: QualityRollRng = {
|
||||
rand: () => {
|
||||
rngCalled = true
|
||||
return 999
|
||||
},
|
||||
}
|
||||
|
||||
const quality = rollItemQuality({
|
||||
ilvl: 10,
|
||||
qlvl: 10,
|
||||
tcFactor: 1024,
|
||||
rng: mockRng,
|
||||
})
|
||||
expect(quality).toBe('unique')
|
||||
expect(rngCalled).toBe(false) // chance <= 0 returns immediately without RNG
|
||||
})
|
||||
|
||||
it('respects quality disable flags', () => {
|
||||
// If allowUnique = false, jumps straight to Set
|
||||
const mockRng: QualityRollRng = { rand: () => 0 }
|
||||
const quality = rollItemQuality({
|
||||
ilvl: 50,
|
||||
qlvl: 10,
|
||||
allowUnique: false,
|
||||
rng: mockRng,
|
||||
})
|
||||
expect(quality).toBe('set')
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue