feat(drop/affix): 将AutoMagic.txt与1.13c StaffMods接入掉落与Tooltip管线 (fixes #129)
This commit is contained in:
parent
23a4a6867a
commit
6d2de24000
|
|
@ -556,3 +556,16 @@ export function rollRareAffixes(
|
|||
seed: initialSeed,
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
rollAutoMagic,
|
||||
type RolledAutoMagic,
|
||||
type RolledAutoMagicMod,
|
||||
resolveAutoPrefixGroup,
|
||||
ITEM_TYPE_AUTO_PREFIX_MAP,
|
||||
rollStaffMods,
|
||||
resolveStaffModsClass,
|
||||
ITEM_TYPE_STAFF_MODS_MAP,
|
||||
STAFF_MOD_CLASS_SKILL_START,
|
||||
} from './automagic.ts'
|
||||
|
||||
|
|
|
|||
|
|
@ -81,6 +81,8 @@ export interface ArmorBase extends ItemBase {
|
|||
readonly value: number
|
||||
/** Inherent weapon attack damage (0 for armors). */
|
||||
readonly damage: number
|
||||
/** Optional AutoMagic group from 'auto prefix' column (e.g. 304 for Paladin shields, 305 for Shrunken Heads). */
|
||||
readonly autoPrefix?: number | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -205,6 +207,9 @@ export function parseArmorTable(table: D2Table): ArmorTable {
|
|||
const ultracode = strAt(row, 'ultracode')
|
||||
const invWidth = numAt(row, 'invwidth', 0)
|
||||
const invHeight = numAt(row, 'invheight', 0)
|
||||
const rawAutoPrefix = strAt(row, 'auto prefix')
|
||||
const parsedAutoPrefix = rawAutoPrefix ? numAt(row, 'auto prefix', 0) : 0
|
||||
const autoPrefix = parsedAutoPrefix > 0 ? parsedAutoPrefix : undefined
|
||||
|
||||
const tags: string[] = []
|
||||
if (type) tags.push(type)
|
||||
|
|
@ -243,6 +248,7 @@ export function parseArmorTable(table: D2Table): ArmorTable {
|
|||
maxStack: 1,
|
||||
value: cost,
|
||||
damage: 0,
|
||||
...(autoPrefix !== undefined ? { autoPrefix } : {}),
|
||||
}
|
||||
|
||||
all.push(armor)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,16 @@
|
|||
import { parseTable } from './acts.ts'
|
||||
import type { D2Table } from './acts.ts'
|
||||
import type { MountedArchives } from '../mpq/mount.ts'
|
||||
import type { ItemBase, RolledItemProp } from './items.ts'
|
||||
import { D2Rng } from './d2-rng.ts'
|
||||
import type { ItemTypeTable } from './item-types.ts'
|
||||
import {
|
||||
calculateAlvl,
|
||||
getItemTypeCode,
|
||||
getBaseQlvl,
|
||||
getBaseMagicLvl,
|
||||
NON_RANDOM_RANGE_CODES,
|
||||
} from './affix-generator.ts'
|
||||
|
||||
/** One modifier on an AutoMagic entry. */
|
||||
export interface AutoMagicMod {
|
||||
|
|
@ -443,3 +453,393 @@ export function getEligibleAutoMagic(
|
|||
|
||||
return filterEligibleAutoMagic(entries, typeCode, alvl, isA, options)
|
||||
}
|
||||
|
||||
/** Concrete rolled modifier from an AutoMagic entry. */
|
||||
export interface RolledAutoMagicMod {
|
||||
readonly code: string
|
||||
readonly param?: string | undefined
|
||||
readonly min: number
|
||||
readonly max: number
|
||||
readonly value: number
|
||||
}
|
||||
|
||||
/** Concrete rolled AutoMagic affix on an item. */
|
||||
export interface RolledAutoMagic {
|
||||
readonly entry: AutoMagicEntry
|
||||
readonly id: number
|
||||
readonly name: string
|
||||
readonly group: number
|
||||
readonly level: number
|
||||
readonly levelreq: number
|
||||
readonly mods: readonly RolledAutoMagicMod[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback mapping from 1.13c item type code to AutoMagic group (Armor.txt & Weapons.txt `auto prefix` column).
|
||||
* - `abow`: 300 (Amazon Bow -> Bow & Crossbow Skills)
|
||||
* - `aspe`: 302 (Amazon Spear -> Javelin & Spear Skills)
|
||||
* - `ajav`: 302 (Amazon Javelin -> Javelin & Spear Skills)
|
||||
* - `orb`: 303 (Sorceress Orb -> Life / Mana)
|
||||
* - `ashd`: 304 (Paladin Shield -> All Resistances / Enhanced Damage & Attack Rating)
|
||||
* - `head`: 305 (Necromancer Shrunken Head -> Poison Damage)
|
||||
*/
|
||||
export const ITEM_TYPE_AUTO_PREFIX_MAP: Readonly<Record<string, number>> = {
|
||||
abow: 300,
|
||||
aspe: 302,
|
||||
ajav: 302,
|
||||
orb: 303,
|
||||
ashd: 304,
|
||||
head: 305,
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the AutoMagic group (`auto prefix`, e.g. 300..305) for an item base.
|
||||
*/
|
||||
export function resolveAutoPrefixGroup(
|
||||
base: ItemBase,
|
||||
itemTypes?: ItemTypeTable,
|
||||
isA?: IsAPredicate,
|
||||
): number | undefined {
|
||||
const explicit = (base as any).autoPrefix
|
||||
if (typeof explicit === 'number' && explicit > 0) {
|
||||
return explicit
|
||||
}
|
||||
|
||||
const typeCode = getItemTypeCode(base).trim().toLowerCase()
|
||||
if (ITEM_TYPE_AUTO_PREFIX_MAP[typeCode] !== undefined) {
|
||||
return ITEM_TYPE_AUTO_PREFIX_MAP[typeCode]
|
||||
}
|
||||
|
||||
if (base.tags) {
|
||||
for (const tag of base.tags) {
|
||||
const t = tag.trim().toLowerCase()
|
||||
if (ITEM_TYPE_AUTO_PREFIX_MAP[t] !== undefined) {
|
||||
return ITEM_TYPE_AUTO_PREFIX_MAP[t]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (itemTypes) {
|
||||
const visited = new Set<string>()
|
||||
const queue: string[] = [typeCode, ...(base.tags ?? [])]
|
||||
while (queue.length > 0) {
|
||||
const curr = queue.shift()!.trim().toLowerCase()
|
||||
if (!curr || visited.has(curr)) continue
|
||||
visited.add(curr)
|
||||
if (ITEM_TYPE_AUTO_PREFIX_MAP[curr] !== undefined) {
|
||||
return ITEM_TYPE_AUTO_PREFIX_MAP[curr]
|
||||
}
|
||||
const def = itemTypes.byCode.get(curr)
|
||||
if (!def) continue
|
||||
if (def.equiv1) queue.push(def.equiv1)
|
||||
if (def.equiv2) queue.push(def.equiv2)
|
||||
}
|
||||
}
|
||||
|
||||
if (isA) {
|
||||
for (const [candidateType, grp] of Object.entries(ITEM_TYPE_AUTO_PREFIX_MAP)) {
|
||||
if (isA(typeCode, candidateType)) {
|
||||
return grp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Rolls an innate AutoMagic affix for a class-specific item base (D2Common!Items).
|
||||
*/
|
||||
export function rollAutoMagic(
|
||||
base: ItemBase,
|
||||
ilvl: number,
|
||||
tableOrIsA?: AutoMagicTable | IsAPredicate,
|
||||
isAOrRng?: IsAPredicate | D2Rng,
|
||||
rngOrOptions?: D2Rng | { readonly group?: number | undefined; readonly isRare?: boolean | undefined },
|
||||
maybeOptions?: { readonly group?: number | undefined; readonly isRare?: boolean | undefined },
|
||||
): RolledAutoMagic | null {
|
||||
let table: AutoMagicTable = CANONICAL_AUTOMAGIC_TABLE
|
||||
let isA: IsAPredicate = (a, b) => a === b
|
||||
let rng: D2Rng = new D2Rng(1)
|
||||
let options: { readonly group?: number | undefined; readonly isRare?: boolean | undefined } | undefined
|
||||
|
||||
if (typeof tableOrIsA === 'function') {
|
||||
isA = tableOrIsA
|
||||
if (isAOrRng instanceof D2Rng) {
|
||||
rng = isAOrRng
|
||||
}
|
||||
if (rngOrOptions && !(rngOrOptions instanceof D2Rng)) {
|
||||
options = rngOrOptions
|
||||
} else {
|
||||
options = maybeOptions
|
||||
}
|
||||
} else {
|
||||
if (tableOrIsA) {
|
||||
table = tableOrIsA
|
||||
}
|
||||
if (typeof isAOrRng === 'function') {
|
||||
isA = isAOrRng
|
||||
}
|
||||
if (rngOrOptions instanceof D2Rng) {
|
||||
rng = rngOrOptions
|
||||
}
|
||||
options = maybeOptions
|
||||
}
|
||||
|
||||
const group = options?.group ?? resolveAutoPrefixGroup(base, undefined, isA)
|
||||
if (!group || group <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const qlvl = getBaseQlvl(base)
|
||||
const magicLvl = getBaseMagicLvl(base)
|
||||
const alvl = calculateAlvl(ilvl, qlvl, magicLvl)
|
||||
const typeCode = getItemTypeCode(base).trim().toLowerCase()
|
||||
|
||||
const effectiveIsA: IsAPredicate = (child, parent) => {
|
||||
if (child === parent) return true
|
||||
if (isA(child, parent)) return true
|
||||
if (base.kind === 'armor' && parent === 'armo') return true
|
||||
if (base.kind === 'weapon' && parent === 'weap') return true
|
||||
if (base.tags?.some(t => t.toLowerCase() === parent.toLowerCase())) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const candidates = getEligibleAutoMagic(table, typeCode, alvl, effectiveIsA, {
|
||||
group,
|
||||
rare: Boolean(options?.isRare),
|
||||
})
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
let totalWeight = 0
|
||||
for (const c of candidates) {
|
||||
totalWeight += Math.max(0, c.frequency)
|
||||
}
|
||||
|
||||
let picked: AutoMagicEntry = candidates[candidates.length - 1]!
|
||||
if (totalWeight <= 0) {
|
||||
picked = candidates[rng.rand(candidates.length)]!
|
||||
} else {
|
||||
const roll = rng.rand(totalWeight)
|
||||
let acc = 0
|
||||
for (const c of candidates) {
|
||||
acc += Math.max(0, c.frequency)
|
||||
if (roll < acc) {
|
||||
picked = c
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mods: RolledAutoMagicMod[] = picked.mods.map(mod => {
|
||||
const codeLower = mod.code.toLowerCase()
|
||||
if (NON_RANDOM_RANGE_CODES.has(codeLower) || mod.min === mod.max) {
|
||||
return {
|
||||
code: mod.code,
|
||||
...(mod.param !== undefined ? { param: mod.param } : {}),
|
||||
min: mod.min,
|
||||
max: mod.max,
|
||||
value: mod.min,
|
||||
}
|
||||
}
|
||||
const val = rng.randRange(mod.min, mod.max)
|
||||
return {
|
||||
code: mod.code,
|
||||
...(mod.param !== undefined ? { param: mod.param } : {}),
|
||||
min: mod.min,
|
||||
max: mod.max,
|
||||
value: val,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
entry: picked,
|
||||
id: picked.id,
|
||||
name: picked.name,
|
||||
group: picked.group,
|
||||
level: picked.level,
|
||||
levelreq: picked.levelreq,
|
||||
mods,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starting skill ID for each character class's 30 skills (6 tiers x 5 skills per tier) in Skills.txt.
|
||||
*/
|
||||
export const STAFF_MOD_CLASS_SKILL_START: Readonly<Record<string, number>> = {
|
||||
ama: 6,
|
||||
sor: 36,
|
||||
nec: 66,
|
||||
pal: 96,
|
||||
bar: 126,
|
||||
dru: 221,
|
||||
ass: 251,
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback mapping from 1.13c item type code to `StaffMods` class code (`ItemTypes.txt`).
|
||||
*/
|
||||
export const ITEM_TYPE_STAFF_MODS_MAP: Readonly<Record<string, string>> = {
|
||||
scep: 'pal',
|
||||
wand: 'nec',
|
||||
staf: 'sor',
|
||||
orb: 'sor',
|
||||
head: 'nec',
|
||||
phlm: 'bar',
|
||||
pelt: 'dru',
|
||||
cloa: 'ass',
|
||||
h2h2: 'ass',
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the character class (`'sor' | 'nec' | 'pal' | 'bar' | 'dru' | 'ass'`) for StaffMods on an item base.
|
||||
* Traverses `ItemTypes.txt` via `equiv1` / `equiv2` and falls back to `ITEM_TYPE_STAFF_MODS_MAP`.
|
||||
*/
|
||||
export function resolveStaffModsClass(
|
||||
base: ItemBase,
|
||||
itemTypes?: ItemTypeTable,
|
||||
): string | undefined {
|
||||
const typeCode = getItemTypeCode(base).trim().toLowerCase()
|
||||
|
||||
if (itemTypes) {
|
||||
const visited = new Set<string>()
|
||||
const queue: string[] = [typeCode, ...(base.tags ?? [])]
|
||||
while (queue.length > 0) {
|
||||
const curr = queue.shift()!.trim().toLowerCase()
|
||||
if (!curr || visited.has(curr)) continue
|
||||
visited.add(curr)
|
||||
const def = itemTypes.byCode.get(curr)
|
||||
if (def) {
|
||||
const sm = def.staffMods?.trim().toLowerCase()
|
||||
if (sm && STAFF_MOD_CLASS_SKILL_START[sm] !== undefined) {
|
||||
return sm
|
||||
}
|
||||
if (def.equiv1) queue.push(def.equiv1)
|
||||
if (def.equiv2) queue.push(def.equiv2)
|
||||
}
|
||||
if (ITEM_TYPE_STAFF_MODS_MAP[curr]) {
|
||||
return ITEM_TYPE_STAFF_MODS_MAP[curr]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ITEM_TYPE_STAFF_MODS_MAP[typeCode]) {
|
||||
return ITEM_TYPE_STAFF_MODS_MAP[typeCode]
|
||||
}
|
||||
|
||||
if (base.tags) {
|
||||
for (const tag of base.tags) {
|
||||
const t = tag.trim().toLowerCase()
|
||||
if (ITEM_TYPE_STAFF_MODS_MAP[t]) {
|
||||
return ITEM_TYPE_STAFF_MODS_MAP[t]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Rolls 1.13c StaffMods (`D2Common!ITEMMOD_ApplyStaffMods`) for eligible equipment bases.
|
||||
*
|
||||
* Rules:
|
||||
* - Each class has 30 skills across 6 tiers (reqLvl 1, 6, 12, 18, 24, 30; 5 skills per tier).
|
||||
* - Base tier by `ilvl`:
|
||||
* - `ilvl >= 37` -> 5
|
||||
* - `ilvl >= 25` -> 4
|
||||
* - `ilvl >= 19` -> 3
|
||||
* - `ilvl >= 12` -> 2
|
||||
* - `ilvl < 12` -> 1
|
||||
* - Skill count (`r = rng.rand(100)`):
|
||||
* - `r >= 90` (10%): 3 skills
|
||||
* - `r >= 70` (20%): 2 skills
|
||||
* - `r >= 30` (40%): 1 skill
|
||||
* - `r < 30` (30%): 0 skills
|
||||
* - For each slot:
|
||||
* - Tier offset (`tRoll = rng.rand(100)`):
|
||||
* `>= 80` (+1, max 6), `>= 30` (+0), `>= 10` (-1, min 1), `< 10` (-2, min 1).
|
||||
* - Pick 1 unselected skill ID from the 5 skills in that tier.
|
||||
* - Bonus roll (`bonusRoll = rng.rand(100)`):
|
||||
* `>= 90` -> `+3`, `>= 60` -> `+2`, else `+1`.
|
||||
* - Emit `{ code: 'skill', param: skillId, min: bonus, max: bonus, value: bonus }`.
|
||||
*/
|
||||
export function rollStaffMods(
|
||||
base: ItemBase,
|
||||
ilvl: number,
|
||||
itemTypes?: ItemTypeTable,
|
||||
rng?: D2Rng,
|
||||
): RolledItemProp[] {
|
||||
const classCode = resolveStaffModsClass(base, itemTypes)
|
||||
if (!classCode) {
|
||||
return []
|
||||
}
|
||||
const startSkillId = STAFF_MOD_CLASS_SKILL_START[classCode]
|
||||
if (startSkillId === undefined) {
|
||||
return []
|
||||
}
|
||||
|
||||
const effectiveRng = rng ?? new D2Rng(1)
|
||||
|
||||
let baseTier = 1
|
||||
if (ilvl >= 37) baseTier = 5
|
||||
else if (ilvl >= 25) baseTier = 4
|
||||
else if (ilvl >= 19) baseTier = 3
|
||||
else if (ilvl >= 12) baseTier = 2
|
||||
else baseTier = 1
|
||||
|
||||
const r = effectiveRng.rand(100)
|
||||
let count = 0
|
||||
if (r >= 90) count = 3
|
||||
else if (r >= 70) count = 2
|
||||
else if (r >= 30) count = 1
|
||||
else count = 0
|
||||
|
||||
if (count === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const selectedSkills = new Set<number>()
|
||||
const props: RolledItemProp[] = []
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const tRoll = effectiveRng.rand(100)
|
||||
let tierOffset = 0
|
||||
if (tRoll >= 80) tierOffset = 1
|
||||
else if (tRoll >= 30) tierOffset = 0
|
||||
else if (tRoll >= 10) tierOffset = -1
|
||||
else tierOffset = -2
|
||||
|
||||
const tier = Math.max(1, Math.min(6, baseTier + tierOffset))
|
||||
const tierStart = startSkillId + (tier - 1) * 5
|
||||
const available: number[] = []
|
||||
for (let k = 0; k < 5; k++) {
|
||||
const candidateId = tierStart + k
|
||||
if (!selectedSkills.has(candidateId)) {
|
||||
available.push(candidateId)
|
||||
}
|
||||
}
|
||||
if (available.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const skillId = available[effectiveRng.rand(available.length)]!
|
||||
selectedSkills.add(skillId)
|
||||
|
||||
const bonusRoll = effectiveRng.rand(100)
|
||||
const bonus = bonusRoll >= 90 ? 3 : bonusRoll >= 60 ? 2 : 1
|
||||
|
||||
props.push({
|
||||
code: 'skill',
|
||||
param: skillId,
|
||||
min: bonus,
|
||||
max: bonus,
|
||||
value: bonus,
|
||||
})
|
||||
}
|
||||
|
||||
return props
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -118,6 +118,32 @@ import {
|
|||
type GeneratedAffixes,
|
||||
type GeneratedRareAffixes,
|
||||
} from './affix-generator.ts'
|
||||
import {
|
||||
type AutoMagicTable,
|
||||
loadAutoMagic,
|
||||
CANONICAL_AUTOMAGIC_TABLE,
|
||||
rollAutoMagic,
|
||||
type RolledAutoMagic,
|
||||
type RolledAutoMagicMod,
|
||||
resolveAutoPrefixGroup,
|
||||
ITEM_TYPE_AUTO_PREFIX_MAP,
|
||||
rollStaffMods,
|
||||
resolveStaffModsClass,
|
||||
ITEM_TYPE_STAFF_MODS_MAP,
|
||||
STAFF_MOD_CLASS_SKILL_START,
|
||||
} from './automagic.ts'
|
||||
|
||||
export {
|
||||
rollAutoMagic,
|
||||
type RolledAutoMagic,
|
||||
type RolledAutoMagicMod,
|
||||
resolveAutoPrefixGroup,
|
||||
ITEM_TYPE_AUTO_PREFIX_MAP,
|
||||
rollStaffMods,
|
||||
resolveStaffModsClass,
|
||||
ITEM_TYPE_STAFF_MODS_MAP,
|
||||
STAFF_MOD_CLASS_SKILL_START,
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete collection of parsed game data tables required for the drop pipeline.
|
||||
|
|
@ -134,6 +160,7 @@ export interface DropTables {
|
|||
readonly sets: { readonly items: SetItemTable; readonly sets: SetTable }
|
||||
readonly magicAffixes: { readonly prefixes: AffixTable; readonly suffixes: AffixTable }
|
||||
readonly rareNames: { readonly prefixes: RareNameTable; readonly suffixes: RareNameTable }
|
||||
readonly autoMagic?: AutoMagicTable | undefined
|
||||
readonly itemTypes: ItemTypeTable
|
||||
readonly monsterKinds: Map<string, MonsterKind>
|
||||
readonly superUniques: Map<string, SuperUnique>
|
||||
|
|
@ -177,6 +204,13 @@ export async function loadDropTables(archives: MountedArchives): Promise<DropTab
|
|||
archives.read('data\\global\\excel\\monstats.txt'),
|
||||
])
|
||||
|
||||
let autoMagic: AutoMagicTable = CANONICAL_AUTOMAGIC_TABLE
|
||||
try {
|
||||
autoMagic = await loadAutoMagic(archives)
|
||||
} catch {
|
||||
autoMagic = CANONICAL_AUTOMAGIC_TABLE
|
||||
}
|
||||
|
||||
const rawAutoTcTable = generateAutoTreasureClasses(itemTypes, armor, weapons)
|
||||
const autoTcTable = new Map<string, TreasureClassNode>()
|
||||
for (const [key, node] of rawAutoTcTable) {
|
||||
|
|
@ -215,6 +249,7 @@ export async function loadDropTables(archives: MountedArchives): Promise<DropTab
|
|||
sets,
|
||||
magicAffixes,
|
||||
rareNames,
|
||||
autoMagic,
|
||||
itemTypes,
|
||||
monsterKinds,
|
||||
superUniques,
|
||||
|
|
@ -402,6 +437,8 @@ export function createDroppedItem(
|
|||
readonly setItem?: SetItem | undefined
|
||||
readonly magicAffixes?: GeneratedAffixes | undefined
|
||||
readonly rareAffixes?: GeneratedRareAffixes | undefined
|
||||
readonly autoMagicAffix?: RolledAutoMagic | null | undefined
|
||||
readonly staffMods?: readonly RolledItemProp[] | undefined
|
||||
readonly ilvl: number
|
||||
readonly dwInitSeed: number
|
||||
readonly durabilityMultiplier?: number | undefined
|
||||
|
|
@ -527,6 +564,48 @@ export function createDroppedItem(
|
|||
name = `Cracked ${base.name}`
|
||||
}
|
||||
|
||||
let effectiveAutoMagic = options.autoMagicAffix
|
||||
if (effectiveAutoMagic === undefined && qualityTier !== 'unique' && qualityTier !== 'set') {
|
||||
const autoPrefixGroup = resolveAutoPrefixGroup(base)
|
||||
if (autoPrefixGroup !== undefined && autoPrefixGroup > 0) {
|
||||
effectiveAutoMagic = rollAutoMagic(
|
||||
base,
|
||||
options.ilvl,
|
||||
CANONICAL_AUTOMAGIC_TABLE,
|
||||
(a, b) => a === b,
|
||||
propRng,
|
||||
{ group: autoPrefixGroup, isRare: qualityTier === 'rare' },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (qualityTier !== 'unique' && qualityTier !== 'set') {
|
||||
if (effectiveAutoMagic) {
|
||||
for (const mod of effectiveAutoMagic.mods) {
|
||||
rolledProps.push({
|
||||
code: mod.code,
|
||||
param: mod.param,
|
||||
min: mod.min,
|
||||
max: mod.max,
|
||||
value: mod.value,
|
||||
})
|
||||
stats[mod.code] = (stats[mod.code] ?? 0) + mod.value
|
||||
}
|
||||
}
|
||||
if (options.staffMods && options.staffMods.length > 0) {
|
||||
for (const sm of options.staffMods) {
|
||||
rolledProps.push({
|
||||
code: sm.code,
|
||||
param: sm.param,
|
||||
min: sm.min,
|
||||
max: sm.max,
|
||||
value: sm.value,
|
||||
})
|
||||
stats[sm.code] = (stats[sm.code] ?? 0) + sm.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rawDurability = (base as any).durability ?? 0
|
||||
const mult = options.durabilityMultiplier ?? 1
|
||||
let durability: number | undefined = undefined
|
||||
|
|
@ -562,6 +641,8 @@ export function createDroppedItem(
|
|||
...(options.setItem ? { setId: options.setItem.id, setItemDef: options.setItem } : {}),
|
||||
...(options.rareAffixes ? { rolledRareAffixes: options.rareAffixes } : {}),
|
||||
...(options.magicAffixes ? { rolledMagicAffixes: options.magicAffixes } : {}),
|
||||
...(effectiveAutoMagic ? { rolledAutoMagic: effectiveAutoMagic } : {}),
|
||||
...(options.staffMods && options.staffMods.length > 0 ? { staffMods: options.staffMods } : {}),
|
||||
...(rolledProps.length > 0 ? { rolledProps } : {}),
|
||||
} as Item
|
||||
}
|
||||
|
|
@ -802,8 +883,26 @@ export function executeDropPipeline(
|
|||
itemRng,
|
||||
)
|
||||
if (rareAffixes && rareAffixes.affixes && rareAffixes.affixes.length > 0) {
|
||||
const autoPrefixGroup = resolveAutoPrefixGroup(effectiveBase, dropTables.itemTypes, dropTables.isA)
|
||||
const autoMagicAffix =
|
||||
autoPrefixGroup !== undefined
|
||||
? rollAutoMagic(
|
||||
effectiveBase,
|
||||
nLevel,
|
||||
dropTables.autoMagic ?? CANONICAL_AUTOMAGIC_TABLE,
|
||||
dropTables.isA,
|
||||
itemRng,
|
||||
{ group: autoPrefixGroup, isRare: true },
|
||||
)
|
||||
: null
|
||||
const staffMods =
|
||||
resolveStaffModsClass(effectiveBase, dropTables.itemTypes) !== undefined
|
||||
? rollStaffMods(effectiveBase, nLevel, dropTables.itemTypes, itemRng)
|
||||
: undefined
|
||||
drops.push(createDroppedItem(effectiveBase, 'rare', {
|
||||
rareAffixes,
|
||||
autoMagicAffix,
|
||||
staffMods,
|
||||
ilvl: nLevel,
|
||||
dwInitSeed,
|
||||
durabilityMultiplier,
|
||||
|
|
@ -825,8 +924,26 @@ export function executeDropPipeline(
|
|||
dropTables.isA,
|
||||
itemRng,
|
||||
)
|
||||
const autoPrefixGroup = resolveAutoPrefixGroup(effectiveBase, dropTables.itemTypes, dropTables.isA)
|
||||
const autoMagicAffix =
|
||||
autoPrefixGroup !== undefined
|
||||
? rollAutoMagic(
|
||||
effectiveBase,
|
||||
nLevel,
|
||||
dropTables.autoMagic ?? CANONICAL_AUTOMAGIC_TABLE,
|
||||
dropTables.isA,
|
||||
itemRng,
|
||||
{ group: autoPrefixGroup, isRare: false },
|
||||
)
|
||||
: null
|
||||
const staffMods =
|
||||
resolveStaffModsClass(effectiveBase, dropTables.itemTypes) !== undefined
|
||||
? rollStaffMods(effectiveBase, nLevel, dropTables.itemTypes, itemRng)
|
||||
: undefined
|
||||
drops.push(createDroppedItem(effectiveBase, 'magic', {
|
||||
magicAffixes,
|
||||
autoMagicAffix,
|
||||
staffMods,
|
||||
ilvl: nLevel,
|
||||
dwInitSeed,
|
||||
durabilityMultiplier,
|
||||
|
|
@ -835,26 +952,52 @@ export function executeDropPipeline(
|
|||
continue
|
||||
}
|
||||
|
||||
const autoPrefixGroup = resolveAutoPrefixGroup(effectiveBase, dropTables.itemTypes, dropTables.isA)
|
||||
const autoMagicAffix =
|
||||
autoPrefixGroup !== undefined
|
||||
? rollAutoMagic(
|
||||
effectiveBase,
|
||||
nLevel,
|
||||
dropTables.autoMagic ?? CANONICAL_AUTOMAGIC_TABLE,
|
||||
dropTables.isA,
|
||||
itemRng,
|
||||
{ group: autoPrefixGroup, isRare: false },
|
||||
)
|
||||
: null
|
||||
const staffMods =
|
||||
resolveStaffModsClass(effectiveBase, dropTables.itemTypes) !== undefined
|
||||
? rollStaffMods(effectiveBase, nLevel, dropTables.itemTypes, itemRng)
|
||||
: undefined
|
||||
|
||||
if (quality === 'superior') {
|
||||
drops.push(createDroppedItem(effectiveBase, 'superior', {
|
||||
autoMagicAffix,
|
||||
staffMods,
|
||||
ilvl: nLevel,
|
||||
dwInitSeed,
|
||||
itemRng,
|
||||
}))
|
||||
continue
|
||||
}
|
||||
|
||||
if (quality === 'low') {
|
||||
drops.push(createDroppedItem(effectiveBase, 'low', {
|
||||
autoMagicAffix,
|
||||
staffMods,
|
||||
ilvl: nLevel,
|
||||
dwInitSeed,
|
||||
itemRng,
|
||||
}))
|
||||
continue
|
||||
}
|
||||
|
||||
// Default: normal quality
|
||||
drops.push(createDroppedItem(effectiveBase, 'normal', {
|
||||
autoMagicAffix,
|
||||
staffMods,
|
||||
ilvl: nLevel,
|
||||
dwInitSeed,
|
||||
itemRng,
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import {
|
|||
} from './item-upgrade.ts'
|
||||
import type { DropTables } from './drop-pipeline.ts'
|
||||
import type { ItemBase } from './items.ts'
|
||||
import { CANONICAL_AUTOMAGIC_TABLE } from './automagic.ts'
|
||||
|
||||
let _cachedDropTables: DropTables | null = null
|
||||
|
||||
|
|
@ -102,6 +103,7 @@ export function getEmbeddedDropTables(): DropTables {
|
|||
sets,
|
||||
magicAffixes,
|
||||
rareNames,
|
||||
autoMagic: CANONICAL_AUTOMAGIC_TABLE,
|
||||
itemTypes,
|
||||
monsterKinds: new Map(),
|
||||
superUniques: new Map(),
|
||||
|
|
|
|||
|
|
@ -1554,7 +1554,7 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip {
|
|||
}
|
||||
|
||||
// Check if this is Gold
|
||||
if (base.id === 'gld' || item.name.toLowerCase().includes('gold')) {
|
||||
if (base.id === 'gld' || (base.kind !== 'weapon' && base.kind !== 'armor' && base.name.trim().toLowerCase() === 'gold')) {
|
||||
const amount = (item.stats.amount ?? item.value ?? 100)
|
||||
title = `${amount.toLocaleString()} Gold`
|
||||
qualityColor = '#ffd700'
|
||||
|
|
@ -1572,6 +1572,11 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip {
|
|||
const rawProps: RolledItemProp[] = []
|
||||
let affixReqLevel = 0
|
||||
|
||||
const autoMagicDef = (item as any).rolledAutoMagic as { levelreq?: number; mods?: readonly RolledMod[] } | undefined
|
||||
if (autoMagicDef?.levelreq && autoMagicDef.levelreq > 0) {
|
||||
affixReqLevel = Math.max(affixReqLevel, autoMagicDef.levelreq)
|
||||
}
|
||||
|
||||
if (quality === 'unique') {
|
||||
qualityColor = '#c8a15a'
|
||||
const uniqDef = item.uniqueItemDef as UniqueItem | undefined
|
||||
|
|
@ -1626,10 +1631,27 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip {
|
|||
qualityColor = '#ffff60'
|
||||
subTitle = base.name
|
||||
const rareDef = item.rolledRareAffixes as GeneratedRareAffixes | undefined
|
||||
if (rareDef && rareDef.affixes) {
|
||||
title = rareDef.name
|
||||
if (rareDef) {
|
||||
if (rareDef.name) title = rareDef.name
|
||||
if (rareDef.affixes) {
|
||||
for (const aff of rareDef.affixes) {
|
||||
if (aff.levelreq > 0) affixReqLevel = Math.max(affixReqLevel, aff.levelreq)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (item.rolledProps && item.rolledProps.length > 0) {
|
||||
for (const p of item.rolledProps) {
|
||||
const keepRange = NON_RANDOM_RANGE_CODES.has(p.code.toLowerCase()) || (p.min === 0 && p.max === 0)
|
||||
rawProps.push({
|
||||
code: p.code,
|
||||
param: p.param,
|
||||
min: keepRange ? p.min : p.value,
|
||||
max: keepRange ? p.max : p.value,
|
||||
value: p.value,
|
||||
})
|
||||
}
|
||||
} else if (rareDef && rareDef.affixes) {
|
||||
for (const aff of rareDef.affixes) {
|
||||
if (aff.levelreq > 0) affixReqLevel = Math.max(affixReqLevel, aff.levelreq)
|
||||
for (const mod of aff.mods) {
|
||||
const keepRange = NON_RANDOM_RANGE_CODES.has(mod.code.toLowerCase()) || (mod.min === 0 && mod.max === 0)
|
||||
rawProps.push({
|
||||
|
|
@ -1641,17 +1663,33 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip {
|
|||
})
|
||||
}
|
||||
}
|
||||
} else if (item.rolledProps && item.rolledProps.length > 0) {
|
||||
rawProps.push(...item.rolledProps)
|
||||
}
|
||||
} else if (quality === 'magic') {
|
||||
qualityColor = '#4880ff'
|
||||
subTitle = base.name
|
||||
const magicDef = item.rolledMagicAffixes as GeneratedAffixes | undefined
|
||||
if (magicDef) {
|
||||
title = magicDef.name
|
||||
subTitle = base.name
|
||||
if (magicDef.name) title = magicDef.name
|
||||
if (magicDef.prefix && magicDef.prefix.levelreq > 0) {
|
||||
affixReqLevel = Math.max(affixReqLevel, magicDef.prefix.levelreq)
|
||||
}
|
||||
if (magicDef.suffix && magicDef.suffix.levelreq > 0) {
|
||||
affixReqLevel = Math.max(affixReqLevel, magicDef.suffix.levelreq)
|
||||
}
|
||||
}
|
||||
if (item.rolledProps && item.rolledProps.length > 0) {
|
||||
for (const p of item.rolledProps) {
|
||||
const keepRange = NON_RANDOM_RANGE_CODES.has(p.code.toLowerCase()) || (p.min === 0 && p.max === 0)
|
||||
rawProps.push({
|
||||
code: p.code,
|
||||
param: p.param,
|
||||
min: keepRange ? p.min : p.value,
|
||||
max: keepRange ? p.max : p.value,
|
||||
value: p.value,
|
||||
})
|
||||
}
|
||||
} else if (magicDef) {
|
||||
if (magicDef.prefix) {
|
||||
if (magicDef.prefix.levelreq > 0) affixReqLevel = Math.max(affixReqLevel, magicDef.prefix.levelreq)
|
||||
for (const m of magicDef.prefix.mods) {
|
||||
const keepRange = NON_RANDOM_RANGE_CODES.has(m.code.toLowerCase()) || (m.min === 0 && m.max === 0)
|
||||
rawProps.push({
|
||||
|
|
@ -1664,7 +1702,6 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip {
|
|||
}
|
||||
}
|
||||
if (magicDef.suffix) {
|
||||
if (magicDef.suffix.levelreq > 0) affixReqLevel = Math.max(affixReqLevel, magicDef.suffix.levelreq)
|
||||
for (const m of magicDef.suffix.mods) {
|
||||
const keepRange = NON_RANDOM_RANGE_CODES.has(m.code.toLowerCase()) || (m.min === 0 && m.max === 0)
|
||||
rawProps.push({
|
||||
|
|
@ -1676,11 +1713,6 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip {
|
|||
})
|
||||
}
|
||||
}
|
||||
} else if (item.rolledProps && item.rolledProps.length > 0) {
|
||||
subTitle = base.name
|
||||
rawProps.push(...item.rolledProps)
|
||||
} else {
|
||||
subTitle = base.name
|
||||
}
|
||||
} else if (quality === 'superior') {
|
||||
qualityColor = '#ffffff'
|
||||
|
|
@ -1690,9 +1722,15 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip {
|
|||
} else {
|
||||
rawProps.push({ code: 'ac%', min: 15, max: 15, value: 15 })
|
||||
}
|
||||
if (item.rolledProps && item.rolledProps.length > 0) {
|
||||
rawProps.push(...item.rolledProps)
|
||||
}
|
||||
} else if (quality === 'low') {
|
||||
qualityColor = '#a0a0a0'
|
||||
title = `Cracked ${base.name}`
|
||||
if (item.rolledProps && item.rolledProps.length > 0) {
|
||||
rawProps.push(...item.rolledProps)
|
||||
}
|
||||
} else {
|
||||
qualityColor = '#e0d5c1'
|
||||
title = base.name
|
||||
|
|
|
|||
|
|
@ -110,6 +110,8 @@ export interface WeaponBase extends ItemBase {
|
|||
readonly defense: number
|
||||
/** Maximum stack capacity for ItemBase compliance (maxstack ?? 1). */
|
||||
readonly maxStack: number
|
||||
/** Optional AutoMagic group from 'auto prefix' column (e.g. 300, 302, 303). */
|
||||
readonly autoPrefix?: number
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -276,6 +278,7 @@ export function parseWeaponsTable(input: D2Table | Uint8Array | string): WeaponT
|
|||
const gemsockets = parseNumber(getCell(row, 'gemsockets'), 0)
|
||||
const invWidth = parseNumber(getCell(row, 'invwidth'), 0)
|
||||
const invHeight = parseNumber(getCell(row, 'invheight'), 0)
|
||||
const autoPrefix = parseOptionalNumber(getCell(row, 'auto prefix'))
|
||||
const tags = type2 ? [type, type2] : [type]
|
||||
const damage = maxdam
|
||||
|
||||
|
|
@ -322,6 +325,7 @@ export function parseWeaponsTable(input: D2Table | Uint8Array | string): WeaponT
|
|||
value: cost,
|
||||
defense: 0,
|
||||
maxStack: maxstack ?? 1,
|
||||
...(autoPrefix !== undefined && autoPrefix > 0 ? { autoPrefix } : {}),
|
||||
}
|
||||
|
||||
all.push(weapon)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,10 @@ import {
|
|||
executeDropPipeline,
|
||||
resolveItemTypeFlags,
|
||||
createDroppedItem,
|
||||
rollAutoMagic,
|
||||
rollStaffMods,
|
||||
resolveAutoPrefixGroup,
|
||||
resolveStaffModsClass,
|
||||
type DropTables,
|
||||
} from '../src/game/drop-pipeline.ts'
|
||||
import {
|
||||
|
|
@ -551,3 +555,184 @@ describe('Magic & Rare Affix NON_RANDOM_RANGE_CODES Range & Skill Parameter Prot
|
|||
})
|
||||
})
|
||||
|
||||
describe('AutoMagic.txt & 1.13c StaffMods Drop Pipeline & Tooltip Integration (Issue #129)', () => {
|
||||
let dropTables: DropTables
|
||||
|
||||
beforeAll(() => {
|
||||
dropTables = getEmbeddedDropTables()
|
||||
})
|
||||
|
||||
it('1. loads dropTables.autoMagic with 36 canonical entries and parses autoPrefix on class-specific bases', () => {
|
||||
expect(dropTables.autoMagic).toBeDefined()
|
||||
expect(dropTables.autoMagic!.length).toBe(36)
|
||||
expect(dropTables.autoMagic!.entries.length).toBe(36)
|
||||
|
||||
const targe = dropTables.getBase('pa1')!
|
||||
const vortexShield = dropTables.getBase('paf')!
|
||||
const maidenJavelin = dropTables.getBase('am5')!
|
||||
const eagleOrb = dropTables.getBase('ob1')!
|
||||
const stagBow = dropTables.getBase('am1')!
|
||||
const preservedHead = dropTables.getBase('ne1')!
|
||||
|
||||
expect(resolveAutoPrefixGroup(targe, dropTables.itemTypes, dropTables.isA)).toBe(304)
|
||||
expect(resolveAutoPrefixGroup(vortexShield, dropTables.itemTypes, dropTables.isA)).toBe(304)
|
||||
expect(resolveAutoPrefixGroup(maidenJavelin, dropTables.itemTypes, dropTables.isA)).toBe(302)
|
||||
expect(resolveAutoPrefixGroup(eagleOrb, dropTables.itemTypes, dropTables.isA)).toBe(303)
|
||||
expect(resolveAutoPrefixGroup(stagBow, dropTables.itemTypes, dropTables.isA)).toBe(300)
|
||||
expect(resolveAutoPrefixGroup(preservedHead, dropTables.itemTypes, dropTables.isA)).toBe(305)
|
||||
})
|
||||
|
||||
it('2. Paladin Shields (pa1 / paf) and Amazon Javelins (am5) automatically carry AutoMagic affixes across Normal, Magic, and Rare drops and render in formatItemTooltip', () => {
|
||||
// Test Paladin Shield (paf Vortex Shield / pa1 Targe) across Normal, Magic, Rare
|
||||
const seenPalQualities = new Set<string>()
|
||||
for (let i = 0; i < 250; i++) {
|
||||
const mf = i < 40 ? -100 : i < 100 ? 100 : 800
|
||||
const drops = executeDropPipeline({
|
||||
tcName: i % 2 === 0 ? 'paf' : 'pa1',
|
||||
nLevel: 85,
|
||||
monsterType: 1,
|
||||
dropTables,
|
||||
difficulty: 'hell',
|
||||
playerMf: mf,
|
||||
gamePlayers: 1,
|
||||
partyPlayers: 1,
|
||||
monsterRng: (0x12900000 + i * 997) >>> 0,
|
||||
})
|
||||
expect(drops.length).toBe(1)
|
||||
const item = drops[0]!
|
||||
const q = item.rarity ?? 'normal'
|
||||
if (q === 'normal' || q === 'magic' || q === 'rare') {
|
||||
seenPalQualities.add(q)
|
||||
expect((item as any).rolledAutoMagic).toBeDefined()
|
||||
expect((item as any).rolledAutoMagic.group).toBe(304)
|
||||
|
||||
const tooltip = formatItemTooltip(item)
|
||||
const texts = tooltip.lines.map(l => l.text)
|
||||
const hasResAll = texts.some(t => /^All Resistances \+\d+/.test(t) || /Resist \+\d+%/.test(t))
|
||||
const hasEdAndAr =
|
||||
texts.some(t => /\+\d+% Enhanced Damage/.test(t)) &&
|
||||
texts.some(t => /\+\d+ to Attack Rating/.test(t))
|
||||
expect(
|
||||
hasResAll || hasEdAndAr,
|
||||
`Paladin shield (${item.base.id}, quality=${q}) tooltip lines ${JSON.stringify(texts)} must include AutoMagic res-all or dmg%/att`,
|
||||
).toBe(true)
|
||||
}
|
||||
}
|
||||
expect(seenPalQualities.has('normal')).toBe(true)
|
||||
expect(seenPalQualities.has('magic')).toBe(true)
|
||||
expect(seenPalQualities.has('rare')).toBe(true)
|
||||
|
||||
// Test Amazon Javelin (am5 Maiden Javelin) across Normal, Magic, Rare
|
||||
const seenJavQualities = new Set<string>()
|
||||
for (let i = 0; i < 250; i++) {
|
||||
const mf = i < 40 ? -100 : i < 100 ? 100 : 800
|
||||
const drops = executeDropPipeline({
|
||||
tcName: 'am5',
|
||||
nLevel: 85,
|
||||
monsterType: 1,
|
||||
dropTables,
|
||||
difficulty: 'hell',
|
||||
playerMf: mf,
|
||||
gamePlayers: 1,
|
||||
partyPlayers: 1,
|
||||
monsterRng: (0x12950000 + i * 1013) >>> 0,
|
||||
})
|
||||
expect(drops.length).toBe(1)
|
||||
const item = drops[0]!
|
||||
const q = item.rarity ?? 'normal'
|
||||
if (q === 'normal' || q === 'magic' || q === 'rare') {
|
||||
seenJavQualities.add(q)
|
||||
expect((item as any).rolledAutoMagic).toBeDefined()
|
||||
expect((item as any).rolledAutoMagic.group).toBe(302)
|
||||
|
||||
const tooltip = formatItemTooltip(item)
|
||||
const texts = tooltip.lines.map(l => l.text)
|
||||
expect(
|
||||
texts.some(t => /^\+[1-9]\d* to Javelin and Spear Skills \(Amazon Only\)$/.test(t)),
|
||||
`Amazon javelin (am5, quality=${q}) tooltip lines ${JSON.stringify(texts)} must include +X to Javelin and Spear Skills (Amazon Only)`,
|
||||
).toBe(true)
|
||||
}
|
||||
}
|
||||
expect(seenJavQualities.has('normal')).toBe(true)
|
||||
expect(seenJavQualities.has('magic')).toBe(true)
|
||||
expect(seenJavQualities.has('rare')).toBe(true)
|
||||
})
|
||||
|
||||
it('3. rollStaffMods generates +1..+3 to [Skill] ([Class] Only) for Sorceress Orb (ob1), Necromancer Wand (wnd), and Paladin Scepter (scp) by ilvl and renders in formatItemTooltip', () => {
|
||||
const orbBase = dropTables.getBase('ob1')!
|
||||
const wandBase = dropTables.getBase('wnd')!
|
||||
const scepterBase = dropTables.getBase('scp')!
|
||||
|
||||
expect(resolveStaffModsClass(orbBase, dropTables.itemTypes)).toBe('sor')
|
||||
expect(resolveStaffModsClass(wandBase, dropTables.itemTypes)).toBe('nec')
|
||||
expect(resolveStaffModsClass(scepterBase, dropTables.itemTypes)).toBe('pal')
|
||||
|
||||
const checkStaffModsForClass = (
|
||||
base: ItemBase,
|
||||
expectedClassLabel: string,
|
||||
minSkillId: number,
|
||||
maxSkillId: number,
|
||||
) => {
|
||||
// Low ilvl (ilvl = 1 -> baseTier = 1, max tier = 2 -> first 10 skills of class)
|
||||
let lowIlvlModsFound = 0
|
||||
for (let s = 1; s <= 40; s++) {
|
||||
const mods = rollStaffMods(base, 1, dropTables.itemTypes, new D2Rng(s * 31337))
|
||||
for (const m of mods) {
|
||||
lowIlvlModsFound++
|
||||
expect(m.code).toBe('skill')
|
||||
const skillId = Number(m.param)
|
||||
expect(skillId).toBeGreaterThanOrEqual(minSkillId)
|
||||
expect(skillId).toBeLessThanOrEqual(minSkillId + 9) // Tier 1..2 only at ilvl 1
|
||||
expect(m.value).toBeGreaterThanOrEqual(1)
|
||||
expect(m.value).toBeLessThanOrEqual(3)
|
||||
}
|
||||
}
|
||||
expect(lowIlvlModsFound).toBeGreaterThan(0)
|
||||
|
||||
// High ilvl (ilvl = 50 -> baseTier = 5, max tier = 6 -> up to 30th skill of class)
|
||||
let highTierSkillFound = false
|
||||
let renderedTooltipVerified = false
|
||||
for (let s = 1; s <= 60; s++) {
|
||||
const drops = executeDropPipeline({
|
||||
tcName: base.id,
|
||||
nLevel: 50,
|
||||
monsterType: 1,
|
||||
dropTables,
|
||||
difficulty: 'nightmare',
|
||||
playerMf: 50,
|
||||
gamePlayers: 1,
|
||||
partyPlayers: 1,
|
||||
monsterRng: (0x12980000 + s * 2027) >>> 0,
|
||||
})
|
||||
expect(drops.length).toBe(1)
|
||||
const item = drops[0]!
|
||||
const staffMods = (item as any).staffMods as { code: string; param: number; value: number }[] | undefined
|
||||
if (staffMods && staffMods.length > 0) {
|
||||
for (const sm of staffMods) {
|
||||
const skillId = Number(sm.param)
|
||||
expect(skillId).toBeGreaterThanOrEqual(minSkillId)
|
||||
expect(skillId).toBeLessThanOrEqual(maxSkillId)
|
||||
if (skillId >= minSkillId + 15) {
|
||||
highTierSkillFound = true
|
||||
}
|
||||
}
|
||||
const tooltip = formatItemTooltip(item)
|
||||
const texts = tooltip.lines.map(l => l.text)
|
||||
const classRegex = new RegExp(`^\\+[1-9]\\d* to .+ \\(${expectedClassLabel} Only\\)$`)
|
||||
expect(
|
||||
texts.some(t => classRegex.test(t)),
|
||||
`Expected ${base.id} tooltip ${JSON.stringify(texts)} to contain +X to [Skill] (${expectedClassLabel} Only)`,
|
||||
).toBe(true)
|
||||
renderedTooltipVerified = true
|
||||
}
|
||||
}
|
||||
expect(highTierSkillFound).toBe(true)
|
||||
expect(renderedTooltipVerified).toBe(true)
|
||||
}
|
||||
|
||||
checkStaffModsForClass(orbBase, 'Sorceress', 36, 65)
|
||||
checkStaffModsForClass(wandBase, 'Necromancer', 66, 95)
|
||||
checkStaffModsForClass(scepterBase, 'Paladin', 96, 125)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue