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:
parent
b7ccc448b4
commit
3b993cf04a
|
|
@ -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)
|
||||||
|
})
|
||||||
|
|
@ -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
|
|
@ -12,6 +12,7 @@ import type { D2Table } from './acts.ts'
|
||||||
import { parseTable } from './acts.ts'
|
import { parseTable } from './acts.ts'
|
||||||
import type { MountedArchives } from '../mpq/mount.ts'
|
import type { MountedArchives } from '../mpq/mount.ts'
|
||||||
import type { ItemBase } from './items.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`. */
|
/** Canonical MPQ path of `Armor.txt`. */
|
||||||
export const ARMOR_TABLE_PATH = 'data\\global\\excel\\Armor.txt'
|
export const ARMOR_TABLE_PATH = 'data\\global\\excel\\Armor.txt'
|
||||||
|
|
@ -87,6 +88,14 @@ export interface ArmorBase extends ItemBase {
|
||||||
readonly damage: number
|
readonly damage: number
|
||||||
/** Optional AutoMagic group from 'auto prefix' column (e.g. 304 for Paladin shields, 305 for Shrunken Heads). */
|
/** Optional AutoMagic group from 'auto prefix' column (e.g. 304 for Paladin shields, 305 for Shrunken Heads). */
|
||||||
readonly autoPrefix?: number | undefined
|
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 dropsound = strAt(row, 'dropsound') || undefined
|
||||||
const dropsfxframe = numAt(row, 'dropsfxframe', 0) || 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 = {
|
const armor: ArmorBase = {
|
||||||
id: code,
|
id: code,
|
||||||
code,
|
code,
|
||||||
|
|
@ -257,6 +293,14 @@ export function parseArmorTable(table: D2Table): ArmorTable {
|
||||||
...(autoPrefix !== undefined ? { autoPrefix } : {}),
|
...(autoPrefix !== undefined ? { autoPrefix } : {}),
|
||||||
...(dropsound ? { dropsound } : {}),
|
...(dropsound ? { dropsound } : {}),
|
||||||
...(dropsfxframe !== undefined ? { dropsfxframe } : {}),
|
...(dropsfxframe !== undefined ? { dropsfxframe } : {}),
|
||||||
|
nightmareUpgrade,
|
||||||
|
hellUpgrade,
|
||||||
|
permStoreItem,
|
||||||
|
multibuy,
|
||||||
|
bitfield1,
|
||||||
|
quest,
|
||||||
|
questDiffCheck,
|
||||||
|
vendorColumns,
|
||||||
}
|
}
|
||||||
|
|
||||||
all.push(armor)
|
all.push(armor)
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,7 @@ import {
|
||||||
resolveRareNameEn,
|
resolveRareNameEn,
|
||||||
resolveMagicNameEn,
|
resolveMagicNameEn,
|
||||||
} from './tooltip-i18n.ts'
|
} from './tooltip-i18n.ts'
|
||||||
|
import type { NpcTable, BooksTable, SkillCostMap } from './npc-table.ts'
|
||||||
|
|
||||||
export {
|
export {
|
||||||
rollAutoMagic,
|
rollAutoMagic,
|
||||||
|
|
@ -181,6 +182,11 @@ export interface DropTables {
|
||||||
readonly autoMagic?: AutoMagicTable | undefined
|
readonly autoMagic?: AutoMagicTable | undefined
|
||||||
readonly properties?: PropertiesTable | undefined
|
readonly properties?: PropertiesTable | undefined
|
||||||
readonly itemStatCost?: ItemStatCostTable | 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 itemTypes: ItemTypeTable
|
||||||
readonly monsterKinds: Map<string, MonsterKind>
|
readonly monsterKinds: Map<string, MonsterKind>
|
||||||
readonly superUniques: Map<string, SuperUnique>
|
readonly superUniques: Map<string, SuperUnique>
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,15 @@ import {
|
||||||
RAW_SUPERUNIQUES,
|
RAW_SUPERUNIQUES,
|
||||||
RAW_DIFFICULTY_LEVELS,
|
RAW_DIFFICULTY_LEVELS,
|
||||||
} from '../data/canonical-drop-data.ts'
|
} 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 { parseTable } from './acts.ts'
|
||||||
import { parseTreasureClassTable, type TreasureClassNode } from './treasure-class.ts'
|
import { parseTreasureClassTable, type TreasureClassNode } from './treasure-class.ts'
|
||||||
import { parseWeaponsTable } from './weapons.ts'
|
import { parseWeaponsTable } from './weapons.ts'
|
||||||
|
|
@ -47,6 +56,19 @@ import type { DropTables } from './drop-pipeline.ts'
|
||||||
import type { ItemBase } from './items.ts'
|
import type { ItemBase } from './items.ts'
|
||||||
import { CANONICAL_AUTOMAGIC_TABLE } from './automagic.ts'
|
import { CANONICAL_AUTOMAGIC_TABLE } from './automagic.ts'
|
||||||
import { readMonsterKinds, readSuperUniques, type SuperUnique } from './monsters.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
|
let _cachedDropTables: DropTables | null = null
|
||||||
|
|
||||||
|
|
@ -103,6 +125,17 @@ export function getEmbeddedDropTables(): DropTables {
|
||||||
superUniques.set(su.id, su)
|
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 = {
|
_cachedDropTables = {
|
||||||
tcTable,
|
tcTable,
|
||||||
autoTcTable,
|
autoTcTable,
|
||||||
|
|
@ -117,6 +150,13 @@ export function getEmbeddedDropTables(): DropTables {
|
||||||
magicAffixes,
|
magicAffixes,
|
||||||
rareNames,
|
rareNames,
|
||||||
autoMagic: CANONICAL_AUTOMAGIC_TABLE,
|
autoMagic: CANONICAL_AUTOMAGIC_TABLE,
|
||||||
|
properties,
|
||||||
|
itemStatCost,
|
||||||
|
npcTable,
|
||||||
|
gambleCodes,
|
||||||
|
booksTable,
|
||||||
|
lowQualityItems,
|
||||||
|
skillCosts,
|
||||||
itemTypes,
|
itemTypes,
|
||||||
monsterKinds,
|
monsterKinds,
|
||||||
superUniques,
|
superUniques,
|
||||||
|
|
@ -126,3 +166,4 @@ export function getEmbeddedDropTables(): DropTables {
|
||||||
|
|
||||||
return _cachedDropTables!
|
return _cachedDropTables!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -61,6 +61,8 @@ export interface ItemTypeDefinition {
|
||||||
readonly beltable: boolean
|
readonly beltable: boolean
|
||||||
/** Character class code required for class-specific items (e.g., 'ama', 'bar', 'nec'). */
|
/** Character class code required for class-specific items (e.g., 'ama', 'bar', 'nec'). */
|
||||||
readonly class: string
|
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(),
|
bodyLoc2: cell(table, row, 'BodyLoc2').trim(),
|
||||||
beltable: cell(table, row, 'Beltable').trim() === '1',
|
beltable: cell(table, row, 'Beltable').trim() === '1',
|
||||||
class: cell(table, row, 'Class').trim(),
|
class: cell(table, row, 'Class').trim(),
|
||||||
|
storePage: cell(table, row, 'StorePage').trim(),
|
||||||
}
|
}
|
||||||
|
|
||||||
types.push(def)
|
types.push(def)
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,44 @@ export interface ItemBase {
|
||||||
readonly dropsound?: string | undefined
|
readonly dropsound?: string | undefined
|
||||||
/** Authentic drop animation frame at which dropsound triggers (from dropsfxframe column). */
|
/** Authentic drop animation frame at which dropsound triggers (from dropsfxframe column). */
|
||||||
readonly dropsfxframe?: number | undefined
|
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. */
|
/** One affix, read from a prefix or suffix row. */
|
||||||
|
|
@ -117,6 +155,7 @@ export interface ItemFlagsDecoded {
|
||||||
readonly simple?: boolean | undefined
|
readonly simple?: boolean | undefined
|
||||||
readonly ethereal?: boolean | undefined
|
readonly ethereal?: boolean | undefined
|
||||||
readonly personalized?: boolean | undefined
|
readonly personalized?: boolean | undefined
|
||||||
|
readonly gamble?: boolean | undefined
|
||||||
readonly runeword?: boolean | undefined
|
readonly runeword?: boolean | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -248,6 +287,7 @@ export function decodeItemFlags(rawFlags: number): ItemFlagsDecoded {
|
||||||
simple: (rawFlags & ItemFlag.SIMPLE) !== 0,
|
simple: (rawFlags & ItemFlag.SIMPLE) !== 0,
|
||||||
ethereal: (rawFlags & ItemFlag.ETHEREAL) !== 0,
|
ethereal: (rawFlags & ItemFlag.ETHEREAL) !== 0,
|
||||||
personalized: (rawFlags & ItemFlag.PERSONALIZED) !== 0,
|
personalized: (rawFlags & ItemFlag.PERSONALIZED) !== 0,
|
||||||
|
gamble: (rawFlags & ItemFlag.GAMBLE) !== 0,
|
||||||
runeword: (rawFlags & ItemFlag.RUNEWORD) !== 0,
|
runeword: (rawFlags & ItemFlag.RUNEWORD) !== 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -265,6 +305,7 @@ export function encodeItemFlags(flags: ItemFlagsDecoded): number {
|
||||||
if (flags.simple) raw |= ItemFlag.SIMPLE
|
if (flags.simple) raw |= ItemFlag.SIMPLE
|
||||||
if (flags.ethereal) raw |= ItemFlag.ETHEREAL
|
if (flags.ethereal) raw |= ItemFlag.ETHEREAL
|
||||||
if (flags.personalized) raw |= ItemFlag.PERSONALIZED
|
if (flags.personalized) raw |= ItemFlag.PERSONALIZED
|
||||||
|
if (flags.gamble) raw |= ItemFlag.GAMBLE
|
||||||
if (flags.runeword) raw |= ItemFlag.RUNEWORD
|
if (flags.runeword) raw |= ItemFlag.RUNEWORD
|
||||||
return raw >>> 0
|
return raw >>> 0
|
||||||
}
|
}
|
||||||
|
|
@ -379,7 +420,7 @@ export interface Item {
|
||||||
/** Current / rolled armor defense value (11 bits). */
|
/** Current / rolled armor defense value (11 bits). */
|
||||||
readonly defense?: number | undefined
|
readonly defense?: number | undefined
|
||||||
/** Current durability (8 bits). */
|
/** Current durability (8 bits). */
|
||||||
readonly durability?: number | undefined
|
durability?: number | undefined
|
||||||
/** Maximum durability (8 bits). */
|
/** Maximum durability (8 bits). */
|
||||||
readonly maxDurability?: number | undefined
|
readonly maxDurability?: number | undefined
|
||||||
/** Quantity (9 bits) if stackable (mirrors `stack`). */
|
/** Quantity (9 bits) if stackable (mirrors `stack`). */
|
||||||
|
|
@ -405,6 +446,16 @@ export interface Item {
|
||||||
/** Runeword bonus stat list (terminated by 0x1FF). */
|
/** Runeword bonus stat list (terminated by 0x1FF). */
|
||||||
readonly runewordStats?: readonly ItemStatEntry[] | undefined
|
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 ---
|
// --- Ear Item Data ---
|
||||||
/** Player ear details if IsEar flag is set. */
|
/** Player ear details if IsEar flag is set. */
|
||||||
readonly earData?: ItemEarData | undefined
|
readonly earData?: ItemEarData | undefined
|
||||||
|
|
@ -693,7 +744,30 @@ export class Inventory {
|
||||||
return this.items
|
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 {
|
get usedCells(): number {
|
||||||
return this.items.reduce((total, placed) => total + placed.item.invWidth * placed.item.invHeight, 0)
|
return this.items.reduce((total, placed) => total + placed.item.invWidth * placed.item.invHeight, 0)
|
||||||
}
|
}
|
||||||
|
|
@ -722,6 +796,25 @@ export class Inventory {
|
||||||
return true
|
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.
|
* Add an item, stacking onto an existing stack when possible.
|
||||||
*
|
*
|
||||||
|
|
@ -777,12 +870,14 @@ export class Inventory {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove an item by its placement.
|
* Remove an item by its placement or cell coordinates (x, y).
|
||||||
*
|
|
||||||
* @param placed - the placement to clear.
|
|
||||||
* @returns true when it was present.
|
|
||||||
*/
|
*/
|
||||||
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)
|
const index = this.items.indexOf(placed)
|
||||||
if (index === -1) return false
|
if (index === -1) return false
|
||||||
for (let row = placed.y; row < placed.y + placed.item.invHeight; row += 1) {
|
for (let row = placed.y; row < placed.y + placed.item.invHeight; row += 1) {
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import type { D2Table } from './acts.ts'
|
||||||
import { cell, parseTable } from './acts.ts'
|
import { cell, parseTable } from './acts.ts'
|
||||||
import type { ItemBase } from './items.ts'
|
import type { ItemBase } from './items.ts'
|
||||||
import type { MountedArchives } from '../mpq/mount.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. */
|
/** File path of `Misc.txt` inside MPQ archives. */
|
||||||
export const MISC_TABLE_PATH = 'data\\global\\excel\\Misc.txt'
|
export const MISC_TABLE_PATH = 'data\\global\\excel\\Misc.txt'
|
||||||
|
|
@ -88,6 +89,14 @@ export interface MiscBase extends ItemBase {
|
||||||
readonly damage: number
|
readonly damage: number
|
||||||
/** Armor rating (0 for misc items). */
|
/** Armor rating (0 for misc items). */
|
||||||
readonly defense: number
|
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 rawDropFrame = cell(table, row, 'dropsfxframe') || cell(table, row, 'DropSfxFrame')
|
||||||
const dropsfxframe = rawDropFrame.trim() !== '' ? Number(rawDropFrame) : undefined
|
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 = {
|
const item: MiscBase = {
|
||||||
id: code,
|
id: code,
|
||||||
code,
|
code,
|
||||||
|
|
@ -254,6 +290,14 @@ export function parseMiscTable(input: D2Table | Uint8Array | string): MiscTable
|
||||||
defense,
|
defense,
|
||||||
...(dropsound ? { dropsound } : {}),
|
...(dropsound ? { dropsound } : {}),
|
||||||
...(dropsfxframe !== undefined && !Number.isNaN(dropsfxframe) ? { dropsfxframe } : {}),
|
...(dropsfxframe !== undefined && !Number.isNaN(dropsfxframe) ? { dropsfxframe } : {}),
|
||||||
|
nightmareUpgrade,
|
||||||
|
hellUpgrade,
|
||||||
|
permStoreItem,
|
||||||
|
multibuy,
|
||||||
|
bitfield1,
|
||||||
|
quest,
|
||||||
|
questDiffCheck,
|
||||||
|
vendorColumns,
|
||||||
}
|
}
|
||||||
|
|
||||||
all.push(item)
|
all.push(item)
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -74,6 +74,8 @@ export interface ItemStatCostRecord {
|
||||||
readonly dgrpStr2: string
|
readonly dgrpStr2: string
|
||||||
/** Value precision shift. */
|
/** Value precision shift. */
|
||||||
readonly valShift: number
|
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. */
|
/** Price divide factor. */
|
||||||
readonly divide: number
|
readonly divide: number
|
||||||
/** Price multiply factor. */
|
/** Price multiply factor. */
|
||||||
|
|
@ -306,6 +308,7 @@ export function parseItemStatCostTable(table: D2Table): ItemStatCostTable {
|
||||||
const dgrpStr2 = getCell(table, raw, 'dgrpstr2')
|
const dgrpStr2 = getCell(table, raw, 'dgrpstr2')
|
||||||
|
|
||||||
const valShift = getNum(table, raw, 'ValShift')
|
const valShift = getNum(table, raw, 'ValShift')
|
||||||
|
const encode = getNum(table, raw, 'Encode')
|
||||||
const divide = getNum(table, raw, 'Divide')
|
const divide = getNum(table, raw, 'Divide')
|
||||||
const multiply = getNum(table, raw, 'Multiply')
|
const multiply = getNum(table, raw, 'Multiply')
|
||||||
const add = getNum(table, raw, 'Add')
|
const add = getNum(table, raw, 'Add')
|
||||||
|
|
@ -340,6 +343,7 @@ export function parseItemStatCostTable(table: D2Table): ItemStatCostTable {
|
||||||
dgrpStrNeg,
|
dgrpStrNeg,
|
||||||
dgrpStr2,
|
dgrpStr2,
|
||||||
valShift,
|
valShift,
|
||||||
|
encode,
|
||||||
divide,
|
divide,
|
||||||
multiply,
|
multiply,
|
||||||
add,
|
add,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,7 @@ import type { ItemBase } from './items.ts'
|
||||||
import type { D2Table } from './acts.ts'
|
import type { D2Table } from './acts.ts'
|
||||||
import { parseTable } from './acts.ts'
|
import { parseTable } from './acts.ts'
|
||||||
import type { MountedArchives } from '../mpq/mount.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. */
|
/** File path of Weapons.txt inside MPQ archives. */
|
||||||
export const WEAPONS_TABLE_PATH = 'data\\global\\excel\\Weapons.txt'
|
export const WEAPONS_TABLE_PATH = 'data\\global\\excel\\Weapons.txt'
|
||||||
|
|
@ -116,6 +117,14 @@ export interface WeaponBase extends ItemBase {
|
||||||
readonly dropsound?: string | undefined
|
readonly dropsound?: string | undefined
|
||||||
/** Authentic frame at which dropsound triggers (from dropsfxframe column). */
|
/** Authentic frame at which dropsound triggers (from dropsfxframe column). */
|
||||||
readonly dropsfxframe?: number | undefined
|
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 dropsound = getCell(row, 'dropsound') || undefined
|
||||||
const dropsfxframe = parseOptionalNumber(getCell(row, 'dropsfxframe'))
|
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 = {
|
const weapon: WeaponBase = {
|
||||||
id: code,
|
id: code,
|
||||||
code,
|
code,
|
||||||
|
|
@ -334,6 +370,14 @@ export function parseWeaponsTable(input: D2Table | Uint8Array | string): WeaponT
|
||||||
...(autoPrefix !== undefined && autoPrefix > 0 ? { autoPrefix } : {}),
|
...(autoPrefix !== undefined && autoPrefix > 0 ? { autoPrefix } : {}),
|
||||||
...(dropsound ? { dropsound } : {}),
|
...(dropsound ? { dropsound } : {}),
|
||||||
...(dropsfxframe !== undefined ? { dropsfxframe } : {}),
|
...(dropsfxframe !== undefined ? { dropsfxframe } : {}),
|
||||||
|
nightmareUpgrade,
|
||||||
|
hellUpgrade,
|
||||||
|
permStoreItem,
|
||||||
|
multibuy,
|
||||||
|
bitfield1,
|
||||||
|
quest,
|
||||||
|
questDiffCheck,
|
||||||
|
vendorColumns,
|
||||||
}
|
}
|
||||||
|
|
||||||
all.push(weapon)
|
all.push(weapon)
|
||||||
|
|
|
||||||
|
|
@ -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 type { DropTables } from '../game/drop-pipeline.ts'
|
||||||
import { getEmbeddedDropTables } from '../game/embedded-drop-tables.ts'
|
import { getEmbeddedDropTables } from '../game/embedded-drop-tables.ts'
|
||||||
import { buildNpcDef, hasPackedSprite } from '../game/npc.ts'
|
import { buildNpcDef, hasPackedSprite } from '../game/npc.ts'
|
||||||
|
import { getTownNpcDescriptorByName } from '../game/npc-table.ts'
|
||||||
import { ADDITIVE_OBJECT_TOKENS } from '../game/objects.ts'
|
import { ADDITIVE_OBJECT_TOKENS } from '../game/objects.ts'
|
||||||
import { npcDialog } from '../game/quests.ts'
|
import { npcDialog } from '../game/quests.ts'
|
||||||
import type { NpcDef } 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 {
|
handleLeftClickWorld(worldX: number, worldY: number): void {
|
||||||
const player = this.engine.world.player
|
const player = this.engine.world.player
|
||||||
|
|
||||||
|
|
@ -3745,10 +3785,7 @@ export class SceneMouseController {
|
||||||
if (distToPlayer <= 80) { // talkRadius = 80
|
if (distToPlayer <= 80) { // talkRadius = 80
|
||||||
this.navTarget = null
|
this.navTarget = null
|
||||||
this.pendingInteraction = null
|
this.pendingInteraction = null
|
||||||
player.facing = facingOf(clickedNpc.x - player.x, clickedNpc.y - player.y)
|
this.interactWithNpc(clickedNpc)
|
||||||
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] ?? ''}`
|
|
||||||
} else {
|
} else {
|
||||||
;(this.engine as any).dialog = null
|
;(this.engine as any).dialog = null
|
||||||
this.pendingInteraction = { kind: 'npc', npc: clickedNpc }
|
this.pendingInteraction = { kind: 'npc', npc: clickedNpc }
|
||||||
|
|
@ -3982,10 +4019,7 @@ export class SceneMouseController {
|
||||||
if (dist <= 80) {
|
if (dist <= 80) {
|
||||||
this.navTarget = null
|
this.navTarget = null
|
||||||
this.pendingInteraction = null
|
this.pendingInteraction = null
|
||||||
player.facing = facingOf(npc.x - player.x, npc.y - player.y)
|
this.interactWithNpc(npc)
|
||||||
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] ?? ''}`
|
|
||||||
} else {
|
} else {
|
||||||
this.navTarget = { x: npc.x, y: npc.y }
|
this.navTarget = { x: npc.x, y: npc.y }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import {
|
||||||
createStarterProfileForClass,
|
createStarterProfileForClass,
|
||||||
type ClassProfileState,
|
type ClassProfileState,
|
||||||
} from '../game/class-starter-profiles.ts'
|
} from '../game/class-starter-profiles.ts'
|
||||||
|
import type { TownNpcServiceDescriptor, VendorNpcId } from '../game/npc-table.ts'
|
||||||
|
|
||||||
export interface PublishedHudState {
|
export interface PublishedHudState {
|
||||||
ready: boolean
|
ready: boolean
|
||||||
|
|
@ -71,6 +72,11 @@ export interface PublishedHudState {
|
||||||
stamina: number
|
stamina: number
|
||||||
maxStamina: number
|
maxStamina: number
|
||||||
docking: DockingLayout
|
docking: DockingLayout
|
||||||
|
activeVendor?: string | null
|
||||||
|
vendorMode?: 'trade' | 'gamble'
|
||||||
|
vendorTab?: string
|
||||||
|
vendorItemsCount?: number
|
||||||
|
npcMenuOpen?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
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 { CHAR_PANEL_ORIGIN } from './character-sheet.ts'
|
||||||
export { INV_PANEL_ORIGIN, INV_GRID_ORIGIN } from './inventory.ts'
|
export { INV_PANEL_ORIGIN, INV_GRID_ORIGIN } from './inventory.ts'
|
||||||
export { SKILL_PANEL_ORIGIN } from './skill-tree-panel.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.
|
* Computes widescreen panel docking layout metrics, margins, and central vision corridor.
|
||||||
|
|
@ -458,6 +464,39 @@ export class HudManager {
|
||||||
this.state.ready = true
|
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`).
|
* Toggle a Left-Half Panel (`char`, `quest`, `waypoint`, `stash`, `cube`, `vendor`).
|
||||||
* Strictly follows D2Client.dll mutual exclusion on the Left Dock (`0..400`).
|
* Strictly follows D2Client.dll mutual exclusion on the Left Dock (`0..400`).
|
||||||
|
|
@ -466,10 +505,14 @@ export class HudManager {
|
||||||
if (this.leftPanel === kind) {
|
if (this.leftPanel === kind) {
|
||||||
this.leftPanel = 'none'
|
this.leftPanel = 'none'
|
||||||
this.charSheet.visible = false
|
this.charSheet.visible = false
|
||||||
|
this.worldPanels.repairCursorMode = false
|
||||||
} else {
|
} else {
|
||||||
this.leftPanel = kind
|
this.leftPanel = kind
|
||||||
this.charSheet.visible = kind === 'char'
|
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')
|
this.toggleRightPanel('inv')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -498,7 +541,8 @@ export class HudManager {
|
||||||
this.leftPanel !== 'none' ||
|
this.leftPanel !== 'none' ||
|
||||||
this.rightPanel !== 'none' ||
|
this.rightPanel !== 'none' ||
|
||||||
this.belt.lockedOpen ||
|
this.belt.lockedOpen ||
|
||||||
this.hotkeys.openPopup !== null
|
this.hotkeys.openPopup !== null ||
|
||||||
|
this.worldPanels.npcMenu !== null
|
||||||
this.leftPanel = 'none'
|
this.leftPanel = 'none'
|
||||||
this.rightPanel = 'none'
|
this.rightPanel = 'none'
|
||||||
this.charSheet.visible = false
|
this.charSheet.visible = false
|
||||||
|
|
@ -506,6 +550,8 @@ export class HudManager {
|
||||||
this.skillTree.visible = false
|
this.skillTree.visible = false
|
||||||
this.belt.lockedOpen = false
|
this.belt.lockedOpen = false
|
||||||
this.hotkeys.openPopup = null
|
this.hotkeys.openPopup = null
|
||||||
|
this.worldPanels.npcMenu = null
|
||||||
|
this.worldPanels.repairCursorMode = false
|
||||||
this.syncPublishedState()
|
this.syncPublishedState()
|
||||||
return anyOpen
|
return anyOpen
|
||||||
}
|
}
|
||||||
|
|
@ -553,6 +599,7 @@ export class HudManager {
|
||||||
const docking = this.getDockingLayout()
|
const docking = this.getDockingLayout()
|
||||||
const { marginW } = docking
|
const { marginW } = docking
|
||||||
if (logicalX < -marginW || logicalX > 800 + marginW || logicalY < 0 || logicalY > 600) return false
|
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 (logicalY >= 540 && logicalX >= 0 && logicalX <= 800) return true
|
||||||
if (logicalX >= 0 && logicalX <= 117 && logicalY >= 496) return true
|
if (logicalX >= 0 && logicalX <= 117 && logicalY >= 496) return true
|
||||||
if (logicalX >= 683 && logicalX <= 800 && 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.hotkeys.handleMouseMove(pt.x, pt.y)
|
||||||
this.controlBar.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 docking = this.getDockingLayout()
|
||||||
const deltaLeft = -docking.marginW
|
const deltaLeft = -docking.marginW
|
||||||
const deltaRight = docking.marginW
|
const deltaRight = docking.marginW
|
||||||
|
|
@ -657,13 +708,19 @@ export class HudManager {
|
||||||
this.inventory.hoveredItem.x += deltaRight
|
this.inventory.hoveredItem.x += deltaRight
|
||||||
}
|
}
|
||||||
this.skillTree.handleMouseMove(pt.x - deltaRight, pt.y)
|
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)
|
this.worldPanels.handleMouseMove(pt.x - deltaLeft, pt.y)
|
||||||
if (this.worldPanels.hoveredStashItem) {
|
if (this.leftPanel === 'stash' && this.worldPanels.hoveredStashItem) {
|
||||||
this.inventory.hoveredItem = {
|
this.inventory.hoveredItem = {
|
||||||
...this.worldPanels.hoveredStashItem,
|
...this.worldPanels.hoveredStashItem,
|
||||||
x: this.worldPanels.hoveredStashItem.x + deltaLeft,
|
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.preventDefault()
|
||||||
e.stopPropagation()
|
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
|
// Right-click on belt slot drinks potion
|
||||||
const beltHit = this.belt.slotAtPoint(pt.x, pt.y)
|
const beltHit = this.belt.slotAtPoint(pt.x, pt.y)
|
||||||
if (beltHit) {
|
if (beltHit) {
|
||||||
|
|
@ -779,6 +854,7 @@ export class HudManager {
|
||||||
if (pt.x >= 400 + deltaRight) {
|
if (pt.x >= 400 + deltaRight) {
|
||||||
const rightX = pt.x - deltaRight
|
const rightX = pt.x - deltaRight
|
||||||
if (this.rightPanel === 'inv') {
|
if (this.rightPanel === 'inv') {
|
||||||
|
// Stash quick transfer
|
||||||
if (this.leftPanel === 'stash' && (e.shiftKey || e.button === 2)) {
|
if (this.leftPanel === 'stash' && (e.shiftKey || e.button === 2)) {
|
||||||
if (
|
if (
|
||||||
rightX >= INV_GRID_ORIGIN.x &&
|
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, {
|
this.inventory.handleClick(rightX, pt.y, {
|
||||||
onOpenCube: () => this.toggleLeftPanel('cube'),
|
onOpenCube: () => this.toggleLeftPanel('cube'),
|
||||||
onCastTownPortal: () => this.callbacks.onCastTownPortal?.(),
|
onCastTownPortal: () => this.callbacks.onCastTownPortal?.(),
|
||||||
|
|
@ -901,14 +1015,19 @@ export class HudManager {
|
||||||
this.state.stamina = Math.round(this.stamina)
|
this.state.stamina = Math.round(this.stamina)
|
||||||
this.state.maxStamina = Math.round(this.maxStamina)
|
this.state.maxStamina = Math.round(this.maxStamina)
|
||||||
this.state.docking = this.getDockingLayout()
|
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.
|
* Tick stamina drain/recovery and draw the complete 800x600 Diablo II HUD.
|
||||||
*/
|
*/
|
||||||
render(
|
render(
|
||||||
nowMs: number,
|
nowMs: number = performance.now(),
|
||||||
playerMoving: boolean,
|
playerMoving: boolean = false,
|
||||||
): void {
|
): void {
|
||||||
if (playerMoving && this.isRunning) {
|
if (playerMoving && this.isRunning) {
|
||||||
this.stamina = Math.max(0, this.stamina - 0.12)
|
this.stamina = Math.max(0, this.stamina - 0.12)
|
||||||
|
|
@ -1094,21 +1213,26 @@ export class HudManager {
|
||||||
// 7. Top-Center Monster Target Health Bar & Plaque
|
// 7. Top-Center Monster Target Health Bar & Plaque
|
||||||
this.worldPanels.drawMonsterTargetBar(ctx, this.targetMonster, this.font)
|
this.worldPanels.drawMonsterTargetBar(ctx, this.targetMonster, this.font)
|
||||||
|
|
||||||
// 8. Area Entry Gothic Banner (`Entering: <Level Name>`, only when no split panels are open)
|
// 8. Town NPC Stone Interaction Menu (`npcmenu.cpp`)
|
||||||
if (this.leftPanel === 'none' && this.rightPanel === 'none') {
|
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)
|
this.worldPanels.drawAreaBanner(ctx, nowMs, this.font)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 9. Hover Item Tooltip (Inventory / Equipment / Stash)
|
// 10. Hover Item Tooltip (Inventory / Equipment / Stash / Vendor)
|
||||||
if (
|
if (
|
||||||
(this.rightPanel === 'inv' || this.leftPanel === 'stash') &&
|
(this.rightPanel === 'inv' || this.leftPanel === 'stash' || this.leftPanel === 'vendor') &&
|
||||||
this.inventory.hoveredItem &&
|
this.inventory.hoveredItem &&
|
||||||
!this.inventory.cursorItem
|
!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)
|
this.renderCursor(ctx, nowMs)
|
||||||
|
|
||||||
ctx.restore()
|
ctx.restore()
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
|
|
||||||
import type { D2ColorCode, D2FontRenderer } from './font.ts'
|
import type { D2ColorCode, D2FontRenderer } from './font.ts'
|
||||||
import { BAKED_UI_MANIFEST, type SpriteRect } from './baked-ui-meta.ts'
|
import { BAKED_UI_MANIFEST, type SpriteRect } from './baked-ui-meta.ts'
|
||||||
|
import type { Item } from '../game/items.ts'
|
||||||
|
|
||||||
export type { SpriteRect }
|
export type { SpriteRect }
|
||||||
export { itemToUiInventoryItem, resolveAllowedSlots, resolveItemInvFile } from './item-bridge.ts'
|
export { itemToUiInventoryItem, resolveAllowedSlots, resolveItemInvFile } from './item-bridge.ts'
|
||||||
|
|
@ -60,6 +61,12 @@ export interface UiInventoryItem {
|
||||||
readonly flippyFile?: string | undefined
|
readonly flippyFile?: string | undefined
|
||||||
readonly dropSound?: string | undefined
|
readonly dropSound?: string | undefined
|
||||||
readonly dropSfxFrame?: number | 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 {
|
export interface GridPlacement {
|
||||||
|
|
|
||||||
|
|
@ -398,14 +398,17 @@ export function itemToUiInventoryItem(
|
||||||
)
|
)
|
||||||
|
|
||||||
// 5. Tooltip & Formatted Stats
|
// 5. Tooltip & Formatted Stats
|
||||||
|
const isGambleMystery = Boolean(rawItem.isGambleMystery || rawItem.flags?.gamble)
|
||||||
const tt = formatItemTooltip({ ...rawItem, base } as Item, 'zh')
|
const tt = formatItemTooltip({ ...rawItem, base } as Item, 'zh')
|
||||||
let stats: { text: string; color?: D2ColorCode }[] = (tt.lines || []).map(l => ({
|
let stats: { text: string; color?: D2ColorCode }[] = isGambleMystery
|
||||||
text: l.text,
|
? [{ text: '未鉴定 (Unidentified Gamble Item)', color: 'red' }]
|
||||||
color: (l.color === 'instruction' ? 'white' : l.color) as D2ColorCode,
|
: (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
|
// 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)
|
const fallbackStats = Object.entries(rawItem.stats)
|
||||||
.filter(([k, v]) => typeof v === 'number' && v !== 0 && k !== 'gold' && k !== 'value')
|
.filter(([k, v]) => typeof v === 'number' && v !== 0 && k !== 'gold' && k !== 'value')
|
||||||
.map(([k, v]) => ({
|
.map(([k, v]) => ({
|
||||||
|
|
@ -419,18 +422,22 @@ export function itemToUiInventoryItem(
|
||||||
|
|
||||||
// Damage evaluation
|
// Damage evaluation
|
||||||
let damage: string | undefined = undefined
|
let damage: string | undefined = undefined
|
||||||
if (tt.oneHandDamage && tt.twoHandDamage) {
|
if (!isGambleMystery) {
|
||||||
damage = `${tt.oneHandDamage.min} - ${tt.oneHandDamage.max} / 双手: ${tt.twoHandDamage.min} - ${tt.twoHandDamage.max}`
|
if (tt.oneHandDamage && tt.twoHandDamage) {
|
||||||
} else if (tt.twoHandDamage) {
|
damage = `${tt.oneHandDamage.min} - ${tt.oneHandDamage.max} / 双手: ${tt.twoHandDamage.min} - ${tt.twoHandDamage.max}`
|
||||||
damage = `双手伤害: ${tt.twoHandDamage.min} - ${tt.twoHandDamage.max}`
|
} else if (tt.twoHandDamage) {
|
||||||
} else if (tt.oneHandDamage) {
|
damage = `双手伤害: ${tt.twoHandDamage.min} - ${tt.twoHandDamage.max}`
|
||||||
damage = `${tt.oneHandDamage.min} - ${tt.oneHandDamage.max}`
|
} else if (tt.oneHandDamage) {
|
||||||
} else if (base.kind === 'weapon' && (base as any).damage !== undefined && (base as any).damage > 0) {
|
damage = `${tt.oneHandDamage.min} - ${tt.oneHandDamage.max}`
|
||||||
damage = String((base as any).damage)
|
} else if (base.kind === 'weapon' && (base as any).damage !== undefined && (base as any).damage > 0) {
|
||||||
|
damage = String((base as any).damage)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Durability evaluation
|
// Durability evaluation
|
||||||
const durability = tt.durability
|
const durability = isGambleMystery
|
||||||
|
? undefined
|
||||||
|
: tt.durability
|
||||||
? { current: tt.durability.current, max: tt.durability.max }
|
? { current: tt.durability.current, max: tt.durability.max }
|
||||||
: rawItem.durability !== undefined && rawItem.maxDurability !== undefined
|
: rawItem.durability !== undefined && rawItem.maxDurability !== undefined
|
||||||
? { current: rawItem.durability, max: rawItem.maxDurability }
|
? { current: rawItem.durability, max: rawItem.maxDurability }
|
||||||
|
|
@ -439,12 +446,13 @@ export function itemToUiInventoryItem(
|
||||||
const rolledSockProp = Array.isArray(rawItem.rolledProps)
|
const rolledSockProp = Array.isArray(rawItem.rolledProps)
|
||||||
? rawItem.rolledProps.reduce((sum, p) => ((p.code || '').trim().toLowerCase() === 'sock' ? sum + p.value : sum), 0)
|
? rawItem.rolledProps.reduce((sum, p) => ((p.code || '').trim().toLowerCase() === 'sock' ? sum + p.value : sum), 0)
|
||||||
: 0
|
: 0
|
||||||
const sockets =
|
const sockets = isGambleMystery
|
||||||
(rawItem as any).sockets ??
|
? undefined
|
||||||
rawItem.totalSockets ??
|
: (rawItem as any).sockets ??
|
||||||
rawItem.socketedCount ??
|
rawItem.totalSockets ??
|
||||||
(rolledSockProp > 0 ? rolledSockProp : undefined)
|
rawItem.socketedCount ??
|
||||||
const ethereal = Boolean((tt as any).ethereal || (rawItem as any).ethereal || rawItem.flags?.ethereal)
|
(rolledSockProp > 0 ? rolledSockProp : undefined)
|
||||||
|
const ethereal = !isGambleMystery && Boolean((tt as any).ethereal || (rawItem as any).ethereal || rawItem.flags?.ethereal)
|
||||||
|
|
||||||
const id = rawItem.uniqueId
|
const id = rawItem.uniqueId
|
||||||
? `item-${code}-${rawItem.uniqueId}`
|
? `item-${code}-${rawItem.uniqueId}`
|
||||||
|
|
@ -453,6 +461,7 @@ export function itemToUiInventoryItem(
|
||||||
const flippyFile = (rawItem as any).flippyFile ?? (base as any).flippyfile
|
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 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 dropSfxFrame = (rawItem as any).dropSfxFrame ?? (rawItem as any).dropsfxframe ?? (base as any).dropsfxframe
|
||||||
|
const permStore = Boolean(rawItem.permStore ?? base.permStoreItem)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
|
|
@ -461,26 +470,44 @@ export function itemToUiInventoryItem(
|
||||||
name: rawItem.name || baseEn,
|
name: rawItem.name || baseEn,
|
||||||
nameZh,
|
nameZh,
|
||||||
baseNameZh,
|
baseNameZh,
|
||||||
quality,
|
quality: isGambleMystery ? 'normal' : quality,
|
||||||
invWidth: rawItem.invWidth ?? base.invWidth ?? 1,
|
invWidth: rawItem.invWidth ?? base.invWidth ?? 1,
|
||||||
invHeight: rawItem.invHeight ?? base.invHeight ?? 1,
|
invHeight: rawItem.invHeight ?? base.invHeight ?? 1,
|
||||||
allowedSlots,
|
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 } : {}),
|
...(damage !== undefined ? { damage } : {}),
|
||||||
...(tt.reqLevel !== undefined ? { reqLevel: tt.reqLevel } : (rawItem.level || base.level ? { reqLevel: rawItem.level || base.level } : {})),
|
...(!isGambleMystery && tt.reqLevel !== undefined
|
||||||
...(tt.reqStr !== undefined ? { reqStr: tt.reqStr } : ((base as any).reqstr ? { reqStr: (base as any).reqstr } : {})),
|
? { 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,
|
stats,
|
||||||
...(invtransform ? { invtransform } : {}),
|
...(!isGambleMystery && invtransform ? { invtransform } : {}),
|
||||||
...(chrtransform ? { chrtransform } : {}),
|
...(!isGambleMystery && chrtransform ? { chrtransform } : {}),
|
||||||
...(durability ? { durability } : {}),
|
...(durability ? { durability } : {}),
|
||||||
...(sockets !== undefined && sockets > 0 ? { sockets } : {}),
|
...(sockets !== undefined && sockets > 0 ? { sockets } : {}),
|
||||||
...(ethereal ? { ethereal: true } : {}),
|
...(ethereal ? { ethereal: true } : {}),
|
||||||
...((rawItem as any).speedText || tt.speedText ? { speedText: (rawItem as any).speedText || tt.speedText } : {}),
|
...(!isGambleMystery && ((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 } : {}),
|
...(!isGambleMystery && ((rawItem as any).runewordRunes || tt.runewordRunes) ? { runewordRunes: (rawItem as any).runewordRunes || tt.runewordRunes } : {}),
|
||||||
...(tt.setPieces ? { setPieces: tt.setPieces } : {}),
|
...(!isGambleMystery && tt.setPieces ? { setPieces: tt.setPieces } : {}),
|
||||||
...(tt.setBonuses ? { setBonuses: tt.setBonuses.map(b => ({ text: b.text, color: (b.color ?? 'green') as D2ColorCode })) } : {}),
|
...(!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 flippyFile === 'string' && flippyFile.length > 0 ? { flippyFile } : {}),
|
||||||
...(typeof dropSound === 'string' && dropSound.length > 0 ? { dropSound } : {}),
|
...(typeof dropSound === 'string' && dropSound.length > 0 ? { dropSound } : {}),
|
||||||
...(typeof dropSfxFrame === 'number' && Number.isFinite(dropSfxFrame) ? { dropSfxFrame } : {}),
|
...(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 } : {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,19 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { D2ColorCode, D2FontRenderer } from './font.ts'
|
import type { D2ColorCode, D2FontRenderer } from './font.ts'
|
||||||
import type { GridPlacement, InventoryPanel, UiInventoryItem } from './inventory.ts'
|
import { findFreeGridSlot, type EquipSlotId, type GridPlacement, type InventoryPanel, type UiInventoryItem } from './inventory.ts'
|
||||||
import { resolveItemSpriteRect } from './inventory.ts'
|
import { resolveItemSpriteRect, itemToUiInventoryItem } from './inventory.ts'
|
||||||
import { BAKED_UI_MANIFEST } from './baked-ui-meta.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'
|
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_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 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 {
|
import {
|
||||||
CANONICAL_QUESTS_BY_ACT,
|
CANONICAL_QUESTS_BY_ACT,
|
||||||
type CanonicalQuestItem,
|
type CanonicalQuestItem,
|
||||||
|
|
@ -177,8 +208,419 @@ export class WorldPanelsHud {
|
||||||
y: number
|
y: number
|
||||||
dialogLines: readonly string[]
|
dialogLines: readonly string[]
|
||||||
talking: boolean
|
talking: boolean
|
||||||
|
descriptor?: TownNpcServiceDescriptor | undefined
|
||||||
|
options?: readonly NpcMenuOption[] | undefined
|
||||||
|
hoveredOptionIdx?: number | null | undefined
|
||||||
} | null = null
|
} | 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' */
|
/** Runtime quest status overrides: questId -> 'completed' | 'active' | 'locked' */
|
||||||
private questStatuses = new Map<string, 'completed' | 'active' | 'locked'>()
|
private questStatuses = new Map<string, 'completed' | 'active' | 'locked'>()
|
||||||
private cachedQuestsAtlasImg: HTMLImageElement | null = null
|
private cachedQuestsAtlasImg: HTMLImageElement | null = null
|
||||||
|
|
@ -435,6 +877,7 @@ export class WorldPanelsHud {
|
||||||
|
|
||||||
handleMouseMove(logicalX: number, logicalY: number): void {
|
handleMouseMove(logicalX: number, logicalY: number): void {
|
||||||
this.hoveredStashItem = null
|
this.hoveredStashItem = null
|
||||||
|
this.hoveredVendorItem = null
|
||||||
this.hoveredWaypointIdx = null
|
this.hoveredWaypointIdx = null
|
||||||
|
|
||||||
const ox = 80
|
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(
|
handleLeftDockClick(
|
||||||
|
|
@ -488,8 +1028,13 @@ export class WorldPanelsHud {
|
||||||
const ox = 80
|
const ox = 80
|
||||||
const oy = 60
|
const oy = 60
|
||||||
|
|
||||||
// Close button (`x = ox + 272, y = oy + 388` or `ox + 18, oy + 385`)
|
// 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 + 58 || logicalX >= ox + 260)) {
|
if (
|
||||||
|
logicalY >= oy + 380 &&
|
||||||
|
logicalY <= oy + 422 &&
|
||||||
|
(logicalX >= ox + 260 || (kind !== 'vendor' && logicalX <= ox + 58))
|
||||||
|
) {
|
||||||
|
this.repairCursorMode = false
|
||||||
callbacks.onClose()
|
callbacks.onClose()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
@ -528,8 +1073,6 @@ export class WorldPanelsHud {
|
||||||
if (logicalX >= ox + 17 && logicalX <= ox + 298 && logicalY >= wy && logicalY <= wy + 32) {
|
if (logicalX >= ox + 17 && logicalX <= ox + 298 && logicalY >= wy && logicalY <= wy + 32) {
|
||||||
const wp = list[i]!
|
const wp = list[i]!
|
||||||
if (!this.isWaypointUnlocked(wp)) return true
|
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
|
if (this.isCurrentWaypoint(wp)) return true
|
||||||
callbacks.onWaypointTeleport(wp.act, wp.slug, wp.levelId)
|
callbacks.onWaypointTeleport(wp.act, wp.slug, wp.levelId)
|
||||||
callbacks.onClose()
|
callbacks.onClose()
|
||||||
|
|
@ -604,6 +1147,74 @@ export class WorldPanelsHud {
|
||||||
}
|
}
|
||||||
return true
|
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
|
return true
|
||||||
}
|
}
|
||||||
|
|
@ -727,15 +1338,6 @@ export class WorldPanelsHud {
|
||||||
ctx.strokeStyle = isHovered ? '#e8c26b' : '#5a4a32'
|
ctx.strokeStyle = isHovered ? '#e8c26b' : '#5a4a32'
|
||||||
ctx.strokeRect(ox + 17.5, wy + 0.5, 280, 30)
|
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) {
|
if (isUnlocked) {
|
||||||
const iconFrameIdx = isCurrent ? (isHovered ? 1 : 0) : (isHovered ? 4 : 3)
|
const iconFrameIdx = isCurrent ? (isHovered ? 1 : 0) : (isHovered ? 4 : 3)
|
||||||
const iconSx = iconFrameIdx * 30
|
const iconSx = iconFrameIdx * 30
|
||||||
|
|
@ -743,7 +1345,6 @@ export class WorldPanelsHud {
|
||||||
if (assets.waypointIconsImg && assets.waypointIconsImg.complete && assets.waypointIconsImg.naturalWidth > 0) {
|
if (assets.waypointIconsImg && assets.waypointIconsImg.complete && assets.waypointIconsImg.naturalWidth > 0) {
|
||||||
ctx.drawImage(assets.waypointIconsImg, iconSx, 0, 30, 30, ox + 17, wy + 1, 30, 30)
|
ctx.drawImage(assets.waypointIconsImg, iconSx, 0, 30, 30, ox + 17, wy + 1, 30, 30)
|
||||||
} else {
|
} else {
|
||||||
// Fallback indicator
|
|
||||||
ctx.fillStyle = isCurrent ? (isHovered ? '#78b4ff' : '#4888ff') : (isHovered ? '#f0d898' : '#c8aa6e')
|
ctx.fillStyle = isCurrent ? (isHovered ? '#78b4ff' : '#4888ff') : (isHovered ? '#f0d898' : '#c8aa6e')
|
||||||
ctx.fillRect(ox + 26, wy + 9, 12, 12)
|
ctx.fillRect(ox + 26, wy + 9, 12, 12)
|
||||||
}
|
}
|
||||||
|
|
@ -869,12 +1470,163 @@ export class WorldPanelsHud {
|
||||||
align: 'center',
|
align: 'center',
|
||||||
})
|
})
|
||||||
} else if (kind === 'vendor') {
|
} else if (kind === 'vendor') {
|
||||||
if (assets.vendorBgImg) ctx.drawImage(assets.vendorBgImg, ox, oy)
|
if (!this.activeVendorDescriptor) {
|
||||||
font.drawText(ctx, '阿卡拉 · 魔法药剂与卷轴商店', ox + 160, oy + 26, {
|
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',
|
font: 'fontexocet10',
|
||||||
color: 'gold',
|
color: 'gold',
|
||||||
align: 'center',
|
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) {
|
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(
|
private drawActTabs(
|
||||||
ctx: CanvasRenderingContext2D,
|
ctx: CanvasRenderingContext2D,
|
||||||
ox: number,
|
ox: number,
|
||||||
|
|
@ -1022,6 +1857,7 @@ export class WorldPanelsHud {
|
||||||
ctx: CanvasRenderingContext2D,
|
ctx: CanvasRenderingContext2D,
|
||||||
hover: { item: UiInventoryItem; x: number; y: number } | null,
|
hover: { item: UiInventoryItem; x: number; y: number } | null,
|
||||||
font: D2FontRenderer,
|
font: D2FontRenderer,
|
||||||
|
vendorOpen = false,
|
||||||
): void {
|
): void {
|
||||||
if (!hover) return
|
if (!hover) return
|
||||||
const { item } = hover
|
const { item } = hover
|
||||||
|
|
@ -1049,6 +1885,24 @@ export class WorldPanelsHud {
|
||||||
lines.push({ text: item.baseNameZh, color: 'white', font: 'font8' })
|
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
|
// Base Stats Block
|
||||||
if (item.defense !== undefined) {
|
if (item.defense !== undefined) {
|
||||||
lines.push({ text: '防御: ' + item.defense, color: 'white', font: 'font8' })
|
lines.push({ text: '防御: ' + item.defense, color: 'white', font: 'font8' })
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue