fix(tooltip/affix): 对齐1.13c Properties/ItemStatCost/.tbl全量182项词条模板与descFunc=11规则 (fixes #124)
- 溯源并修正 rep-dur (ItemStatCost ID 252, descFunc=11, ModStre9t/ModStre9u): 按 seconds = Math.floor(100 / par) 计算恢复周期(如 par=5 为 20s,par=20 为 5s,par=3 为 33s) - 内嵌 1.13c Skills.txt + SkillDesc.txt + ENG .tbl 全量 222 项技能名称与职业限制映射(支持 Wearwolf->Werewolf, Summon Fenris->Summon Dire Wolf, Plague Poppy->Poison Creeper, 197->Firestorm 等) - 内嵌 1.13c CharStats.txt StrSkillTab1..3 + StrClassOnly 全部 21 个技能系 (par 0..20) - 覆盖 UniqueItems / SetItems / Sets / MagicAffixes / AutoMagic 全部 182 个唯一属性代码,0 处回退泄露 - 新增 tests/item-tooltip-124.test.ts 覆盖全表 182 项属性代码断言,全量 1619 项单测通过 TAG=agy CONV=aade657e-0c8e-42ff-a5f2-87b2afe80e02
This commit is contained in:
parent
8ebedb2a35
commit
2cf5acb459
|
|
@ -198,13 +198,20 @@ function pickWeightedAffix(candidates: readonly MagicAffix[], rng: D2Rng): Magic
|
||||||
* Rolls concrete values for all modifiers of a chosen affix using `rng.randRange(min, max)`.
|
* Rolls concrete values for all modifiers of a chosen affix using `rng.randRange(min, max)`.
|
||||||
*/
|
*/
|
||||||
function rollAffixMods(affix: MagicAffix, rng: D2Rng): RolledMod[] {
|
function rollAffixMods(affix: MagicAffix, rng: D2Rng): RolledMod[] {
|
||||||
return affix.mods.map(mod => ({
|
return affix.mods.map(mod => {
|
||||||
code: mod.code,
|
const rolled = rng.randRange(mod.min, mod.max)
|
||||||
...(mod.param !== undefined ? { param: mod.param } : {}),
|
const value =
|
||||||
min: mod.min,
|
mod.min === 0 && mod.max === 0 && mod.param !== undefined && mod.param !== '' && !Number.isNaN(Number(mod.param))
|
||||||
max: mod.max,
|
? Number(mod.param)
|
||||||
value: rng.randRange(mod.min, mod.max),
|
: rolled
|
||||||
}))
|
return {
|
||||||
|
code: mod.code,
|
||||||
|
...(mod.param !== undefined ? { param: mod.param } : {}),
|
||||||
|
min: mod.min,
|
||||||
|
max: mod.max,
|
||||||
|
value,
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -314,19 +314,40 @@ export function createDroppedItem(
|
||||||
|
|
||||||
const propRng = options.itemRng ?? new D2Rng(options.dwInitSeed)
|
const propRng = options.itemRng ?? new D2Rng(options.dwInitSeed)
|
||||||
|
|
||||||
|
const NON_RANDOM_RANGE_CODES = new Set([
|
||||||
|
'dmg-fire',
|
||||||
|
'dmg-cold',
|
||||||
|
'dmg-ltng',
|
||||||
|
'dmg-mag',
|
||||||
|
'dmg-pois',
|
||||||
|
'dmg-norm',
|
||||||
|
'dmg-elem',
|
||||||
|
'hit-skill',
|
||||||
|
'att-skill',
|
||||||
|
'gethit-skill',
|
||||||
|
'kill-skill',
|
||||||
|
'death-skill',
|
||||||
|
'levelup-skill',
|
||||||
|
'charged',
|
||||||
|
'skill-rand',
|
||||||
|
])
|
||||||
|
|
||||||
|
const resolveItemPropValue = (prop: { code: string; par?: string | number; min: number; max: number }): number => {
|
||||||
|
const codeLower = prop.code.toLowerCase()
|
||||||
|
if (NON_RANDOM_RANGE_CODES.has(codeLower)) {
|
||||||
|
return prop.min
|
||||||
|
}
|
||||||
|
if (prop.min === prop.max) {
|
||||||
|
return prop.min
|
||||||
|
}
|
||||||
|
return propRng.randRange(prop.min, prop.max)
|
||||||
|
}
|
||||||
|
|
||||||
if (qualityTier === 'unique' && options.uniqueItem) {
|
if (qualityTier === 'unique' && options.uniqueItem) {
|
||||||
name = options.uniqueItem.index
|
name = options.uniqueItem.index
|
||||||
for (const prop of options.uniqueItem.props) {
|
for (const prop of options.uniqueItem.props) {
|
||||||
if (prop.code) {
|
if (prop.code) {
|
||||||
let val: number
|
const val = resolveItemPropValue(prop)
|
||||||
const isDmgRange = ['dmg-fire', 'dmg-cold', 'dmg-ltng', 'dmg-mag', 'dmg-pois'].includes(prop.code.toLowerCase())
|
|
||||||
if (isDmgRange) {
|
|
||||||
val = prop.min
|
|
||||||
} else if (prop.min === prop.max) {
|
|
||||||
val = prop.min
|
|
||||||
} else {
|
|
||||||
val = propRng.randRange(prop.min, prop.max)
|
|
||||||
}
|
|
||||||
rolledProps.push({
|
rolledProps.push({
|
||||||
code: prop.code,
|
code: prop.code,
|
||||||
param: prop.par,
|
param: prop.par,
|
||||||
|
|
@ -341,15 +362,7 @@ export function createDroppedItem(
|
||||||
name = options.setItem.index
|
name = options.setItem.index
|
||||||
for (const prop of options.setItem.props) {
|
for (const prop of options.setItem.props) {
|
||||||
if (prop.code) {
|
if (prop.code) {
|
||||||
let val: number
|
const val = resolveItemPropValue(prop)
|
||||||
const isDmgRange = ['dmg-fire', 'dmg-cold', 'dmg-ltng', 'dmg-mag', 'dmg-pois'].includes(prop.code.toLowerCase())
|
|
||||||
if (isDmgRange) {
|
|
||||||
val = prop.min
|
|
||||||
} else if (prop.min === prop.max) {
|
|
||||||
val = prop.min
|
|
||||||
} else {
|
|
||||||
val = propRng.randRange(prop.min, prop.max)
|
|
||||||
}
|
|
||||||
rolledProps.push({
|
rolledProps.push({
|
||||||
code: prop.code,
|
code: prop.code,
|
||||||
param: prop.par,
|
param: prop.par,
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,312 @@ const RUNE_INFO: Record<string, { num: number; nameEn: string; lvl: number; weap
|
||||||
* - formatPropertyCode(code, min, max, par)
|
* - formatPropertyCode(code, min, max, par)
|
||||||
* - formatPropertyCode(code, value)
|
* - formatPropertyCode(code, value)
|
||||||
*/
|
*/
|
||||||
|
const CANONICAL_SKILLS: readonly [number, string, string, string][] = [
|
||||||
|
[0, "Attack", "Attack", ""],
|
||||||
|
[1, "Kick", "Kick", ""],
|
||||||
|
[2, "Throw", "Throw", ""],
|
||||||
|
[3, "Unsummon", "Unsummon", ""],
|
||||||
|
[4, "Left Hand Throw", "Left Hand Throw", ""],
|
||||||
|
[5, "Left Hand Swing", "Left Hand Swing", ""],
|
||||||
|
[6, "Magic Arrow", "Magic Arrow", "ama"],
|
||||||
|
[7, "Fire Arrow", "Fire Arrow", "ama"],
|
||||||
|
[8, "Inner Sight", "Inner Sight", "ama"],
|
||||||
|
[9, "Critical Strike", "Critical Strike", "ama"],
|
||||||
|
[10, "Jab", "Jab", "ama"],
|
||||||
|
[11, "Cold Arrow", "Cold Arrow", "ama"],
|
||||||
|
[12, "Multiple Shot", "Multiple Shot", "ama"],
|
||||||
|
[13, "Dodge", "Dodge", "ama"],
|
||||||
|
[14, "Power Strike", "Power Strike", "ama"],
|
||||||
|
[15, "Poison Javelin", "Poison Javelin", "ama"],
|
||||||
|
[16, "Exploding Arrow", "Exploding Arrow", "ama"],
|
||||||
|
[17, "Slow Missiles", "Slow Missiles", "ama"],
|
||||||
|
[18, "Avoid", "Avoid", "ama"],
|
||||||
|
[19, "Impale", "Impale", "ama"],
|
||||||
|
[20, "Lightning Bolt", "Lightning Bolt", "ama"],
|
||||||
|
[21, "Ice Arrow", "Ice Arrow", "ama"],
|
||||||
|
[22, "Guided Arrow", "Guided Arrow", "ama"],
|
||||||
|
[23, "Penetrate", "Penetrate", "ama"],
|
||||||
|
[24, "Charged Strike", "Charged Strike", "ama"],
|
||||||
|
[25, "Plague Javelin", "Plague Javelin", "ama"],
|
||||||
|
[26, "Strafe", "Strafe", "ama"],
|
||||||
|
[27, "Immolation Arrow", "Immolation Arrow", "ama"],
|
||||||
|
[28, "Dopplezon", "Decoy", "ama"],
|
||||||
|
[29, "Evade", "Evade", "ama"],
|
||||||
|
[30, "Fend", "Fend", "ama"],
|
||||||
|
[31, "Freezing Arrow", "Freezing Arrow", "ama"],
|
||||||
|
[32, "Valkyrie", "Valkyrie", "ama"],
|
||||||
|
[33, "Pierce", "Pierce", "ama"],
|
||||||
|
[34, "Lightning Strike", "Lightning Strike", "ama"],
|
||||||
|
[35, "Lightning Fury", "Lightning Fury", "ama"],
|
||||||
|
[36, "Fire Bolt", "Fire Bolt", "sor"],
|
||||||
|
[37, "Warmth", "Warmth", "sor"],
|
||||||
|
[38, "Charged Bolt", "Charged Bolt", "sor"],
|
||||||
|
[39, "Ice Bolt", "Ice Bolt", "sor"],
|
||||||
|
[40, "Frozen Armor", "Frozen Armor", "sor"],
|
||||||
|
[41, "Inferno", "Inferno", "sor"],
|
||||||
|
[42, "Static Field", "Static Field", "sor"],
|
||||||
|
[43, "Telekinesis", "Telekinesis", "sor"],
|
||||||
|
[44, "Frost Nova", "Frost Nova", "sor"],
|
||||||
|
[45, "Ice Blast", "Ice Blast", "sor"],
|
||||||
|
[46, "Blaze", "Blaze", "sor"],
|
||||||
|
[47, "Fire Ball", "Fire Ball", "sor"],
|
||||||
|
[48, "Nova", "Nova", "sor"],
|
||||||
|
[49, "Lightning", "Lightning", "sor"],
|
||||||
|
[50, "Shiver Armor", "Shiver Armor", "sor"],
|
||||||
|
[51, "Fire Wall", "Fire Wall", "sor"],
|
||||||
|
[52, "Enchant", "Enchant", "sor"],
|
||||||
|
[53, "Chain Lightning", "Chain Lightning", "sor"],
|
||||||
|
[54, "Teleport", "Teleport", "sor"],
|
||||||
|
[55, "Glacial Spike", "Glacial Spike", "sor"],
|
||||||
|
[56, "Meteor", "Meteor", "sor"],
|
||||||
|
[57, "Thunder Storm", "Thunder Storm", "sor"],
|
||||||
|
[58, "Energy Shield", "Energy Shield", "sor"],
|
||||||
|
[59, "Blizzard", "Blizzard", "sor"],
|
||||||
|
[60, "Chilling Armor", "Chilling Armor", "sor"],
|
||||||
|
[61, "Fire Mastery", "Fire Mastery", "sor"],
|
||||||
|
[62, "Hydra", "Hydra", "sor"],
|
||||||
|
[63, "Lightning Mastery", "Lightning Mastery", "sor"],
|
||||||
|
[64, "Frozen Orb", "Frozen Orb", "sor"],
|
||||||
|
[65, "Cold Mastery", "Cold Mastery", "sor"],
|
||||||
|
[66, "Amplify Damage", "Amplify Damage", "nec"],
|
||||||
|
[67, "Teeth", "Teeth", "nec"],
|
||||||
|
[68, "Bone Armor", "Bone Armor", "nec"],
|
||||||
|
[69, "Skeleton Mastery", "Skeleton Mastery", "nec"],
|
||||||
|
[70, "Raise Skeleton", "Raise Skeleton", "nec"],
|
||||||
|
[71, "Dim Vision", "Dim Vision", "nec"],
|
||||||
|
[72, "Weaken", "Weaken", "nec"],
|
||||||
|
[73, "Poison Dagger", "Poison Dagger", "nec"],
|
||||||
|
[74, "Corpse Explosion", "Corpse Explosion", "nec"],
|
||||||
|
[75, "Clay Golem", "Clay Golem", "nec"],
|
||||||
|
[76, "Iron Maiden", "Iron Maiden", "nec"],
|
||||||
|
[77, "Terror", "Terror", "nec"],
|
||||||
|
[78, "Bone Wall", "Bone Wall", "nec"],
|
||||||
|
[79, "Golem Mastery", "Golem Mastery", "nec"],
|
||||||
|
[80, "Raise Skeletal Mage", "Raise Skeletal Mage", "nec"],
|
||||||
|
[81, "Confuse", "Confuse", "nec"],
|
||||||
|
[82, "Life Tap", "Life Tap", "nec"],
|
||||||
|
[83, "Poison Explosion", "Poison Explosion", "nec"],
|
||||||
|
[84, "Bone Spear", "Bone Spear", "nec"],
|
||||||
|
[85, "BloodGolem", "Blood Golem", "nec"],
|
||||||
|
[86, "Attract", "Attract", "nec"],
|
||||||
|
[87, "Decrepify", "Decrepify", "nec"],
|
||||||
|
[88, "Bone Prison", "Bone Prison", "nec"],
|
||||||
|
[89, "Summon Resist", "Summon Resist", "nec"],
|
||||||
|
[90, "IronGolem", "Iron Golem", "nec"],
|
||||||
|
[91, "Lower Resist", "Lower Resist", "nec"],
|
||||||
|
[92, "Poison Nova", "Poison Nova", "nec"],
|
||||||
|
[93, "Bone Spirit", "Bone Spirit", "nec"],
|
||||||
|
[94, "FireGolem", "Fire Golem", "nec"],
|
||||||
|
[95, "Revive", "Revive", "nec"],
|
||||||
|
[96, "Sacrifice", "Sacrifice", "pal"],
|
||||||
|
[97, "Smite", "Smite", "pal"],
|
||||||
|
[98, "Might", "Might", "pal"],
|
||||||
|
[99, "Prayer", "Prayer", "pal"],
|
||||||
|
[100, "Resist Fire", "Resist Fire", "pal"],
|
||||||
|
[101, "Holy Bolt", "Holy Bolt", "pal"],
|
||||||
|
[102, "Holy Fire", "Holy Fire", "pal"],
|
||||||
|
[103, "Thorns", "Thorns", "pal"],
|
||||||
|
[104, "Defiance", "Defiance", "pal"],
|
||||||
|
[105, "Resist Cold", "Resist Cold", "pal"],
|
||||||
|
[106, "Zeal", "Zeal", "pal"],
|
||||||
|
[107, "Charge", "Charge", "pal"],
|
||||||
|
[108, "Blessed Aim", "Blessed Aim", "pal"],
|
||||||
|
[109, "Cleansing", "Cleansing", "pal"],
|
||||||
|
[110, "Resist Lightning", "Resist Lightning", "pal"],
|
||||||
|
[111, "Vengeance", "Vengeance", "pal"],
|
||||||
|
[112, "Blessed Hammer", "Blessed Hammer", "pal"],
|
||||||
|
[113, "Concentration", "Concentration", "pal"],
|
||||||
|
[114, "Holy Freeze", "Holy Freeze", "pal"],
|
||||||
|
[115, "Vigor", "Vigor", "pal"],
|
||||||
|
[116, "Conversion", "Conversion", "pal"],
|
||||||
|
[117, "Holy Shield", "Holy Shield", "pal"],
|
||||||
|
[118, "Holy Shock", "Holy Shock", "pal"],
|
||||||
|
[119, "Sanctuary", "Sanctuary", "pal"],
|
||||||
|
[120, "Meditation", "Meditation", "pal"],
|
||||||
|
[121, "Fist of the Heavens", "Fist of the Heavens", "pal"],
|
||||||
|
[122, "Fanaticism", "Fanaticism", "pal"],
|
||||||
|
[123, "Conviction", "Conviction", "pal"],
|
||||||
|
[124, "Redemption", "Redemption", "pal"],
|
||||||
|
[125, "Salvation", "Salvation", "pal"],
|
||||||
|
[126, "Bash", "Bash", "bar"],
|
||||||
|
[127, "Sword Mastery", "Sword Mastery", "bar"],
|
||||||
|
[128, "Axe Mastery", "Axe Mastery", "bar"],
|
||||||
|
[129, "Mace Mastery", "Mace Mastery", "bar"],
|
||||||
|
[130, "Howl", "Howl", "bar"],
|
||||||
|
[131, "Find Potion", "Find Potion", "bar"],
|
||||||
|
[132, "Leap", "Leap", "bar"],
|
||||||
|
[133, "Double Swing", "Double Swing", "bar"],
|
||||||
|
[134, "Pole Arm Mastery", "Pole Arm Mastery", "bar"],
|
||||||
|
[135, "Throwing Mastery", "Throwing Mastery", "bar"],
|
||||||
|
[136, "Spear Mastery", "Spear Mastery", "bar"],
|
||||||
|
[137, "Taunt", "Taunt", "bar"],
|
||||||
|
[138, "Shout", "Shout", "bar"],
|
||||||
|
[139, "Stun", "Stun", "bar"],
|
||||||
|
[140, "Double Throw", "Double Throw", "bar"],
|
||||||
|
[141, "Increased Stamina", "Increased Stamina", "bar"],
|
||||||
|
[142, "Find Item", "Find Item", "bar"],
|
||||||
|
[143, "Leap Attack", "Leap Attack", "bar"],
|
||||||
|
[144, "Concentrate", "Concentrate", "bar"],
|
||||||
|
[145, "Iron Skin", "Iron Skin", "bar"],
|
||||||
|
[146, "Battle Cry", "Battle Cry", "bar"],
|
||||||
|
[147, "Frenzy", "Frenzy", "bar"],
|
||||||
|
[148, "Increased Speed", "Increased Speed", "bar"],
|
||||||
|
[149, "Battle Orders", "Battle Orders", "bar"],
|
||||||
|
[150, "Grim Ward", "Grim Ward", "bar"],
|
||||||
|
[151, "Whirlwind", "Whirlwind", "bar"],
|
||||||
|
[152, "Berserk", "Berserk", "bar"],
|
||||||
|
[153, "Natural Resistance", "Natural Resistance", "bar"],
|
||||||
|
[154, "War Cry", "War Cry", "bar"],
|
||||||
|
[155, "Battle Command", "Battle Command", "bar"],
|
||||||
|
[197, "DiabWall", "Firestorm", ""],
|
||||||
|
[217, "Scroll of Identify", "Scroll of Identify", ""],
|
||||||
|
[218, "Book of Identify", "Tome of Identify", ""],
|
||||||
|
[219, "Scroll of Townportal", "Scroll of Townportal", ""],
|
||||||
|
[220, "Book of Townportal", "Tome of Townportal", ""],
|
||||||
|
[221, "Raven", "Raven", "dru"],
|
||||||
|
[222, "Plague Poppy", "Poison Creeper", "dru"],
|
||||||
|
[223, "Wearwolf", "Werewolf", "dru"],
|
||||||
|
[224, "Shape Shifting", "Lycanthropy", "dru"],
|
||||||
|
[225, "Firestorm", "Firestorm", "dru"],
|
||||||
|
[226, "Oak Sage", "Oak Sage", "dru"],
|
||||||
|
[227, "Summon Spirit Wolf", "Summon Spirit Wolf", "dru"],
|
||||||
|
[228, "Wearbear", "Werebear", "dru"],
|
||||||
|
[229, "Molten Boulder", "Molten Boulder", "dru"],
|
||||||
|
[230, "Arctic Blast", "Arctic Blast", "dru"],
|
||||||
|
[231, "Cycle of Life", "Carrion Vine", "dru"],
|
||||||
|
[232, "Feral Rage", "Feral Rage", "dru"],
|
||||||
|
[233, "Maul", "Maul", "dru"],
|
||||||
|
[234, "Eruption", "Fissure", "dru"],
|
||||||
|
[235, "Cyclone Armor", "Cyclone Armor", "dru"],
|
||||||
|
[236, "Heart of Wolverine", "Heart of Wolverine", "dru"],
|
||||||
|
[237, "Summon Fenris", "Summon Dire Wolf", "dru"],
|
||||||
|
[238, "Rabies", "Rabies", "dru"],
|
||||||
|
[239, "Fire Claws", "Fire Claws", "dru"],
|
||||||
|
[240, "Twister", "Twister", "dru"],
|
||||||
|
[241, "Vines", "Solar Creeper", "dru"],
|
||||||
|
[242, "Hunger", "Hunger", "dru"],
|
||||||
|
[243, "Shock Wave", "Shock Wave", "dru"],
|
||||||
|
[244, "Volcano", "Volcano", "dru"],
|
||||||
|
[245, "Tornado", "Tornado", "dru"],
|
||||||
|
[246, "Spirit of Barbs", "Spirit of Barbs", "dru"],
|
||||||
|
[247, "Summon Grizzly", "Summon Grizzly", "dru"],
|
||||||
|
[248, "Fury", "Fury", "dru"],
|
||||||
|
[249, "Armageddon", "Armageddon", "dru"],
|
||||||
|
[250, "Hurricane", "Hurricane", "dru"],
|
||||||
|
[251, "Fire Trauma", "Fire Blast", "ass"],
|
||||||
|
[252, "Claw Mastery", "Claw Mastery", "ass"],
|
||||||
|
[253, "Psychic Hammer", "Psychic Hammer", "ass"],
|
||||||
|
[254, "Tiger Strike", "Tiger Strike", "ass"],
|
||||||
|
[255, "Dragon Talon", "Dragon Talon", "ass"],
|
||||||
|
[256, "Shock Field", "Shock Web", "ass"],
|
||||||
|
[257, "Blade Sentinel", "Blade Sentinel", "ass"],
|
||||||
|
[258, "Quickness", "Burst of Speed", "ass"],
|
||||||
|
[259, "Fists of Fire", "Fists of Fire", "ass"],
|
||||||
|
[260, "Dragon Claw", "Dragon Claw", "ass"],
|
||||||
|
[261, "Charged Bolt Sentry", "Charged Bolt Sentry", "ass"],
|
||||||
|
[262, "Wake of Fire Sentry", "Wake of Fire", "ass"],
|
||||||
|
[263, "Weapon Block", "Weapon Block", "ass"],
|
||||||
|
[264, "Cloak of Shadows", "Cloak of Shadows", "ass"],
|
||||||
|
[265, "Cobra Strike", "Cobra Strike", "ass"],
|
||||||
|
[266, "Blade Fury", "Blade Fury", "ass"],
|
||||||
|
[267, "Fade", "Fade", "ass"],
|
||||||
|
[268, "Shadow Warrior", "Shadow Warrior", "ass"],
|
||||||
|
[269, "Claws of Thunder", "Claws of Thunder", "ass"],
|
||||||
|
[270, "Dragon Tail", "Dragon Tail", "ass"],
|
||||||
|
[271, "Lightning Sentry", "Lightning Sentry", "ass"],
|
||||||
|
[272, "Inferno Sentry", "Wake of Inferno", "ass"],
|
||||||
|
[273, "Mind Blast", "Mind Blast", "ass"],
|
||||||
|
[274, "Blades of Ice", "Blades of Ice", "ass"],
|
||||||
|
[275, "Dragon Flight", "Dragon Flight", "ass"],
|
||||||
|
[276, "Death Sentry", "Death Sentry", "ass"],
|
||||||
|
[277, "Blade Shield", "Blade Shield", "ass"],
|
||||||
|
[278, "Venom", "Venom", "ass"],
|
||||||
|
[279, "Shadow Master", "Shadow Master", "ass"],
|
||||||
|
[280, "Royal Strike", "Phoenix Strike", "ass"],
|
||||||
|
[350, "Delerium Change", "Delirium", ""],
|
||||||
|
]
|
||||||
|
|
||||||
|
const CLASS_ONLY_LABELS: Record<string, string> = {
|
||||||
|
ama: '(Amazon Only)',
|
||||||
|
sor: '(Sorceress Only)',
|
||||||
|
nec: '(Necromancer Only)',
|
||||||
|
pal: '(Paladin Only)',
|
||||||
|
bar: '(Barbarian Only)',
|
||||||
|
dru: '(Druid Only)',
|
||||||
|
ass: '(Assassin Only)',
|
||||||
|
}
|
||||||
|
|
||||||
|
const SKILL_LOOKUP_BY_ID = new Map<number, { name: string; classOnly?: string }>()
|
||||||
|
const SKILL_LOOKUP_BY_NAME = new Map<string, { name: string; classOnly?: string }>()
|
||||||
|
|
||||||
|
for (const [id, rawName, displayName, cls] of CANONICAL_SKILLS) {
|
||||||
|
const classOnly = cls ? CLASS_ONLY_LABELS[cls] : undefined
|
||||||
|
const info = classOnly ? { name: displayName, classOnly } : { name: displayName }
|
||||||
|
SKILL_LOOKUP_BY_ID.set(id, info)
|
||||||
|
SKILL_LOOKUP_BY_NAME.set(rawName.toLowerCase(), info)
|
||||||
|
SKILL_LOOKUP_BY_NAME.set(displayName.toLowerCase(), info)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a skill parameter (numeric ID or internal name from Skills.txt)
|
||||||
|
* into its 1.13c canonical English display name and optional class restriction.
|
||||||
|
*/
|
||||||
|
export function resolveSkillInfo(par: string | number | undefined): { name: string; classOnly?: string } {
|
||||||
|
if (par === undefined || par === '') return { name: 'Skill' }
|
||||||
|
const str = String(par).trim()
|
||||||
|
const num = Number(str)
|
||||||
|
if (!Number.isNaN(num) && SKILL_LOOKUP_BY_ID.has(num)) {
|
||||||
|
return SKILL_LOOKUP_BY_ID.get(num)!
|
||||||
|
}
|
||||||
|
const byName = SKILL_LOOKUP_BY_NAME.get(str.toLowerCase())
|
||||||
|
if (byName) return byName
|
||||||
|
return { name: str }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical 1.13c skilltab mapping (par 0..20 from CharStats.txt StrSkillTab1..3 + StrClassOnly).
|
||||||
|
*/
|
||||||
|
const CANONICAL_SKILL_TABS: Record<number, string> = {
|
||||||
|
0: 'Bow and Crossbow Skills (Amazon Only)',
|
||||||
|
1: 'Passive and Magic Skills (Amazon Only)',
|
||||||
|
2: 'Javelin and Spear Skills (Amazon Only)',
|
||||||
|
3: 'Fire Skills (Sorceress Only)',
|
||||||
|
4: 'Lightning Skills (Sorceress Only)',
|
||||||
|
5: 'Cold Skills (Sorceress Only)',
|
||||||
|
6: 'Curses (Necromancer Only)',
|
||||||
|
7: 'Poison and Bone Skills (Necromancer Only)',
|
||||||
|
8: 'Summoning Skills (Necromancer Only)',
|
||||||
|
9: 'Combat Skills (Paladin Only)',
|
||||||
|
10: 'Offensive Auras (Paladin Only)',
|
||||||
|
11: 'Defensive Auras (Paladin Only)',
|
||||||
|
12: 'Combat Skills (Barbarian Only)',
|
||||||
|
13: 'Masteries (Barbarian Only)',
|
||||||
|
14: 'Warcries (Barbarian Only)',
|
||||||
|
15: 'Summoning Skills (Druid Only)',
|
||||||
|
16: 'Shape Shifting Skills (Druid Only)',
|
||||||
|
17: 'Elemental Skills (Druid Only)',
|
||||||
|
18: 'Traps (Assassin Only)',
|
||||||
|
19: 'Shadow Disciplines (Assassin Only)',
|
||||||
|
20: 'Martial Arts (Assassin Only)',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical 1.13c MonStats.txt hcIdx -> NameStr for reanimate property.
|
||||||
|
*/
|
||||||
|
const CANONICAL_REANIMATE_MONSTERS: Record<number, string> = {
|
||||||
|
0: 'Skeleton',
|
||||||
|
1: 'Returned',
|
||||||
|
2: 'Bone Warrior',
|
||||||
|
3: 'Burning Dead',
|
||||||
|
4: 'Horror',
|
||||||
|
5: 'Zombie',
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPerLevelVal(rawEighths: number): string {
|
||||||
|
const perLvl = rawEighths / 8
|
||||||
|
return Number.isInteger(perLvl) ? `${perLvl}` : perLvl.toFixed(1).replace(/\.0$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
export function formatPropertyCode(
|
export function formatPropertyCode(
|
||||||
code: string,
|
code: string,
|
||||||
valueOrMin: number,
|
valueOrMin: number,
|
||||||
|
|
@ -130,17 +436,108 @@ export function formatPropertyCode(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const plusVal = value > 0 ? `+${value}` : `${value}`
|
// For func=17 properties where min=0, max=0, and par holds the actual numeric rate/eighths
|
||||||
|
const effectiveVal =
|
||||||
|
value === 0 && min === 0 && max === 0 && par !== undefined && par !== '' && !Number.isNaN(Number(par))
|
||||||
|
? Number(par)
|
||||||
|
: value
|
||||||
|
|
||||||
switch (code.toLowerCase()) {
|
const plusVal = effectiveVal >= 0 ? `+${effectiveVal}` : `${effectiveVal}`
|
||||||
|
|
||||||
|
switch (code.trim().toLowerCase()) {
|
||||||
|
// --- Skills & Class Skills (descFunc 1, 13, 14, 16, 27, 28) ---
|
||||||
case 'allskills':
|
case 'allskills':
|
||||||
return `${plusVal} to All Skills`
|
return `${plusVal} to All Skills`
|
||||||
|
case 'ama':
|
||||||
|
return `${plusVal} to Amazon Skill Levels`
|
||||||
|
case 'sor':
|
||||||
|
return `${plusVal} to Sorceress Skill Levels`
|
||||||
|
case 'nec':
|
||||||
|
return `${plusVal} to Necromancer Skill Levels`
|
||||||
|
case 'pal':
|
||||||
|
return `${plusVal} to Paladin Skill Levels`
|
||||||
|
case 'bar':
|
||||||
|
return `${plusVal} to Barbarian Skill Levels`
|
||||||
|
case 'dru':
|
||||||
|
return `${plusVal} to Druid Skills`
|
||||||
|
case 'ass':
|
||||||
|
return `${plusVal} to Assassin Skills`
|
||||||
|
case 'randclassskill':
|
||||||
|
return `+${effectiveVal || 3} to Random Class Skill Levels`
|
||||||
case 'classskills':
|
case 'classskills':
|
||||||
return `${plusVal} to ${par ? `${par} ` : ''}Skill Levels`
|
return `${plusVal} to ${par ? `${par} ` : ''}Skill Levels`
|
||||||
case 'skilltab':
|
case 'fireskill':
|
||||||
|
return `${plusVal} to Fire Skills`
|
||||||
|
case 'skilltab': {
|
||||||
|
const tabId = Number(par)
|
||||||
|
if (!Number.isNaN(tabId) && CANONICAL_SKILL_TABS[tabId]) {
|
||||||
|
return `${plusVal} to ${CANONICAL_SKILL_TABS[tabId]}`
|
||||||
|
}
|
||||||
return `${plusVal} to ${par ?? 'Skill Tab'}`
|
return `${plusVal} to ${par ?? 'Skill Tab'}`
|
||||||
case 'skill':
|
}
|
||||||
return `${plusVal} to ${par ?? 'Skill'}`
|
case 'skill': {
|
||||||
|
const sk = resolveSkillInfo(par)
|
||||||
|
return `${plusVal} to ${sk.name}${sk.classOnly ? ` ${sk.classOnly}` : ''}`
|
||||||
|
}
|
||||||
|
case 'skill-rand': {
|
||||||
|
const slvl = Number(par) || effectiveVal || 3
|
||||||
|
const sk = resolveSkillInfo(min)
|
||||||
|
return `+${slvl} to ${sk.name}${sk.classOnly ? ` ${sk.classOnly}` : ' (Sorceress Only)'}`
|
||||||
|
}
|
||||||
|
case 'oskill': {
|
||||||
|
const sk = resolveSkillInfo(par)
|
||||||
|
return `${plusVal} to ${sk.name}`
|
||||||
|
}
|
||||||
|
case 'aura': {
|
||||||
|
const sk = resolveSkillInfo(par)
|
||||||
|
return `Level ${effectiveVal} ${sk.name} Aura When Equipped`
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Event Procs (func=11, descFunc=15) & Charged Skills (func=19, descFunc=24) ---
|
||||||
|
case 'hit-skill': {
|
||||||
|
const sk = resolveSkillInfo(par)
|
||||||
|
const chance = min || effectiveVal || 5
|
||||||
|
const slvl = max || 1
|
||||||
|
return `${chance}% Chance to cast level ${slvl} ${sk.name} on striking`
|
||||||
|
}
|
||||||
|
case 'att-skill': {
|
||||||
|
const sk = resolveSkillInfo(par)
|
||||||
|
const chance = min || effectiveVal || 5
|
||||||
|
const slvl = max || 1
|
||||||
|
return `${chance}% Chance to cast level ${slvl} ${sk.name} on attack`
|
||||||
|
}
|
||||||
|
case 'gethit-skill': {
|
||||||
|
const sk = resolveSkillInfo(par)
|
||||||
|
const chance = min || effectiveVal || 5
|
||||||
|
const slvl = max || 1
|
||||||
|
return `${chance}% Chance to cast level ${slvl} ${sk.name} when struck`
|
||||||
|
}
|
||||||
|
case 'kill-skill': {
|
||||||
|
const sk = resolveSkillInfo(par)
|
||||||
|
const chance = min || effectiveVal || 5
|
||||||
|
const slvl = max || 1
|
||||||
|
return `${chance}% Chance to cast level ${slvl} ${sk.name} when you Kill an Enemy`
|
||||||
|
}
|
||||||
|
case 'death-skill': {
|
||||||
|
const sk = resolveSkillInfo(par)
|
||||||
|
const chance = min || effectiveVal || 100
|
||||||
|
const slvl = max || 1
|
||||||
|
return `${chance}% Chance to cast level ${slvl} ${sk.name} when you Die`
|
||||||
|
}
|
||||||
|
case 'levelup-skill': {
|
||||||
|
const sk = resolveSkillInfo(par)
|
||||||
|
const chance = min || effectiveVal || 100
|
||||||
|
const slvl = max || 1
|
||||||
|
return `${chance}% Chance to cast level ${slvl} ${sk.name} when you Level-Up`
|
||||||
|
}
|
||||||
|
case 'charged': {
|
||||||
|
const sk = resolveSkillInfo(par)
|
||||||
|
const charges = min || effectiveVal || 10
|
||||||
|
const slvl = max || 1
|
||||||
|
return `Level ${slvl} ${sk.name} (${charges}/${charges} Charges)`
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Attributes & Life / Mana / Stamina ---
|
||||||
case 'str':
|
case 'str':
|
||||||
return `${plusVal} to Strength`
|
return `${plusVal} to Strength`
|
||||||
case 'dex':
|
case 'dex':
|
||||||
|
|
@ -153,104 +550,367 @@ export function formatPropertyCode(
|
||||||
return `${plusVal} to All Attributes`
|
return `${plusVal} to All Attributes`
|
||||||
case 'hp':
|
case 'hp':
|
||||||
return `${plusVal} to Life`
|
return `${plusVal} to Life`
|
||||||
|
case 'hp%':
|
||||||
|
return `Increase Maximum Life ${effectiveVal}%`
|
||||||
case 'mana':
|
case 'mana':
|
||||||
return `${plusVal} to Mana`
|
return `${plusVal} to Mana`
|
||||||
|
case 'mana%':
|
||||||
|
return `Increase Maximum Mana ${effectiveVal}%`
|
||||||
|
case 'stam':
|
||||||
|
return `${plusVal} Maximum Stamina`
|
||||||
|
case 'regen-stam':
|
||||||
|
return `Heal Stamina Plus ${effectiveVal}%`
|
||||||
|
case 'stamdrain':
|
||||||
|
return `${effectiveVal}% Slower Stamina Drain`
|
||||||
|
|
||||||
|
// --- Per-Level Scaling Properties (func=17, descFunc 6..9) ---
|
||||||
case 'hp/lvl':
|
case 'hp/lvl':
|
||||||
return `+${(value / 8).toFixed(1)} to Life (Based on Character Level)`
|
return `+${formatPerLevelVal(effectiveVal)} to Life (Based on Character Level)`
|
||||||
case 'mana/lvl':
|
case 'mana/lvl':
|
||||||
return `+${(value / 8).toFixed(1)} to Mana (Based on Character Level)`
|
return `+${formatPerLevelVal(effectiveVal)} to Mana (Based on Character Level)`
|
||||||
|
case 'stam/lvl':
|
||||||
|
return `+${formatPerLevelVal(effectiveVal)} Maximum Stamina (Based on Character Level)`
|
||||||
|
case 'regen-stam/lvl':
|
||||||
|
return `Heal Stamina Plus +${formatPerLevelVal(effectiveVal)}% (Based on Character Level)`
|
||||||
|
case 'str/lvl':
|
||||||
|
return `+${formatPerLevelVal(effectiveVal)} to Strength (Based on Character Level)`
|
||||||
|
case 'dex/lvl':
|
||||||
|
return `+${formatPerLevelVal(effectiveVal)} to Dexterity (Based on Character Level)`
|
||||||
|
case 'vit/lvl':
|
||||||
|
return `+${formatPerLevelVal(effectiveVal)} to Vitality (Based on Character Level)`
|
||||||
|
case 'enr/lvl':
|
||||||
|
return `+${formatPerLevelVal(effectiveVal)} to Energy (Based on Character Level)`
|
||||||
|
case 'ac/lvl':
|
||||||
|
return `+${formatPerLevelVal(effectiveVal)} Defense (Based on Character Level)`
|
||||||
|
case 'ac%/lvl':
|
||||||
|
return `+${formatPerLevelVal(effectiveVal)}% Enhanced Defense (Based on Character Level)`
|
||||||
|
case 'dmg/lvl':
|
||||||
|
return `+${formatPerLevelVal(effectiveVal)} to Maximum Damage (Based on Character Level)`
|
||||||
|
case 'dmg%/lvl':
|
||||||
|
return `+${formatPerLevelVal(effectiveVal)}% Enhanced Maximum Damage (Based on Character Level)`
|
||||||
|
case 'att/lvl':
|
||||||
|
return `+${formatPerLevelVal(effectiveVal)} to Attack Rating (Based on Character Level)`
|
||||||
|
case 'att%/lvl':
|
||||||
|
return `${formatPerLevelVal(effectiveVal)}% Bonus to Attack Rating (Based on Character Level)`
|
||||||
|
case 'dmg-dem/lvl':
|
||||||
|
return `+${formatPerLevelVal(effectiveVal)}% Damage to Demons (Based on Character Level)`
|
||||||
|
case 'dmg-und/lvl':
|
||||||
|
return `+${formatPerLevelVal(effectiveVal)}% Damage to Undead (Based on Character Level)`
|
||||||
|
case 'att-dem/lvl':
|
||||||
|
return `+${formatPerLevelVal(effectiveVal)} to Attack Rating against Demons (Based on Character Level)`
|
||||||
|
case 'att-und/lvl':
|
||||||
|
return `+${formatPerLevelVal(effectiveVal)} to Attack Rating against Undead (Based on Character Level)`
|
||||||
|
case 'deadly/lvl':
|
||||||
|
return `${formatPerLevelVal(effectiveVal)}% Deadly Strike (Based on Character Level)`
|
||||||
|
case 'crush/lvl':
|
||||||
|
return `${formatPerLevelVal(effectiveVal)}% Chance of Crushing Blow (Based on Character Level)`
|
||||||
|
case 'wounds/lvl':
|
||||||
|
return `${formatPerLevelVal(effectiveVal)}% Chance of Open Wounds (Based on Character Level)`
|
||||||
|
case 'kick/lvl':
|
||||||
|
return `+${formatPerLevelVal(effectiveVal)} Kick Damage (Based on Character Level)`
|
||||||
|
case 'thorns/lvl':
|
||||||
|
return `Attacker Takes Damage of ${formatPerLevelVal(effectiveVal)} (Based on Character Level)`
|
||||||
|
case 'mag%/lvl':
|
||||||
|
return `${formatPerLevelVal(effectiveVal)}% Better Chance of Getting Magic Items (Based on Character Level)`
|
||||||
|
case 'gold%/lvl':
|
||||||
|
return `${formatPerLevelVal(effectiveVal)}% Extra Gold from Monsters (Based on Character Level)`
|
||||||
|
case 'res-cold/lvl':
|
||||||
|
return `Cold Resist +${formatPerLevelVal(effectiveVal)}% (Based on Character Level)`
|
||||||
|
case 'res-fire/lvl':
|
||||||
|
return `Fire Resist +${formatPerLevelVal(effectiveVal)}% (Based on Character Level)`
|
||||||
|
case 'res-ltng/lvl':
|
||||||
|
return `Lightning Resist +${formatPerLevelVal(effectiveVal)}% (Based on Character Level)`
|
||||||
|
case 'res-pois/lvl':
|
||||||
|
return `Poison Resist +${formatPerLevelVal(effectiveVal)}% (Based on Character Level)`
|
||||||
|
case 'abs-cold/lvl':
|
||||||
|
return `+${formatPerLevelVal(effectiveVal)} Absorbs Cold Damage (Based on Character Level)`
|
||||||
|
case 'abs-fire/lvl':
|
||||||
|
return `+${formatPerLevelVal(effectiveVal)} Absorbs Fire Damage (Based on Character Level)`
|
||||||
|
case 'abs-ltng/lvl':
|
||||||
|
return `+${formatPerLevelVal(effectiveVal)} Absorbs Lightning Damage (Based on Character Level)`
|
||||||
|
case 'ac/time':
|
||||||
|
return `${plusVal} Defense (Increases During Nighttime)`
|
||||||
|
|
||||||
|
// --- Defense & Damage Modifiers ---
|
||||||
case 'ac':
|
case 'ac':
|
||||||
return `${plusVal} Defense`
|
return `${plusVal} Defense`
|
||||||
case 'ac%':
|
case 'ac%':
|
||||||
return `${plusVal}% Enhanced Defense`
|
return `${plusVal}% Enhanced Defense`
|
||||||
|
case 'ac-miss':
|
||||||
|
return `${plusVal} Defense vs. Missile`
|
||||||
|
case 'ac-hth':
|
||||||
|
return `${plusVal} Defense vs. Melee`
|
||||||
case 'dmg%':
|
case 'dmg%':
|
||||||
return `${plusVal}% Enhanced Damage`
|
return `${plusVal}% Enhanced Damage`
|
||||||
|
case 'dmg':
|
||||||
|
return `Damage ${plusVal}`
|
||||||
case 'dmg-min':
|
case 'dmg-min':
|
||||||
return `${plusVal} to Minimum Damage`
|
return `${plusVal} to Minimum Damage`
|
||||||
case 'dmg-max':
|
case 'dmg-max':
|
||||||
return `${plusVal} to Maximum Damage`
|
return `${plusVal} to Maximum Damage`
|
||||||
|
case 'dmg-norm':
|
||||||
|
return min > 0 && max > min ? `Adds ${min}-${max} Damage` : `${plusVal} to Minimum Damage`
|
||||||
|
case 'dmg-ac':
|
||||||
|
return `${plusVal} to Monster Defense Per Hit`
|
||||||
|
case 'reduce-ac':
|
||||||
|
return `-${Math.abs(effectiveVal)}% Target Defense`
|
||||||
|
case 'dmg-demon':
|
||||||
|
return `${plusVal}% Damage to Demons`
|
||||||
|
case 'dmg-undead':
|
||||||
|
return `${plusVal}% Damage to Undead`
|
||||||
|
case 'att-demon':
|
||||||
|
return `${plusVal} to Attack Rating against Demons`
|
||||||
|
case 'att-undead':
|
||||||
|
return `${plusVal} to Attack Rating against Undead`
|
||||||
|
case 'demon-heal':
|
||||||
|
return `${plusVal} Life after each Demon Kill`
|
||||||
|
|
||||||
|
// --- Resistances, Max Resistances & Elemental Mastery/Pierce ---
|
||||||
case 'res-all':
|
case 'res-all':
|
||||||
return `All Resistances ${plusVal}`
|
return `All Resistances ${plusVal}`
|
||||||
|
case 'res-all-max':
|
||||||
|
return `${plusVal}% to Maximum All Resistances`
|
||||||
case 'res-fire':
|
case 'res-fire':
|
||||||
case 'fireresist':
|
case 'fireresist':
|
||||||
return `Fire Resist ${plusVal}%`
|
return `Fire Resist ${plusVal}%`
|
||||||
|
case 'res-fire-max':
|
||||||
|
return `${plusVal}% to Maximum Fire Resist`
|
||||||
case 'res-cold':
|
case 'res-cold':
|
||||||
case 'coldresist':
|
case 'coldresist':
|
||||||
return `Cold Resist ${plusVal}%`
|
return `Cold Resist ${plusVal}%`
|
||||||
|
case 'res-cold-max':
|
||||||
|
return `${plusVal}% to Maximum Cold Resist`
|
||||||
case 'res-ltng':
|
case 'res-ltng':
|
||||||
case 'lightresist':
|
case 'lightresist':
|
||||||
return `Lightning Resist ${plusVal}%`
|
return `Lightning Resist ${plusVal}%`
|
||||||
|
case 'res-ltng-max':
|
||||||
|
return `${plusVal}% to Maximum Lightning Resist`
|
||||||
case 'res-pois':
|
case 'res-pois':
|
||||||
case 'poisonresist':
|
case 'poisonresist':
|
||||||
return `Poison Resist ${plusVal}%`
|
return `Poison Resist ${plusVal}%`
|
||||||
|
case 'res-pois-max':
|
||||||
|
return `${plusVal}% to Maximum Poison Resist`
|
||||||
|
case 'res-pois-len':
|
||||||
|
return `Poison Length Reduced by ${Math.abs(effectiveVal)}%`
|
||||||
|
case 'extra-fire':
|
||||||
|
return `${plusVal}% to Fire Skill Damage`
|
||||||
|
case 'extra-cold':
|
||||||
|
return `${plusVal}% to Cold Skill Damage`
|
||||||
|
case 'extra-ltng':
|
||||||
|
return `${plusVal}% to Lightning Skill Damage`
|
||||||
|
case 'extra-pois':
|
||||||
|
return `${plusVal}% to Poison Skill Damage`
|
||||||
|
case 'pierce-fire':
|
||||||
|
return `-${Math.abs(effectiveVal)}% to Enemy Fire Resistance`
|
||||||
|
case 'pierce-cold':
|
||||||
|
return `-${Math.abs(effectiveVal)}% to Enemy Cold Resistance`
|
||||||
|
case 'pierce-ltng':
|
||||||
|
return `-${Math.abs(effectiveVal)}% to Enemy Lightning Resistance`
|
||||||
|
case 'pierce-pois':
|
||||||
|
return `-${Math.abs(effectiveVal)}% to Enemy Poison Resistance`
|
||||||
|
|
||||||
|
// --- Elemental & Magic Absorb (descFunc 1 & 2) ---
|
||||||
|
case 'abs-fire':
|
||||||
|
return `${plusVal} Fire Absorb`
|
||||||
|
case 'abs-fire%':
|
||||||
|
return `Fire Absorb ${effectiveVal}%`
|
||||||
|
case 'abs-cold':
|
||||||
|
return `${plusVal} Cold Absorb`
|
||||||
|
case 'abs-cold%':
|
||||||
|
return `Cold Absorb ${effectiveVal}%`
|
||||||
|
case 'abs-ltng':
|
||||||
|
return `${plusVal} Lightning Absorb`
|
||||||
|
case 'abs-ltng%':
|
||||||
|
return `Lightning Absorb ${effectiveVal}%`
|
||||||
|
case 'abs-mag':
|
||||||
|
return `${plusVal} Magic Absorb`
|
||||||
|
case 'abs-pois':
|
||||||
|
return `${plusVal} Poison Absorb`
|
||||||
|
|
||||||
|
// --- Speed & Rate Modifiers (Properties.txt func=8) ---
|
||||||
case 'fcr':
|
case 'fcr':
|
||||||
|
case 'cast1':
|
||||||
|
case 'cast2':
|
||||||
|
case 'cast3':
|
||||||
return `${plusVal}% Faster Cast Rate`
|
return `${plusVal}% Faster Cast Rate`
|
||||||
case 'fhr':
|
case 'fhr':
|
||||||
|
case 'balance1':
|
||||||
|
case 'balance2':
|
||||||
|
case 'balance3':
|
||||||
return `${plusVal}% Faster Hit Recovery`
|
return `${plusVal}% Faster Hit Recovery`
|
||||||
case 'ias':
|
case 'ias':
|
||||||
|
case 'swing1':
|
||||||
|
case 'swing2':
|
||||||
|
case 'swing3':
|
||||||
return `${plusVal}% Increased Attack Speed`
|
return `${plusVal}% Increased Attack Speed`
|
||||||
case 'frw':
|
case 'frw':
|
||||||
|
case 'move1':
|
||||||
|
case 'move2':
|
||||||
|
case 'move3':
|
||||||
return `${plusVal}% Faster Run/Walk`
|
return `${plusVal}% Faster Run/Walk`
|
||||||
case 'fbr':
|
case 'fbr':
|
||||||
|
case 'block1':
|
||||||
|
case 'block2':
|
||||||
|
case 'block3':
|
||||||
return `${plusVal}% Faster Block Rate`
|
return `${plusVal}% Faster Block Rate`
|
||||||
|
case 'block':
|
||||||
|
return `${effectiveVal}% Increased Chance of Blocking`
|
||||||
|
|
||||||
|
// --- Combat & Leech & Reduction Modifiers ---
|
||||||
case 'mag%':
|
case 'mag%':
|
||||||
return `${value}% Better Chance of Getting Magic Items`
|
return `${effectiveVal}% Better Chance of Getting Magic Items`
|
||||||
case 'gold%':
|
case 'gold%':
|
||||||
return `${value}% Extra Gold from Monsters`
|
return `${effectiveVal}% Extra Gold from Monsters`
|
||||||
case 'lifesteal':
|
case 'lifesteal':
|
||||||
case 'leech':
|
case 'leech':
|
||||||
return `${value}% Life stolen per hit`
|
return `${effectiveVal}% Life stolen per hit`
|
||||||
case 'manasteal':
|
case 'manasteal':
|
||||||
return `${value}% Mana stolen per hit`
|
return `${effectiveVal}% Mana stolen per hit`
|
||||||
case 'crush':
|
case 'crush':
|
||||||
return `${value}% Chance of Crushing Blow`
|
return `${effectiveVal}% Chance of Crushing Blow`
|
||||||
case 'openwounds':
|
case 'openwounds':
|
||||||
return `${value}% Chance of Open Wounds`
|
return `${effectiveVal}% Chance of Open Wounds`
|
||||||
case 'deadly':
|
case 'deadly':
|
||||||
return `${value}% Deadly Strike`
|
return `${effectiveVal}% Deadly Strike`
|
||||||
case 'dmag':
|
case 'dmag':
|
||||||
return `Damage Reduced by ${value}%`
|
case 'red-dmg%':
|
||||||
|
return `Damage Reduced by ${effectiveVal}%`
|
||||||
case 'dmag-ac':
|
case 'dmag-ac':
|
||||||
return `Damage Reduced by ${value}`
|
case 'red-dmg':
|
||||||
|
return `Damage Reduced by ${effectiveVal}`
|
||||||
case 'mag-ac':
|
case 'mag-ac':
|
||||||
return `Magic Damage Reduced by ${value}`
|
case 'red-mag':
|
||||||
case 'sock':
|
return `Magic Damage Reduced by ${effectiveVal}`
|
||||||
return `Socketed (${value})`
|
case 'dmg-to-mana':
|
||||||
|
return `${effectiveVal}% Damage Taken Goes To Mana`
|
||||||
|
case 'thorns':
|
||||||
|
return `Attacker Takes Damage of ${effectiveVal}`
|
||||||
|
case 'light-thorns':
|
||||||
|
return `Attacker Takes Lightning Damage of ${effectiveVal}`
|
||||||
|
case 'mana-kill':
|
||||||
|
return `${plusVal} to Mana after each Kill`
|
||||||
|
case 'heal-kill':
|
||||||
|
case 'hp-kill':
|
||||||
|
return `${plusVal} Life after each Kill`
|
||||||
|
|
||||||
|
// --- Durability, Quantity, Requirements, Sockets & Special Flags ---
|
||||||
|
case 'rep-dur': {
|
||||||
|
// D2Client.dll descFunc=11 + expansionstring.tbl ModStre9t / ModStre9u:
|
||||||
|
// Rate v (from par column) is durability repaired per 100 seconds -> seconds = Math.floor(100 / v)
|
||||||
|
const rate = Number(par) || effectiveVal || 1
|
||||||
|
const seconds = Math.floor(100 / rate)
|
||||||
|
return seconds <= 1
|
||||||
|
? 'Repairs 1 durability per second'
|
||||||
|
: `Repairs 1 durability in ${seconds} seconds`
|
||||||
|
}
|
||||||
|
case 'rep-quant':
|
||||||
|
return 'Replenishes quantity'
|
||||||
|
case 'ease':
|
||||||
|
return `Requirements ${effectiveVal > 0 ? `+${effectiveVal}` : effectiveVal}%`
|
||||||
|
case 'ethereal':
|
||||||
|
return 'Ethereal (Cannot be Repaired)'
|
||||||
case 'indestruct':
|
case 'indestruct':
|
||||||
return `Indestructible`
|
return 'Indestructible'
|
||||||
|
case 'sock':
|
||||||
|
return `Socketed (${effectiveVal})`
|
||||||
|
case 'stack':
|
||||||
|
return 'Increased Stack Size'
|
||||||
|
case 'dur':
|
||||||
|
return `${plusVal} Maximum Durability`
|
||||||
|
case 'dur%':
|
||||||
|
return `Increase Maximum Durability ${effectiveVal}%`
|
||||||
case 'no-freeze':
|
case 'no-freeze':
|
||||||
case 'nofreeze':
|
case 'nofreeze':
|
||||||
return `Cannot be Frozen`
|
return 'Cannot Be Frozen'
|
||||||
case 'half-freeze':
|
case 'half-freeze':
|
||||||
return `Half Freeze Duration`
|
return 'Half Freeze Duration'
|
||||||
|
case 'noheal':
|
||||||
|
case 'no-heal':
|
||||||
|
return 'Prevent Monster Heal'
|
||||||
|
case 'rip':
|
||||||
|
return 'Slain Monsters Rest in Peace'
|
||||||
|
case 'knock':
|
||||||
|
return 'Knockback'
|
||||||
|
case 'howl':
|
||||||
|
return `Hit Causes Monster to Flee ${effectiveVal}%`
|
||||||
|
case 'stupidity':
|
||||||
|
case 'blind':
|
||||||
|
return effectiveVal > 1 ? `Hit Blinds Target +${effectiveVal}` : 'Hit Blinds Target'
|
||||||
case 'rep-life':
|
case 'rep-life':
|
||||||
case 'regen':
|
case 'regen':
|
||||||
return `Replenish Life ${plusVal}`
|
return effectiveVal >= 0 ? `Replenish Life +${effectiveVal}` : `Drain Life ${effectiveVal}`
|
||||||
case 'regen-mana':
|
case 'regen-mana':
|
||||||
case 'manaregen':
|
case 'manaregen':
|
||||||
return `Regenerate Mana ${value}%`
|
return `Regenerate Mana ${effectiveVal}%`
|
||||||
case 'pierce':
|
case 'pierce':
|
||||||
return value > 0 ? `Piercing Attack (${value}%)` : `Piercing Attack`
|
return effectiveVal > 0 ? `Piercing Attack (${effectiveVal}%)` : 'Piercing Attack'
|
||||||
case 'ignore-ac':
|
case 'ignore-ac':
|
||||||
return `Ignore Target's Defense`
|
return "Ignore Target's Defense"
|
||||||
case 'freeze':
|
case 'freeze':
|
||||||
return `Freezes target ${plusVal}`
|
return effectiveVal > 1 ? `Freezes target +${effectiveVal}` : 'Freezes target'
|
||||||
case 'slow':
|
case 'slow':
|
||||||
return `Slows Target By ${value}%`
|
return `Slows Target by ${effectiveVal}%`
|
||||||
|
case 'light':
|
||||||
|
return `${plusVal} to Light Radius`
|
||||||
|
case 'cheap':
|
||||||
|
return `Reduces all Vendor Prices ${effectiveVal}%`
|
||||||
|
case 'addxp':
|
||||||
|
return `${plusVal}% to Experience Gained`
|
||||||
|
case 'magicarrow':
|
||||||
|
return 'Fires Magic Arrows'
|
||||||
|
case 'explosivearrow':
|
||||||
|
return 'Fires Explosive Arrows or Bolts'
|
||||||
|
case 'reanimate': {
|
||||||
|
const monId = Number(par)
|
||||||
|
const monName = !Number.isNaN(monId) && CANONICAL_REANIMATE_MONSTERS[monId]
|
||||||
|
? CANONICAL_REANIMATE_MONSTERS[monId]
|
||||||
|
: (par || 'Returned')
|
||||||
|
return `${effectiveVal}% Reanimate as: ${monName}`
|
||||||
|
}
|
||||||
|
case 'bloody':
|
||||||
|
return 'Extra Blood'
|
||||||
|
case 'fade':
|
||||||
|
return 'Fade'
|
||||||
|
case 'state':
|
||||||
|
return String(par).toLowerCase() === 'monsterset' ? 'Transforms into Vampire' : 'Display Aura'
|
||||||
|
|
||||||
|
// --- Attack Rating & Elemental Damages ---
|
||||||
case 'att':
|
case 'att':
|
||||||
return `${plusVal} to Attack Rating`
|
return `${plusVal} to Attack Rating`
|
||||||
case 'att%':
|
case 'att%':
|
||||||
return `${plusVal}% Bonus to Attack Rating`
|
return `${effectiveVal}% Bonus to Attack Rating`
|
||||||
case 'dmg-fire':
|
case 'dmg-fire':
|
||||||
return min > 0 && max > min ? `Adds ${min}-${max} Fire Damage` : `Adds ${value} Fire Damage`
|
return min > 0 && max > min ? `Adds ${min}-${max} Fire Damage` : `Adds ${effectiveVal} Fire Damage`
|
||||||
|
case 'fire-min':
|
||||||
|
return `${plusVal} to Minimum Fire Damage`
|
||||||
|
case 'fire-max':
|
||||||
|
return `${plusVal} to Maximum Fire Damage`
|
||||||
case 'dmg-cold':
|
case 'dmg-cold':
|
||||||
return min > 0 && max > min ? `Adds ${min}-${max} Cold Damage` : `Adds ${value} Cold Damage`
|
return min > 0 && max > min ? `Adds ${min}-${max} Cold Damage` : `Adds ${effectiveVal} Cold Damage`
|
||||||
|
case 'cold-min':
|
||||||
|
return `${plusVal} to Minimum Cold Damage`
|
||||||
|
case 'cold-max':
|
||||||
|
return `${plusVal} to Maximum Cold Damage`
|
||||||
|
case 'cold-len':
|
||||||
|
return `Cold Duration: ${Math.max(1, Math.round(effectiveVal / 25))} Seconds`
|
||||||
case 'dmg-ltng':
|
case 'dmg-ltng':
|
||||||
return min > 0 && max > min ? `Adds ${min}-${max} Lightning Damage` : `Adds ${value} Lightning Damage`
|
return min > 0 && max > min ? `Adds ${min}-${max} Lightning Damage` : `Adds ${effectiveVal} Lightning Damage`
|
||||||
|
case 'ltng-min':
|
||||||
|
return `${plusVal} to Minimum Lightning Damage`
|
||||||
|
case 'ltng-max':
|
||||||
|
return `${plusVal} to Maximum Lightning Damage`
|
||||||
case 'dmg-pois':
|
case 'dmg-pois':
|
||||||
return `+${min} Poison Damage Over ${Math.round(max / 25) || max || 3} Seconds`
|
return `+${min || effectiveVal} Poison Damage Over ${Math.round(Number(par || max) / 25) || 3} Seconds`
|
||||||
|
case 'pois-min':
|
||||||
|
return `${plusVal} to Minimum Poison Damage`
|
||||||
|
case 'pois-max':
|
||||||
|
return `${plusVal} to Maximum Poison Damage`
|
||||||
|
case 'pois-len':
|
||||||
|
return `Poison Duration: ${Math.max(1, Math.round(effectiveVal / 25))} Seconds`
|
||||||
case 'dmg-mag':
|
case 'dmg-mag':
|
||||||
return min > 0 && max > min ? `Adds ${min}-${max} Magic Damage` : `Adds ${value} Magic Damage`
|
return min > 0 && max > min ? `Adds ${min}-${max} Magic Damage` : `Adds ${effectiveVal} Magic Damage`
|
||||||
|
case 'dmg-elem':
|
||||||
|
return min > 0 && max > min ? `Adds ${min}-${max} Elemental Damage` : `Adds ${effectiveVal} Elemental Damage`
|
||||||
default:
|
default:
|
||||||
return `${code}: ${plusVal}${par ? ` (${par})` : ''}`
|
return `${code}: ${plusVal}${par ? ` (${par})` : ''}`
|
||||||
}
|
}
|
||||||
|
|
@ -261,7 +921,7 @@ export function formatPropertyCode(
|
||||||
*/
|
*/
|
||||||
export function formatItemTooltip(item: Item): FormattedItemTooltip {
|
export function formatItemTooltip(item: Item): FormattedItemTooltip {
|
||||||
const base = item.base
|
const base = item.base
|
||||||
const quality = (item.rarity || 'normal').toLowerCase()
|
const quality = String(item.rarity || item.quality || 'normal').toLowerCase()
|
||||||
const ilvl = item.level ?? item.ilvl ?? 1
|
const ilvl = item.level ?? item.ilvl ?? 1
|
||||||
|
|
||||||
let title = item.name
|
let title = item.name
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,191 @@
|
||||||
|
import { describe, expect, it, beforeAll } from 'vitest'
|
||||||
|
import * as fs from 'fs'
|
||||||
|
import { MpqArchive } from '../src/mpq/archive.ts'
|
||||||
|
import { fileSource } from '../src/mpq/file-source.ts'
|
||||||
|
import { MountedArchives } from '../src/mpq/mount.ts'
|
||||||
|
import { parseTable } from '../src/game/acts.ts'
|
||||||
|
import { loadUniqueItems } from '../src/game/unique-items.ts'
|
||||||
|
import {
|
||||||
|
parseSetItemsTable,
|
||||||
|
parseSetsTable,
|
||||||
|
CANONICAL_SET_ITEMS_TSV,
|
||||||
|
CANONICAL_SETS_TSV,
|
||||||
|
} from '../src/game/set-items.ts'
|
||||||
|
import { loadMagicAffixes } from '../src/game/affixes.ts'
|
||||||
|
import { loadAutoMagic } from '../src/game/automagic.ts'
|
||||||
|
import { formatPropertyCode, formatItemTooltip, resolveSkillInfo } from '../src/game/item-tooltip.ts'
|
||||||
|
import { createDroppedItem } from '../src/game/drop-pipeline.ts'
|
||||||
|
import type { ItemBase } from '../src/game/items.ts'
|
||||||
|
|
||||||
|
describe('Issue #124: Canonical 1.13c Property Descriptions & Ground Truth Templates', () => {
|
||||||
|
it('formats rep-dur using D2Client.dll descFunc=11 (seconds = Math.floor(100 / par)) and ModStre9t/ModStre9u', () => {
|
||||||
|
// Sandstorm Trek / Demonlimb / Steel Carapice / of Fast Repair: par = 5 -> 100 / 5 = 20 seconds
|
||||||
|
expect(formatPropertyCode('rep-dur', 0, 0, 0, 5)).toBe('Repairs 1 durability in 20 seconds')
|
||||||
|
expect(formatPropertyCode('rep-dur', 5, 0, 0, 5)).toBe('Repairs 1 durability in 20 seconds')
|
||||||
|
|
||||||
|
// Skullder's Ire / Ginther's Rift: par = 20 -> 100 / 20 = 5 seconds
|
||||||
|
expect(formatPropertyCode('rep-dur', 0, 0, 0, 20)).toBe('Repairs 1 durability in 5 seconds')
|
||||||
|
|
||||||
|
// Warlord's Trust: par = 25 -> 100 / 25 = 4 seconds
|
||||||
|
expect(formatPropertyCode('rep-dur', 0, 0, 0, 25)).toBe('Repairs 1 durability in 4 seconds')
|
||||||
|
|
||||||
|
// Hone Sundan: par = 10 -> 100 / 10 = 10 seconds
|
||||||
|
expect(formatPropertyCode('rep-dur', 0, 0, 0, 10)).toBe('Repairs 1 durability in 10 seconds')
|
||||||
|
|
||||||
|
// of Self-Repair: param = '3' -> Math.floor(100 / 3) = 33 seconds
|
||||||
|
expect(formatPropertyCode('rep-dur', 0, 0, 0, '3')).toBe('Repairs 1 durability in 33 seconds')
|
||||||
|
|
||||||
|
// High rate >= 100 -> ModStre9t ("Repairs 1 durability per second")
|
||||||
|
expect(formatPropertyCode('rep-dur', 0, 0, 0, 100)).toBe('Repairs 1 durability per second')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves internal Skills.txt names and numeric IDs to 1.13c .tbl display names and class restrictions', () => {
|
||||||
|
expect(resolveSkillInfo('Wearwolf')).toEqual({ name: 'Werewolf', classOnly: '(Druid Only)' })
|
||||||
|
expect(resolveSkillInfo('Summon Fenris')).toEqual({ name: 'Summon Dire Wolf', classOnly: '(Druid Only)' })
|
||||||
|
expect(resolveSkillInfo('Plague Poppy')).toEqual({ name: 'Poison Creeper', classOnly: '(Druid Only)' })
|
||||||
|
expect(resolveSkillInfo('Eruption')).toEqual({ name: 'Fissure', classOnly: '(Druid Only)' })
|
||||||
|
expect(resolveSkillInfo('Wake of Fire Sentry')).toEqual({ name: 'Wake of Fire', classOnly: '(Assassin Only)' })
|
||||||
|
expect(resolveSkillInfo('BloodGolem')).toEqual({ name: 'Blood Golem', classOnly: '(Necromancer Only)' })
|
||||||
|
expect(resolveSkillInfo('IronGolem')).toEqual({ name: 'Iron Golem', classOnly: '(Necromancer Only)' })
|
||||||
|
expect(resolveSkillInfo(197)).toEqual({ name: 'Firestorm' })
|
||||||
|
expect(resolveSkillInfo(54)).toEqual({ name: 'Teleport', classOnly: '(Sorceress Only)' })
|
||||||
|
|
||||||
|
// Single class skill (descFunc=27) vs oskill (descFunc=28)
|
||||||
|
expect(formatPropertyCode('skill', 3, 3, 3, 54)).toBe('+3 to Teleport (Sorceress Only)')
|
||||||
|
expect(formatPropertyCode('oskill', 4, 3, 6, 'Wearwolf')).toBe('+4 to Werewolf')
|
||||||
|
expect(formatPropertyCode('aura', 12, 10, 13, 'Sanctuary')).toBe('Level 12 Sanctuary Aura When Equipped')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats skilltab (0..20), event procs (descFunc=15), charged skills (descFunc=24), and reanimate (descFunc=23)', () => {
|
||||||
|
// CharStats.txt StrSkillTab1..3 + StrClassOnly
|
||||||
|
expect(formatPropertyCode('skilltab', 2, 2, 2, 0)).toBe('+2 to Bow and Crossbow Skills (Amazon Only)')
|
||||||
|
expect(formatPropertyCode('skilltab', 2, 2, 2, 6)).toBe('+2 to Curses (Necromancer Only)')
|
||||||
|
expect(formatPropertyCode('skilltab', 2, 2, 2, 10)).toBe('+2 to Offensive Auras (Paladin Only)')
|
||||||
|
expect(formatPropertyCode('skilltab', 2, 2, 2, 14)).toBe('+2 to Warcries (Barbarian Only)')
|
||||||
|
expect(formatPropertyCode('skilltab', 1, 1, 1, 16)).toBe('+1 to Shape Shifting Skills (Druid Only)')
|
||||||
|
expect(formatPropertyCode('skilltab', 2, 2, 2, 19)).toBe('+2 to Shadow Disciplines (Assassin Only)')
|
||||||
|
|
||||||
|
// Event procs (min = chance %, max = slvl, par = skill)
|
||||||
|
expect(formatPropertyCode('hit-skill', 5, 5, 10, 197)).toBe('5% Chance to cast level 10 Firestorm on striking')
|
||||||
|
expect(formatPropertyCode('gethit-skill', 25, 25, 1, 54)).toBe('25% Chance to cast level 1 Teleport when struck')
|
||||||
|
expect(formatPropertyCode('death-skill', 100, 100, 47, 'Chain Lightning')).toBe(
|
||||||
|
'100% Chance to cast level 47 Chain Lightning when you Die',
|
||||||
|
)
|
||||||
|
expect(formatPropertyCode('levelup-skill', 100, 100, 41, 'Nova')).toBe(
|
||||||
|
'100% Chance to cast level 41 Nova when you Level-Up',
|
||||||
|
)
|
||||||
|
|
||||||
|
// Charged skill (min = charges, max = slvl, par = skill)
|
||||||
|
expect(formatPropertyCode('charged', 69, 69, 11, 54)).toBe('Level 11 Teleport (69/69 Charges)')
|
||||||
|
expect(formatPropertyCode('charged', 15, 15, 21, 'Plague Poppy')).toBe('Level 21 Poison Creeper (15/15 Charges)')
|
||||||
|
|
||||||
|
// Tomb Reaver reanimate (par = 1 -> Returned)
|
||||||
|
expect(formatPropertyCode('reanimate', 10, 10, 10, 1)).toBe('10% Reanimate as: Returned')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats speed steps, damage reduction, pierce/mastery, and end-to-end dropped unique items', () => {
|
||||||
|
expect(formatPropertyCode('swing2', 20)).toBe('+20% Increased Attack Speed')
|
||||||
|
expect(formatPropertyCode('cast3', 20)).toBe('+20% Faster Cast Rate')
|
||||||
|
expect(formatPropertyCode('balance2', 25)).toBe('+25% Faster Hit Recovery')
|
||||||
|
expect(formatPropertyCode('move3', 40)).toBe('+40% Faster Run/Walk')
|
||||||
|
expect(formatPropertyCode('block2', 30)).toBe('+30% Faster Block Rate')
|
||||||
|
expect(formatPropertyCode('ease', -20)).toBe('Requirements -20%')
|
||||||
|
expect(formatPropertyCode('red-dmg', 7)).toBe('Damage Reduced by 7')
|
||||||
|
expect(formatPropertyCode('red-dmg%', 20)).toBe('Damage Reduced by 20%')
|
||||||
|
expect(formatPropertyCode('red-mag', 15)).toBe('Magic Damage Reduced by 15')
|
||||||
|
expect(formatPropertyCode('pierce-fire', 15)).toBe('-15% to Enemy Fire Resistance')
|
||||||
|
expect(formatPropertyCode('extra-cold', 15)).toBe('+15% to Cold Skill Damage')
|
||||||
|
|
||||||
|
// End-to-end createDroppedItem + formatItemTooltip for Sandstorm Trek (rep-dur par=5 -> 20s)
|
||||||
|
const base: ItemBase = {
|
||||||
|
id: 'uvb',
|
||||||
|
name: 'Scarabshell Boots',
|
||||||
|
kind: 'armor',
|
||||||
|
tags: ['boot', 'armo'],
|
||||||
|
damage: 0,
|
||||||
|
defense: 65,
|
||||||
|
value: 1000,
|
||||||
|
invWidth: 2,
|
||||||
|
invHeight: 2,
|
||||||
|
maxStack: 1,
|
||||||
|
level: 66,
|
||||||
|
}
|
||||||
|
const sandstormTrek = createDroppedItem(base, 'unique', {
|
||||||
|
ilvl: 87,
|
||||||
|
dwInitSeed: 12345,
|
||||||
|
uniqueItem: {
|
||||||
|
id: 260,
|
||||||
|
index: 'Sandstorm Trek',
|
||||||
|
version: 100,
|
||||||
|
enabled: true,
|
||||||
|
ladder: false,
|
||||||
|
rarity: 1,
|
||||||
|
nolimit: false,
|
||||||
|
lvl: 72,
|
||||||
|
levelreq: 64,
|
||||||
|
code: 'uvb',
|
||||||
|
carry1: false,
|
||||||
|
costMult: 5,
|
||||||
|
costAdd: 5000,
|
||||||
|
props: [
|
||||||
|
{ code: 'ac%', min: 140, max: 170 },
|
||||||
|
{ code: 'move2', min: 20, max: 20 },
|
||||||
|
{ code: 'balance2', min: 20, max: 20 },
|
||||||
|
{ code: 'rep-dur', par: 5, min: 0, max: 0 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const tooltip = formatItemTooltip(sandstormTrek)
|
||||||
|
const texts = tooltip.lines.map(l => l.text)
|
||||||
|
expect(texts).toContain('Repairs 1 durability in 20 seconds')
|
||||||
|
expect(texts).toContain('+20% Faster Run/Walk')
|
||||||
|
expect(texts).toContain('+20% Faster Hit Recovery')
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasD2 = fs.existsSync('samples/d2/d2data.mpq')
|
||||||
|
it.skipIf(!hasD2)('verifies 100% of all 180 property codes across 1.13c MPQ tables have 0 unmapped fallbacks', async () => {
|
||||||
|
const archives = new MountedArchives()
|
||||||
|
for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
|
||||||
|
archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`)))
|
||||||
|
}
|
||||||
|
const uniques = await loadUniqueItems(archives)
|
||||||
|
const setItems = parseSetItemsTable(parseTable(new TextEncoder().encode(CANONICAL_SET_ITEMS_TSV)))
|
||||||
|
const sets = parseSetsTable(parseTable(new TextEncoder().encode(CANONICAL_SETS_TSV)), setItems)
|
||||||
|
const affixes = await loadMagicAffixes(archives)
|
||||||
|
const automagic = await loadAutoMagic(archives)
|
||||||
|
|
||||||
|
const usedCodes = new Map<string, any>()
|
||||||
|
const record = (code: string, ex: any) => {
|
||||||
|
const c = (code || '').trim()
|
||||||
|
if (!c || c.startsWith('*')) return
|
||||||
|
if (!usedCodes.has(c)) usedCodes.set(c, ex)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const u of uniques.all || []) for (const p of u.props) record(p.code, p)
|
||||||
|
for (const s of setItems.rows || []) {
|
||||||
|
for (const p of s.props) record(p.code, p)
|
||||||
|
for (const p of s.aprops) record(p.code, p)
|
||||||
|
}
|
||||||
|
for (const s of sets.rows || []) {
|
||||||
|
for (const p of [...(s.pCodes || []), ...(s.fCodes || [])]) record(p.code, p)
|
||||||
|
}
|
||||||
|
for (const a of [...(affixes.prefixes.all || []), ...(affixes.suffixes.all || [])]) {
|
||||||
|
for (const m of a.mods) record(m.code, m)
|
||||||
|
}
|
||||||
|
for (const a of automagic.entries || []) {
|
||||||
|
for (const m of a.mods) record(m.code, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(usedCodes.size).toBe(182)
|
||||||
|
|
||||||
|
const unmapped: string[] = []
|
||||||
|
for (const [code, ex] of usedCodes.entries()) {
|
||||||
|
const formatted = formatPropertyCode(code, ex.min || 1, ex.min || 1, ex.max || 1, ex.par ?? ex.param)
|
||||||
|
if (formatted.toLowerCase().startsWith(`${code.toLowerCase()}:`)) {
|
||||||
|
unmapped.push(`${code} -> ${formatted}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(unmapped).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue