feat(vendor): implement 1.13c vendor inventory generator, pricing engine & town NPC UI (Fixes #481, #482, #483, #484, #485, #486)

This commit is contained in:
troytt 2026-09-26 05:47:49 +00:00
parent b7ccc448b4
commit 3b993cf04a
23 changed files with 8168 additions and 76 deletions

View File

@ -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<T>(path: string, method = 'GET', body?: unknown): Promise<T> {
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<MilestoneResp[]>('/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<MilestoneResp>('/milestones', 'POST', {
title: targetTitle,
description: milestoneDesc,
state: 'open',
})
console.log(`Created Milestone #${milestone.id}: ${milestone.title}`)
} else {
milestone = await giteaFetch<MilestoneResp>(`/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<IssueResp[]>(`/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<IssueResp>(`/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<IssueResp>('/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)
})

227
scripts/verify-vendor-ui.ts Normal file
View File

@ -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<void>(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<number, (res: any) => 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 = <T = any>(method: string, params: Record<string, unknown> = {}): Promise<T> =>
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 <T = any>(expression: string): Promise<T> => {
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<string> => {
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>(`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)
})

File diff suppressed because one or more lines are too long

View File

@ -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<Record<number, VendorItemColumns>> | 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<number, VendorItemColumns> = {}
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)

View File

@ -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<string, MonsterKind>
readonly superUniques: Map<string, SuperUnique>

View File

@ -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!
}

786
src/game/item-cost.ts Normal file
View File

@ -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<number> | 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<string>()
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<string>()
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<RolledItemProp>(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)
}

View File

@ -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)

View File

@ -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<Record<number, {
readonly min: number
readonly max: number
readonly magicMin: number
readonly magicMax: number
readonly magicLvl: number
}>> | 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) {

View File

@ -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<Record<number, VendorItemColumns>>
}
/**
@ -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<number, VendorItemColumns> = {}
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)

878
src/game/npc-table.ts Normal file
View File

@ -0,0 +1,878 @@
import type { SkillCostEntry as CanonicalSkillCostEntry } from '../data/canonical-vendor-data';
export interface ParsedTxtTable {
readonly headers: readonly string[];
readonly colIndex: ReadonlyMap<string, number>;
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<string, number>();
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 (`<Prefix>Min`, `<Prefix>Max`, `<Prefix>MagicMin`,
* `<Prefix>MagicMax`, `<Prefix>MagicLvl`) for a single row in Weapons.txt / Armor.txt / Misc.txt.
*/
export function parseVendorColumnsFromRow(
table: ParsedTxtTable,
row: string[],
): Readonly<Record<number, VendorItemColumns>> {
const result: Record<number, VendorItemColumns> = {};
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<string, NpcRecord>();
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<Record<VendorNpcId, string>> = {
[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<string, BookCostRecord>();
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<number, CanonicalSkillCostEntry>;
export function buildSkillCostMap(entries: readonly CanonicalSkillCostEntry[]): SkillCostMap {
const map = new Map<number, CanonicalSkillCostEntry>();
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<number, TownNpcServiceDescriptor>(
TOWN_NPC_DESCRIPTORS.map(d => [d.hcIdx, d]),
);
const DESCRIPTOR_BY_NAME = new Map<string, TownNpcServiceDescriptor>();
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);
}

View File

@ -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,

View File

@ -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<Record<number, readonly VendorItemCacheEntry[]>>
/** Permanent store items (`PermStoreItem == 1`) per VendorNpcId (0..16). */
readonly permCache: Readonly<Record<number, readonly VendorItemCacheEntry[]>>
}
let _cachedGlobalVendorCache: WeakMap<DropTables, GlobalVendorItemCache> = 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<number, VendorItemCacheEntry[]> = {}
const permCache: Record<number, VendorItemCacheEntry[]> = {}
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<string>()
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<Record<VendorTabKey, Inventory>>
readonly allItems: readonly Item[]
}
/**
* Places an item into the appropriate vendor tab (`armor`, `weapons1` -> overflow `weapons2`, `misc`).
*/
export function addItemToVendorTabs(
tabs: Readonly<Record<VendorTabKey, Inventory>>,
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<VendorTabKey, Inventory> = {
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<string>()
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,
}
}

980
src/game/vendor-service.ts Normal file
View File

@ -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<string, VendorStoreState>()
private readonly gambleStores = new Map<string, Inventory>()
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<number> | 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<number> | 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<number> | 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<number> | 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<number> | 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<number> | 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<string, Item>
readonly playerInventory: Inventory
readonly purse: GoldPurseState
readonly difficulty: 0 | 1 | 2
readonly completedQuests?: ReadonlySet<number> | 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<string, Item>
readonly playerInventory: Inventory
readonly difficulty: 0 | 1 | 2
readonly cLvl: number
readonly completedQuests?: ReadonlySet<number> | 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,
}
}
}

View File

@ -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<Record<number, VendorItemColumns>> | 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<number, VendorItemColumns> = {}
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)

View File

@ -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 }
}

View File

@ -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: <Level Name>`, 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: <Level Name>`, 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()

View File

@ -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 {

View File

@ -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 } : {}),
}
}

View File

@ -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<VendorTabId, GridPlacement[]> = {
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<string, 'completed' | 'active' | 'locked'>()
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' })

262
tests/item-cost.test.ts Normal file
View File

@ -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)
})
})

View File

@ -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<string>()
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<string>()
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)
})
})

View File

@ -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<string, Item>([['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)
})
})