From 6d2de240003d0994792478f071fc4fceaf72af6b Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Sat, 19 Sep 2026 10:27:07 +0000 Subject: [PATCH] =?UTF-8?q?feat(drop/affix):=20=E5=B0=86AutoMagic.txt?= =?UTF-8?q?=E4=B8=8E1.13c=20StaffMods=E6=8E=A5=E5=85=A5=E6=8E=89=E8=90=BD?= =?UTF-8?q?=E4=B8=8ETooltip=E7=AE=A1=E7=BA=BF=20(fixes=20#129)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/game/affix-generator.ts | 13 + src/game/armor.ts | 6 + src/game/automagic.ts | 400 +++++++++++++++++++++++++++++++ src/game/drop-pipeline.ts | 143 +++++++++++ src/game/embedded-drop-tables.ts | 2 + src/game/item-tooltip.ts | 68 ++++-- src/game/weapons.ts | 4 + tests/parity-1.13c.test.ts | 185 ++++++++++++++ 8 files changed, 806 insertions(+), 15 deletions(-) diff --git a/src/game/affix-generator.ts b/src/game/affix-generator.ts index f736bf4..e14ba9b 100644 --- a/src/game/affix-generator.ts +++ b/src/game/affix-generator.ts @@ -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' + diff --git a/src/game/armor.ts b/src/game/armor.ts index 5a951e5..220e636 100644 --- a/src/game/armor.ts +++ b/src/game/armor.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) diff --git a/src/game/automagic.ts b/src/game/automagic.ts index 5da198e..e059a69 100644 --- a/src/game/automagic.ts +++ b/src/game/automagic.ts @@ -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> = { + 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() + 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> = { + 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> = { + 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() + 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() + 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 +} + diff --git a/src/game/drop-pipeline.ts b/src/game/drop-pipeline.ts index 9452361..34c84bc 100644 --- a/src/game/drop-pipeline.ts +++ b/src/game/drop-pipeline.ts @@ -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 readonly superUniques: Map @@ -177,6 +204,13 @@ export async function loadDropTables(archives: MountedArchives): Promise() for (const [key, node] of rawAutoTcTable) { @@ -215,6 +249,7 @@ export async function loadDropTables(archives: MountedArchives): Promise 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, })) } diff --git a/src/game/embedded-drop-tables.ts b/src/game/embedded-drop-tables.ts index a635d11..b4b1c4f 100644 --- a/src/game/embedded-drop-tables.ts +++ b/src/game/embedded-drop-tables.ts @@ -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(), diff --git a/src/game/item-tooltip.ts b/src/game/item-tooltip.ts index 5a45be8..fe7c548 100644 --- a/src/game/item-tooltip.ts +++ b/src/game/item-tooltip.ts @@ -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 diff --git a/src/game/weapons.ts b/src/game/weapons.ts index 483cf6e..7734ce7 100644 --- a/src/game/weapons.ts +++ b/src/game/weapons.ts @@ -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) diff --git a/tests/parity-1.13c.test.ts b/tests/parity-1.13c.test.ts index 2181d8f..2565b2d 100644 --- a/tests/parity-1.13c.test.ts +++ b/tests/parity-1.13c.test.ts @@ -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() + 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() + 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) + }) +}) +