From 3b993cf04a1a7f11444b0d4eb063e7b3f5e2be21 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Sat, 26 Sep 2026 05:47:49 +0000 Subject: [PATCH] feat(vendor): implement 1.13c vendor inventory generator, pricing engine & town NPC UI (Fixes #481, #482, #483, #484, #485, #486) --- scripts/create-gitea-vendor-milestone.ts | 278 +++ scripts/verify-vendor-ui.ts | 227 +++ src/data/canonical-vendor-data.ts | 2170 ++++++++++++++++++++++ src/game/armor.ts | 44 + src/game/drop-pipeline.ts | 6 + src/game/embedded-drop-tables.ts | 41 + src/game/item-cost.ts | 786 ++++++++ src/game/item-types.ts | 3 + src/game/items.ts | 109 +- src/game/misc-items.ts | 44 + src/game/npc-table.ts | 878 +++++++++ src/game/properties.ts | 4 + src/game/vendor-generator.ts | 789 ++++++++ src/game/vendor-service.ts | 980 ++++++++++ src/game/weapons.ts | 44 + src/scene/act-scene.ts | 50 +- src/ui/hud-manager.ts | 150 +- src/ui/inventory.ts | 7 + src/ui/item-bridge.ts | 87 +- src/ui/world-panels.ts | 890 ++++++++- tests/item-cost.test.ts | 262 +++ tests/vendor-generator.test.ts | 246 +++ tests/vendor-service.test.ts | 149 ++ 23 files changed, 8168 insertions(+), 76 deletions(-) create mode 100644 scripts/create-gitea-vendor-milestone.ts create mode 100644 scripts/verify-vendor-ui.ts create mode 100644 src/data/canonical-vendor-data.ts create mode 100644 src/game/item-cost.ts create mode 100644 src/game/npc-table.ts create mode 100644 src/game/vendor-generator.ts create mode 100644 src/game/vendor-service.ts create mode 100644 tests/item-cost.test.ts create mode 100644 tests/vendor-generator.test.ts create mode 100644 tests/vendor-service.test.ts diff --git a/scripts/create-gitea-vendor-milestone.ts b/scripts/create-gitea-vendor-milestone.ts new file mode 100644 index 0000000..42cbd7e --- /dev/null +++ b/scripts/create-gitea-vendor-milestone.ts @@ -0,0 +1,278 @@ +/** + * Creates the "M15 · 原版商人 NPC 与交易经济系统 (1.13c Ground Truth)" Milestone and its 6 atomic Issues + * in the Gitea repository https://git.projectdiablo2.cn/troytt/diablo2-web + */ + +const BASE_URL = 'https://git.projectdiablo2.cn/api/v1/repos/troytt/diablo2-web' +const TOKEN = '94c84cc3bd2eab804346cb889650c843d0cda244' + +async function giteaFetch(path: string, method = 'GET', body?: unknown): Promise { + const res = await fetch(`${BASE_URL}${path}`, { + method, + headers: { + Authorization: `token ${TOKEN}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }) + if (!res.ok) { + const txt = await res.text() + throw new Error(`Gitea API ${method} ${path} failed (${res.status}): ${txt}`) + } + return (await res.json()) as T +} + +interface MilestoneResp { + id: number + title: string + state: string +} + +interface IssueResp { + id: number + number: number + title: string + html_url: string +} + +async function main() { + const existingMilestones = await giteaFetch('/milestones?state=all&limit=50') + console.log('Existing milestones:', existingMilestones.map(m => `#${m.id}: ${m.title} (${m.state})`)) + + const targetTitle = '[M19] 原版商人 NPC 与交易经济系统 (1.13c Ground Truth)' + let milestone = existingMilestones.find( + m => m.id === 13 || m.title === targetTitle || m.title.includes('原版商人 NPC 与交易经济系统'), + ) + + const milestoneDesc = `实装暗黑破坏神 II:毁灭之王 v1.13c 原版城镇商人(Vendor NPC)库存生成、物品交易/收购/修理/赌博定价引擎与四分页商店 UI。 + +### 核心 Ground Truth 权威对标 (\`D2MOO\` + 官方 MPQ 数据表) +1. **商人全局物品缓存 (\`SUnitProxy.cpp\`)**: + - \`SUNITPROXY_InitializeNpcControl\` / \`SUNITPROXY_FillGlobalItemCache\` / \`SUNITPROXY_FillIGlobaltemCacheRecordForNpc\` + - 严格映射 17 位商人 ID (\`nNpcId = 0..16\`:阿卡拉、基德、恰西、法拉、雷山德、卓格南、赫拉铁力 \`Hralti*\`、阿尔科、奥玛斯、艾尔吉斯、艾席拉、凯恩、海尔布、贾梅拉、马拉、拉苏克、安亚/尼拉塞克),分离常驻商品 \`pPermCache\` (\`PermStoreItem = 1\`) 与动态随机库存 \`pItemCache\`。 +2. **按商人 ID、游戏难度与角色等级动态生成出售物品 (\`SUnitNpc.cpp\`)**: + - \`D2GAME_NPC_FillStoreInventory_6FCC7100\` & \`D2GAME_NPC_GenerateStoreItem_6FCC6A60\` + - 商店物品等级公式:\`ilvl = max(1, min(99, cLvl + 5))\`,普通难度受 Act 1..5 幕数上限 \`[12, 20, 28, 36, 45]\` 钳制,噩梦/地狱难度无幕数上限。 + - \`ilvl < 25\` 时滚动普通/劣质/超强底材;\`ilvl >= 25\` 时非固定库存 100% 升级为魔法品质 (\`ITEMQUAL_MAGIC\`) 且保底下限提升 (\`nMinItems = (rand & 1) + 2\`)。 + - \`cLvl > 25\` 时在噩梦/地狱难度严格执行扩展级 (\`UberCode\`) / 精华级 (\`UltraCode\`) 升级骰子以及 \`NightmareUpgrade\` / \`HellUpgrade\` 药水替换。 +3. **1.13c 交易价格与收购价格计算引擎 (\`Items.cpp\`)**: + - \`ITEMS_CalculateTransactionCost (0x6FD9B1C0)\` 及辅助函数 \`0x6FD9AB90\` / \`0x6FD9ACE0\` / \`0x6FD9CB50\`。 + - 精确计算购买价 (\`BUY\`)、商人收购价 (\`SELL\`)、修理费 (\`REPAIR\`) 与赌博价 (\`GAMBLE\`):涵盖防御比例缩放、Staffmods 单技能高额加价(售价 \`/1024\` vs 收购价 \`/4096\`)、词缀与 \`ItemStatCost.txt\` 逐点属性加价、镶嵌物半价累加、无形/职业专属收购价 \`/4\` 惩罚、\`Npc.txt\` 汇率与任务折扣、各难度最高收购上限(普通 \`5k/10k/15k/20k/25k\`、噩梦 \`30k\`、地狱 \`35k\`)及减价属性 (\`STAT_ITEM_REDUCEDPRICES\`)。 +4. **城镇 NPC 交互菜单与四分页商店 UI**: + - 原版风格 NPC 交互菜单(交谈 / 交易 / 赌博 / 修理 / 鉴定),左侧 10×10 四分页商店面板(护甲 / 武器 I / 武器 II / 杂物)与右侧玩家背包联动交易。` + + if (!milestone) { + milestone = await giteaFetch('/milestones', 'POST', { + title: targetTitle, + description: milestoneDesc, + state: 'open', + }) + console.log(`Created Milestone #${milestone.id}: ${milestone.title}`) + } else { + milestone = await giteaFetch(`/milestones/${milestone.id}`, 'PATCH', { + title: targetTitle, + description: milestoneDesc, + state: 'open', + }) + console.log(`Updated Milestone #${milestone.id}: ${milestone.title}`) + } + + const issuesToCreate = [ + { + key: 'P1', + title: '[M19-P1] 烘焙 1.13c 商人经济与库存权威数据表 (Npc.txt / Vendor Columns / Gamble.txt / Books.txt / Stat & Skill Costs)', + body: `## 背景与目标 +为 1.13c 商人库存生成器与价格计算引擎提供零网络、100% 忠实于官方 MPQ 数据表的底层数据契约。 + +## 1.13c Ground Truth 对标 (\`ItemsTbls.cpp\` / \`MonsterTbls.cpp\`) +1. **17 位商人配置列 (\`Weapons.txt\` / \`Armor.txt\` / \`Misc.txt\`)**: + - 提取 \`nNpcId = 0..16\` 的 \`Min\`, \`Max\`, \`MagicMin\`, \`MagicMax\`, \`MagicLvl\`(注意 \`nNpcId = 6\` 赫拉铁力在原版表中的列名拼写为 \`HraltiMin\` / \`HraltiMax\` / \`HraltiMagicMin\` / \`HraltiMagicMax\` / \`HraltiMagicLvl\`)。 + - 提取 \`PermStoreItem\`、\`multibuy\`、\`NightmareUpgrade\`、\`HellUpgrade\`、\`cost\`、\`gamble cost\`、\`bitfield1\`。 +2. **\`Npc.txt\` 商人经济表 (\`src/game/npc-table.ts\`)**: + - 解析 \`npc\`, \`buy mult\`, \`sell mult\`, \`rep mult\`, \`questflag A/B/C\`, \`questbuymult A/B/C\`, \`questsellmult A/B/C\`, \`questrepmult A/B/C\`, \`max buy\`, \`max buy (N)\`, \`max buy (H)\`。 + - 建立 \`MonsterId (hcIdx)\` $\\leftrightarrow$ \`VendorNpcId (0..16)\` 及 NPC 服务能力矩阵(Trade / Gamble / Repair / Heal / Identify)。 +3. **\`ItemTypes.txt\` / \`ItemStatCost.txt\` / \`Skills.txt\` / \`Gamble.txt\` / \`Books.txt\` / \`LowQualityItems.txt\`**: + - \`ItemTypes.txt\`:解析 \`StorePage\` (\`armr\` / \`weap\` / \`misc\`)、\`StaffMods\` 与 \`Class\`(用于职业专属物品收购价 \`/4\` 折算)。 + - \`ItemStatCost.txt\`:解析 \`ID\`, \`Stat\`, \`Encode\`, \`Multiply\`, \`Add\`, \`ValShift\`。 + - \`Skills.txt\`:解析 \`Id\`, \`skill\`, \`charclass\`, \`cost mult\`, \`cost add\`。 + - 烘焙 \`Gamble.txt\`(赌博底材代码池)与 \`Books.txt\`(回城/辨识书卷轴价格与充能成本)。 + +## 验收标准 +- [ ] \`canonical-drop-data.ts\` 与 \`embedded-drop-tables.ts\` 零网络同步导出上述全部权威表结构。 +- [ ] 单元测试验证全部 17 位商人的列解析(含 \`Hralti*\`)及 \`Npc.txt\` 三难度 \`max buy\` 上限(普通 \`5000~25000\`,噩梦 \`30000\`,地狱 \`35000\`)准确无误。`, + }, + { + key: 'P2', + title: '[M19-P2] 实装 1.13c 商人库存生成引擎 (按商人 ID、游戏难度与角色等级生成出售物品)', + body: `## 背景与目标 +严格按照 \`D2Game.dll\` 反编译源码实装商人库存生成引擎 (\`src/game/vendor-generator.ts\`),根据**商人 ID (\`nNpcId\`)**、**游戏难度 (\`Normal/Nightmare/Hell\`)** 与**角色等级 (\`cLvl\`)** 生成四分页出售物品。 + +## 1.13c Ground Truth 对标 (\`SUnitProxy.cpp\` & \`SUnitNpc.cpp\`) +1. **全局缓存构建 (\`SUNITPROXY_FillIGlobaltemCacheRecordForNpc\`)**: + - 按 \`Weapons.txt\` $\\rightarrow$ \`Armor.txt\` $\\rightarrow$ \`Misc.txt\` 顺序扫描 \`nSpawnable != 0\` 且 \`(nVendorMax > 0 || nVendorMagicMax > 0)\` 的物品。 + - \`PermStoreItem != 0\` 归入 \`pPermCache\`;其余归入 \`pItemCache\`。 +2. **商店物品等级 \`ilvl\` 计算 (\`D2GAME_NPC_FillStoreInventory_6FCC7100\`)**: + - \`nItemLevel = Math.max(1, Math.min(99, nPlayerLevel + 5))\`。 + - 普通难度 (\`nDifficulty == 0\`) 按幕数 \`nAct = 0..4\` 强制上限 \`npcLevels[5] = [12, 20, 28, 36, 45]\`;噩梦与地狱难度无幕数上限。 +3. **非固定商品生成 (\`pItemCache\`)**: + - 跳过 \`base.level > nItemLevel\` 的底材。 + - **当且仅当 \`nItemLevel < 25\` 且 \`nMax > 0\` 时**生成普通/劣质/超强底材: + - \`nNormalItems = nMin + rand(nMax - nMin + 1)\`。 + - 品质掷骰 \`rand(100)\`:\`ilvl < 5\` 时 \`> 90\` 为劣质 (\`ITEMQUAL_INFERIOR\`);\`5 <= ilvl < 10\` 时 \`> 85\` 为超强 (\`ITEMQUAL_SUPERIOR\`);\`10 <= ilvl < 25\` 时 \`>= 75\` 为超强;其余为普通白色 (\`ITEMQUAL_NORMAL\`)。 + - **魔法商品生成 (\`nMagicMax > 0\` 且 \`bitfield1 & 1\`)**: + - \`nMinItems = (nItemLevel >= 25) ? ((rand(2)) + 2) : 1\`。 + - \`nRange = nMinItems + nMagicMax - nMagicMin\`,生成 \`ITEMQUAL_MAGIC\` 并调用词缀生成器赋予前后缀(若词缀骰子未出词缀则自动重掷确保有效魔法词缀)与法杖/手杖/权杖 Staffmods。 +4. **噩梦/地狱难度底材升级与药水替换 (\`D2GAME_NPC_GenerateStoreItem_6FCC6A60\`)**: + - 当 \`nPlayerLevel > 25\` 时: + - 噩梦难度:\`rand(100000) < (nItemLevel << 6) + 4000\` 升级为 \`UberCode\`(若 \`Uber.level <= nItemLevel\`),否则应用 \`NightmareUpgrade\`。 + - 地狱难度:\`nRand = rand(100000)\`,资料片 \`< 16 * nItemLevel + 1000\` 升级为 \`UltraCode\`,否则 \`< (nItemLevel << 7) + 5000\` 升级为 \`UberCode\`;随后应用 \`HellUpgrade\`。 +5. **常驻商品 (\`pPermCache\`) 与四分页 10×10 网格排布**: + - 常驻物品标记 \`bPermStore = true\`,箭袋 (\`aqv\`/\`cqv\`) 设为满数量 \`maxstack\`。 + - 按 \`StorePage\` (\`armr -> Tab 0\`, \`weap -> Tab 1\` 溢出至 \`Tab 2\`, \`misc -> Tab 3\`) 自动进行 2D 网格碰撞排布。 + +## 验收标准 +- [ ] 完整实现 \`src/game/vendor-generator.ts\`。 +- [ ] 覆盖 Act 1~5 全部 17 位商人在普通/噩梦/地狱及不同等级下的库存生成单元测试。`, + }, + { + key: 'P3', + title: '[M19-P3] 实装 1.13c 物品交易与收购价格计算引擎 (ITEMS_CalculateTransactionCost 0x6FD9B1C0)', + body: `## 背景与目标 +100% 还原 \`D2Common.dll\` 中的 \`ITEMS_CalculateTransactionCost (0x6FD9B1C0)\` 及其 3 个子函数,精确计算任意物品的**商人售价 (玩家购买价 \`BUY\`)**、**商人收购价 (玩家出售价 \`SELL\`)**、**铁匠修理费 (\`REPAIR\`)** 与**赌博价 (\`GAMBLE\`)**。 + +## 1.13c Ground Truth 对标 (\`Items.cpp:1709-2495\`) +1. **特殊物品短路与基础成本 (\`BaseCost\`)**: + - 初始装备 (\`IFLAG_STARTITEM\`) 恒返回 \`1\` 金币。 + - 防具按当前防御值比例缩放:\`nAC * dwCost / dwMaxAc\`。 + - 箭袋按堆叠缩放:\`qty * dwCost / 1024\`;书册 (\`Books.txt\`) 按书页数量累加:\`BaseCost + qty * CostPerCharge\`。 + - 初始累加器:\`nSellCost = BaseCost\`, \`nBuyCost = Math.trunc(BaseCost / 2)\`, \`nRepCost = BaseCost\`。 +2. **Staffmods 单技能高额加价 (\`ITEMS_CalculateAdditionalCostsForItemSkill 0x6FD9CB50\`)**: + - 对非全职业固定技能底材 (\`ItemTypes.StaffMods != 7\`),遍历物品上的单技能 (\`STAT_ITEM_SINGLESKILL\`): + - \`nSellCost\` / \`nRepCost\` 增加 \`(2 * slvl - 1) * (SkillCostAdd + Math.trunc(BaseCost * SkillCostMult / 1024))\`。 + - \`nBuyCost\`(收购价)增加 \`(2 * slvl - 1) * (SkillCostAdd + Math.trunc(BaseCost * SkillCostMult / 4096))\`(严格 1/4 比例)。 +3. **品质、词缀与属性逐点加价 (\`IFLAG_IDENTIFIED\`)**: + - **Magic / Rare / Crafted**:累加前后缀 \`dwAdd + BaseCost * dwMultiply / 1024\`(收购价 \`/2048\`)并调用 \`ITEMS_CalculateAdditionalCostsForBonusStats (0x6FD9ACE0)\` 按 \`ItemStatCost.txt\` 的 \`Multiply\` 与 \`Add\` 逐点累加(若出现负向词缀导致低于 \`BaseCost\`,严格触发保底回退 \`nSellCost = BaseCost, nBuyCost = BaseCost / 2\`)。 + - **Unique / Set**:按 \`UniqueItems.txt\` / \`SetItems.txt\` 的 \`cost mult\` 与 \`cost add\` 加成并累加属性成本。 + - **Superior**:累加超强属性加成;**Inferior**:扣减 \`BaseCost / 2\`。 +4. **镶嵌物、无形与职业专属惩罚**: + - 遍历孔内已镶嵌物品,三者均加上 \`Math.trunc(Filler.dwCost / 2)\`。 + - **无形惩罚 (\`IFLAG_ETHEREAL\`)**:收购价 \`nBuyCost = Math.trunc(nBuyCost / 4)\`(破损无形归 0)。 + - **职业专属物品惩罚 (\`ItemTypes.Class < 7\`)**:收购价 \`nBuyCost = Math.trunc(nBuyCost / 4)\`。 +5. **\`Npc.txt\` 汇率、任务折扣、难度最高收购上限 (\`max buy\`) 与减价属性**: + - 应用 \`Npc.txt\` 的 \`sell mult\` / \`buy mult\` / \`rep mult\` (\`/ 1024\`) 及已完成任务的 \`quest*mult\` (\`/ 1024\`)。 + - **难度收购封顶**:当 \`transactionType === 'sell'\` 时,\`nBuyCost = Math.min(nBuyCost, NpcTxt.maxBuy[difficulty])\`(普通 Act 1..5 为 \`5000 / 10000 / 15000 / 20000 / 25000\`,噩梦 \`30000\`,地狱 \`35000\`)。 + - **减价属性 (\`STAT_ITEM_REDUCEDPRICES\`)**:仅对 \`BUY\`、\`REPAIR\`、\`GAMBLE\` 生效(不对 \`SELL\` 生效)。 + +## 验收标准 +- [ ] 完整实现 \`src/game/item-cost.ts\`。 +- [ ] 全面的单元测试覆盖全部品质、Staffmods、无形/职业专属惩罚、任务折扣与三难度收购上限。`, + }, + { + key: 'P4', + title: '[M19-P4] 实装 1.13c 城镇 NPC 交互系统与服务逻辑 (交谈 / 交易 / 赌博 / 治疗 / 修理 / 凯恩鉴定)', + body: `## 背景与目标 +基于 \`SUnitNpc.cpp\` 实装全 5 幕城镇 NPC 的完整服务会话与业务逻辑 (\`src/game/vendor-service.ts\`)。 + +## 1.13c Ground Truth 对标 (\`SUnitNpc.cpp\`) +1. **购买与批量购买 (\`sub_6FCC88B0\`)**: + - 校验玩家金币与背包空间;扣除金币。 + - 常驻物品 (\`bPermStore = true\`) 购买后克隆新实例放入玩家背包/腰带,原商店格物品保留;非常驻物品购买后从商店网格移除。 + - **Shift + 右键批量购买 (\`bShiftBuy\`)**: + - 药水:自动买满玩家腰带对应列空槽。 + - 回城卷轴 (\`tsc\`) / 辨识卷轴 (\`isc\`):自动补满玩家背包中的回城之书 (\`tbk\`) / 辨识之书 (\`ibk\`) 至 20 张上限。 + - 钥匙 (\`key\`):自动补满当前钥匙堆叠至 12 把上限。 +2. **出售与商店回购 (\`D2GAME_STORES_SellItem_6FCC7680\`)**: + - 禁止出售任务物品或不可出售物品(如赫拉迪克方块等)。 + - 计算收购价并增加玩家金币(遵守角色背包金币携带上限 \`getInventoryGoldLimit(cLvl)\`)。 + - 若该物品在商店对应分页 (10×10) 有空位且非消耗杂物,放入商店对应分页供玩家原价买回。 +3. **铁匠修理 (\`D2GAME_NPC_Repair_6FCC95B0\`)**: + - 支持单件修理与“全身装备一键修理”(修复已穿戴装备的耐久度与聚气技能充能,跳过无形装备)。 +4. **医疗与凯恩鉴定 (\`D2GAME_NPC_Heal_6FCCB220\` & \`D2GAME_NPC_IdentifyAllItems_6FCC9C90\`)**: + - 与阿卡拉、法拉、奥玛斯、贾梅拉、马拉交谈时自动回满生命、法力、体力并清除中毒/冰冻/诅咒状态。 + - 凯恩支持一键鉴定玩家背包中所有未鉴定物品。 +5. **赌博系统 (\`D2GAME_STORES_FillGamble_6FCCA9F0\`)**: + - 基德、艾尔吉斯、阿尔科、贾梅拉、安亚/尼拉塞克支持赌博面板,购买前显示未鉴定基底与一口价赌博费,购买入包瞬间按 1.13c 概率揭晓品质(Magic 89.85%、Rare 10%、Set 0.1%、Unique 0.05%)与扩展/精华底材升级。 + +## 验收标准 +- [ ] 实现 \`src/game/vendor-service.ts\` 并通过全部交易事务单元测试。`, + }, + { + key: 'P5', + title: '[M19-P5] 实装原版四分页商人面板 UI 与买卖交互 (Panel\\NPCInv.dc6 + Buy/Sell/Repair/Gamble)', + body: `## 背景与目标 +打通城镇 NPC 点击菜单、左侧四分页商店面板 (\`Panel\\NPCInv.dc6\`)、右侧玩家背包联动与悬浮价格 Tooltip。 + +## 核心实装内容 +1. **城镇 NPC 交互弹窗菜单 (\`src/scene/act-scene.ts\` & \`src/ui/world-panels.ts\`)**: + - 当玩家走近并点击城镇 NPC 时,除了触发治疗与头顶/面板问候外,弹出原版暗黑风格选项菜单: + - 交易型 NPC:\`交谈\` / \`交易\` (或 \`交易/修理\`) / \`赌博\` (若支持) / \`取消\` + - 凯恩:\`交谈\` / \`鉴定物品\` / \`取消\` +2. **左侧四分页商店面板 (\`src/ui/world-panels.ts\`)**: + - 替换 \`drawLeftDockPanel\` 中的 \`'vendor'\` 占位符,使用 \`vendor-bg.png\` 与 \`buysellbtn.png\` 渲染: + - 顶部 4 个分页标签:\`护甲\` / \`武器 I\` / \`武器 II\` / \`杂物\`。 + - 中部 10×10 物品网格(每格 29×29 px),绘制商店物品图标、染色、堆叠角标、鼠标悬停高亮。 + - 底部功能按钮栏:购买/出售、单件修理锤、全身修理(显示总修理费)、赌博刷新按钮(赌博模式)与关闭按钮。 +3. **左右双栏联动与 Tooltip 价格展示 (\`src/ui/hud-manager.ts\` & \`src/ui/inventory.ts\`)**: + - 打开左侧 \`vendor\` 面板时自动打开右侧玩家背包 (\`rightOpen.inventory = true\`)。 + - 悬停商店物品时:Tooltip 显示原版属性 + **\`购买价格: X 金币\`**(金币不足时标红)。 + - 悬停玩家背包/装备物品时:Tooltip 显示 **\`出售价格: X 金币\`**(修理光标激活时显示 **\`修理费用: X 金币\`**)。 + - 支持右键快捷购买/出售、Shift+右键批量买满药水/卷轴、左键拖拽买卖。 + +## 验收标准 +- [ ] 左侧商店面板与右侧背包面板严丝合缝并排显示,所有买卖/修理/赌博操作流畅闭环。`, + }, + { + key: 'P6', + title: '[M19-P6] 全量 17 商人 × 3 难度差分测试与浏览器 E2E 闭环审计', + body: `## 背景与目标 +对 M19 商人 NPC 与交易经济系统进行全谱系自动化验证与浏览器 E2E 闭环审计。 + +## 核心验证项 +1. **17 商人 × 3 难度 × 多角色等级自动化差分审计**: + - 验证 Act 1~5 共 17 位商人 ID 在 \`Normal / Nightmare / Hell\` 与 \`cLvl = 1, 12, 20, 25, 30, 60, 90\` 下的库存生成合法性(无越界等级物品、\`ilvl >= 25\` 零白装、常驻药水/卷轴/箭袋齐全)。 + - 验证各类物品(普通、超强、劣质、魔法、亮金、暗金、套装、带 +3 技能 Staffmods 手杖/法杖、无形、职业专属)的售价与收购价严格符合 \`0x6FD9B1C0\` 公式及 \`Npc.txt\` 难度封顶。 +2. **Playwright 浏览器端到端验证**: + - 启动本地开发服并在无头浏览器中实测点击城镇商人 NPC、打开交互菜单、切换四分页商店、购买药水/装备、出售背包物品、铁匠修理及凯恩鉴定全流程,确保 Console / PageError 为 0。 + +## 验收标准 +- [ ] 新增单元测试与 E2E 验证全部 100% 绿灯通过。`, + }, + ] + + const existingIssues = await giteaFetch(`/issues?state=all&milestones=${milestone.id}&limit=50`) + const createdUrls: string[] = [] + + for (const item of issuesToCreate) { + const found = existingIssues.find( + i => i.title === item.title || i.title.includes(`-${item.key}]`), + ) + if (found) { + const updated = await giteaFetch(`/issues/${found.number}`, 'PATCH', { + title: item.title, + body: item.body, + }) + console.log(`Updated Issue #${updated.number}: ${updated.title} -> ${updated.html_url}`) + createdUrls.push(`#${updated.number} ${updated.title}: ${updated.html_url}`) + continue + } + const created = await giteaFetch('/issues', 'POST', { + title: item.title, + body: item.body, + milestone: milestone.id, + }) + console.log(`Created Issue #${created.number}: ${created.title} -> ${created.html_url}`) + createdUrls.push(`#${created.number} ${created.title}: ${created.html_url}`) + } + + console.log('\n=== SUMMARY ===') + console.log(`Milestone #${milestone.id}: ${milestone.title} (https://git.projectdiablo2.cn/troytt/diablo2-web/milestone/${milestone.id})`) + for (const line of createdUrls) { + console.log(`- ${line}`) + } +} + +main().catch(err => { + console.error(err) + process.exit(1) +}) diff --git a/scripts/verify-vendor-ui.ts b/scripts/verify-vendor-ui.ts new file mode 100644 index 0000000..e4df10e --- /dev/null +++ b/scripts/verify-vendor-ui.ts @@ -0,0 +1,227 @@ +import { spawn, type ChildProcess } from 'node:child_process' +import { existsSync, mkdirSync, writeFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { createServer, type ViteDevServer } from 'vite' +import { ensurePacksAvailable } from './verify-ground-combat-browser.ts' + +const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)) + +const CHROME_CANDIDATES = [ + '/usr/bin/google-chrome', + '/usr/local/google/home/taodao/.cache/ms-playwright/chromium-1228/chrome-linux64/chrome', + '/root/.cache/ms-playwright/chromium-1223/chrome-linux64/chrome', +] + +function resolveChromeBinary(): string { + for (const bin of CHROME_CANDIDATES) { + if (existsSync(bin)) return bin + } + throw new Error('No accessible Chromium binary found') +} + +async function main() { + const outputDir = + process.env.VENDOR_AUDIT_OUT ?? + '/usr/local/google/home/taodao/.gemini/jetski/brain/ba5784fe-f165-4cff-aa7e-c3cfdf22c420' + mkdirSync(outputDir, { recursive: true }) + ensurePacksAvailable() + + const port = 5419 + const viteServer: ViteDevServer = await createServer({ + root: resolve('.'), + logLevel: 'silent', + server: { host: '127.0.0.1', port, strictPort: false }, + }) + await viteServer.listen() + const actualPort = (viteServer.httpServer?.address() as any)?.port ?? port + const targetUrl = `http://127.0.0.1:${actualPort}/acts.html?act=1&level=1-act-1-town-1` + + const chromeBin = resolveChromeBinary() + const debugPort = 9519 + const chromeProc: ChildProcess = spawn( + chromeBin, + [ + '--headless=new', + '--no-sandbox', + '--disable-dev-shm-usage', + '--window-size=1280,840', + '--enable-webgl', + '--ignore-gpu-blocklist', + '--use-gl=angle', + '--use-angle=swiftshader', + '--no-proxy-server', + `--remote-debugging-port=${debugPort}`, + targetUrl, + ], + { stdio: ['ignore', 'ignore', 'pipe'] }, + ) + + let ws: WebSocket | null = null + try { + let wsDebuggerUrl: string | null = null + for (let i = 0; i < 80; i++) { + await sleep(150) + try { + const res = await fetch(`http://127.0.0.1:${debugPort}/json`) + const pages = (await res.json()) as any[] + const page = pages.find(p => p.type === 'page' && p.url.includes('acts.html')) + if (page?.webSocketDebuggerUrl) { + wsDebuggerUrl = page.webSocketDebuggerUrl + break + } + } catch {} + } + if (!wsDebuggerUrl) throw new Error('Failed to attach to Chrome CDP') + + ws = new WebSocket(wsDebuggerUrl) + await new Promise((res, rej) => { + ws!.onopen = () => res(true) + ws!.onerror = rej + }) + + let cmdId = 1 + const pending = new Map void>() + ws.onmessage = (event: MessageEvent) => { + const msg = JSON.parse(String(event.data)) + if (msg.id && pending.has(msg.id)) { + pending.get(msg.id)!(msg) + pending.delete(msg.id) + } + } + + const cdp = (method: string, params: Record = {}): Promise => + new Promise((resolve, reject) => { + const id = cmdId++ + pending.set(id, (msg: any) => { + if (msg.error) reject(new Error(JSON.stringify(msg.error))) + else resolve(msg.result as T) + }) + ws!.send(JSON.stringify({ id, method, params })) + }) + + const evalJs = async (expression: string): Promise => { + const res = await cdp<{ result: { value: T }; exceptionDetails?: any }>('Runtime.evaluate', { + expression, + awaitPromise: true, + returnByValue: true, + }) + if (res.exceptionDetails) { + throw new Error(`Eval error: ${JSON.stringify(res.exceptionDetails)}`) + } + return res.result.value + } + + const takeScreenshot = async (name: string): Promise => { + const shot = await cdp<{ data: string }>('Page.captureScreenshot', { format: 'png' }) + const filePath = join(outputDir, `${name}.png`) + writeFileSync(filePath, Buffer.from(shot.data, 'base64')) + console.log(`Saved screenshot: ${filePath}`) + return filePath + } + + await cdp('Runtime.enable') + await cdp('Page.enable') + + for (let i = 0; i < 100; i++) { + const ready = await evalJs(`Boolean(window.__d2webAct?.ready && window.__d2webHud?.ready)`) + if (ready) break + await sleep(200) + } + + // 1. Open Charsi NPC Menu + const menuInfo = await evalJs(`(() => { + const hud = window.__d2webHudInstance; + hud.openNpcMenuByName('Charsi', 400, 220); + hud.render(); + return { + npcName: hud.worldPanels.npcMenu?.npcName, + options: hud.worldPanels.npcMenu?.options?.map(o => o.labelZh), + }; + })()`) + console.log('NPC Menu:', JSON.stringify(menuInfo)) + await sleep(250) + await takeScreenshot('vendor_npc_menu_charsi') + + // 2. Open Charsi Trade/Repair 4-tab 10x10 Vendor UI + const charsiInfo = await evalJs(`(() => { + const hud = window.__d2webHudInstance; + hud.charSheet.attrs.level = 24; + hud.openVendorSession('Charsi', 'trade', 0); + const wp = hud.worldPanels; + wp.activeVendorTab = 'armor'; + const armorItems = wp.vendorTabPlacements.armor; + const docking = hud.getDockingLayout(); + const deltaLeft = -docking.marginW; + if (armorItems.length > 0) { + const first = armorItems[0]; + wp.hoveredVendorItem = { + item: first.item, + x: 80 + 17 + first.col * 29 + 14, + y: 60 + 64 + first.row * 29 + 14, + col: first.col, + row: first.row, + }; + hud.inventory.hoveredItem = { + item: first.item, + x: wp.hoveredVendorItem.x + deltaLeft, + y: wp.hoveredVendorItem.y, + }; + } + hud.render(); + return { + leftPanel: hud.leftPanel, + rightPanel: hud.rightPanel, + armorCount: wp.vendorTabPlacements.armor.length, + weapons1Count: wp.vendorTabPlacements.weapons1.length, + miscCount: wp.vendorTabPlacements.misc.length, + firstItem: armorItems[0] ? { nameZh: armorItems[0].item.nameZh, buyCost: armorItems[0].item.buyCost } : null, + }; + })()`) + console.log('Charsi Trade UI:', JSON.stringify(charsiInfo)) + await sleep(250) + await takeScreenshot('vendor_trade_charsi') + + // 3. Open Gheed Gamble UI + const gheedInfo = await evalJs(`(() => { + const hud = window.__d2webHudInstance; + hud.charSheet.attrs.level = 60; + hud.openVendorSession('Gheed', 'gamble', 1); + const wp = hud.worldPanels; + const gambleItems = wp.gamblePlacements; + const docking = hud.getDockingLayout(); + const deltaLeft = -docking.marginW; + if (gambleItems.length > 0) { + const first = gambleItems[0]; + wp.hoveredVendorItem = { + item: first.item, + x: 80 + 17 + first.col * 29 + 14, + y: 60 + 64 + first.row * 29 + 14, + col: first.col, + row: first.row, + }; + hud.inventory.hoveredItem = { + item: first.item, + x: wp.hoveredVendorItem.x + deltaLeft, + y: wp.hoveredVendorItem.y, + }; + } + hud.render(); + return { + gambleCount: gambleItems.length, + firstGamble: gambleItems[0] ? { nameZh: gambleItems[0].item.nameZh, buyCost: gambleItems[0].item.buyCost } : null, + }; + })()`) + console.log('Gheed Gamble UI:', JSON.stringify(gheedInfo)) + await sleep(250) + await takeScreenshot('vendor_gamble_gheed') + } finally { + ws?.close() + chromeProc.kill('SIGKILL') + await viteServer.close() + } +} + +main().catch(err => { + console.error(err) + process.exit(1) +}) diff --git a/src/data/canonical-vendor-data.ts b/src/data/canonical-vendor-data.ts new file mode 100644 index 0000000..619c405 --- /dev/null +++ b/src/data/canonical-vendor-data.ts @@ -0,0 +1,2170 @@ +/** + * Auto-generated canonical Blizzard Diablo II 1.13c Vendor, Gambling, Books, + * LowQualityItems, ItemStatCost, Properties, and Skill Cost tables. + * + * Ground truth extracted directly from Patch_D2.mpq / d2exp.mpq / d2data.mpq. + */ + +export interface SkillCostEntry { + readonly id: number + readonly skill: string + readonly costMult: number + readonly costAdd: number +} + +export const RAW_NPC: string = "npc\tbuy mult\tsell mult\trep mult\tquestflag A\tquestbuymult A\tquestsellmult A\tquestrepmult A\tquestflag B\tquestbuymult B\tquestsellmult B\tquestrepmult B\tquestflag C\tquestbuymult C\tquestsellmult C\tquestrepmult C\tmax buy\tmax buy (N)\tmax buy (H)\r\ngheed\t512\t1088\t128\t4\t1024\t922\t1024\t\t\t\t\t\t\t\t\t5000\t30000\t35000\r\ncharsi\t512\t960\t128\t4\t1024\t922\t1024\t\t\t\t\t\t\t\t\t5000\t30000\t35000\r\nakara\t512\t1024\t128\t4\t1024\t922\t1024\t\t\t\t\t\t\t\t\t5000\t30000\t35000\r\nlysander\t512\t1024\t128\t9\t1024\t922\t1024\t\t\t\t\t\t\t\t\t10000\t30000\t35000\r\ndrognan\t512\t1024\t128\t9\t1024\t922\t1024\t\t\t\t\t\t\t\t\t10000\t30000\t35000\r\nelzix\t512\t1024\t128\t9\t1024\t922\t1024\t\t\t\t\t\t\t\t\t10000\t30000\t35000\r\nfara\t512\t1024\t128\t9\t1024\t922\t1024\t\t\t\t\t\t\t\t\t10000\t30000\t35000\r\nhratli\t512\t1024\t128\t17\t1024\t922\t1024\t\t\t\t\t\t\t\t\t15000\t30000\t35000\r\nalkor\t512\t1024\t128\t17\t1024\t922\t1024\t\t\t\t\t\t\t\t\t15000\t30000\t35000\r\normus\t512\t1024\t128\t17\t1024\t922\t1024\t\t\t\t\t\t\t\t\t15000\t30000\t35000\r\nasheara\t512\t1024\t128\t17\t1024\t922\t1024\t\t\t\t\t\t\t\t\t15000\t30000\t35000\r\njamella\t512\t1024\t128\t41\t1024\t922\t1024\t\t\t\t\t\t\t\t\t20000\t30000\t35000\r\nhalbu\t512\t1024\t128\t41\t1024\t922\t1024\t\t\t\t\t\t\t\t\t20000\t30000\t35000\r\nmalah\t512\t2048\t128\t41\t1024\t922\t1024\t35\t1024\t512\t1024\t\t\t\t\t25000\t30000\t35000\r\ndrehya\t512\t2048\t128\t41\t1024\t922\t1024\t35\t1024\t512\t1024\t\t\t\t\t25000\t30000\t35000\r\nlarzuk\t512\t2048\t128\t41\t1024\t922\t1024\t35\t1024\t512\t1024\t\t\t\t\t25000\t30000\t35000\r\nnihlathak\t512\t2048\t128\t41\t1024\t922\t1024\t35\t1024\t512\t1024\t\t\t\t\t25000\t30000\t35000\r\n" + +export const RAW_GAMBLE: string = "name\tcode\r\namulet\tamu\r\nring\trin\r\nHand Axe\thax\r\nAxe\taxe\r\nDouble Axe\t2ax\r\nMilitary Pick\tmpi\r\nWar Axe\twax\r\nLarge Axe\tlax\r\nBroad Axe \tbax\r\nBattle Axe\tbtx\r\nGreat Axe\tgax\r\nGiant Axe\tgix\r\nClub\tclb\r\nSpiked Club\tspc\r\nMace\tmac\r\nMorning Star\tmst\r\nFlail\tfla\r\nWar Hammer\twhm\r\nMaul\tmau\r\nGreat Maul\tgma\r\nShort Sword\tssd\r\nScimitar\tscm\r\nSaber\tsbr\r\nFalchion\tflc\r\nCrystal Sword\tcrs\r\nBroad Sword\tbsd\r\nLong Sword \tlsd\r\nWar Sword\twsd\r\nTwo-Handed Sword\t2hs\r\nClaymore\tclm\r\nGiant Sword\tgis\r\nBastard Sword\tbsw\r\nFlamberge\tflb\r\nGreat Sword\tgsd\r\nDagger\tdgr\r\nDirk\tdir\r\nKriss\tkri\r\nBlade\tbld\r\nSpear\tspr\r\nTrident\ttri\r\nBrandistock\tbrn\r\nSpetum\tspt\r\nPike\tpik\r\nBardiche\tbar\r\nVoulge\tvou\r\nScythe\tscy\r\nPoleaxe\tpax\r\nHalberd\thal\r\nWar Scythe\twsc\r\nShort Bow\tsbw\r\nHunter's Bow\thbw\r\nLong Bow\tlbw\r\nComposite Bow\tcbw\r\nShort Battle Bow\tsbb\r\nLong Battle Bow\tlbb\r\nShort War Bow\tswb\r\nLong War Bow\tlwb\r\nLight Crossbow\tlxb\r\nCrossbow\tmxb\r\nHeavy Crossbow\thxb\r\nRepeating Crossbow\trxb\r\nCap/hat\tcap\r\nSkull Cap\tskp\r\nHelm\thlm\r\nFull Helm\tfhl\r\nGreat Helm\tghm\r\nCrown\tcrn\r\nMask\tmsk\r\nQuilted Armor\tqui\r\nLeather Armor\tlea\r\nHard Leather Armor\thla\r\nStudded Leather \tstu\r\nRing Mail\trng\r\nScale Mail\tscl\r\nChain Mail\tchn\r\nBreast Plate\tbrs\r\nSplint Mail\tspl\r\nPlate Mail\tplt\r\nField Plate\tfld\r\nGothic Plate\tgth\r\nFull Plate Mail\tful\r\nAncient Armor\taar\r\nLight Plate\tltp\r\nBuckler\tbuc\r\nSmall Shield\tsml\r\nLarge Shield\tlrg\r\nKite Shield\tkit\r\nTower Shield\ttow\r\nGothic Shield\tgts\r\nGloves(L)\tlgl\r\nHeavy Gloves\tvgl\r\nBracers(M)\tmgl\r\nLight Gauntlets\ttgl\r\nGaunlets(H)\thgl\r\nLeather Boots\tlbt\r\nHeavy Boots\tvbt\r\nChain Boots\tmbt\r\nLight Plate Boots\ttbt\r\nPlate Boots\thbt\r\nSash(L)\tlbl\r\nLight Belt\tvbl\r\nBelt(M)\tmbl\r\nHeavy Belt\ttbl\r\nGirdle(H)\thbl\r\nBone Helm\tbhm\r\nBone Shield\tbsh\r\nSpiked Shield\tspk\r\nExpansion\t\r\nThrowing Knife\ttkf\r\nThrowing Axe\ttax\r\nBalanced Knife\tbkf\r\nBalanced Axe\tbal\r\nJavelin\tjav\r\nPilum\tpil\r\nShort Spear\tssp\r\nGlaive\tglv\r\nThrowing Spear\ttsp\r\nKatar\tktr\r\nWrist Blade\twrb\r\nAxe Fist\taxf\r\nCestus\tces\r\nClaws\tclw\r\nBlade Talons\tbtl\r\nScissors Katar\tskr\r\nCirclet\tci0\r\nCoronet\tci1\r\n" + +export const RAW_BOOKS: string = "Name\tNamco\tCompleted\tScrollSpellCode\tBookSpellCode\tpSpell\tSpellIcon\tScrollSkill\tBookSkill\tBaseCost\tCostPerCharge\r\nunused\tof Town Portal\t1\ttsc\ttbk\t2\t-1\tScroll of Townportal\tBook of Townportal\t250\t50\r\nunused\tof Identify\t1\tisc\tibk\t1\t0\tScroll of Identify\tBook of Identify\t200\t40\r\nunused\tof Ressurect\t0\t\t\t\t-1\t\t\t500\t100\r\n" + +export const RAW_LOW_QUALITY_ITEMS: string = "Name\r\nCrude\r\nCracked\r\nDamaged\r\nLow Quality\r\n" + +export const RAW_ITEM_STAT_COST: string = "Stat\tID\tSend Other\tSigned\tSend Bits\tSend Param Bits\tUpdateAnimRate\tSaved\tCSvSigned\tCSvBits\tCSvParam\tfCallback\tfMin\tMinAccr\tEncode\tAdd\tMultiply\tDivide\tValShift\t1.09-Save Bits\t1.09-Save Add\tSave Bits\tSave Add\tSave Param Bits\tkeepzero\top\top param\top base\top stat1\top stat2\top stat3\tdirect\tmaxstat\titemspecific\tdamagerelated\titemevent1\titemeventfunc1\titemevent2\titemeventfunc2\tdescpriority\tdescfunc\tdescval\tdescstrpos\tdescstrneg\tdescstr2\tdgrp\tdgrpfunc\tdgrpval\tdgrpstrpos\tdgrpstrneg\tdgrpstr2\tstuff\t*eol\r\nstrength\t0\t1\t\t11\t\t\t1\t0\t10\t\t\t1\t1\t\t125\t55\t1024\t\t7\t32\t8\t32\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t67\t1\t1\tModStr1a\tModStr1a\t\t1\t1\t1\tModitem2allattrib\tModitem2allattrib\t\t6\t0\r\nenergy\t1\t\t\t11\t\t\t1\t0\t10\t\t\t1\t1\t\t100\t55\t1024\t\t7\t32\t7\t32\t\t\t8\t\t\tmaxmana\t\t\t\t\t\t\t\t\t\t\t61\t1\t1\tModStr1d\tModStr1d\t\t1\t1\t1\tModitem2allattrib\tModitem2allattrib\t\t\t0\r\ndexterity\t2\t1\t\t11\t\t\t1\t0\t10\t\t\t1\t1\t\t125\t55\t1024\t\t7\t32\t7\t32\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t65\t1\t1\tModStr1b\tModStr1b\t\t1\t1\t1\tModitem2allattrib\tModitem2allattrib\t\t\t0\r\nvitality\t3\t\t\t11\t\t\t1\t0\t10\t\t\t1\t1\t\t100\t55\t1024\t\t7\t32\t7\t32\t\t\t9\t\t\tmaxhp\tmaxstamina\t\t\t\t\t\t\t\t\t\t63\t1\t1\tModStr1c\tModStr1c\t\t1\t1\t1\tModitem2allattrib\tModitem2allattrib\t\t\t0\r\nstatpts\t4\t\t\t9\t\t\t1\t0\t10\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nnewskills\t5\t\t\t9\t\t\t1\t0\t8\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nhitpoints\t6\t\t\t32\t\t\t1\t0\t21\t\t\t\t\t\t\t\t1024\t8\t\t\t\t\t\t\t\t\t\t\t\t\t1\tmaxhp\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nmaxhp\t7\t\t\t32\t\t\t1\t0\t21\t\t1\t1\t1\t\t56\t20\t1024\t8\t8\t32\t9\t32\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t59\t1\t1\tModStr1u\tModStr1u\t\t\t\t\t\t\t\t\t0\r\nmana\t8\t\t\t32\t\t\t1\t0\t21\t\t\t\t\t\t\t\t1024\t8\t\t\t\t\t\t1\t\t\t\t\t\t\t1\tmaxmana\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nmaxmana\t9\t\t\t32\t\t\t1\t0\t21\t\t1\t1\t0\t\t81\t20\t1024\t8\t8\t32\t8\t32\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t55\t1\t1\tModStr1e\tModStr1e\t\t\t\t\t\t\t\t\t0\r\nstamina\t10\t\t\t32\t\t\t1\t0\t21\t\t\t\t\t\t\t\t1024\t8\t\t\t\t\t\t1\t\t\t\t\t\t\t1\tmaxstamina\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nmaxstamina\t11\t\t\t32\t\t\t1\t0\t21\t\t1\t1\t0\t\t75\t20\t1024\t8\t8\t32\t8\t32\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t51\t1\t1\tModStr5d\tModStr5d\t\t\t\t\t\t\t\t\t0\r\nlevel\t12\t\t\t9\t\t\t1\t0\t7\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nexperience\t13\t\t\t32\t\t\t1\t0\t32\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\ngold\t14\t\t\t32\t\t\t1\t0\t25\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\ngoldbank\t15\t\t\t32\t\t\t1\t0\t25\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nitem_armor_percent\t16\t\t1\t11\t\t\t\t\t\t\t\t\t\t\t47\t20\t1024\t\t9\t0\t9\t0\t\t\t13\t\t\tarmorclass\t\t\t\t\t\t\t\t\t\t\t74\t4\t1\tModstr2v\tModstr2v\t\t\t\t\t\t\t\t\t0\r\nitem_maxdamage_percent\t17\t\t1\t11\t\t\t\t\t\t\t\t\t\t\t45\t20\t1024\t\t9\t0\t9\t0\t\t\t13\t\t\tmaxdamage\tsecondary_maxdamage\titem_throw_maxdamage\t\t\t\t1\t\t\t\t\t129\t3\t0\tModStr2j\tModStr2j\t\t\t\t\t\t\t\t\t0\r\nitem_mindamage_percent\t18\t\t1\t11\t\t\t\t\t\t\t\t\t\t\t45\t20\t1024\t\t9\t0\t9\t0\t\t\t13\t\t\tmindamage\tsecondary_mindamage\titem_throw_mindamage\t\t\t\t1\t\t\t\t\t130\t3\t0\tModStr2k\tModStr2k\t\t\t\t\t\t\t\t\t0\r\ntohit\t19\t\t1\t16\t\t\t\t\t\t\t\t\t\t\t15\t10\t1024\t\t10\t\t10\t\t\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t115\t1\t1\tModStr1h\tModStr1h\t\t\t\t\t\t\t\t\t0\r\ntoblock\t20\t\t1\t10\t\t\t\t\t\t\t\t\t\t\t89\t204\t1024\t\t6\t0\t6\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t134\t2\t1\tModStr3g\tModStr3g\t\t\t\t\t\t\t\t\t0\r\nmindamage\t21\t\t1\t16\t\t\t\t\t\t\t\t\t\t\t122\t25\t1024\t\t6\t0\t6\t0\t\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t127\t1\t1\tModStr1g\tModStr1g\t\t\t\t\t\t\t\t\t0\r\nmaxdamage\t22\t\t1\t16\t\t\t\t\t\t\t\t\t\t\t94\t16\t1024\t\t7\t0\t7\t0\t\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t126\t1\t1\tModStr1f\tModStr1f\t\t\t\t\t\t\t\t\t0\r\nsecondary_mindamage\t23\t\t1\t16\t\t\t\t\t\t\t\t\t\t\t97\t15\t1024\t\t6\t0\t6\t0\t\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t124\t1\t1\tModStr1g\tModStr1g\t\t\t\t\t\t\t\t\t0\r\nsecondary_maxdamage\t24\t\t1\t16\t\t\t\t\t\t\t\t\t\t\t85\t11\t1024\t\t7\t0\t7\t0\t\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t123\t1\t1\tModStr1f\tModStr1f\t\t\t\t\t\t\t\t\t0\r\ndamagepercent\t25\t\t1\t12\t\t\t\t\t\t\t\t\t\t\t45\t40\t1024\t\t8\t0\t8\t0\t\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nmanarecovery\t26\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t8\t0\t8\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nmanarecoverybonus\t27\t\t1\t16\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t8\t0\t8\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t52\t2\t2\tModStr4g\tModStr4g\t\t\t\t\t\t\t\t\t0\r\nstaminarecoverybonus\t28\t\t1\t16\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t8\t0\t8\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t48\t2\t2\tModStr3v\tModStr3v\t\t\t\t\t\t\t\t\t0\r\nlastexp\t29\t\t\t32\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nnextexp\t30\t\t\t32\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\narmorclass\t31\t\t1\t16\t\t\t\t\t\t\t\t\t\t\t17\t10\t1024\t\t10\t10\t11\t10\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t71\t1\t1\tModStr1i\tModStr1i\t\t\t\t\t\t\t\t\t0\r\narmorclass_vs_missile\t32\t\t1\t16\t\t\t\t\t\t\t\t\t\t\t11\t5\t1024\t\t8\t0\t9\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t69\t1\t1\tModStr6a\tModStr6a\t\t\t\t\t\t\t\t\t0\r\narmorclass_vs_hth\t33\t\t1\t16\t\t\t\t\t\t\t\t\t\t\t13\t7\t1024\t\t8\t0\t8\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t70\t1\t1\tModStr6b\tModStr6b\t\t\t\t\t\t\t\t\t0\r\nnormal_damage_reduction\t34\t\t1\t10\t\t\t\t\t\t\t\t\t\t\t188\t200\t1024\t\t6\t0\t6\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t22\t3\t2\tModStr2u\tModStr2u\t\t\t\t\t\t\t\t\t0\r\nmagic_damage_reduction\t35\t\t1\t10\t\t\t\t\t\t\t\t\t\t\t397\t340\t1024\t\t6\t0\t6\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t21\t3\t2\tModStr2t\tModStr2t\t\t\t\t\t\t\t\t\t0\r\ndamageresist\t36\t\t1\t9\t\t\t\t\t\t\t\t\t\t\t152\t68\t1024\t\t8\t0\t8\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t22\t2\t2\tModStr2u\tModStr2u\t\t\t\t\t\t\t\t\t0\r\nmagicresist\t37\t\t1\t9\t\t\t\t\t\t\t\t\t\t\t164\t68\t1024\t\t8\t0\t8\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t41\t4\t2\tModStr1m\tModStr1m\t\t\t\t\t\t\t\t\t0\r\nmaxmagicresist\t38\t\t1\t9\t\t\t\t\t\t\t\t\t\t\t1091\t409\t1024\t\t5\t0\t5\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t46\t4\t1\tModStr5x\tModStr5x\t\t\t\t\t\t\t\t\t0\r\nfireresist\t39\t\t1\t9\t\t\t\t\t\t\t\t\t\t\t43\t20\t1024\t\t8\t0\t8\t50\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t36\t4\t2\tModStr1j\tModStr1j\t\t2\t19\t\tstrModAllResistances\tstrModAllResistances\t\t\t0\r\nmaxfireresist\t40\t\t1\t9\t\t\t\t\t\t\t\t\t\t\t584\t256\t1024\t\t5\t0\t5\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t42\t4\t1\tModStr5u\tModStr5u\t\t\t\t\t\t\t\t\t0\r\nlightresist\t41\t\t1\t9\t\t\t\t\t\t\t\t\t\t\t43\t20\t1024\t\t8\t0\t8\t50\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t38\t4\t2\tModStr1l\tModStr1l\t\t2\t19\t\tstrModAllResistances\tstrModAllResistances\t\t\t0\r\nmaxlightresist\t42\t\t1\t9\t\t\t\t\t\t\t\t\t\t\t584\t256\t1024\t\t5\t0\t5\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t43\t4\t1\tModStr5w\tModStr5w\t\t\t\t\t\t\t\t\t0\r\ncoldresist\t43\t\t1\t9\t\t\t\t\t\t\t\t\t\t\t43\t20\t1024\t\t8\t0\t8\t50\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t40\t4\t2\tModStr1k\tModStr1k\t\t2\t19\t\tstrModAllResistances\tstrModAllResistances\t\t\t0\r\nmaxcoldresist\t44\t\t1\t9\t\t\t\t\t\t\t\t\t\t\t584\t256\t1024\t\t5\t0\t5\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t44\t4\t1\tModStr5v\tModStr5v\t\t\t\t\t\t\t\t\t0\r\npoisonresist\t45\t\t1\t9\t\t\t\t\t\t\t\t\t\t\t43\t20\t1024\t\t8\t0\t8\t50\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t34\t4\t2\tModStr1n\tModStr1n\t\t2\t19\t\tstrModAllResistances\tstrModAllResistances\t\t\t0\r\nmaxpoisonresist\t46\t\t1\t9\t\t\t\t\t\t\t\t\t\t\t526\t256\t1024\t\t5\t0\t5\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t45\t4\t1\tModStr5y\tModStr5y\t\t\t\t\t\t\t\t\t0\r\ndamageaura\t47\t\t1\t16\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nfiremindam\t48\t\t1\t16\t\t\t\t\t\t\t\t\t\t\t11\t10\t1024\t\t8\t0\t8\t0\t\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t102\t1\t1\tModStr1p\tModStr1p\t\t\t\t\t\t\t\t\t0\r\nfiremaxdam\t49\t\t1\t16\t\t\t\t\t\t\t\t\t\t\t19\t10\t1024\t\t8\t0\t9\t0\t\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t101\t1\t1\tModStr1o\tModStr1o\t\t\t\t\t\t\t\t\t0\r\nlightmindam\t50\t\t1\t16\t\t\t\t\t\t\t\t\t\t\t12\t10\t1024\t\t6\t0\t6\t0\t\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t99\t1\t1\tModStr1r\tModStr1r\t\t\t\t\t\t\t\t\t0\r\nlightmaxdam\t51\t\t1\t16\t\t\t\t\t\t\t\t\t\t\t17\t10\t1024\t\t9\t0\t10\t0\t\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t98\t1\t1\tModStr1q\tModStr1q\t\t\t\t\t\t\t\t\t0\r\nmagicmindam\t52\t\t1\t16\t\t\t\t\t\t\t\t\t\t\t196\t20\t1024\t\t6\t0\t8\t0\t\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t104\t1\t1\tstrModMagicDamage\tstrModMagicDamage\t\t\t\t\t\t\t\t\t0\r\nmagicmaxdam\t53\t\t1\t16\t\t\t\t\t\t\t\t\t\t\t183\t20\t1024\t\t7\t0\t9\t0\t\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t103\t1\t1\tstrModMagicDamage\tstrModMagicDamage\t\t\t\t\t\t\t\t\t0\r\ncoldmindam\t54\t\t1\t16\t\t\t\t\t\t\t\t\t\t\t451\t512\t1024\t\t6\t0\t8\t0\t\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t96\t1\t1\tModStr1t\tModStr1t\t\t\t\t\t\t\t\t\t0\r\ncoldmaxdam\t55\t\t1\t16\t\t\t\t\t\t\t\t\t\t\t128\t340\t1024\t\t8\t0\t9\t0\t\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t95\t1\t1\tModStr1s\tModStr1s\t\t\t\t\t\t\t\t\t0\r\ncoldlength\t56\t\t1\t16\t\t\t\t\t\t\t\t\t\t\t77\t4\t1024\t\t8\t0\t8\t0\t\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\npoisonmindam\t57\t\t1\t19\t\t\t\t\t\t\t\t\t\t\t12\t28\t1024\t\t9\t0\t10\t0\t\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t92\t1\t1\tModStr4i\tModStr4i\t\t\t\t\t\t\t\t\t0\r\npoisonmaxdam\t58\t\t1\t19\t\t\t\t\t\t\t\t\t\t\t11\t34\t1024\t\t9\t0\t10\t0\t\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t91\t1\t1\tModStr4h\tModStr4h\t\t\t\t\t\t\t\t\t0\r\npoisonlength\t59\t\t1\t16\t\t\t\t\t\t\t\t\t\t\t0\t4\t1024\t\t8\t0\t9\t0\t\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nlifedrainmindam\t60\t\t1\t16\t\t\t\t\t\t\t\t\t\t\t1044\t341\t1024\t\t7\t0\t7\t0\t\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t88\t2\t1\tModStr2z\tModStr2z\t\t\t\t\t\t\t\t\t0\r\nlifedrainmaxdam\t61\t\t1\t16\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nmanadrainmindam\t62\t\t1\t16\t\t\t\t\t\t\t\t\t\t\t1179\t341\t1024\t\t7\t0\t7\t0\t\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t89\t2\t1\tModStr2y\tModStr2y\t\t\t\t\t\t\t\t\t0\r\nmanadrainmaxdam\t63\t\t1\t16\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nstamdrainmindam\t64\t\t1\t16\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nstamdrainmaxdam\t65\t\t1\t16\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nstunlength\t66\t\t\t16\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nvelocitypercent\t67\t1\t1\t10\t\t1\t\t\t\t\t\t\t\t\t\t\t1024\t\t7\t30\t7\t30\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nattackrate\t68\t1\t1\t10\t\t1\t\t\t\t\t\t\t\t\t\t\t1024\t\t7\t30\t7\t30\t\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nother_animrate\t69\t1\t1\t10\t\t1\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nquantity\t70\t\t1\t16\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t1\t1\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nvalue\t71\t\t1\t9\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t8\t100\t8\t100\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\ndurability\t72\t\t1\t9\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t8\t0\t9\t0\t\t\t\t\t\t\t\t\t1\tmaxdurability\t1\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nmaxdurability\t73\t\t1\t9\t\t\t\t\t\t\t\t\t\t\t9\t4\t1024\t\t8\t0\t8\t0\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nhpregen\t74\t\t\t\t\t\t\t\t\t\t\t\t\t\t451\t410\t1024\t\t6\t30\t6\t30\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t56\t1\t2\tModStr2l\tModStr2w\t\t\t\t\t\t\t\t\t0\r\nitem_maxdurability_percent\t75\t\t1\t7\t\t\t\t\t\t\t\t\t\t\t117\t10\t1024\t\t7\t20\t7\t20\t\t\t13\t\t\tmaxdurability\t\t\t\t\t\t\t\t\t\t\t3\t2\t2\tModStr2i\tModStr2i\t\t\t\t\t\t\t\t\t0\r\nitem_maxhp_percent\t76\t\t1\t16\t\t\t\t\t\t\t\t\t\t\t32093\t204\t1024\t\t6\t10\t6\t10\t\t\t11\t\t\tmaxhp\t\t\t\t\t\t\t\t\t\t\t58\t2\t2\tModStr2g\tModStr2g\t\t\t\t\t\t\t\t\t0\r\nitem_maxmana_percent\t77\t\t1\t16\t\t\t\t\t\t\t\t\t\t\t56452\t204\t1024\t\t6\t10\t6\t10\t\t\t11\t\t\tmaxmana\t\t\t\t\t\t\t\t\t\t\t54\t2\t2\tModStr2h\tModStr2h\t\t\t\t\t\t\t\t\t0\r\nitem_attackertakesdamage\t78\t\t1\t16\t\t\t\t\t\t\t1\t\t\t\t112\t128\t1024\t\t7\t0\t7\t0\t\t\t\t\t\t\t\t\t\t\t\t\tdamagedinmelee\t6\t\t\t13\t3\t2\tModStr1v\tModStr1v\t\t\t\t\t\t\t\t\t0\r\nitem_goldbonus\t79\t\t1\t10\t\t\t\t\t\t\t\t\t\t\t187\t34\t1024\t\t9\t100\t9\t100\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t10\t2\t1\tModStr1w\tModStr1w\t\t\t\t\t\t\t\t\t0\r\nitem_magicbonus\t80\t\t1\t9\t\t\t\t\t\t\t\t\t\t\t577\t102\t1024\t\t8\t100\t8\t100\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t8\t2\t1\tModStr1x\tModStr1x\t\t\t\t\t\t\t\t\t0\r\nitem_knockback\t81\t\t1\t8\t\t\t\t\t\t\t1\t\t\t\t105\t0\t1024\t\t7\t0\t7\t0\t\t\t\t\t\t\t\t\t\t\t\t1\tdomeleedamage\t7\tdomissiledamage\t7\t76\t3\t0\tModStr1y\tModStr1y\t\t\t\t\t\t\t\t\t0\r\nitem_timeduration\t82\t\t1\t10\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t9\t20\t9\t20\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nitem_addclassskills\t83\t\t1\t4\t3\t\t\t\t\t\t1\t\t\t\t49523\t1560\t1024\t\t3\t0\t3\t0\t3\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t150\t13\t1\tModStr3a\tModStr3a\t\t\t\t\t\t\t\t\t0\r\nunsentparam1\t84\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t3\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nitem_addexperience\t85\t\t1\t9\t\t\t\t\t\t\t\t\t\t\t36015\t519\t1024\t\t3\t0\t9\t50\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t11\t4\t1\tModitem2ExpG\tModitem2ExpG\t\t\t\t\t\t\t\t\t0\r\nitem_healafterkill\t86\t\t1\t7\t\t\t\t\t\t\t1\t\t\t\t30\t101\t1024\t\t3\t0\t7\t0\t\t\t\t\t\t\t\t\t\t\t\t\tkill\t28\t\t\t16\t1\t1\tModitemHPaK\tModitemHPaK\t\t\t\t\t\t\t\t\t0\r\nitem_reducedprices\t87\t\t\t8\t\t\t\t\t\t\t\t\t\t\t18957\t203\t1024\t\t3\t0\t7\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t8\t2\t2\tModitemRedVendP\tModitemRedVendP\t\t\t\t\t\t\t\t\t0\r\nitem_doubleherbduration\t88\t\t1\t2\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t1\t0\t1\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nitem_lightradius\t89\t\t1\t5\t\t\t\t\t\t\t1\t\t\t\t15\t51\t1024\t\t4\t4\t4\t4\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t6\t1\t1\tModStr3f\tModStr3f\t\t\t\t\t\t\t\t\t0\r\nitem_lightcolor\t90\t\t1\t24\t\t\t\t\t\t\t1\t\t\t\t155\t0\t1024\t\t5\t0\t24\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nitem_req_percent\t91\t\t1\t8\t\t\t\t\t\t\t\t\t\t\t26\t-34\t1024\t\t8\t100\t8\t100\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\t4\t2\tModStr3h\tModStr3h\t\t\t\t\t\t\t\t\t0\r\nitem_levelreq\t92\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t6\t20\t7\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nitem_fasterattackrate\t93\t1\t1\t9\t\t\t\t\t\t\t\t\t\t\t1042\t156\t1024\t\t7\t20\t7\t20\t\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t145\t4\t1\tModStr4m\tModStr4m\t\t\t\t\t\t\t\t\t0\r\nitem_levelreqpct\t94\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t7\t20\t7\t64\t\t\t13\t\t\titem_levelreq\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nlastblockframe\t95\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t6\t20\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nitem_fastermovevelocity\t96\t1\t1\t9\t\t\t\t\t\t\t\t\t\t\t4083\t156\t1024\t\t7\t20\t7\t20\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t148\t4\t1\tModStr4s\tModStr4s\t\t\t\t\t\t\t\t\t0\r\nitem_nonclassskill\t97\t\t1\t15\t9\t\t\t\t\t\t1\t\t\t1\t181\t327\t1024\t\t7\t20\t6\t0\t9\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t81\t28\t\t\t\t\t\t\t\t\t\t\t\t0\r\nstate\t98\t1\t\t1\t8\t\t\t\t\t\t1\t\t\t\t415\t64\t1024\t\t6\t20\t1\t\t8\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nitem_fastergethitrate\t99\t1\t1\t9\t\t\t\t\t\t\t\t\t\t\t1065\t72\t1024\t\t7\t20\t7\t20\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t139\t4\t1\tModStr4p\tModStr4p\t\t\t\t\t\t\t\t\t0\r\nmonster_playercount\t100\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t7\t20\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nskill_poison_override_length\t101\t\t\t8\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t6\t20\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nitem_fasterblockrate\t102\t1\t1\t9\t\t\t\t\t\t\t\t\t\t\t1484\t72\t1024\t\t7\t20\t7\t20\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t136\t4\t1\tModStr4y\tModStr4y\t\t\t\t\t\t\t\t\t0\r\nskill_bypass_undead\t103\t\t\t1\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t7\t20\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nskill_bypass_demons\t104\t\t\t1\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t6\t20\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nitem_fastercastrate\t105\t1\t1\t9\t\t\t\t\t\t\t\t\t\t\t3876\t156\t1024\t\t7\t20\t7\t20\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t142\t4\t1\tModStr4v\tModStr4v\t\t\t\t\t\t\t\t\t0\r\nskill_bypass_beasts\t106\t\t\t1\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t7\t20\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nitem_singleskill\t107\t\t1\t15\t9\t\t\t\t\t\t1\t\t\t1\t181\t256\t1024\t\t14\t0\t3\t0\t9\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t81\t27\t\t\t\t\t\t\t\t\t\t\t\t0\r\nitem_restinpeace\t108\t\t\t1\t\t\t\t\t\t\t1\t\t\t\t1987\t0\t1024\t\t14\t0\t1\t0\t\t\t\t\t\t\t\t\t\t\t\t1\tkill\t29\t\t\t81\t3\t0\tModitemSMRIP\tModitemSMRIP\t\t\t\t\t\t\t\t\t0\r\ncurse_resistance\t109\t\t\t9\t\t\t\t\t\t\t\t\t\t\t159\t33\t1024\t\t14\t0\t9\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nitem_poisonlengthresist\t110\t\t1\t9\t\t\t\t\t\t\t\t\t\t\t27\t10\t1024\t\t8\t20\t8\t20\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t18\t2\t2\tModStr3r\tModStr3r\t\t\t\t\t\t\t\t\t0\r\nitem_normaldamage\t111\t\t1\t8\t\t\t\t\t\t\t\t\t\t\t94\t100\t1024\t\t7\t20\t9\t20\t\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t122\t1\t2\tModStr5b\tModStr5b\t\t\t\t\t\t\t\t\t0\r\nitem_howl\t112\t\t1\t8\t\t\t\t\t\t\t1\t\t\t\t55\t10\t1024\t\t7\t-1\t7\t-1\t\t\t\t\t\t\t\t\t\t\t\t1\tdomeleedamage\t8\tdomissiledamage\t8\t79\t5\t2\tModStr3u\tModStr3u\t\t\t\t\t\t\t\t\t0\r\nitem_stupidity\t113\t\t1\t8\t\t\t\t\t\t\t1\t\t\t\t332\t1024\t1024\t\t7\t0\t7\t0\t\t\t\t\t\t\t\t\t\t\t\t1\tdomeleedamage\t9\tdomissiledamage\t9\t80\t12\t2\tModStr6d\tModStr6d\t\t\t\t\t\t\t\t\t0\r\nitem_damagetomana\t114\t\t1\t7\t\t\t\t\t\t\t1\t\t\t\t43\t20\t1024\t\t6\t0\t6\t0\t\t\t\t\t\t\t\t\t\t\t\t\tdamagedinmelee\t13\tdamagedbymissile\t13\t11\t2\t1\tModStr3w\tModStr3w\t\t\t\t\t\t\t\t\t0\r\nitem_ignoretargetac\t115\t\t1\t2\t\t\t\t\t\t\t\t\t\t\t1088\t1024\t1024\t\t1\t0\t1\t0\t\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t119\t3\t0\tModStr3y\tModStr3y\t\t\t\t\t\t\t\t\t0\r\nitem_fractionaltargetac\t116\t\t1\t8\t\t\t\t\t\t\t\t\t\t\t67\t20\t1024\t\t7\t0\t7\t0\t\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t118\t20\t1\tModStr5o\tModStr5o\t\t\t\t\t\t\t\t\t0\r\nitem_preventheal\t117\t\t1\t8\t\t\t\t\t\t\t\t\t\t\t48\t50\t1024\t\t7\t0\t7\t0\t\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t81\t3\t0\tModStr4a\tModStr4a\t\t\t\t\t\t\t\t\t0\r\nitem_halffreezeduration\t118\t\t1\t2\t\t\t\t\t\t\t\t\t\t\t5096\t988\t1024\t\t1\t0\t1\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t19\t3\t0\tModStr4b\tModStr4b\t\t\t\t\t\t\t\t\t0\r\nitem_tohit_percent\t119\t\t1\t12\t\t\t\t\t\t\t\t\t\t\t981\t40\t1024\t\t9\t20\t9\t20\t\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t117\t2\t1\tModStr4c\tModStr4c\t\t\t\t\t\t\t\t\t0\r\nitem_damagetargetac\t120\t\t1\t8\t\t\t\t\t\t\t\t\t\t\t24\t-20\t1024\t\t7\t128\t7\t128\t\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t75\t1\t1\tModStr4d\tModStr4d\t\t\t\t\t\t\t\t\t0\r\nitem_demondamage_percent\t121\t\t1\t12\t\t\t\t\t\t\t\t\t\t\t19\t12\t1024\t\t9\t20\t9\t20\t\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t112\t4\t1\tModStr4e\tModStr4e\t\t\t\t\t\t\t\t\t0\r\nitem_undeaddamage_percent\t122\t\t1\t12\t\t\t\t\t\t\t\t\t\t\t13\t12\t1024\t\t9\t20\t9\t20\t\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t108\t4\t1\tModStr4f\tModStr4f\t\t\t\t\t\t\t\t\t0\r\nitem_demon_tohit\t123\t\t1\t13\t\t\t\t\t\t\t\t\t\t\t15\t7\t1024\t\t10\t128\t10\t128\t\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t110\t1\t1\tModStr4j\tModStr4j\t\t\t\t\t\t\t\t\t0\r\nitem_undead_tohit\t124\t\t1\t13\t\t\t\t\t\t\t\t\t\t\t11\t7\t1024\t\t10\t128\t10\t128\t\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t106\t1\t1\tModStr4k\tModStr4k\t\t\t\t\t\t\t\t\t0\r\nitem_throwable\t125\t\t1\t2\t\t\t\t\t\t\t\t\t\t\t82\t1024\t1024\t\t1\t0\t1\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t5\t3\t0\tModStr5a\tModStr5a\t\t\t\t\t\t\t\t\t0\r\nitem_elemskill\t126\t\t1\t3\t3\t\t\t\t\t\t1\t\t\t\t76\t1024\t1024\t\t4\t0\t3\t0\t3\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t157\t1\t1\tModStr3i\tModStr3i\t\t\t\t\t\t\t\t\t0\r\nitem_allskills\t127\t\t1\t8\t\t\t\t\t\t\t1\t\t\t\t15123\t4096\t1024\t\t3\t0\t3\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t158\t1\t1\tModStr3k\tModStr3k\t\t\t\t\t\t\t\t\t0\r\nitem_attackertakeslightdamage\t128\t\t1\t6\t\t\t\t\t\t\t1\t\t\t\t4\t102\t1024\t\t5\t0\t5\t0\t\t\t\t\t\t\t\t\t\t\t\t\tdamagedinmelee\t10\t\t\t14\t3\t2\tModStr3j\tModStr3j\t\t\t\t\t\t\t\t\t0\r\nironmaiden_level\t129\t\t1\t10\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nlifetap_level\t130\t\t1\t10\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nthorns_percent\t131\t\t\t12\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nbonearmor\t132\t\t1\t32\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nbonearmormax\t133\t\t1\t32\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nitem_freeze\t134\t\t1\t5\t\t\t\t\t\t\t1\t\t\t\t666\t12\t1024\t\t5\t0\t5\t0\t\t\t\t\t\t\t\t\t\t\t\t1\tdomeleedamage\t14\tdomissiledamage\t14\t78\t12\t2\tModStr3l\tModStr3l\t\t\t\t\t\t\t\t\t0\r\nitem_openwounds\t135\t\t1\t7\t\t\t\t\t\t\t1\t\t\t\t23\t10\t1024\t\t7\t0\t7\t0\t\t\t\t\t\t\t\t\t\t\t\t1\tdomeleedamage\t15\tdomissiledamage\t15\t83\t2\t1\tModStr3m\tModStr3m\t\t\t\t\t\t\t\t\t0\r\nitem_crushingblow\t136\t\t1\t7\t\t\t\t\t\t\t1\t\t\t\t98\t40\t1024\t\t7\t0\t7\t0\t\t\t\t\t\t\t\t\t\t\t\t1\tdomeleedamage\t16\tdomissiledamage\t16\t87\t2\t1\tModStr5c\tModStr5c\t\t\t\t\t\t\t\t\t0\r\nitem_kickdamage\t137\t\t1\t7\t\t\t\t\t\t\t\t\t\t\t77\t51\t1024\t\t7\t0\t7\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t121\t1\t1\tModStr5e\tModStr5e\t\t\t\t\t\t\t\t\t0\r\nitem_manaafterkill\t138\t\t1\t7\t\t\t\t\t\t\t1\t\t\t\t17\t102\t1024\t\t7\t0\t7\t0\t\t\t\t\t\t\t\t\t\t\t\t\tkill\t17\t\t\t16\t1\t1\tModStr5f\tModStr5f\t\t\t\t\t\t\t\t\t0\r\nitem_healafterdemonkill\t139\t\t1\t7\t\t\t\t\t\t\t1\t\t\t\t18\t102\t1024\t\t7\t0\t7\t0\t\t\t\t\t\t\t\t\t\t\t\t\tkill\t18\t\t\t15\t1\t1\tModStr6c\tModStr6c\t\t\t\t\t\t\t\t\t0\r\nitem_extrablood\t140\t\t1\t7\t\t\t\t\t\t\t\t\t\t\t15\t10\t1024\t\t7\t0\t7\t0\t\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nitem_deadlystrike\t141\t\t1\t7\t\t\t\t\t\t\t\t\t\t\t31\t25\t1024\t\t7\t0\t7\t0\t\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t85\t2\t1\tModStr5q\tModStr5q\t\t\t\t\t\t\t\t\t0\r\nitem_absorbfire_percent\t142\t\t1\t7\t\t\t\t\t\t\t\t\t\t\t5486\t102\t1024\t\t7\t0\t7\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t23\t2\t2\tModStr5g\tModStr5g\t\t\t\t\t\t\t\t\t0\r\nitem_absorbfire\t143\t\t1\t7\t\t\t\t\t\t\t\t\t\t\t1739\t204\t1024\t\t7\t0\t7\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t27\t1\t1\tModStr5h\tModStr5h\t\t\t\t\t\t\t\t\t0\r\nitem_absorblight_percent\t144\t\t1\t7\t\t\t\t\t\t\t\t\t\t\t5486\t102\t1024\t\t7\t0\t7\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t24\t2\t2\tModStr5i\tModStr5i\t\t\t\t\t\t\t\t\t0\r\nitem_absorblight\t145\t\t1\t7\t\t\t\t\t\t\t\t\t\t\t1739\t204\t1024\t\t7\t0\t7\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t29\t1\t1\tModStr5j\tModStr5j\t\t\t\t\t\t\t\t\t0\r\nitem_absorbmagic_percent\t146\t\t1\t7\t\t\t\t\t\t\t\t\t\t\t5486\t102\t1024\t\t7\t0\t7\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t26\t2\t2\tModStr5k\tModStr5k\t\t\t\t\t\t\t\t\t0\r\nitem_absorbmagic\t147\t\t1\t7\t\t\t\t\t\t\t\t\t\t\t1739\t204\t1024\t\t7\t0\t7\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t33\t1\t1\tModStr5l\tModStr5l\t\t\t\t\t\t\t\t\t0\r\nitem_absorbcold_percent\t148\t\t1\t7\t\t\t\t\t\t\t\t\t\t\t5486\t102\t1024\t\t7\t0\t7\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t25\t2\t2\tModStr5m\tModStr5m\t\t\t\t\t\t\t\t\t0\r\nitem_absorbcold\t149\t\t1\t7\t\t\t\t\t\t\t\t\t\t\t1739\t204\t1024\t\t7\t0\t7\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t31\t1\t1\tModStr5n\tModStr5n\t\t\t\t\t\t\t\t\t0\r\nitem_slow\t150\t\t1\t7\t\t\t\t\t\t\t1\t\t\t\t101\t40\t1024\t\t7\t0\t7\t0\t\t\t\t\t\t\t\t\t\t\t\t1\tdomeleedamage\t19\tdomissiledamage\t19\t77\t2\t2\tModStr5r\tModStr5r\t\t\t\t\t\t\t\t\t0\r\nitem_aura\t151\t\t1\t\t\t\t\t\t\t\t1\t\t\t\t\t\t1024\t\t7\t0\t5\t0\t9\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t159\t16\t0\tModitemAura\tModitemAura\t\t\t\t\t\t\t\t\t0\r\nitem_indesctructible\t152\t\t1\t1\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t7\t0\t1\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t160\t3\t0\tModStre9s\tModStre9s\t\t\t\t\t\t\t\t\t0\r\nitem_cannotbefrozen\t153\t\t1\t2\t\t\t\t\t\t\t\t\t\t\t15011\t2048\t1024\t\t1\t\t1\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t20\t3\t0\tModStr5z\tModStr5z\t\t\t\t\t\t\t\t\t0\r\nitem_staminadrainpct\t154\t\t1\t7\t\t\t\t\t\t\t\t\t\t\t102\t20\t1024\t\t7\t20\t7\t20\t\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t49\t2\t1\tModStr6e\tModStr6e\t\t\t\t\t\t\t\t\t0\r\nitem_reanimate\t155\t\t\t7\t10\t\t\t\t\t\t1\t\t\t\t\t\t1024\t\t7\t0\t7\t0\t10\t\t\t\t\t\t\t\t\t\t\t1\tkill\t31\t\t\t17\t23\t1\tModitemreanimas\tModitemreanimas\t\t\t\t\t\t\t\t\t0\r\nitem_pierce\t156\t\t1\t7\t\t\t\t\t\t\t\t\t\t\t1924\t2048\t1024\t\t7\t0\t7\t0\t\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t132\t3\t0\tModStr6g\tModStr6g\t\t\t\t\t\t\t\t\t0\r\nitem_magicarrow\t157\t\t1\t7\t\t\t\t\t\t\t\t\t\t\t511\t1024\t1024\t\t7\t0\t7\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t131\t3\t0\tModStr6h\tModStr6h\t\t\t\t\t\t\t\t\t0\r\nitem_explosivearrow\t158\t\t1\t7\t\t\t\t\t\t\t\t\t\t\t492\t1536\t1024\t\t7\t0\t7\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t133\t3\t0\tModStr6i\tModStr6i\t\t\t\t\t\t\t\t\t0\r\nitem_throw_mindamage\t159\t\t1\t6\t\t\t\t\t\t\t\t\t\t\t76\t128\t1024\t\t6\t0\t6\t0\t\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nitem_throw_maxdamage\t160\t\t1\t7\t\t\t\t\t\t\t\t\t\t\t88\t128\t1024\t\t7\t0\t7\t0\t\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nskill_handofathena\t161\t\t1\t12\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nskill_staminapercent\t162\t\t1\t13\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t1\t\t\tmaxstamina\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nskill_passive_staminapercent\t163\t\t1\t12\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t1\t\t\tmaxstamina\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nskill_concentration\t164\t\t1\t12\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nskill_enchant\t165\t\t1\t12\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nskill_pierce\t166\t\t1\t12\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nskill_conviction\t167\t\t1\t12\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nskill_chillingarmor\t168\t\t1\t12\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nskill_frenzy\t169\t\t1\t12\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nskill_decrepify\t170\t\t1\t12\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nskill_armor_percent\t171\t\t1\t16\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nalignment\t172\t1\t\t2\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\ntarget0\t173\t\t\t32\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\ntarget1\t174\t\t\t32\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\ngoldlost\t175\t\t\t24\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nconversion_level\t176\t\t\t8\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nconversion_maxhp\t177\t\t\t16\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nunit_dooverlay\t178\t\t\t16\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nattack_vs_montype\t179\t\t\t9\t10\t\t\t\t\t\t\t\t\t\t19\t14\t1024\t\t3\t0\t9\t\t10\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t108\t22\t1\tModitemAttratvsM\tModitemAttratvsM\t\t\t\t\t\t\t\t\t0\r\ndamage_vs_montype\t180\t\t\t9\t10\t\t\t\t\t\t\t\t\t\t27\t17\t1024\t\t3\t0\t9\t\t10\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t106\t22\t1\tModitemdamvsm\tModitemdamvsm\t\t\t\t\t\t\t\t\t0\r\nfade\t181\t1\t\t7\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t14\t0\t3\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\narmor_override_percent\t182\t1\t1\t8\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t14\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nunused183\t183\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t14\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nunused184\t184\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t14\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nunused185\t185\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t14\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nunused186\t186\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t14\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nunused187\t187\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t14\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nitem_addskill_tab\t188\t\t1\t3\t6\t\t\t\t\t\t1\t\t\t\t11042\t768\t1024\t\t10\t0\t3\t0\t16\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t151\t14\t\tStrSklTabItem1\tStrSklTabItem1\t\t\t\t\t\t\t\t\t0\r\nunused189\t189\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t10\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nunused190\t190\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t10\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nunused191\t191\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t10\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nunused192\t192\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t10\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nunused193\t193\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t10\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nitem_numsockets\t194\t\t1\t4\t\t\t\t\t\t\t\t\t\t\t38\t170\t1024\t\t4\t0\t4\t0\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nitem_skillonattack\t195\t\t1\t7\t16\t\t\t\t\t\t1\t\t\t2\t190\t256\t1024\t\t21\t0\t7\t0\t16\t\t\t\t\t\t\t\t\t\t\t1\tdomeleeattack\t20\tdomissileattack\t20\t160\t15\t\tItemExpansiveChancX\tItemExpansiveChancX\t\t\t\t\t\t\t\t\t0\r\nitem_skillonkill\t196\t\t1\t7\t16\t\t\t\t\t\t1\t\t\t2\t85\t19\t1024\t\t21\t0\t7\t0\t16\t\t\t\t\t\t\t\t\t\t\t1\tkill\t20\t\t\t160\t15\t\tModitemskonKill\tModitemskonKill\t\t\t\t\t\t\t\t\t0\r\nitem_skillondeath\t197\t\t1\t7\t16\t\t\t\t\t\t1\t\t\t2\t11\t9\t1024\t\t21\t0\t7\t0\t16\t\t\t\t\t\t\t\t\t\t\t\tkilled\t30\t\t\t160\t15\t\tModitemskondeath\tModitemskondeath\t\t\t\t\t\t\t\t\t0\r\nitem_skillonhit\t198\t\t1\t7\t16\t\t\t\t\t\t1\t\t\t2\t190\t256\t1024\t\t21\t0\t7\t0\t16\t\t\t\t\t\t\t\t\t\t\t1\tdomeleedamage\t20\tdomissiledamage\t20\t160\t15\t\tItemExpansiveChanc1\tItemExpansiveChanc1\t\t\t\t\t\t\t\t\t0\r\nitem_skillonlevelup\t199\t\t1\t7\t16\t\t\t\t\t\t1\t\t\t2\t7\t6\t1024\t\t21\t0\t7\t0\t16\t\t\t\t\t\t\t\t\t\t\t\tlevelup\t30\t\t\t160\t15\t\tModitemskonLevel\tModitemskonLevel\t\t\t\t\t\t\t\t\t0\r\nunused200\t200\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t21\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nitem_skillongethit\t201\t\t1\t7\t16\t\t\t\t\t\t1\t\t\t2\t190\t256\t1024\t\t21\t0\t7\t0\t16\t\t\t\t\t\t\t\t\t\t\t\tdamagedinmelee\t21\tdamagedbymissile\t21\t160\t15\t\tItemExpansiveChanc2\tItemExpansiveChanc2\t\t\t\t\t\t\t\t\t0\r\nunused202\t202\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t21\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nunused203\t203\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t21\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nitem_charged_skill\t204\t\t1\t30\t\t\t\t\t\t\t1\t\t\t3\t401\t256\t1024\t\t30\t0\t16\t0\t16\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t1\t24\t\tModStre10d\tModStre10d\t\t\t\t\t\t\t\t\t0\r\nunused204\t205\t\t1\t30\t\t\t\t\t\t\t\t\t\t3\t401\t256\t1024\t\t30\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nunused205\t206\t\t1\t30\t\t\t\t\t\t\t\t\t\t3\t401\t256\t1024\t\t30\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nunused206\t207\t\t1\t30\t\t\t\t\t\t\t\t\t\t3\t401\t256\t1024\t\t30\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nunused207\t208\t\t1\t30\t\t\t\t\t\t\t\t\t\t3\t401\t256\t1024\t\t30\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nunused208\t209\t\t1\t30\t\t\t\t\t\t\t\t\t\t3\t401\t256\t1024\t\t30\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nunused209\t210\t\t1\t30\t\t\t\t\t\t\t\t\t\t3\t401\t256\t1024\t\t30\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nunused210\t211\t\t1\t30\t\t\t\t\t\t\t\t\t\t3\t401\t256\t1024\t\t30\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nunused211\t212\t\t1\t30\t\t\t\t\t\t\t\t\t\t3\t401\t256\t1024\t\t30\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nunused212\t213\t\t1\t30\t\t\t\t\t\t\t\t\t\t3\t401\t256\t1024\t\t30\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nitem_armor_perlevel\t214\t\t1\t6\t\t\t\t\t\t\t\t\t\t\t43\t42\t1024\t\t6\t0\t6\t0\t\t\t4\t3\tlevel\tarmorclass\t\t\t\t\t\t\t\t\t\t\t72\t6\t1\tModStr1i\tModStr1i\tincreaseswithplaylevelX\t\t\t\t\t\t\t\t0\r\nitem_armorpercent_perlevel\t215\t\t1\t6\t\t\t\t\t\t\t\t\t\t\t87\t100\t1024\t\t6\t0\t6\t0\t\t\t5\t3\tlevel\tarmorclass\t\t\t\t\t\t\t\t\t\t\t73\t8\t1\tModstr2v\tModstr2v\tincreaseswithplaylevelX\t\t\t\t\t\t\t\t0\r\nitem_hp_perlevel\t216\t\t1\t6\t\t\t\t\t\t\t\t\t\t\t92\t64\t1024\t8\t6\t0\t6\t0\t\t\t2\t3\tlevel\tmaxhp\t\t\t\t\t\t\t\t\t\t\t57\t6\t1\tModStr1u\tModStr1u\tincreaseswithplaylevelX\t\t\t\t\t\t\t\t0\r\nitem_mana_perlevel\t217\t\t1\t6\t\t\t\t\t\t\t\t\t\t\t90\t128\t1024\t8\t6\t0\t6\t0\t\t\t2\t3\tlevel\tmaxmana\t\t\t\t\t\t\t\t\t\t\t53\t6\t1\tModStr1e\tModStr1e\tincreaseswithplaylevelX\t\t\t\t\t\t\t\t0\r\nitem_maxdamage_perlevel\t218\t\t1\t6\t\t\t\t\t\t\t\t\t\t\t54\t204\t1024\t\t6\t0\t6\t0\t\t\t4\t3\tlevel\tmaxdamage\tsecondary_maxdamage\titem_throw_maxdamage\t\t\t\t1\t\t\t\t\t125\t6\t1\tModStr1f\tModStr1f\tincreaseswithplaylevelX\t\t\t\t\t\t\t\t0\r\nitem_maxdamage_percent_perlevel\t219\t\t1\t6\t\t\t\t\t\t\t\t\t\t\t86\t100\t1024\t\t6\t0\t6\t0\t\t\t5\t3\tlevel\tmaxdamage\tsecondary_maxdamage\titem_throw_maxdamage\t\t\t\t1\t\t\t\t\t128\t8\t1\tModStr2j\tModStr2j\tincreaseswithplaylevelX\t\t\t\t\t\t\t\t0\r\nitem_strength_perlevel\t220\t\t1\t6\t\t\t\t\t\t\t\t\t\t\t132\t128\t1024\t\t6\t0\t6\t0\t\t\t2\t3\tlevel\tstrength\t\t\t\t\t\t\t\t\t\t\t66\t6\t1\tModStr1a\tModStr1a\tincreaseswithplaylevelX\t\t\t\t\t\t\t\t0\r\nitem_dexterity_perlevel\t221\t\t1\t6\t\t\t\t\t\t\t\t\t\t\t132\t128\t1024\t\t6\t0\t6\t0\t\t\t2\t3\tlevel\tdexterity\t\t\t\t\t\t\t\t\t\t\t64\t6\t1\tModStr1b\tModStr1b\tincreaseswithplaylevelX\t\t\t\t\t\t\t\t0\r\nitem_energy_perlevel\t222\t\t1\t6\t\t\t\t\t\t\t\t\t\t\t105\t128\t1024\t\t6\t0\t6\t0\t\t\t2\t3\tlevel\tenergy\t\t\t\t\t\t\t\t\t\t\t60\t6\t1\tModStr1d\tModStr1d\tincreaseswithplaylevelX\t\t\t\t\t\t\t\t0\r\nitem_vitality_perlevel\t223\t\t1\t6\t\t\t\t\t\t\t\t\t\t\t105\t128\t1024\t\t6\t0\t6\t0\t\t\t2\t3\tlevel\tvitality\t\t\t\t\t\t\t\t\t\t\t62\t6\t1\tModStr1c\tModStr1c\tincreaseswithplaylevelX\t\t\t\t\t\t\t\t0\r\nitem_tohit_perlevel\t224\t\t1\t6\t\t\t\t\t\t\t\t\t\t\t53\t20\t1024\t\t6\t0\t6\t0\t\t\t2\t1\tlevel\ttohit\t\t\t\t\t\t1\t\t\t\t\t114\t6\t1\tModStr1h\tModStr1h\tincreaseswithplaylevelX\t\t\t\t\t\t\t\t0\r\nitem_tohitpercent_perlevel\t225\t\t1\t6\t\t\t\t\t\t\t\t\t\t\t10\t256\t1024\t\t6\t0\t6\t0\t\t\t2\t1\tlevel\titem_tohit_percent\t\t\t\t\t\t1\t\t\t\t\t116\t7\t1\tModStr4c\tModStr4c\tincreaseswithplaylevelX\t\t\t\t\t\t\t\t0\r\nitem_cold_damagemax_perlevel\t226\t\t1\t6\t\t\t\t\t\t\t\t\t\t\t1058\t340\t1024\t\t6\t0\t6\t0\t\t\t2\t3\tlevel\tcoldmaxdam\t\t\t\t\t\t1\t\t\t\t\t94\t6\t1\tModStr1s\tModStr1s\tincreaseswithplaylevelX\t\t\t\t\t\t\t\t0\r\nitem_fire_damagemax_perlevel\t227\t\t1\t6\t\t\t\t\t\t\t\t\t\t\t49\t128\t1024\t\t6\t0\t6\t0\t\t\t2\t3\tlevel\tfiremaxdam\t\t\t\t\t\t1\t\t\t\t\t100\t6\t1\tModStr1o\tModStr1o\tincreaseswithplaylevelX\t\t\t\t\t\t\t\t0\r\nitem_ltng_damagemax_perlevel\t228\t\t1\t6\t\t\t\t\t\t\t\t\t\t\t49\t128\t1024\t\t6\t0\t6\t0\t\t\t2\t3\tlevel\tlightmaxdam\t\t\t\t\t\t1\t\t\t\t\t97\t6\t1\tModStr1q\tModStr1q\tincreaseswithplaylevelX\t\t\t\t\t\t\t\t0\r\nitem_pois_damagemax_perlevel\t229\t\t1\t6\t\t\t\t\t\t\t\t\t\t\t49\t128\t1024\t\t6\t0\t6\t0\t\t\t2\t3\tlevel\tpoisonmaxdam\t\t\t\t\t\t1\t\t\t\t\t90\t6\t1\tModStr4h\tModStr4h\tincreaseswithplaylevelX\t\t\t\t\t\t\t\t0\r\nitem_resist_cold_perlevel\t230\t\t1\t6\t\t\t\t\t\t\t\t\t\t\t101\t128\t1024\t\t6\t0\t6\t0\t\t\t2\t3\tlevel\tcoldresist\t\t\t\t\t\t\t\t\t\t\t39\t7\t2\tModStr1k\tModStr1k\tincreaseswithplaylevelX\t\t\t\t\t\t\t\t0\r\nitem_resist_fire_perlevel\t231\t\t1\t6\t\t\t\t\t\t\t\t\t\t\t101\t128\t1024\t\t6\t0\t6\t0\t\t\t2\t3\tlevel\tfireresist\t\t\t\t\t\t\t\t\t\t\t35\t7\t2\tModStr1j\tModStr1j\tincreaseswithplaylevelX\t\t\t\t\t\t\t\t0\r\nitem_resist_ltng_perlevel\t232\t\t1\t6\t\t\t\t\t\t\t\t\t\t\t101\t128\t1024\t\t6\t0\t6\t0\t\t\t2\t3\tlevel\tlightresist\t\t\t\t\t\t\t\t\t\t\t37\t7\t2\tModStr1l\tModStr1l\tincreaseswithplaylevelX\t\t\t\t\t\t\t\t0\r\nitem_resist_pois_perlevel\t233\t\t1\t6\t\t\t\t\t\t\t\t\t\t\t101\t128\t1024\t\t6\t0\t6\t0\t\t\t2\t3\tlevel\tpoisonresist\t\t\t\t\t\t\t\t\t\t\t33\t7\t2\tModStr1n\tModStr1n\tincreaseswithplaylevelX\t\t\t\t\t\t\t\t0\r\nitem_absorb_cold_perlevel\t234\t\t1\t6\t\t\t\t\t\t\t\t\t\t\t207\t340\t1024\t\t6\t0\t6\t0\t\t\t2\t3\tlevel\titem_absorbcold\t\t\t\t\t\t\t\t\t\t\t32\t6\t1\tModStre9p\tModStre9p\tincreaseswithplaylevelX\t\t\t\t\t\t\t\t0\r\nitem_absorb_fire_perlevel\t235\t\t1\t6\t\t\t\t\t\t\t\t\t\t\t207\t340\t1024\t\t6\t0\t6\t0\t\t\t2\t3\tlevel\titem_absorbfire\t\t\t\t\t\t\t\t\t\t\t28\t6\t1\tModStre9o\tModStre9o\tincreaseswithplaylevelX\t\t\t\t\t\t\t\t0\r\nitem_absorb_ltng_perlevel\t236\t\t1\t6\t\t\t\t\t\t\t\t\t\t\t207\t340\t1024\t\t6\t0\t6\t0\t\t\t2\t3\tlevel\titem_absorblight\t\t\t\t\t\t\t\t\t\t\t30\t6\t1\tModStre9q\tModStre9q\tincreaseswithplaylevelX\t\t\t\t\t\t\t\t0\r\nitem_absorb_pois_perlevel\t237\t\t1\t6\t\t\t\t\t\t\t\t\t\t\t207\t340\t1024\t\t6\t0\t6\t0\t\t\t2\t3\tlevel\titem_absorbmagic\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nitem_thorns_perlevel\t238\t\t1\t6\t\t\t\t\t\t\t\t\t\t\t55\t256\t1024\t\t6\t0\t5\t0\t\t\t2\t3\tlevel\titem_attackertakesdamage\t\t\t\t\t\t\t\t\t\t\t12\t9\t2\tModStr1v\tModStr1v\tincreaseswithplaylevelX\t\t\t\t\t\t\t\t0\r\nitem_find_gold_perlevel\t239\t\t1\t6\t\t\t\t\t\t\t\t\t\t\t42\t256\t1024\t\t6\t0\t6\t0\t\t\t2\t3\tlevel\titem_goldbonus\t\t\t\t\t\t\t\t\t\t\t9\t7\t1\tModStr1w\tModStr1w\tincreaseswithplaylevelX\t\t\t\t\t\t\t\t0\r\nitem_find_magic_perlevel\t240\t\t1\t6\t\t\t\t\t\t\t\t\t\t\t814\t1024\t1024\t\t6\t0\t6\t0\t\t\t2\t3\tlevel\titem_magicbonus\t\t\t\t\t\t\t\t\t\t\t7\t7\t1\tModStr1x\tModStr1x\tincreaseswithplaylevelX\t\t\t\t\t\t\t\t0\r\nitem_regenstamina_perlevel\t241\t\t1\t6\t\t\t\t\t\t\t\t\t\t\t79\t256\t1024\t\t6\t0\t6\t0\t\t\t2\t3\tlevel\tstaminarecoverybonus\t\t\t\t\t\t\t\t\t\t\t47\t8\t2\tModStr3v\tModStr3v\tincreaseswithplaylevelX\t\t\t\t\t\t\t\t0\r\nitem_stamina_perlevel\t242\t\t1\t6\t\t\t\t\t\t\t\t\t\t\t104\t64\t1024\t\t6\t0\t6\t0\t\t\t2\t3\tlevel\tmaxstamina\t\t\t\t\t\t\t\t\t\t\t50\t6\t1\tModStr5d\tModStr5d\tincreaseswithplaylevelX\t\t\t\t\t\t\t\t0\r\nitem_damage_demon_perlevel\t243\t\t1\t6\t\t\t\t\t\t\t\t\t\t\t56\t10\t1024\t\t6\t0\t6\t0\t\t\t2\t3\tlevel\titem_demondamage_percent\t\t\t\t\t\t1\t\t\t\t\t111\t8\t1\tModStr4e\tModStr4e\tincreaseswithplaylevelX\t\t\t\t\t\t\t\t0\r\nitem_damage_undead_perlevel\t244\t\t1\t6\t\t\t\t\t\t\t\t\t\t\t91\t10\t1024\t\t6\t0\t6\t0\t\t\t2\t3\tlevel\titem_undeaddamage_percent\t\t\t\t\t\t1\t\t\t\t\t107\t8\t1\tModStr4f\tModStr4f\tincreaseswithplaylevelX\t\t\t\t\t\t\t\t0\r\nitem_tohit_demon_perlevel\t245\t\t1\t6\t\t\t\t\t\t\t\t\t\t\t55\t10\t1024\t\t6\t0\t6\t0\t\t\t2\t1\tlevel\titem_demon_tohit\t\t\t\t\t\t1\t\t\t\t\t109\t6\t1\tModStr4j\tModStr4j\tincreaseswithplaylevelX\t\t\t\t\t\t\t\t0\r\nitem_tohit_undead_perlevel\t246\t\t1\t6\t\t\t\t\t\t\t\t\t\t\t12\t10\t1024\t\t6\t0\t6\t0\t\t\t2\t1\tlevel\titem_undead_tohit\t\t\t\t\t\t1\t\t\t\t\t105\t6\t1\tModStr4k\tModStr4k\tincreaseswithplaylevelX\t\t\t\t\t\t\t\t0\r\nitem_crushingblow_perlevel\t247\t\t1\t6\t\t\t\t\t\t\t\t\t\t\t213\t1024\t1024\t\t6\t0\t6\t0\t\t\t2\t3\tlevel\titem_crushingblow\t\t\t\t\t\t1\t\t\t\t\t86\t7\t1\tModStr5c\tModStr5c\tincreaseswithplaylevelX\t\t\t\t\t\t\t\t0\r\nitem_openwounds_perlevel\t248\t\t1\t6\t\t\t\t\t\t\t\t\t\t\t181\t128\t1024\t\t6\t0\t6\t0\t\t\t2\t3\tlevel\titem_openwounds\t\t\t\t\t\t1\t\t\t\t\t82\t7\t1\tModStr3m\tModStr3m\tincreaseswithplaylevelX\t\t\t\t\t\t\t\t0\r\nitem_kick_damage_perlevel\t249\t\t1\t6\t\t\t\t\t\t\t\t\t\t\t104\t128\t1024\t\t6\t0\t6\t0\t\t\t2\t3\tlevel\titem_kickdamage\t\t\t\t\t\t1\t\t\t\t\t120\t6\t1\tModStr5e\tModStr5e\tincreaseswithplaylevelX\t\t\t\t\t\t\t\t0\r\nitem_deadlystrike_perlevel\t250\t\t1\t6\t\t\t\t\t\t\t\t\t\t\t118\t512\t1024\t\t6\t0\t6\t0\t\t\t2\t3\tlevel\titem_deadlystrike\t\t\t\t\t\t1\t\t\t\t\t84\t7\t1\tModStr5q\tModStr5q\tincreaseswithplaylevelX\t\t\t\t\t\t\t\t0\r\nitem_find_gems_perlevel\t251\t\t1\t\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nitem_replenish_durability\t252\t\t1\t5\t\t\t\t\t\t\t\t\t\t\t106\t256\t1024\t\t5\t0\t6\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t1\t11\t0\tModStre9t\tModStre9t\t\t\t\t\t\t\t\t\t0\r\nitem_replenish_quantity\t253\t\t1\t5\t\t\t\t\t\t\t\t\t\t\t106\t256\t1024\t\t5\t0\t6\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t2\t3\t0\tModStre9v\tModStre9v\t\t\t\t\t\t\t\t\t0\r\nitem_extra_stack\t254\t\t1\t\t\t\t\t\t\t\t\t\t\t\t99\t10\t1024\t\t8\t0\t8\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t4\t3\t0\tModStre9i\tModStre9i\t\t\t\t\t\t\t\t\t0\r\nitem_find_item\t255\t\t1\t\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nitem_slash_damage\t256\t\t1\t\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nitem_slash_damage_percent\t257\t\t1\t\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nitem_crush_damage\t258\t\t1\t\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nitem_crush_damage_percent\t259\t\t1\t\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nitem_thrust_damage\t260\t\t1\t\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nitem_thrust_damage_percent\t261\t\t1\t\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nitem_absorb_slash\t262\t\t1\t\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nitem_absorb_crush\t263\t\t1\t\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nitem_absorb_thrust\t264\t\t1\t\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nitem_absorb_slash_percent\t265\t\t1\t\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nitem_absorb_crush_percent\t266\t\t1\t\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nitem_absorb_thrust_percent\t267\t\t1\t\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nitem_armor_bytime\t268\t\t1\t22\t\t\t\t\t\t\t\t\t\t4\t\t0\t1024\t\t22\t0\t22\t0\t\t\t6\t\t\tarmorclass\t\t\t\t\t\t\t\t\t\t\t180\t17\t1\tModStr1i\tModStr1i\t\t\t\t\t\t\t\t\t0\r\nitem_armorpercent_bytime\t269\t\t1\t22\t\t\t\t\t\t\t\t\t\t4\t\t0\t1024\t\t22\t0\t22\t0\t\t\t7\t\t\tarmorclass\t\t\t\t\t\t\t\t\t\t\t180\t18\t1\tModstr2v\tModstr2v\t\t\t\t\t\t\t\t\t0\r\nitem_hp_bytime\t270\t\t1\t22\t\t\t\t\t\t\t\t\t\t4\t\t0\t1024\t\t22\t0\t22\t0\t\t\t6\t\t\tmaxhp\t\t\t\t\t\t\t\t\t\t\t180\t17\t1\tModStr1u\tModStr1u\t\t\t\t\t\t\t\t\t0\r\nitem_mana_bytime\t271\t\t1\t22\t\t\t\t\t\t\t\t\t\t4\t\t0\t1024\t\t22\t0\t22\t0\t\t\t6\t\t\tmaxmana\t\t\t\t\t\t\t\t\t\t\t180\t17\t1\tModStr1e\tModStr1e\t\t\t\t\t\t\t\t\t0\r\nitem_maxdamage_bytime\t272\t\t1\t22\t\t\t\t\t\t\t\t\t\t4\t\t0\t1024\t\t22\t0\t22\t0\t\t\t6\t\t\tmaxdamage\tsecondary_maxdamage\titem_throw_maxdamage\t\t\t\t1\t\t\t\t\t180\t17\t1\tModStr1f\tModStr1f\t\t\t\t\t\t\t\t\t0\r\nitem_maxdamage_percent_bytime\t273\t\t1\t22\t\t\t\t\t\t\t\t\t\t4\t\t0\t1024\t\t22\t0\t22\t0\t\t\t7\t\t\tmaxdamage\tsecondary_mindamage\titem_throw_mindamage\t\t\t\t1\t\t\t\t\t180\t18\t1\tModStr2j\tModStr2j\t\t\t\t\t\t\t\t\t0\r\nitem_strength_bytime\t274\t\t1\t22\t\t\t\t\t\t\t\t\t\t4\t\t0\t1024\t\t22\t0\t22\t0\t\t\t6\t\t\tstrength\t\t\t\t\t\t\t\t\t\t\t180\t17\t1\tModStr1a\tModStr1a\t\t\t\t\t\t\t\t\t0\r\nitem_dexterity_bytime\t275\t\t1\t22\t\t\t\t\t\t\t\t\t\t4\t\t0\t1024\t\t22\t0\t22\t0\t\t\t6\t\t\tdexterity\t\t\t\t\t\t\t\t\t\t\t180\t17\t1\tModStr1b\tModStr1b\t\t\t\t\t\t\t\t\t0\r\nitem_energy_bytime\t276\t\t1\t22\t\t\t\t\t\t\t\t\t\t4\t\t0\t1024\t\t22\t0\t22\t0\t\t\t6\t\t\tenergy\t\t\t\t\t\t\t\t\t\t\t180\t17\t1\tModStr1d\tModStr1d\t\t\t\t\t\t\t\t\t0\r\nitem_vitality_bytime\t277\t\t1\t22\t\t\t\t\t\t\t\t\t\t4\t\t0\t1024\t\t22\t0\t22\t0\t\t\t6\t\t\tvitality\t\t\t\t\t\t\t\t\t\t\t180\t17\t1\tModStr1c\tModStr1c\t\t\t\t\t\t\t\t\t0\r\nitem_tohit_bytime\t278\t\t1\t22\t\t\t\t\t\t\t\t\t\t4\t\t0\t1024\t\t22\t0\t22\t0\t\t\t6\t\t\ttohit\t\t\t\t\t\t1\t\t\t\t\t180\t17\t1\tModStr1h\tModStr1h\t\t\t\t\t\t\t\t\t0\r\nitem_tohitpercent_bytime\t279\t\t1\t22\t\t\t\t\t\t\t\t\t\t4\t\t0\t1024\t\t22\t0\t22\t0\t\t\t6\t\t\titem_tohit_percent\t\t\t\t\t\t1\t\t\t\t\t180\t18\t1\tModStr4c\tModStr4c\t\t\t\t\t\t\t\t\t0\r\nitem_cold_damagemax_bytime\t280\t\t1\t22\t\t\t\t\t\t\t\t\t\t4\t\t0\t1024\t\t22\t0\t22\t0\t\t\t6\t\t\tcoldmaxdam\t\t\t\t\t\t1\t\t\t\t\t180\t17\t1\tModStr1s\tModStr1s\t\t\t\t\t\t\t\t\t0\r\nitem_fire_damagemax_bytime\t281\t\t1\t22\t\t\t\t\t\t\t\t\t\t4\t\t0\t1024\t\t22\t0\t22\t0\t\t\t6\t\t\tfiremaxdam\t\t\t\t\t\t1\t\t\t\t\t180\t17\t1\tModStr1o\tModStr1o\t\t\t\t\t\t\t\t\t0\r\nitem_ltng_damagemax_bytime\t282\t\t1\t22\t\t\t\t\t\t\t\t\t\t4\t\t0\t1024\t\t22\t0\t22\t0\t\t\t6\t\t\tlightmaxdam\t\t\t\t\t\t1\t\t\t\t\t180\t17\t1\tModStr1q\tModStr1q\t\t\t\t\t\t\t\t\t0\r\nitem_pois_damagemax_bytime\t283\t\t1\t22\t\t\t\t\t\t\t\t\t\t4\t\t0\t1024\t\t22\t0\t22\t0\t\t\t6\t\t\tpoisonmaxdam\t\t\t\t\t\t1\t\t\t\t\t180\t17\t1\tModStr4h\tModStr4h\t\t\t\t\t\t\t\t\t0\r\nitem_resist_cold_bytime\t284\t\t1\t22\t\t\t\t\t\t\t\t\t\t4\t\t0\t1024\t\t22\t0\t22\t0\t\t\t6\t\t\tcoldresist\t\t\t\t\t\t\t\t\t\t\t180\t18\t2\tModStr1k\tModStr1k\t\t\t\t\t\t\t\t\t0\r\nitem_resist_fire_bytime\t285\t\t1\t22\t\t\t\t\t\t\t\t\t\t4\t\t0\t1024\t\t22\t0\t22\t0\t\t\t6\t\t\tfireresist\t\t\t\t\t\t\t\t\t\t\t180\t18\t2\tModStr1j\tModStr1j\t\t\t\t\t\t\t\t\t0\r\nitem_resist_ltng_bytime\t286\t\t1\t22\t\t\t\t\t\t\t\t\t\t4\t\t0\t1024\t\t22\t0\t22\t0\t\t\t6\t\t\tlightresist\t\t\t\t\t\t\t\t\t\t\t180\t18\t2\tModStr1l\tModStr1l\t\t\t\t\t\t\t\t\t0\r\nitem_resist_pois_bytime\t287\t\t1\t22\t\t\t\t\t\t\t\t\t\t4\t\t0\t1024\t\t22\t0\t22\t0\t\t\t6\t\t\tpoisonresist\t\t\t\t\t\t\t\t\t\t\t180\t18\t2\tModStr1n\tModStr1n\t\t\t\t\t\t\t\t\t0\r\nitem_absorb_cold_bytime\t288\t\t1\t22\t\t\t\t\t\t\t\t\t\t4\t\t0\t1024\t\t22\t0\t22\t0\t\t\t6\t\t\titem_absorbcold\t\t\t\t\t\t\t\t\t\t\t180\t18\t1\tModStre9p\tModStre9p\t\t\t\t\t\t\t\t\t0\r\nitem_absorb_fire_bytime\t289\t\t1\t22\t\t\t\t\t\t\t\t\t\t4\t\t0\t1024\t\t22\t0\t22\t0\t\t\t6\t\t\titem_absorbfire\t\t\t\t\t\t\t\t\t\t\t180\t18\t1\tModStre9o\tModStre9o\t\t\t\t\t\t\t\t\t0\r\nitem_absorb_ltng_bytime\t290\t\t1\t22\t\t\t\t\t\t\t\t\t\t4\t\t0\t1024\t\t22\t0\t22\t0\t\t\t6\t\t\titem_absorblight\t\t\t\t\t\t\t\t\t\t\t180\t18\t1\tModStre9q\tModStre9q\t\t\t\t\t\t\t\t\t0\r\nitem_absorb_pois_bytime\t291\t\t1\t22\t\t\t\t\t\t\t\t\t\t4\t\t0\t1024\t\t22\t0\t22\t0\t\t\t6\t\t\titem_absorbmagic\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nitem_find_gold_bytime\t292\t\t1\t22\t\t\t\t\t\t\t\t\t\t4\t\t0\t1024\t\t22\t0\t22\t0\t\t\t6\t\t\titem_goldbonus\t\t\t\t\t\t\t\t\t\t\t180\t18\t2\tModStr1w\tModStr1w\t\t\t\t\t\t\t\t\t0\r\nitem_find_magic_bytime\t293\t\t1\t22\t\t\t\t\t\t\t\t\t\t4\t\t0\t1024\t\t22\t0\t22\t0\t\t\t6\t\t\titem_magicbonus\t\t\t\t\t\t\t\t\t\t\t180\t18\t1\tModStr1x\tModStr1x\t\t\t\t\t\t\t\t\t0\r\nitem_regenstamina_bytime\t294\t\t1\t22\t\t\t\t\t\t\t\t\t\t4\t\t0\t1024\t\t22\t0\t22\t0\t\t\t6\t\t\tstaminarecoverybonus\t\t\t\t\t\t\t\t\t\t\t180\t18\t2\tModStr3v\tModStr3v\t\t\t\t\t\t\t\t\t0\r\nitem_stamina_bytime\t295\t\t1\t22\t\t\t\t\t\t\t\t\t\t4\t\t0\t1024\t\t22\t0\t22\t0\t\t\t6\t\t\tmaxstamina\t\t\t\t\t\t\t\t\t\t\t180\t17\t1\tModStr5d\tModStr5d\t\t\t\t\t\t\t\t\t0\r\nitem_damage_demon_bytime\t296\t\t1\t22\t\t\t\t\t\t\t\t\t\t4\t\t0\t1024\t\t22\t0\t22\t0\t\t\t6\t\t\titem_demondamage_percent\t\t\t\t\t\t1\t\t\t\t\t180\t18\t1\tModStr4e\tModStr4e\t\t\t\t\t\t\t\t\t0\r\nitem_damage_undead_bytime\t297\t\t1\t22\t\t\t\t\t\t\t\t\t\t4\t\t0\t1024\t\t22\t0\t22\t0\t\t\t6\t\t\titem_undeaddamage_percent\t\t\t\t\t\t1\t\t\t\t\t180\t18\t1\tModStr4f\tModStr4f\t\t\t\t\t\t\t\t\t0\r\nitem_tohit_demon_bytime\t298\t\t1\t22\t\t\t\t\t\t\t\t\t\t4\t\t0\t1024\t\t22\t0\t22\t0\t\t\t6\t\t\titem_demon_tohit\t\t\t\t\t\t1\t\t\t\t\t180\t17\t1\tModStr4j\tModStr4j\t\t\t\t\t\t\t\t\t0\r\nitem_tohit_undead_bytime\t299\t\t1\t22\t\t\t\t\t\t\t\t\t\t4\t\t0\t1024\t\t22\t0\t22\t0\t\t\t6\t\t\titem_undead_tohit\t\t\t\t\t\t1\t\t\t\t\t180\t17\t1\tModStr4k\tModStr4k\t\t\t\t\t\t\t\t\t0\r\nitem_crushingblow_bytime\t300\t\t1\t22\t\t\t\t\t\t\t\t\t\t4\t\t0\t1024\t\t22\t0\t22\t0\t\t\t6\t\t\titem_crushingblow\t\t\t\t\t\t1\t\t\t\t\t180\t18\t1\tModStr5c\tModStr5c\t\t\t\t\t\t\t\t\t0\r\nitem_openwounds_bytime\t301\t\t1\t22\t\t\t\t\t\t\t\t\t\t4\t\t0\t1024\t\t22\t0\t22\t0\t\t\t6\t\t\titem_openwounds\t\t\t\t\t\t1\t\t\t\t\t180\t18\t1\tModStr3m\tModStr3m\t\t\t\t\t\t\t\t\t0\r\nitem_kick_damage_bytime\t302\t\t1\t22\t\t\t\t\t\t\t\t\t\t4\t\t0\t1024\t\t22\t0\t22\t0\t\t\t6\t\t\titem_kickdamage\t\t\t\t\t\t1\t\t\t\t\t180\t17\t1\tModStr5e\tModStr5e\t\t\t\t\t\t\t\t\t0\r\nitem_deadlystrike_bytime\t303\t\t1\t22\t\t\t\t\t\t\t\t\t\t4\t\t0\t1024\t\t22\t0\t22\t0\t\t\t6\t\t\titem_deadlystrike\t\t\t\t\t\t1\t\t\t\t\t180\t18\t1\tModStr5q\tModStr5q\t\t\t\t\t\t\t\t\t0\r\nitem_find_gems_bytime\t304\t\t1\t\t\t\t\t\t\t\t\t\t\t4\t\t0\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nitem_pierce_cold\t305\t\t1\t9\t\t\t\t\t\t\t\t\t\t\t1432\t513\t1024\t\t\t\t8\t50\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t88\t20\t1\tModitemenrescoldsk\tModitemenrescoldsk\t\t\t\t\t\t\t\t\t0\r\nitem_pierce_fire\t306\t\t1\t9\t\t\t\t\t\t\t\t\t\t\t1240\t497\t1024\t\t\t\t8\t50\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t88\t20\t1\tModitemenresfiresk\tModitemenresfiresk\t\t\t\t\t\t\t\t\t0\r\nitem_pierce_ltng\t307\t\t1\t9\t\t\t\t\t\t\t\t\t\t\t1187\t481\t1024\t\t\t\t8\t50\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t88\t20\t1\tModitemenresltngsk\tModitemenresltngsk\t\t\t\t\t\t\t\t\t0\r\nitem_pierce_pois\t308\t\t1\t9\t\t\t\t\t\t\t\t\t\t\t1322\t506\t1024\t\t\t\t8\t50\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t88\t20\t1\tModitemenrespoissk\tModitemenrespoissk\t\t\t\t\t\t\t\t\t0\r\nitem_damage_vs_monster\t309\t\t1\t\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nitem_damage_percent_vs_monster\t310\t\t1\t\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nitem_tohit_vs_monster\t311\t\t1\t\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nitem_tohit_percent_vs_monster\t312\t\t1\t\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nitem_ac_vs_monster\t313\t\t1\t\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nitem_ac_percent_vs_monster\t314\t\t1\t\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nfirelength\t315\t\t1\t16\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nburningmin\t316\t\t1\t16\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nburningmax\t317\t\t1\t16\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nprogressive_damage\t318\t\t1\t3\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nprogressive_steal\t319\t\t1\t3\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nprogressive_other\t320\t\t1\t3\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nprogressive_fire\t321\t\t1\t3\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nprogressive_cold\t322\t\t1\t3\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nprogressive_lightning\t323\t\t1\t3\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nitem_extra_charges\t324\t\t1\t6\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t6\t0\t6\t0\t\t\t\t\t\t\t\t\t\t\t1\t1\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nprogressive_tohit\t325\t\t1\t16\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\npoison_count\t326\t\t1\t5\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t1\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\ndamage_framerate\t327\t\t1\t8\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\npierce_idx\t328\t\t1\t6\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\npassive_fire_mastery\t329\t\t1\t12\t\t\t\t\t\t\t\t\t\t\t1117\t415\t1024\t\t8\t0\t9\t50\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t88\t4\t1\tModitemdamFiresk\tModitemdamFiresk\t\t\t\t\t\t\t\t\t0\r\npassive_ltng_mastery\t330\t\t1\t12\t\t\t\t\t\t\t\t\t\t\t1054\t408\t1024\t\t8\t0\t9\t50\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t88\t4\t1\tModitemdamLtngsk\tModitemdamLtngsk\t\t\t\t\t\t\t\t\t0\r\npassive_cold_mastery\t331\t\t1\t12\t\t\t\t\t\t\t\t\t\t\t1295\t379\t1024\t\t8\t0\t9\t50\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t88\t4\t1\tModitemdamColdsk\tModitemdamColdsk\t\t\t\t\t\t\t\t\t0\r\npassive_pois_mastery\t332\t\t1\t12\t\t\t\t\t\t\t\t\t\t\t978\t394\t1024\t\t8\t0\t9\t50\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t88\t4\t1\tModitemdamPoissk\tModitemdamPoissk\t\t\t\t\t\t\t\t\t0\r\npassive_fire_pierce\t333\t\t1\t9\t\t\t\t\t\t\t\t\t\t\t\t2578\t1024\t\t8\t0\t8\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t88\t20\t1\tModitemenresfiresk\tModitemenresfiresk\t\t\t\t\t\t\t\t\t0\r\npassive_ltng_pierce\t334\t\t1\t9\t\t\t\t\t\t\t\t\t\t\t\t2493\t1024\t\t8\t0\t8\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t88\t20\t1\tModitemenresltngsk\tModitemenresltngsk\t\t\t\t\t\t\t\t\t0\r\npassive_cold_pierce\t335\t\t1\t9\t\t\t\t\t\t\t\t\t\t\t\t1984\t1024\t\t8\t0\t8\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t88\t20\t1\tModitemenrescoldsk\tModitemenrescoldsk\t\t\t\t\t\t\t\t\t0\r\npassive_pois_pierce\t336\t\t1\t9\t\t\t\t\t\t\t\t\t\t\t\t2345\t1024\t\t8\t0\t8\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t88\t20\t1\tModitemenrespoissk\tModitemenrespoissk\t\t\t\t\t\t\t\t\t0\r\npassive_critical_strike\t337\t\t1\t9\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t8\t0\t8\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\npassive_dodge\t338\t\t1\t9\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t7\t0\t7\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\npassive_avoid\t339\t\t1\t9\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t7\t0\t7\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\npassive_evade\t340\t\t1\t9\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t7\t0\t7\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\npassive_warmth\t341\t\t1\t9\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t8\t0\t8\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\npassive_mastery_melee_th\t342\t\t1\t11\t8\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t8\t0\t8\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\npassive_mastery_melee_dmg\t343\t\t1\t11\t8\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t8\t0\t8\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\npassive_mastery_melee_crit\t344\t\t1\t9\t8\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t8\t0\t8\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\npassive_mastery_throw_th\t345\t\t1\t11\t8\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t8\t0\t8\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\npassive_mastery_throw_dmg\t346\t\t1\t11\t8\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t8\t0\t8\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\npassive_mastery_throw_crit\t347\t\t1\t9\t8\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t8\t0\t8\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\npassive_weaponblock\t348\t\t1\t9\t8\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t8\t0\t8\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\npassive_summon_resist\t349\t\t1\t9\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t8\t0\t8\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nmodifierlist_skill\t350\t\t\t9\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nmodifierlist_level\t351\t\t\t8\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nlast_sent_hp_pct\t352\t\t1\t8\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nsource_unit_type\t353\t\t\t5\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nsource_unit_id\t354\t\t\t32\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nshortparam1\t355\t\t\t16\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nquestitemdifficulty\t356\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t1024\t\t\t\t2\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\npassive_mag_mastery\t357\t\t1\t12\t\t\t\t\t\t\t\t\t\t\t1211\t431\t1024\t\t8\t0\t9\t50\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\npassive_mag_pierce\t358\t\t1\t9\t\t\t\t\t\t\t\t\t\t\t\t2812\t1024\t\t8\t0\t8\t0\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\n" + +export const RAW_PROPERTIES: string = "code\t*done\tset1\tval1\tfunc1\tstat1\tset2\tval2\tfunc2\tstat2\tset3\tval3\tfunc3\tstat3\tset4\tval4\tfunc4\tstat4\tset5\tval5\tfunc5\tstat5\tset6\tval6\tfunc6\tstat6\tset7\tval7\tfunc7\tstat7\t*desc\t*param\t*min\t*max\t*notes\t*eol\r\nac\t1\t\t\t1\tarmorclass\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nac-miss\t1\t\t\t1\tarmorclass_vs_missile\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nac-hth\t1\t\t\t1\tarmorclass_vs_hth\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nred-dmg\t1\t\t\t1\tnormal_damage_reduction\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nred-dmg%\t1\t\t\t1\tdamageresist\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nac%\t1\t\t\t2\titem_armor_percent\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nred-mag\t1\t\t\t1\tmagic_damage_reduction\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nstr\t1\t\t\t1\tstrength\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\ndex\t1\t\t\t1\tdexterity\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nvit\t1\t\t\t1\tvitality\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nenr\t1\t\t\t1\tenergy\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nmana\t1\t\t\t1\tmaxmana\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nmana%\t1\t\t\t1\titem_maxmana_percent\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nhp\t1\t\t\t1\tmaxhp\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nhp%\t1\t\t\t1\titem_maxhp_percent\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\natt\t1\t\t\t1\ttohit\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nblock\t1\t\t\t1\ttoblock\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\ncold-min\t1\t\t\t1\tcoldmindam\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\ncold-max\t1\t\t\t1\tcoldmaxdam\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\ncold-len\t1\t\t\t1\tcoldlength\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nfire-min\t1\t\t\t1\tfiremindam\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nfire-max\t1\t\t\t1\tfiremaxdam\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nltng-min\t1\t\t\t1\tlightmindam\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nltng-max\t1\t\t\t1\tlightmaxdam\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\npois-min\t1\t\t\t1\tpoisonmindam\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\npois-max\t1\t\t\t1\tpoisonmaxdam\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\npois-len\t1\t\t\t1\tpoisonlength\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\ndmg-min\t1\t\t\t5\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\ndmg-max\t1\t\t\t6\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\ndmg%\t1\t\t\t7\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\ndmg-to-mana\t1\t\t\t1\titem_damagetomana\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nres-fire\t1\t\t\t1\tfireresist\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nres-fire-max\t1\t\t\t1\tmaxfireresist\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nres-ltng\t1\t\t\t1\tlightresist\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nres-ltng-max\t1\t\t\t1\tmaxlightresist\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nres-cold\t1\t\t\t1\tcoldresist\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nres-cold-max\t1\t\t\t1\tmaxcoldresist\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nres-mag\t1\t\t\t1\tmagicresist\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nres-mag-max\t1\t\t\t1\tmaxmagicresist\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nres-pois\t1\t\t\t1\tpoisonresist\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nres-pois-max\t1\t\t\t1\tmaxpoisonresist\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nres-all\t1\t\t\t1\tfireresist\t\t\t3\tlightresist\t\t\t3\tcoldresist\t\t\t3\tpoisonresist\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nres-all-max\t1\t\t\t1\tmaxfireresist\t\t\t3\tmaxlightresist\t\t\t3\tmaxcoldresist\t\t\t3\tmaxpoisonresist\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nabs-fire%\t1\t\t\t1\titem_absorbfire_percent\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nabs-fire\t1\t\t\t1\titem_absorbfire\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nabs-ltng%\t1\t\t\t1\titem_absorblight_percent\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nabs-ltng\t1\t\t\t1\titem_absorblight\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nabs-mag%\t1\t\t\t1\titem_absorbmagic_percent\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nabs-mag\t1\t\t\t1\titem_absorbmagic\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nabs-cold%\t1\t\t\t1\titem_absorbcold_percent\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nabs-cold\t1\t\t\t1\titem_absorbcold\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\ndur\t1\t\t\t1\tmaxdurability\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\ndur%\t1\t\t\t13\titem_maxdurability_percent\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nregen\t1\t\t\t1\thpregen\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nthorns\t1\t\t\t1\titem_attackertakesdamage\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nswing1\t1\t\t\t8\titem_fasterattackrate\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nswing2\t1\t\t\t8\titem_fasterattackrate\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nswing3\t1\t\t\t8\titem_fasterattackrate\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\ngold%\t1\t\t\t1\titem_goldbonus\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nmag%\t1\t\t\t1\titem_magicbonus\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nknock\t1\t\t\t1\titem_knockback\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nregen-stam\t1\t\t\t1\tstaminarecoverybonus\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nregen-mana\t1\t\t\t1\tmanarecoverybonus\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nstam\t1\t\t\t1\tmaxstamina\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\ntime\t1\t\t\t1\titem_timeduration\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nmanasteal\t1\t\t\t1\tmanadrainmindam\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nlifesteal\t1\t\t\t1\tlifedrainmindam\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nama\t1\t\t0\t21\titem_addclassskills\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\npal\t1\t\t3\t21\titem_addclassskills\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nnec\t1\t\t2\t21\titem_addclassskills\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nsor\t1\t\t1\t21\titem_addclassskills\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nbar\t1\t\t4\t21\titem_addclassskills\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nherb\t1\t\t\t1\titem_doubleherbduration\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nlight\t1\t\t\t1\titem_lightradius\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\ncolor\t1\t1\t\t1\titem_lightcolor\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nease\t1\t\t\t1\titem_req_percent\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nmove1\t1\t\t\t8\titem_fastermovevelocity\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nmove2\t1\t\t\t8\titem_fastermovevelocity\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nmove3\t1\t\t\t8\titem_fastermovevelocity\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nbalance1\t1\t\t\t8\titem_fastergethitrate\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nbalance2\t1\t\t\t8\titem_fastergethitrate\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nbalance3\t1\t\t\t8\titem_fastergethitrate\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nblock1\t1\t\t\t8\titem_fasterblockrate\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nblock2\t1\t\t\t8\titem_fasterblockrate\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nblock3\t1\t\t\t8\titem_fasterblockrate\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\ncast1\t1\t\t\t8\titem_fastercastrate\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\ncast2\t1\t\t\t8\titem_fastercastrate\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\ncast3\t1\t\t\t8\titem_fastercastrate\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nres-pois-len\t1\t\t\t1\titem_poisonlengthresist\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\ndmg\t1\t\t\t1\titem_normaldamage\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nhowl\t1\t\t\t1\titem_howl\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nstupidity\t1\t\t\t1\titem_stupidity\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nignore-ac\t1\t\t\t1\titem_ignoretargetac\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nreduce-ac\t1\t\t\t1\titem_fractionaltargetac\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nnoheal\t1\t\t\t1\titem_preventheal\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nhalf-freeze\t1\t\t\t1\titem_halffreezeduration\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\natt%\t1\t\t\t1\titem_tohit_percent\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\ndmg-ac\t1\t\t\t1\titem_damagetargetac\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\ndmg-demon\t1\t\t\t1\titem_demondamage_percent\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\ndmg-undead\t1\t\t\t1\titem_undeaddamage_percent\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\natt-demon\t1\t\t\t1\titem_demon_tohit\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\natt-undead\t1\t\t\t1\titem_undead_tohit\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nthrow\t1\t\t\t1\titem_throwable\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nfireskill\t1\t\t1\t21\titem_elemskill\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nallskills\t1\t\t\t1\titem_allskills\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nlight-thorns\t1\t\t\t1\titem_attackertakeslightdamage\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nfreeze\t1\t\t\t1\titem_freeze\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tchance in 128\tlength in frames\t\tnone\t0\r\nopenwounds\t1\t\t\t1\titem_openwounds\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\ncrush\t1\t\t\t1\titem_crushingblow\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nkick\t1\t\t\t1\titem_kickdamage\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nmana-kill\t1\t\t\t1\titem_manaafterkill\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\ndemon-heal\t1\t\t\t1\titem_healafterdemonkill\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nbloody\t1\t\t\t1\titem_extrablood\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\ndeadly\t1\t\t\t1\titem_deadlystrike\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nslow\t1\t\t\t1\titem_slow\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nnofreeze\t1\t\t\t1\titem_cannotbefrozen\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nstamdrain\t1\t\t\t1\titem_staminadrainpct\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nreanimate\t1\t\t\t24\titem_reanimate\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\npierce\t1\t\t\t1\titem_pierce\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nmagicarrow\t1\t\t\t1\titem_magicarrow\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nexplosivearrow\t1\t\t\t1\titem_explosivearrow\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nExpansion\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\r\ndru\t1\t\t5\t21\titem_addclassskills\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tAll Druid Skills\t\t\t\tnone\t0\r\nass\t1\t\t6\t21\titem_addclassskills\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tAll Assassin Skills\t\t\t\tnone\t0\r\nskill\t1\t\t\t22\titem_singleskill\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nskilltab\t1\t\t\t10\titem_addskill_tab\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\naura\t1\t\t\t22\titem_aura\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\natt-skill\t1\t\t\t11\titem_skillonattack\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tProc Skill on Swing\tSkill #\t% Chance\tLevel\tnone\t0\r\nhit-skill\t1\t\t\t11\titem_skillonhit\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tProc Skill on Hit\tSkill #\t% Chance\tLevel\tnone\t0\r\ngethit-skill\t1\t\t\t11\titem_skillongethit\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tProc Skill on Get Hit\tSkill #\t% Chance\tLevel\tnone\t0\r\ngembonus\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tIncrease chance of finding Gems\t\t\t\tnone\t0\r\nregen-dur\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nfire-fx\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nltng-fx\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\nsock\t1\t\t\t14\titem_numsockets\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnone\t0\r\ndmg-fire\t1\t\t\t15\tfiremindam\t\t\t16\tfiremaxdam\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tFire Damage\t\tMin\tMax\tnone\t0\r\ndmg-ltng\t1\t\t\t15\tlightmindam\t\t\t16\tlightmaxdam\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tLightning Damage\t\tMin\tMax\tnone\t0\r\ndmg-mag\t1\t\t\t15\tmagicmindam\t\t\t16\tmagicmaxdam\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tMagic Damge\t\tMin\tMax\tnone\t0\r\ndmg-cold\t1\t\t\t15\tcoldmindam\t\t\t16\tcoldmaxdam\t\t\t17\tcoldlength\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tCold Damage\tLength (Frames)\tMin\tMax\tnone\t0\r\ndmg-pois\t1\t\t\t15\tpoisonmindam\t\t\t16\tpoisonmaxdam\t\t\t17\tpoisonlength\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tPoison Damage\tLength (Frames)\tMin\tMax\tnone\t0\r\ndmg-throw\t1\t\t\t15\titem_throw_mindamage\t\t\t16\titem_throw_maxdamage\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tThrowing Damage\t\tMin\tMax\tnone\t0\r\ndmg-norm\t1\t\t\t15\tmindamage\t\t\t16\tmaxdamage\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tNormal Damage Modifier\t\tMin\tMax\tnone\t0\r\nac/lvl\t1\t\t\t17\titem_armor_perlevel\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tAC per Player Level\tac/lvl (8ths)\t\t\tnone\t0\r\nac%/lvl\t1\t\t\t17\titem_armorpercent_perlevel\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tAC% per Player Level\tac%/lvl (8ths)\t\t\tnone\t0\r\nhp/lvl\t1\t\t\t17\titem_hp_perlevel\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tHP per Player Level\thp/lvl (8ths)\t\t\tnone\t0\r\nmana/lvl\t1\t\t\t17\titem_mana_perlevel\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tMana per Player Level\t(8ths)\t\t\tnone\t0\r\ndmg/lvl\t1\t\t\t17\titem_maxdamage_perlevel\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tMax Damage per Player Level\t(8ths)\t\t\tnone\t0\r\ndmg%/lvl\t1\t\t\t17\titem_maxdamage_percent_perlevel\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tMax Damage % per Player Level\t(8ths)\t\t\tnone\t0\r\nstr/lvl\t1\t\t\t17\titem_strength_perlevel\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tStrength per Player Level\t(8ths)\t\t\tnone\t0\r\ndex/lvl\t1\t\t\t17\titem_dexterity_perlevel\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tDexterity per Player Level\t(8ths)\t\t\tnone\t0\r\nenr/lvl\t1\t\t\t17\titem_energy_perlevel\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tEnergy per Player Level\t(8ths)\t\t\tnone\t0\r\nvit/lvl\t1\t\t\t17\titem_vitality_perlevel\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tVitality per Player Level\t(8ths)\t\t\tnone\t0\r\natt/lvl\t1\t\t\t17\titem_tohit_perlevel\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tAttack per Player Level\tatt/lvl (1)\t\t\tnone\t0\r\natt%/lvl\t1\t\t\t17\titem_tohitpercent_perlevel\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tAttack% per Player Level\tatt%/lvl (8ths)\t\t\tnone\t0\r\ndmg-cold/lvl\t1\t\t\t17\titem_cold_damagemax_perlevel\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tMax Cold Damage per Player Level\t(8ths)\t\t\tnone\t0\r\ndmg-fire/lvl\t1\t\t\t17\titem_fire_damagemax_perlevel\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tMax Fire Damage per Player Level\t(8ths)\t\t\tnone\t0\r\ndmg-ltng/lvl\t1\t\t\t17\titem_ltng_damagemax_perlevel\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tMax Lightning Dmg per Player Level\t(8ths)\t\t\tnone\t0\r\ndmg-pois/lvl\t1\t\t\t17\titem_pois_damagemax_perlevel\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tMax Poison Dmg per Player Level\t(8ths)\t\t\tnone\t0\r\nres-cold/lvl\t1\t\t\t17\titem_resist_cold_perlevel\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tResist Cold% per Player Level\t(8ths)\t\t\tnone\t0\r\nres-fire/lvl\t1\t\t\t17\titem_resist_fire_perlevel\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tResist Fire% per Player Level\t(8ths)\t\t\tnone\t0\r\nres-ltng/lvl\t1\t\t\t17\titem_resist_ltng_perlevel\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tResist Lightning% per Player Level\t(8ths)\t\t\tnone\t0\r\nres-pois/lvl\t1\t\t\t17\titem_resist_pois_perlevel\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tResist Poison% per Player Level\t(8ths)\t\t\tnone\t0\r\nabs-cold/lvl\t1\t\t\t17\titem_absorb_cold_perlevel\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tAbsorb Cold Dmg per Player Level\t(8ths)\t\t\tnone\t0\r\nabs-fire/lvl\t1\t\t\t17\titem_absorb_fire_perlevel\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tAbsorb Fire Dmg per Player Level\t(8ths)\t\t\tnone\t0\r\nabs-ltng/lvl\t1\t\t\t17\titem_absorb_ltng_perlevel\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tAbsorb Lightning Dmg per Player Lvl\t(8ths)\t\t\tnone\t0\r\nabs-pois/lvl\t1\t\t\t17\titem_absorb_pois_perlevel\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tAbsorb Poison Dmg per Player Lvl\t(8ths)\t\t\tnone\t0\r\nthorns/lvl\t1\t\t\t17\titem_thorns_perlevel\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tDamage to Attacker per Player Lvl\t(8ths)\t\t\tnone\t0\r\ngold%/lvl\t1\t\t\t17\titem_find_gold_perlevel\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t+% Gold Dropped per Player Lvl\t(8ths)\t\t\tnone\t0\r\nmag%/lvl\t1\t\t\t17\titem_find_magic_perlevel\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t+% Magical per Player Lvl\t(8ths)\t\t\tnone\t0\r\nregen-stam/lvl\t1\t\t\t17\titem_regenstamina_perlevel\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t+% Stamina Regeneration per Player Lvl\t(8ths)\t\t\tnone\t0\r\nstam/lvl\t1\t\t\t17\titem_stamina_perlevel\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tStamina per Player Level\t(8ths)\t\t\tnone\t0\r\ndmg-dem/lvl\t1\t\t\t17\titem_damage_demon_perlevel\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tDamage to Demons % per Player Level\t(8ths)\t\t\tnone\t0\r\ndmg-und/lvl\t1\t\t\t17\titem_damage_undead_perlevel\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tDamage to Undead % per Player Level\t(8ths)\t\t\tnone\t0\r\natt-dem/lvl\t1\t\t\t17\titem_tohit_demon_perlevel\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tAttack Demons + per Player Level\tatt/lvl (1)\t\t\tnone\t0\r\natt-und/lvl\t1\t\t\t17\titem_tohit_undead_perlevel\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tAttack Undead + per Player Level\tatt/lvl (1)\t\t\tnone\t0\r\ncrush/lvl\t1\t\t\t17\titem_crushingblow_perlevel\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t+% Chance of Crushing Blow per Player Level\t(8ths)\t\t\tnone\t0\r\nwounds/lvl\t1\t\t\t17\titem_openwounds_perlevel\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t+% Chance of Open Wounds per Player Level\t(8ths)\t\t\tnone\t0\r\nkick/lvl\t1\t\t\t17\titem_kick_damage_perlevel\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tKick Damage per Player Level\t(8ths)\t\t\tnone\t0\r\ndeadly/lvl\t1\t\t\t17\titem_deadlystrike_perlevel\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t+% Chance of Deadly Strike per Player Level\t(8ths)\t\t\tnone\t0\r\ngems%/lvl\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t+% Chance of finding Gems per Player Level\t(8ths)\t\t\tnone\t0\r\nrep-dur\t1\t\t\t17\titem_replenish_durability\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tregenerates durability\tspeed (see note)\t\t\tregenerates 1 durability point per 100/lvl seconds\t0\r\nrep-quant\t1\t\t\t17\titem_replenish_quantity\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tregenerates quantity\tspeed (see note)\t\t\tregenerates 1 quantity per 100/lvl seconds\t0\r\nstack\t1\t\t\t1\titem_extra_stack\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tIncreased stack size\t\tmin count\tmax count\tnone\t0\r\nitem%\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t+% Chance of finding item\titem type\tmin chance\tmax chance\trandom chance from min to max is assigned to item\t0\r\ndmg-slash\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tSlashing Damage\t\tmin\tmax\trandom amount from min to max is assigned to item\t0\r\ndmg-slash%\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tSlashing Damage %\t\tmin %\tmax %\trandom amount from min to max is assigned to item\t0\r\ndmg-crush\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tCrush Damage\t\tmin\tmax\trandom amount from min to max is assigned to item\t0\r\ndmg-crush%\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tCrush Damage %\t\tmin %\tmax %\trandom amount from min to max is assigned to item\t0\r\ndmg-thrust\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tThrust Damage\t\tmin\tmax\trandom amount from min to max is assigned to item\t0\r\ndmg-thrust%\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tThrust Damage %\t\tmin %\tmax %\trandom amount from min to max is assigned to item\t0\r\nabs-slash\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tAbsorb Slashing Damage\t\tmin amt\tmax amt\trandom amount from min to max is assigned to item\t0\r\nabs-crush\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tAbsorb Crushing Damage\t\tmin amt\tmax amt\trandom amount from min to max is assigned to item\t0\r\nabs-thrust\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tAbsorb Thrusting Damage\t\tmin amt\tmax amt\trandom amount from min to max is assigned to item\t0\r\nabs-slash%\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tAbsorb Slashing Damage %\t\tmin %\tmax %\trandom amount from min to max is assigned to item\t0\r\nabs-crush%\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tAbsorb Crushing Damage %\t\tmin %\tmax %\trandom amount from min to max is assigned to item\t0\r\nabs-thrust%\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tAbsorb Thrusting Damage %\t\tmin %\tmax %\trandom amount from min to max is assigned to item\t0\r\nac/time\t1\t\t\t18\titem_armor_bytime\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"AC / time increment (0=day, 1=dusk, 2=night, 3=dawn)\"\tcenter period\tmin\tmax\t\"max at center period, min at opposite period, linear progression\"\t0\r\nac%/time\t1\t\t\t18\titem_armorpercent_bytime\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tAC% / time increment (8 periods)\tcenter period\tmin\tmax\t\"max at center period, min at opposite period, linear progression\"\t0\r\nhp/time\t1\t\t\t18\titem_hp_bytime\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tHP / time increment\tcenter period\tmin\tmax\t\"max at center period, min at opposite period, linear progression\"\t0\r\nmana/time\t1\t\t\t18\titem_mana_bytime\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tMana / time increment\tcenter period\tmin\tmax\t\"max at center period, min at opposite period, linear progression\"\t0\r\ndmg/time\t1\t\t\t18\titem_maxdamage_bytime\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tMax Damage / time increment\tcenter period\tmin\tmax\t\"max at center period, min at opposite period, linear progression\"\t0\r\ndmg%/time\t1\t\t\t18\titem_maxdamage_percent_bytime\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tMax Damage % / time increment\tcenter period\tmin\tmax\t\"max at center period, min at opposite period, linear progression\"\t0\r\nstr/time\t1\t\t\t18\titem_strength_bytime\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tStrength / time increment\tcenter period\tmin\tmax\t\"max at center period, min at opposite period, linear progression\"\t0\r\ndex/time\t1\t\t\t18\titem_dexterity_bytime\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tDexterity / time increment\tcenter period\tmin\tmax\t\"max at center period, min at opposite period, linear progression\"\t0\r\nenr/time\t1\t\t\t18\titem_energy_bytime\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tEnergy / time increment\tcenter period\tmin\tmax\t\"max at center period, min at opposite period, linear progression\"\t0\r\nvit/time\t1\t\t\t18\titem_vitality_bytime\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tVitality / time increment\tcenter period\tmin\tmax\t\"max at center period, min at opposite period, linear progression\"\t0\r\natt/time\t1\t\t\t18\titem_tohit_bytime\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tTo hit / time increment\tcenter period\tmin\tmax\t\"max at center period, min at opposite period, linear progression\"\t0\r\natt%/time\t1\t\t\t18\titem_tohitpercent_bytime\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tTo Hit % / time increment\tcenter period\tmin\tmax\t\"max at center period, min at opposite period, linear progression\"\t0\r\ndmg-cold/time\t1\t\t\t18\titem_cold_damagemax_bytime\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tCold Damage Max / time inc.\tcenter period\tmin\tmax\t\"max at center period, min at opposite period, linear progression\"\t0\r\ndmg-fire/time\t1\t\t\t18\titem_fire_damagemax_bytime\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tFire Damage Max / time inc.\tcenter period\tmin\tmax\t\"max at center period, min at opposite period, linear progression\"\t0\r\ndmg-ltng/time\t1\t\t\t18\titem_ltng_damagemax_bytime\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tLightning Damage Max / time inc.\tcenter period\tmin\tmax\t\"max at center period, min at opposite period, linear progression\"\t0\r\ndmg-pois/time\t1\t\t\t18\titem_pois_damagemax_bytime\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tPoison Damage Max / time inc.\tcenter period\tmin\tmax\t\"max at center period, min at opposite period, linear progression\"\t0\r\nres-cold/time\t1\t\t\t18\titem_resist_cold_bytime\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tResist Cold / time inc.\tcenter period\tmin\tmax\t\"max at center period, min at opposite period, linear progression\"\t0\r\nres-fire/time\t1\t\t\t18\titem_resist_fire_bytime\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tResist Fire / time inc.\tcenter period\tmin\tmax\t\"max at center period, min at opposite period, linear progression\"\t0\r\nres-ltng/time\t1\t\t\t18\titem_resist_ltng_bytime\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tResist Lightning / time inc\tcenter period\tmin\tmax\t\"max at center period, min at opposite period, linear progression\"\t0\r\nres-pois/time\t1\t\t\t18\titem_resist_pois_bytime\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tResist Poison / time inc\tcenter period\tmin\tmax\t\"max at center period, min at opposite period, linear progression\"\t0\r\nabs-cold/time\t1\t\t\t18\titem_absorb_cold_bytime\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tAbsorb Cold / time inc.\tcenter period\tmin\tmax\t\"max at center period, min at opposite period, linear progression\"\t0\r\nabs-fire/time\t1\t\t\t18\titem_absorb_fire_bytime\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tAbsorb Fire / time inc.\tcenter period\tmin\tmax\t\"max at center period, min at opposite period, linear progression\"\t0\r\nabs-ltng/time\t1\t\t\t18\titem_absorb_ltng_bytime\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tAbsorb Lightning / time inc.\tcenter period\tmin\tmax\t\"max at center period, min at opposite period, linear progression\"\t0\r\nabs-pois/time\t1\t\t\t18\titem_absorb_pois_bytime\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tAbsorb Poison / time inc.\tcenter period\tmin\tmax\t\"max at center period, min at opposite period, linear progression\"\t0\r\ngold%/time\t1\t\t\t18\titem_find_gold_bytime\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tFind Gold Amt % / time inc.\tcenter period\tmin\tmax\t\"max at center period, min at opposite period, linear progression\"\t0\r\nmag%/time\t1\t\t\t18\titem_find_magic_bytime\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tFind Magic % / time inc.\tcenter period\tmin\tmax\t\"max at center period, min at opposite period, linear progression\"\t0\r\nregen-stam/time\t1\t\t\t18\titem_regenstamina_bytime\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t% / time inc.\tcenter period\tmin\tmax\t\"max at center period, min at opposite period, linear progression\"\t0\r\nstam/time\t1\t\t\t18\titem_stamina_bytime\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tStamina / time inc.\tcenter period\tmin\tmax\t\"max at center period, min at opposite period, linear progression\"\t0\r\ndmg-dem/time\t1\t\t\t18\titem_damage_demon_bytime\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tDamage to Demons % / time inc.\tcenter period\tmin\tmax\t\"max at center period, min at opposite period, linear progression\"\t0\r\ndmg-und/time\t1\t\t\t18\titem_damage_undead_bytime\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tDamage to Undead % / time inc.\tcenter period\tmin\tmax\t\"max at center period, min at opposite period, linear progression\"\t0\r\natt-dem/time\t1\t\t\t18\titem_tohit_demon_bytime\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tTo Hit Demons % / time inc.\tcenter period\tmin\tmax\t\"max at center period, min at opposite period, linear progression\"\t0\r\natt-und/time\t1\t\t\t18\titem_tohit_undead_bytime\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tTo Hit Undead % / time inc.\tcenter period\tmin\tmax\t\"max at center period, min at opposite period, linear progression\"\t0\r\ncrush/time\t1\t\t\t18\titem_crushingblow_bytime\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t% chance of Crushing Blow / time inc.\tcenter period\tmin\tmax\t\"max at center period, min at opposite period, linear progression\"\t0\r\nwounds/time\t1\t\t\t18\titem_openwounds_bytime\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t+% chance of Open Wounds / time inc.\tcenter period\tmin\tmax\t\"max at center period, min at opposite period, linear progression\"\t0\r\nkick/time\t1\t\t\t18\titem_kick_damage_bytime\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tKick Damage / time inc.\tcenter period\tmin\tmax\t\"max at center period, min at opposite period, linear progression\"\t0\r\ndeadly/time\t1\t\t\t18\titem_deadlystrike_bytime\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t+% chance of Deadly Strike / time inc.\tcenter period\tmin\tmax\t\"max at center period, min at opposite period, linear progression\"\t0\r\ngems%/time\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t+% chance of finding Gems / time inc.\tcenter period\tmin\tmax\t\"max at center period, min at opposite period, linear progression\"\t0\r\npierce-fire\t1\t\t\t1\tpassive_fire_pierce\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tNegates % of Enemy Cold Resistance\t\tmin\tmax\tnone\t0\r\npierce-ltng\t1\t\t\t1\tpassive_ltng_pierce\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tNegates % of Enemy Fire Resistance\t\tmin\tmax\tnone\t0\r\npierce-cold\t1\t\t\t1\tpassive_cold_pierce\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tNegates % of Enemy Lightning Resistance\t\tmin\tmax\tnone\t0\r\npierce-pois\t1\t\t\t1\tpassive_pois_pierce\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tNegates % of Enemy Poison Resistance\t\tmin\tmax\tnone\t0\r\ndmg-mon\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tDamage vs. specific Monster Type\tmonster\tmin\tmax\tnone\t0\r\ndmg%-mon\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tDamage % vs. specific Monster Type\tmonster\tmin\tmax\tnone\t0\r\natt-mon\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tTo Hit vs. specific Monster Type\tmonster\tmin\tmax\tnone\t0\r\natt%-mon\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tTo Hit % vs. specific Monster Type\tmonster\tmin\tmax\tnone\t0\r\nac-mon\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tAC vs. specific Monster Type\tmonster\tmin\tmax\tnone\t0\r\nac%-mon\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tAC% vs. specific Monster Type\tmonster\tmin\tmax\tnone\t0\r\nindestruct\t1\t\t\t20\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tIndestructible\t\t\t\tnone\t0\r\ncharged\t1\t\t\t19\titem_charged_skill\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tCharged Skill\tskill\tcharges\tlevel\tnone\t0\r\nextra-fire\t1\t\t\t1\tpassive_fire_mastery\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nextra-ltng\t1\t\t\t1\tpassive_ltng_mastery\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nextra-cold\t1\t\t\t1\tpassive_cold_mastery\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nextra-pois\t1\t\t\t1\tpassive_pois_mastery\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\ndmg-elem\t1\t\t\t15\tfiremindam\t\t\t16\tfiremaxdam\t\t\t15\tlightmindam\t\t\t16\tlightmaxdam\t\t\t15\tcoldmindam\t\t\t16\tcoldmaxdam\t\t\t17\tcoldlength\t\t\t\t\t\t0\r\ndmg-elem-min\t1\t\t\t1\tfiremindam\t\t\t3\tlightmindam\t\t\t3\tcoldmindam\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\ndmg-elem-max\t1\t\t\t1\tfiremaxdam\t\t\t3\tlightmaxdam\t\t\t3\tcoldmaxdam\t\t\t17\tcoldlength\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nall-stats\t1\t\t\t1\tstrength\t\t\t3\tenergy\t\t\t3\tdexterity\t\t\t3\tvitality\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\naddxp\t1\t\t\t1\titem_addexperience\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tadditional xp gain\t\t\t\t\t0\r\nheal-kill\t1\t\t\t1\titem_healafterkill\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tlife gained after each kill\t\t\t\t\t0\r\ncheap\t1\t\t\t1\titem_reducedprices\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\treduces vendor prices\t\tmin\tmax\tnone\t0\r\nrip\t1\t\t\t1\titem_restinpeace\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tslain monsters' corpses can't be used\t\t1\t1\tnone\t0\r\natt-mon%\t1\t\t\t24\tattack_vs_montype\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tattack% vs. monster type\tmon type\tmin\tmax\t\t0\r\ndmg-mon%\t1\t\t\t24\tdamage_vs_montype\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tdamage% vs. monster type\tmon type\tmin\tmax\t\t0\r\nkill-skill\t1\t\t\t11\titem_skillonkill\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tProc Skill on killing something\tSkill #\t% Chance\tLevel\t\t0\r\ndeath-skill\t1\t\t\t11\titem_skillondeath\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tProc Skill on getting killed\tSkill #\t% Chance\tLevel\t\t0\r\nlevelup-skill\t1\t\t\t11\titem_skillonlevelup\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tProc Skill on level up\tSkill #\t% Chance\tLevel\t\t0\r\nskill-rand\t1\t\t\t12\titem_singleskill\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tBonus to random skill\tLevel\tmin Skill #\tmax Skill #\t\t0\r\nfade\t1\t\t\t17\tfade\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tonly use on monprop\tfade type\t\t\t\t0\r\nlevelreq\t1\t\t\t1\titem_levelreq\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\nethereal\t1\t\t\t23\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\r\noskill\t1\t\t\t22\titem_nonclassskill\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tSkill\tmin level\tmax level\tnone\t0\r\nstate\t1\t\t\t24\tstate\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tstate\t1\t1\t\t0\r\nrandclassskill\t1\t\t3\t36\titem_addclassskills\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\r\n" + +export const CANONICAL_SKILL_COSTS: readonly SkillCostEntry[] = [ + { + "id": 0, + "skill": "Attack", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 1, + "skill": "Kick", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 2, + "skill": "Throw", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 3, + "skill": "Unsummon", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 4, + "skill": "Left Hand Throw", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 5, + "skill": "Left Hand Swing", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 6, + "skill": "Magic Arrow", + "costMult": 256, + "costAdd": 1000 + }, + { + "id": 7, + "skill": "Fire Arrow", + "costMult": 256, + "costAdd": 1000 + }, + { + "id": 8, + "skill": "Inner Sight", + "costMult": 256, + "costAdd": 1000 + }, + { + "id": 9, + "skill": "Critical Strike", + "costMult": 256, + "costAdd": 1000 + }, + { + "id": 10, + "skill": "Jab", + "costMult": 256, + "costAdd": 1000 + }, + { + "id": 11, + "skill": "Cold Arrow", + "costMult": 384, + "costAdd": 3000 + }, + { + "id": 12, + "skill": "Multiple Shot", + "costMult": 384, + "costAdd": 3000 + }, + { + "id": 13, + "skill": "Dodge", + "costMult": 384, + "costAdd": 3000 + }, + { + "id": 14, + "skill": "Power Strike", + "costMult": 384, + "costAdd": 3000 + }, + { + "id": 15, + "skill": "Poison Javelin", + "costMult": 384, + "costAdd": 3000 + }, + { + "id": 16, + "skill": "Exploding Arrow", + "costMult": 512, + "costAdd": 8000 + }, + { + "id": 17, + "skill": "Slow Missiles", + "costMult": 512, + "costAdd": 8000 + }, + { + "id": 18, + "skill": "Avoid", + "costMult": 512, + "costAdd": 8000 + }, + { + "id": 19, + "skill": "Impale", + "costMult": 512, + "costAdd": 8000 + }, + { + "id": 20, + "skill": "Lightning Bolt", + "costMult": 512, + "costAdd": 8000 + }, + { + "id": 21, + "skill": "Ice Arrow", + "costMult": 640, + "costAdd": 16000 + }, + { + "id": 22, + "skill": "Guided Arrow", + "costMult": 640, + "costAdd": 16000 + }, + { + "id": 23, + "skill": "Penetrate", + "costMult": 640, + "costAdd": 16000 + }, + { + "id": 24, + "skill": "Charged Strike", + "costMult": 640, + "costAdd": 16000 + }, + { + "id": 25, + "skill": "Plague Javelin", + "costMult": 640, + "costAdd": 16000 + }, + { + "id": 26, + "skill": "Strafe", + "costMult": 768, + "costAdd": 32000 + }, + { + "id": 27, + "skill": "Immolation Arrow", + "costMult": 768, + "costAdd": 32000 + }, + { + "id": 28, + "skill": "Dopplezon", + "costMult": 768, + "costAdd": 32000 + }, + { + "id": 29, + "skill": "Evade", + "costMult": 768, + "costAdd": 32000 + }, + { + "id": 30, + "skill": "Fend", + "costMult": 768, + "costAdd": 32000 + }, + { + "id": 31, + "skill": "Freezing Arrow", + "costMult": 896, + "costAdd": 64000 + }, + { + "id": 32, + "skill": "Valkyrie", + "costMult": 896, + "costAdd": 64000 + }, + { + "id": 33, + "skill": "Pierce", + "costMult": 896, + "costAdd": 64000 + }, + { + "id": 34, + "skill": "Lightning Strike", + "costMult": 896, + "costAdd": 64000 + }, + { + "id": 35, + "skill": "Lightning Fury", + "costMult": 896, + "costAdd": 64000 + }, + { + "id": 36, + "skill": "Fire Bolt", + "costMult": 256, + "costAdd": 1000 + }, + { + "id": 37, + "skill": "Warmth", + "costMult": 256, + "costAdd": 1000 + }, + { + "id": 38, + "skill": "Charged Bolt", + "costMult": 256, + "costAdd": 1000 + }, + { + "id": 39, + "skill": "Ice Bolt", + "costMult": 256, + "costAdd": 1000 + }, + { + "id": 40, + "skill": "Frozen Armor", + "costMult": 256, + "costAdd": 1000 + }, + { + "id": 41, + "skill": "Inferno", + "costMult": 384, + "costAdd": 3000 + }, + { + "id": 42, + "skill": "Static Field", + "costMult": 384, + "costAdd": 3000 + }, + { + "id": 43, + "skill": "Telekinesis", + "costMult": 384, + "costAdd": 3000 + }, + { + "id": 44, + "skill": "Frost Nova", + "costMult": 384, + "costAdd": 3000 + }, + { + "id": 45, + "skill": "Ice Blast", + "costMult": 384, + "costAdd": 3000 + }, + { + "id": 46, + "skill": "Blaze", + "costMult": 512, + "costAdd": 8000 + }, + { + "id": 47, + "skill": "Fire Ball", + "costMult": 512, + "costAdd": 8000 + }, + { + "id": 48, + "skill": "Nova", + "costMult": 512, + "costAdd": 8000 + }, + { + "id": 49, + "skill": "Lightning", + "costMult": 512, + "costAdd": 8000 + }, + { + "id": 50, + "skill": "Shiver Armor", + "costMult": 512, + "costAdd": 8000 + }, + { + "id": 51, + "skill": "Fire Wall", + "costMult": 640, + "costAdd": 16000 + }, + { + "id": 52, + "skill": "Enchant", + "costMult": 640, + "costAdd": 16000 + }, + { + "id": 53, + "skill": "Chain Lightning", + "costMult": 640, + "costAdd": 16000 + }, + { + "id": 54, + "skill": "Teleport", + "costMult": 640, + "costAdd": 16000 + }, + { + "id": 55, + "skill": "Glacial Spike", + "costMult": 640, + "costAdd": 16000 + }, + { + "id": 56, + "skill": "Meteor", + "costMult": 768, + "costAdd": 32000 + }, + { + "id": 57, + "skill": "Thunder Storm", + "costMult": 768, + "costAdd": 32000 + }, + { + "id": 58, + "skill": "Energy Shield", + "costMult": 768, + "costAdd": 32000 + }, + { + "id": 59, + "skill": "Blizzard", + "costMult": 768, + "costAdd": 32000 + }, + { + "id": 60, + "skill": "Chilling Armor", + "costMult": 768, + "costAdd": 32000 + }, + { + "id": 61, + "skill": "Fire Mastery", + "costMult": 896, + "costAdd": 64000 + }, + { + "id": 62, + "skill": "Hydra", + "costMult": 896, + "costAdd": 64000 + }, + { + "id": 63, + "skill": "Lightning Mastery", + "costMult": 896, + "costAdd": 64000 + }, + { + "id": 64, + "skill": "Frozen Orb", + "costMult": 896, + "costAdd": 64000 + }, + { + "id": 65, + "skill": "Cold Mastery", + "costMult": 896, + "costAdd": 64000 + }, + { + "id": 66, + "skill": "Amplify Damage", + "costMult": 256, + "costAdd": 1000 + }, + { + "id": 67, + "skill": "Teeth", + "costMult": 256, + "costAdd": 1000 + }, + { + "id": 68, + "skill": "Bone Armor", + "costMult": 256, + "costAdd": 1000 + }, + { + "id": 69, + "skill": "Skeleton Mastery", + "costMult": 256, + "costAdd": 1000 + }, + { + "id": 70, + "skill": "Raise Skeleton", + "costMult": 256, + "costAdd": 1000 + }, + { + "id": 71, + "skill": "Dim Vision", + "costMult": 384, + "costAdd": 3000 + }, + { + "id": 72, + "skill": "Weaken", + "costMult": 384, + "costAdd": 3000 + }, + { + "id": 73, + "skill": "Poison Dagger", + "costMult": 384, + "costAdd": 3000 + }, + { + "id": 74, + "skill": "Corpse Explosion", + "costMult": 384, + "costAdd": 3000 + }, + { + "id": 75, + "skill": "Clay Golem", + "costMult": 384, + "costAdd": 3000 + }, + { + "id": 76, + "skill": "Iron Maiden", + "costMult": 512, + "costAdd": 8000 + }, + { + "id": 77, + "skill": "Terror", + "costMult": 512, + "costAdd": 8000 + }, + { + "id": 78, + "skill": "Bone Wall", + "costMult": 512, + "costAdd": 8000 + }, + { + "id": 79, + "skill": "Golem Mastery", + "costMult": 512, + "costAdd": 8000 + }, + { + "id": 80, + "skill": "Raise Skeletal Mage", + "costMult": 512, + "costAdd": 8000 + }, + { + "id": 81, + "skill": "Confuse", + "costMult": 640, + "costAdd": 16000 + }, + { + "id": 82, + "skill": "Life Tap", + "costMult": 640, + "costAdd": 16000 + }, + { + "id": 83, + "skill": "Poison Explosion", + "costMult": 640, + "costAdd": 16000 + }, + { + "id": 84, + "skill": "Bone Spear", + "costMult": 640, + "costAdd": 16000 + }, + { + "id": 85, + "skill": "BloodGolem", + "costMult": 640, + "costAdd": 16000 + }, + { + "id": 86, + "skill": "Attract", + "costMult": 768, + "costAdd": 32000 + }, + { + "id": 87, + "skill": "Decrepify", + "costMult": 768, + "costAdd": 32000 + }, + { + "id": 88, + "skill": "Bone Prison", + "costMult": 768, + "costAdd": 32000 + }, + { + "id": 89, + "skill": "Summon Resist", + "costMult": 768, + "costAdd": 32000 + }, + { + "id": 90, + "skill": "IronGolem", + "costMult": 768, + "costAdd": 32000 + }, + { + "id": 91, + "skill": "Lower Resist", + "costMult": 896, + "costAdd": 64000 + }, + { + "id": 92, + "skill": "Poison Nova", + "costMult": 896, + "costAdd": 64000 + }, + { + "id": 93, + "skill": "Bone Spirit", + "costMult": 896, + "costAdd": 64000 + }, + { + "id": 94, + "skill": "FireGolem", + "costMult": 896, + "costAdd": 64000 + }, + { + "id": 95, + "skill": "Revive", + "costMult": 896, + "costAdd": 64000 + }, + { + "id": 96, + "skill": "Sacrifice", + "costMult": 256, + "costAdd": 1000 + }, + { + "id": 97, + "skill": "Smite", + "costMult": 256, + "costAdd": 1000 + }, + { + "id": 98, + "skill": "Might", + "costMult": 256, + "costAdd": 1000 + }, + { + "id": 99, + "skill": "Prayer", + "costMult": 256, + "costAdd": 1000 + }, + { + "id": 100, + "skill": "Resist Fire", + "costMult": 256, + "costAdd": 1000 + }, + { + "id": 101, + "skill": "Holy Bolt", + "costMult": 384, + "costAdd": 3000 + }, + { + "id": 102, + "skill": "Holy Fire", + "costMult": 384, + "costAdd": 3000 + }, + { + "id": 103, + "skill": "Thorns", + "costMult": 384, + "costAdd": 3000 + }, + { + "id": 104, + "skill": "Defiance", + "costMult": 384, + "costAdd": 3000 + }, + { + "id": 105, + "skill": "Resist Cold", + "costMult": 384, + "costAdd": 3000 + }, + { + "id": 106, + "skill": "Zeal", + "costMult": 512, + "costAdd": 8000 + }, + { + "id": 107, + "skill": "Charge", + "costMult": 512, + "costAdd": 8000 + }, + { + "id": 108, + "skill": "Blessed Aim", + "costMult": 512, + "costAdd": 8000 + }, + { + "id": 109, + "skill": "Cleansing", + "costMult": 512, + "costAdd": 8000 + }, + { + "id": 110, + "skill": "Resist Lightning", + "costMult": 512, + "costAdd": 8000 + }, + { + "id": 111, + "skill": "Vengeance", + "costMult": 640, + "costAdd": 16000 + }, + { + "id": 112, + "skill": "Blessed Hammer", + "costMult": 640, + "costAdd": 16000 + }, + { + "id": 113, + "skill": "Concentration", + "costMult": 640, + "costAdd": 16000 + }, + { + "id": 114, + "skill": "Holy Freeze", + "costMult": 640, + "costAdd": 16000 + }, + { + "id": 115, + "skill": "Vigor", + "costMult": 640, + "costAdd": 16000 + }, + { + "id": 116, + "skill": "Conversion", + "costMult": 768, + "costAdd": 32000 + }, + { + "id": 117, + "skill": "Holy Shield", + "costMult": 768, + "costAdd": 32000 + }, + { + "id": 118, + "skill": "Holy Shock", + "costMult": 768, + "costAdd": 32000 + }, + { + "id": 119, + "skill": "Sanctuary", + "costMult": 768, + "costAdd": 32000 + }, + { + "id": 120, + "skill": "Meditation", + "costMult": 768, + "costAdd": 32000 + }, + { + "id": 121, + "skill": "Fist of the Heavens", + "costMult": 896, + "costAdd": 64000 + }, + { + "id": 122, + "skill": "Fanaticism", + "costMult": 896, + "costAdd": 64000 + }, + { + "id": 123, + "skill": "Conviction", + "costMult": 896, + "costAdd": 64000 + }, + { + "id": 124, + "skill": "Redemption", + "costMult": 896, + "costAdd": 64000 + }, + { + "id": 125, + "skill": "Salvation", + "costMult": 896, + "costAdd": 64000 + }, + { + "id": 126, + "skill": "Bash", + "costMult": 256, + "costAdd": 1000 + }, + { + "id": 127, + "skill": "Sword Mastery", + "costMult": 256, + "costAdd": 1000 + }, + { + "id": 128, + "skill": "Axe Mastery", + "costMult": 256, + "costAdd": 1000 + }, + { + "id": 129, + "skill": "Mace Mastery", + "costMult": 256, + "costAdd": 1000 + }, + { + "id": 130, + "skill": "Howl", + "costMult": 256, + "costAdd": 1000 + }, + { + "id": 131, + "skill": "Find Potion", + "costMult": 256, + "costAdd": 3000 + }, + { + "id": 132, + "skill": "Leap", + "costMult": 384, + "costAdd": 3000 + }, + { + "id": 133, + "skill": "Double Swing", + "costMult": 384, + "costAdd": 3000 + }, + { + "id": 134, + "skill": "Pole Arm Mastery", + "costMult": 384, + "costAdd": 3000 + }, + { + "id": 135, + "skill": "Throwing Mastery", + "costMult": 384, + "costAdd": 3000 + }, + { + "id": 136, + "skill": "Spear Mastery", + "costMult": 384, + "costAdd": 3000 + }, + { + "id": 137, + "skill": "Taunt", + "costMult": 384, + "costAdd": 3000 + }, + { + "id": 138, + "skill": "Shout", + "costMult": 384, + "costAdd": 3000 + }, + { + "id": 139, + "skill": "Stun", + "costMult": 512, + "costAdd": 8000 + }, + { + "id": 140, + "skill": "Double Throw", + "costMult": 512, + "costAdd": 8000 + }, + { + "id": 141, + "skill": "Increased Stamina", + "costMult": 512, + "costAdd": 8000 + }, + { + "id": 142, + "skill": "Find Item", + "costMult": 512, + "costAdd": 8000 + }, + { + "id": 143, + "skill": "Leap Attack", + "costMult": 640, + "costAdd": 16000 + }, + { + "id": 144, + "skill": "Concentrate", + "costMult": 640, + "costAdd": 16000 + }, + { + "id": 145, + "skill": "Iron Skin", + "costMult": 640, + "costAdd": 16000 + }, + { + "id": 146, + "skill": "Battle Cry", + "costMult": 640, + "costAdd": 16000 + }, + { + "id": 147, + "skill": "Frenzy", + "costMult": 768, + "costAdd": 32000 + }, + { + "id": 148, + "skill": "Increased Speed", + "costMult": 768, + "costAdd": 32000 + }, + { + "id": 149, + "skill": "Battle Orders", + "costMult": 768, + "costAdd": 32000 + }, + { + "id": 150, + "skill": "Grim Ward", + "costMult": 768, + "costAdd": 32000 + }, + { + "id": 151, + "skill": "Whirlwind", + "costMult": 896, + "costAdd": 64000 + }, + { + "id": 152, + "skill": "Berserk", + "costMult": 896, + "costAdd": 64000 + }, + { + "id": 153, + "skill": "Natural Resistance", + "costMult": 896, + "costAdd": 64000 + }, + { + "id": 154, + "skill": "War Cry", + "costMult": 896, + "costAdd": 64000 + }, + { + "id": 155, + "skill": "Battle Command", + "costMult": 896, + "costAdd": 64000 + }, + { + "id": 156, + "skill": "Fire Hit", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 157, + "skill": "UnHolyBolt", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 158, + "skill": "SkeletonRaise", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 159, + "skill": "MaggotEgg", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 160, + "skill": "ShamanFire", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 161, + "skill": "MagottUp", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 162, + "skill": "MagottDown", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 163, + "skill": "MagottLay", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 164, + "skill": "AndrialSpray", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 165, + "skill": "Jump", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 166, + "skill": "Swarm Move", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 167, + "skill": "Nest", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 168, + "skill": "Quick Strike", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 169, + "skill": "VampireFireball", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 170, + "skill": "VampireFirewall", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 171, + "skill": "VampireMeteor", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 172, + "skill": "GargoyleTrap", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 173, + "skill": "SpiderLay", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 174, + "skill": "VampireHeal", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 175, + "skill": "VampireRaise", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 176, + "skill": "Submerge", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 177, + "skill": "FetishAura", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 178, + "skill": "FetishInferno", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 179, + "skill": "ZakarumHeal", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 180, + "skill": "Emerge", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 181, + "skill": "Resurrect", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 182, + "skill": "Bestow", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 183, + "skill": "MissileSkill1", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 184, + "skill": "MonTeleport", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 185, + "skill": "PrimeLightning", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 186, + "skill": "PrimeBolt", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 187, + "skill": "PrimeBlaze", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 188, + "skill": "PrimeFirewall", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 189, + "skill": "PrimeSpike", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 190, + "skill": "PrimeIceNova", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 191, + "skill": "PrimePoisonball", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 192, + "skill": "PrimePoisonNova", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 193, + "skill": "DiabLight", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 194, + "skill": "DiabCold", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 195, + "skill": "DiabFire", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 196, + "skill": "FingerMageSpider", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 197, + "skill": "DiabWall", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 198, + "skill": "DiabRun", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 199, + "skill": "DiabPrison", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 200, + "skill": "PoisonBallTrap", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 201, + "skill": "AndyPoisonBolt", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 202, + "skill": "HireableMissile", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 203, + "skill": "DesertTurret", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 204, + "skill": "ArcaneTower", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 205, + "skill": "MonBlizzard", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 206, + "skill": "Mosquito", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 207, + "skill": "CursedBallTrapRight", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 208, + "skill": "CursedBallTrapLeft", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 209, + "skill": "MonFrozenArmor", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 210, + "skill": "MonBoneArmor", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 211, + "skill": "MonBoneSpirit", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 212, + "skill": "MonCurseCast", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 213, + "skill": "HellMeteor", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 214, + "skill": "RegurgitatorEat", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 215, + "skill": "MonFrenzy", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 216, + "skill": "QueenDeath", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 217, + "skill": "Scroll of Identify", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 218, + "skill": "Book of Identify", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 219, + "skill": "Scroll of Townportal", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 220, + "skill": "Book of Townportal", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 221, + "skill": "Raven", + "costMult": 256, + "costAdd": 1000 + }, + { + "id": 222, + "skill": "Plague Poppy", + "costMult": 256, + "costAdd": 1000 + }, + { + "id": 223, + "skill": "Wearwolf", + "costMult": 256, + "costAdd": 1000 + }, + { + "id": 224, + "skill": "Shape Shifting", + "costMult": 256, + "costAdd": 1000 + }, + { + "id": 225, + "skill": "Firestorm", + "costMult": 256, + "costAdd": 1000 + }, + { + "id": 226, + "skill": "Oak Sage", + "costMult": 384, + "costAdd": 3000 + }, + { + "id": 227, + "skill": "Summon Spirit Wolf", + "costMult": 384, + "costAdd": 3000 + }, + { + "id": 228, + "skill": "Wearbear", + "costMult": 384, + "costAdd": 3000 + }, + { + "id": 229, + "skill": "Molten Boulder", + "costMult": 384, + "costAdd": 3000 + }, + { + "id": 230, + "skill": "Arctic Blast", + "costMult": 384, + "costAdd": 3000 + }, + { + "id": 231, + "skill": "Cycle of Life", + "costMult": 512, + "costAdd": 3000 + }, + { + "id": 232, + "skill": "Feral Rage", + "costMult": 512, + "costAdd": 3000 + }, + { + "id": 233, + "skill": "Maul", + "costMult": 512, + "costAdd": 3000 + }, + { + "id": 234, + "skill": "Eruption", + "costMult": 512, + "costAdd": 8000 + }, + { + "id": 235, + "skill": "Cyclone Armor", + "costMult": 512, + "costAdd": 8000 + }, + { + "id": 236, + "skill": "Heart of Wolverine", + "costMult": 640, + "costAdd": 8000 + }, + { + "id": 237, + "skill": "Summon Fenris", + "costMult": 640, + "costAdd": 8000 + }, + { + "id": 238, + "skill": "Rabies", + "costMult": 640, + "costAdd": 16000 + }, + { + "id": 239, + "skill": "Fire Claws", + "costMult": 640, + "costAdd": 16000 + }, + { + "id": 240, + "skill": "Twister", + "costMult": 640, + "costAdd": 16000 + }, + { + "id": 241, + "skill": "Vines", + "costMult": 768, + "costAdd": 16000 + }, + { + "id": 242, + "skill": "Hunger", + "costMult": 768, + "costAdd": 32000 + }, + { + "id": 243, + "skill": "Shock Wave", + "costMult": 768, + "costAdd": 32000 + }, + { + "id": 244, + "skill": "Volcano", + "costMult": 768, + "costAdd": 32000 + }, + { + "id": 245, + "skill": "Tornado", + "costMult": 768, + "costAdd": 32000 + }, + { + "id": 246, + "skill": "Spirit of Barbs", + "costMult": 896, + "costAdd": 64000 + }, + { + "id": 247, + "skill": "Summon Grizzly", + "costMult": 896, + "costAdd": 64000 + }, + { + "id": 248, + "skill": "Fury", + "costMult": 896, + "costAdd": 64000 + }, + { + "id": 249, + "skill": "Armageddon", + "costMult": 896, + "costAdd": 64000 + }, + { + "id": 250, + "skill": "Hurricane", + "costMult": 896, + "costAdd": 64000 + }, + { + "id": 251, + "skill": "Fire Trauma", + "costMult": 256, + "costAdd": 1000 + }, + { + "id": 252, + "skill": "Claw Mastery", + "costMult": 256, + "costAdd": 1000 + }, + { + "id": 253, + "skill": "Psychic Hammer", + "costMult": 256, + "costAdd": 1000 + }, + { + "id": 254, + "skill": "Tiger Strike", + "costMult": 256, + "costAdd": 1000 + }, + { + "id": 255, + "skill": "Dragon Talon", + "costMult": 256, + "costAdd": 1000 + }, + { + "id": 256, + "skill": "Shock Field", + "costMult": 384, + "costAdd": 3000 + }, + { + "id": 257, + "skill": "Blade Sentinel", + "costMult": 384, + "costAdd": 3000 + }, + { + "id": 258, + "skill": "Quickness", + "costMult": 384, + "costAdd": 3000 + }, + { + "id": 259, + "skill": "Fists of Fire", + "costMult": 384, + "costAdd": 3000 + }, + { + "id": 260, + "skill": "Dragon Claw", + "costMult": 384, + "costAdd": 3000 + }, + { + "id": 261, + "skill": "Charged Bolt Sentry", + "costMult": 512, + "costAdd": 3000 + }, + { + "id": 262, + "skill": "Wake of Fire Sentry", + "costMult": 512, + "costAdd": 3000 + }, + { + "id": 263, + "skill": "Weapon Block", + "costMult": 512, + "costAdd": 3000 + }, + { + "id": 264, + "skill": "Cloak of Shadows", + "costMult": 512, + "costAdd": 8000 + }, + { + "id": 265, + "skill": "Cobra Strike", + "costMult": 512, + "costAdd": 8000 + }, + { + "id": 266, + "skill": "Blade Fury", + "costMult": 640, + "costAdd": 8000 + }, + { + "id": 267, + "skill": "Fade", + "costMult": 640, + "costAdd": 8000 + }, + { + "id": 268, + "skill": "Shadow Warrior", + "costMult": 640, + "costAdd": 16000 + }, + { + "id": 269, + "skill": "Claws of Thunder", + "costMult": 640, + "costAdd": 16000 + }, + { + "id": 270, + "skill": "Dragon Tail", + "costMult": 640, + "costAdd": 16000 + }, + { + "id": 271, + "skill": "Lightning Sentry", + "costMult": 768, + "costAdd": 16000 + }, + { + "id": 272, + "skill": "Inferno Sentry", + "costMult": 768, + "costAdd": 32000 + }, + { + "id": 273, + "skill": "Mind Blast", + "costMult": 768, + "costAdd": 32000 + }, + { + "id": 274, + "skill": "Blades of Ice", + "costMult": 768, + "costAdd": 32000 + }, + { + "id": 275, + "skill": "Dragon Flight", + "costMult": 768, + "costAdd": 32000 + }, + { + "id": 276, + "skill": "Death Sentry", + "costMult": 896, + "costAdd": 64000 + }, + { + "id": 277, + "skill": "Blade Shield", + "costMult": 896, + "costAdd": 64000 + }, + { + "id": 278, + "skill": "Venom", + "costMult": 896, + "costAdd": 64000 + }, + { + "id": 279, + "skill": "Shadow Master", + "costMult": 896, + "costAdd": 64000 + }, + { + "id": 280, + "skill": "Royal Strike", + "costMult": 896, + "costAdd": 64000 + }, + { + "id": 281, + "skill": "Wake Of Destruction Sentry", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 282, + "skill": "Imp Inferno", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 283, + "skill": "Imp Fireball", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 284, + "skill": "Baal Taunt", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 285, + "skill": "Baal Corpse Explode", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 286, + "skill": "Baal Monster Spawn", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 287, + "skill": "Catapult Charged Ball", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 288, + "skill": "Catapult Spike Ball", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 289, + "skill": "Suck Blood", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 290, + "skill": "Cry Help", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 291, + "skill": "Healing Vortex", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 292, + "skill": "Teleport 2", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 293, + "skill": "Self-resurrect", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 294, + "skill": "Vine Attack", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 295, + "skill": "Overseer Whip", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 296, + "skill": "Barbs Aura", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 297, + "skill": "Wolverine Aura", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 298, + "skill": "Oak Sage Aura", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 299, + "skill": "Imp Fire Missile", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 300, + "skill": "Impregnate", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 301, + "skill": "Siege Beast Stomp", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 302, + "skill": "MinionSpawner", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 303, + "skill": "CatapultBlizzard", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 304, + "skill": "CatapultPlague", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 305, + "skill": "CatapultMeteor", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 306, + "skill": "BoltSentry", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 307, + "skill": "CorpseCycler", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 308, + "skill": "DeathMaul", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 309, + "skill": "Defense Curse", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 310, + "skill": "Blood Mana", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 311, + "skill": "mon inferno sentry", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 312, + "skill": "mon death sentry", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 313, + "skill": "sentry lightning", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 314, + "skill": "fenris rage", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 315, + "skill": "Baal Tentacle", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 316, + "skill": "Baal Nova", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 317, + "skill": "Baal Inferno", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 318, + "skill": "Baal Cold Missiles", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 319, + "skill": "MegademonInferno", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 320, + "skill": "EvilHutSpawner", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 321, + "skill": "CountessFirewall", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 322, + "skill": "ImpBolt", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 323, + "skill": "Horror Arctic Blast", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 324, + "skill": "death sentry ltng", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 325, + "skill": "VineCycler", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 326, + "skill": "BearSmite", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 327, + "skill": "Resurrect2", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 328, + "skill": "BloodLordFrenzy", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 329, + "skill": "Baal Teleport", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 330, + "skill": "Imp Teleport", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 331, + "skill": "Baal Clone Teleport", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 332, + "skill": "ZakarumLightning", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 333, + "skill": "VampireMissile", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 334, + "skill": "MephistoMissile", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 335, + "skill": "DoomKnightMissile", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 336, + "skill": "RogueMissile", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 337, + "skill": "HydraMissile", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 338, + "skill": "NecromageMissile", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 339, + "skill": "MonBow", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 340, + "skill": "MonFireArrow", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 341, + "skill": "MonColdArrow", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 342, + "skill": "MonExplodingArrow", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 343, + "skill": "MonFreezingArrow", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 344, + "skill": "MonPowerStrike", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 345, + "skill": "SuccubusBolt", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 346, + "skill": "MephFrostNova", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 347, + "skill": "MonIceSpear", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 348, + "skill": "ShamanIce", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 349, + "skill": "Diablogeddon", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 350, + "skill": "Delerium Change", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 351, + "skill": "NihlathakCorpseExplosion", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 352, + "skill": "SerpentCharge", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 353, + "skill": "Trap Nova", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 354, + "skill": "UnHolyBoltEx", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 355, + "skill": "ShamanFireEx", + "costMult": 0, + "costAdd": 0 + }, + { + "id": 356, + "skill": "Imp Fire Missile Ex", + "costMult": 0, + "costAdd": 0 + } +] diff --git a/src/game/armor.ts b/src/game/armor.ts index 579a3ff..0f5470e 100644 --- a/src/game/armor.ts +++ b/src/game/armor.ts @@ -12,6 +12,7 @@ import type { D2Table } from './acts.ts' import { parseTable } from './acts.ts' import type { MountedArchives } from '../mpq/mount.ts' import type { ItemBase } from './items.ts' +import { VENDOR_COLUMN_PREFIXES, VENDOR_COUNT, type VendorItemColumns } from './npc-table.ts' /** Canonical MPQ path of `Armor.txt`. */ export const ARMOR_TABLE_PATH = 'data\\global\\excel\\Armor.txt' @@ -87,6 +88,14 @@ export interface ArmorBase extends ItemBase { readonly damage: number /** Optional AutoMagic group from 'auto prefix' column (e.g. 304 for Paladin shields, 305 for Shrunken Heads). */ readonly autoPrefix?: number | undefined + readonly nightmareUpgrade?: string | undefined + readonly hellUpgrade?: string | undefined + readonly permStoreItem?: boolean | undefined + readonly multibuy?: boolean | undefined + readonly bitfield1?: number | undefined + readonly quest?: number | undefined + readonly questDiffCheck?: boolean | undefined + readonly vendorColumns?: Readonly> | undefined } /** @@ -223,6 +232,33 @@ export function parseArmorTable(table: D2Table): ArmorTable { const dropsound = strAt(row, 'dropsound') || undefined const dropsfxframe = numAt(row, 'dropsfxframe', 0) || undefined + const nightmareUpgrade = strAt(row, 'NightmareUpgrade') || 'xxx' + const hellUpgrade = strAt(row, 'HellUpgrade') || 'xxx' + const permStoreItem = strAt(row, 'PermStoreItem') === '1' + const multibuy = strAt(row, 'multibuy') === '1' + const bitfield1 = numAt(row, 'bitfield1', 0) + const quest = numAt(row, 'quest', 0) + const questDiffCheck = strAt(row, 'questdiffcheck') === '1' + + const vendorColumns: Record = {} + for (let vId = 0; vId < VENDOR_COUNT; vId++) { + const prefix = VENDOR_COLUMN_PREFIXES[vId]! + const min = numAt(row, `${prefix}Min`, 0) + const max = numAt(row, `${prefix}Max`, 0) + const magicMin = numAt(row, `${prefix}MagicMin`, 0) + const magicMax = numAt(row, `${prefix}MagicMax`, 0) + const magicLvl = numAt(row, `${prefix}MagicLvl`, 0) + if (min > 0 || max > 0 || magicMin > 0 || magicMax > 0) { + vendorColumns[vId] = { + min, + max, + magicMin, + magicMax, + magicLvl: magicLvl > 0 ? magicLvl : 255, + } + } + } + const armor: ArmorBase = { id: code, code, @@ -257,6 +293,14 @@ export function parseArmorTable(table: D2Table): ArmorTable { ...(autoPrefix !== undefined ? { autoPrefix } : {}), ...(dropsound ? { dropsound } : {}), ...(dropsfxframe !== undefined ? { dropsfxframe } : {}), + nightmareUpgrade, + hellUpgrade, + permStoreItem, + multibuy, + bitfield1, + quest, + questDiffCheck, + vendorColumns, } all.push(armor) diff --git a/src/game/drop-pipeline.ts b/src/game/drop-pipeline.ts index 3b0d3d8..79b5fc4 100644 --- a/src/game/drop-pipeline.ts +++ b/src/game/drop-pipeline.ts @@ -149,6 +149,7 @@ import { resolveRareNameEn, resolveMagicNameEn, } from './tooltip-i18n.ts' +import type { NpcTable, BooksTable, SkillCostMap } from './npc-table.ts' export { rollAutoMagic, @@ -181,6 +182,11 @@ export interface DropTables { readonly autoMagic?: AutoMagicTable | undefined readonly properties?: PropertiesTable | undefined readonly itemStatCost?: ItemStatCostTable | undefined + readonly npcTable?: NpcTable | undefined + readonly gambleCodes?: readonly string[] | undefined + readonly booksTable?: BooksTable | undefined + readonly lowQualityItems?: readonly string[] | undefined + readonly skillCosts?: SkillCostMap | undefined readonly itemTypes: ItemTypeTable readonly monsterKinds: Map readonly superUniques: Map diff --git a/src/game/embedded-drop-tables.ts b/src/game/embedded-drop-tables.ts index a09579f..4962ddb 100644 --- a/src/game/embedded-drop-tables.ts +++ b/src/game/embedded-drop-tables.ts @@ -25,6 +25,15 @@ import { RAW_SUPERUNIQUES, RAW_DIFFICULTY_LEVELS, } from '../data/canonical-drop-data.ts' +import { + RAW_NPC, + RAW_GAMBLE, + RAW_BOOKS, + RAW_LOW_QUALITY_ITEMS, + RAW_ITEM_STAT_COST, + RAW_PROPERTIES, + CANONICAL_SKILL_COSTS, +} from '../data/canonical-vendor-data.ts' import { parseTable } from './acts.ts' import { parseTreasureClassTable, type TreasureClassNode } from './treasure-class.ts' import { parseWeaponsTable } from './weapons.ts' @@ -47,6 +56,19 @@ import type { DropTables } from './drop-pipeline.ts' import type { ItemBase } from './items.ts' import { CANONICAL_AUTOMAGIC_TABLE } from './automagic.ts' import { readMonsterKinds, readSuperUniques, type SuperUnique } from './monsters.ts' +import { + parseItemStatCostTable, + parsePropertiesTable, + setDefaultItemPropertiesAndStats, +} from './properties.ts' +import { setRuntimePropertyTables } from './item-tooltip.ts' +import { + parseNpcTable, + parseGambleTable, + parseBooksTable, + parseLowQualityTable, + buildSkillCostMap, +} from './npc-table.ts' let _cachedDropTables: DropTables | null = null @@ -103,6 +125,17 @@ export function getEmbeddedDropTables(): DropTables { superUniques.set(su.id, su) } + const itemStatCost = parseItemStatCostTable(parseTable(enc.encode(RAW_ITEM_STAT_COST))) + const properties = parsePropertiesTable(parseTable(enc.encode(RAW_PROPERTIES))) + setDefaultItemPropertiesAndStats({ stats: itemStatCost, properties }) + setRuntimePropertyTables(properties, itemStatCost) + + const npcTable = parseNpcTable(RAW_NPC) + const gambleCodes = parseGambleTable(RAW_GAMBLE) + const booksTable = parseBooksTable(RAW_BOOKS) + const lowQualityItems = parseLowQualityTable(RAW_LOW_QUALITY_ITEMS) + const skillCosts = buildSkillCostMap(CANONICAL_SKILL_COSTS) + _cachedDropTables = { tcTable, autoTcTable, @@ -117,6 +150,13 @@ export function getEmbeddedDropTables(): DropTables { magicAffixes, rareNames, autoMagic: CANONICAL_AUTOMAGIC_TABLE, + properties, + itemStatCost, + npcTable, + gambleCodes, + booksTable, + lowQualityItems, + skillCosts, itemTypes, monsterKinds, superUniques, @@ -126,3 +166,4 @@ export function getEmbeddedDropTables(): DropTables { return _cachedDropTables! } + diff --git a/src/game/item-cost.ts b/src/game/item-cost.ts new file mode 100644 index 0000000..b3e783a --- /dev/null +++ b/src/game/item-cost.ts @@ -0,0 +1,786 @@ +import type { DropTables } from './drop-pipeline.ts' +import { ItemFlag, ItemQuality, type Item, type ItemBase, type RolledItemProp } from './items.ts' +import { getItemTypeCode } from './affix-generator.ts' +import type { ItemTypeTable } from './item-types.ts' +import { VendorNpcId, getTownNpcDescriptorByVendorId, type NpcRecord } from './npc-table.ts' + +/** + * Canonical 1.13c transaction types (`D2C_TransactionTypes` in D2Common.dll). + */ +export const enum TransactionType { + BUY = 0, + SELL = 1, + GAMBLE = 2, + REPAIR = 3, +} + +/** + * Normal difficulty fallback Max Buy caps by Act (Acts 1..5), Nightmare, and Hell. + */ +export const DEFAULT_NORMAL_MAX_BUY_BY_ACT: readonly [number, number, number, number, number] = [ + 5000, 10000, 15000, 20000, 25000, +] as const +export const DEFAULT_NIGHTMARE_MAX_BUY = 30000 +export const DEFAULT_HELL_MAX_BUY = 35000 + +export interface TransactionCostOptions { + readonly item: Item + readonly transactionType: TransactionType + readonly difficulty?: 0 | 1 | 2 | undefined + readonly cLvl?: number | undefined + readonly vendorId?: VendorNpcId | undefined + readonly npcKey?: string | undefined + readonly actIndex?: 0 | 1 | 2 | 3 | 4 | undefined + readonly completedQuests?: ReadonlySet | undefined + readonly reducedPricesPercent?: number | undefined + readonly tables: DropTables +} + +/** + * Checks whether an item base belongs to a class-specific ItemType (`ItemTypes.Class !== ''`, i.e., `nClass < 7`). + */ +export function isClassSpecificItemBase(base: ItemBase, itemTypes: ItemTypeTable): boolean { + const startType = getItemTypeCode(base).trim() + const queue: string[] = [startType, ...(base.tags ?? [])] + const visited = new Set() + + while (queue.length > 0) { + const code = queue.shift()!.trim() + if (!code || visited.has(code.toLowerCase())) continue + visited.add(code.toLowerCase()) + + const def = itemTypes.byCode.get(code) + if (!def) continue + if (def.class && def.class.trim().length > 0) { + return true + } + if (def.equiv1) queue.push(def.equiv1) + if (def.equiv2) queue.push(def.equiv2) + } + return false +} + +/** + * Checks whether an item base can receive StaffMods (`ItemTypes.StaffMods !== ''`, i.e., `nStaffMods != 7`). + */ +export function hasStaffModsItemType(base: ItemBase, itemTypes: ItemTypeTable): boolean { + const startType = getItemTypeCode(base).trim() + const queue: string[] = [startType, ...(base.tags ?? [])] + const visited = new Set() + + while (queue.length > 0) { + const code = queue.shift()!.trim() + if (!code || visited.has(code.toLowerCase())) continue + visited.add(code.toLowerCase()) + + const def = itemTypes.byCode.get(code) + if (!def) continue + if (def.staffMods && def.staffMods.trim().length > 0) { + return true + } + if (def.equiv1) queue.push(def.equiv1) + if (def.equiv2) queue.push(def.equiv2) + } + return false +} + +export function isQuiverItem(base: ItemBase): boolean { + const t = (base.type ?? '').toLowerCase() + const c = base.id.toLowerCase() + return t === 'bowq' || t === 'xbowq' || c === 'aqv' || c === 'cqv' +} + +export function isBookItem(base: ItemBase): boolean { + const t = (base.type ?? '').toLowerCase() + const c = base.id.toLowerCase() + return t === 'book' || c === 'tbk' || c === 'ibk' +} + +export function isItemRepairable(item: Item): boolean { + const isEthereal = + Boolean(item.flags?.ethereal) || + Boolean((item as any).ethereal) || + ((item.rawFlags ?? 0) & ItemFlag.ETHEREAL) !== 0 + if (isEthereal) return false + + if (isQuiverItem(item.base) || isBookItem(item.base)) return false + + const hasIndestructible = Boolean( + item.rolledProps?.some(p => p.code.toLowerCase() === 'indestruct') || + item.stats?.indestruct, + ) + if (hasIndestructible) return false + + const maxDur = item.maxDurability ?? Number((item.base as any).durability) ?? 0 + const curDur = item.durability ?? maxDur + if (maxDur > 0 && curDur < maxDur) { + return true + } + + // Also repairable if stackable throwing weapon below maxStack + if ((item.base as any).stackable && item.base.kind === 'weapon') { + const maxStack = item.base.maxStack || Number((item.base as any).maxstack) || 1 + const qty = item.quantity ?? item.stack ?? maxStack + if (qty < maxStack) return true + } + + // Also repairable if it has partially depleted charged skills + if (item.rolledProps?.some(p => p.code.toLowerCase() === 'charged')) { + return true + } + + return false +} + +/** + * Implements `ITEMS_CalculateAdditionalCostsForItemSkill` (`0x6FD9CB50`) + * for single-skill staffmods (`STAT_ITEM_SINGLESKILL`) on StaffMods-eligible item types. + */ +export function calculateAdditionalCostsForItemSkill( + item: Item, + sellCost: number, + buyCost: number, + repCost: number, + divisor: number, + tables: DropTables, +): { sellCost: number; buyCost: number; repCost: number } { + if (!hasStaffModsItemType(item.base, tables.itemTypes)) { + return { sellCost, buyCost, repCost } + } + + const staffMods: readonly RolledItemProp[] = + item.staffMods ?? + (item.rolledProps?.filter(p => p.code.toLowerCase() === 'skill') ?? []) + + if (staffMods.length === 0) { + return { sellCost, buyCost, repCost } + } + + let nSell = 0 + let nBuy = 0 + let nRep = 0 + + for (const sm of staffMods) { + const skillId = Number(sm.param) + const slvl = Math.max(1, Math.trunc(sm.value)) + const skillRec = tables.skillCosts?.get(skillId) + if (!skillRec) continue + + const factor = 2 * slvl - 1 + nSell += factor * (skillRec.costAdd + Math.trunc((sellCost * skillRec.costMult) / 1024)) + nBuy += factor * (skillRec.costAdd + Math.trunc((buyCost * skillRec.costMult) / 4096)) + nRep += factor * (skillRec.costAdd + Math.trunc((repCost * skillRec.costMult) / 1024)) + } + + const effDiv = Math.max(1, divisor) + return { + sellCost: sellCost + Math.trunc(nSell / effDiv), + buyCost: buyCost + Math.trunc(nBuy / effDiv), + repCost: repCost + Math.trunc(nRep / effDiv), + } +} + +/** + * Implements `ITEMS_CalculateAdditionalCostsForBonusStats` (`0x6FD9ACE0`). + * Iterates resolved bonus stats on the item and applies `ItemStatCost.txt` + * (`Encode`, `Add`, `Multiply`, `Divide`) or `Skills.txt` (`CostAdd`, `CostMult`). + */ +export function calculateAdditionalCostsForBonusStats( + item: Item, + sellCost: number, + buyCost: number, + repCost: number, + divisor: number, + tables: DropTables, +): { sellCost: number; buyCost: number; repCost: number } { + const propertiesTable = tables.properties + const statCostTable = tables.itemStatCost + if (!statCostTable) { + return { sellCost, buyCost, repCost } + } + + let nSell = 0 + let nBuy = 0 + let nRep = 0 + + // Collect resolved stats from rolledProps (excluding staffMods 'skill' which are handled by calculateAdditionalCostsForItemSkill) + const staffModSet = new Set(item.staffMods ?? []) + const props = item.rolledProps ?? [] + + interface StatBonusEntry { + readonly statId: number + readonly param: number + readonly value: number + readonly propCode?: string + readonly min?: number + readonly max?: number + } + + const entries: StatBonusEntry[] = [] + + if (props.length > 0 && propertiesTable) { + for (const prop of props) { + if (staffModSet.has(prop)) continue + if (prop.code.toLowerCase() === 'skill' && hasStaffModsItemType(item.base, tables.itemTypes)) { + continue + } + const numParam = + typeof prop.param === 'number' + ? prop.param + : typeof prop.param === 'string' && prop.param.trim() !== '' + ? parseInt(prop.param, 10) || 0 + : 0 + const resolved = propertiesTable.resolveProperty( + prop.code, + numParam, + prop.value, + prop.value, + statCostTable, + ) + if (resolved.length > 0) { + for (const r of resolved) { + entries.push({ + statId: r.statId, + param: r.param ?? numParam, + value: r.value, + propCode: prop.code, + min: prop.min, + max: prop.max, + }) + } + } + } + } else if (item.statList && item.statList.length > 0) { + for (const s of item.statList) { + entries.push({ + statId: s.statId, + param: s.param ?? 0, + value: s.value, + }) + } + } + + for (const entry of entries) { + const statRec = statCostTable.getById(entry.statId) + if (!statRec) continue + // STAT_ITEM_SINGLESKILL (107) on staffMod items is priced by calculateAdditionalCostsForItemSkill + if (statRec.id === 107 && hasStaffModsItemType(item.base, tables.itemTypes)) { + continue + } + const nBonusValue = entry.value + if (!nBonusValue) continue + + switch (statRec.encode) { + case 1: { + const skillRec = tables.skillCosts?.get(entry.param) + if (skillRec) { + nSell += skillRec.costAdd + Math.trunc((nBonusValue * sellCost * skillRec.costMult) / 1024) + nBuy += skillRec.costAdd + Math.trunc((nBonusValue * buyCost * skillRec.costMult) / 4096) + nRep += skillRec.costAdd + Math.trunc((nBonusValue * repCost * skillRec.costMult) / 1024) + } + break + } + case 2: + case 3: { + const skillId = entry.param > 63 ? entry.param >> 6 : entry.param + const slvl = entry.param > 63 ? Math.max(1, entry.param & 0x3f) : Math.max(1, entry.max ?? 1) + const skillRec = tables.skillCosts?.get(skillId) + if (skillRec) { + nSell += skillRec.costAdd + Math.trunc((slvl * sellCost * skillRec.costMult) / 1024) + nBuy += skillRec.costAdd + Math.trunc((slvl * buyCost * skillRec.costMult) / 4096) + nRep += skillRec.costAdd + Math.trunc((slvl * repCost * skillRec.costMult) / 1024) + } + break + } + default: { + if (statRec.multiply > 0 || statRec.add > 0) { + nSell += statRec.add + Math.trunc((nBonusValue * sellCost * statRec.multiply) / 1024) + nBuy += statRec.add + Math.trunc((nBonusValue * buyCost * statRec.multiply) / 1024) + nRep += statRec.add + Math.trunc((nBonusValue * repCost * statRec.multiply) / 1024) + } + break + } + } + } + + const effDiv = Math.max(1, divisor) + return { + sellCost: sellCost + Math.trunc(nSell / effDiv), + buyCost: buyCost + Math.trunc(nBuy / effDiv), + repCost: repCost + Math.trunc(nRep / effDiv), + } +} + +/** + * Calculates the 1.13c Gamble purchase price for an item (`ITEMS_CalculateTransactionCost` branch `TRANSACTIONTYPE_GAMBLE` at `0x6FD9B1C0`). + */ +export function calculateGambleCost(options: { + readonly item: Item + readonly cLvl: number + readonly reducedPricesPercent?: number | undefined + readonly tables: DropTables +}): number { + const { item, cLvl, reducedPricesPercent = 0, tables } = options + const normCode = item.base.normcode || item.base.id + const normBase = tables.getBase(normCode) ?? item.base + const codeLower = normBase.id.toLowerCase() + + let nCost = 0 + if (codeLower === 'rin' || codeLower === 'amu') { + nCost = normBase.gambleCost ?? (codeLower === 'rin' ? 50000 : 63000) + } else { + const minStack = Number((normBase as any).minstack) || 0 + const maxStack = Number((normBase as any).maxstack) || 0 + const v27 = Math.max(1, Math.trunc((minStack + maxStack) / 2)) + + let v23 = 0 + let nUberCost = 0 + if (normBase.ubercode && normBase.ubercode !== '0' && normBase.ubercode !== 'xxx') { + const uberBase = tables.getBase(normBase.ubercode) + if (uberBase) { + const v30 = 100 * (cLvl - uberBase.level) + v23 = Math.max(0, Math.trunc(v30 / 2) + 1) + nUberCost = uberBase.cost ?? uberBase.value + } + } + + let v22 = 0 + let nUltraCost = 0 + if (normBase.ultracode && normBase.ultracode !== '0' && normBase.ultracode !== 'xxx') { + const ultraBase = tables.getBase(normBase.ultracode) + if (ultraBase) { + const v30 = 100 * (cLvl - ultraBase.level) + v22 = Math.max(0, Math.trunc(v30 / 4) + 1) + nUltraCost = ultraBase.cost ?? ultraBase.value + } + } + + const normCost = normBase.cost ?? normBase.value + const nBase = + v23 * nUberCost + + v22 * nUltraCost + + v27 * normCost * (10000 - v22 - v23) + + const effLvl = Math.max(5, Math.trunc(cLvl)) + const baseQlvl = normBase.level + const lvlExcess = baseQlvl - 45 > 0 ? baseQlvl - 45 : 0 + + const factorA = Math.trunc((2 * effLvl + 1) / 3) + 20 + const factorB = + Math.trunc(nBase / 10000) + + Math.trunc((250 * (effLvl + lvlExcess - Math.trunc(baseQlvl / 2))) / 3) + + nCost = Math.trunc((factorA * factorB) / 15) + } + + const clampedReduce = Math.min(99, Math.max(0, Math.trunc(reducedPricesPercent))) + if (clampedReduce > 0) { + nCost -= Math.trunc((nCost * clampedReduce) / 100) + } + + return Math.max(1, nCost) +} + +/** + * Resolves the NpcRecord for a vendor / town NPC. + */ +function resolveNpcRecord( + tables: DropTables, + vendorId?: VendorNpcId, + npcKey?: string, +): NpcRecord | undefined { + if (npcKey && tables.npcTable) { + const byKey = tables.npcTable.get(npcKey) + if (byKey) return byKey + } + if (vendorId !== undefined && tables.npcTable) { + return tables.npcTable.getByVendorId(vendorId) + } + return undefined +} + +/** + * Full 1.13c item transaction & acquisition pricing engine (`ITEMS_CalculateTransactionCost` at `0x6FD9B1C0`). + */ +export function calculateTransactionCost(options: TransactionCostOptions): number { + const { + item, + transactionType, + difficulty = 0, + cLvl = 1, + vendorId, + npcKey, + actIndex, + completedQuests, + reducedPricesPercent = 0, + tables, + } = options + + if (transactionType === TransactionType.REPAIR && !isItemRepairable(item)) { + return 0 + } + + const isStarter = + Boolean(item.flags?.starterItem) || + ((item.rawFlags ?? 0) & ItemFlag.STARTER_ITEM) !== 0 + if (isStarter) { + return 1 + } + + if (transactionType === TransactionType.GAMBLE) { + return calculateGambleCost({ + item, + cLvl, + reducedPricesPercent, + tables, + }) + } + + const nQuantity = Math.max(1, Math.trunc(item.quantity ?? item.stack ?? 1)) + const clampedReduce = Math.min(99, Math.max(0, Math.trunc(reducedPricesPercent))) + const baseCost = item.base.cost ?? item.base.value ?? 1 + + let nSellCost = 0 + let nBuyCost = 0 + let nRepCost = 0 + let nDivisor = 1 + + const isEar = + Boolean(item.flags?.isEar) || + ((item.rawFlags ?? 0) & ItemFlag.IS_EAR) !== 0 + + if (isEar) { + const earLvl = item.earData?.level ?? 1 + nSellCost = baseCost * earLvl + nBuyCost = nSellCost + } else if (isBookItem(item.base)) { + const bookRec = tables.booksTable?.getByBookCode(item.base.id) + const costPerCharge = bookRec?.costPerCharge ?? 80 + nSellCost = baseCost + nQuantity * costPerCharge + nBuyCost = nSellCost + } else if (isQuiverItem(item.base)) { + nSellCost = Math.trunc((nQuantity * baseCost) / 1024) + nBuyCost = nSellCost + const maxStack = Math.min(511, item.base.maxStack || 350) + nRepCost = Math.trunc((maxStack * baseCost) / 1024) + } else if (!(item.base as any).stackable) { + nSellCost = baseCost + nBuyCost = nSellCost + nRepCost = nSellCost + } else { + nSellCost = baseCost + nBuyCost = nSellCost + nRepCost = nSellCost + const maxStack = item.base.maxStack || Number((item.base as any).maxstack) || 1 + nDivisor = Math.max(1, Math.min(511, maxStack)) + } + + // Armor defense scaling (lines 2119-2131 of Items.cpp) + if (item.base.kind === 'armor') { + const minAc = Number((item.base as any).minac) || 0 + const maxAc = Number((item.base as any).maxac) || item.base.defense || 0 + if (maxAc > 0 && maxAc - minAc !== -1) { + // Base unit STAT_ARMORCLASS (before ac% bonus stat) + const acPct = item.stats?.['ac%'] ?? 0 + const rawDef = item.defense ?? item.stats?.defense ?? maxAc + const baseAc = + acPct > 0 + ? Math.min(maxAc + 1, Math.max(minAc, Math.round((rawDef * 100) / (100 + acPct)))) + : Math.min(maxAc, Math.max(1, rawDef)) + nSellCost = Math.trunc((baseAc * baseCost) / maxAc) + nBuyCost = nSellCost + nRepCost = nSellCost + } + } + + const quality = item.quality ?? ItemQuality.NORMAL + + // StaffMods for Normal / Low / Superior items (lines 2135-2138) + if (quality < ItemQuality.MAGIC || quality > ItemQuality.TEMPERED) { + const afterSkill = calculateAdditionalCostsForItemSkill( + item, + nSellCost, + nBuyCost, + nRepCost, + nDivisor, + tables, + ) + nSellCost = afterSkill.sellCost + nBuyCost = afterSkill.buyCost + nRepCost = afterSkill.repCost + } + + const isIdentified = item.flags?.identified !== false && !item.isGambleMystery + + if (isIdentified) { + let nSell = 0 + let nBuy = 0 + let nRep = 0 + + // AutoMagic affix cost (lines 2142-2157) + const autoAffix = (item as any).rolledAutoMagic?.entry + if (autoAffix && (autoAffix.add || autoAffix.multiply)) { + const add = autoAffix.add ?? 0 + const mult = autoAffix.multiply ?? 0 + nSell += add + Math.trunc((nSellCost * mult) / 1024) + nBuy += add + Math.trunc((nBuyCost * mult) / 1024) + nRep += add + Math.trunc((nRepCost * mult) / 1024) + } + + switch (quality) { + case ItemQuality.RARE: + case ItemQuality.CRAFTED: { + const rareAffixes = item.rolledRareAffixes?.affixes ?? [] + for (const entry of rareAffixes) { + const affix = entry.affix ?? entry + const add = Number(affix?.add) || 0 + const mult = Number(affix?.multiply) || 0 + if (add || mult) { + nSell += add + Math.trunc((nSellCost * mult) / 1024) + nBuy += add + Math.trunc((nBuyCost * mult) / 1024) + nRep += add + Math.trunc((nRepCost * mult) / 1024) + } + } + const afterBonus = calculateAdditionalCostsForBonusStats( + item, + nSellCost, + nBuyCost, + nRepCost, + nDivisor, + tables, + ) + nSellCost = afterBonus.sellCost + nBuyCost = afterBonus.buyCost + nRepCost = afterBonus.repCost + break + } + + case ItemQuality.UNIQUE: { + const uDef = + item.uniqueItemDef ?? + tables.uniques.byCode.get(item.base.id)?.find(u => u.index === item.name) + if (uDef) { + const costAdd = Number(uDef.costAdd ?? (uDef as any).costadd) || 0 + const costMult = Number(uDef.costMult ?? (uDef as any).costmult) || 0 + nSell += costAdd + Math.trunc((nSellCost * costMult) / 1024) + nBuy += costAdd + Math.trunc((nBuyCost * costMult) / 1024) + nRep += costAdd + Math.trunc((nRepCost * costMult) / 1024) + } else { + const afterBonus = calculateAdditionalCostsForBonusStats( + item, + nSellCost, + nBuyCost, + nRepCost, + nDivisor, + tables, + ) + nSellCost = afterBonus.sellCost + nBuyCost = afterBonus.buyCost + nRepCost = afterBonus.repCost + } + break + } + + case ItemQuality.SET: { + const sDef = + item.setItemDef ?? + tables.sets.items.entries.find( + s => s.item.toLowerCase() === item.base.id.toLowerCase() && s.index === item.name, + ) + if (sDef) { + const costAdd = Number(sDef.costAdd ?? (sDef as any).costadd) || 0 + const costMult = Number(sDef.costMult ?? (sDef as any).costmult) || 0 + nSell += costAdd + Math.trunc((nSellCost * costMult) / 1024) + nBuy += costAdd + Math.trunc((nBuyCost * costMult) / 1024) + nRep += costAdd + Math.trunc((nRepCost * costMult) / 1024) + } + break + } + + case ItemQuality.MAGIC: { + const pAffix = item.rolledMagicAffixes?.prefix?.affix + if (pAffix) { + const add = Number(pAffix.add) || 0 + const mult = Number(pAffix.multiply) || 0 + nSell += add + Math.trunc((nSellCost * mult) / 1024) + nBuy += add + Math.trunc((nBuyCost * mult) / 1024) + nRep += add + Math.trunc((nRepCost * mult) / 1024) + } + const sAffix = item.rolledMagicAffixes?.suffix?.affix + if (sAffix) { + const add = Number(sAffix.add) || 0 + const mult = Number(sAffix.multiply) || 0 + nSell += add + Math.trunc((nSellCost * mult) / 1024) + nBuy += add + Math.trunc((nBuyCost * mult) / 1024) + nRep += add + Math.trunc((nRepCost * mult) / 1024) + } + const afterBonus = calculateAdditionalCostsForBonusStats( + item, + nSellCost, + nBuyCost, + nRepCost, + nDivisor, + tables, + ) + nSellCost = afterBonus.sellCost + nBuyCost = afterBonus.buyCost + nRepCost = afterBonus.repCost + break + } + + case ItemQuality.SUPERIOR: + case ItemQuality.TEMPERED: { + const afterBonus = calculateAdditionalCostsForBonusStats( + item, + nSellCost, + nBuyCost, + nRepCost, + nDivisor, + tables, + ) + nSellCost = afterBonus.sellCost + nBuyCost = afterBonus.buyCost + nRepCost = afterBonus.repCost + break + } + + case ItemQuality.LOW: { + nSell = -Math.trunc(nSellCost / 2) + nBuy = -Math.trunc(nBuyCost / 2) + nRep = -Math.trunc(nRepCost / 2) + break + } + + default: + break + } + + nSellCost += Math.trunc(nSell / nDivisor) + nBuyCost += Math.trunc(nBuy / nDivisor) + nRepCost += Math.trunc(nRep / nDivisor) + + if (quality >= ItemQuality.MAGIC && quality <= ItemQuality.TEMPERED) { + const afterSkill = calculateAdditionalCostsForItemSkill( + item, + nSellCost, + nBuyCost, + nRepCost, + nDivisor, + tables, + ) + nSellCost = afterSkill.sellCost + nBuyCost = afterSkill.buyCost + nRepCost = afterSkill.repCost + } + } + + // Socketed items (lines 2311-2325 of Items.cpp) + if (item.socketedItems && item.socketedItems.length > 0) { + for (const socketable of item.socketedItems) { + const socketableCost = Math.trunc((socketable.base.cost ?? socketable.base.value ?? 0) / 2) + nSellCost += socketableCost + nBuyCost += socketableCost + nRepCost += socketableCost + } + } + + // Ethereal acquisition quartering (lines 2327-2330) + const isEthereal = + Boolean(item.flags?.ethereal) || + Boolean((item as any).ethereal) || + ((item.rawFlags ?? 0) & ItemFlag.ETHEREAL) !== 0 + if (isEthereal) { + nBuyCost = Math.trunc(nBuyCost / 4) + } + + // Class-specific item acquisition quartering (lines 2332-2340) + if (isClassSpecificItemBase(item.base, tables.itemTypes)) { + nBuyCost = Math.trunc(nBuyCost / 4) + } + + const maxDura = item.maxDurability ?? Number((item.base as any).durability) ?? 0 + const curDura = item.durability ?? maxDura + + if (transactionType === TransactionType.SELL) { + if (isEthereal && maxDura > 0 && curDura <= 0) { + nBuyCost = 0 + } + } else if (transactionType === TransactionType.REPAIR) { + if (maxDura > 0) { + if (curDura < maxDura) { + nRepCost = Math.trunc((nRepCost * (maxDura - curDura)) / maxDura) + } else { + nRepCost = 0 + } + } + } + + // Apply Npc.txt multipliers and quest discounts (lines 2388-2450) + const npcRec = resolveNpcRecord(tables, vendorId, npcKey) + const sellMult = npcRec?.sellMult ?? 1024 + const buyMult = npcRec?.buyMult ?? 512 + const repMult = npcRec?.repMult ?? 1024 + + nSellCost = Math.trunc((nSellCost * sellMult) / 1024) + nBuyCost = Math.trunc((nBuyCost * buyMult) / 1024) + nRepCost = Math.trunc((nRepCost * repMult) / 1024) + + if (npcRec && completedQuests && completedQuests.size > 0) { + for (let i = 0; i < 3; i++) { + const qFlag = npcRec.questFlag[i] ?? 0 + if (qFlag > 0 && completedQuests.has(qFlag)) { + nSellCost = Math.trunc((nSellCost * npcRec.questSellMult[i]!) / 1024) + nBuyCost = Math.trunc((nBuyCost * npcRec.questBuyMult[i]!) / 1024) + nRepCost = Math.trunc((nRepCost * npcRec.questRepMult[i]!) / 1024) + } + } + } + + // Quantity multiplication for non-book, non-quiver items (lines 2452-2483) + if (!isBookItem(item.base) && !isQuiverItem(item.base)) { + nSellCost *= nQuantity + if ((item.base as any).stackable && isItemRepairable(item)) { + const maxStack = Math.min(511, item.base.maxStack || Number((item.base as any).maxstack) || 1) + if (nQuantity < maxStack) { + nRepCost *= maxStack - nQuantity + } else { + nRepCost = 0 + } + nBuyCost *= nQuantity + } else { + nBuyCost *= nQuantity + } + } + + if (transactionType === TransactionType.SELL) { + let nCost = nBuyCost + let maxBuyCap: number + if (npcRec) { + maxBuyCap = npcRec.maxBuy[difficulty] ?? 35000 + } else if (difficulty === 0) { + const resolvedAct = + actIndex ?? + (vendorId !== undefined ? getTownNpcDescriptorByVendorId(vendorId)?.actIndex : undefined) ?? + 0 + maxBuyCap = DEFAULT_NORMAL_MAX_BUY_BY_ACT[resolvedAct] ?? 5000 + } else if (difficulty === 1) { + maxBuyCap = DEFAULT_NIGHTMARE_MAX_BUY + } else { + maxBuyCap = DEFAULT_HELL_MAX_BUY + } + + if (nCost > maxBuyCap) { + nCost = maxBuyCap + } + return Math.max(1, nCost) + } + + if (transactionType === TransactionType.REPAIR) { + let nCost = nRepCost - Math.trunc((nRepCost * clampedReduce) / 100) + return Math.max(1, nCost) + } + + // TransactionType.BUY + let nCost = nSellCost - Math.trunc((nSellCost * clampedReduce) / 100) + return Math.max(1, nCost) +} diff --git a/src/game/item-types.ts b/src/game/item-types.ts index 8385d50..924dcf2 100644 --- a/src/game/item-types.ts +++ b/src/game/item-types.ts @@ -61,6 +61,8 @@ export interface ItemTypeDefinition { readonly beltable: boolean /** Character class code required for class-specific items (e.g., 'ama', 'bar', 'nec'). */ readonly class: string + /** Vendor store tab page code from StorePage column ('armo', 'weap', 'misc', or ''). */ + readonly storePage?: string | undefined } /** @@ -148,6 +150,7 @@ export function parseItemTypesTable(table: D2Table): ItemTypeTable { bodyLoc2: cell(table, row, 'BodyLoc2').trim(), beltable: cell(table, row, 'Beltable').trim() === '1', class: cell(table, row, 'Class').trim(), + storePage: cell(table, row, 'StorePage').trim(), } types.push(def) diff --git a/src/game/items.ts b/src/game/items.ts index cc4ce29..6c8a566 100644 --- a/src/game/items.ts +++ b/src/game/items.ts @@ -59,6 +59,44 @@ export interface ItemBase { readonly dropsound?: string | undefined /** Authentic drop animation frame at which dropsound triggers (from dropsfxframe column). */ readonly dropsfxframe?: number | undefined + /** Base gold cost from cost column. */ + readonly cost?: number | undefined + /** Gamble cost from gamble cost column. */ + readonly gambleCost?: number | undefined + /** Primary item type code from type column. */ + readonly type?: string | undefined + /** Secondary item type code from type2 column. */ + readonly type2?: string | undefined + /** Normal tier item code. */ + readonly normcode?: string | undefined + /** Exceptional (uber) tier item code. */ + readonly ubercode?: string | undefined + /** Elite (ultra) tier item code. */ + readonly ultracode?: string | undefined + /** Upgrade item code in Nightmare vendor stores ('xxx' if none). */ + readonly nightmareUpgrade?: string | undefined + /** Upgrade item code in Hell vendor stores ('xxx' if none). */ + readonly hellUpgrade?: string | undefined + /** Whether this item is a permanent store item (PermStoreItem === 1). */ + readonly permStoreItem?: boolean | undefined + /** Whether shift+right-click multi-buy is supported (multibuy === 1). */ + readonly multibuy?: boolean | undefined + /** Bitfield1 flags from Weapons/Armor/Misc.txt (bit 0 = magic vendor generation eligible). */ + readonly bitfield1?: number | undefined + /** Quest ID from quest column (non-zero for quest items). */ + readonly quest?: number | undefined + /** Quest difficulty check from questdiffcheck column. */ + readonly questDiffCheck?: boolean | undefined + /** Per-vendor generation columns keyed by VendorNpcId (0..16). */ + readonly vendorColumns?: Readonly> | undefined + /** Whether the item can spawn in normal gameplay or vendor stores. */ + readonly spawnable?: boolean | undefined } /** One affix, read from a prefix or suffix row. */ @@ -117,6 +155,7 @@ export interface ItemFlagsDecoded { readonly simple?: boolean | undefined readonly ethereal?: boolean | undefined readonly personalized?: boolean | undefined + readonly gamble?: boolean | undefined readonly runeword?: boolean | undefined } @@ -248,6 +287,7 @@ export function decodeItemFlags(rawFlags: number): ItemFlagsDecoded { simple: (rawFlags & ItemFlag.SIMPLE) !== 0, ethereal: (rawFlags & ItemFlag.ETHEREAL) !== 0, personalized: (rawFlags & ItemFlag.PERSONALIZED) !== 0, + gamble: (rawFlags & ItemFlag.GAMBLE) !== 0, runeword: (rawFlags & ItemFlag.RUNEWORD) !== 0, } } @@ -265,6 +305,7 @@ export function encodeItemFlags(flags: ItemFlagsDecoded): number { if (flags.simple) raw |= ItemFlag.SIMPLE if (flags.ethereal) raw |= ItemFlag.ETHEREAL if (flags.personalized) raw |= ItemFlag.PERSONALIZED + if (flags.gamble) raw |= ItemFlag.GAMBLE if (flags.runeword) raw |= ItemFlag.RUNEWORD return raw >>> 0 } @@ -379,7 +420,7 @@ export interface Item { /** Current / rolled armor defense value (11 bits). */ readonly defense?: number | undefined /** Current durability (8 bits). */ - readonly durability?: number | undefined + durability?: number | undefined /** Maximum durability (8 bits). */ readonly maxDurability?: number | undefined /** Quantity (9 bits) if stackable (mirrors `stack`). */ @@ -405,6 +446,16 @@ export interface Item { /** Runeword bonus stat list (terminated by 0x1FF). */ readonly runewordStats?: readonly ItemStatEntry[] | undefined + // --- Vendor & Staffmod Extensions --- + /** Whether this item in a vendor inventory has infinite stock (PermStoreItem). */ + readonly permStore?: boolean | undefined + /** Rolled single-skill staffmods (up to 3 skills on wands/staves/scepters/orbs/heads/pelts/primal helms). */ + readonly staffMods?: readonly RolledItemProp[] | undefined + /** Whether this item is an unrevealed mystery item in the Gamble window. */ + readonly isGambleMystery?: boolean | undefined + /** Whether this item is identified. */ + identified?: boolean | undefined + // --- Ear Item Data --- /** Player ear details if IsEar flag is set. */ readonly earData?: ItemEarData | undefined @@ -693,7 +744,30 @@ export class Inventory { return this.items } - /** Occupied cell count. */ + /** Return all placed items. */ + list(): readonly PlacedItem[] { + return this.items + } + + /** Return all placed items. */ + getAllItems(): readonly PlacedItem[] { + return this.items + } + + /** Return the placement occupying cell (x, y), or null. */ + at(x: number, y: number): PlacedItem | null { + return ( + this.items.find( + p => + x >= p.x && + x < p.x + p.item.invWidth && + y >= p.y && + y < p.y + p.item.invHeight, + ) ?? null + ) + } + + /**Occupied cell count. */ get usedCells(): number { return this.items.reduce((total, placed) => total + placed.item.invWidth * placed.item.invHeight, 0) } @@ -722,6 +796,25 @@ export class Inventory { return true } + /** + * Place an item at explicit grid coordinates (x, y). + */ + place(item: Item, x: number, y: number): PlacedItem | null { + const invWidth = typeof item.invWidth === 'number' ? item.invWidth : (item.base?.invWidth ?? 1) + const invHeight = typeof item.invHeight === 'number' ? item.invHeight : (item.base?.invHeight ?? 1) + if (!this.canPlace(invWidth, invHeight, x, y)) return null + const id = this.nextId + this.nextId += 1 + for (let row = y; row < y + invHeight; row += 1) { + for (let column = x; column < x + invWidth; column += 1) { + this.cells[row * this.width + column] = id + } + } + const placed: PlacedItem = { x, y, item } + this.items.push(placed) + return placed + } + /** * Add an item, stacking onto an existing stack when possible. * @@ -777,12 +870,14 @@ export class Inventory { } /** - * Remove an item by its placement. - * - * @param placed - the placement to clear. - * @returns true when it was present. + * Remove an item by its placement or cell coordinates (x, y). */ - remove(placed: PlacedItem): boolean { + remove(placedOrX: PlacedItem | number, maybeY?: number): boolean { + const placed = + typeof placedOrX === 'number' + ? this.at(placedOrX, maybeY ?? 0) + : placedOrX + if (!placed) return false const index = this.items.indexOf(placed) if (index === -1) return false for (let row = placed.y; row < placed.y + placed.item.invHeight; row += 1) { diff --git a/src/game/misc-items.ts b/src/game/misc-items.ts index cfbc71c..a4b2a6d 100644 --- a/src/game/misc-items.ts +++ b/src/game/misc-items.ts @@ -14,6 +14,7 @@ import type { D2Table } from './acts.ts' import { cell, parseTable } from './acts.ts' import type { ItemBase } from './items.ts' import type { MountedArchives } from '../mpq/mount.ts' +import { VENDOR_COLUMN_PREFIXES, VENDOR_COUNT, type VendorItemColumns } from './npc-table.ts' /** File path of `Misc.txt` inside MPQ archives. */ export const MISC_TABLE_PATH = 'data\\global\\excel\\Misc.txt' @@ -88,6 +89,14 @@ export interface MiscBase extends ItemBase { readonly damage: number /** Armor rating (0 for misc items). */ readonly defense: number + readonly nightmareUpgrade: string + readonly hellUpgrade: string + readonly permStoreItem: boolean + readonly multibuy: boolean + readonly bitfield1: number + readonly quest: number + readonly questDiffCheck: boolean + readonly vendorColumns: Readonly> } /** @@ -220,6 +229,33 @@ export function parseMiscTable(input: D2Table | Uint8Array | string): MiscTable const rawDropFrame = cell(table, row, 'dropsfxframe') || cell(table, row, 'DropSfxFrame') const dropsfxframe = rawDropFrame.trim() !== '' ? Number(rawDropFrame) : undefined + const nightmareUpgrade = (cell(table, row, 'NightmareUpgrade') || cell(table, row, 'nightmareupgrade')).trim() || 'xxx' + const hellUpgrade = (cell(table, row, 'HellUpgrade') || cell(table, row, 'hellupgrade')).trim() || 'xxx' + const permStoreItem = (cell(table, row, 'PermStoreItem') || cell(table, row, 'permstoreitem')).trim() === '1' + const multibuy = cell(table, row, 'multibuy').trim() === '1' + const bitfield1 = Number(cell(table, row, 'bitfield1')) || 0 + const quest = Number(cell(table, row, 'quest')) || 0 + const questDiffCheck = cell(table, row, 'questdiffcheck').trim() === '1' + + const vendorColumns: Record = {} + for (let vId = 0; vId < VENDOR_COUNT; vId++) { + const prefix = VENDOR_COLUMN_PREFIXES[vId]! + const min = Number(cell(table, row, `${prefix}Min`) || cell(table, row, `${prefix}min`)) || 0 + const max = Number(cell(table, row, `${prefix}Max`) || cell(table, row, `${prefix}max`)) || 0 + const magicMin = Number(cell(table, row, `${prefix}MagicMin`) || cell(table, row, `${prefix}magicmin`)) || 0 + const magicMax = Number(cell(table, row, `${prefix}MagicMax`) || cell(table, row, `${prefix}magicmax`)) || 0 + const magicLvl = Number(cell(table, row, `${prefix}MagicLvl`) || cell(table, row, `${prefix}magiclvl`)) || 0 + if (min > 0 || max > 0 || magicMin > 0 || magicMax > 0) { + vendorColumns[vId] = { + min, + max, + magicMin, + magicMax, + magicLvl: magicLvl > 0 ? magicLvl : 255, + } + } + } + const item: MiscBase = { id: code, code, @@ -254,6 +290,14 @@ export function parseMiscTable(input: D2Table | Uint8Array | string): MiscTable defense, ...(dropsound ? { dropsound } : {}), ...(dropsfxframe !== undefined && !Number.isNaN(dropsfxframe) ? { dropsfxframe } : {}), + nightmareUpgrade, + hellUpgrade, + permStoreItem, + multibuy, + bitfield1, + quest, + questDiffCheck, + vendorColumns, } all.push(item) diff --git a/src/game/npc-table.ts b/src/game/npc-table.ts new file mode 100644 index 0000000..2142a70 --- /dev/null +++ b/src/game/npc-table.ts @@ -0,0 +1,878 @@ +import type { SkillCostEntry as CanonicalSkillCostEntry } from '../data/canonical-vendor-data'; + +export interface ParsedTxtTable { + readonly headers: readonly string[]; + readonly colIndex: ReadonlyMap; + readonly rows: readonly string[][]; +} + +export function parseTxtTable(rawTsv: string): ParsedTxtTable { + const lines = rawTsv.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n'); + const nonEmptyLines = lines.filter((l) => l.trim().length > 0); + if (nonEmptyLines.length === 0) { + return { headers: [], colIndex: new Map(), rows: [] }; + } + const headers = nonEmptyLines[0]!.split('\t').map((h) => h.trim()); + const colIndex = new Map(); + headers.forEach((h, i) => { + if (h && !colIndex.has(h)) { + colIndex.set(h, i); + } + }); + const rows: string[][] = []; + for (let i = 1; i < nonEmptyLines.length; i++) { + const cols = nonEmptyLines[i]!.split('\t'); + if (cols[0]?.trim().toLowerCase() === 'expansion') continue; + rows.push(cols); + } + return { headers, colIndex, rows }; +} + +/** + * Canonical 1.13c Vendor IDs (0..16) from D2Game.dll (NPC_GetVendorId / sgptDataTables->pItemCache). + * Note: Index 6 (Hratli) uses column prefix 'Hralti' in Weapons.txt / Armor.txt / Misc.txt! + */ +export const enum VendorNpcId { + AKARA = 0, + GHEED = 1, + CHARSI = 2, + FARA = 3, + LYSANDER = 4, + DROGNAN = 5, + HRATLI = 6, + ALKOR = 7, + ORMUS = 8, + ELZIX = 9, + ASHEARA = 10, + CAIN = 11, + HALBU = 12, + JAMELLA = 13, + MALAH = 14, + LARZUK = 15, + DREHYA = 16, +} + +export const VENDOR_COUNT = 17; + +/** + * Exact 1.13c column prefixes in Weapons.txt, Armor.txt, and Misc.txt for VendorNpcId 0..16. + * Note index 6 is 'Hralti' (official Blizzard typo in MPQ tables). + */ +export const VENDOR_COLUMN_PREFIXES: readonly string[] = [ + 'Akara', + 'Gheed', + 'Charsi', + 'Fara', + 'Lysander', + 'Drognan', + 'Hralti', + 'Alkor', + 'Ormus', + 'Elzix', + 'Asheara', + 'Cain', + 'Halbu', + 'Jamella', + 'Malah', + 'Larzuk', + 'Drehya', +] as const; + +export interface VendorItemColumns { + readonly min: number; + readonly max: number; + readonly magicMin: number; + readonly magicMax: number; + readonly magicLvl: number; +} + +/** + * Parses the 17 vendor column sets (`Min`, `Max`, `MagicMin`, + * `MagicMax`, `MagicLvl`) for a single row in Weapons.txt / Armor.txt / Misc.txt. + */ +export function parseVendorColumnsFromRow( + table: ParsedTxtTable, + row: string[], +): Readonly> { + const result: Record = {}; + for (let vendorId = 0; vendorId < VENDOR_COUNT; vendorId++) { + const prefix = VENDOR_COLUMN_PREFIXES[vendorId]; + const minIdx = table.colIndex.get(`${prefix}Min`); + const maxIdx = table.colIndex.get(`${prefix}Max`); + const magicMinIdx = table.colIndex.get(`${prefix}MagicMin`); + const magicMaxIdx = table.colIndex.get(`${prefix}MagicMax`); + const magicLvlIdx = table.colIndex.get(`${prefix}MagicLvl`); + + const min = minIdx !== undefined ? parseInt(row[minIdx] ?? '', 10) || 0 : 0; + const max = maxIdx !== undefined ? parseInt(row[maxIdx] ?? '', 10) || 0 : 0; + const magicMin = magicMinIdx !== undefined ? parseInt(row[magicMinIdx] ?? '', 10) || 0 : 0; + const magicMax = magicMaxIdx !== undefined ? parseInt(row[magicMaxIdx] ?? '', 10) || 0 : 0; + const magicLvl = magicLvlIdx !== undefined ? parseInt(row[magicLvlIdx] ?? '', 10) || 0 : 0; + + if (min > 0 || max > 0 || magicMin > 0 || magicMax > 0) { + result[vendorId] = { + min, + max, + magicMin, + magicMax: magicMax, + magicLvl: magicLvl > 0 ? magicLvl : 255, + }; + } + } + return result; +} + +export interface NpcRecord { + /** MonStats hcIdx name (e.g. 'akara', 'charsi', 'Warriv2') */ + readonly npc: string; + /** Sell multiplier (1024-based, when player buys from NPC) */ + readonly sellMult: number; + /** Buy multiplier (1024-based, when NPC buys from player) */ + readonly buyMult: number; + /** Repair multiplier (1024-based) */ + readonly repMult: number; + /** Quest flags (up to 3 quest slots) */ + readonly questFlag: readonly [number, number, number]; + readonly questSellMult: readonly [number, number, number]; + readonly questBuyMult: readonly [number, number, number]; + readonly questRepMult: readonly [number, number, number]; + /** Max gold NPC pays for a single item in Normal / Nightmare / Hell */ + readonly maxBuy: readonly [number, number, number]; +} + +export class NpcTable { + private readonly byKey = new Map(); + + constructor(records: NpcRecord[]) { + for (const rec of records) { + this.byKey.set(rec.npc.toLowerCase(), rec); + } + } + + get(npcKey: string): NpcRecord | undefined { + return this.byKey.get(npcKey.toLowerCase()); + } + + getByVendorId(vendorId: VendorNpcId): NpcRecord | undefined { + const key = VENDOR_ID_TO_NPC_TXT_KEY[vendorId]; + return key ? this.byKey.get(key) : undefined; + } + + get size(): number { + return this.byKey.size; + } +} + +const VENDOR_ID_TO_NPC_TXT_KEY: Readonly> = { + [VendorNpcId.AKARA]: 'akara', + [VendorNpcId.GHEED]: 'gheed', + [VendorNpcId.CHARSI]: 'charsi', + [VendorNpcId.FARA]: 'fara', + [VendorNpcId.LYSANDER]: 'lysander', + [VendorNpcId.DROGNAN]: 'drognan', + [VendorNpcId.HRATLI]: 'hratli', + [VendorNpcId.ALKOR]: 'alkor', + [VendorNpcId.ORMUS]: 'ormus', + [VendorNpcId.ELZIX]: 'elzix', + [VendorNpcId.ASHEARA]: 'asheara', + [VendorNpcId.CAIN]: 'cain1', + [VendorNpcId.HALBU]: 'halbu', + [VendorNpcId.JAMELLA]: 'jamella', + [VendorNpcId.MALAH]: 'malah', + [VendorNpcId.LARZUK]: 'larzuk', + [VendorNpcId.DREHYA]: 'drehya', +}; + +function cellStr(table: ParsedTxtTable, row: string[], col: string): string { + const idx = table.colIndex.get(col); + if (idx === undefined) return ''; + return (row[idx] ?? '').trim(); +} + +function cellNum(table: ParsedTxtTable, row: string[], col: string, fallback = 0): number { + const raw = cellStr(table, row, col); + if (!raw) return fallback; + const n = parseInt(raw, 10); + return Number.isNaN(n) ? fallback : n; +} + +export function parseNpcTable(rawTsv: string): NpcTable { + const table = parseTxtTable(rawTsv); + const records: NpcRecord[] = []; + for (const row of table.rows) { + const npc = cellStr(table, row, 'npc'); + if (!npc) continue; + records.push({ + npc, + sellMult: cellNum(table, row, 'sell mult', 1024), + buyMult: cellNum(table, row, 'buy mult', 1024), + repMult: cellNum(table, row, 'rep mult', 1024), + questFlag: [ + cellNum(table, row, 'questflag A', 0), + cellNum(table, row, 'questflag B', 0), + cellNum(table, row, 'questflag C', 0), + ], + questSellMult: [ + cellNum(table, row, 'questsellmult A', 1024), + cellNum(table, row, 'questsellmult B', 1024), + cellNum(table, row, 'questsellmult C', 1024), + ], + questBuyMult: [ + cellNum(table, row, 'questbuymult A', 1024), + cellNum(table, row, 'questbuymult B', 1024), + cellNum(table, row, 'questbuymult C', 1024), + ], + questRepMult: [ + cellNum(table, row, 'questrepmult A', 1024), + cellNum(table, row, 'questrepmult B', 1024), + cellNum(table, row, 'questrepmult C', 1024), + ], + maxBuy: [ + cellNum(table, row, 'max buy', 5000), + cellNum(table, row, 'max buy (N)', 30000), + cellNum(table, row, 'max buy (H)', 35000), + ], + }); + } + return new NpcTable(records); +} + +/** + * Parses Gamble.txt and returns the ordered array of gambleable base item codes. + */ +export function parseGambleTable(rawTsv: string): readonly string[] { + const table = parseTxtTable(rawTsv); + const codes: string[] = []; + for (const row of table.rows) { + const code = cellStr(table, row, 'code'); + if (code) { + codes.push(code); + } + } + return codes; +} + +export interface BookCostRecord { + readonly name: string; + readonly scrollSpellCode: string; + readonly bookSpellCode: string; + readonly baseCost: number; + readonly costPerCharge: number; +} + +export class BooksTable { + private readonly byBookCode = new Map(); + + constructor(records: BookCostRecord[]) { + for (const rec of records) { + this.byBookCode.set(rec.bookSpellCode, rec); + } + } + + getByBookCode(code: string): BookCostRecord | undefined { + return this.byBookCode.get(code); + } +} + +export function parseBooksTable(rawTsv: string): BooksTable { + const table = parseTxtTable(rawTsv); + const records: BookCostRecord[] = []; + for (const row of table.rows) { + const bookSpellCode = cellStr(table, row, 'BookSpellCode'); + if (!bookSpellCode) continue; + records.push({ + name: cellStr(table, row, 'Name'), + scrollSpellCode: cellStr(table, row, 'ScrollSpellCode'), + bookSpellCode, + baseCost: cellNum(table, row, 'BaseCost', 0), + costPerCharge: cellNum(table, row, 'CostPerCharge', 0), + }); + } + return new BooksTable(records); +} + +export function parseLowQualityTable(rawTsv: string): readonly string[] { + const table = parseTxtTable(rawTsv); + const names: string[] = []; + for (const row of table.rows) { + const name = cellStr(table, row, 'Name'); + if (name) names.push(name); + } + return names; +} + +export type SkillCostMap = ReadonlyMap; + +export function buildSkillCostMap(entries: readonly CanonicalSkillCostEntry[]): SkillCostMap { + const map = new Map(); + for (const entry of entries) { + map.set(entry.id, entry); + } + return map; +} + +/** + * Town NPC service descriptor mapping D2 1.13c MonStats hcIdx & town NPC names + * to their canonical VendorNpcId, Act index (0..4), and available services. + */ +export interface TownNpcServiceDescriptor { + readonly hcIdx: number; + readonly npcTxtKey: string; + readonly displayName: string; + readonly actIndex: 0 | 1 | 2 | 3 | 4; + readonly vendorId: VendorNpcId | null; + readonly canTrade: boolean; + readonly canRepair: boolean; + readonly canGamble: boolean; + readonly canHeal: boolean; + readonly canIdentify: boolean; + readonly greetingLines: readonly string[]; +} + +export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [ + // Act 1 + { + hcIdx: 148, + npcTxtKey: 'akara', + displayName: 'Akara', + actIndex: 0, + vendorId: VendorNpcId.AKARA, + canTrade: true, + canRepair: false, + canGamble: false, + canHeal: true, + canIdentify: false, + greetingLines: [ + 'I am Akara, High Priestess of the Sisterhood of the Sightless Eye.', + 'Welcome back, my friend. We are still clearing the monastery, but you are welcome to rest here.', + ], + }, + { + hcIdx: 147, + npcTxtKey: 'gheed', + displayName: 'Gheed', + actIndex: 0, + vendorId: VendorNpcId.GHEED, + canTrade: true, + canRepair: false, + canGamble: true, + canHeal: false, + canIdentify: false, + greetingLines: [ + 'Good day to you, partner! I am Gheed, and I can already tell that I will be your best friend in this forsaken camp.', + 'Step right up! Spend some gold or take a chance on a mystery item!', + ], + }, + { + hcIdx: 154, + npcTxtKey: 'charsi', + displayName: 'Charsi', + actIndex: 0, + vendorId: VendorNpcId.CHARSI, + canTrade: true, + canRepair: true, + canGamble: false, + canHeal: false, + canIdentify: false, + greetingLines: [ + 'Hi there! I am Charsi, the blacksmith here in camp.', + 'It is good to see some strong adventurers around here. Need your gear forged or repaired?', + ], + }, + { + hcIdx: 146, + npcTxtKey: 'cain1', + displayName: 'Deckard Cain', + actIndex: 0, + vendorId: VendorNpcId.CAIN, + canTrade: false, + canRepair: false, + canGamble: false, + canHeal: false, + canIdentify: true, + greetingLines: [ + 'Stay awhile and listen!', + 'Bring me any unidentified items you have found, and I shall reveal their hidden properties.', + ], + }, + // Act 2 + { + hcIdx: 178, + npcTxtKey: 'fara', + displayName: 'Fara', + actIndex: 1, + vendorId: VendorNpcId.FARA, + canTrade: true, + canRepair: true, + canGamble: false, + canHeal: true, + canIdentify: false, + greetingLines: [ + 'Greetings, wanderer. I am Fara. I was once a paladin of the Zakarum in the eastern lands.', + 'Now I ply my trade as a smith and healer here in Lut Gholein.', + ], + }, + { + hcIdx: 202, + npcTxtKey: 'lysander', + displayName: 'Lysander', + actIndex: 1, + vendorId: VendorNpcId.LYSANDER, + canTrade: true, + canRepair: false, + canGamble: false, + canHeal: false, + canIdentify: false, + greetingLines: [ + 'What do you want? Speak up! Mixing potions can be hazardous to one\'s hearing.', + 'Buy a potion or move along before something explodes!', + ], + }, + { + hcIdx: 177, + npcTxtKey: 'drognan', + displayName: 'Drognan', + actIndex: 1, + vendorId: VendorNpcId.DROGNAN, + canTrade: true, + canRepair: false, + canGamble: false, + canHeal: false, + canIdentify: false, + greetingLines: [ + 'Indeed, dark times have come to Lut Gholein.', + 'If you seek arcane knowledge, wands, or staves, examine my wares.', + ], + }, + { + hcIdx: 199, + npcTxtKey: 'elzix', + displayName: 'Elzix', + actIndex: 1, + vendorId: VendorNpcId.ELZIX, + canTrade: true, + canRepair: false, + canGamble: true, + canHeal: false, + canIdentify: false, + greetingLines: [ + 'Glad to meet you, traveler. I am Elzix, innkeeper and merchant.', + 'In my youth I was a bandit in the desert. Care to trade or test your luck?', + ], + }, + { + hcIdx: 244, + npcTxtKey: 'cain2', + displayName: 'Deckard Cain', + actIndex: 1, + vendorId: VendorNpcId.CAIN, + canTrade: false, + canRepair: false, + canGamble: false, + canHeal: false, + canIdentify: true, + greetingLines: [ + 'Stay awhile and listen!', + 'The Horadric mysteries run deep in Lut Gholein. Let me identify your artifacts.', + ], + }, + // Act 3 + { + hcIdx: 253, + npcTxtKey: 'hratli', + displayName: 'Hratli', + actIndex: 2, + vendorId: VendorNpcId.HRATLI, + canTrade: true, + canRepair: true, + canGamble: false, + canHeal: false, + canIdentify: false, + greetingLines: [ + 'Welcome, stranger. I am Hratli, a sorcerer and smith.', + 'My enchantments keep these docks safe, and my anvil keeps your blades sharp.', + ], + }, + { + hcIdx: 254, + npcTxtKey: 'alkor', + displayName: 'Alkor', + actIndex: 2, + vendorId: VendorNpcId.ALKOR, + canTrade: true, + canRepair: false, + canGamble: true, + canHeal: false, + canIdentify: false, + greetingLines: [ + 'Oh, great. Another interrupting adventurer.', + 'If you want potions or wish to gamble away your coin, make it quick.', + ], + }, + { + hcIdx: 255, + npcTxtKey: 'ormus', + displayName: 'Ormus', + actIndex: 2, + vendorId: VendorNpcId.ORMUS, + canTrade: true, + canRepair: false, + canGamble: false, + canHeal: true, + canIdentify: false, + greetingLines: [ + 'You now speak to Ormus. He was once a great mage, but now lives like a rat in a sinking vessel.', + 'Ormus can heal your wounds and trade mystic garments.', + ], + }, + { + hcIdx: 252, + npcTxtKey: 'asheara', + displayName: 'Asheara', + actIndex: 2, + vendorId: VendorNpcId.ASHEARA, + canTrade: true, + canRepair: false, + canGamble: false, + canHeal: false, + canIdentify: false, + greetingLines: [ + 'Greetings. I am Asheara, commander of the Iron Wolves.', + 'Browse my weapons and armor if you are heading into the jungle.', + ], + }, + { + hcIdx: 245, + npcTxtKey: 'cain3', + displayName: 'Deckard Cain', + actIndex: 2, + vendorId: VendorNpcId.CAIN, + canTrade: false, + canRepair: false, + canGamble: false, + canHeal: false, + canIdentify: true, + greetingLines: [ + 'Stay awhile and listen!', + 'Mephisto\'s corruption has twisted Kurast. Show me any relics you have uncovered.', + ], + }, + // Act 4 + { + hcIdx: 257, + npcTxtKey: 'halbu', + displayName: 'Halbu', + actIndex: 3, + vendorId: VendorNpcId.HALBU, + canTrade: true, + canRepair: true, + canGamble: false, + canHeal: false, + canIdentify: false, + greetingLines: [ + 'Keep your weapons ready and your armor mended, mortal.', + ], + }, + { + hcIdx: 405, + npcTxtKey: 'jamella', + displayName: 'Jamella', + actIndex: 3, + vendorId: VendorNpcId.JAMELLA, + canTrade: true, + canRepair: false, + canGamble: true, + canHeal: true, + canIdentify: false, + greetingLines: [ + 'The Light blesses you, hero. Let me restore your strength and offer my sacred wares.', + ], + }, + { + hcIdx: 246, + npcTxtKey: 'cain4', + displayName: 'Deckard Cain', + actIndex: 3, + vendorId: VendorNpcId.CAIN, + canTrade: false, + canRepair: false, + canGamble: false, + canHeal: false, + canIdentify: true, + greetingLines: [ + 'Stay awhile and listen!', + 'Even at the gates of Hell, Horadric wisdom shall guide us.', + ], + }, + // Act 5 + { + hcIdx: 513, + npcTxtKey: 'malah', + displayName: 'Malah', + actIndex: 4, + vendorId: VendorNpcId.MALAH, + canTrade: true, + canRepair: false, + canGamble: false, + canHeal: true, + canIdentify: false, + greetingLines: [ + 'Welcome to Harrogath, traveler. I am Malah, healer of this besieged fortress.', + 'Let me tend to your wounds or supply you with potions and scrolls.', + ], + }, + { + hcIdx: 511, + npcTxtKey: 'larzuk', + displayName: 'Larzuk', + actIndex: 4, + vendorId: VendorNpcId.LARZUK, + canTrade: true, + canRepair: true, + canGamble: false, + canHeal: false, + canIdentify: false, + greetingLines: [ + 'What are you looking at? Never seen a Barbarian smith before?', + 'Take a look at my steel—finest in all the northern highlands!', + ], + }, + { + hcIdx: 512, + npcTxtKey: 'drehya', + displayName: 'Anya', + actIndex: 4, + vendorId: VendorNpcId.DREHYA, + canTrade: true, + canRepair: false, + canGamble: true, + canHeal: false, + canIdentify: false, + greetingLines: [ + 'Thank you for rescuing me from the frozen prison, hero.', + 'Please, browse my wares or test your fortune with a gamble.', + ], + }, + { + hcIdx: 514, + npcTxtKey: 'nihlathak', + displayName: 'Nihlathak', + actIndex: 4, + vendorId: VendorNpcId.DREHYA, + canTrade: false, + canRepair: false, + canGamble: true, + canHeal: false, + canIdentify: false, + greetingLines: [ + 'Why do you bother me? Harrogath does not need outsiders.', + 'If you insist on wasting your gold, I have some curiosities you can gamble on.', + ], + }, + { + hcIdx: 520, + npcTxtKey: 'cain5', + displayName: 'Deckard Cain', + actIndex: 4, + vendorId: VendorNpcId.CAIN, + canTrade: false, + canRepair: false, + canGamble: false, + canHeal: false, + canIdentify: true, + greetingLines: [ + 'Stay awhile and listen!', + 'Mount Arreat holds the Worldstone itself. Let me identify your discoveries.', + ], + }, + // Non-vendor Town NPCs (Talk / Heal) + { + hcIdx: 155, + npcTxtKey: 'warriv1', + displayName: 'Warriv', + actIndex: 0, + vendorId: null, + canTrade: false, + canRepair: false, + canGamble: false, + canHeal: false, + canIdentify: false, + greetingLines: [ + 'Greetings, stranger. Once the pass to the East is safe, my caravan will take you to Lut Gholein.', + ], + }, + { + hcIdx: 150, + npcTxtKey: 'kashya', + displayName: 'Kashya', + actIndex: 0, + vendorId: null, + canTrade: false, + canRepair: false, + canGamble: false, + canHeal: false, + canIdentify: false, + greetingLines: [ + 'My Rogue scouts have suffered heavy losses. Prove your valor in the field, warrior.', + ], + }, + { + hcIdx: 176, + npcTxtKey: 'atma', + displayName: 'Atma', + actIndex: 1, + vendorId: null, + canTrade: false, + canRepair: false, + canGamble: false, + canHeal: true, + canIdentify: false, + greetingLines: [ + 'Welcome to my tavern, traveler. Let me tend to your wounds while you rest from the desert sun.', + ], + }, + { + hcIdx: 198, + npcTxtKey: 'greiz', + displayName: 'Greiz', + actIndex: 1, + vendorId: null, + canTrade: false, + canRepair: false, + canGamble: false, + canHeal: false, + canIdentify: false, + greetingLines: [ + 'My desert mercenaries guard the walls of Lut Gholein day and night.', + ], + }, + { + hcIdx: 201, + npcTxtKey: 'jerhyn', + displayName: 'Jerhyn', + actIndex: 1, + vendorId: null, + canTrade: false, + canRepair: false, + canGamble: false, + canHeal: false, + canIdentify: false, + greetingLines: [ + 'Welcome to Lut Gholein. Dark forces stir beneath the desert sands.', + ], + }, + { + hcIdx: 210, + npcTxtKey: 'meshif1', + displayName: 'Meshif', + actIndex: 1, + vendorId: null, + canTrade: false, + canRepair: false, + canGamble: false, + canHeal: false, + canIdentify: false, + greetingLines: [ + 'Ahoy, traveler! My ship is ready to sail across the Twin Seas whenever your quest demands.', + ], + }, + { + hcIdx: 200, + npcTxtKey: 'geglash', + displayName: 'Geglash', + actIndex: 1, + vendorId: null, + canTrade: false, + canRepair: false, + canGamble: false, + canHeal: false, + canIdentify: false, + greetingLines: [ + 'What are you looking at? Pour another drink or move along!', + ], + }, + { + hcIdx: 256, + npcTxtKey: 'natalya', + displayName: 'Natalya', + actIndex: 2, + vendorId: null, + canTrade: false, + canRepair: false, + canGamble: false, + canHeal: false, + canIdentify: false, + greetingLines: [ + 'Keep your eyes open in the jungle. The Prime Evils are gathering in Travincal.', + ], + }, + { + hcIdx: 367, + npcTxtKey: 'tyrael1', + displayName: 'Tyrael', + actIndex: 3, + vendorId: null, + canTrade: false, + canRepair: false, + canGamble: false, + canHeal: false, + canIdentify: false, + greetingLines: [ + 'Hail to you, champion. The hour of judgment upon Diablo is at hand.', + ], + }, + { + hcIdx: 515, + npcTxtKey: 'qual-kehk', + displayName: 'Qual-Kehk', + actIndex: 4, + vendorId: null, + canTrade: false, + canRepair: false, + canGamble: false, + canHeal: false, + canIdentify: false, + greetingLines: [ + 'Once you free our warriors on the Frigid Highlands, the finest Barbarians will fight by your side.', + ], + }, +]; + +const DESCRIPTOR_BY_HCIDX = new Map( + TOWN_NPC_DESCRIPTORS.map(d => [d.hcIdx, d]), +); + +const DESCRIPTOR_BY_NAME = new Map(); +for (const d of TOWN_NPC_DESCRIPTORS) { + if (!DESCRIPTOR_BY_NAME.has(d.displayName.toLowerCase())) { + DESCRIPTOR_BY_NAME.set(d.displayName.toLowerCase(), d); + } + DESCRIPTOR_BY_NAME.set(d.npcTxtKey.toLowerCase(), d); +} + +export function getTownNpcDescriptorByHcIdx(hcIdx: number): TownNpcServiceDescriptor | undefined { + return DESCRIPTOR_BY_HCIDX.get(hcIdx); +} + +export function getTownNpcDescriptorByName( + name: string, + actIndex?: number, +): TownNpcServiceDescriptor | undefined { + const lower = name.trim().toLowerCase(); + if (lower.includes('cain')) { + const cainMatch = TOWN_NPC_DESCRIPTORS.find( + d => d.vendorId === VendorNpcId.CAIN && (actIndex === undefined || d.actIndex === actIndex), + ); + if (cainMatch) return cainMatch; + } + return DESCRIPTOR_BY_NAME.get(lower); +} + +export function getTownNpcDescriptorByVendorId( + vendorId: VendorNpcId, + actIndex?: number, +): TownNpcServiceDescriptor | undefined { + return TOWN_NPC_DESCRIPTORS.find( + d => d.vendorId === vendorId && (actIndex === undefined || d.actIndex === actIndex), + ) ?? TOWN_NPC_DESCRIPTORS.find(d => d.vendorId === vendorId); +} diff --git a/src/game/properties.ts b/src/game/properties.ts index a07aa8b..4aebd24 100644 --- a/src/game/properties.ts +++ b/src/game/properties.ts @@ -74,6 +74,8 @@ export interface ItemStatCostRecord { readonly dgrpStr2: string /** Value precision shift. */ readonly valShift: number + /** Encode mode (0=normal, 1=skill str, 2=skill on hit/attack/level/kill/gethit, 3=charged skill, 4=by-time). */ + readonly encode: number /** Price divide factor. */ readonly divide: number /** Price multiply factor. */ @@ -306,6 +308,7 @@ export function parseItemStatCostTable(table: D2Table): ItemStatCostTable { const dgrpStr2 = getCell(table, raw, 'dgrpstr2') const valShift = getNum(table, raw, 'ValShift') + const encode = getNum(table, raw, 'Encode') const divide = getNum(table, raw, 'Divide') const multiply = getNum(table, raw, 'Multiply') const add = getNum(table, raw, 'Add') @@ -340,6 +343,7 @@ export function parseItemStatCostTable(table: D2Table): ItemStatCostTable { dgrpStrNeg, dgrpStr2, valShift, + encode, divide, multiply, add, diff --git a/src/game/vendor-generator.ts b/src/game/vendor-generator.ts new file mode 100644 index 0000000..5e24ade --- /dev/null +++ b/src/game/vendor-generator.ts @@ -0,0 +1,789 @@ +import { D2Rng } from './d2-rng.ts' +import type { DropTables } from './drop-pipeline.ts' +import { createDroppedItem, rollStaffMods } from './drop-pipeline.ts' +import { rollMagicAffixes, rollRareAffixes, getItemTypeCode } from './affix-generator.ts' +import { Inventory, ItemFlag, ItemQuality, type Item, type ItemBase, type RolledItemProp, decodeItemFlags, formatItemCode } from './items.ts' +import type { ItemQualityTier } from './item-ratio.ts' +import type { ItemTypeTable } from './item-types.ts' +import { + VENDOR_COUNT, + VendorNpcId, + getTownNpcDescriptorByVendorId, + type VendorItemColumns, +} from './npc-table.ts' + +/** + * 1.13c Normal difficulty vendor item level caps per Act (Acts 1..5). + * D2Game.dll `D2GAME_NPC_FillStoreInventory_6FCC7100`: `[12, 20, 28, 36, 45]`. + */ +export const NORMAL_ACT_VENDOR_ILVL_CAP: readonly [number, number, number, number, number] = [ + 12, 20, 28, 36, 45, +] as const +export const VENDOR_NORMAL_ILVL_CAPS = NORMAL_ACT_VENDOR_ILVL_CAP + +export type VendorTabKey = 'armor' | 'weapons1' | 'weapons2' | 'misc' +export type VendorTabId = VendorTabKey + +export const VENDOR_TAB_KEYS: readonly VendorTabKey[] = [ + 'armor', + 'weapons1', + 'weapons2', + 'misc', +] as const + +export const VENDOR_GRID_WIDTH = 10 +export const VENDOR_GRID_HEIGHT = 10 + +export interface VendorItemCacheEntry { + readonly base: ItemBase + readonly columns: VendorItemColumns +} + +export interface GlobalVendorItemCache { + /** Non-permanent rollable store items per VendorNpcId (0..16). */ + readonly itemCache: Readonly> + /** Permanent store items (`PermStoreItem == 1`) per VendorNpcId (0..16). */ + readonly permCache: Readonly> +} + +let _cachedGlobalVendorCache: WeakMap = new WeakMap() + +/** + * Builds the global per-vendor item cache (`D2GAME_NPC_BuildItemCache_6FCCA970`) + * iterating Weapons.txt -> Armor.txt -> Misc.txt in exact MPQ row order. + */ +export function buildGlobalVendorItemCache(tables: DropTables): GlobalVendorItemCache { + const existing = _cachedGlobalVendorCache.get(tables) + if (existing) return existing + + const itemCache: Record = {} + const permCache: Record = {} + for (let vId = 0; vId < VENDOR_COUNT; vId++) { + itemCache[vId] = [] + permCache[vId] = [] + } + + const allBases: ItemBase[] = [ + ...tables.weapons.all, + ...tables.armor.all, + ...tables.misc.all, + ] + + for (const base of allBases) { + if (!base.spawnable) continue + const vCols = base.vendorColumns + if (!vCols) continue + + for (let vId = 0; vId < VENDOR_COUNT; vId++) { + const cols = vCols[vId] + if (!cols) continue + if (base.permStoreItem) { + if (cols.min > 0 || cols.max > 0) { + permCache[vId]!.push({ base, columns: cols }) + } + } else { + if (cols.max > 0 || cols.magicMax > 0) { + itemCache[vId]!.push({ base, columns: cols }) + } + } + } + } + + const built: GlobalVendorItemCache = { itemCache, permCache } + _cachedGlobalVendorCache.set(tables, built) + return built +} + +/** + * Resolves the effective vendor item level (`nItemLevel`) per `D2GAME_NPC_FillStoreInventory_6FCC7100`. + * + * - Base: `cLvl + 5` (clamped to `[1, 99]`). + * - In Normal (`difficulty === 0`), clamped to Act cap `[12, 20, 28, 36, 45][actIndex]`. + * - In Nightmare (`1`) and Hell (`2`), no Act cap is applied. + */ +export function computeVendorItemLevel( + vendorId: VendorNpcId, + difficulty: 0 | 1 | 2, + cLvl: number, + actIndex?: 0 | 1 | 2 | 3 | 4, +): number { + let nItemLevel = Math.max(1, Math.min(99, Math.trunc(cLvl) + 5)) + if (difficulty === 0) { + const resolvedAct = + actIndex ?? getTownNpcDescriptorByVendorId(vendorId)?.actIndex ?? 0 + const cap = NORMAL_ACT_VENDOR_ILVL_CAP[resolvedAct] ?? 12 + if (nItemLevel > cap) { + nItemLevel = cap + } + } + return Math.max(1, nItemLevel) +} + +/** + * Resolves the store tab page (`'armo' | 'weap' | 'misc'`) for an item base + * by traversing `ItemTypes.txt` (`StorePage` column). + */ +export function resolveStorePageForBase( + base: ItemBase, + itemTypes: ItemTypeTable, +): 'armo' | 'weap' | 'misc' { + const startType = getItemTypeCode(base).trim() + const queue: string[] = [startType, ...(base.tags ?? [])] + const visited = new Set() + + while (queue.length > 0) { + const code = queue.shift()!.trim() + if (!code || visited.has(code.toLowerCase())) continue + visited.add(code.toLowerCase()) + + const def = itemTypes.byCode.get(code) + if (!def) continue + const sp = (def.storePage ?? '').toLowerCase() + if (sp === 'armo' || sp === 'weap' || sp === 'misc') { + return sp + } + if (def.equiv1) queue.push(def.equiv1) + if (def.equiv2) queue.push(def.equiv2) + } + + if (base.kind === 'armor') return 'armo' + if (base.kind === 'weapon') return 'weap' + return 'misc' +} + +/** + * Resolves Nightmare/Hell base item upgrade inside `D2GAME_NPC_GenerateStoreItem_6FCC6A60`. + * Only runs when `cLvl > 25`. + */ +export function resolveVendorBaseUpgrade( + base: ItemBase, + cLvl: number, + nItemLevel: number, + difficulty: 0 | 1 | 2, + isExpansion: boolean, + tables: DropTables, + rng: D2Rng, +): ItemBase { + let selected = base + if (cLvl <= 25) { + return selected + } + + if (difficulty === 1) { + // Nightmare upgrade + const nRand = rng.rand(100000) + const nProb = (nItemLevel << 6) + 4000 + let upgradedToUber = false + if ( + nRand < nProb && + selected.ubercode && + selected.ubercode !== selected.id && + selected.ubercode !== 'xxx' + ) { + const uberBase = tables.getBase(selected.ubercode) + if (uberBase && uberBase.level <= nItemLevel) { + selected = uberBase + upgradedToUber = true + } + } + if ( + !upgradedToUber && + selected.nightmareUpgrade && + selected.nightmareUpgrade !== 'xxx' + ) { + const upgBase = tables.getBase(selected.nightmareUpgrade) + if (upgBase && upgBase.level <= nItemLevel) { + selected = upgBase + } + } + } else if (difficulty === 2) { + // Hell upgrade + const nRand = rng.rand(100000) + let upgradedTier = false + if (isExpansion) { + const nUltraProb = 16 * nItemLevel + 1000 + if ( + nRand < nUltraProb && + selected.ultracode && + selected.ultracode !== selected.id && + selected.ultracode !== 'xxx' + ) { + const ultraBase = tables.getBase(selected.ultracode) + if (ultraBase && ultraBase.level <= nItemLevel) { + selected = ultraBase + upgradedTier = true + } + } + } + if (!upgradedTier) { + const nUberProb = (nItemLevel << 7) + 5000 + if ( + nRand < nUberProb && + selected.ubercode && + selected.ubercode !== selected.id && + selected.ubercode !== 'xxx' + ) { + const uberBase = tables.getBase(selected.ubercode) + if (uberBase && uberBase.level <= nItemLevel) { + selected = uberBase + upgradedTier = true + } + } + } + if (selected.hellUpgrade && selected.hellUpgrade !== 'xxx') { + const upgBase = tables.getBase(selected.hellUpgrade) + if (upgBase && upgBase.level <= nItemLevel) { + selected = upgBase + } + } + } + + return selected +} + +/** + * Generates a single store item instance (`D2GAME_NPC_GenerateStoreItem_6FCC6A60`). + */ +export function generateVendorStoreItem(options: { + readonly base: ItemBase + readonly vendorId: VendorNpcId + readonly qualityTier: ItemQualityTier + readonly cLvl: number + readonly nItemLevel: number + readonly difficulty: 0 | 1 | 2 + readonly isExpansion?: boolean + readonly permStore: boolean + readonly tables: DropTables + readonly rng: D2Rng +}): Item | null { + const { + base, + vendorId, + qualityTier, + cLvl, + nItemLevel, + difficulty, + isExpansion = true, + permStore, + tables, + rng, + } = options + + // Never generate quest items in vendor stores + if (base.quest && base.quest > 0) { + return null + } + if (!isExpansion && (base as any).version >= 100) { + return null + } + + const selectedBase = resolveVendorBaseUpgrade( + base, + cLvl, + nItemLevel, + difficulty, + isExpansion, + tables, + rng, + ) + + if (!isExpansion && (selectedBase as any).version >= 100) { + return null + } + + const dwInitSeed = rng.rand(0x7fffffff) >>> 0 + const itemRng = new D2Rng(dwInitSeed || 1) + + const magicLvl = base.vendorColumns?.[vendorId]?.magicLvl ?? 255 + const rawAffixIlvl = + magicLvl > 0 && magicLvl < 255 ? Math.min(nItemLevel, magicLvl) : nItemLevel + // Ensure effective ilvl passed to rollMagicAffixes yields alvl >= 1 even when base qlvl > rawAffixIlvl + const minIlvlForAlvl1 = Math.trunc((selectedBase.level ?? 1) / 2) + 1 + const effectiveAffixIlvl = Math.max(rawAffixIlvl, minIlvlForAlvl1, 1) + + const magicAffixes = + qualityTier === 'magic' + ? rollMagicAffixes( + selectedBase, + effectiveAffixIlvl, + tables.magicAffixes, + tables.isA, + itemRng, + ) + : undefined + + const staffMods = + selectedBase.kind === 'weapon' || selectedBase.kind === 'armor' + ? rollStaffMods(selectedBase, nItemLevel, tables.itemTypes, itemRng) + : undefined + + // Roll sockets for normal/superior/low weapons and armor (never ethereal in vendor stores) + let sockets = 0 + if ( + (selectedBase.kind === 'weapon' || selectedBase.kind === 'armor') && + (qualityTier === 'normal' || qualityTier === 'superior' || qualityTier === 'low') + ) { + const baseMaxSockets = Number((selectedBase as any).gemsockets) || 0 + if (baseMaxSockets > 0) { + const typeCode = getItemTypeCode(selectedBase).trim().toLowerCase() + const def = tables.itemTypes.byCode.get(typeCode) + const typeMaxSockets = def + ? nItemLevel >= 40 + ? def.maxSock40 + : nItemLevel >= 25 + ? def.maxSock25 + : def.maxSock1 + : baseMaxSockets + const maxAllowed = Math.min(baseMaxSockets, typeMaxSockets) + if (maxAllowed > 0 && itemRng.rand(3) === 0) { + sockets = itemRng.randRange(1, maxAllowed) + } + } + } + + const created = createDroppedItem(selectedBase, qualityTier, { + magicAffixes, + staffMods, + ethereal: false, + sockets, + itemTypes: tables.itemTypes, + ilvl: nItemLevel, + dwInitSeed, + itemRng, + }) + + // Roll authentic armor defense in [minac, maxac] (or maxac + 1 if enhanced defense) + let defense = created.defense + if (selectedBase.kind === 'armor') { + const minac = Number((selectedBase as any).minac) || selectedBase.defense + const maxac = Number((selectedBase as any).maxac) || selectedBase.defense + const acPct = created.stats['ac%'] ?? 0 + if (acPct > 0) { + defense = Math.trunc(((maxac + 1) * (100 + acPct)) / 100) + } else if (qualityTier === 'low') { + const rawAc = minac < maxac ? itemRng.randRange(minac, maxac) : maxac + defense = Math.max(1, Math.trunc((rawAc * 75) / 100)) + } else { + defense = minac < maxac ? itemRng.randRange(minac, maxac) : maxac + } + } + + // Stack / quantity handling for permanent store items, quivers, throwing weapons, and tomes + let stack = created.stack + const codeLower = selectedBase.id.toLowerCase() + const typeLower = (selectedBase.type ?? '').toLowerCase() + if (permStore && (typeLower === 'bowq' || typeLower === 'xbowq' || codeLower === 'aqv' || codeLower === 'cqv')) { + stack = selectedBase.maxStack > 1 ? selectedBase.maxStack : 350 + } else if (codeLower === 'tbk' || codeLower === 'ibk') { + stack = Number((selectedBase as any).spawnstack) || 3 + } else if ((selectedBase as any).stackable) { + const minStack = Number((selectedBase as any).minstack) || 1 + const maxStack = Number((selectedBase as any).maxstack) || selectedBase.maxStack || 1 + const spawnStack = Number((selectedBase as any).spawnstack) || maxStack + stack = permStore ? maxStack : Math.max(minStack, Math.min(maxStack, spawnStack)) + } + + return { + ...created, + ...(defense !== undefined ? { defense, stats: { ...created.stats, defense } } : {}), + stack, + quantity: stack, + permStore, + } +} + +export interface VendorStoreState { + readonly vendorId: VendorNpcId + readonly difficulty: 0 | 1 | 2 + readonly cLvl: number + readonly nItemLevel: number + readonly tabs: Readonly> + readonly allItems: readonly Item[] +} + +/** + * Places an item into the appropriate vendor tab (`armor`, `weapons1` -> overflow `weapons2`, `misc`). + */ +export function addItemToVendorTabs( + tabs: Readonly>, + item: Item, + itemTypes: ItemTypeTable, +): { readonly tab: VendorTabKey; readonly x: number; readonly y: number } | null { + const storePage = resolveStorePageForBase(item.base, itemTypes) + if (storePage === 'armo') { + const pos = tabs.armor.add(item) + return pos ? { tab: 'armor', ...pos } : null + } + if (storePage === 'weap') { + const pos1 = tabs.weapons1.add(item) + if (pos1) return { tab: 'weapons1', ...pos1 } + const pos2 = tabs.weapons2.add(item) + if (pos2) return { tab: 'weapons2', ...pos2 } + return null + } + const posMisc = tabs.misc.add(item) + return posMisc ? { tab: 'misc', ...posMisc } : null +} + +/** + * Generates the complete 4-tab 10x10 store inventory for a vendor (`D2GAME_NPC_FillStoreInventory_6FCC7100`). + */ +export function generateVendorStoreInventory(options: { + readonly vendorId: VendorNpcId + readonly difficulty: 0 | 1 | 2 + readonly cLvl: number + readonly actIndex?: 0 | 1 | 2 | 3 | 4 | undefined + readonly isExpansion?: boolean | undefined + readonly tables: DropTables + readonly rng?: D2Rng | undefined + readonly seed?: number | undefined +}): VendorStoreState { + const { + vendorId, + difficulty, + cLvl, + actIndex, + isExpansion = true, + tables, + } = options + const rng = options.rng ?? new D2Rng(options.seed ?? ((vendorId + 1) * 0x9e3779b9 + cLvl * 131 + difficulty * 997)) + + const nItemLevel = computeVendorItemLevel(vendorId, difficulty, cLvl, actIndex) + const cache = buildGlobalVendorItemCache(tables) + + const tabs: Record = { + armor: new Inventory(VENDOR_GRID_WIDTH, VENDOR_GRID_HEIGHT), + weapons1: new Inventory(VENDOR_GRID_WIDTH, VENDOR_GRID_HEIGHT), + weapons2: new Inventory(VENDOR_GRID_WIDTH, VENDOR_GRID_HEIGHT), + misc: new Inventory(VENDOR_GRID_WIDTH, VENDOR_GRID_HEIGHT), + } + + const allItems: Item[] = [] + + const nonPermEntries = cache.itemCache[vendorId] ?? [] + for (const entry of nonPermEntries) { + const { base, columns } = entry + if (base.level > nItemLevel) { + continue + } + + // 1. Normal / Inferior / Superior roll (ONLY when nItemLevel < 25 and nMax > 0) + if (nItemLevel < 25 && columns.max > 0) { + const range = columns.max - columns.min + 1 + const count = columns.min + (range > 0 ? rng.rand(range) : 0) + for (let i = 0; i < count; i++) { + const qRoll = rng.rand(100) + let qTier: ItemQualityTier = 'normal' + if (nItemLevel < 5) { + if (qRoll > 90) qTier = 'low' + } else if (nItemLevel < 10) { + if (qRoll > 85) qTier = 'superior' + } else { + if (qRoll >= 75) qTier = 'superior' + } + + const item = generateVendorStoreItem({ + base, + vendorId, + qualityTier: qTier, + cLvl, + nItemLevel, + difficulty, + isExpansion, + permStore: false, + tables, + rng, + }) + if (item) { + const placed = addItemToVendorTabs(tabs, item, tables.itemTypes) + if (placed) allItems.push(item) + } + } + } + + // 2. Magic item roll (when magicMax > 0 and (bitfield1 & 1) !== 0) + const bitfield1 = base.bitfield1 ?? 0 + if (columns.magicMax > 0 && (bitfield1 & 1) !== 0) { + const nMinItems = nItemLevel >= 25 ? rng.rand(2) + 2 : 1 + const nRange = nMinItems + columns.magicMax - columns.magicMin + const nMagicCount = + nRange > 0 ? columns.magicMin + rng.rand(nRange) : columns.magicMin + for (let i = 0; i < nMagicCount; i++) { + const item = generateVendorStoreItem({ + base, + vendorId, + qualityTier: 'magic', + cLvl, + nItemLevel, + difficulty, + isExpansion, + permStore: false, + tables, + rng, + }) + if (item) { + const placed = addItemToVendorTabs(tabs, item, tables.itemTypes) + if (placed) allItems.push(item) + } + } + } + } + + // Step 2: Permanent store items (`pPermCache`) + const permEntries = cache.permCache[vendorId] ?? [] + for (const entry of permEntries) { + const { base } = entry + const item = generateVendorStoreItem({ + base, + vendorId, + qualityTier: 'normal', + cLvl, + nItemLevel, + difficulty, + isExpansion, + permStore: true, + tables, + rng, + }) + if (item) { + const placed = addItemToVendorTabs(tabs, item, tables.itemTypes) + if (placed) allItems.push(item) + } + } + + return { + vendorId, + difficulty, + cLvl, + nItemLevel, + tabs, + allItems, + } +} + +/** + * Generates a 10x10 Gamble inventory (`D2GAME_NPC_FillGamble_6FCC73E0`) + * from `Gamble.txt` for a given character level `cLvl`. + */ +export function generateGambleInventory(options: { + readonly vendorId: VendorNpcId + readonly cLvl: number + readonly isExpansion?: boolean | undefined + readonly tables: DropTables + readonly rng?: D2Rng | undefined + readonly seed?: number | undefined +}): Inventory { + const { cLvl, isExpansion = true, tables } = options + const rng = options.rng ?? new D2Rng(options.seed ?? (cLvl * 2654435761 + 17)) + const grid = new Inventory(VENDOR_GRID_WIDTH, VENDOR_GRID_HEIGHT) + + const gambleCodes = tables.gambleCodes ?? [] + const maxBaseLvl = Math.max(1, cLvl + 4) + const candidates: ItemBase[] = [] + for (const code of gambleCodes) { + const base = tables.getBase(code) + if (!base) continue + if (!isExpansion && (base as any).version >= 100) continue + if (base.level <= maxBaseLvl) { + candidates.push(base) + } + } + + if (candidates.length === 0) { + return grid + } + + // Always guarantee Ring ('rin') and Amulet ('amu') are included in Gamble inventory per 1.13c + const rinBase = tables.getBase('rin') + const amuBase = tables.getBase('amu') + const priorityBases: ItemBase[] = [rinBase, amuBase].filter((b): b is ItemBase => Boolean(b)) + + const targetCount = 14 + const usedCodes = new Set() + let placedCount = 0 + let attempts = 0 + + while (placedCount < targetCount && attempts < 80) { + attempts++ + const base = + placedCount < priorityBases.length + ? priorityBases[placedCount]! + : candidates[rng.rand(candidates.length)]! + if (usedCodes.has(base.id) && attempts < 40 && placedCount >= priorityBases.length) { + continue + } + const dwInitSeed = rng.rand(0x7fffffff) >>> 0 + const rawFlags = ItemFlag.GAMBLE + const ilvl = Math.max(5, Math.min(99, Math.trunc(cLvl) + rng.rand(10) - 5)) + const mysteryItem: Item = { + base, + prefix: null, + suffix: null, + level: ilvl, + ilvl, + name: base.name, + stats: { + ...(base.damage > 0 ? { damage: base.damage } : {}), + ...(base.defense > 0 ? { defense: base.defense } : {}), + }, + invWidth: base.invWidth, + invHeight: base.invHeight, + stack: 1, + value: base.gambleCost ?? base.value, + rawFlags, + flags: decodeItemFlags(rawFlags), + version: 0x65, + code: formatItemCode(base.id), + quality: ItemQuality.MAGIC, + rarity: 'magic', + uniqueId: dwInitSeed, + isGambleMystery: true, + identified: false, + } + + const pos = grid.add(mysteryItem) + if (pos) { + usedCodes.add(base.id) + placedCount++ + } + } + + return grid +} + +/** + * Resolves a purchased mystery Gamble item into its revealed Magic / Rare / Set / Unique item + * per 1.13c `D2GAME_NPC_CreateGambleItem`: + * - `ilvl = clamp(cLvl + rand(10) - 5, 5, 99)` (`[cLvl - 5, cLvl + 4]`) + * - Exceptional (`ubercode`) chance: `(ilvl - qlvl_uber) * 90 + 100` out of `10000` + * - Elite (`ultracode`) chance: `(ilvl - qlvl_ultra) * 33 + 100` out of `10000` + * - Quality roll (out of `100000`): + * - Unique: `50` (`0.05%`, 1 in 2000) + * - Set: `100` (`0.10%`, 1 in 1000) + * - Rare: `10000` (`10.0%`, 1 in 10) + * - Magic: `89850` (`89.85%`) + */ +export function resolveGambledItem(options: { + readonly mysteryItem: Item + readonly cLvl: number + readonly isExpansion?: boolean | undefined + readonly tables: DropTables + readonly rng?: D2Rng | undefined +}): Item { + const { mysteryItem, cLvl, isExpansion = true, tables } = options + const rng = options.rng ?? new D2Rng((mysteryItem.uniqueId ?? 1) ^ (cLvl * 0x45d9f3b)) + + const ilvl = Math.max(5, Math.min(99, Math.trunc(cLvl) + rng.rand(10) - 5)) + const normBase = mysteryItem.base + let selectedBase: ItemBase = normBase + + const uberBase = + normBase.ubercode && normBase.ubercode !== normBase.id && normBase.ubercode !== 'xxx' + ? tables.getBase(normBase.ubercode) + : undefined + const ultraBase = + isExpansion && + normBase.ultracode && + normBase.ultracode !== normBase.id && + normBase.ultracode !== 'xxx' + ? tables.getBase(normBase.ultracode) + : undefined + + const chanceUltra = + ultraBase && ilvl >= ultraBase.level ? (ilvl - ultraBase.level) * 33 + 100 : 0 + const chanceUber = + uberBase && ilvl >= uberBase.level ? (ilvl - uberBase.level) * 90 + 100 : 0 + + const upgRoll = rng.rand(10000) + if (chanceUltra > 0 && upgRoll < chanceUltra && ultraBase) { + selectedBase = ultraBase + } else if (chanceUber > 0 && upgRoll < chanceUber && uberBase) { + selectedBase = uberBase + } + + const qRoll = rng.rand(100000) + let qualityTier: ItemQualityTier = 'magic' + let uniqueItem = undefined + let setItem = undefined + + if (qRoll < 50) { + // Try Unique (0.05%) + const candidates = tables.uniques.byCode.get(selectedBase.id)?.filter( + u => u.enabled && u.lvl <= ilvl && (isExpansion || u.version < 100), + ) + if (candidates && candidates.length > 0) { + qualityTier = 'unique' + uniqueItem = candidates[rng.rand(candidates.length)] + } else { + qualityTier = 'rare' + } + } else if (qRoll < 150) { + // Try Set (0.10%) + const candidates = tables.sets.items.entries.filter( + s => + s.item.toLowerCase() === selectedBase.id.toLowerCase() && + s.lvl <= ilvl, + ) + if (candidates.length > 0) { + qualityTier = 'set' + setItem = candidates[rng.rand(candidates.length)] + } else { + qualityTier = 'rare' + } + } else if (qRoll < 10150) { + qualityTier = 'rare' + } else { + qualityTier = 'magic' + } + + const dwInitSeed = rng.rand(0x7fffffff) >>> 0 + const itemRng = new D2Rng(dwInitSeed || 1) + + const rareAffixes = + qualityTier === 'rare' + ? rollRareAffixes( + selectedBase, + ilvl, + tables.magicAffixes, + tables.rareNames, + tables.isA, + itemRng, + ) + : undefined + + const magicAffixes = + qualityTier === 'magic' + ? rollMagicAffixes( + selectedBase, + ilvl, + tables.magicAffixes, + tables.isA, + itemRng, + ) + : undefined + + const staffMods: RolledItemProp[] | undefined = + selectedBase.kind === 'weapon' || selectedBase.kind === 'armor' + ? rollStaffMods(selectedBase, ilvl, tables.itemTypes, itemRng) + : undefined + + const created = createDroppedItem(selectedBase, qualityTier, { + uniqueItem, + setItem, + rareAffixes, + magicAffixes, + staffMods, + ethereal: false, + sockets: 0, + itemTypes: tables.itemTypes, + ilvl, + dwInitSeed, + itemRng, + }) + + return { + ...created, + isGambleMystery: false, + identified: true, + } +} diff --git a/src/game/vendor-service.ts b/src/game/vendor-service.ts new file mode 100644 index 0000000..b3e05e6 --- /dev/null +++ b/src/game/vendor-service.ts @@ -0,0 +1,980 @@ +import { D2Rng } from './d2-rng.ts' +import type { DropTables } from './drop-pipeline.ts' +import { getEmbeddedDropTables } from './embedded-drop-tables.ts' +import { + Inventory, + ItemFlag, + decodeItemFlags, + type PlacedItem, + type Item, +} from './items.ts' +import { + TransactionType, + calculateGambleCost, + calculateTransactionCost, + isItemRepairable, +} from './item-cost.ts' +import { + VendorNpcId, + getTownNpcDescriptorByHcIdx, + getTownNpcDescriptorByName, + getTownNpcDescriptorByVendorId, + type TownNpcServiceDescriptor, +} from './npc-table.ts' +import { + VENDOR_GRID_HEIGHT, + VENDOR_GRID_WIDTH, + addItemToVendorTabs, + generateGambleInventory, + generateVendorStoreInventory, + resolveGambledItem, + type VendorStoreState, + type VendorTabKey, +} from './vendor-generator.ts' + +export interface GoldPurseState { + inventoryGold: number + stashGold: number + readonly cLvl: number +} + +/** + * Deducts `amount` of gold from `inventoryGold` first, then `stashGold` (1.13c vendor/repair behavior). + * Returns `false` without mutating if total gold is insufficient. + */ +export function deductGoldFromPurse(purse: GoldPurseState, amount: number): boolean { + const total = purse.inventoryGold + purse.stashGold + if (amount <= 0) return true + if (total < amount) return false + + if (purse.inventoryGold >= amount) { + purse.inventoryGold -= amount + } else { + const remainder = amount - purse.inventoryGold + purse.inventoryGold = 0 + purse.stashGold = Math.max(0, purse.stashGold - remainder) + } + return true +} + +/** + * Adds `amount` of gold to `inventoryGold` up to `10000 * cLvl`, overflowing any excess into `stashGold`. + */ +export function addGoldToPurse(purse: GoldPurseState, amount: number): void { + if (amount <= 0) return + const maxInvGold = Math.max(10000, Math.trunc(purse.cLvl) * 10000) + const roomInInv = Math.max(0, maxInvGold - purse.inventoryGold) + const toInv = Math.min(amount, roomInInv) + purse.inventoryGold += toInv + const overflow = amount - toInv + if (overflow > 0) { + purse.stashGold += overflow + } +} + +export interface BuyItemResult { + readonly ok: boolean + readonly reason?: string | undefined + readonly goldSpent: number + readonly itemsGranted: readonly Item[] + readonly countPurchased: number +} + +export interface SellItemResult { + readonly ok: boolean + readonly reason?: string | undefined + readonly goldEarned: number + readonly placedInTab?: VendorTabKey | undefined +} + +export interface RepairResult { + readonly ok: boolean + readonly reason?: string | undefined + readonly goldSpent: number + readonly repairedCount: number +} + +export interface IdentifyAllResult { + readonly ok: boolean + readonly reason?: string | undefined + readonly identifiedCount: number + readonly goldSpent: number +} + +/** + * Stateful manager for Town NPC interactions, vendor store inventories, Gamble sessions, + * Buy/Shift-Buy/Sell/Buyback, Repair, Heal, and Cain Identify All. + */ +export class VendorSessionManager { + private tables: DropTables + private readonly rng: D2Rng + private readonly stores = new Map() + private readonly gambleStores = new Map() + private activeDescriptor: TownNpcServiceDescriptor | null = null + private activeTab: VendorTabKey = 'misc' + private isGambleMode = false + private activeGambleGrid: Inventory | null = null + private repairCursorActive = false + + constructor(tables?: DropTables, seed = 0x1337c0de) { + this.tables = tables ?? getEmbeddedDropTables() + this.rng = new D2Rng(seed) + } + + setDropTables(tables: DropTables): void { + this.tables = tables + } + + getTables(): DropTables { + return this.tables + } + + getActiveDescriptor(): TownNpcServiceDescriptor | null { + return this.activeDescriptor + } + + getActiveVendorId(): VendorNpcId | null { + return this.activeDescriptor?.vendorId ?? null + } + + getActiveTab(): VendorTabKey { + return this.activeTab + } + + setActiveTab(tab: VendorTabKey): void { + this.activeTab = tab + } + + isGambleSession(): boolean { + return this.isGambleMode + } + + isRepairCursorActive(): boolean { + return this.repairCursorActive + } + + setRepairCursorActive(active: boolean): void { + this.repairCursorActive = active + } + + private storeCacheKey(vendorId: VendorNpcId, difficulty: 0 | 1 | 2, cLvl: number): string { + return `${vendorId}:${difficulty}:${cLvl}` + } + + /** + * Resolves a Town NPC service descriptor from `hcIdx` or display name. + */ + resolveNpcDescriptor( + npcNameOrHcIdx: string | number, + actIndex?: 0 | 1 | 2 | 3 | 4, + ): TownNpcServiceDescriptor | undefined { + if (typeof npcNameOrHcIdx === 'number') { + return getTownNpcDescriptorByHcIdx(npcNameOrHcIdx) + } + return getTownNpcDescriptorByName(npcNameOrHcIdx, actIndex) + } + + /** + * Gets or generates the 4-tab store state for a vendor at `(difficulty, cLvl)`. + */ + getOrCreateVendorStore( + vendorId: VendorNpcId, + difficulty: 0 | 1 | 2, + cLvl: number, + actIndex?: 0 | 1 | 2 | 3 | 4, + ): VendorStoreState { + const key = this.storeCacheKey(vendorId, difficulty, cLvl) + let store = this.stores.get(key) + if (!store) { + store = generateVendorStoreInventory({ + vendorId, + difficulty, + cLvl, + actIndex, + tables: this.tables, + rng: this.rng, + }) + this.stores.set(key, store) + } + return store + } + + /** + * Gets or generates a 10x10 Gamble inventory grid for `(vendorId, cLvl)`. + */ + getOrCreateGambleStore(vendorId: VendorNpcId, cLvl: number): Inventory { + const key = `gamble:${vendorId}:${cLvl}` + let inv = this.gambleStores.get(key) + if (!inv) { + inv = generateGambleInventory({ + vendorId, + cLvl, + tables: this.tables, + rng: this.rng, + }) + this.gambleStores.set(key, inv) + } + return inv + } + + /** + * Convenience wrapper for UI: computes buy or gamble price for an item. + */ + getBuyPrice( + item: Item, + vendorId: VendorNpcId, + difficulty: 0 | 1 | 2, + cLvl: number, + isGamble = false, + ): number { + if (isGamble || item.isGambleMystery) { + return calculateGambleCost({ + item, + cLvl, + tables: this.tables, + }) + } + const desc = getTownNpcDescriptorByVendorId(vendorId) + return calculateTransactionCost({ + item, + transactionType: TransactionType.BUY, + difficulty, + cLvl, + vendorId, + npcKey: desc?.npcTxtKey, + actIndex: desc?.actIndex, + tables: this.tables, + }) + } + + /** + * Convenience wrapper for UI: computes sell (acquisition) price for an item. + */ + getSellPrice( + item: Item, + vendorId: VendorNpcId, + difficulty: 0 | 1 | 2, + cLvl: number, + ): number { + const desc = getTownNpcDescriptorByVendorId(vendorId) + return calculateTransactionCost({ + item, + transactionType: TransactionType.SELL, + difficulty, + cLvl, + vendorId, + npcKey: desc?.npcTxtKey, + actIndex: desc?.actIndex, + tables: this.tables, + }) + } + + /** + * Convenience wrapper for UI: computes repair price for an item. + */ + getRepairPrice( + item: Item, + vendorId: VendorNpcId, + difficulty: 0 | 1 | 2, + cLvl: number, + ): number { + const desc = getTownNpcDescriptorByVendorId(vendorId) + return calculateTransactionCost({ + item, + transactionType: TransactionType.REPAIR, + difficulty, + cLvl, + vendorId, + npcKey: desc?.npcTxtKey, + actIndex: desc?.actIndex, + tables: this.tables, + }) + } + + /** + * Purchases and resolves a mystery item from the Gamble store at `(col, row)`. + */ + buyGambleItem( + vendorId: VendorNpcId, + col: number, + row: number, + playerGold: number, + cLvl: number, + ): { + readonly success: boolean + readonly item?: Item | undefined + readonly goldSpent: number + readonly remainingGold: number + readonly error?: string | undefined + } { + const gambleInv = this.getOrCreateGambleStore(vendorId, cLvl) + const placed = gambleInv.at(col, row) + if (!placed) { + return { success: false, goldSpent: 0, remainingGold: playerGold, error: 'empty_cell' } + } + const cost = calculateGambleCost({ + item: placed.item, + cLvl, + tables: this.tables, + }) + if (playerGold < cost) { + return { success: false, goldSpent: 0, remainingGold: playerGold, error: 'not_enough_gold' } + } + const revealed = resolveGambledItem({ + mysteryItem: placed.item, + cLvl, + tables: this.tables, + rng: this.rng, + }) + gambleInv.remove(placed.x, placed.y) + return { + success: true, + item: revealed, + goldSpent: cost, + remainingGold: playerGold - cost, + } + } + + /** + * Opens a Trade session with a vendor NPC. + */ + openTradeSession( + descriptor: TownNpcServiceDescriptor, + difficulty: 0 | 1 | 2, + cLvl: number, + ): VendorStoreState | null { + if (descriptor.vendorId === null || !descriptor.canTrade) { + return null + } + this.activeDescriptor = descriptor + this.isGambleMode = false + this.activeGambleGrid = null + this.repairCursorActive = false + + const store = this.getOrCreateVendorStore( + descriptor.vendorId, + difficulty, + cLvl, + descriptor.actIndex, + ) + + // Choose default non-empty tab (Armor -> Weapons I -> Misc) + if (store.tabs.armor.list().length > 0) { + this.activeTab = 'armor' + } else if (store.tabs.weapons1.list().length > 0) { + this.activeTab = 'weapons1' + } else { + this.activeTab = 'misc' + } + return store + } + + /** + * Opens a Gamble session with a gamble-capable NPC (Gheed, Elzix, Alkor, Jamella, Anya, Nihlathak). + */ + openGambleSession( + descriptor: TownNpcServiceDescriptor, + cLvl: number, + ): Inventory | null { + if (descriptor.vendorId === null || !descriptor.canGamble) { + return null + } + this.activeDescriptor = descriptor + this.isGambleMode = true + this.repairCursorActive = false + this.activeTab = 'armor' + this.activeGambleGrid = generateGambleInventory({ + vendorId: descriptor.vendorId, + cLvl, + tables: this.tables, + rng: this.rng, + }) + return this.activeGambleGrid + } + + /** + * Refreshes the Gamble grid with a new roll of mystery items. + */ + refreshGambleSession(cLvl: number): Inventory | null { + if (!this.activeDescriptor || !this.isGambleMode || this.activeDescriptor.vendorId === null) { + return null + } + this.activeGambleGrid = generateGambleInventory({ + vendorId: this.activeDescriptor.vendorId, + cLvl, + tables: this.tables, + rng: this.rng, + }) + return this.activeGambleGrid + } + + closeSession(): void { + this.activeDescriptor = null + this.isGambleMode = false + this.activeGambleGrid = null + this.repairCursorActive = false + } + + /** + * Returns the currently displayed 10x10 Inventory grid for the active vendor tab or Gamble window. + */ + getActiveTabInventory(difficulty: 0 | 1 | 2, cLvl: number): Inventory | null { + if (!this.activeDescriptor || this.activeDescriptor.vendorId === null) { + return null + } + if (this.isGambleMode) { + if (!this.activeGambleGrid) { + this.activeGambleGrid = generateGambleInventory({ + vendorId: this.activeDescriptor.vendorId, + cLvl, + tables: this.tables, + rng: this.rng, + }) + } + return this.activeGambleGrid + } + const store = this.getOrCreateVendorStore( + this.activeDescriptor.vendorId, + difficulty, + cLvl, + this.activeDescriptor.actIndex, + ) + return store.tabs[this.activeTab] + } + + /** + * Calculates the purchase price (or Gamble price) for an item in the vendor's inventory. + */ + getItemBuyCost(options: { + readonly item: Item + readonly difficulty: 0 | 1 | 2 + readonly cLvl: number + readonly vendorId?: VendorNpcId | undefined + readonly completedQuests?: ReadonlySet | undefined + readonly reducedPricesPercent?: number | undefined + }): number { + const vId = options.vendorId ?? this.activeDescriptor?.vendorId ?? VendorNpcId.AKARA + if (this.isGambleMode || options.item.isGambleMystery) { + return calculateGambleCost({ + item: options.item, + cLvl: options.cLvl, + reducedPricesPercent: options.reducedPricesPercent, + tables: this.tables, + }) + } + return calculateTransactionCost({ + item: options.item, + transactionType: TransactionType.BUY, + difficulty: options.difficulty, + cLvl: options.cLvl, + vendorId: vId, + npcKey: this.activeDescriptor?.npcTxtKey, + actIndex: this.activeDescriptor?.actIndex, + completedQuests: options.completedQuests, + reducedPricesPercent: options.reducedPricesPercent, + tables: this.tables, + }) + } + + /** + * Calculates the acquisition (Sell to NPC) price for a player's item. + */ + getItemSellPrice(options: { + readonly item: Item + readonly difficulty: 0 | 1 | 2 + readonly cLvl: number + readonly vendorId?: VendorNpcId | undefined + readonly completedQuests?: ReadonlySet | undefined + }): number { + const vId = options.vendorId ?? this.activeDescriptor?.vendorId ?? VendorNpcId.AKARA + return calculateTransactionCost({ + item: options.item, + transactionType: TransactionType.SELL, + difficulty: options.difficulty, + cLvl: options.cLvl, + vendorId: vId, + npcKey: this.activeDescriptor?.npcTxtKey, + actIndex: this.activeDescriptor?.actIndex, + completedQuests: options.completedQuests, + tables: this.tables, + }) + } + + /** + * Calculates the repair cost for an item. + */ + getItemRepairCost(options: { + readonly item: Item + readonly difficulty: 0 | 1 | 2 + readonly cLvl: number + readonly vendorId?: VendorNpcId | undefined + readonly completedQuests?: ReadonlySet | undefined + readonly reducedPricesPercent?: number | undefined + }): number { + const vId = options.vendorId ?? this.activeDescriptor?.vendorId ?? VendorNpcId.CHARSI + return calculateTransactionCost({ + item: options.item, + transactionType: TransactionType.REPAIR, + difficulty: options.difficulty, + cLvl: options.cLvl, + vendorId: vId, + npcKey: this.activeDescriptor?.npcTxtKey, + actIndex: this.activeDescriptor?.actIndex, + completedQuests: options.completedQuests, + reducedPricesPercent: options.reducedPricesPercent, + tables: this.tables, + }) + } + + /** + * Purchases an item at `(cellX, cellY)` from the active vendor tab or Gamble grid. + * Supports Shift-Buy (`shiftBuy: true`) for filling Tomes (`tsc`/`isc`), Belt potions (`hp*`/`mp*`), and Keys (`key`). + */ + buyItemAtCell(options: { + readonly cellX: number + readonly cellY: number + readonly shiftBuy?: boolean | undefined + readonly purse: GoldPurseState + readonly playerInventory: Inventory + readonly beltItems?: (Item | null)[] | undefined + readonly beltCapacity?: number | undefined + readonly difficulty: 0 | 1 | 2 + readonly completedQuests?: ReadonlySet | undefined + readonly reducedPricesPercent?: number | undefined + }): BuyItemResult { + const { + cellX, + cellY, + shiftBuy = false, + purse, + playerInventory, + beltItems, + beltCapacity = 16, + difficulty, + completedQuests, + reducedPricesPercent = 0, + } = options + + const vendorGrid = this.getActiveTabInventory(difficulty, purse.cLvl) + if (!vendorGrid) { + return { ok: false, reason: 'No active vendor store', goldSpent: 0, itemsGranted: [], countPurchased: 0 } + } + + const placement: PlacedItem | null = vendorGrid.at(cellX, cellY) + if (!placement) { + return { ok: false, reason: 'No item at cell', goldSpent: 0, itemsGranted: [], countPurchased: 0 } + } + + const storeItem = placement.item + const unitCost = this.getItemBuyCost({ + item: storeItem, + difficulty, + cLvl: purse.cLvl, + completedQuests, + reducedPricesPercent, + }) + + const totalAvailableGold = purse.inventoryGold + purse.stashGold + if (totalAvailableGold < unitCost) { + return { ok: false, reason: 'Not enough gold', goldSpent: 0, itemsGranted: [], countPurchased: 0 } + } + + // Branch 1: Gamble purchase + if (this.isGambleMode || storeItem.isGambleMystery) { + const revealed = resolveGambledItem({ + mysteryItem: storeItem, + cLvl: purse.cLvl, + tables: this.tables, + rng: this.rng, + }) + const addedPos = playerInventory.add(revealed) + if (!addedPos) { + return { ok: false, reason: 'Not enough space in inventory', goldSpent: 0, itemsGranted: [], countPurchased: 0 } + } + deductGoldFromPurse(purse, unitCost) + vendorGrid.remove(placement.x, placement.y) + return { + ok: true, + goldSpent: unitCost, + itemsGranted: [revealed], + countPurchased: 1, + } + } + + const codeLower = storeItem.base.id.toLowerCase() + + // Branch 2: Shift-Buy Scroll (`tsc` -> `tbk`, `isc` -> `ibk`) + if (shiftBuy && storeItem.permStore && (codeLower === 'tsc' || codeLower === 'isc')) { + const tomeCode = codeLower === 'tsc' ? 'tbk' : 'ibk' + const tomePlacement = playerInventory + .list() + .find(p => p.item.base.id.toLowerCase() === tomeCode && (p.item.quantity ?? p.item.stack) < 20) + if (tomePlacement) { + const curQty = tomePlacement.item.quantity ?? tomePlacement.item.stack ?? 1 + const needed = Math.max(0, 20 - curQty) + const affordable = Math.min(needed, Math.trunc((purse.inventoryGold + purse.stashGold) / unitCost)) + if (affordable > 0) { + const spent = affordable * unitCost + deductGoldFromPurse(purse, spent) + playerInventory.remove(tomePlacement.x, tomePlacement.y) + const updatedTome: Item = { + ...tomePlacement.item, + stack: curQty + affordable, + quantity: curQty + affordable, + } + playerInventory.place(updatedTome, tomePlacement.x, tomePlacement.y) + return { + ok: true, + goldSpent: spent, + itemsGranted: [updatedTome], + countPurchased: affordable, + } + } + } + } + + // Branch 3: Shift-Buy Belt Potions (`autobelt`) + if (shiftBuy && storeItem.permStore && (storeItem.base as any).autobelt && beltItems) { + let bought = 0 + let spent = 0 + const granted: Item[] = [] + const cap = Math.min(beltCapacity, beltItems.length) + for (let i = 0; i < cap; i++) { + if (beltItems[i] === null) { + if (purse.inventoryGold + purse.stashGold < unitCost) break + deductGoldFromPurse(purse, unitCost) + const clone: Item = { + ...storeItem, + permStore: false, + uniqueId: this.rng.rand(0x7fffffff) >>> 0, + } + beltItems[i] = clone + granted.push(clone) + bought++ + spent += unitCost + } + } + if (bought > 0) { + return { + ok: true, + goldSpent: spent, + itemsGranted: granted, + countPurchased: bought, + } + } + } + + // Branch 4: Shift-Buy Keys (`key` up to maxStack 12) + if (shiftBuy && storeItem.permStore && codeLower === 'key') { + const maxKeyStack = storeItem.base.maxStack || 12 + const existingKey = playerInventory + .list() + .find(p => p.item.base.id.toLowerCase() === 'key' && p.item.stack < maxKeyStack) + if (existingKey) { + const needed = maxKeyStack - existingKey.item.stack + const affordable = Math.min(needed, Math.trunc((purse.inventoryGold + purse.stashGold) / unitCost)) + if (affordable > 0) { + const spent = affordable * unitCost + deductGoldFromPurse(purse, spent) + playerInventory.remove(existingKey.x, existingKey.y) + const updatedKey: Item = { + ...existingKey.item, + stack: existingKey.item.stack + affordable, + quantity: existingKey.item.stack + affordable, + } + playerInventory.place(updatedKey, existingKey.x, existingKey.y) + return { + ok: true, + goldSpent: spent, + itemsGranted: [updatedKey], + countPurchased: affordable, + } + } + } + } + + // Standard single-item purchase + const purchasedItem: Item = { + ...storeItem, + permStore: false, + uniqueId: storeItem.permStore ? (this.rng.rand(0x7fffffff) >>> 0) : storeItem.uniqueId, + } + + // If buying a scroll and player has a non-full matching tome, auto-add to tome if single buy & no inventory space, or place in inventory + const addedPos = playerInventory.add(purchasedItem) + if (!addedPos) { + return { ok: false, reason: 'Not enough space in inventory', goldSpent: 0, itemsGranted: [], countPurchased: 0 } + } + + deductGoldFromPurse(purse, unitCost) + if (!storeItem.permStore) { + vendorGrid.remove(placement.x, placement.y) + } + + return { + ok: true, + goldSpent: unitCost, + itemsGranted: [purchasedItem], + countPurchased: 1, + } + } + + /** + * Sells an item from the player's inventory (or cursor) to the active vendor. + * Non-permanent items are placed into the vendor's corresponding tab for Buyback! + */ + sellPlayerItem(options: { + readonly item: Item + readonly purse: GoldPurseState + readonly difficulty: 0 | 1 | 2 + readonly completedQuests?: ReadonlySet | undefined + }): SellItemResult { + const { item, purse, difficulty, completedQuests } = options + + if ((item.base.quest ?? 0) > 0) { + return { ok: false, reason: 'Quest items cannot be sold', goldEarned: 0 } + } + + const sellPrice = this.getItemSellPrice({ + item, + difficulty, + cLvl: purse.cLvl, + completedQuests, + }) + + addGoldToPurse(purse, sellPrice) + + let placedInTab: VendorTabKey | undefined + if (this.activeDescriptor && this.activeDescriptor.vendorId !== null && !this.isGambleMode) { + const store = this.getOrCreateVendorStore( + this.activeDescriptor.vendorId, + difficulty, + purse.cLvl, + this.activeDescriptor.actIndex, + ) + // Only add to vendor tab for buyback if it's not already a permanent store consumable + if (!item.base.permStoreItem) { + const buybackItem: Item = { + ...item, + permStore: false, + } + let placed = addItemToVendorTabs(store.tabs, buybackItem, this.tables.itemTypes) + if (!placed) { + const targetTabKey: VendorTabKey = + item.base.kind === 'armor' + ? 'armor' + : item.base.kind === 'weapon' + ? 'weapons1' + : 'misc' + const targetGrid = store.tabs[targetTabKey] + for (const existing of targetGrid.list()) { + if (!existing.item.permStore) { + targetGrid.remove(existing.x, existing.y) + placed = addItemToVendorTabs(store.tabs, buybackItem, this.tables.itemTypes) + if (placed) break + } + } + } + if (placed) { + placedInTab = placed.tab + } + } + } + + return { + ok: true, + goldEarned: sellPrice, + ...(placedInTab ? { placedInTab } : {}), + } + } + + /** + * Repairs a single item, restoring durability to `maxDurability`. + */ + repairSingleItem(options: { + readonly item: Item + readonly purse: GoldPurseState + readonly difficulty: 0 | 1 | 2 + readonly completedQuests?: ReadonlySet | undefined + readonly reducedPricesPercent?: number | undefined + }): { readonly ok: boolean; readonly reason?: string | undefined; readonly repairedItem: Item; readonly goldSpent: number } { + const { item, purse, difficulty, completedQuests, reducedPricesPercent = 0 } = options + + if (!isItemRepairable(item)) { + return { ok: false, reason: 'Item cannot be repaired', repairedItem: item, goldSpent: 0 } + } + + const cost = this.getItemRepairCost({ + item, + difficulty, + cLvl: purse.cLvl, + completedQuests, + reducedPricesPercent, + }) + + if (cost <= 0) { + return { ok: false, reason: 'Item does not need repair', repairedItem: item, goldSpent: 0 } + } + + if (!deductGoldFromPurse(purse, cost)) { + return { ok: false, reason: 'Not enough gold to repair', repairedItem: item, goldSpent: 0 } + } + + const maxDur = item.maxDurability ?? Number((item.base as any).durability) ?? 0 + const maxStack = + (item.base as any).stackable && item.base.kind === 'weapon' + ? item.base.maxStack || Number((item.base as any).maxstack) || item.stack + : item.stack + + const repairedItem: Item = { + ...item, + ...(maxDur > 0 ? { durability: maxDur, maxDurability: maxDur } : {}), + stack: maxStack, + quantity: maxStack, + } + + return { ok: true, repairedItem, goldSpent: cost } + } + + /** + * Repairs all damaged items across `equippedItems` and `playerInventory`. + */ + repairAllItems(options: { + readonly equippedItems: Map + readonly playerInventory: Inventory + readonly purse: GoldPurseState + readonly difficulty: 0 | 1 | 2 + readonly completedQuests?: ReadonlySet | undefined + readonly reducedPricesPercent?: number | undefined + }): RepairResult { + const { equippedItems, playerInventory, purse, difficulty, completedQuests, reducedPricesPercent = 0 } = options + + let totalSpent = 0 + let repairedCount = 0 + + // 1. Repair equipped items first + for (const [slot, eqItem] of equippedItems.entries()) { + if (!isItemRepairable(eqItem)) continue + const res = this.repairSingleItem({ + item: eqItem, + purse, + difficulty, + completedQuests, + reducedPricesPercent, + }) + if (res.ok) { + equippedItems.set(slot, res.repairedItem) + totalSpent += res.goldSpent + repairedCount++ + } + } + + // 2. Repair inventory items + for (const placement of playerInventory.list()) { + if (!isItemRepairable(placement.item)) continue + const res = this.repairSingleItem({ + item: placement.item, + purse, + difficulty, + completedQuests, + reducedPricesPercent, + }) + if (res.ok) { + playerInventory.remove(placement.x, placement.y) + playerInventory.place(res.repairedItem, placement.x, placement.y) + totalSpent += res.goldSpent + repairedCount++ + } + } + + return { + ok: repairedCount > 0, + ...(repairedCount === 0 ? { reason: 'No items need repair' } : {}), + goldSpent: totalSpent, + repairedCount, + } + } + + /** + * Calculates the total cost to repair all damaged items in `equippedItems` and `playerInventory`. + */ + getTotalRepairAllCost(options: { + readonly equippedItems: ReadonlyMap + readonly playerInventory: Inventory + readonly difficulty: 0 | 1 | 2 + readonly cLvl: number + readonly completedQuests?: ReadonlySet | undefined + readonly reducedPricesPercent?: number | undefined + }): number { + const { equippedItems, playerInventory, difficulty, cLvl, completedQuests, reducedPricesPercent = 0 } = options + let total = 0 + for (const eqItem of equippedItems.values()) { + if (isItemRepairable(eqItem)) { + total += this.getItemRepairCost({ + item: eqItem, + difficulty, + cLvl, + completedQuests, + reducedPricesPercent, + }) + } + } + for (const placement of playerInventory.list()) { + if (isItemRepairable(placement.item)) { + total += this.getItemRepairCost({ + item: placement.item, + difficulty, + cLvl, + completedQuests, + reducedPricesPercent, + }) + } + } + return total + } + + /** + * Deckard Cain "Identify Items" service: identifies all unidentified items in `playerInventory`. + */ + identifyAllItems(options: { + readonly playerInventory: Inventory + readonly purse: GoldPurseState + readonly cainRescued?: boolean | undefined + }): IdentifyAllResult { + const { playerInventory, purse, cainRescued = true } = options + const costPerItem = cainRescued ? 0 : 100 + + let identifiedCount = 0 + let goldSpent = 0 + + for (const placement of playerInventory.list()) { + const it = placement.item + const isIdentified = + it.flags?.identified !== false && + ((it.rawFlags ?? ItemFlag.IDENTIFIED) & ItemFlag.IDENTIFIED) !== 0 + if (isIdentified) continue + + if (costPerItem > 0 && !deductGoldFromPurse(purse, costPerItem)) { + break + } + + const newRawFlags = (it.rawFlags ?? 0) | ItemFlag.IDENTIFIED + const identifiedItem: Item = { + ...it, + rawFlags: newRawFlags, + flags: decodeItemFlags(newRawFlags), + } + playerInventory.remove(placement.x, placement.y) + playerInventory.place(identifiedItem, placement.x, placement.y) + identifiedCount++ + goldSpent += costPerItem + } + + return { + ok: identifiedCount > 0, + ...(identifiedCount === 0 ? { reason: 'No unidentified items in inventory' } : {}), + identifiedCount, + goldSpent, + } + } +} diff --git a/src/game/weapons.ts b/src/game/weapons.ts index b975896..6dc9338 100644 --- a/src/game/weapons.ts +++ b/src/game/weapons.ts @@ -18,6 +18,7 @@ import type { ItemBase } from './items.ts' import type { D2Table } from './acts.ts' import { parseTable } from './acts.ts' import type { MountedArchives } from '../mpq/mount.ts' +import { VENDOR_COLUMN_PREFIXES, VENDOR_COUNT, type VendorItemColumns } from './npc-table.ts' /** File path of Weapons.txt inside MPQ archives. */ export const WEAPONS_TABLE_PATH = 'data\\global\\excel\\Weapons.txt' @@ -116,6 +117,14 @@ export interface WeaponBase extends ItemBase { readonly dropsound?: string | undefined /** Authentic frame at which dropsound triggers (from dropsfxframe column). */ readonly dropsfxframe?: number | undefined + readonly nightmareUpgrade?: string | undefined + readonly hellUpgrade?: string | undefined + readonly permStoreItem?: boolean | undefined + readonly multibuy?: boolean | undefined + readonly bitfield1?: number | undefined + readonly quest?: number | undefined + readonly questDiffCheck?: boolean | undefined + readonly vendorColumns?: Readonly> | undefined } /** @@ -288,6 +297,33 @@ export function parseWeaponsTable(input: D2Table | Uint8Array | string): WeaponT const dropsound = getCell(row, 'dropsound') || undefined const dropsfxframe = parseOptionalNumber(getCell(row, 'dropsfxframe')) + const nightmareUpgrade = getCell(row, 'nightmareupgrade') || 'xxx' + const hellUpgrade = getCell(row, 'hellupgrade') || 'xxx' + const permStoreItem = getCell(row, 'permstoreitem') === '1' + const multibuy = getCell(row, 'multibuy') === '1' + const bitfield1 = parseNumber(getCell(row, 'bitfield1'), 0) + const quest = parseNumber(getCell(row, 'quest'), 0) + const questDiffCheck = getCell(row, 'questdiffcheck') === '1' + + const vendorColumns: Record = {} + for (let vId = 0; vId < VENDOR_COUNT; vId++) { + const prefix = VENDOR_COLUMN_PREFIXES[vId]! + const min = parseNumber(getCell(row, `${prefix}min`), 0) + const max = parseNumber(getCell(row, `${prefix}max`), 0) + const magicMin = parseNumber(getCell(row, `${prefix}magicmin`), 0) + const magicMax = parseNumber(getCell(row, `${prefix}magicmax`), 0) + const magicLvl = parseNumber(getCell(row, `${prefix}magiclvl`), 0) + if (min > 0 || max > 0 || magicMin > 0 || magicMax > 0) { + vendorColumns[vId] = { + min, + max, + magicMin, + magicMax, + magicLvl: magicLvl > 0 ? magicLvl : 255, + } + } + } + const weapon: WeaponBase = { id: code, code, @@ -334,6 +370,14 @@ export function parseWeaponsTable(input: D2Table | Uint8Array | string): WeaponT ...(autoPrefix !== undefined && autoPrefix > 0 ? { autoPrefix } : {}), ...(dropsound ? { dropsound } : {}), ...(dropsfxframe !== undefined ? { dropsfxframe } : {}), + nightmareUpgrade, + hellUpgrade, + permStoreItem, + multibuy, + bitfield1, + quest, + questDiffCheck, + vendorColumns, } all.push(weapon) diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index 20473f6..594cce7 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -51,6 +51,7 @@ import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_NPCS, DEMO_QUESTS } from "../game/de import type { DropTables } from '../game/drop-pipeline.ts' import { getEmbeddedDropTables } from '../game/embedded-drop-tables.ts' import { buildNpcDef, hasPackedSprite } from '../game/npc.ts' +import { getTownNpcDescriptorByName } from '../game/npc-table.ts' import { ADDITIVE_OBJECT_TOKENS } from '../game/objects.ts' import { npcDialog } from '../game/quests.ts' import type { NpcDef } from '../game/quests.ts' @@ -3723,6 +3724,45 @@ export class SceneMouseController { } } + interactWithNpc(npc: NpcEntity): void { + const player = this.engine.world.player + player.facing = facingOf(npc.x - player.x, npc.y - player.y) + if (npc.def.questId !== null) this.engine.questLog.accept(npc.def.questId) + this.engine.dialog = npcDialog(npc.def, this.engine.questLog) + this.status.textContent = `${npc.def.name}: ${this.engine.dialog[0] ?? ''}` + + const runtime = this.getRuntime() + const actIndex = Math.max(0, (runtime.act ?? 1) - 1) + const descriptor = getTownNpcDescriptorByName(npc.def.name, actIndex) + if (descriptor) { + if (descriptor.canHeal) { + if (player.maxHp !== undefined) player.hp = player.maxHp + if (this.hudManager) { + this.hudManager.hp = this.hudManager.maxHp + this.hudManager.mana = this.hudManager.maxMana + this.hudManager.stamina = this.hudManager.maxStamina + } + } + if (this.hudManager) { + let menuX = 400 + let menuY = 180 + if (this.lastClientPos && typeof this.hudManager.clientToLogical === 'function') { + const logical = this.hudManager.clientToLogical(this.lastClientPos.x, this.lastClientPos.y) + menuX = logical.x + menuY = logical.y + } + if (this.hudManager.worldPanels) { + if (this.engine.dropTables) { + this.hudManager.worldPanels.vendorSession.setDropTables(this.engine.dropTables) + } + this.hudManager.worldPanels.vendorCharLevel = (player as any).level ?? this.hudManager.worldPanels.vendorCharLevel ?? 85 + this.hudManager.worldPanels.openNpcMenu(descriptor, menuX, menuY) + } + this.hudManager.syncPublishedState?.() + } + } + } + handleLeftClickWorld(worldX: number, worldY: number): void { const player = this.engine.world.player @@ -3745,10 +3785,7 @@ export class SceneMouseController { if (distToPlayer <= 80) { // talkRadius = 80 this.navTarget = null this.pendingInteraction = null - player.facing = facingOf(clickedNpc.x - player.x, clickedNpc.y - player.y) - if (clickedNpc.def.questId !== null) this.engine.questLog.accept(clickedNpc.def.questId) - this.engine.dialog = npcDialog(clickedNpc.def, this.engine.questLog) - this.status.textContent = `${clickedNpc.def.name}: ${this.engine.dialog[0] ?? ''}` + this.interactWithNpc(clickedNpc) } else { ;(this.engine as any).dialog = null this.pendingInteraction = { kind: 'npc', npc: clickedNpc } @@ -3982,10 +4019,7 @@ export class SceneMouseController { if (dist <= 80) { this.navTarget = null this.pendingInteraction = null - player.facing = facingOf(npc.x - player.x, npc.y - player.y) - if (npc.def.questId !== null) this.engine.questLog.accept(npc.def.questId) - this.engine.dialog = npcDialog(npc.def, this.engine.questLog) - this.status.textContent = `${npc.def.name}: ${this.engine.dialog[0] ?? ''}` + this.interactWithNpc(npc) } else { this.navTarget = { x: npc.x, y: npc.y } } diff --git a/src/ui/hud-manager.ts b/src/ui/hud-manager.ts index 6b8038f..c8927b7 100644 --- a/src/ui/hud-manager.ts +++ b/src/ui/hud-manager.ts @@ -31,6 +31,7 @@ import { createStarterProfileForClass, type ClassProfileState, } from '../game/class-starter-profiles.ts' +import type { TownNpcServiceDescriptor, VendorNpcId } from '../game/npc-table.ts' export interface PublishedHudState { ready: boolean @@ -71,6 +72,11 @@ export interface PublishedHudState { stamina: number maxStamina: number docking: DockingLayout + activeVendor?: string | null + vendorMode?: 'trade' | 'gamble' + vendorTab?: string + vendorItemsCount?: number + npcMenuOpen?: boolean } declare global { @@ -135,7 +141,7 @@ export function computeHudLayout(cssW: number, cssH: number, dpr: number = 1): H export { CHAR_PANEL_ORIGIN } from './character-sheet.ts' export { INV_PANEL_ORIGIN, INV_GRID_ORIGIN } from './inventory.ts' export { SKILL_PANEL_ORIGIN } from './skill-tree-panel.ts' -export { STASH_PANEL_ORIGIN, STASH_GRID_ORIGIN } from './world-panels.ts' +export { STASH_PANEL_ORIGIN, STASH_GRID_ORIGIN, VENDOR_PANEL_ORIGIN, VENDOR_GRID_ORIGIN } from './world-panels.ts' /** * Computes widescreen panel docking layout metrics, margins, and central vision corridor. @@ -458,6 +464,39 @@ export class HudManager { this.state.ready = true } + openNpcMenuByName(name: string, screenX = 400, screenY = 220): boolean { + const ok = this.worldPanels.openNpcMenuByName(name, screenX, screenY) + if (ok) { + this.syncPublishedState() + } + return ok + } + + /** + * Opens a Town NPC Vendor or Gamble session on the Left Dock and ensures Player Inventory is open on the Right Dock. + */ + openVendorSession( + descriptorOrName: TownNpcServiceDescriptor | VendorNpcId | string, + mode: 'trade' | 'gamble' = 'trade', + difficulty: 0 | 1 | 2 = 0, + ): boolean { + const ok = this.worldPanels.openVendorForNpc( + descriptorOrName, + mode, + this.charSheet.attrs.level, + difficulty, + ) + if (!ok) return false + this.worldPanels.npcMenu = null + this.leftPanel = 'vendor' + this.charSheet.visible = false + if (this.rightPanel !== 'inv') { + this.toggleRightPanel('inv') + } + this.syncPublishedState() + return true + } + /** * Toggle a Left-Half Panel (`char`, `quest`, `waypoint`, `stash`, `cube`, `vendor`). * Strictly follows D2Client.dll mutual exclusion on the Left Dock (`0..400`). @@ -466,10 +505,14 @@ export class HudManager { if (this.leftPanel === kind) { this.leftPanel = 'none' this.charSheet.visible = false + this.worldPanels.repairCursorMode = false } else { this.leftPanel = kind this.charSheet.visible = kind === 'char' - if (kind === 'stash' && this.rightPanel === 'none') { + if (kind === 'vendor' && !this.worldPanels.activeVendorDescriptor) { + this.worldPanels.openVendorForNpc('Akara', 'trade', this.charSheet.attrs.level, 0) + } + if ((kind === 'stash' || kind === 'vendor') && this.rightPanel === 'none') { this.toggleRightPanel('inv') } } @@ -498,7 +541,8 @@ export class HudManager { this.leftPanel !== 'none' || this.rightPanel !== 'none' || this.belt.lockedOpen || - this.hotkeys.openPopup !== null + this.hotkeys.openPopup !== null || + this.worldPanels.npcMenu !== null this.leftPanel = 'none' this.rightPanel = 'none' this.charSheet.visible = false @@ -506,6 +550,8 @@ export class HudManager { this.skillTree.visible = false this.belt.lockedOpen = false this.hotkeys.openPopup = null + this.worldPanels.npcMenu = null + this.worldPanels.repairCursorMode = false this.syncPublishedState() return anyOpen } @@ -553,6 +599,7 @@ export class HudManager { const docking = this.getDockingLayout() const { marginW } = docking if (logicalX < -marginW || logicalX > 800 + marginW || logicalY < 0 || logicalY > 600) return false + if (this.worldPanels.npcMenu !== null) return true if (logicalY >= 540 && logicalX >= 0 && logicalX <= 800) return true if (logicalX >= 0 && logicalX <= 117 && logicalY >= 496) return true if (logicalX >= 683 && logicalX <= 800 && logicalY >= 496) return true @@ -648,6 +695,10 @@ export class HudManager { this.hotkeys.handleMouseMove(pt.x, pt.y) this.controlBar.handleMouseMove(pt.x, pt.y) + if (this.worldPanels.npcMenu) { + this.worldPanels.handleNpcMenuMove(pt.x, pt.y) + } + const docking = this.getDockingLayout() const deltaLeft = -docking.marginW const deltaRight = docking.marginW @@ -657,13 +708,19 @@ export class HudManager { this.inventory.hoveredItem.x += deltaRight } this.skillTree.handleMouseMove(pt.x - deltaRight, pt.y) - if (this.leftPanel === 'stash') { + if (this.leftPanel === 'stash' || this.leftPanel === 'waypoint' || this.leftPanel === 'vendor') { this.worldPanels.handleMouseMove(pt.x - deltaLeft, pt.y) - if (this.worldPanels.hoveredStashItem) { + if (this.leftPanel === 'stash' && this.worldPanels.hoveredStashItem) { this.inventory.hoveredItem = { ...this.worldPanels.hoveredStashItem, x: this.worldPanels.hoveredStashItem.x + deltaLeft, } + } else if (this.leftPanel === 'vendor' && this.worldPanels.hoveredVendorItem) { + this.inventory.hoveredItem = { + item: this.worldPanels.hoveredVendorItem.item, + x: this.worldPanels.hoveredVendorItem.x + deltaLeft, + y: this.worldPanels.hoveredVendorItem.y, + } } } }) @@ -699,6 +756,24 @@ export class HudManager { e.preventDefault() e.stopPropagation() + // Town NPC Stone Menu click + if (this.worldPanels.npcMenu) { + this.worldPanels.handleNpcMenuClick(pt.x, pt.y, { + onOpenVendor: (desc, mode) => { + this.openVendorSession(desc, mode) + }, + onIdentifyAll: (desc) => { + const count = this.worldPanels.identifyAllUiItems(this.inventory) + this.worldPanels.showAreaBanner( + count > 0 ? `已鉴定 ${count} 件物品` : '背包中没有未鉴定物品', + desc.displayName, + ) + }, + }) + this.syncPublishedState() + return + } + // Right-click on belt slot drinks potion const beltHit = this.belt.slotAtPoint(pt.x, pt.y) if (beltHit) { @@ -779,6 +854,7 @@ export class HudManager { if (pt.x >= 400 + deltaRight) { const rightX = pt.x - deltaRight if (this.rightPanel === 'inv') { + // Stash quick transfer if (this.leftPanel === 'stash' && (e.shiftKey || e.button === 2)) { if ( rightX >= INV_GRID_ORIGIN.x && @@ -798,6 +874,44 @@ export class HudManager { } } } + + // Vendor Repair or Quick-Sell from Player Inventory + if (this.leftPanel === 'vendor') { + if ( + rightX >= INV_GRID_ORIGIN.x && + rightX < INV_GRID_ORIGIN.x + INV_GRID_ORIGIN.cols * INV_GRID_ORIGIN.cellPx && + pt.y >= INV_GRID_ORIGIN.y && + pt.y < INV_GRID_ORIGIN.y + INV_GRID_ORIGIN.rows * INV_GRID_ORIGIN.cellPx + ) { + const col = Math.floor((rightX - INV_GRID_ORIGIN.x) / INV_GRID_ORIGIN.cellPx) + const row = Math.floor((pt.y - INV_GRID_ORIGIN.y) / INV_GRID_ORIGIN.cellPx) + const hitIdx = this.inventory.gridItems.findIndex( + p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight, + ) + if (hitIdx !== -1) { + const hit = this.inventory.gridItems[hitIdx]! + if (this.worldPanels.repairCursorMode) { + this.worldPanels.repairSingleUiItem(hit.item, this.inventory) + this.syncPublishedState() + return + } + if (e.shiftKey || e.button === 2) { + const res = this.worldPanels.sellToActiveVendor(hit.item, this.inventory) + if (res.ok) { + this.inventory.gridItems.splice(hitIdx, 1) + this.inventory.hoveredItem = null + } + this.syncPublishedState() + return + } + } + } else if (this.worldPanels.repairCursorMode && this.inventory.hoveredItem) { + this.worldPanels.repairSingleUiItem(this.inventory.hoveredItem.item, this.inventory) + this.syncPublishedState() + return + } + } + this.inventory.handleClick(rightX, pt.y, { onOpenCube: () => this.toggleLeftPanel('cube'), onCastTownPortal: () => this.callbacks.onCastTownPortal?.(), @@ -901,14 +1015,19 @@ export class HudManager { this.state.stamina = Math.round(this.stamina) this.state.maxStamina = Math.round(this.maxStamina) this.state.docking = this.getDockingLayout() + this.state.activeVendor = this.worldPanels.activeVendorDescriptor?.displayName ?? null + this.state.vendorMode = this.worldPanels.vendorMode + this.state.vendorTab = this.worldPanels.activeVendorTab + this.state.vendorItemsCount = this.worldPanels.getActiveVendorPlacements().length + this.state.npcMenuOpen = this.worldPanels.npcMenu !== null } /** * Tick stamina drain/recovery and draw the complete 800x600 Diablo II HUD. */ render( - nowMs: number, - playerMoving: boolean, + nowMs: number = performance.now(), + playerMoving: boolean = false, ): void { if (playerMoving && this.isRunning) { this.stamina = Math.max(0, this.stamina - 0.12) @@ -1094,21 +1213,26 @@ export class HudManager { // 7. Top-Center Monster Target Health Bar & Plaque this.worldPanels.drawMonsterTargetBar(ctx, this.targetMonster, this.font) - // 8. Area Entry Gothic Banner (`Entering: `, only when no split panels are open) - if (this.leftPanel === 'none' && this.rightPanel === 'none') { + // 8. Town NPC Stone Interaction Menu (`npcmenu.cpp`) + if (this.worldPanels.npcMenu) { + this.worldPanels.drawNpcMenu(ctx, this.font) + } + + // 9. Area Entry Gothic Banner (`Entering: `, only when no split panels are open) + if (this.leftPanel === 'none' && this.rightPanel === 'none' && !this.worldPanels.npcMenu) { this.worldPanels.drawAreaBanner(ctx, nowMs, this.font) } - // 9. Hover Item Tooltip (Inventory / Equipment / Stash) + // 10. Hover Item Tooltip (Inventory / Equipment / Stash / Vendor) if ( - (this.rightPanel === 'inv' || this.leftPanel === 'stash') && + (this.rightPanel === 'inv' || this.leftPanel === 'stash' || this.leftPanel === 'vendor') && this.inventory.hoveredItem && !this.inventory.cursorItem ) { - this.worldPanels.drawItemTooltip(ctx, this.inventory.hoveredItem, this.font) + this.worldPanels.drawItemTooltip(ctx, this.inventory.hoveredItem, this.font, this.leftPanel === 'vendor') } - // 10. Authentic Hand Cursor & Held Item at Hotspot + // 11. Authentic Hand Cursor & Held Item at Hotspot this.renderCursor(ctx, nowMs) ctx.restore() diff --git a/src/ui/inventory.ts b/src/ui/inventory.ts index 9087c9f..dae28b2 100644 --- a/src/ui/inventory.ts +++ b/src/ui/inventory.ts @@ -13,6 +13,7 @@ import type { D2ColorCode, D2FontRenderer } from './font.ts' import { BAKED_UI_MANIFEST, type SpriteRect } from './baked-ui-meta.ts' +import type { Item } from '../game/items.ts' export type { SpriteRect } export { itemToUiInventoryItem, resolveAllowedSlots, resolveItemInvFile } from './item-bridge.ts' @@ -60,6 +61,12 @@ export interface UiInventoryItem { readonly flippyFile?: string | undefined readonly dropSound?: string | undefined readonly dropSfxFrame?: number | undefined + readonly rawItem?: Item | undefined + readonly buyCost?: number | undefined + readonly sellCost?: number | undefined + readonly repairCost?: number | undefined + readonly permStore?: boolean | undefined + readonly isGambleMystery?: boolean | undefined } export interface GridPlacement { diff --git a/src/ui/item-bridge.ts b/src/ui/item-bridge.ts index 9d303f7..8f672d7 100644 --- a/src/ui/item-bridge.ts +++ b/src/ui/item-bridge.ts @@ -398,14 +398,17 @@ export function itemToUiInventoryItem( ) // 5. Tooltip & Formatted Stats + const isGambleMystery = Boolean(rawItem.isGambleMystery || rawItem.flags?.gamble) const tt = formatItemTooltip({ ...rawItem, base } as Item, 'zh') - let stats: { text: string; color?: D2ColorCode }[] = (tt.lines || []).map(l => ({ - text: l.text, - color: (l.color === 'instruction' ? 'white' : l.color) as D2ColorCode, - })) + let stats: { text: string; color?: D2ColorCode }[] = isGambleMystery + ? [{ text: '未鉴定 (Unidentified Gamble Item)', color: 'red' }] + : (tt.lines || []).map(l => ({ + text: l.text, + color: (l.color === 'instruction' ? 'white' : l.color) as D2ColorCode, + })) // Fallback for items with flat stats dictionary but no rolled props - if (stats.length === 0 && rawItem.stats && typeof rawItem.stats === 'object') { + if (!isGambleMystery && stats.length === 0 && rawItem.stats && typeof rawItem.stats === 'object') { const fallbackStats = Object.entries(rawItem.stats) .filter(([k, v]) => typeof v === 'number' && v !== 0 && k !== 'gold' && k !== 'value') .map(([k, v]) => ({ @@ -419,18 +422,22 @@ export function itemToUiInventoryItem( // Damage evaluation let damage: string | undefined = undefined - if (tt.oneHandDamage && tt.twoHandDamage) { - damage = `${tt.oneHandDamage.min} - ${tt.oneHandDamage.max} / 双手: ${tt.twoHandDamage.min} - ${tt.twoHandDamage.max}` - } else if (tt.twoHandDamage) { - damage = `双手伤害: ${tt.twoHandDamage.min} - ${tt.twoHandDamage.max}` - } else if (tt.oneHandDamage) { - damage = `${tt.oneHandDamage.min} - ${tt.oneHandDamage.max}` - } else if (base.kind === 'weapon' && (base as any).damage !== undefined && (base as any).damage > 0) { - damage = String((base as any).damage) + if (!isGambleMystery) { + if (tt.oneHandDamage && tt.twoHandDamage) { + damage = `${tt.oneHandDamage.min} - ${tt.oneHandDamage.max} / 双手: ${tt.twoHandDamage.min} - ${tt.twoHandDamage.max}` + } else if (tt.twoHandDamage) { + damage = `双手伤害: ${tt.twoHandDamage.min} - ${tt.twoHandDamage.max}` + } else if (tt.oneHandDamage) { + damage = `${tt.oneHandDamage.min} - ${tt.oneHandDamage.max}` + } else if (base.kind === 'weapon' && (base as any).damage !== undefined && (base as any).damage > 0) { + damage = String((base as any).damage) + } } // Durability evaluation - const durability = tt.durability + const durability = isGambleMystery + ? undefined + : tt.durability ? { current: tt.durability.current, max: tt.durability.max } : rawItem.durability !== undefined && rawItem.maxDurability !== undefined ? { current: rawItem.durability, max: rawItem.maxDurability } @@ -439,12 +446,13 @@ export function itemToUiInventoryItem( const rolledSockProp = Array.isArray(rawItem.rolledProps) ? rawItem.rolledProps.reduce((sum, p) => ((p.code || '').trim().toLowerCase() === 'sock' ? sum + p.value : sum), 0) : 0 - const sockets = - (rawItem as any).sockets ?? - rawItem.totalSockets ?? - rawItem.socketedCount ?? - (rolledSockProp > 0 ? rolledSockProp : undefined) - const ethereal = Boolean((tt as any).ethereal || (rawItem as any).ethereal || rawItem.flags?.ethereal) + const sockets = isGambleMystery + ? undefined + : (rawItem as any).sockets ?? + rawItem.totalSockets ?? + rawItem.socketedCount ?? + (rolledSockProp > 0 ? rolledSockProp : undefined) + const ethereal = !isGambleMystery && Boolean((tt as any).ethereal || (rawItem as any).ethereal || rawItem.flags?.ethereal) const id = rawItem.uniqueId ? `item-${code}-${rawItem.uniqueId}` @@ -453,6 +461,7 @@ export function itemToUiInventoryItem( const flippyFile = (rawItem as any).flippyFile ?? (base as any).flippyfile const dropSound = (rawItem as any).dropSound ?? (rawItem as any).dropsound ?? (base as any).dropsound const dropSfxFrame = (rawItem as any).dropSfxFrame ?? (rawItem as any).dropsfxframe ?? (base as any).dropsfxframe + const permStore = Boolean(rawItem.permStore ?? base.permStoreItem) return { id, @@ -461,26 +470,44 @@ export function itemToUiInventoryItem( name: rawItem.name || baseEn, nameZh, baseNameZh, - quality, + quality: isGambleMystery ? 'normal' : quality, invWidth: rawItem.invWidth ?? base.invWidth ?? 1, invHeight: rawItem.invHeight ?? base.invHeight ?? 1, allowedSlots, - ...(tt.defense !== undefined ? { defense: tt.defense } : (rawItem.defense !== undefined || base.defense > 0 ? { defense: rawItem.defense ?? base.defense } : {})), + ...(!isGambleMystery && tt.defense !== undefined + ? { defense: tt.defense } + : !isGambleMystery && (rawItem.defense !== undefined || base.defense > 0) + ? { defense: rawItem.defense ?? base.defense } + : {}), ...(damage !== undefined ? { damage } : {}), - ...(tt.reqLevel !== undefined ? { reqLevel: tt.reqLevel } : (rawItem.level || base.level ? { reqLevel: rawItem.level || base.level } : {})), - ...(tt.reqStr !== undefined ? { reqStr: tt.reqStr } : ((base as any).reqstr ? { reqStr: (base as any).reqstr } : {})), + ...(!isGambleMystery && tt.reqLevel !== undefined + ? { reqLevel: tt.reqLevel } + : !isGambleMystery && (rawItem.level || base.level) + ? { reqLevel: rawItem.level || base.level } + : {}), + ...(!isGambleMystery && tt.reqStr !== undefined + ? { reqStr: tt.reqStr } + : !isGambleMystery && (base as any).reqstr + ? { reqStr: (base as any).reqstr } + : {}), stats, - ...(invtransform ? { invtransform } : {}), - ...(chrtransform ? { chrtransform } : {}), + ...(!isGambleMystery && invtransform ? { invtransform } : {}), + ...(!isGambleMystery && chrtransform ? { chrtransform } : {}), ...(durability ? { durability } : {}), ...(sockets !== undefined && sockets > 0 ? { sockets } : {}), ...(ethereal ? { ethereal: true } : {}), - ...((rawItem as any).speedText || tt.speedText ? { speedText: (rawItem as any).speedText || tt.speedText } : {}), - ...((rawItem as any).runewordRunes || tt.runewordRunes ? { runewordRunes: (rawItem as any).runewordRunes || tt.runewordRunes } : {}), - ...(tt.setPieces ? { setPieces: tt.setPieces } : {}), - ...(tt.setBonuses ? { setBonuses: tt.setBonuses.map(b => ({ text: b.text, color: (b.color ?? 'green') as D2ColorCode })) } : {}), + ...(!isGambleMystery && ((rawItem as any).speedText || tt.speedText) ? { speedText: (rawItem as any).speedText || tt.speedText } : {}), + ...(!isGambleMystery && ((rawItem as any).runewordRunes || tt.runewordRunes) ? { runewordRunes: (rawItem as any).runewordRunes || tt.runewordRunes } : {}), + ...(!isGambleMystery && tt.setPieces ? { setPieces: tt.setPieces } : {}), + ...(!isGambleMystery && tt.setBonuses ? { setBonuses: tt.setBonuses.map(b => ({ text: b.text, color: (b.color ?? 'green') as D2ColorCode })) } : {}), ...(typeof flippyFile === 'string' && flippyFile.length > 0 ? { flippyFile } : {}), ...(typeof dropSound === 'string' && dropSound.length > 0 ? { dropSound } : {}), ...(typeof dropSfxFrame === 'number' && Number.isFinite(dropSfxFrame) ? { dropSfxFrame } : {}), + rawItem: { ...rawItem, base } as Item, + ...(permStore ? { permStore: true } : {}), + ...(isGambleMystery ? { isGambleMystery: true } : {}), + ...((rawItem as any).buyCost !== undefined ? { buyCost: (rawItem as any).buyCost } : {}), + ...((rawItem as any).sellCost !== undefined ? { sellCost: (rawItem as any).sellCost } : {}), + ...((rawItem as any).repairCost !== undefined ? { repairCost: (rawItem as any).repairCost } : {}), } } diff --git a/src/ui/world-panels.ts b/src/ui/world-panels.ts index d6f6df9..02bf6fa 100644 --- a/src/ui/world-panels.ts +++ b/src/ui/world-panels.ts @@ -9,9 +9,19 @@ */ import type { D2ColorCode, D2FontRenderer } from './font.ts' -import type { GridPlacement, InventoryPanel, UiInventoryItem } from './inventory.ts' -import { resolveItemSpriteRect } from './inventory.ts' +import { findFreeGridSlot, type EquipSlotId, type GridPlacement, type InventoryPanel, type UiInventoryItem } from './inventory.ts' +import { resolveItemSpriteRect, itemToUiInventoryItem } from './inventory.ts' import { BAKED_UI_MANIFEST } from './baked-ui-meta.ts' +import { + type TownNpcServiceDescriptor, + type VendorNpcId, + getTownNpcDescriptorByName, + getTownNpcDescriptorByVendorId, +} from '../game/npc-table.ts' +import type { VendorTabId } from '../game/vendor-generator.ts' +import { VendorSessionManager } from '../game/vendor-service.ts' +import { getEmbeddedDropTables } from '../game/embedded-drop-tables.ts' +import { isItemRepairable } from '../game/item-cost.ts' export type LeftDockPanelKind = 'none' | 'char' | 'quest' | 'waypoint' | 'stash' | 'cube' | 'vendor' @@ -23,6 +33,27 @@ export const STASH_CLOSE_BTN_BOUNDS = { x: 80 + 272, y: 60 + 388, w: 32, h: 32 } export const STASH_DEPOSIT_BTN_BOUNDS = { x: 80 + 68, y: 60 + 386, w: 76, h: 26 } as const export const STASH_WITHDRAW_BTN_BOUNDS = { x: 80 + 154, y: 60 + 386, w: 76, h: 26 } as const +export const VENDOR_PANEL_ORIGIN = { x: 80, y: 60, width: 320, height: 432, w: 320, h: 432 } as const +export const VENDOR_GRID_ORIGIN = { x: 80 + 17, y: 60 + 64, cols: 10, rows: 10, cellPx: 29 } as const +export const VENDOR_CLOSE_BTN_BOUNDS = { x: 80 + 272, y: 60 + 388, w: 32, h: 32 } as const +export const VENDOR_REPAIR_BTN_BOUNDS = { x: 80 + 18, y: 60 + 386, w: 76, h: 26 } as const +export const VENDOR_REPAIR_ALL_BTN_BOUNDS = { x: 80 + 102, y: 60 + 386, w: 96, h: 26 } as const + +export const VENDOR_TABS: readonly { readonly id: VendorTabId; readonly labelZh: string; readonly labelEn: string }[] = [ + { id: 'armor', labelZh: '护甲', labelEn: 'Armor' }, + { id: 'weapons1', labelZh: '武器 I', labelEn: 'Weapons I' }, + { id: 'weapons2', labelZh: '武器 II', labelEn: 'Weapons II' }, + { id: 'misc', labelZh: '杂物', labelEn: 'Misc' }, +] as const + +export type NpcMenuActionId = 'talk' | 'trade' | 'gamble' | 'identify' | 'cancel' + +export interface NpcMenuOption { + readonly id: NpcMenuActionId + readonly labelZh: string + readonly labelEn: string +} + import { CANONICAL_QUESTS_BY_ACT, type CanonicalQuestItem, @@ -177,8 +208,419 @@ export class WorldPanelsHud { y: number dialogLines: readonly string[] talking: boolean + descriptor?: TownNpcServiceDescriptor | undefined + options?: readonly NpcMenuOption[] | undefined + hoveredOptionIdx?: number | null | undefined } | null = null + private _vendorSession: VendorSessionManager | null = null + get vendorSession(): VendorSessionManager { + if (!this._vendorSession) { + this._vendorSession = new VendorSessionManager(getEmbeddedDropTables()) + } + return this._vendorSession + } + + activeVendorDescriptor: TownNpcServiceDescriptor | null = null + vendorMode: 'trade' | 'gamble' = 'trade' + activeVendorTab: VendorTabId = 'armor' + vendorDifficulty: 0 | 1 | 2 = 0 + vendorCharLevel = 85 + repairCursorMode = false + vendorStatusMsg: { text: string; color: D2ColorCode; untilMs: number } | null = null + hoveredVendorItem: { item: UiInventoryItem; x: number; y: number; col: number; row: number } | null = null + vendorTabPlacements: Record = { + armor: [], + weapons1: [], + weapons2: [], + misc: [], + } + gamblePlacements: GridPlacement[] = [] + + showVendorStatus(text: string, color: D2ColorCode = 'gold', nowMs = performance.now()): void { + this.vendorStatusMsg = { text, color, untilMs: nowMs + 2800 } + } + + buildNpcMenuOptions(descriptor: TownNpcServiceDescriptor): NpcMenuOption[] { + const opts: NpcMenuOption[] = [{ id: 'talk', labelZh: '交谈', labelEn: 'Talk' }] + if (descriptor.canIdentify) { + opts.push({ id: 'identify', labelZh: '鉴定物品', labelEn: 'Identify Items' }) + } + if (descriptor.canTrade) { + opts.push({ + id: 'trade', + labelZh: descriptor.canRepair ? '交易 / 修理' : '交易', + labelEn: descriptor.canRepair ? 'Trade / Repair' : 'Trade', + }) + } + if (descriptor.canGamble) { + opts.push({ id: 'gamble', labelZh: '赌博', labelEn: 'Gamble' }) + } + opts.push({ id: 'cancel', labelZh: '取消', labelEn: 'Cancel' }) + return opts + } + + openNpcMenu( + descriptor: TownNpcServiceDescriptor, + screenX = 400, + screenY = 220, + ): void { + const options = this.buildNpcMenuOptions(descriptor) + const clampedX = Math.max(130, Math.min(670, Math.round(screenX))) + const clampedY = Math.max(70, Math.min(400, Math.round(screenY))) + this.npcMenu = { + npcName: descriptor.displayName, + x: clampedX, + y: clampedY, + dialogLines: descriptor.greetingLines, + talking: false, + descriptor, + options, + hoveredOptionIdx: null, + } + } + + openNpcMenuByName(name: string, screenX = 400, screenY = 220): boolean { + const desc = getTownNpcDescriptorByName(name) + if (!desc) return false + this.openNpcMenu(desc, screenX, screenY) + return true + } + + openVendorForNpc( + descriptorOrName: TownNpcServiceDescriptor | VendorNpcId | string, + mode: 'trade' | 'gamble' = 'trade', + cLvl = 85, + difficulty: 0 | 1 | 2 = 0, + ): boolean { + const descriptor = + typeof descriptorOrName === 'object' + ? descriptorOrName + : typeof descriptorOrName === 'number' + ? getTownNpcDescriptorByVendorId(descriptorOrName) + : getTownNpcDescriptorByName(descriptorOrName) + if (!descriptor || descriptor.vendorId === null) return false + + this.activeVendorDescriptor = descriptor + this.vendorMode = mode + this.vendorCharLevel = Math.max(1, cLvl) + this.vendorDifficulty = difficulty + this.repairCursorMode = false + this.hoveredVendorItem = null + + const tables = getEmbeddedDropTables() + const vendorId = descriptor.vendorId + + if (mode === 'gamble') { + const gambleInv = this.vendorSession.getOrCreateGambleStore(vendorId, this.vendorCharLevel) + this.gamblePlacements = gambleInv.getAllItems().map(p => { + const buyCost = this.vendorSession.getBuyPrice(p.item, vendorId, difficulty, this.vendorCharLevel, true) + const uiItem = itemToUiInventoryItem({ ...(p.item as any), buyCost, isGambleMystery: true }, tables) + return { item: uiItem, col: p.x, row: p.y } + }) + this.activeVendorTab = 'armor' + return true + } + + const store = this.vendorSession.getOrCreateVendorStore(vendorId, difficulty, this.vendorCharLevel) + const tabIds: VendorTabId[] = ['armor', 'weapons1', 'weapons2', 'misc'] + for (const tabId of tabIds) { + const inv = store.tabs[tabId] + this.vendorTabPlacements[tabId] = inv.getAllItems().map(p => { + const buyCost = this.vendorSession.getBuyPrice(p.item, vendorId, difficulty, this.vendorCharLevel, false) + const sellCost = this.vendorSession.getSellPrice(p.item, vendorId, difficulty, this.vendorCharLevel) + const uiItem = itemToUiInventoryItem({ ...(p.item as any), buyCost, sellCost }, tables) + return { item: uiItem, col: p.x, row: p.y } + }) + } + + // Select first non-empty tab (defaulting to armor, or misc if armor is empty) + const firstNonEmpty = tabIds.find(t => this.vendorTabPlacements[t].length > 0) ?? 'misc' + this.activeVendorTab = firstNonEmpty + return true + } + + getActiveVendorPlacements(): GridPlacement[] { + if (this.vendorMode === 'gamble') { + return this.gamblePlacements + } + return this.vendorTabPlacements[this.activeVendorTab] ?? [] + } + + getPlayerItemSellPrice(item: UiInventoryItem): number { + if (item.sellCost !== undefined) return item.sellCost + const vendorId = this.activeVendorDescriptor?.vendorId ?? (0 as VendorNpcId) + if (item.rawItem) { + return this.vendorSession.getSellPrice(item.rawItem, vendorId, this.vendorDifficulty, this.vendorCharLevel) + } + return Math.max(1, Math.floor(150)) + } + + getPlayerItemRepairPrice(item: UiInventoryItem): number { + if (!item.durability || item.durability.current >= item.durability.max) return 0 + if (item.ethereal) return 0 + const vendorId = this.activeVendorDescriptor?.vendorId ?? (2 as VendorNpcId) + if (item.rawItem) { + const syncedRaw = { + ...item.rawItem, + durability: item.durability.current, + maxDurability: item.durability.max, + } + if (!isItemRepairable(syncedRaw)) return 0 + return this.vendorSession.getRepairPrice(syncedRaw, vendorId, this.vendorDifficulty, this.vendorCharLevel) + } + const lost = Math.max(0, item.durability.max - item.durability.current) + return Math.max(1, lost * 12) + } + + buyFromActiveVendor( + col: number, + row: number, + playerInventory: InventoryPanel, + isShiftClick = false, + ): { ok: boolean; reason?: string; boughtCount: number; totalCost: number } { + if (!this.activeVendorDescriptor || this.activeVendorDescriptor.vendorId === null) { + return { ok: false, reason: 'no_vendor', boughtCount: 0, totalCost: 0 } + } + const vendorId = this.activeVendorDescriptor.vendorId + const placements = this.getActiveVendorPlacements() + const hitIdx = placements.findIndex( + p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight, + ) + if (hitIdx === -1) { + return { ok: false, reason: 'empty_cell', boughtCount: 0, totalCost: 0 } + } + const hit = placements[hitIdx]! + const unitCost = hit.item.buyCost ?? 1 + + if (playerInventory.gold < unitCost) { + this.showVendorStatus('金币不足 (Not enough gold)', 'red') + return { ok: false, reason: 'not_enough_gold', boughtCount: 0, totalCost: 0 } + } + + const tables = getEmbeddedDropTables() + + // Gamble Mode: resolve mystery item into concrete rolled item! + if (this.vendorMode === 'gamble') { + const freeSlot = findFreeGridSlot(playerInventory.gridItems, hit.item.invWidth, hit.item.invHeight) + if (!freeSlot) { + this.showVendorStatus('背包空间不足 (Inventory full)', 'red') + return { ok: false, reason: 'inventory_full', boughtCount: 0, totalCost: 0 } + } + const gambleRes = this.vendorSession.buyGambleItem( + vendorId, + hit.col, + hit.row, + playerInventory.gold, + this.vendorCharLevel, + ) + if (!gambleRes.success || !gambleRes.item) { + this.showVendorStatus('无法赌博该物品', 'red') + return { ok: false, reason: gambleRes.error ?? 'gamble_failed', boughtCount: 0, totalCost: 0 } + } + playerInventory.gold = gambleRes.remainingGold + const sellCost = this.vendorSession.getSellPrice(gambleRes.item, vendorId, this.vendorDifficulty, this.vendorCharLevel) + const resolvedUi = itemToUiInventoryItem({ ...(gambleRes.item as any), sellCost }, tables) + playerInventory.gridItems.push({ item: resolvedUi, col: freeSlot.col, row: freeSlot.row }) + this.gamblePlacements.splice(hitIdx, 1) + this.showVendorStatus(`赌博获得: ${resolvedUi.nameZh}`, 'gold') + return { ok: true, boughtCount: 1, totalCost: gambleRes.goldSpent } + } + + // Trade Mode: Shift-Click bulk buy for Scrolls/Potions/Keys + const isScrollOrPotionOrKey = + hit.item.permStore && + (hit.item.code === 'tsc' || + hit.item.code === 'isc' || + hit.item.code === 'key' || + hit.item.code === 'yps' || + hit.item.code === 'vps' || + hit.item.code === 'wms' || + hit.item.code.startsWith('hp') || + hit.item.code.startsWith('mp')) + + const maxCopies = isShiftClick && isScrollOrPotionOrKey ? 8 : 1 + let boughtCount = 0 + let totalCost = 0 + + for (let i = 0; i < maxCopies; i++) { + if (playerInventory.gold < unitCost) break + const freeSlot = findFreeGridSlot(playerInventory.gridItems, hit.item.invWidth, hit.item.invHeight) + if (!freeSlot) { + if (boughtCount === 0) { + this.showVendorStatus('背包空间不足 (Inventory full)', 'red') + return { ok: false, reason: 'inventory_full', boughtCount: 0, totalCost: 0 } + } + break + } + playerInventory.gold -= unitCost + totalCost += unitCost + boughtCount++ + + const rawClone = hit.item.rawItem + ? { ...hit.item.rawItem, uniqueId: Math.floor(Math.random() * 0x7fffffff), permStore: false } + : undefined + const sellCost = rawClone + ? this.vendorSession.getSellPrice(rawClone, vendorId, this.vendorDifficulty, this.vendorCharLevel) + : Math.max(1, Math.floor(unitCost / 2)) + + const clonedUiItem: UiInventoryItem = { + ...hit.item, + id: `item-${hit.item.code}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, + ...(rawClone ? { rawItem: rawClone } : {}), + sellCost, + } + delete (clonedUiItem as any).buyCost + delete (clonedUiItem as any).permStore + + playerInventory.gridItems.push({ item: clonedUiItem, col: freeSlot.col, row: freeSlot.row }) + + if (!hit.item.permStore) { + placements.splice(hitIdx, 1) + break + } + } + + if (boughtCount > 0) { + this.showVendorStatus( + `已购买 ${hit.item.nameZh}${boughtCount > 1 ? ` x${boughtCount}` : ''} (-${totalCost} 金币)`, + 'gold', + ) + return { ok: true, boughtCount, totalCost } + } + return { ok: false, reason: 'purchase_failed', boughtCount: 0, totalCost: 0 } + } + + sellToActiveVendor( + item: UiInventoryItem, + playerInventory: InventoryPanel, + ): { ok: boolean; goldReceived: number } { + if (!this.activeVendorDescriptor || this.activeVendorDescriptor.vendorId === null) { + return { ok: false, goldReceived: 0 } + } + const vendorId = this.activeVendorDescriptor.vendorId + // Quest items cannot be sold + if (item.rawItem?.base?.quest && item.rawItem.base.quest > 0) { + this.showVendorStatus('任务物品无法出售 (Cannot sell quest item)', 'red') + return { ok: false, goldReceived: 0 } + } + + const sellPrice = this.getPlayerItemSellPrice(item) + playerInventory.gold = Math.min(PLAYER_GOLD_CAP, playerInventory.gold + sellPrice) + + // Place into vendor store tab for buyback (unless in gamble mode or item is permStore) + if (this.vendorMode === 'trade' && !item.permStore) { + const kind = item.rawItem?.base?.kind ?? (item.allowedSlots.includes('weapon1') ? 'weapon' : item.allowedSlots.length > 0 ? 'armor' : 'misc') + let targetTab: VendorTabId = kind === 'armor' ? 'armor' : kind === 'weapon' ? 'weapons1' : 'misc' + let tabPlacements = this.vendorTabPlacements[targetTab] + let freeSlot = findFreeGridSlot(tabPlacements, item.invWidth, item.invHeight, 10, 10) + if (!freeSlot && targetTab === 'weapons1') { + targetTab = 'weapons2' + tabPlacements = this.vendorTabPlacements[targetTab] + freeSlot = findFreeGridSlot(tabPlacements, item.invWidth, item.invHeight, 10, 10) + } + while (!freeSlot) { + const evictIdx = tabPlacements.findIndex(p => !p.item.permStore) + if (evictIdx === -1) break + tabPlacements.splice(evictIdx, 1) + freeSlot = findFreeGridSlot(tabPlacements, item.invWidth, item.invHeight, 10, 10) + } + if (freeSlot) { + const buybackPrice = item.rawItem + ? this.vendorSession.getBuyPrice(item.rawItem, vendorId, this.vendorDifficulty, this.vendorCharLevel, false) + : Math.max(sellPrice * 2, 2) + const buybackUi: UiInventoryItem = { + ...item, + buyCost: buybackPrice, + sellCost: sellPrice, + permStore: false, + } + tabPlacements.push({ item: buybackUi, col: freeSlot.col, row: freeSlot.row }) + } + } + + this.showVendorStatus(`已出售 ${item.nameZh} (+${sellPrice} 金币)`, 'gold') + return { ok: true, goldReceived: sellPrice } + } + + repairSingleUiItem( + item: UiInventoryItem, + playerInventory: InventoryPanel, + ): { ok: boolean; cost: number } { + if (!this.activeVendorDescriptor?.canRepair) { + return { ok: false, cost: 0 } + } + if (!item.durability || item.durability.current >= item.durability.max) { + this.showVendorStatus('该物品无需修理', 'tan') + return { ok: false, cost: 0 } + } + if (item.ethereal) { + this.showVendorStatus('无形物品无法修理 (Cannot repair ethereal item)', 'red') + return { ok: false, cost: 0 } + } + const cost = this.getPlayerItemRepairPrice(item) + if (cost <= 0) return { ok: false, cost: 0 } + if (playerInventory.gold < cost) { + this.showVendorStatus('金币不足,无法修理', 'red') + return { ok: false, cost: 0 } + } + playerInventory.gold -= cost + ;(item as { durability: { current: number; max: number } }).durability = { + current: item.durability.max, + max: item.durability.max, + } + if (item.rawItem) { + item.rawItem.durability = item.durability.max + } + this.showVendorStatus(`已修理 ${item.nameZh} (-${cost} 金币)`, 'gold') + return { ok: true, cost } + } + + repairAllUiItems(playerInventory: InventoryPanel): { repairedCount: number; totalCost: number } { + if (!this.activeVendorDescriptor?.canRepair) { + return { repairedCount: 0, totalCost: 0 } + } + let repairedCount = 0 + let totalCost = 0 + const allItems: UiInventoryItem[] = [ + ...(Object.values(playerInventory.equipped).filter(Boolean) as UiInventoryItem[]), + ...playerInventory.gridItems.map(p => p.item), + ] + for (const item of allItems) { + if (!item.durability || item.durability.current >= item.durability.max || item.ethereal) continue + const cost = this.getPlayerItemRepairPrice(item) + if (cost <= 0) continue + if (playerInventory.gold < cost) break + playerInventory.gold -= cost + totalCost += cost + repairedCount++ + ;(item as { durability: { current: number; max: number } }).durability = { + current: item.durability.max, + max: item.durability.max, + } + if (item.rawItem) { + item.rawItem.durability = item.durability.max + } + } + if (repairedCount > 0) { + this.showVendorStatus(`已修理 ${repairedCount} 件装备 (-${totalCost} 金币)`, 'gold') + } else { + this.showVendorStatus('所有装备完好,无需修理', 'tan') + } + return { repairedCount, totalCost } + } + + identifyAllUiItems(playerInventory: InventoryPanel): number { + let count = 0 + for (const placed of playerInventory.gridItems) { + if (placed.item.rawItem && placed.item.rawItem.identified === false) { + placed.item.rawItem.identified = true + count++ + } + } + return count + } + /** Runtime quest status overrides: questId -> 'completed' | 'active' | 'locked' */ private questStatuses = new Map() private cachedQuestsAtlasImg: HTMLImageElement | null = null @@ -435,6 +877,7 @@ export class WorldPanelsHud { handleMouseMove(logicalX: number, logicalY: number): void { this.hoveredStashItem = null + this.hoveredVendorItem = null this.hoveredWaypointIdx = null const ox = 80 @@ -470,6 +913,103 @@ export class WorldPanelsHud { } } } + + if ( + logicalX >= VENDOR_GRID_ORIGIN.x && + logicalX < VENDOR_GRID_ORIGIN.x + VENDOR_GRID_ORIGIN.cols * VENDOR_GRID_ORIGIN.cellPx && + logicalY >= VENDOR_GRID_ORIGIN.y && + logicalY < VENDOR_GRID_ORIGIN.y + VENDOR_GRID_ORIGIN.rows * VENDOR_GRID_ORIGIN.cellPx + ) { + const col = Math.floor((logicalX - VENDOR_GRID_ORIGIN.x) / VENDOR_GRID_ORIGIN.cellPx) + const row = Math.floor((logicalY - VENDOR_GRID_ORIGIN.y) / VENDOR_GRID_ORIGIN.cellPx) + const placements = this.getActiveVendorPlacements() + const hit = placements.find( + p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight, + ) + if (hit) { + this.hoveredVendorItem = { + item: hit.item, + x: VENDOR_GRID_ORIGIN.x + (hit.col + hit.item.invWidth / 2) * VENDOR_GRID_ORIGIN.cellPx, + y: VENDOR_GRID_ORIGIN.y + hit.row * VENDOR_GRID_ORIGIN.cellPx, + col: hit.col, + row: hit.row, + } + } + } + } + + handleNpcMenuMove(logicalX: number, logicalY: number): void { + if (!this.npcMenu || this.npcMenu.talking) return + const opts = this.npcMenu.options ?? [] + const boxW = 216 + const rowH = 26 + const headerH = 32 + const boxH = headerH + opts.length * rowH + 10 + const bx = Math.round(this.npcMenu.x - boxW / 2) + const by = Math.round(this.npcMenu.y - boxH / 2) + + this.npcMenu.hoveredOptionIdx = null + if (logicalX >= bx + 8 && logicalX <= bx + boxW - 8) { + for (let i = 0; i < opts.length; i++) { + const ry = by + headerH + i * rowH + if (logicalY >= ry && logicalY < ry + rowH) { + this.npcMenu.hoveredOptionIdx = i + break + } + } + } + } + + handleNpcMenuClick( + logicalX: number, + logicalY: number, + callbacks: { + onOpenVendor: (descriptor: TownNpcServiceDescriptor, mode: 'trade' | 'gamble') => void + onIdentifyAll: (descriptor: TownNpcServiceDescriptor) => void + }, + ): boolean { + if (!this.npcMenu) return false + if (this.npcMenu.talking) { + this.npcMenu.talking = false + return true + } + + const opts = this.npcMenu.options ?? [] + const boxW = 216 + const rowH = 26 + const headerH = 32 + const boxH = headerH + opts.length * rowH + 10 + const bx = Math.round(this.npcMenu.x - boxW / 2) + const by = Math.round(this.npcMenu.y - boxH / 2) + + if (logicalX < bx || logicalX > bx + boxW || logicalY < by || logicalY > by + boxH) { + this.npcMenu = null + return true + } + + for (let i = 0; i < opts.length; i++) { + const ry = by + headerH + i * rowH + if (logicalY >= ry && logicalY < ry + rowH) { + const opt = opts[i]! + const desc = this.npcMenu.descriptor + if (opt.id === 'talk') { + this.npcMenu.talking = true + } else if (opt.id === 'trade' && desc) { + this.npcMenu = null + callbacks.onOpenVendor(desc, 'trade') + } else if (opt.id === 'gamble' && desc) { + this.npcMenu = null + callbacks.onOpenVendor(desc, 'gamble') + } else if (opt.id === 'identify' && desc) { + this.npcMenu = null + callbacks.onIdentifyAll(desc) + } else { + this.npcMenu = null + } + return true + } + } + return true } handleLeftDockClick( @@ -488,8 +1028,13 @@ export class WorldPanelsHud { const ox = 80 const oy = 60 - // Close button (`x = ox + 272, y = oy + 388` or `ox + 18, oy + 385`) - if (logicalY >= oy + 380 && logicalY <= oy + 422 && (logicalX <= ox + 58 || logicalX >= ox + 260)) { + // Close button (`x = ox + 272, y = oy + 388` or `ox + 18, oy + 385` for non-vendor panels) + if ( + logicalY >= oy + 380 && + logicalY <= oy + 422 && + (logicalX >= ox + 260 || (kind !== 'vendor' && logicalX <= ox + 58)) + ) { + this.repairCursorMode = false callbacks.onClose() return true } @@ -528,8 +1073,6 @@ export class WorldPanelsHud { if (logicalX >= ox + 17 && logicalX <= ox + 298 && logicalY >= wy && logicalY <= wy + 32) { const wp = list[i]! if (!this.isWaypointUnlocked(wp)) return true - // Decompiled D2Client.dll 1.13c 0x6fb5a53d (cmp 0x6fbacdd0, %eax): - // Clicking current waypoint does nothing (player is already at this waypoint) if (this.isCurrentWaypoint(wp)) return true callbacks.onWaypointTeleport(wp.act, wp.slug, wp.levelId) callbacks.onClose() @@ -604,6 +1147,74 @@ export class WorldPanelsHud { } return true } + } else if (kind === 'vendor') { + // 1. Vendor 4 Tabs (`armor`, `weapons1`, `weapons2`, `misc`) at `oy + 34 .. oy + 62` + if (this.vendorMode === 'trade' && logicalY >= oy + 32 && logicalY <= oy + 62 && logicalX >= ox + 16 && logicalX <= ox + 306) { + const tabIdx = Math.min(3, Math.max(0, Math.floor((logicalX - (ox + 16)) / 72))) + const chosen = VENDOR_TABS[tabIdx] + if (chosen) { + this.activeVendorTab = chosen.id + this.hoveredVendorItem = null + } + return true + } + + // 2. Repair & Repair All buttons (if active vendor canRepair) + if (this.activeVendorDescriptor?.canRepair && this.vendorMode === 'trade') { + if ( + logicalX >= VENDOR_REPAIR_BTN_BOUNDS.x && + logicalX <= VENDOR_REPAIR_BTN_BOUNDS.x + VENDOR_REPAIR_BTN_BOUNDS.w && + logicalY >= VENDOR_REPAIR_BTN_BOUNDS.y && + logicalY <= VENDOR_REPAIR_BTN_BOUNDS.y + VENDOR_REPAIR_BTN_BOUNDS.h + ) { + this.repairCursorMode = !this.repairCursorMode + this.showVendorStatus( + this.repairCursorMode ? '修理模式:点击右侧装备进行修理' : '已退出修理模式', + this.repairCursorMode ? 'gold' : 'tan', + ) + return true + } + + if ( + logicalX >= VENDOR_REPAIR_ALL_BTN_BOUNDS.x && + logicalX <= VENDOR_REPAIR_ALL_BTN_BOUNDS.x + VENDOR_REPAIR_ALL_BTN_BOUNDS.w && + logicalY >= VENDOR_REPAIR_ALL_BTN_BOUNDS.y && + logicalY <= VENDOR_REPAIR_ALL_BTN_BOUNDS.y + VENDOR_REPAIR_ALL_BTN_BOUNDS.h + ) { + if (callbacks.inventory) { + this.repairAllUiItems(callbacks.inventory) + } + return true + } + } + + // 3. Vendor 10x10 Grid (`VENDOR_GRID_ORIGIN`) + if ( + logicalX >= VENDOR_GRID_ORIGIN.x && + logicalX < VENDOR_GRID_ORIGIN.x + VENDOR_GRID_ORIGIN.cols * VENDOR_GRID_ORIGIN.cellPx && + logicalY >= VENDOR_GRID_ORIGIN.y && + logicalY < VENDOR_GRID_ORIGIN.y + VENDOR_GRID_ORIGIN.rows * VENDOR_GRID_ORIGIN.cellPx + ) { + const col = Math.floor((logicalX - VENDOR_GRID_ORIGIN.x) / VENDOR_GRID_ORIGIN.cellPx) + const row = Math.floor((logicalY - VENDOR_GRID_ORIGIN.y) / VENDOR_GRID_ORIGIN.cellPx) + + if (callbacks.inventory) { + // If player is holding an item on cursor and clicks inside Vendor grid -> Sell it! + if (callbacks.inventory.cursorItem) { + const held = callbacks.inventory.cursorItem + const res = this.sellToActiveVendor(held, callbacks.inventory) + if (res.ok) { + callbacks.inventory.cursorItem = null + } + return true + } + + // Otherwise buy the clicked item from the vendor! + this.buyFromActiveVendor(col, row, callbacks.inventory, Boolean(callbacks.isShiftClick)) + this.handleMouseMove(logicalX, logicalY) + } + return true + } } return true } @@ -727,15 +1338,6 @@ export class WorldPanelsHud { ctx.strokeStyle = isHovered ? '#e8c26b' : '#5a4a32' ctx.strokeRect(ox + 17.5, wy + 0.5, 280, 30) - // Authentic D2 waypoint icon from waygateicons.dc6 (30x30) - // Decompiled D2Client.dll 1.13c at 0x6fb5b2f4 - 0x6fb5b355: - // - Locked waypoint: skip drawing sprite (je 0x6fb5b355) - // - Current waypoint (cmp 0x6fbacdd0, %esi == 0): - // hovered -> Frame 1 (sx = 30) - // unhovered -> Frame 0 (sx = 0) - // - Other activated waypoint (destination): - // hovered -> Frame 4 (sx = 120) - // unhovered -> Frame 3 (sx = 90) if (isUnlocked) { const iconFrameIdx = isCurrent ? (isHovered ? 1 : 0) : (isHovered ? 4 : 3) const iconSx = iconFrameIdx * 30 @@ -743,7 +1345,6 @@ export class WorldPanelsHud { if (assets.waypointIconsImg && assets.waypointIconsImg.complete && assets.waypointIconsImg.naturalWidth > 0) { ctx.drawImage(assets.waypointIconsImg, iconSx, 0, 30, 30, ox + 17, wy + 1, 30, 30) } else { - // Fallback indicator ctx.fillStyle = isCurrent ? (isHovered ? '#78b4ff' : '#4888ff') : (isHovered ? '#f0d898' : '#c8aa6e') ctx.fillRect(ox + 26, wy + 9, 12, 12) } @@ -869,12 +1470,163 @@ export class WorldPanelsHud { align: 'center', }) } else if (kind === 'vendor') { - if (assets.vendorBgImg) ctx.drawImage(assets.vendorBgImg, ox, oy) - font.drawText(ctx, '阿卡拉 · 魔法药剂与卷轴商店', ox + 160, oy + 26, { + if (!this.activeVendorDescriptor) { + this.openVendorForNpc('Akara', 'trade', this.vendorCharLevel, this.vendorDifficulty) + } + if (assets.vendorBgImg) { + ctx.drawImage(assets.vendorBgImg, ox, oy) + } else { + ctx.fillStyle = 'rgba(12, 10, 8, 0.96)' + ctx.fillRect(ox, oy, 320, 432) + ctx.strokeStyle = '#6c5838' + ctx.strokeRect(ox + 0.5, oy + 0.5, 319, 431) + } + + const npcTitle = this.activeVendorDescriptor?.displayName ?? 'Akara' + const modeTitle = this.vendorMode === 'gamble' ? '赌博 (Gamble)' : '商店 (Trade)' + font.drawText(ctx, `${npcTitle} · ${modeTitle}`, ox + 160, oy + 22, { font: 'fontexocet10', color: 'gold', align: 'center', }) + + // 4 Vendor Tabs (`护甲`, `武器 I`, `武器 II`, `杂物`) or Gamble Banner + if (this.vendorMode === 'trade') { + for (let i = 0; i < VENDOR_TABS.length; i++) { + const tab = VENDOR_TABS[i]! + const active = this.activeVendorTab === tab.id + const tx = ox + 17 + i * 72 + const ty = oy + 34 + ctx.fillStyle = active ? 'rgba(56, 44, 28, 0.95)' : 'rgba(20, 16, 12, 0.88)' + ctx.fillRect(tx, ty, 70, 26) + ctx.strokeStyle = active ? '#e8c26b' : '#584a34' + ctx.strokeRect(tx + 0.5, ty + 0.5, 69, 25) + font.drawText(ctx, tab.labelZh, tx + 35, ty + 17, { + font: 'font8', + color: active ? 'gold' : 'tan', + align: 'center', + }) + } + } else { + ctx.fillStyle = 'rgba(42, 32, 18, 0.92)' + ctx.fillRect(ox + 17, oy + 34, 289, 26) + ctx.strokeStyle = '#e8c26b' + ctx.strokeRect(ox + 17.5, oy + 34.5, 288, 25) + font.drawText(ctx, '未鉴定神秘赌博物品 · 点击直接购买鉴定', ox + 160, oy + 51, { + font: 'font8', + color: 'gold', + align: 'center', + }) + } + + // 10x10 Vendor Grid cell borders (`VENDOR_GRID_ORIGIN`) + for (let r = 0; r < VENDOR_GRID_ORIGIN.rows; r++) { + for (let c = 0; c < VENDOR_GRID_ORIGIN.cols; c++) { + const cx = VENDOR_GRID_ORIGIN.x + c * VENDOR_GRID_ORIGIN.cellPx + const cy = VENDOR_GRID_ORIGIN.y + r * VENDOR_GRID_ORIGIN.cellPx + ctx.fillStyle = 'rgba(10, 8, 6, 0.65)' + ctx.fillRect(cx, cy, VENDOR_GRID_ORIGIN.cellPx, VENDOR_GRID_ORIGIN.cellPx) + ctx.strokeStyle = 'rgba(68, 56, 40, 0.45)' + ctx.strokeRect(cx + 0.5, cy + 0.5, VENDOR_GRID_ORIGIN.cellPx - 1, VENDOR_GRID_ORIGIN.cellPx - 1) + } + } + + // Draw items in the active vendor 10x10 tab + const atlas = assets.itemsAtlasImg ?? null + const placements = this.getActiveVendorPlacements() + for (const placed of placements) { + const gx = VENDOR_GRID_ORIGIN.x + placed.col * VENDOR_GRID_ORIGIN.cellPx + const gy = VENDOR_GRID_ORIGIN.y + placed.row * VENDOR_GRID_ORIGIN.cellPx + const gw = placed.item.invWidth * VENDOR_GRID_ORIGIN.cellPx + const gh = placed.item.invHeight * VENDOR_GRID_ORIGIN.cellPx + const isHovered = this.hoveredVendorItem?.item.id === placed.item.id + + ctx.fillStyle = isHovered + ? 'rgba(46, 68, 36, 0.55)' + : placed.item.quality === 'magic' + ? 'rgba(20, 34, 62, 0.42)' + : 'rgba(28, 24, 18, 0.42)' + ctx.fillRect(gx + 1, gy + 1, gw - 2, gh - 2) + + const rect = resolveItemSpriteRect(placed.item, BAKED_UI_MANIFEST.itemRects) + if (atlas && rect) { + const scale = Math.min(gw / rect.w, gh / rect.h, 1) + const dw = Math.round(rect.w * scale) + const dh = Math.round(rect.h * scale) + const dx = gx + Math.round((gw - dw) / 2) + const dy = gy + Math.round((gh - dh) / 2) + ctx.drawImage(atlas, rect.x, rect.y, rect.w, rect.h, dx, dy, dw, dh) + } else { + ctx.fillStyle = placed.item.quality === 'magic' ? '#4850b8' : '#786850' + ctx.fillRect(gx + 2, gy + 2, gw - 4, gh - 4) + } + } + + // Repair & Repair All buttons for blacksmiths + if (this.activeVendorDescriptor?.canRepair && this.vendorMode === 'trade') { + ctx.fillStyle = this.repairCursorMode ? 'rgba(64, 48, 20, 0.95)' : 'rgba(20, 16, 12, 0.9)' + ctx.fillRect( + VENDOR_REPAIR_BTN_BOUNDS.x, + VENDOR_REPAIR_BTN_BOUNDS.y, + VENDOR_REPAIR_BTN_BOUNDS.w, + VENDOR_REPAIR_BTN_BOUNDS.h, + ) + ctx.strokeStyle = this.repairCursorMode ? '#e8c26b' : '#8a7248' + ctx.strokeRect( + VENDOR_REPAIR_BTN_BOUNDS.x + 0.5, + VENDOR_REPAIR_BTN_BOUNDS.y + 0.5, + VENDOR_REPAIR_BTN_BOUNDS.w - 1, + VENDOR_REPAIR_BTN_BOUNDS.h - 1, + ) + font.drawText( + ctx, + '修理 (Repair)', + VENDOR_REPAIR_BTN_BOUNDS.x + VENDOR_REPAIR_BTN_BOUNDS.w / 2, + VENDOR_REPAIR_BTN_BOUNDS.y + 16, + { font: 'font8', color: this.repairCursorMode ? 'white' : 'gold', align: 'center' }, + ) + + ctx.fillStyle = 'rgba(20, 16, 12, 0.9)' + ctx.fillRect( + VENDOR_REPAIR_ALL_BTN_BOUNDS.x, + VENDOR_REPAIR_ALL_BTN_BOUNDS.y, + VENDOR_REPAIR_ALL_BTN_BOUNDS.w, + VENDOR_REPAIR_ALL_BTN_BOUNDS.h, + ) + ctx.strokeStyle = '#8a7248' + ctx.strokeRect( + VENDOR_REPAIR_ALL_BTN_BOUNDS.x + 0.5, + VENDOR_REPAIR_ALL_BTN_BOUNDS.y + 0.5, + VENDOR_REPAIR_ALL_BTN_BOUNDS.w - 1, + VENDOR_REPAIR_ALL_BTN_BOUNDS.h - 1, + ) + font.drawText( + ctx, + '全部修理 (All)', + VENDOR_REPAIR_ALL_BTN_BOUNDS.x + VENDOR_REPAIR_ALL_BTN_BOUNDS.w / 2, + VENDOR_REPAIR_ALL_BTN_BOUNDS.y + 16, + { font: 'font8', color: 'gold', align: 'center' }, + ) + } else { + font.drawText(ctx, '左键/右键点击购买 · 右键背包物品出售', ox + 130, oy + 403, { + font: 'font8', + color: 'tan', + align: 'center', + }) + } + + // Status toast message at bottom of vendor panel + if (this.vendorStatusMsg && performance.now() < this.vendorStatusMsg.untilMs) { + ctx.fillStyle = 'rgba(8, 6, 4, 0.92)' + ctx.fillRect(ox + 17, oy + 356, 289, 24) + ctx.strokeStyle = '#8a7248' + ctx.strokeRect(ox + 17.5, oy + 356.5, 288, 23) + font.drawText(ctx, this.vendorStatusMsg.text, ox + 160, oy + 372, { + font: 'font8', + color: this.vendorStatusMsg.color, + align: 'center', + }) + } } if (assets.buySellBtnImg) { @@ -882,6 +1634,89 @@ export class WorldPanelsHud { } } + /** + * Draw Town NPC Stone Interaction Menu (`npcmenu.cpp`) and Greeting Dialog Box. + */ + drawNpcMenu(ctx: CanvasRenderingContext2D, font: D2FontRenderer): void { + if (!this.npcMenu) return + + if (this.npcMenu.talking) { + const boxW = 340 + const boxH = 156 + const bx = Math.max(20, Math.min(800 - boxW - 20, Math.round(this.npcMenu.x - boxW / 2))) + const by = Math.max(40, Math.min(520 - boxH, Math.round(this.npcMenu.y - boxH / 2))) + + ctx.fillStyle = 'rgba(10, 8, 6, 0.94)' + ctx.fillRect(bx, by, boxW, boxH) + ctx.strokeStyle = '#a88c54' + ctx.lineWidth = 2 + ctx.strokeRect(bx + 1, by + 1, boxW - 2, boxH - 2) + ctx.lineWidth = 1 + + font.drawText(ctx, this.npcMenu.npcName, bx + boxW / 2, by + 24, { + font: 'fontexocet10', + color: 'gold', + align: 'center', + }) + + const bodyText = this.npcMenu.dialogLines.join('\n') + this.drawWrappedText(ctx, font, bodyText, bx + 18, by + 48, boxW - 36, 16, 5) + + font.drawText(ctx, '[点击继续 / Click to continue]', bx + boxW / 2, by + boxH - 12, { + font: 'font8', + color: 'tan', + align: 'center', + }) + return + } + + const opts = this.npcMenu.options ?? [ + { id: 'talk', labelZh: '交谈', labelEn: 'Talk' }, + { id: 'cancel', labelZh: '取消', labelEn: 'Cancel' }, + ] + const boxW = 216 + const rowH = 26 + const headerH = 32 + const boxH = headerH + opts.length * rowH + 10 + const bx = Math.round(this.npcMenu.x - boxW / 2) + const by = Math.round(this.npcMenu.y - boxH / 2) + + ctx.fillStyle = 'rgba(12, 10, 8, 0.94)' + ctx.fillRect(bx, by, boxW, boxH) + ctx.strokeStyle = '#9c824c' + ctx.lineWidth = 2 + ctx.strokeRect(bx + 1, by + 1, boxW - 2, boxH - 2) + ctx.lineWidth = 1 + + // Header divider + ctx.strokeStyle = '#5a4a30' + ctx.beginPath() + ctx.moveTo(bx + 10, by + headerH - 4) + ctx.lineTo(bx + boxW - 10, by + headerH - 4) + ctx.stroke() + + font.drawText(ctx, this.npcMenu.npcName, bx + boxW / 2, by + 21, { + font: 'fontexocet10', + color: 'gold', + align: 'center', + }) + + for (let i = 0; i < opts.length; i++) { + const opt = opts[i]! + const ry = by + headerH + i * rowH + const hovered = this.npcMenu.hoveredOptionIdx === i + if (hovered) { + ctx.fillStyle = 'rgba(64, 50, 28, 0.75)' + ctx.fillRect(bx + 6, ry + 2, boxW - 12, rowH - 4) + } + font.drawText(ctx, `${opt.labelZh} (${opt.labelEn})`, bx + boxW / 2, ry + 18, { + font: 'fontexocet10', + color: hovered ? 'white' : 'gold', + align: 'center', + }) + } + } + private drawActTabs( ctx: CanvasRenderingContext2D, ox: number, @@ -1022,6 +1857,7 @@ export class WorldPanelsHud { ctx: CanvasRenderingContext2D, hover: { item: UiInventoryItem; x: number; y: number } | null, font: D2FontRenderer, + vendorOpen = false, ): void { if (!hover) return const { item } = hover @@ -1049,6 +1885,24 @@ export class WorldPanelsHud { lines.push({ text: item.baseNameZh, color: 'white', font: 'font8' }) } + // Vendor Transaction Cost Line (`ITEMS_CalculateTransactionCost` at 0x6FD9B1C0) + if (item.buyCost !== undefined) { + const label = item.isGambleMystery ? '赌博价格 (Gamble Cost)' : '购买价格 (Buy Cost)' + lines.push({ text: `${label}: ${item.buyCost.toLocaleString()} 金币`, color: 'gold', font: 'font8' }) + } else if (vendorOpen && this.activeVendorDescriptor) { + if (this.repairCursorMode && this.activeVendorDescriptor.canRepair) { + const repPrice = this.getPlayerItemRepairPrice(item) + if (repPrice > 0) { + lines.push({ text: `修理费用 (Repair Cost): ${repPrice.toLocaleString()} 金币`, color: 'gold', font: 'font8' }) + } + } else { + const sellPrice = this.getPlayerItemSellPrice(item) + if (sellPrice > 0) { + lines.push({ text: `出售价格 (Sell Value): ${sellPrice.toLocaleString()} 金币`, color: 'gold', font: 'font8' }) + } + } + } + // Base Stats Block if (item.defense !== undefined) { lines.push({ text: '防御: ' + item.defense, color: 'white', font: 'font8' }) diff --git a/tests/item-cost.test.ts b/tests/item-cost.test.ts new file mode 100644 index 0000000..d62a8cd --- /dev/null +++ b/tests/item-cost.test.ts @@ -0,0 +1,262 @@ +import { describe, expect, it } from 'vitest' +import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts' +import { + TransactionType, + calculateTransactionCost, + calculateGambleCost, + isItemRepairable, + isClassSpecificItemBase, + hasStaffModsItemType, +} from '../src/game/item-cost.ts' +import { VendorNpcId } from '../src/game/npc-table.ts' +import { createDroppedItem } from '../src/game/drop-pipeline.ts' +import { D2Rng } from '../src/game/d2-rng.ts' +import { ItemFlag, ItemQuality, decodeItemFlags, type Item } from '../src/game/items.ts' + +describe('1.13c Item Transaction & Acquisition Cost Engine (M19-P3)', () => { + const tables = getEmbeddedDropTables() + + it('prices Starter Items at 1 gold for Buy and Sell and 0/1 for Repair', () => { + const haxBase = tables.getBase('hax')! + const normalItem = createDroppedItem(haxBase, 'normal', { + itemTypes: tables.itemTypes, + ilvl: 1, + dwInitSeed: 100, + itemRng: new D2Rng(100), + }) + const starterItem: Item = { + ...normalItem, + rawFlags: (normalItem.rawFlags ?? 0) | ItemFlag.STARTER_ITEM, + flags: decodeItemFlags((normalItem.rawFlags ?? 0) | ItemFlag.STARTER_ITEM), + } + + expect( + calculateTransactionCost({ + item: starterItem, + transactionType: TransactionType.BUY, + vendorId: VendorNpcId.CHARSI, + tables, + }), + ).toBe(1) + + expect( + calculateTransactionCost({ + item: starterItem, + transactionType: TransactionType.SELL, + vendorId: VendorNpcId.CHARSI, + tables, + }), + ).toBe(1) + }) + + it('prices Tomes (tbk / ibk) dynamically by scroll charge count per Books.txt', () => { + const tbkBase = tables.getBase('tbk')! + const baseTome = createDroppedItem(tbkBase, 'normal', { + itemTypes: tables.itemTypes, + ilvl: 1, + dwInitSeed: 200, + itemRng: new D2Rng(200), + }) + + // Tome with 3 scrolls vs Tome with 20 scrolls + const tome3: Item = { ...baseTome, stack: 3, quantity: 3 } + const tome20: Item = { ...baseTome, stack: 20, quantity: 20 } + + const buy3 = calculateTransactionCost({ + item: tome3, + transactionType: TransactionType.BUY, + vendorId: VendorNpcId.AKARA, + tables, + }) + const buy20 = calculateTransactionCost({ + item: tome20, + transactionType: TransactionType.BUY, + vendorId: VendorNpcId.AKARA, + tables, + }) + + // Books.txt: BaseCost=250, CostPerCharge=50 -> 3 scrolls = 400, 20 scrolls = 1250 + expect(buy20).toBeGreaterThan(buy3) + expect(buy20 - buy3).toBe(17 * 50) + + const sell3 = calculateTransactionCost({ + item: tome3, + transactionType: TransactionType.SELL, + vendorId: VendorNpcId.AKARA, + tables, + }) + const sell20 = calculateTransactionCost({ + item: tome20, + transactionType: TransactionType.SELL, + vendorId: VendorNpcId.AKARA, + tables, + }) + expect(sell20).toBeGreaterThan(sell3) + }) + + it('applies 1/4 acquisition divisor to class-specific items (e.g. Sorceress Orb / Necromancer Wand)', () => { + const ob1 = tables.getBase('ob1')! // Jared's Stone (Orb, class='sor') + expect(isClassSpecificItemBase(ob1, tables.itemTypes)).toBe(true) + + const wnd = tables.getBase('wnd')! // Wand (StaffMods='nec', non-class-specific) + expect(hasStaffModsItemType(wnd, tables.itemTypes)).toBe(true) + }) + + it('adds affix / stat bonus cost and staffMod single-skill cost to Magic/Rare items', () => { + const wndBase = tables.getBase('wnd')! + const plainWand = createDroppedItem(wndBase, 'normal', { + itemTypes: tables.itemTypes, + ilvl: 12, + dwInitSeed: 301, + itemRng: new D2Rng(301), + }) + + const staffModWand: Item = { + ...plainWand, + staffMods: [{ code: 'skill', param: 67, min: 3, max: 3, value: 3 }], // +3 Teeth (skillId 67) + } + + const plainBuy = calculateTransactionCost({ + item: plainWand, + transactionType: TransactionType.BUY, + vendorId: VendorNpcId.AKARA, + tables, + }) + const staffModBuy = calculateTransactionCost({ + item: staffModWand, + transactionType: TransactionType.BUY, + vendorId: VendorNpcId.AKARA, + tables, + }) + expect(staffModBuy).toBeGreaterThan(plainBuy) + + const plainSell = calculateTransactionCost({ + item: plainWand, + transactionType: TransactionType.SELL, + vendorId: VendorNpcId.AKARA, + tables, + }) + const staffModSell = calculateTransactionCost({ + item: staffModWand, + transactionType: TransactionType.SELL, + vendorId: VendorNpcId.AKARA, + tables, + }) + expect(staffModSell).toBeGreaterThan(plainSell) + }) + + it('clamps acquisition (SELL) price to Npc.txt MaxBuy cap per Act and Difficulty (5000 / 30000 / 35000)', () => { + const aarBase = tables.getBase('uar')! // Sacred Armor (high cost elite armor) + const baseArmor = createDroppedItem(aarBase, 'magic', { + itemTypes: tables.itemTypes, + ilvl: 85, + dwInitSeed: 401, + itemRng: new D2Rng(401), + }) + // Give it high-cost skills/stats so raw sell value exceeds 35,000 + const expensiveArmor: Item = { + ...baseArmor, + statList: [ + { statId: 127, param: 0, value: 5 }, // +5 All Skills + { statId: 7, param: 0, value: 100 }, // +100 Life + ], + } + + const sellAct1Norm = calculateTransactionCost({ + item: expensiveArmor, + transactionType: TransactionType.SELL, + difficulty: 0, + vendorId: VendorNpcId.AKARA, + actIndex: 0, + tables, + }) + expect(sellAct1Norm).toBe(5000) + + const sellNightmare = calculateTransactionCost({ + item: expensiveArmor, + transactionType: TransactionType.SELL, + difficulty: 1, + vendorId: VendorNpcId.AKARA, + actIndex: 0, + tables, + }) + expect(sellNightmare).toBe(30000) + + const sellHell = calculateTransactionCost({ + item: expensiveArmor, + transactionType: TransactionType.SELL, + difficulty: 2, + vendorId: VendorNpcId.AKARA, + actIndex: 0, + tables, + }) + expect(sellHell).toBe(35000) + }) + + it('halves acquisition (SELL) price for Ethereal items and forbids repairing Ethereal items', () => { + const capBase = tables.getBase('plt')! // Plate Mail (below 35k cap so halving is observable) + const normalArmor = createDroppedItem(capBase, 'normal', { + itemTypes: tables.itemTypes, + ilvl: 25, + ethereal: false, + dwInitSeed: 501, + itemRng: new D2Rng(501), + }) + const ethArmor = createDroppedItem(capBase, 'normal', { + itemTypes: tables.itemTypes, + ilvl: 25, + ethereal: true, + dwInitSeed: 501, + itemRng: new D2Rng(501), + }) + + const normSell = calculateTransactionCost({ + item: normalArmor, + transactionType: TransactionType.SELL, + difficulty: 2, + vendorId: VendorNpcId.CHARSI, + tables, + }) + const ethSell = calculateTransactionCost({ + item: ethArmor, + transactionType: TransactionType.SELL, + difficulty: 2, + vendorId: VendorNpcId.CHARSI, + tables, + }) + expect(ethSell).toBeLessThan(normSell) + + const damagedEth: Item = { ...ethArmor, durability: 5, maxDurability: 30 } + expect(isItemRepairable(damagedEth)).toBe(false) + expect( + calculateTransactionCost({ + item: damagedEth, + transactionType: TransactionType.REPAIR, + vendorId: VendorNpcId.CHARSI, + tables, + }), + ).toBe(0) + }) + + it('computes Gamble costs (fixed 50k Ring / 63k Amulet, cLvl-scaled equipment) and applies vendor discounts', () => { + const rinBase = tables.getBase('rin')! + const amuBase = tables.getBase('amu')! + const capBase = tables.getBase('cap')! + + const rinItem = createDroppedItem(rinBase, 'magic', { itemTypes: tables.itemTypes, ilvl: 20, dwInitSeed: 601 }) + const amuItem = createDroppedItem(amuBase, 'magic', { itemTypes: tables.itemTypes, ilvl: 20, dwInitSeed: 602 }) + const capItem = createDroppedItem(capBase, 'magic', { itemTypes: tables.itemTypes, ilvl: 20, dwInitSeed: 603 }) + + expect(calculateGambleCost({ item: rinItem, cLvl: 30, tables })).toBe(50000) + expect(calculateGambleCost({ item: amuItem, cLvl: 30, tables })).toBe(63000) + + // 15% Gheed's Fortune + 15% Edge = 30% reduced vendor prices + expect( + calculateGambleCost({ item: rinItem, cLvl: 30, reducedPricesPercent: 30, tables }), + ).toBe(35000) + + const capGambleLvl10 = calculateGambleCost({ item: capItem, cLvl: 10, tables }) + const capGambleLvl80 = calculateGambleCost({ item: capItem, cLvl: 80, tables }) + expect(capGambleLvl80).toBeGreaterThan(capGambleLvl10) + }) +}) diff --git a/tests/vendor-generator.test.ts b/tests/vendor-generator.test.ts new file mode 100644 index 0000000..2def85a --- /dev/null +++ b/tests/vendor-generator.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, it } from 'vitest' +import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts' +import { + VendorNpcId, + VENDOR_COLUMN_PREFIXES, + TOWN_NPC_DESCRIPTORS, + getTownNpcDescriptorByName, +} from '../src/game/npc-table.ts' +import { + computeVendorItemLevel, + resolveVendorBaseUpgrade, + generateVendorStoreInventory, + generateGambleInventory, + resolveGambledItem, + VENDOR_NORMAL_ILVL_CAPS, +} from '../src/game/vendor-generator.ts' +import { D2Rng } from '../src/game/d2-rng.ts' +import { ItemQuality } from '../src/game/items.ts' + +describe('1.13c Vendor Economic & Inventory Tables (M19-P1 & M19-P2)', () => { + const dropTables = getEmbeddedDropTables() + + it('parses all 17 canonical vendors in Npc.txt, Gamble.txt, Books.txt, and LowQualityItems.txt', () => { + expect(VENDOR_COLUMN_PREFIXES).toHaveLength(17) + // Blizzard's historical typo in Weapons.txt/Armor.txt/Misc.txt is 'Hralti' at index 6 + expect(VENDOR_COLUMN_PREFIXES[6]).toBe('Hralti') + + expect(dropTables.npcTable!.size).toBeGreaterThanOrEqual(17) + const akara = dropTables.npcTable!.getByVendorId(VendorNpcId.AKARA) + expect(akara).toBeDefined() + expect(akara?.npc.toLowerCase()).toBe('akara') + expect(akara?.maxBuy).toEqual([5000, 30000, 35000]) + + const charsi = dropTables.npcTable!.getByVendorId(VendorNpcId.CHARSI) + expect(charsi).toBeDefined() + expect(charsi?.npc.toLowerCase()).toBe('charsi') + expect(charsi?.repMult).toBeGreaterThan(0) + + expect(dropTables.gambleCodes!.length).toBeGreaterThan(30) + expect(dropTables.gambleCodes).toContain('rin') + expect(dropTables.gambleCodes).toContain('amu') + expect(dropTables.gambleCodes).toContain('ci0') + + const tbk = dropTables.booksTable!.getByBookCode('tbk') + expect(tbk).toBeDefined() + expect(tbk?.scrollSpellCode).toBe('tsc') + expect(tbk?.baseCost).toBe(250) + expect(tbk?.costPerCharge).toBe(50) + + const ibk = dropTables.booksTable!.getByBookCode('ibk') + expect(ibk).toBeDefined() + expect(ibk?.scrollSpellCode).toBe('isc') + + expect(dropTables.lowQualityItems).toHaveLength(4) + expect(dropTables.lowQualityItems![0]).toBe('Crude') + }) + + it('resolves all Town NPC descriptors across Acts 1-5 including healers, traders, gamblers, and Cain', () => { + expect(TOWN_NPC_DESCRIPTORS.length).toBeGreaterThanOrEqual(25) + + const akara = getTownNpcDescriptorByName('Akara', 0) + expect(akara?.vendorId).toBe(VendorNpcId.AKARA) + expect(akara?.canTrade).toBe(true) + expect(akara?.canHeal).toBe(true) + + const gheed = getTownNpcDescriptorByName('Gheed', 0) + expect(gheed?.vendorId).toBe(VendorNpcId.GHEED) + expect(gheed?.canTrade).toBe(true) + expect(gheed?.canGamble).toBe(true) + + const charsi = getTownNpcDescriptorByName('Charsi', 0) + expect(charsi?.vendorId).toBe(VendorNpcId.CHARSI) + expect(charsi?.canTrade).toBe(true) + expect(charsi?.canRepair).toBe(true) + + const cainA1 = getTownNpcDescriptorByName('Deckard Cain', 0) + expect(cainA1?.canIdentify).toBe(true) + + const atma = getTownNpcDescriptorByName('Atma', 1) + expect(atma?.canHeal).toBe(true) + + const hratli = getTownNpcDescriptorByName('Hratli', 2) + expect(hratli?.vendorId).toBe(VendorNpcId.HRATLI) + expect(hratli?.canTrade).toBe(true) + expect(hratli?.canRepair).toBe(true) + + const larzuk = getTownNpcDescriptorByName('Larzuk', 4) + expect(larzuk?.vendorId).toBe(VendorNpcId.LARZUK) + expect(larzuk?.canTrade).toBe(true) + expect(larzuk?.canRepair).toBe(true) + }) + + it('computes 1.13c vendor ilvl with Normal Act caps [12, 20, 28, 36, 45] and uncapped NM/Hell cLvl + 5', () => { + expect(VENDOR_NORMAL_ILVL_CAPS).toEqual([12, 20, 28, 36, 45]) + + // Normal difficulty: capped by Act + expect(computeVendorItemLevel(VendorNpcId.AKARA, 0, 1, 0)).toBe(6) + expect(computeVendorItemLevel(VendorNpcId.AKARA, 0, 10, 0)).toBe(12) // Act 1 cap 12 + expect(computeVendorItemLevel(VendorNpcId.FARA, 0, 30, 1)).toBe(20) // Act 2 cap 20 + expect(computeVendorItemLevel(VendorNpcId.HRATLI, 0, 30, 2)).toBe(28) // Act 3 cap 28 + expect(computeVendorItemLevel(VendorNpcId.HALBU, 0, 40, 3)).toBe(36) // Act 4 cap 36 + expect(computeVendorItemLevel(VendorNpcId.LARZUK, 0, 50, 4)).toBe(45) // Act 5 cap 45 + + // Nightmare & Hell: uncapped cLvl + 5 clamped to [1..99] + expect(computeVendorItemLevel(VendorNpcId.AKARA, 1, 40, 0)).toBe(45) + expect(computeVendorItemLevel(VendorNpcId.AKARA, 2, 85, 0)).toBe(90) + expect(computeVendorItemLevel(VendorNpcId.LARZUK, 2, 97, 4)).toBe(99) + }) + + it('upgrades Normal base items to Exceptional/Elite in Nightmare and Hell according to 1.13c formulas', () => { + const cap = dropTables.getBase('cap')! + + // Normal difficulty never upgrades base item tier + const rngNorm = new D2Rng(12345) + for (let i = 0; i < 20; i++) { + const res = resolveVendorBaseUpgrade(cap, 80, 12, 0, true, dropTables, rngNorm) + expect(res.id).toBe('cap') + } + + // Hell difficulty at high cLvl upgrades some items to ubercode ('xap') or ultracode ('uap') + const rngHell = new D2Rng(54321) + const seenIds = new Set() + for (let i = 0; i < 60; i++) { + const res = resolveVendorBaseUpgrade(cap, 85, 90, 2, true, dropTables, rngHell) + seenIds.add(res.id) + } + expect(seenIds.has('xap') || seenIds.has('uap')).toBe(true) + }) + + it('generates Akara, Charsi, Hratli, and Larzuk store inventories across difficulties and cLvls', () => { + // Akara (Vendor 0) in Normal at cLvl 1 + const akaraStore = generateVendorStoreInventory({ + vendorId: VendorNpcId.AKARA, + difficulty: 0, + cLvl: 1, + tables: dropTables, + rng: new D2Rng(1001), + }) + expect(akaraStore.vendorId).toBe(VendorNpcId.AKARA) + expect(akaraStore.nItemLevel).toBe(6) + const akaraMisc = akaraStore.tabs.misc.list() + expect(akaraMisc.length).toBeGreaterThan(0) + // Akara permanently sells Tomes/Scrolls/Potions/Keys + const akaraCodes = new Set(akaraMisc.map(p => p.item.base.id)) + expect(akaraCodes.has('isc')).toBe(true) + expect(akaraCodes.has('tsc')).toBe(true) + expect(akaraCodes.has('ibk')).toBe(true) + expect(akaraCodes.has('tbk')).toBe(true) + expect(akaraCodes.has('hp1')).toBe(true) + expect(akaraCodes.has('mp1')).toBe(true) + + for (const placed of akaraMisc) { + if (['isc', 'tsc', 'ibk', 'tbk', 'hp1', 'mp1', 'key'].includes(placed.item.base.id)) { + expect(placed.item.permStore).toBe(true) + expect(placed.item.quality).toBe(ItemQuality.NORMAL) + } + } + + // Charsi (Vendor 2) in Normal at cLvl 12 + const charsiStore = generateVendorStoreInventory({ + vendorId: VendorNpcId.CHARSI, + difficulty: 0, + cLvl: 12, + tables: dropTables, + rng: new D2Rng(2002), + }) + expect(charsiStore.nItemLevel).toBe(12) + const charsiArmor = charsiStore.tabs.armor.list() + const charsiWeap1 = charsiStore.tabs.weapons1.list() + expect(charsiArmor.length).toBeGreaterThan(0) + expect(charsiWeap1.length).toBeGreaterThan(0) + + // Verify magic items generated by Charsi are already identified and have affixes + const magicArmors = charsiArmor.filter(p => p.item.quality === ItemQuality.MAGIC) + expect(magicArmors.length).toBeGreaterThan(0) + for (const m of magicArmors) { + expect(m.item.flags?.identified).toBe(true) + expect(Boolean(m.item.prefix || m.item.suffix)).toBe(true) + } + + // Hratli (Vendor 6, 'Hralti' column) in Nightmare at cLvl 45 + const hratliStore = generateVendorStoreInventory({ + vendorId: VendorNpcId.HRATLI, + difficulty: 1, + cLvl: 45, + tables: dropTables, + rng: new D2Rng(3003), + }) + expect(hratliStore.nItemLevel).toBe(50) + expect(hratliStore.tabs.armor.list().length).toBeGreaterThan(0) + expect(hratliStore.tabs.weapons1.list().length).toBeGreaterThan(0) + + // Larzuk (Vendor 15) in Hell at cLvl 80 + const larzukStore = generateVendorStoreInventory({ + vendorId: VendorNpcId.LARZUK, + difficulty: 2, + cLvl: 80, + tables: dropTables, + rng: new D2Rng(4004), + }) + expect(larzukStore.nItemLevel).toBe(85) + expect(larzukStore.tabs.armor.list().length).toBeGreaterThan(0) + expect(larzukStore.tabs.weapons1.list().length).toBeGreaterThan(0) + }) + + it('generates Gamble inventory and resolves mystery items on purchase', () => { + const rng = new D2Rng(7777) + const gambleGrid = generateGambleInventory({ + vendorId: VendorNpcId.GHEED, + cLvl: 50, + tables: dropTables, + rng, + }) + + const allGambleItems = gambleGrid.list() + expect(allGambleItems.length).toBeGreaterThan(5) + + // Always includes Ring and Amulet in Gamble + const gambleCodes = new Set(allGambleItems.map(p => p.item.base.id)) + expect(gambleCodes.has('rin')).toBe(true) + expect(gambleCodes.has('amu')).toBe(true) + + for (const placed of allGambleItems) { + expect(placed.item.isGambleMystery).toBe(true) + expect(placed.item.identified).toBe(false) + expect(placed.item.ilvl).toBeGreaterThanOrEqual(45) + expect(placed.item.ilvl).toBeLessThanOrEqual(54) + } + + // Resolve gambled items across multiple rolls and verify quality & identification + const rarities = new Set() + for (let i = 0; i < 40; i++) { + const target = allGambleItems[i % allGambleItems.length]!.item + const resolved = resolveGambledItem({ + mysteryItem: target, + cLvl: 50, + tables: dropTables, + rng, + }) + expect(resolved.identified).toBe(true) + expect(resolved.isGambleMystery).toBe(false) + rarities.add(resolved.rarity ?? 'normal') + } + expect(rarities.has('magic')).toBe(true) + }) +}) diff --git a/tests/vendor-service.test.ts b/tests/vendor-service.test.ts new file mode 100644 index 0000000..28ac747 --- /dev/null +++ b/tests/vendor-service.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from 'vitest' +import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts' +import { + VendorSessionManager, + deductGoldFromPurse, + addGoldToPurse, + type GoldPurseState, +} from '../src/game/vendor-service.ts' +import { VendorNpcId, getTownNpcDescriptorByName } from '../src/game/npc-table.ts' +import { Inventory, ItemFlag, decodeItemFlags, type Item } from '../src/game/items.ts' +import { createDroppedItem } from '../src/game/drop-pipeline.ts' +import { D2Rng } from '../src/game/d2-rng.ts' + +describe('1.13c Town NPC Service & Vendor Session Manager (M19-P4)', () => { + const tables = getEmbeddedDropTables() + + it('deducts gold from inventory first and overflows into stash, and adds gold up to 10,000 * cLvl cap', () => { + const purse: GoldPurseState = { inventoryGold: 3000, stashGold: 10000, cLvl: 1 } + expect(deductGoldFromPurse(purse, 5000)).toBe(true) + expect(purse.inventoryGold).toBe(0) + expect(purse.stashGold).toBe(8000) + + expect(deductGoldFromPurse(purse, 9000)).toBe(false) + expect(purse.stashGold).toBe(8000) + + // At cLvl 1, max inventory gold is 10,000; adding 15,000 puts 10,000 in inventory and 5,000 in stash + addGoldToPurse(purse, 15000) + expect(purse.inventoryGold).toBe(10000) + expect(purse.stashGold).toBe(13000) + }) + + it('supports Shift-Buy of Scrolls into a non-full Tome up to 20 charges', () => { + const manager = new VendorSessionManager(tables, 0x123456) + const akara = getTownNpcDescriptorByName('Akara', 0)! + manager.openTradeSession(akara, 0, 10) + manager.setActiveTab('misc') + + const miscGrid = manager.getActiveTabInventory(0, 10)! + const scrollPlacement = miscGrid.list().find(p => p.item.base.id === 'tp' || p.item.base.id === 'tsc')! + expect(scrollPlacement).toBeDefined() + + // Put a Tome of Town Portal ('tbk') with 3 scrolls into player's inventory + const playerInventory = new Inventory(10, 4) + const tbkBase = tables.getBase('tbk')! + const tomeItem: Item = { + ...createDroppedItem(tbkBase, 'normal', { itemTypes: tables.itemTypes, ilvl: 1, dwInitSeed: 701 }), + stack: 3, + quantity: 3, + } + playerInventory.add(tomeItem) + + const purse: GoldPurseState = { inventoryGold: 10000, stashGold: 0, cLvl: 10 } + const res = manager.buyItemAtCell({ + cellX: scrollPlacement.x, + cellY: scrollPlacement.y, + shiftBuy: true, + purse, + playerInventory, + difficulty: 0, + }) + + expect(res.ok).toBe(true) + expect(res.countPurchased).toBe(17) // 3 -> 20 + const updatedTome = playerInventory.list().find(p => p.item.base.id === 'tbk')!.item + expect(updatedTome.quantity).toBe(20) + }) + + it('supports selling player items to NPC and placing non-permanent items into vendor tabs for Buyback', () => { + const manager = new VendorSessionManager(tables, 0x654321) + const charsi = getTownNpcDescriptorByName('Charsi', 0)! + manager.openTradeSession(charsi, 0, 12) + + const capBase = tables.getBase('cap')! + const magicCap = createDroppedItem(capBase, 'magic', { + itemTypes: tables.itemTypes, + ilvl: 12, + dwInitSeed: 999, + itemRng: new D2Rng(999), + }) + + const purse: GoldPurseState = { inventoryGold: 500, stashGold: 0, cLvl: 12 } + const sellRes = manager.sellPlayerItem({ + item: magicCap, + purse, + difficulty: 0, + }) + + expect(sellRes.ok).toBe(true) + expect(sellRes.goldEarned).toBeGreaterThan(0) + expect(purse.inventoryGold).toBe(500 + sellRes.goldEarned) + expect(sellRes.placedInTab).toBe('armor') + + // Verify quest items cannot be sold + const hfhBase = tables.getBase('hfh') // Hellforge Hammer (quest > 0) + if (hfhBase) { + const questItem = createDroppedItem(hfhBase, 'normal', { itemTypes: tables.itemTypes, ilvl: 1, dwInitSeed: 702 }) + const qRes = manager.sellPlayerItem({ item: questItem, purse, difficulty: 0 }) + expect(qRes.ok).toBe(false) + } + }) + + it('repairs single and all damaged items via blacksmith NPCs and identifies all items via Deckard Cain', () => { + const manager = new VendorSessionManager(tables, 0xabcdef) + const charsi = getTownNpcDescriptorByName('Charsi', 0)! + manager.openTradeSession(charsi, 0, 20) + + const capBase = tables.getBase('cap')! + const damagedCap: Item = { + ...createDroppedItem(capBase, 'normal', { itemTypes: tables.itemTypes, ilvl: 10, dwInitSeed: 703 }), + durability: 2, + maxDurability: 12, + } + + const equipped = new Map([['head', damagedCap]]) + const playerInventory = new Inventory(10, 4) + const purse: GoldPurseState = { inventoryGold: 5000, stashGold: 0, cLvl: 20 } + + const repairRes = manager.repairAllItems({ + equippedItems: equipped, + playerInventory, + purse, + difficulty: 0, + }) + expect(repairRes.ok).toBe(true) + expect(repairRes.repairedCount).toBe(1) + expect(repairRes.goldSpent).toBeGreaterThan(0) + expect(equipped.get('head')?.durability).toBe(12) + + // Deckard Cain Identify All + const unidRawFlags = 0 // not identified + const unidCap: Item = { + ...createDroppedItem(capBase, 'magic', { itemTypes: tables.itemTypes, ilvl: 10, dwInitSeed: 704 }), + rawFlags: unidRawFlags, + flags: decodeItemFlags(unidRawFlags), + identified: false, + } + playerInventory.add(unidCap) + + const idRes = manager.identifyAllItems({ + playerInventory, + purse, + cainRescued: true, + }) + expect(idRes.ok).toBe(true) + expect(idRes.identifiedCount).toBe(1) + expect(idRes.goldSpent).toBe(0) + expect(playerInventory.list()[0]?.item.flags?.identified).toBe(true) + }) +})