feat(items): 实现词缀抽取与 alvl 计算 (Issue #104)
This commit is contained in:
parent
ca3512f195
commit
390816c4cc
|
|
@ -0,0 +1,518 @@
|
||||||
|
/**
|
||||||
|
* Diablo II Affix Generation System (alvl calculation, group mutex exclusion, magic & rare generation).
|
||||||
|
*
|
||||||
|
* Gold standard references:
|
||||||
|
* - 1.13c MagicPrefix.txt & MagicSuffix.txt (1162 spawnable affixes)
|
||||||
|
* - 1.13c RarePrefix.txt (46 rows) & RareSuffix.txt (155 rows)
|
||||||
|
* - D2Common!6FD77180 / D2Common!Items.cpp (affix generation, alvl formula, and slot constraints)
|
||||||
|
* - .d2s item bitstream specification (1 prefix + 1 suffix for Magic; up to 3 prefixes + 3 suffixes for Rare)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ItemBase } from './items.ts'
|
||||||
|
import type { MagicAffix, AffixTable, AffixEligibilityOptions } from './affixes.ts'
|
||||||
|
import { isAffixEligible } from './affixes.ts'
|
||||||
|
import type { RareNameEntry, RareNameTable } from './rare-names.ts'
|
||||||
|
import { isRareNameEligible } from './rare-names.ts'
|
||||||
|
import type { D2Rng } from './d2-rng.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rolled modifier entry with concrete rolled value.
|
||||||
|
*/
|
||||||
|
export interface RolledMod {
|
||||||
|
readonly code: string
|
||||||
|
readonly param?: string | undefined
|
||||||
|
readonly min: number
|
||||||
|
readonly max: number
|
||||||
|
readonly value: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Concrete rolled affix instance on an item.
|
||||||
|
*/
|
||||||
|
export interface RolledAffix {
|
||||||
|
readonly affix: MagicAffix
|
||||||
|
readonly name: string
|
||||||
|
readonly isPrefix: boolean
|
||||||
|
readonly group: number
|
||||||
|
readonly level: number
|
||||||
|
readonly levelreq: number
|
||||||
|
readonly mods: readonly RolledMod[]
|
||||||
|
readonly id?: number | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of rolling magic affixes on an item.
|
||||||
|
*/
|
||||||
|
export interface GeneratedAffixes {
|
||||||
|
readonly name: string
|
||||||
|
readonly alvl: number
|
||||||
|
readonly prefix: RolledAffix | null
|
||||||
|
readonly suffix: RolledAffix | null
|
||||||
|
readonly prefixes: readonly RolledAffix[]
|
||||||
|
readonly suffixes: readonly RolledAffix[]
|
||||||
|
readonly affixes: readonly RolledAffix[]
|
||||||
|
readonly baseName: string
|
||||||
|
readonly dwInitSeed?: number | undefined
|
||||||
|
readonly seed?: number | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of rolling rare affixes on an item.
|
||||||
|
*/
|
||||||
|
export interface GeneratedRareAffixes extends GeneratedAffixes {
|
||||||
|
readonly rarePrefix?: RareNameEntry | undefined
|
||||||
|
readonly rareSuffix?: RareNameEntry | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type predicate for item type hierarchy testing.
|
||||||
|
*/
|
||||||
|
export type IsAFunction = (type: string, target: string) => boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes affix level (alvl) according to the 1.13c Diablo II algorithm.
|
||||||
|
*
|
||||||
|
* Formula:
|
||||||
|
* - if (magicLvl > 0) alvl = ilvl + magicLvl
|
||||||
|
* - else if (qlvl > Math.trunc(ilvl / 2)) alvl = ilvl - Math.trunc(qlvl / 2)
|
||||||
|
* - else alvl = ilvl
|
||||||
|
* - alvl = Math.min(alvl, 99)
|
||||||
|
*
|
||||||
|
* @param ilvl - Item drop level (from monster mlvl or area alvl).
|
||||||
|
* @param qlvl - Base item quality level.
|
||||||
|
* @param magicLvl - Optional magic level bonus on base item (e.g. circlets, wands, staves).
|
||||||
|
* @returns Calculated affix level (alvl, capped at 99).
|
||||||
|
*/
|
||||||
|
export function calculateAlvl(ilvl: number, qlvl: number, magicLvl = 0): number {
|
||||||
|
let alvl: number
|
||||||
|
if (magicLvl > 0) {
|
||||||
|
alvl = ilvl + magicLvl
|
||||||
|
} else if (qlvl > Math.trunc(ilvl / 2)) {
|
||||||
|
alvl = ilvl - Math.trunc(qlvl / 2)
|
||||||
|
} else {
|
||||||
|
alvl = ilvl
|
||||||
|
}
|
||||||
|
alvl = Math.min(alvl, 99)
|
||||||
|
return alvl
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the primary item type code for an ItemBase.
|
||||||
|
*
|
||||||
|
* Checks:
|
||||||
|
* 1. `base.type` (if present, e.g. from ArmorEntry/WeaponEntry/MiscItemEntry)
|
||||||
|
* 2. Specific tag in `base.tags` (skipping generic categories 'weap', 'armo', 'misc')
|
||||||
|
* 3. First tag in `base.tags`
|
||||||
|
* 4. `base.code`
|
||||||
|
* 5. `base.id`
|
||||||
|
*
|
||||||
|
* @param base - The base item definition.
|
||||||
|
* @returns 3-4 character item type code (e.g. 'swor', 'helm', 'ring').
|
||||||
|
*/
|
||||||
|
export function getItemTypeCode(base: ItemBase): string {
|
||||||
|
if ((base as any).type) {
|
||||||
|
return (base as any).type
|
||||||
|
}
|
||||||
|
if (base.tags && base.tags.length > 0) {
|
||||||
|
const specific = base.tags.find(t => t !== 'weap' && t !== 'armo' && t !== 'misc')
|
||||||
|
if (specific) {
|
||||||
|
return specific
|
||||||
|
}
|
||||||
|
return base.tags[0]!
|
||||||
|
}
|
||||||
|
if ((base as any).code) {
|
||||||
|
return (base as any).code
|
||||||
|
}
|
||||||
|
return base.id
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts quality level (qlvl) from an ItemBase.
|
||||||
|
*/
|
||||||
|
export function getBaseQlvl(base: ItemBase): number {
|
||||||
|
return (base as any).qlvl ?? base.level ?? 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts magic level (magicLvl) from an ItemBase.
|
||||||
|
*/
|
||||||
|
export function getBaseMagicLvl(base: ItemBase): number {
|
||||||
|
return (base as any).magicLvl ?? (base as any).magicLevel ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters eligible candidate affixes from an AffixTable taking into account group mutex and prior selections.
|
||||||
|
*/
|
||||||
|
function getEligibleCandidates(
|
||||||
|
table: AffixTable,
|
||||||
|
itemTypeCode: string,
|
||||||
|
isA: IsAFunction,
|
||||||
|
options: AffixEligibilityOptions,
|
||||||
|
usedGroups: ReadonlySet<number>,
|
||||||
|
usedAffixes: ReadonlySet<MagicAffix>,
|
||||||
|
): MagicAffix[] {
|
||||||
|
const all = table.all ?? (Array.isArray(table) ? table : [])
|
||||||
|
return all.filter(affix => {
|
||||||
|
if (usedAffixes.has(affix)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (affix.group > 0 && usedGroups.has(affix.group)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return isAffixEligible(affix, itemTypeCode, isA, options)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selects an affix from candidate list weighted by `affix.frequency`.
|
||||||
|
*
|
||||||
|
* If all frequencies are 0 (or total weight <= 0), chooses uniformly at random.
|
||||||
|
*/
|
||||||
|
function pickWeightedAffix(candidates: readonly MagicAffix[], rng: D2Rng): MagicAffix | null {
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
let totalWeight = 0
|
||||||
|
for (const cand of candidates) {
|
||||||
|
totalWeight += Math.max(0, cand.frequency)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalWeight <= 0) {
|
||||||
|
const index = rng.rand(candidates.length)
|
||||||
|
return candidates[index] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
const roll = rng.rand(totalWeight)
|
||||||
|
let acc = 0
|
||||||
|
for (const cand of candidates) {
|
||||||
|
acc += Math.max(0, cand.frequency)
|
||||||
|
if (roll < acc) {
|
||||||
|
return cand
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates[candidates.length - 1] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rolls concrete values for all modifiers of a chosen affix using `rng.randRange(min, max)`.
|
||||||
|
*/
|
||||||
|
function rollAffixMods(affix: MagicAffix, rng: D2Rng): RolledMod[] {
|
||||||
|
return affix.mods.map(mod => ({
|
||||||
|
code: mod.code,
|
||||||
|
...(mod.param !== undefined ? { param: mod.param } : {}),
|
||||||
|
min: mod.min,
|
||||||
|
max: mod.max,
|
||||||
|
value: rng.randRange(mod.min, mod.max),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a RolledAffix from a selected MagicAffix and rolled mods.
|
||||||
|
*/
|
||||||
|
function createRolledAffix(affix: MagicAffix, rng: D2Rng): RolledAffix {
|
||||||
|
const mods = rollAffixMods(affix, rng)
|
||||||
|
return {
|
||||||
|
affix,
|
||||||
|
name: affix.name,
|
||||||
|
isPrefix: affix.isPrefix,
|
||||||
|
group: affix.group,
|
||||||
|
level: affix.level,
|
||||||
|
levelreq: affix.levelreq,
|
||||||
|
mods,
|
||||||
|
id: (affix as any).id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves eligible rare names from a RareNameTable.
|
||||||
|
*/
|
||||||
|
function getEligibleRareNames(
|
||||||
|
table: RareNameTable,
|
||||||
|
itemTypeCode: string,
|
||||||
|
isA: IsAFunction,
|
||||||
|
): readonly RareNameEntry[] {
|
||||||
|
if (typeof table.getEligible === 'function') {
|
||||||
|
return table.getEligible(itemTypeCode, isA)
|
||||||
|
}
|
||||||
|
if (Array.isArray(table.entries)) {
|
||||||
|
return table.entries.filter(entry => isRareNameEligible(entry, itemTypeCode, isA))
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rolls magic affixes for an item:
|
||||||
|
* - 1 prefix slot + 1 suffix slot.
|
||||||
|
* - Randomly choose: {prefix only}, {suffix only}, or {prefix + suffix}.
|
||||||
|
* - Weighted selection by `frequency`.
|
||||||
|
* - Group mutex: when an affix with `group > 0` is chosen, that group is excluded from subsequent rolls.
|
||||||
|
* - Formats name as `[Prefix] [BaseName] [Suffix]`.
|
||||||
|
*
|
||||||
|
* @param base - Base item definition.
|
||||||
|
* @param ilvl - Item level.
|
||||||
|
* @param affixes - Tables containing prefix and suffix tables.
|
||||||
|
* @param isA - Item type inheritance predicate.
|
||||||
|
* @param rng - Unit PRNG instance (D2Rng).
|
||||||
|
* @returns Generated magic item affixes and metadata.
|
||||||
|
*/
|
||||||
|
export function rollMagicAffixes(
|
||||||
|
base: ItemBase,
|
||||||
|
ilvl: number,
|
||||||
|
affixes: { prefixes: AffixTable; suffixes: AffixTable },
|
||||||
|
isA: Function,
|
||||||
|
rng: D2Rng,
|
||||||
|
): GeneratedAffixes {
|
||||||
|
const predicate = isA as IsAFunction
|
||||||
|
const initialSeed = rng.lo
|
||||||
|
const qlvl = getBaseQlvl(base)
|
||||||
|
const magicLvl = getBaseMagicLvl(base)
|
||||||
|
const alvl = calculateAlvl(ilvl, qlvl, magicLvl)
|
||||||
|
const itemTypeCode = getItemTypeCode(base)
|
||||||
|
|
||||||
|
const usedGroups = new Set<number>()
|
||||||
|
const usedAffixes = new Set<MagicAffix>()
|
||||||
|
|
||||||
|
// Randomly choose {prefix only}, {suffix only}, or {prefix + suffix}
|
||||||
|
// 0 = prefix only, 1 = suffix only, 2 = prefix + suffix
|
||||||
|
const slotChoice = rng.rand(3)
|
||||||
|
const wantsPrefix = slotChoice === 0 || slotChoice === 2
|
||||||
|
const wantsSuffix = slotChoice === 1 || slotChoice === 2
|
||||||
|
|
||||||
|
let rolledPrefix: RolledAffix | null = null
|
||||||
|
let rolledSuffix: RolledAffix | null = null
|
||||||
|
|
||||||
|
if (wantsPrefix) {
|
||||||
|
const candidates = getEligibleCandidates(
|
||||||
|
affixes.prefixes,
|
||||||
|
itemTypeCode,
|
||||||
|
predicate,
|
||||||
|
{ alvl, isRare: false },
|
||||||
|
usedGroups,
|
||||||
|
usedAffixes,
|
||||||
|
)
|
||||||
|
const picked = pickWeightedAffix(candidates, rng)
|
||||||
|
if (picked) {
|
||||||
|
usedAffixes.add(picked)
|
||||||
|
if (picked.group > 0) {
|
||||||
|
usedGroups.add(picked.group)
|
||||||
|
}
|
||||||
|
rolledPrefix = createRolledAffix(picked, rng)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wantsSuffix) {
|
||||||
|
const candidates = getEligibleCandidates(
|
||||||
|
affixes.suffixes,
|
||||||
|
itemTypeCode,
|
||||||
|
predicate,
|
||||||
|
{ alvl, isRare: false },
|
||||||
|
usedGroups,
|
||||||
|
usedAffixes,
|
||||||
|
)
|
||||||
|
const picked = pickWeightedAffix(candidates, rng)
|
||||||
|
if (picked) {
|
||||||
|
usedAffixes.add(picked)
|
||||||
|
if (picked.group > 0) {
|
||||||
|
usedGroups.add(picked.group)
|
||||||
|
}
|
||||||
|
rolledSuffix = createRolledAffix(picked, rng)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name format: [Prefix] [BaseName] [Suffix]
|
||||||
|
const nameParts: string[] = []
|
||||||
|
if (rolledPrefix) {
|
||||||
|
nameParts.push(rolledPrefix.name)
|
||||||
|
}
|
||||||
|
nameParts.push(base.name)
|
||||||
|
if (rolledSuffix) {
|
||||||
|
nameParts.push(rolledSuffix.name)
|
||||||
|
}
|
||||||
|
const name = nameParts.filter(p => p.trim().length > 0).join(' ')
|
||||||
|
|
||||||
|
const prefixes = rolledPrefix ? [rolledPrefix] : []
|
||||||
|
const suffixes = rolledSuffix ? [rolledSuffix] : []
|
||||||
|
const allRolled = [...prefixes, ...suffixes]
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
alvl,
|
||||||
|
prefix: rolledPrefix,
|
||||||
|
suffix: rolledSuffix,
|
||||||
|
prefixes,
|
||||||
|
suffixes,
|
||||||
|
affixes: allRolled,
|
||||||
|
baseName: base.name,
|
||||||
|
dwInitSeed: initialSeed,
|
||||||
|
seed: initialSeed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rolls rare affixes for an item:
|
||||||
|
* - Up to 3 prefix + up to 3 suffix slots.
|
||||||
|
* - Total affixes: 3 to 6 (at least 1 prefix and 1 suffix, max 3 each).
|
||||||
|
* - Rare name: pick 1 eligible RarePrefix + 1 eligible RareSuffix.
|
||||||
|
* - Mod values rolled using `itemRng.randRange(min, max)`.
|
||||||
|
*
|
||||||
|
* @param base - Base item definition.
|
||||||
|
* @param ilvl - Item level.
|
||||||
|
* @param affixes - Tables containing prefix and suffix tables.
|
||||||
|
* @param rareNames - Tables containing rare prefix and suffix name tables.
|
||||||
|
* @param isA - Item type inheritance predicate.
|
||||||
|
* @param rng - Unit PRNG instance (D2Rng).
|
||||||
|
* @returns Generated rare item affixes and metadata.
|
||||||
|
*/
|
||||||
|
export function rollRareAffixes(
|
||||||
|
base: ItemBase,
|
||||||
|
ilvl: number,
|
||||||
|
affixes: { prefixes: AffixTable; suffixes: AffixTable },
|
||||||
|
rareNames: { prefixes: RareNameTable; suffixes: RareNameTable },
|
||||||
|
isA: Function,
|
||||||
|
rng: D2Rng,
|
||||||
|
): GeneratedRareAffixes {
|
||||||
|
const predicate = isA as IsAFunction
|
||||||
|
const initialSeed = rng.lo
|
||||||
|
const qlvl = getBaseQlvl(base)
|
||||||
|
const magicLvl = getBaseMagicLvl(base)
|
||||||
|
const alvl = calculateAlvl(ilvl, qlvl, magicLvl)
|
||||||
|
const itemTypeCode = getItemTypeCode(base)
|
||||||
|
|
||||||
|
// 1. Pick Rare Name: 1 eligible RarePrefix + 1 eligible RareSuffix
|
||||||
|
const eligibleRarePrefixes = getEligibleRareNames(rareNames.prefixes, itemTypeCode, predicate)
|
||||||
|
const eligibleRareSuffixes = getEligibleRareNames(rareNames.suffixes, itemTypeCode, predicate)
|
||||||
|
|
||||||
|
let rarePrefix: RareNameEntry | undefined
|
||||||
|
if (eligibleRarePrefixes.length > 0) {
|
||||||
|
const idx = rng.rand(eligibleRarePrefixes.length)
|
||||||
|
rarePrefix = eligibleRarePrefixes[idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
let rareSuffix: RareNameEntry | undefined
|
||||||
|
if (eligibleRareSuffixes.length > 0) {
|
||||||
|
const idx = rng.rand(eligibleRareSuffixes.length)
|
||||||
|
rareSuffix = eligibleRareSuffixes[idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
const rareNameParts = [rarePrefix?.name, rareSuffix?.name].filter(
|
||||||
|
(n): n is string => typeof n === 'string' && n.trim().length > 0,
|
||||||
|
)
|
||||||
|
const name = rareNameParts.length > 0 ? rareNameParts.join(' ') : base.name
|
||||||
|
|
||||||
|
// 2. Determine slot distribution:
|
||||||
|
// Total affixes: 3 to 6 (at least 1 prefix and 1 suffix, max 3 each)
|
||||||
|
const totalAffixes = rng.randRange(3, 6)
|
||||||
|
const minPrefix = Math.max(1, totalAffixes - 3)
|
||||||
|
const maxPrefix = Math.min(3, totalAffixes - 1)
|
||||||
|
const targetPrefixCount = rng.randRange(minPrefix, maxPrefix)
|
||||||
|
const targetSuffixCount = totalAffixes - targetPrefixCount
|
||||||
|
|
||||||
|
const usedGroups = new Set<number>()
|
||||||
|
const usedAffixes = new Set<MagicAffix>()
|
||||||
|
const rolledPrefixes: RolledAffix[] = []
|
||||||
|
const rolledSuffixes: RolledAffix[] = []
|
||||||
|
|
||||||
|
// Roll target prefix slots
|
||||||
|
for (let i = 0; i < targetPrefixCount; i++) {
|
||||||
|
const candidates = getEligibleCandidates(
|
||||||
|
affixes.prefixes,
|
||||||
|
itemTypeCode,
|
||||||
|
predicate,
|
||||||
|
{ alvl, isRare: true },
|
||||||
|
usedGroups,
|
||||||
|
usedAffixes,
|
||||||
|
)
|
||||||
|
const picked = pickWeightedAffix(candidates, rng)
|
||||||
|
if (!picked) break
|
||||||
|
usedAffixes.add(picked)
|
||||||
|
if (picked.group > 0) {
|
||||||
|
usedGroups.add(picked.group)
|
||||||
|
}
|
||||||
|
rolledPrefixes.push(createRolledAffix(picked, rng))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Roll target suffix slots
|
||||||
|
for (let i = 0; i < targetSuffixCount; i++) {
|
||||||
|
const candidates = getEligibleCandidates(
|
||||||
|
affixes.suffixes,
|
||||||
|
itemTypeCode,
|
||||||
|
predicate,
|
||||||
|
{ alvl, isRare: true },
|
||||||
|
usedGroups,
|
||||||
|
usedAffixes,
|
||||||
|
)
|
||||||
|
const picked = pickWeightedAffix(candidates, rng)
|
||||||
|
if (!picked) break
|
||||||
|
usedAffixes.add(picked)
|
||||||
|
if (picked.group > 0) {
|
||||||
|
usedGroups.add(picked.group)
|
||||||
|
}
|
||||||
|
rolledSuffixes.push(createRolledAffix(picked, rng))
|
||||||
|
}
|
||||||
|
|
||||||
|
// If pool exhaustion prevented filling target count, try filling remaining slots from either category (up to 3 each)
|
||||||
|
while (rolledPrefixes.length + rolledSuffixes.length < totalAffixes) {
|
||||||
|
let rolledAny = false
|
||||||
|
if (rolledPrefixes.length < 3) {
|
||||||
|
const candidates = getEligibleCandidates(
|
||||||
|
affixes.prefixes,
|
||||||
|
itemTypeCode,
|
||||||
|
predicate,
|
||||||
|
{ alvl, isRare: true },
|
||||||
|
usedGroups,
|
||||||
|
usedAffixes,
|
||||||
|
)
|
||||||
|
const picked = pickWeightedAffix(candidates, rng)
|
||||||
|
if (picked) {
|
||||||
|
usedAffixes.add(picked)
|
||||||
|
if (picked.group > 0) {
|
||||||
|
usedGroups.add(picked.group)
|
||||||
|
}
|
||||||
|
rolledPrefixes.push(createRolledAffix(picked, rng))
|
||||||
|
rolledAny = true
|
||||||
|
if (rolledPrefixes.length + rolledSuffixes.length >= totalAffixes) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rolledSuffixes.length < 3) {
|
||||||
|
const candidates = getEligibleCandidates(
|
||||||
|
affixes.suffixes,
|
||||||
|
itemTypeCode,
|
||||||
|
predicate,
|
||||||
|
{ alvl, isRare: true },
|
||||||
|
usedGroups,
|
||||||
|
usedAffixes,
|
||||||
|
)
|
||||||
|
const picked = pickWeightedAffix(candidates, rng)
|
||||||
|
if (picked) {
|
||||||
|
usedAffixes.add(picked)
|
||||||
|
if (picked.group > 0) {
|
||||||
|
usedGroups.add(picked.group)
|
||||||
|
}
|
||||||
|
rolledSuffixes.push(createRolledAffix(picked, rng))
|
||||||
|
rolledAny = true
|
||||||
|
if (rolledPrefixes.length + rolledSuffixes.length >= totalAffixes) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rolledAny) break
|
||||||
|
}
|
||||||
|
|
||||||
|
const allRolled = [...rolledPrefixes, ...rolledSuffixes]
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
alvl,
|
||||||
|
rarePrefix,
|
||||||
|
rareSuffix,
|
||||||
|
prefix: rolledPrefixes[0] ?? null,
|
||||||
|
suffix: rolledSuffixes[0] ?? null,
|
||||||
|
prefixes: rolledPrefixes,
|
||||||
|
suffixes: rolledSuffixes,
|
||||||
|
affixes: allRolled,
|
||||||
|
baseName: base.name,
|
||||||
|
dwInitSeed: initialSeed,
|
||||||
|
seed: initialSeed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,705 @@
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import * as fs from 'node:fs'
|
||||||
|
import {
|
||||||
|
calculateAlvl,
|
||||||
|
rollMagicAffixes,
|
||||||
|
rollRareAffixes,
|
||||||
|
getItemTypeCode,
|
||||||
|
getBaseQlvl,
|
||||||
|
getBaseMagicLvl,
|
||||||
|
} from '../src/game/affix-generator.ts'
|
||||||
|
import type { ItemBase } from '../src/game/items.ts'
|
||||||
|
import type { MagicAffix, AffixTable } from '../src/game/affixes.ts'
|
||||||
|
import { parseMagicAffixesTable } from '../src/game/affixes.ts'
|
||||||
|
import type { RareNameEntry, RareNameTable } from '../src/game/rare-names.ts'
|
||||||
|
import { parseRareNamesTable } from '../src/game/rare-names.ts'
|
||||||
|
import { parseTable } from '../src/game/acts.ts'
|
||||||
|
import { D2Rng } from '../src/game/d2-rng.ts'
|
||||||
|
import { MpqArchive } from '../src/mpq/archive.ts'
|
||||||
|
import { MountedArchives } from '../src/mpq/mount.ts'
|
||||||
|
import { fileSource } from '../src/mpq/file-source.ts'
|
||||||
|
import { loadItemTypes, isA as realIsA } from '../src/game/item-types.ts'
|
||||||
|
|
||||||
|
describe('Affix Generator - alvl calculation (Issue #104)', () => {
|
||||||
|
it('calculates alvl with magicLvl bonus', () => {
|
||||||
|
// if (magicLvl > 0) alvl = ilvl + magicLvl
|
||||||
|
expect(calculateAlvl(10, 20, 5)).toBe(15)
|
||||||
|
expect(calculateAlvl(20, 15, 3)).toBe(23)
|
||||||
|
// Diadem (magicLvl = 18) capped at 99
|
||||||
|
expect(calculateAlvl(85, 85, 18)).toBe(99)
|
||||||
|
// Capped at 99
|
||||||
|
expect(calculateAlvl(95, 80, 10)).toBe(99)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calculates alvl when qlvl > Math.trunc(ilvl / 2) without magicLvl', () => {
|
||||||
|
// else if (qlvl > Math.trunc(ilvl / 2)) alvl = ilvl - Math.trunc(qlvl / 2)
|
||||||
|
// ilvl = 30, qlvl = 40 -> trunc(30/2)=15, 40 > 15 -> 30 - 20 = 10
|
||||||
|
expect(calculateAlvl(30, 40)).toBe(10)
|
||||||
|
expect(calculateAlvl(30, 40, 0)).toBe(10)
|
||||||
|
|
||||||
|
// ilvl = 20, qlvl = 16 -> trunc(20/2)=10, 16 > 10 -> 20 - 8 = 12
|
||||||
|
expect(calculateAlvl(20, 16)).toBe(12)
|
||||||
|
|
||||||
|
// ilvl = 50, qlvl = 26 -> trunc(50/2)=25, 26 > 25 -> 50 - 13 = 37
|
||||||
|
expect(calculateAlvl(50, 26)).toBe(37)
|
||||||
|
|
||||||
|
// ilvl = 15, qlvl = 10 -> trunc(15/2)=7, 10 > 7 -> 15 - 5 = 10
|
||||||
|
expect(calculateAlvl(15, 10)).toBe(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calculates alvl when qlvl <= Math.trunc(ilvl / 2) without magicLvl', () => {
|
||||||
|
// else alvl = ilvl
|
||||||
|
// ilvl = 50, qlvl = 20 -> trunc(50/2)=25, 20 <= 25 -> 50
|
||||||
|
expect(calculateAlvl(50, 20)).toBe(50)
|
||||||
|
|
||||||
|
// Exact boundary: ilvl = 50, qlvl = 25 -> trunc(50/2)=25, 25 <= 25 -> 50
|
||||||
|
expect(calculateAlvl(50, 25)).toBe(50)
|
||||||
|
|
||||||
|
// ilvl = 80, qlvl = 30 -> trunc(80/2)=40, 30 <= 40 -> 80
|
||||||
|
expect(calculateAlvl(80, 30)).toBe(80)
|
||||||
|
|
||||||
|
// ilvl = 105, qlvl = 20 -> 105 capped at 99
|
||||||
|
expect(calculateAlvl(105, 20)).toBe(99)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults magicLvl to 0 when omitted', () => {
|
||||||
|
expect(calculateAlvl(40, 50)).toBe(calculateAlvl(40, 50, 0))
|
||||||
|
expect(calculateAlvl(60, 20)).toBe(calculateAlvl(60, 20, 0))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ItemBase helper functions', () => {
|
||||||
|
it('extracts itemTypeCode, qlvl, and magicLvl correctly', () => {
|
||||||
|
const baseWithExplicitType: any = {
|
||||||
|
id: 'hax',
|
||||||
|
name: 'Hand Axe',
|
||||||
|
kind: 'weapon',
|
||||||
|
invWidth: 1,
|
||||||
|
invHeight: 3,
|
||||||
|
maxStack: 1,
|
||||||
|
value: 100,
|
||||||
|
damage: 10,
|
||||||
|
defense: 0,
|
||||||
|
tags: ['weap', 'axe'],
|
||||||
|
type: 'axe',
|
||||||
|
level: 3,
|
||||||
|
magicLvl: 2,
|
||||||
|
}
|
||||||
|
expect(getItemTypeCode(baseWithExplicitType)).toBe('axe')
|
||||||
|
expect(getBaseQlvl(baseWithExplicitType)).toBe(3)
|
||||||
|
expect(getBaseMagicLvl(baseWithExplicitType)).toBe(2)
|
||||||
|
|
||||||
|
const baseWithTagsOnly: ItemBase = {
|
||||||
|
id: 'crs',
|
||||||
|
name: 'Crystal Sword',
|
||||||
|
kind: 'weapon',
|
||||||
|
invWidth: 2,
|
||||||
|
invHeight: 3,
|
||||||
|
maxStack: 1,
|
||||||
|
value: 500,
|
||||||
|
damage: 15,
|
||||||
|
defense: 0,
|
||||||
|
tags: ['weap', 'swor'],
|
||||||
|
level: 11,
|
||||||
|
}
|
||||||
|
expect(getItemTypeCode(baseWithTagsOnly)).toBe('swor')
|
||||||
|
expect(getBaseQlvl(baseWithTagsOnly)).toBe(11)
|
||||||
|
expect(getBaseMagicLvl(baseWithTagsOnly)).toBe(0)
|
||||||
|
|
||||||
|
const baseMinimal: ItemBase = {
|
||||||
|
id: 'rin',
|
||||||
|
name: 'Ring',
|
||||||
|
kind: 'misc',
|
||||||
|
invWidth: 1,
|
||||||
|
invHeight: 1,
|
||||||
|
maxStack: 1,
|
||||||
|
value: 100,
|
||||||
|
damage: 0,
|
||||||
|
defense: 0,
|
||||||
|
tags: ['ring'],
|
||||||
|
level: 1,
|
||||||
|
}
|
||||||
|
expect(getItemTypeCode(baseMinimal)).toBe('ring')
|
||||||
|
expect(getBaseQlvl(baseMinimal)).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Affix Generator - Group exclusion & Rare Generation (Issue #104)', () => {
|
||||||
|
const mockIsA = (type: string, target: string): boolean => {
|
||||||
|
if (type === target) return true
|
||||||
|
if (type === 'swor' && target === 'weap') return true
|
||||||
|
if (type === 'swor' && target === 'mele') return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const swordBase: ItemBase = {
|
||||||
|
id: 'swor',
|
||||||
|
name: 'Broad Sword',
|
||||||
|
kind: 'weapon',
|
||||||
|
invWidth: 1,
|
||||||
|
invHeight: 3,
|
||||||
|
maxStack: 1,
|
||||||
|
value: 100,
|
||||||
|
damage: 10,
|
||||||
|
defense: 0,
|
||||||
|
tags: ['swor'],
|
||||||
|
level: 15,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to create mock MagicAffix
|
||||||
|
function createMockAffix(
|
||||||
|
name: string,
|
||||||
|
isPrefix: boolean,
|
||||||
|
group: number,
|
||||||
|
frequency: number,
|
||||||
|
level = 1,
|
||||||
|
mods = [{ code: 'att', min: 10, max: 20 }],
|
||||||
|
): MagicAffix {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
isPrefix,
|
||||||
|
version: 100,
|
||||||
|
spawnable: true,
|
||||||
|
rare: true,
|
||||||
|
level,
|
||||||
|
maxlevel: 0,
|
||||||
|
levelreq: 1,
|
||||||
|
frequency,
|
||||||
|
group,
|
||||||
|
mods,
|
||||||
|
transform: false,
|
||||||
|
itypes: ['swor'],
|
||||||
|
etypes: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('enforces group mutex: affixes with same group > 0 never co-occur on the same item', () => {
|
||||||
|
// 3 prefixes in group 10 ("Bronze", "Iron", "Steel"), 2 prefixes in group 20 ("Sharp", "Fine")
|
||||||
|
const prefixes: MagicAffix[] = [
|
||||||
|
createMockAffix('Bronze', true, 10, 5),
|
||||||
|
createMockAffix('Iron', true, 10, 5),
|
||||||
|
createMockAffix('Steel', true, 10, 5),
|
||||||
|
createMockAffix('Sharp', true, 20, 5),
|
||||||
|
createMockAffix('Fine', true, 20, 5),
|
||||||
|
createMockAffix('Godly', true, 30, 5),
|
||||||
|
]
|
||||||
|
|
||||||
|
// 2 suffixes in group 10 ("of the Titan", "of the Colossus"), 3 suffixes in group 40 ("of Leech", "of Blood", "of Bat")
|
||||||
|
const suffixes: MagicAffix[] = [
|
||||||
|
createMockAffix('of the Titan', false, 10, 5),
|
||||||
|
createMockAffix('of the Colossus', false, 10, 5),
|
||||||
|
createMockAffix('of Leech', false, 40, 5),
|
||||||
|
createMockAffix('of Blood', false, 40, 5),
|
||||||
|
createMockAffix('of Bat', false, 50, 5),
|
||||||
|
createMockAffix('of Speed', false, 60, 5),
|
||||||
|
]
|
||||||
|
|
||||||
|
const mockAffixes = {
|
||||||
|
prefixes: { all: prefixes } as unknown as AffixTable,
|
||||||
|
suffixes: { all: suffixes } as unknown as AffixTable,
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockRareNames = {
|
||||||
|
prefixes: {
|
||||||
|
entries: [{ index: 0, name: 'Storm', version: 0, itypes: [], etypes: [], add: 0, multiply: 0, divide: 0 }],
|
||||||
|
getEligible: () => [{ index: 0, name: 'Storm', version: 0, itypes: [], etypes: [], add: 0, multiply: 0, divide: 0 }],
|
||||||
|
} as unknown as RareNameTable,
|
||||||
|
suffixes: {
|
||||||
|
entries: [{ index: 0, name: 'Spur', version: 0, itypes: [], etypes: [], add: 0, multiply: 0, divide: 0 }],
|
||||||
|
getEligible: () => [{ index: 0, name: 'Spur', version: 0, itypes: [], etypes: [], add: 0, multiply: 0, divide: 0 }],
|
||||||
|
} as unknown as RareNameTable,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run 50 rare rolls with varying seeds
|
||||||
|
for (let seed = 1000; seed < 1050; seed++) {
|
||||||
|
const rng = new D2Rng(seed)
|
||||||
|
const rare = rollRareAffixes(swordBase, 50, mockAffixes, mockRareNames, mockIsA, rng)
|
||||||
|
|
||||||
|
// Count occurrences of each group > 0
|
||||||
|
const groupCounts = new Map<number, number>()
|
||||||
|
for (const rolled of rare.affixes) {
|
||||||
|
if (rolled.group > 0) {
|
||||||
|
groupCounts.set(rolled.group, (groupCounts.get(rolled.group) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group mutex constraint: every group > 0 must appear at most once!
|
||||||
|
for (const [group, count] of groupCounts.entries()) {
|
||||||
|
expect(count, `Group ${group} appeared ${count} times on the same item!`).toBe(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicit check for cross-prefix/suffix conflict on group 10
|
||||||
|
const group10Affixes = rare.affixes.filter(a => a.group === 10)
|
||||||
|
expect(group10Affixes.length).toBeLessThanOrEqual(1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows affixes with group 0 to co-occur if they are distinct affixes', () => {
|
||||||
|
const prefixes: MagicAffix[] = [
|
||||||
|
createMockAffix('SpecialPrefix1', true, 0, 10),
|
||||||
|
createMockAffix('SpecialPrefix2', true, 0, 10),
|
||||||
|
createMockAffix('SpecialPrefix3', true, 0, 10),
|
||||||
|
]
|
||||||
|
const suffixes: MagicAffix[] = [
|
||||||
|
createMockAffix('of Special1', false, 0, 10),
|
||||||
|
createMockAffix('of Special2', false, 0, 10),
|
||||||
|
createMockAffix('of Special3', false, 0, 10),
|
||||||
|
]
|
||||||
|
|
||||||
|
const mockAffixes = {
|
||||||
|
prefixes: { all: prefixes } as unknown as AffixTable,
|
||||||
|
suffixes: { all: suffixes } as unknown as AffixTable,
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockRareNames = {
|
||||||
|
prefixes: {
|
||||||
|
getEligible: () => [{ index: 0, name: 'Shadow', itypes: [], etypes: [] }],
|
||||||
|
} as unknown as RareNameTable,
|
||||||
|
suffixes: {
|
||||||
|
getEligible: () => [{ index: 0, name: 'Bite', itypes: [], etypes: [] }],
|
||||||
|
} as unknown as RareNameTable,
|
||||||
|
}
|
||||||
|
|
||||||
|
const rng = new D2Rng(42)
|
||||||
|
const rare = rollRareAffixes(swordBase, 50, mockAffixes, mockRareNames, mockIsA, rng)
|
||||||
|
expect(rare.affixes.length).toBeGreaterThanOrEqual(3)
|
||||||
|
|
||||||
|
// All affixes have group 0, and they can co-occur
|
||||||
|
const group0Count = rare.affixes.filter(a => a.group === 0).length
|
||||||
|
expect(group0Count).toBeGreaterThanOrEqual(3)
|
||||||
|
|
||||||
|
// But no duplicate affix instance
|
||||||
|
const names = rare.affixes.map(a => a.name)
|
||||||
|
const uniqueNames = new Set(names)
|
||||||
|
expect(names.length).toBe(uniqueNames.size)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Affix Generator - Quality specific rolls (Issue #104)', () => {
|
||||||
|
const mockIsA = (type: string, target: string): boolean => {
|
||||||
|
if (type === target) return true
|
||||||
|
if (type === 'swor' && target === 'weap') return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const swordBase: ItemBase = {
|
||||||
|
id: 'swor',
|
||||||
|
name: 'Broad Sword',
|
||||||
|
kind: 'weapon',
|
||||||
|
invWidth: 1,
|
||||||
|
invHeight: 3,
|
||||||
|
maxStack: 1,
|
||||||
|
value: 100,
|
||||||
|
damage: 10,
|
||||||
|
defense: 0,
|
||||||
|
tags: ['swor'],
|
||||||
|
level: 15,
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockAffix(name: string, isPrefix: boolean, group: number): MagicAffix {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
isPrefix,
|
||||||
|
version: 100,
|
||||||
|
spawnable: true,
|
||||||
|
rare: true,
|
||||||
|
level: 1,
|
||||||
|
maxlevel: 0,
|
||||||
|
levelreq: 1,
|
||||||
|
frequency: 5,
|
||||||
|
group,
|
||||||
|
mods: [{ code: 'hp', min: 10, max: 20 }],
|
||||||
|
transform: false,
|
||||||
|
itypes: ['swor'],
|
||||||
|
etypes: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefixes = [
|
||||||
|
createMockAffix('Sturdy', true, 1),
|
||||||
|
createMockAffix('Strong', true, 2),
|
||||||
|
createMockAffix('Glorious', true, 3),
|
||||||
|
]
|
||||||
|
|
||||||
|
const suffixes = [
|
||||||
|
createMockAffix('of the Ox', false, 4),
|
||||||
|
createMockAffix('of the Giant', false, 5),
|
||||||
|
createMockAffix('of the Titan', false, 6),
|
||||||
|
]
|
||||||
|
|
||||||
|
const mockAffixes = {
|
||||||
|
prefixes: { all: prefixes } as unknown as AffixTable,
|
||||||
|
suffixes: { all: suffixes } as unknown as AffixTable,
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockRareNames = {
|
||||||
|
prefixes: {
|
||||||
|
getEligible: () => [
|
||||||
|
{ index: 0, name: 'Doom', itypes: [], etypes: [] },
|
||||||
|
{ index: 1, name: 'Beast', itypes: [], etypes: [] },
|
||||||
|
],
|
||||||
|
} as unknown as RareNameTable,
|
||||||
|
suffixes: {
|
||||||
|
getEligible: () => [
|
||||||
|
{ index: 0, name: 'Bite', itypes: [], etypes: [] },
|
||||||
|
{ index: 1, name: 'Casque', itypes: [], etypes: [] },
|
||||||
|
],
|
||||||
|
} as unknown as RareNameTable,
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Magic items: 1 prefix slot + 1 suffix slot', () => {
|
||||||
|
it('generates at most 1 prefix and 1 suffix, correctly formatted', () => {
|
||||||
|
let sawPrefixOnly = false
|
||||||
|
let sawSuffixOnly = false
|
||||||
|
let sawBoth = false
|
||||||
|
|
||||||
|
for (let seed = 1; seed <= 60; seed++) {
|
||||||
|
const rng = new D2Rng(seed)
|
||||||
|
const magic = rollMagicAffixes(swordBase, 20, mockAffixes, mockIsA, rng)
|
||||||
|
|
||||||
|
// Slot constraints
|
||||||
|
expect(magic.prefixes.length).toBeLessThanOrEqual(1)
|
||||||
|
expect(magic.suffixes.length).toBeLessThanOrEqual(1)
|
||||||
|
expect(magic.affixes.length).toBeGreaterThanOrEqual(1)
|
||||||
|
expect(magic.affixes.length).toBeLessThanOrEqual(2)
|
||||||
|
|
||||||
|
if (magic.prefix && !magic.suffix) {
|
||||||
|
sawPrefixOnly = true
|
||||||
|
expect(magic.name).toBe(`${magic.prefix.name} ${swordBase.name}`)
|
||||||
|
expect(magic.prefixes).toEqual([magic.prefix])
|
||||||
|
expect(magic.suffixes).toEqual([])
|
||||||
|
} else if (!magic.prefix && magic.suffix) {
|
||||||
|
sawSuffixOnly = true
|
||||||
|
expect(magic.name).toBe(`${swordBase.name} ${magic.suffix.name}`)
|
||||||
|
expect(magic.prefixes).toEqual([])
|
||||||
|
expect(magic.suffixes).toEqual([magic.suffix])
|
||||||
|
} else if (magic.prefix && magic.suffix) {
|
||||||
|
sawBoth = true
|
||||||
|
expect(magic.name).toBe(`${magic.prefix.name} ${swordBase.name} ${magic.suffix.name}`)
|
||||||
|
expect(magic.prefixes).toEqual([magic.prefix])
|
||||||
|
expect(magic.suffixes).toEqual([magic.suffix])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all 3 random choices ({prefix only}, {suffix only}, {both}) occur
|
||||||
|
expect(sawPrefixOnly).toBe(true)
|
||||||
|
expect(sawSuffixOnly).toBe(true)
|
||||||
|
expect(sawBoth).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Rare items: 3 to 6 affixes, <=3 prefixes, <=3 suffixes', () => {
|
||||||
|
it('satisfies 3..6 total affixes, 1..3 prefixes, 1..3 suffixes, and picks rare name', () => {
|
||||||
|
for (let seed = 1; seed <= 50; seed++) {
|
||||||
|
const rng = new D2Rng(seed * 77)
|
||||||
|
const rare = rollRareAffixes(swordBase, 30, mockAffixes, mockRareNames, mockIsA, rng)
|
||||||
|
|
||||||
|
// Total affixes: 3 to 6
|
||||||
|
expect(rare.affixes.length).toBeGreaterThanOrEqual(3)
|
||||||
|
expect(rare.affixes.length).toBeLessThanOrEqual(6)
|
||||||
|
|
||||||
|
// Up to 3 prefix + up to 3 suffix slots (at least 1 each)
|
||||||
|
expect(rare.prefixes.length).toBeGreaterThanOrEqual(1)
|
||||||
|
expect(rare.prefixes.length).toBeLessThanOrEqual(3)
|
||||||
|
expect(rare.suffixes.length).toBeGreaterThanOrEqual(1)
|
||||||
|
expect(rare.suffixes.length).toBeLessThanOrEqual(3)
|
||||||
|
|
||||||
|
// Total equals sum
|
||||||
|
expect(rare.prefixes.length + rare.suffixes.length).toBe(rare.affixes.length)
|
||||||
|
|
||||||
|
// Rare name
|
||||||
|
expect(rare.rarePrefix).toBeDefined()
|
||||||
|
expect(rare.rareSuffix).toBeDefined()
|
||||||
|
expect(rare.name).toBe(`${rare.rarePrefix!.name} ${rare.rareSuffix!.name}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Mod Values Roll (Issue #104)', () => {
|
||||||
|
const mockIsA = (type: string, target: string): boolean => type === target
|
||||||
|
|
||||||
|
const swordBase: ItemBase = {
|
||||||
|
id: 'swor',
|
||||||
|
name: 'Broad Sword',
|
||||||
|
kind: 'weapon',
|
||||||
|
invWidth: 1,
|
||||||
|
invHeight: 3,
|
||||||
|
maxStack: 1,
|
||||||
|
value: 100,
|
||||||
|
damage: 10,
|
||||||
|
defense: 0,
|
||||||
|
tags: ['swor'],
|
||||||
|
level: 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
it('rolls mod values within [min, max] using item rng', () => {
|
||||||
|
const affixWithRangedMod: MagicAffix = {
|
||||||
|
name: 'Vitality',
|
||||||
|
isPrefix: true,
|
||||||
|
version: 100,
|
||||||
|
spawnable: true,
|
||||||
|
rare: true,
|
||||||
|
level: 1,
|
||||||
|
maxlevel: 0,
|
||||||
|
levelreq: 1,
|
||||||
|
frequency: 1,
|
||||||
|
group: 1,
|
||||||
|
mods: [
|
||||||
|
{ code: 'hp', min: 10, max: 20 },
|
||||||
|
{ code: 'str', min: 5, max: 5 },
|
||||||
|
],
|
||||||
|
transform: false,
|
||||||
|
itypes: ['swor'],
|
||||||
|
etypes: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockAffixes = {
|
||||||
|
prefixes: { all: [affixWithRangedMod] } as unknown as AffixTable,
|
||||||
|
suffixes: { all: [] } as unknown as AffixTable,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let seed = 1; seed <= 30; seed++) {
|
||||||
|
const rng = new D2Rng(seed)
|
||||||
|
const result = rollMagicAffixes(swordBase, 20, mockAffixes, mockIsA, rng)
|
||||||
|
if (result.prefix) {
|
||||||
|
const hpMod = result.prefix.mods.find(m => m.code === 'hp')!
|
||||||
|
expect(hpMod).toBeDefined()
|
||||||
|
expect(hpMod.value).toBeGreaterThanOrEqual(10)
|
||||||
|
expect(hpMod.value).toBeLessThanOrEqual(20)
|
||||||
|
|
||||||
|
const strMod = result.prefix.mods.find(m => m.code === 'str')!
|
||||||
|
expect(strMod).toBeDefined()
|
||||||
|
expect(strMod.value).toBe(5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('weights affix selection by affix.frequency', () => {
|
||||||
|
const commonPrefix: MagicAffix = {
|
||||||
|
name: 'Common',
|
||||||
|
isPrefix: true,
|
||||||
|
version: 100,
|
||||||
|
spawnable: true,
|
||||||
|
rare: true,
|
||||||
|
level: 1,
|
||||||
|
maxlevel: 0,
|
||||||
|
levelreq: 1,
|
||||||
|
frequency: 90,
|
||||||
|
group: 1,
|
||||||
|
mods: [{ code: 'hp', min: 1, max: 1 }],
|
||||||
|
transform: false,
|
||||||
|
itypes: ['swor'],
|
||||||
|
etypes: [],
|
||||||
|
}
|
||||||
|
const rarePrefix: MagicAffix = {
|
||||||
|
name: 'UltraRare',
|
||||||
|
isPrefix: true,
|
||||||
|
version: 100,
|
||||||
|
spawnable: true,
|
||||||
|
rare: true,
|
||||||
|
level: 1,
|
||||||
|
maxlevel: 0,
|
||||||
|
levelreq: 1,
|
||||||
|
frequency: 10,
|
||||||
|
group: 2,
|
||||||
|
mods: [{ code: 'hp', min: 1, max: 1 }],
|
||||||
|
transform: false,
|
||||||
|
itypes: ['swor'],
|
||||||
|
etypes: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockAffixes = {
|
||||||
|
prefixes: { all: [commonPrefix, rarePrefix] } as unknown as AffixTable,
|
||||||
|
suffixes: { all: [] } as unknown as AffixTable,
|
||||||
|
}
|
||||||
|
|
||||||
|
let commonCount = 0
|
||||||
|
let rareCount = 0
|
||||||
|
const totalRuns = 200
|
||||||
|
|
||||||
|
for (let seed = 1; seed <= totalRuns; seed++) {
|
||||||
|
const rng = new D2Rng(seed)
|
||||||
|
const res = rollMagicAffixes(swordBase, 20, mockAffixes, mockIsA, rng)
|
||||||
|
if (res.prefix?.name === 'Common') commonCount++
|
||||||
|
if (res.prefix?.name === 'UltraRare') rareCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common (freq 90) should spawn much more often than UltraRare (freq 10)
|
||||||
|
expect(commonCount).toBeGreaterThan(rareCount * 3)
|
||||||
|
expect(commonCount + rareCount).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Affix Generator - Determinism & Reproducibility (Issue #104)', () => {
|
||||||
|
const mockIsA = (type: string, target: string): boolean => type === target
|
||||||
|
|
||||||
|
const swordBase: ItemBase = {
|
||||||
|
id: 'swor',
|
||||||
|
name: 'Broad Sword',
|
||||||
|
kind: 'weapon',
|
||||||
|
invWidth: 1,
|
||||||
|
invHeight: 3,
|
||||||
|
maxStack: 1,
|
||||||
|
value: 100,
|
||||||
|
damage: 10,
|
||||||
|
defense: 0,
|
||||||
|
tags: ['swor'],
|
||||||
|
level: 25,
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockAffix(name: string, isPrefix: boolean, group: number): MagicAffix {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
isPrefix,
|
||||||
|
version: 100,
|
||||||
|
spawnable: true,
|
||||||
|
rare: true,
|
||||||
|
level: 1,
|
||||||
|
maxlevel: 0,
|
||||||
|
levelreq: 1,
|
||||||
|
frequency: 5,
|
||||||
|
group,
|
||||||
|
mods: [{ code: 'att', min: 10, max: 50 }],
|
||||||
|
transform: false,
|
||||||
|
itypes: ['swor'],
|
||||||
|
etypes: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockAffixes = {
|
||||||
|
prefixes: {
|
||||||
|
all: [
|
||||||
|
createMockAffix('Bronze', true, 1),
|
||||||
|
createMockAffix('Iron', true, 2),
|
||||||
|
createMockAffix('Steel', true, 3),
|
||||||
|
],
|
||||||
|
} as unknown as AffixTable,
|
||||||
|
suffixes: {
|
||||||
|
all: [
|
||||||
|
createMockAffix('of Craft', false, 4),
|
||||||
|
createMockAffix('of Master', false, 5),
|
||||||
|
createMockAffix('of Titan', false, 6),
|
||||||
|
],
|
||||||
|
} as unknown as AffixTable,
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockRareNames = {
|
||||||
|
prefixes: {
|
||||||
|
getEligible: () => [{ index: 0, name: 'Gale', itypes: [], etypes: [] }],
|
||||||
|
} as unknown as RareNameTable,
|
||||||
|
suffixes: {
|
||||||
|
getEligible: () => [{ index: 0, name: 'Claw', itypes: [], etypes: [] }],
|
||||||
|
} as unknown as RareNameTable,
|
||||||
|
}
|
||||||
|
|
||||||
|
it('reproduces exact identical magic rolls with same seed', () => {
|
||||||
|
const fixedSeed = 0x12345678
|
||||||
|
|
||||||
|
const rng1 = new D2Rng(fixedSeed)
|
||||||
|
const magic1 = rollMagicAffixes(swordBase, 30, mockAffixes, mockIsA, rng1)
|
||||||
|
|
||||||
|
const rng2 = new D2Rng(fixedSeed)
|
||||||
|
const magic2 = rollMagicAffixes(swordBase, 30, mockAffixes, mockIsA, rng2)
|
||||||
|
|
||||||
|
expect(magic1.name).toBe(magic2.name)
|
||||||
|
expect(magic1.alvl).toBe(magic2.alvl)
|
||||||
|
expect(magic1.prefixes).toEqual(magic2.prefixes)
|
||||||
|
expect(magic1.suffixes).toEqual(magic2.suffixes)
|
||||||
|
expect(magic1.affixes).toEqual(magic2.affixes)
|
||||||
|
expect(magic1.dwInitSeed).toBe(magic2.dwInitSeed)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reproduces exact identical rare rolls with same seed', () => {
|
||||||
|
const fixedSeed = 0x87654321
|
||||||
|
|
||||||
|
const rng1 = new D2Rng(fixedSeed)
|
||||||
|
const rare1 = rollRareAffixes(swordBase, 30, mockAffixes, mockRareNames, mockIsA, rng1)
|
||||||
|
|
||||||
|
const rng2 = new D2Rng(fixedSeed)
|
||||||
|
const rare2 = rollRareAffixes(swordBase, 30, mockAffixes, mockRareNames, mockIsA, rng2)
|
||||||
|
|
||||||
|
expect(rare1.name).toBe(rare2.name)
|
||||||
|
expect(rare1.alvl).toBe(rare2.alvl)
|
||||||
|
expect(rare1.rarePrefix).toEqual(rare2.rarePrefix)
|
||||||
|
expect(rare1.rareSuffix).toEqual(rare2.rareSuffix)
|
||||||
|
expect(rare1.prefixes).toEqual(rare2.prefixes)
|
||||||
|
expect(rare1.suffixes).toEqual(rare2.suffixes)
|
||||||
|
expect(rare1.affixes).toEqual(rare2.affixes)
|
||||||
|
expect(rare1.dwInitSeed).toBe(rare2.dwInitSeed)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Affix Generator - Real MPQ Data Integration (when samples/d2 exists)', () => {
|
||||||
|
const hasMpqs = fs.existsSync('samples/d2/d2exp.mpq')
|
||||||
|
|
||||||
|
it.skipIf(!hasMpqs)('rolls realistic magic and rare items from vanilla 1.13c game data', async () => {
|
||||||
|
const archives = new MountedArchives()
|
||||||
|
archives.add('d2data.mpq', await MpqArchive.open(await fileSource('samples/d2/d2data.mpq')))
|
||||||
|
archives.add('d2exp.mpq', await MpqArchive.open(await fileSource('samples/d2/d2exp.mpq')))
|
||||||
|
|
||||||
|
// Load tables
|
||||||
|
const prefixBytes = await archives.read('data\\global\\excel\\MagicPrefix.txt')
|
||||||
|
const suffixBytes = await archives.read('data\\global\\excel\\MagicSuffix.txt')
|
||||||
|
const rarePrefixBytes = await archives.read('data\\global\\excel\\RarePrefix.txt')
|
||||||
|
const rareSuffixBytes = await archives.read('data\\global\\excel\\RareSuffix.txt')
|
||||||
|
|
||||||
|
const prefixes = parseMagicAffixesTable(prefixBytes, true)
|
||||||
|
const suffixes = parseMagicAffixesTable(suffixBytes, false)
|
||||||
|
const rarePrefixes = parseRareNamesTable(parseTable(rarePrefixBytes))
|
||||||
|
const rareSuffixes = parseRareNamesTable(parseTable(rareSuffixBytes))
|
||||||
|
const itemTypeTable = await loadItemTypes(archives)
|
||||||
|
|
||||||
|
const isAPredicate = (type: string, target: string) => realIsA(type, target, itemTypeTable)
|
||||||
|
|
||||||
|
const crystalSword: ItemBase = {
|
||||||
|
id: 'crs',
|
||||||
|
name: 'Crystal Sword',
|
||||||
|
kind: 'weapon',
|
||||||
|
invWidth: 2,
|
||||||
|
invHeight: 3,
|
||||||
|
maxStack: 1,
|
||||||
|
value: 1200,
|
||||||
|
damage: 15,
|
||||||
|
defense: 0,
|
||||||
|
tags: ['weap', 'swor'],
|
||||||
|
level: 11,
|
||||||
|
}
|
||||||
|
|
||||||
|
const affixes = { prefixes, suffixes }
|
||||||
|
const rareNames = { prefixes: rarePrefixes, suffixes: rareSuffixes }
|
||||||
|
|
||||||
|
// Roll a magic sword at ilvl 50
|
||||||
|
const rngMagic = new D2Rng(99999)
|
||||||
|
const magicSword = rollMagicAffixes(crystalSword, 50, affixes, isAPredicate, rngMagic)
|
||||||
|
|
||||||
|
expect(magicSword.alvl).toBe(50) // qlvl=11 <= trunc(50/2)=25 -> alvl=50
|
||||||
|
expect(magicSword.affixes.length).toBeGreaterThanOrEqual(1)
|
||||||
|
expect(magicSword.affixes.length).toBeLessThanOrEqual(2)
|
||||||
|
expect(magicSword.name).toContain('Crystal Sword')
|
||||||
|
|
||||||
|
// Roll a rare sword at ilvl 80
|
||||||
|
const rngRare = new D2Rng(88888)
|
||||||
|
const rareSword = rollRareAffixes(crystalSword, 80, affixes, rareNames, isAPredicate, rngRare)
|
||||||
|
|
||||||
|
expect(rareSword.alvl).toBe(80)
|
||||||
|
expect(rareSword.affixes.length).toBeGreaterThanOrEqual(3)
|
||||||
|
expect(rareSword.affixes.length).toBeLessThanOrEqual(6)
|
||||||
|
expect(rareSword.prefixes.length).toBeGreaterThanOrEqual(1)
|
||||||
|
expect(rareSword.prefixes.length).toBeLessThanOrEqual(3)
|
||||||
|
expect(rareSword.suffixes.length).toBeGreaterThanOrEqual(1)
|
||||||
|
expect(rareSword.suffixes.length).toBeLessThanOrEqual(3)
|
||||||
|
expect(rareSword.rarePrefix).toBeDefined()
|
||||||
|
expect(rareSword.rareSuffix).toBeDefined()
|
||||||
|
expect(rareSword.name).toBe(`${rareSword.rarePrefix!.name} ${rareSword.rareSuffix!.name}`)
|
||||||
|
|
||||||
|
// Verify group mutex holds across all rolled affixes
|
||||||
|
const seenGroups = new Set<number>()
|
||||||
|
for (const affix of rareSword.affixes) {
|
||||||
|
if (affix.group > 0) {
|
||||||
|
expect(seenGroups.has(affix.group)).toBe(false)
|
||||||
|
seenGroups.add(affix.group)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue