feat(drop/affix): 将AutoMagic.txt与1.13c StaffMods接入掉落与Tooltip管线 (fixes #129)

This commit is contained in:
troytt 2026-09-19 10:27:07 +00:00
parent 23a4a6867a
commit 6d2de24000
8 changed files with 806 additions and 15 deletions

View File

@ -556,3 +556,16 @@ export function rollRareAffixes(
seed: initialSeed, 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'

View File

@ -81,6 +81,8 @@ export interface ArmorBase extends ItemBase {
readonly value: number readonly value: number
/** Inherent weapon attack damage (0 for armors). */ /** Inherent weapon attack damage (0 for armors). */
readonly damage: number 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 ultracode = strAt(row, 'ultracode')
const invWidth = numAt(row, 'invwidth', 0) const invWidth = numAt(row, 'invwidth', 0)
const invHeight = numAt(row, 'invheight', 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[] = [] const tags: string[] = []
if (type) tags.push(type) if (type) tags.push(type)
@ -243,6 +248,7 @@ export function parseArmorTable(table: D2Table): ArmorTable {
maxStack: 1, maxStack: 1,
value: cost, value: cost,
damage: 0, damage: 0,
...(autoPrefix !== undefined ? { autoPrefix } : {}),
} }
all.push(armor) all.push(armor)

View File

@ -13,6 +13,16 @@
import { parseTable } from './acts.ts' import { parseTable } from './acts.ts'
import type { D2Table } from './acts.ts' import type { D2Table } from './acts.ts'
import type { MountedArchives } from '../mpq/mount.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. */ /** One modifier on an AutoMagic entry. */
export interface AutoMagicMod { export interface AutoMagicMod {
@ -443,3 +453,393 @@ export function getEligibleAutoMagic(
return filterEligibleAutoMagic(entries, typeCode, alvl, isA, options) 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
}

View File

@ -118,6 +118,32 @@ import {
type GeneratedAffixes, type GeneratedAffixes,
type GeneratedRareAffixes, type GeneratedRareAffixes,
} from './affix-generator.ts' } 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. * 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 sets: { readonly items: SetItemTable; readonly sets: SetTable }
readonly magicAffixes: { readonly prefixes: AffixTable; readonly suffixes: AffixTable } readonly magicAffixes: { readonly prefixes: AffixTable; readonly suffixes: AffixTable }
readonly rareNames: { readonly prefixes: RareNameTable; readonly suffixes: RareNameTable } readonly rareNames: { readonly prefixes: RareNameTable; readonly suffixes: RareNameTable }
readonly autoMagic?: AutoMagicTable | undefined
readonly itemTypes: ItemTypeTable readonly itemTypes: ItemTypeTable
readonly monsterKinds: Map<string, MonsterKind> readonly monsterKinds: Map<string, MonsterKind>
readonly superUniques: Map<string, SuperUnique> readonly superUniques: Map<string, SuperUnique>
@ -177,6 +204,13 @@ export async function loadDropTables(archives: MountedArchives): Promise<DropTab
archives.read('data\\global\\excel\\monstats.txt'), 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 rawAutoTcTable = generateAutoTreasureClasses(itemTypes, armor, weapons)
const autoTcTable = new Map<string, TreasureClassNode>() const autoTcTable = new Map<string, TreasureClassNode>()
for (const [key, node] of rawAutoTcTable) { for (const [key, node] of rawAutoTcTable) {
@ -215,6 +249,7 @@ export async function loadDropTables(archives: MountedArchives): Promise<DropTab
sets, sets,
magicAffixes, magicAffixes,
rareNames, rareNames,
autoMagic,
itemTypes, itemTypes,
monsterKinds, monsterKinds,
superUniques, superUniques,
@ -402,6 +437,8 @@ export function createDroppedItem(
readonly setItem?: SetItem | undefined readonly setItem?: SetItem | undefined
readonly magicAffixes?: GeneratedAffixes | undefined readonly magicAffixes?: GeneratedAffixes | undefined
readonly rareAffixes?: GeneratedRareAffixes | undefined readonly rareAffixes?: GeneratedRareAffixes | undefined
readonly autoMagicAffix?: RolledAutoMagic | null | undefined
readonly staffMods?: readonly RolledItemProp[] | undefined
readonly ilvl: number readonly ilvl: number
readonly dwInitSeed: number readonly dwInitSeed: number
readonly durabilityMultiplier?: number | undefined readonly durabilityMultiplier?: number | undefined
@ -527,6 +564,48 @@ export function createDroppedItem(
name = `Cracked ${base.name}` 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 rawDurability = (base as any).durability ?? 0
const mult = options.durabilityMultiplier ?? 1 const mult = options.durabilityMultiplier ?? 1
let durability: number | undefined = undefined let durability: number | undefined = undefined
@ -562,6 +641,8 @@ export function createDroppedItem(
...(options.setItem ? { setId: options.setItem.id, setItemDef: options.setItem } : {}), ...(options.setItem ? { setId: options.setItem.id, setItemDef: options.setItem } : {}),
...(options.rareAffixes ? { rolledRareAffixes: options.rareAffixes } : {}), ...(options.rareAffixes ? { rolledRareAffixes: options.rareAffixes } : {}),
...(options.magicAffixes ? { rolledMagicAffixes: options.magicAffixes } : {}), ...(options.magicAffixes ? { rolledMagicAffixes: options.magicAffixes } : {}),
...(effectiveAutoMagic ? { rolledAutoMagic: effectiveAutoMagic } : {}),
...(options.staffMods && options.staffMods.length > 0 ? { staffMods: options.staffMods } : {}),
...(rolledProps.length > 0 ? { rolledProps } : {}), ...(rolledProps.length > 0 ? { rolledProps } : {}),
} as Item } as Item
} }
@ -802,8 +883,26 @@ export function executeDropPipeline(
itemRng, itemRng,
) )
if (rareAffixes && rareAffixes.affixes && rareAffixes.affixes.length > 0) { 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', { drops.push(createDroppedItem(effectiveBase, 'rare', {
rareAffixes, rareAffixes,
autoMagicAffix,
staffMods,
ilvl: nLevel, ilvl: nLevel,
dwInitSeed, dwInitSeed,
durabilityMultiplier, durabilityMultiplier,
@ -825,8 +924,26 @@ export function executeDropPipeline(
dropTables.isA, dropTables.isA,
itemRng, 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', { drops.push(createDroppedItem(effectiveBase, 'magic', {
magicAffixes, magicAffixes,
autoMagicAffix,
staffMods,
ilvl: nLevel, ilvl: nLevel,
dwInitSeed, dwInitSeed,
durabilityMultiplier, durabilityMultiplier,
@ -835,26 +952,52 @@ export function executeDropPipeline(
continue 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') { if (quality === 'superior') {
drops.push(createDroppedItem(effectiveBase, 'superior', { drops.push(createDroppedItem(effectiveBase, 'superior', {
autoMagicAffix,
staffMods,
ilvl: nLevel, ilvl: nLevel,
dwInitSeed, dwInitSeed,
itemRng,
})) }))
continue continue
} }
if (quality === 'low') { if (quality === 'low') {
drops.push(createDroppedItem(effectiveBase, 'low', { drops.push(createDroppedItem(effectiveBase, 'low', {
autoMagicAffix,
staffMods,
ilvl: nLevel, ilvl: nLevel,
dwInitSeed, dwInitSeed,
itemRng,
})) }))
continue continue
} }
// Default: normal quality // Default: normal quality
drops.push(createDroppedItem(effectiveBase, 'normal', { drops.push(createDroppedItem(effectiveBase, 'normal', {
autoMagicAffix,
staffMods,
ilvl: nLevel, ilvl: nLevel,
dwInitSeed, dwInitSeed,
itemRng,
})) }))
} }

View File

@ -42,6 +42,7 @@ import {
} from './item-upgrade.ts' } from './item-upgrade.ts'
import type { DropTables } from './drop-pipeline.ts' import type { DropTables } from './drop-pipeline.ts'
import type { ItemBase } from './items.ts' import type { ItemBase } from './items.ts'
import { CANONICAL_AUTOMAGIC_TABLE } from './automagic.ts'
let _cachedDropTables: DropTables | null = null let _cachedDropTables: DropTables | null = null
@ -102,6 +103,7 @@ export function getEmbeddedDropTables(): DropTables {
sets, sets,
magicAffixes, magicAffixes,
rareNames, rareNames,
autoMagic: CANONICAL_AUTOMAGIC_TABLE,
itemTypes, itemTypes,
monsterKinds: new Map(), monsterKinds: new Map(),
superUniques: new Map(), superUniques: new Map(),

View File

@ -1554,7 +1554,7 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip {
} }
// Check if this is Gold // 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) const amount = (item.stats.amount ?? item.value ?? 100)
title = `${amount.toLocaleString()} Gold` title = `${amount.toLocaleString()} Gold`
qualityColor = '#ffd700' qualityColor = '#ffd700'
@ -1572,6 +1572,11 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip {
const rawProps: RolledItemProp[] = [] const rawProps: RolledItemProp[] = []
let affixReqLevel = 0 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') { if (quality === 'unique') {
qualityColor = '#c8a15a' qualityColor = '#c8a15a'
const uniqDef = item.uniqueItemDef as UniqueItem | undefined const uniqDef = item.uniqueItemDef as UniqueItem | undefined
@ -1626,10 +1631,27 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip {
qualityColor = '#ffff60' qualityColor = '#ffff60'
subTitle = base.name subTitle = base.name
const rareDef = item.rolledRareAffixes as GeneratedRareAffixes | undefined const rareDef = item.rolledRareAffixes as GeneratedRareAffixes | undefined
if (rareDef && rareDef.affixes) { if (rareDef) {
title = rareDef.name if (rareDef.name) title = rareDef.name
if (rareDef.affixes) {
for (const aff of rareDef.affixes) { for (const aff of rareDef.affixes) {
if (aff.levelreq > 0) affixReqLevel = Math.max(affixReqLevel, aff.levelreq) 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) {
for (const mod of aff.mods) { for (const mod of aff.mods) {
const keepRange = NON_RANDOM_RANGE_CODES.has(mod.code.toLowerCase()) || (mod.min === 0 && mod.max === 0) const keepRange = NON_RANDOM_RANGE_CODES.has(mod.code.toLowerCase()) || (mod.min === 0 && mod.max === 0)
rawProps.push({ 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') { } else if (quality === 'magic') {
qualityColor = '#4880ff' qualityColor = '#4880ff'
subTitle = base.name
const magicDef = item.rolledMagicAffixes as GeneratedAffixes | undefined const magicDef = item.rolledMagicAffixes as GeneratedAffixes | undefined
if (magicDef) { if (magicDef) {
title = magicDef.name if (magicDef.name) title = magicDef.name
subTitle = base.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) {
if (magicDef.prefix.levelreq > 0) affixReqLevel = Math.max(affixReqLevel, magicDef.prefix.levelreq)
for (const m of magicDef.prefix.mods) { for (const m of magicDef.prefix.mods) {
const keepRange = NON_RANDOM_RANGE_CODES.has(m.code.toLowerCase()) || (m.min === 0 && m.max === 0) const keepRange = NON_RANDOM_RANGE_CODES.has(m.code.toLowerCase()) || (m.min === 0 && m.max === 0)
rawProps.push({ rawProps.push({
@ -1664,7 +1702,6 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip {
} }
} }
if (magicDef.suffix) { if (magicDef.suffix) {
if (magicDef.suffix.levelreq > 0) affixReqLevel = Math.max(affixReqLevel, magicDef.suffix.levelreq)
for (const m of magicDef.suffix.mods) { for (const m of magicDef.suffix.mods) {
const keepRange = NON_RANDOM_RANGE_CODES.has(m.code.toLowerCase()) || (m.min === 0 && m.max === 0) const keepRange = NON_RANDOM_RANGE_CODES.has(m.code.toLowerCase()) || (m.min === 0 && m.max === 0)
rawProps.push({ 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') { } else if (quality === 'superior') {
qualityColor = '#ffffff' qualityColor = '#ffffff'
@ -1690,9 +1722,15 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip {
} else { } else {
rawProps.push({ code: 'ac%', min: 15, max: 15, value: 15 }) 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') { } else if (quality === 'low') {
qualityColor = '#a0a0a0' qualityColor = '#a0a0a0'
title = `Cracked ${base.name}` title = `Cracked ${base.name}`
if (item.rolledProps && item.rolledProps.length > 0) {
rawProps.push(...item.rolledProps)
}
} else { } else {
qualityColor = '#e0d5c1' qualityColor = '#e0d5c1'
title = base.name title = base.name

View File

@ -110,6 +110,8 @@ export interface WeaponBase extends ItemBase {
readonly defense: number readonly defense: number
/** Maximum stack capacity for ItemBase compliance (maxstack ?? 1). */ /** Maximum stack capacity for ItemBase compliance (maxstack ?? 1). */
readonly maxStack: number 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 gemsockets = parseNumber(getCell(row, 'gemsockets'), 0)
const invWidth = parseNumber(getCell(row, 'invwidth'), 0) const invWidth = parseNumber(getCell(row, 'invwidth'), 0)
const invHeight = parseNumber(getCell(row, 'invheight'), 0) const invHeight = parseNumber(getCell(row, 'invheight'), 0)
const autoPrefix = parseOptionalNumber(getCell(row, 'auto prefix'))
const tags = type2 ? [type, type2] : [type] const tags = type2 ? [type, type2] : [type]
const damage = maxdam const damage = maxdam
@ -322,6 +325,7 @@ export function parseWeaponsTable(input: D2Table | Uint8Array | string): WeaponT
value: cost, value: cost,
defense: 0, defense: 0,
maxStack: maxstack ?? 1, maxStack: maxstack ?? 1,
...(autoPrefix !== undefined && autoPrefix > 0 ? { autoPrefix } : {}),
} }
all.push(weapon) all.push(weapon)

View File

@ -5,6 +5,10 @@ import {
executeDropPipeline, executeDropPipeline,
resolveItemTypeFlags, resolveItemTypeFlags,
createDroppedItem, createDroppedItem,
rollAutoMagic,
rollStaffMods,
resolveAutoPrefixGroup,
resolveStaffModsClass,
type DropTables, type DropTables,
} from '../src/game/drop-pipeline.ts' } from '../src/game/drop-pipeline.ts'
import { 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)
})
})