Compare commits

...

17 Commits

Author SHA1 Message Date
troytt 8f8ee010ca docs: update README with art assets, JSON data specifications, and UI parity 2026-09-22 13:12:25 +00:00
troytt 4f6eadea6c feat(ui): authentic Diablo II 1.13c quickbar cd mask, aura styling, charges and passive filtering (Fixes #382) 2026-09-22 11:35:47 +00:00
troytt 775f4113c9 feat(quest): authentic Diablo II 1.13c quest log tbl strings (Fixes #381) 2026-09-22 11:24:02 +00:00
troytt 090de33935 feat(ui): authentic Diablo II 1.13c skill quickbar & speedbar tooltips (Fixes #380) 2026-09-22 10:57:58 +00:00
troytt 7f810e40e3 feat(class): complete 7-class switching with signature sets and stash treasures (Fixes #379) 2026-09-22 10:45:48 +00:00
troytt fb43185990 Merge branch 'feat/issue-376' into main (Fixes #376) 2026-09-22 10:14:19 +00:00
troytt 67cf0e576b feat(ui): dynamic attack icon (fist/sword) and add throw & unsummon default skills (Fixes #376) 2026-09-22 10:14:11 +00:00
troytt 3b631b4c0c Merge branch 'feat/issue-378' into main (Fixes #378) 2026-09-22 10:00:00 +00:00
troytt 6f3cf6b8bb feat(ui): authentic D2 1.13c waypoint tabs and icons (Fixes #378) 2026-09-22 09:59:54 +00:00
troytt 0c4af3770d feat(ui): replicate authentic D2 1.13c skill tree tabs and skill points banner (Fixes #377) 2026-09-22 09:50:40 +00:00
troytt 43748eac53 feat(inventory): support ring, amulet, jewel, and charm VarInvGfx graphic variants (Fixes #375) 2026-09-22 09:36:29 +00:00
troytt 609149fa5f Merge branch 'issue-153': Widescreen UI panels left/right docking & central vision corridor (Fixes #153) 2026-09-22 07:56:28 +00:00
troytt 82e32a813e feat(ui): implement widescreen left/right panel docking and central vision corridor (Fixes #153) 2026-09-22 07:56:21 +00:00
troytt 0df0dfede2 fix(skills): reconstruct skill tooltips to 1.13c formula and next level preview (Fixes #152) 2026-09-22 07:13:08 +00:00
troytt 9fac33a1e2 fix(skills): reconstruct skill tooltips to 1.13c formula and next level preview (Fixes #152) 2026-09-22 07:13:00 +00:00
troytt 1f1996f65b feat(skills): add normal attack icon and enable dual left/right slot binding (Fixes #151) 2026-09-22 06:19:29 +00:00
troytt bc6b5cd331 feat(skills): add normal attack icon and enable dual left/right slot binding (Fixes #151) 2026-09-22 06:19:19 +00:00
70 changed files with 16185 additions and 1646 deletions

165
README.md
View File

@ -1,7 +1,7 @@
# d2web
从零实现的**暗黑破坏神 II 引擎**(纯 TypeScript + Vite + WebGL2,**零运行时依赖**),在浏览器里读取
用户自备的经典版 D2 归档(MPQ),渲染等距地图、驱动角色走动,并支持把地图离线烘焙成 web 原生格式。
用户自备的经典版 D2 归档(MPQ),渲染等距地图、驱动角色走动、释放 221 个原版技能,并支持把地图与模型离线烘焙成 web 原生格式。
- **仓库不含任何暴雪资源**:没有 `.mpq` / `.dc6` / `.dt1` / 图集 PNG;原版数据要自己放进 `samples/d2/`。
- **代码可复现一切**:格式解码、地图构建、资源包烘焙、角色合成都在这份代码里,脚本可重跑。
@ -12,17 +12,16 @@
| 能力 | 说明 |
| --- | --- |
| **等距地图渲染** | DS1 + DT1 投影(2:1 dimetric,格 80×40 px),地板 / 墙 / **屋顶** 三层分别绘制;逐格按 `RarityFrameIndex` 加权随机选瓦片变体;画家序排序 |
| **碰撞** | 每格 5×5 个 sub-tile 标志位(一层内所有层做 OR 合并,含 `type 13` 影子层),角色按 sub-tile 判定走位 |
| **碰撞系统** | 每格 5×5 个 sub-tile 标志位(一层内所有层做 OR 合并,含 `type 13` 影子层),角色按 sub-tile 判定走位,支持沿梯度脱困 |
| **真实关卡** | 已烘焙 **365 张地图**,覆盖 5 个 act:35 个预置关卡(罗格营地、修道院、大教堂、地下墓穴、鲁高因、库拉斯特码头、哈洛加斯…)+ **70 个随机迷宫 + 31 个野外关卡**(生成逻辑移植自 D2MOO,按官方参数表复现) |
| **真·女法师** | `d2char.mpq` 的 DCC + COF 逐层合成(8 方向;行走 `SOWLHTH`、站立 `SONUHTH`),不是占位棋子 |
| **资源包(离线烘焙)** | 索引色 PNG 图集页(≤2048²,PLTE + tRNS 取自该 act 的 `pal.pl2`)+ `scene.json`(绘制列表、碰撞 RLE、出生点、对象元数据);首屏只加载视口需要的页,其余后台补 |
| **七大职业与角色合成** | 完整支持亚马逊(Amazon)、刺客(Assassin)、野蛮人(Barbarian)、德鲁伊(Druid)、死灵法师(Necromancer)、圣骑士(Paladin)、女巫(Sorceress)七大职业的 DCC + COF 16 层组件装配与 8 方向动画,支持游戏中实时切换职业与初始专属装备 |
| **全技能系统(221 技能)** | 210 个各职业技能(每职业 3 系 × 10 技能)与 11 个通用玩家技能(普攻/踢击/投掷/反召唤/辨识/回城等),严格复现 1.13c 公式、协同加成(Synergies)、投射物引擎、光环扫描与延迟机制 |
| **完整 UI / HUD 交互** | 800×600 原版底栏控制台(红蓝血球与中毒覆盖、技能快捷槽、腰带)、左/右宽屏 400px 停靠面板(背包、纸娃娃、属性面板、技能树、储物箱、方块、任务日志、传送点菜单)、动态鼠标指针、Alt 物品高亮等 |
| **资源包(离线烘焙)** | 索引色 PNG 图集页(≤2048²,PLTE + tRNS 取自该 act 的 `pal.pl2`)+ `scene.json`(绘制列表、碰撞 RLE、出生点、对象元数据)+ `manifest.json` |
| **按需读归档** | 也可不烘焙,直接用 HTTP Range 只拉需要的 MPQ 字节当场解码(`?live=1`,慢,作为回退路径) |
| **三级关卡选择器** | 章节 → 场景 → 细分场景(如 第一章 / 罗格营地 / 北);中文场景名见 `src/game/level-names-zh.ts` |
**尚未实现**:真实地图上的怪物还是占位配置——怪物种类、密度、精英怪、超级唯一怪都尚未从
`Levels.txt` / `MonStats.txt` / `SuperUniques.txt` 读取(见 M7);关卡之间还不连通(见 M8);
战斗数值不是暗黑2 的公式(见 M9);可破坏物品的美术(位置与美术成员已进包,DCC 帧未接)。
完整的里程碑排期见 [`ROADMAP.md`](ROADMAP.md)。
**开发进展**:当前底层引擎、七大职业表现、221 技能完整实现与全套 UI 面板已全面打通。下一步将深入完善野外怪物密度与 AI 行为、装备词缀掉落平衡与 `.d2s` 官方存档双向互通,详见 [`ROADMAP.md`](ROADMAP.md)。
### 代码结构
@ -30,21 +29,124 @@
src/mpq/ MPQ v1 容器:hash/block 表、Storm 加密、分扇区编解码、PKWARE implode/explode、Range 读取
src/formats/ dc6 / ds1 / dt1 / pal / pl2 / cel / pcx / tbl / sprite / dcc / cof / bitstream
src/game/ 等距场景与碰撞、关卡表解析、DCC+COF 角色合成、对象与物品解析、确定性 RNG
engine/ 1.13c 真实战斗引擎、AST 公式求值器、状态机、投射物调度、光环与召唤物管理
skills/ 221 个技能的具体实现模块(uni / ama / sor / nec / pal / bar / dru / ass)
src/ui/ 完整 UI / HUD 模块:背包、属性面板、技能树、控制底栏、传送点、储物箱、任务日志、字体、鼠标指针
src/render/ WebGL2 渲染器(多图集;纹理切换时 flush 以保画家序)
src/scene/ 页面级装配(资源包优先,失败回退到直读归档)
src/net/ src/sim/ 联机中继与确定性锁步(夹具地图上的 2–4 人合作)
src/data/ 技能目录、Gitea 跟踪 Issue 数据库、掉落表与任务官方文本数据
scripts/ 资源包烘焙、格式/数据/部署校验、无头浏览器检查、夹具生成
*.html 五个入口页面:acts / index / map / walk / net
*.html 主入口页面:acts.html(游戏全功能主页面)、arena.html(数值与技能验证竞技场)
```
## 美术资源与图集资产(Assets)
项目在离线烘焙和客户端运行时划分了清晰的美术资产目录结构,所有视觉素材均可从 1.13c 原版 MPQ 自动导出与打包:
### 1. UI 界面图集与面板切片 (`public/ui/`)
- **综合图集 (Atlases)**:
- `items-atlas.png`:装备与物品大图集,覆盖所有武器、防具、饰品(戒指、护身符、珠宝)、符文、药水等,完整支持大/中/小护身符与首饰的原版 `VarInvGfx` 多视觉外观变体。
- `quests-atlas.png`:1–5 幕全部任务状态图标图集(未触发、进行中、已完成)。
- `skill-tree-tabs.png`:七大职业技能树的 3 系专属垂直选项卡图集(激活/未激活切片)。
- **经典功能面板背景与控件**:
- `ctrlpnl-800.png`:800×600 原版底栏控制台面板(包含技能槽底框、经验槽、腰带槽)。
- `inv-sheet.png` / `inv-tab-0.png` / `inv-tab-1.png`:装备纸娃娃界面与 10×4 背包网格、主副手武器切换 Tab。
- `char-sheet.png`:角色属性与抗性详情面板。
- `stash-bg.png`:营地私人储物箱面板(6×8 格子)。
- `cube-bg.png`:赫拉迪克方块(3×4 合成面板)。
- `vendor-bg.png` / `buysellbtn.png`:NPC 商人交易界面与买卖交互按钮。
- `quest-bg.png` / `quest-tabs.png`:羊皮纸任务日志背景与 1–5 幕选项卡。
- `waypoint-bg.png` / `waypoint-tabs.png` / `waypoint-icons.png`:传送点菜单、各幕选项卡与石碑传送状态图标。
- **指示器与动态视觉元素**:
- `globe-life.png` / `globe-mana.png` / `globe-poison.png`:经典流体红蓝血球与中毒、法力燃烧覆盖层。
- `cursor.png` / `cursor-hand.png`:硬件感暗黑手套鼠标指针(支持空闲状态机与点击动作动画)。
- `border-left.png` / `border-right.png` / `overlap-left.png` / `overlap-right.png`:宽屏自适应下的左右面板停靠石柱边框与过渡阴影。
- `minipanel.png` / `minipanel-btns.png` / `popbelt.png` / `runbutton.png` / `menubutton.png` / `level-btn.png` / `skillpoints.png`:菜单微型按钮、升级提示羽毛球、跑步状态切换钮等。
- **原版位图字体 (`public/ui/fonts/`)**:
- 提供 6 套暗黑 II 经典字体纹理:`font6.png`、`font8.png`、`font16.png`、`font30.png`、`fontexocet10.png`、`fontformal12.png`。
### 2. 技能树背景与技能图标资产 (`public/skills/` 与 `public/ui/`)
- **全职业技能树背景切片 (`public/skills/bg_<cls>_<tab>.png`)**:
- 共 21 张 256×432 px 原版复古羊皮纸技能树背景原画,覆盖 7 大职业各自的 3 大技能分支:
- 亚马逊:`bg_ama_0.png`(标枪与长矛)、`bg_ama_1.png`(被动与魔法)、`bg_ama_2.png`(弓与十字弓)
- 刺客:`bg_ass_0.png`(武学艺术)、`bg_ass_1.png`(影子训练)、`bg_ass_2.png`(陷阱)
- 野蛮人:`bg_bar_0.png`(战吼)、`bg_bar_1.png`(战斗专家)、`bg_bar_2.png`(战斗技能)
- 德鲁伊:`bg_dru_0.png`(元素)、`bg_dru_1.png`(变形)、`bg_dru_2.png`(召唤)
- 死灵法师:`bg_nec_0.png`(召唤术)、`bg_nec_1.png`(毒素与白骨)、`bg_nec_2.png`(诅咒)
- 圣骑士:`bg_pal_0.png`(战斗技能)、`bg_pal_1.png`(攻击灵气)、`bg_pal_2.png`(防御灵气)
- 女巫:`bg_sor_0.png`(火焰法术)、`bg_sor_1.png`(闪电法术)、`bg_sor_2.png`(冰冷法术)
- **技能图标资产 (`public/skills/icon_*.png` 与 `public/ui/`)**:
- 包含 221 个技能的 48×48 px 原版技能图标,细分为普通态(`icon_<id>.png`)与激活/按下高光态(`icon_<id>_active.png`)。
- 涵盖七大职业全部 210 个技能,以及 11 个通用技能(普通攻击、踢击、投掷、反召唤、辨识/回城卷轴与书、迪勒瑞姆变身等)。
- 动态普通攻击图标:根据当前主手装备自动切换拳头图标(`icon_0_fist.png`)或武器剑图标(`icon_0_sword.png`),并具备投掷(`icon_2.png`)与反召唤(`icon_3.png`)专用图标。
### 3. 角色与怪物实体动画包 (`samples/d2-packs/entities/`)
- **七大职业模型**:
- 亚马逊(`char-am`)、刺客(`char-ai`)、野蛮人(`char-ba`)、德鲁伊(`char-dz`)、死灵法师(`char-ne`)、圣骑士(`char-pa`)、女巫(`char-so`)。
- 由 16 个装备图层复合装配,解包出行走、跑动、各种武器攻击、施法、受击、死亡等 8 方向完整序列。
- 伴随同名 `.r8` 紧凑二进制像素包,用于在 WebGL/Canvas 中零拷贝极速解码渲染。
- **怪物实体模型**:
- 包含沉沦魔(Fallen)、骷髅(Skeleton)、僵尸(Zombie)、血鸟(Blood Raven)、督瑞尔(Duriel)、安达利尔(Andariel)、墨菲斯托(Mephisto)、暗黑破坏神(Diablo)、巴尔(Baal)等数十种怪物的完整动画包与 `.r8` 调色板像素数据。
### 4. 场景瓦片图集 (`samples/d2-packs/act[1-5]/`)
- 365 个地图场景分块切片图集,每个图集包含 ≤2048² 尺寸的索引色 PNG 瓦片页(地板层、墙体层、屋顶层),附带 PLTE 调色板与透明度表。
## 结构化数据与 JSON 规范(Data & JSON)
引擎采用结构化 JSON 数据来驱动界面排版、图集映射、场景还原与游戏逻辑:
### 1. `public/ui/manifest.json`(UI 资源与图集总清单)
由 `scripts/pack-ui.ts` 自动烘焙生成,为 UI 系统提供像素级定位映射:
- `atlasWidth` / `atlasHeight`:`items-atlas.png` 装备图集的分辨率规格。
- `questsAtlasWidth` / `questsAtlasHeight`:`quests-atlas.png` 任务图集的分辨率规格。
- `codeToInvFile`:物品 3 字母代码(如 `rin`、`amu`、`cm1`、`hpot`)到原版 MPQ 图像文件名的映射表。
- `itemRects`:所有物品及装备的 UV 矩形坐标表(`{ x, y, w, h }`),针对戒指、项链、珠宝和护身符支持 `VarInvGfx` 多图集变体定位。
- `questRects`:1–5 幕任务日志在图集中的坐标切片。
- `images`:各种独立 UI 面板背景、边框、按键在 `public/ui/` 下的文件索引。
### 2. `public/ui/fonts/metrics.json`(位图字体排版度量)
- 记录 6 套经典字体(Exocet、Formal、Font6/8/16/30)的字形度量参数。
- 包含每个 ASCII 字符在字体图集上的矩形包围盒、字宽(`charWidths`)、字符高度、基线偏移与字间距(Kerning),为游戏内悬浮 Tooltip、装备描述和任务日志提供完全原版的排版效果。
### 3. `samples/d2-packs/index.json`(全关卡全局场景目录)
- 记录全游戏 5 幕 365 个地图块的元数据。
- 包含字段:章节编号(Act 1..5)、关卡 ID(Level ID)、内部场景标识、关卡类型(预置场景 Preset / 随机迷宫 Maze / 野外关卡 Wilderness)、网格尺寸(Grid Bounds)、可用变体数(Variants)与对应的烘焙目录相对路径。
### 4. `samples/d2-packs/world-graph.json`(世界拓扑连通网络图)
- 从 `Levels.txt` 的连接参数(`Vis0..7`、`Warp0..7`)生成的全局世界拓扑图。
- 记录场景之间的连通边(Edges)、传送点(Waypoints)分布、楼梯出入口(Warps)、野外边界开放开口(Perimeter Openings),支撑小地图与跨关卡传送切换。
### 5. `samples/d2-packs/anim/animdata.json`(动画关键帧事件权威表)
由 `scripts/pack-animdata.ts` 直接解析 1.13c `AnimData.d2` 二进制文件(570,304 字节,3,558 条记录)生成:
- 记录每个动作 Hash 的播放帧率(Frame Speed)、总帧数(Total Frames)。
- 精确解析动作事件标志位:`0x01` 判定为近战命中/施法触发判定帧,`0x02` 判定为投射物(Missile)发射事件帧,确保 25Hz 动作逻辑与原版单机完全一致。
### 6. `samples/d2-packs/anim/monstats2.json`(怪物组件与动画模式配置)
- 结构化提取自 `MonStats2.txt`。
- 记录所有怪物的身体构成组件、图层合成规则、各武器模式下的动作代码映射(如 `hth`、`1hs`、`2ht`、`bow`)。
### 7. `samples/d2-packs/entities/char-*.json` 与 `monster-*.json`(实体模型定义)
- 描述 7 大角色职业与数十种怪物的骨骼装配结构。
- 包含复合图层映射、8 方向动作切片元数据、每帧中心偏移(Pivot),搭配同名 `.r8` 文件在渲染管线中执行高效逐像素着色。
### 8. `samples/d2-packs/act<N>/<level>/scene.json` & `manifest.json`(单场景规格)
- `manifest.json`:当前场景所需的瓦片图集页清单与调色板信息。
- `scene.json`:记录等距地图的地板(Floor)、墙体(Wall)、屋顶(Roof)多层瓦片绘制列表与深度排序,采用 RLE 压缩的 5×5 子格碰撞阻挡网格(Collision Grid),以及预置对象(储物箱、石碑、传送门、出生点)。
### 9. `src/data/gitea-skill-issues.json`(技能跟踪数据库)
- 建立 221 个技能与开发追踪系统的双向索引映射,包含 Skill ID、英文名称、职业缩写、Slug 标识与对应的 Issue/Milestone 编号。
## 部署与使用
### 1. 前置:自备游戏数据
把你自己合法拥有的**经典版暗黑破坏神 II(1.13c)** 数据放进 `samples/d2/`,至少需要
`d2data.mpq`、`d2char.mpq`、`d2exp.mpq`、`Patch_D2.mpq`。资源包模式(页面默认)只需要
`samples/d2-packs/`——它不在 Git 里(63 MB 的索引 PNG 每次重烘都会让仓库再长一份),
按下面的「资源包从哪来」下载解包后,**没有原版数据也能看地图**。
`samples/d2-packs/`——它不在 Git 里,按下面的「资源包从哪来」下载解包后,**没有原版数据也能看地图**。
`samples/d2/` 通常做成软链接,指向一个**磁盘上的持久目录**。切勿链到 `/tmp`——它是内存盘,
重启即清空,链接会变死链而角色/怪物退化成占位方块。本机的实际摆放、清单与逆向参考用的原版
@ -78,8 +180,11 @@ tar xJf d2-packs-2026-09-14.tar.xz # 解出 samples/d2-packs/
npm install # 仅 vite / typescript / @types/node
tsx scripts/make-fixtures.ts samples/fixtures # 夹具:DC6 + 调色板 + 文本表
tsx scripts/make-map-fixtures.ts samples/fixtures # 夹具:DT1 + DS1 + 夹具 MPQ
npm run pack:data # 从 samples/d2/ 烘焙 62 个地图块 → samples/d2-packs
npm run dev # http://127.0.0.1:5173/acts.html
npm run pack:data # 从 samples/d2/ 烘焙 365 场景图集 → samples/d2-packs
npm run pack:ui # 烘焙 UI 综合图集与 manifest.json
npm run pack:animdata # 烘焙 AnimData.d2 与 monstats2.json
npm run pack:entities # 烘焙 7 大职业模型与怪物实体动画包
npm run dev # 启动 Vite:http://127.0.0.1:5173/acts.html
```
`pack:data` 需要原版 MPQ(约 2 分钟);只用上面下载来的 `samples/d2-packs/` 可以跳过它,
@ -89,10 +194,10 @@ npm run dev # http://127.0.0.1:5173/act
| 页面 | 用途 |
| --- | --- |
| `/acts.html`(或访问根路径 `/`) | **主页面**:全屏画布 + 三级选择器(章节 / 场景 / 细分场景)+ HUD |
| `/arena.html` | **战斗测试竞技场**:攻防数值数学模型、技能加成、伤害计算器(1.13c 原版公式验证) |
| `/acts.html`(或访问根路径 `/`) | **主游戏页面**:全屏 WebGL2 画布 + HUD 控制条 + 左右停靠全套功能面板 + 7 职业漫步战斗 |
| `/arena.html` | **战斗数值与技能测试竞技场**:攻防数值数学模型、技能加成、伤害计算器(1.13c 原版公式验证) |
主页面查询参数:
#### 页面查询参数
```
?act=1..5 章节
@ -100,9 +205,32 @@ npm run dev # http://127.0.0.1:5173/act
?quadrant=<DS1 名> 直读归档模式下指定该关卡的某个 DS1
?live=1 不走资源包,直接按 HTTP Range 读 MPQ 解码(慢)
?pack=<目录> 换一个资源包目录 ?base=<目录> 换一个归档目录
?arena=1&class=<cls>&skill=<id>&slvl=<lvl>&autocast=1 启动竞技场自动验证模式
```
操作:**WASD / 方向键**移动(按屏幕方向),**滚轮 或 `+` / `-`** 缩放,`0` 复位;画布随窗口自适应并铺满视口。
#### 完整操作快捷键
- **移动与视口**:
- `WASD` / 方向键:移动角色(按等距屏幕方向)
- 鼠标滚轮 或 `+` / `-`:视口无级缩放;`0`:缩放复位
- **功能面板(宽屏左右停靠)**:
- `I` / `B`:开启 / 关闭背包与装备纸娃娃面板(右停靠)
- `A` / `C`:开启 / 关闭角色属性面板(左停靠)
- `T`:开启 / 关闭技能树面板(右停靠,3 系垂直选项卡切换与加点)
- `Q`:开启 / 关闭任务日志面板(左停靠,按幕查看真实任务状态)
- `V`:开启 / 关闭传送点网络菜单(左停靠,各幕传送点导航)
- `Esc`:关闭当前开启的所有停靠面板与弹出层
- **战斗与技能快捷栏**:
- 鼠标左键 / 右键:施放当前选中的左 / 右手技能
- `S` 或点击底栏技能图标:打开 Speedbar 技能选择菜单
- 鼠标悬浮在 Speedbar 技能上按 `F1` ~ `F8`:绑定快捷键;在游戏过程中按 `F1` ~ `F8`:即时切换技能
- `1` ~ `4`:快捷使用腰带对应列药水;`~`:展开 / 折叠腰带
- `Alt`(按住):高亮显示地面掉落物品标签
- `R`:切换跑步 / 走路模式
- `W`:主副手武器切换(`I` / `II` 套)
- **测试与调试功能**:
- 键盘数字 `1` ~ `7`:一键热切换 7 大职业(亚马逊、刺客、野蛮人、德鲁伊、死灵法师、圣骑士、女巫)并自动装配初始专属套装与储物箱
- `K` / `L`:快速保存 / 读取角色快照存档
### 5. 部署到服务器
@ -149,6 +277,7 @@ npm run verify:acts # 关卡表 → DS1 → DT1 → 等距场景 + 碰
npm run verify:d2 # 真 MPQ 数据端到端解码
npm run verify:implode # PKWARE implode 解码器(全成员)
npm run typecheck # tsc --noEmit
npm test # 运行 Vitest 完整测试套件
```
## 数据与许可

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 76 KiB

BIN
public/skills/icon_0.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
public/skills/icon_2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
public/skills/icon_3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
public/ui/icon_0.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
public/ui/icon_0_fist.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

BIN
public/ui/icon_0_sword.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
public/ui/icon_2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
public/ui/icon_3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 391 KiB

After

Width:  |  Height:  |  Size: 397 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@ -110,6 +110,8 @@ async function main() {
for (const cls of CLASS_CODES) {
const backDc6 = decodeDc6(await archives.read(CLASS_BACK_DC6[cls]!))
const W = 256, H = 432
const baseTop = backDc6.groups[0]!.frames[0]!
const baseBot = backDc6.groups[0]!.frames[2]!
for (let tab = 0; tab < 3; tab++) {
const topFrameIndex = 4 + tab * 4
const botFrameIndex = 6 + tab * 4
@ -117,8 +119,14 @@ async function main() {
const botFrame = backDc6.groups[0]!.frames[botFrameIndex]!
const pixels = new Uint8Array(W * H)
pixels.set(topFrame.indices, 0)
pixels.set(botFrame.indices, 256 * 256)
for (let i = 0; i < 256 * 256; i++) {
const p = topFrame.indices[i]!
pixels[i] = p !== 0 ? p : baseTop.indices[i]!
}
for (let i = 0; i < 256 * 176; i++) {
const p = botFrame.indices[i]!
pixels[256 * 256 + i] = p !== 0 ? p : baseBot.indices[i]!
}
const png = encodeIndexedPng({
width: W,

View File

@ -358,6 +358,63 @@ async function main(): Promise<void> {
const skillPts = await decodeUiDc6('data/global/ui/Panel/skillpoints.dc6')
savePng('skillpoints.png', skillPts.groups[0]!.frames[0]!.width, skillPts.groups[0]!.frames[0]!.height, skillPts.groups[0]!.frames[0]!.indices)
const skltreeBack = await decodeUiDc6('data/global/ui/SPELLS/skltree_s_back.DC6')
const stF1 = skltreeBack.groups[0]!.frames[1]!
const stF3 = skltreeBack.groups[0]!.frames[3]!
const stF7 = skltreeBack.groups[0]!.frames[7]!
const stF9 = skltreeBack.groups[0]!.frames[9]!
const stF11 = skltreeBack.groups[0]!.frames[11]!
const stF13 = skltreeBack.groups[0]!.frames[13]!
const sttPixels = new Uint8Array(192 * 432)
const pasteTabCol = (colIdx: number) => {
const ox = colIdx * 64
for (let y = 0; y < 256; y++) {
for (let x = 0; x < 64; x++) {
sttPixels[y * 192 + (ox + x)] = stF1.indices[y * 64 + x]!
}
}
for (let y = 0; y < 176; y++) {
for (let x = 0; x < 64; x++) {
sttPixels[(256 + y) * 192 + (ox + x)] = stF3.indices[y * 64 + x]!
}
}
}
// Col 0: Top Tab Active (y = 108..216, stF13)
pasteTabCol(0)
for (let y = 108; y < 216; y++) {
for (let x = 0; x < 64; x++) {
const idx = stF13.indices[y * 64 + x]!
if (idx !== 0) sttPixels[y * 192 + (0 + x)] = idx
}
}
// Col 1: Middle Tab Active (y = 216..324, stF9 + stF11)
pasteTabCol(1)
for (let y = 216; y < 256; y++) {
for (let x = 0; x < 64; x++) {
const idx = stF9.indices[y * 64 + x]!
if (idx !== 0) sttPixels[y * 192 + (64 + x)] = idx
}
}
for (let y = 0; y < 68; y++) {
for (let x = 0; x < 64; x++) {
const idx = stF11.indices[y * 64 + x]!
if (idx !== 0) sttPixels[(256 + y) * 192 + (64 + x)] = idx
}
}
// Col 2: Bottom Tab Active (y = 324..432, stF7)
pasteTabCol(2)
for (let y = 0; y < 176; y++) {
for (let x = 0; x < 64; x++) {
const idx = stF7.indices[y * 64 + x]!
if (idx !== 0) sttPixels[(256 + y) * 192 + (128 + x)] = idx
}
}
savePng('skill-tree-tabs.png', 192, 432, sttPixels)
const expQuestTabs = await decodeUiDc6('data/global/ui/Menu/expquesttabs.dc6')
const eqtStrip = stitchHorizontalStrip(expQuestTabs.groups[0]!.frames)
savePng('quest-tabs.png', eqtStrip.width, eqtStrip.height, eqtStrip.pixels)
@ -373,6 +430,22 @@ async function main(): Promise<void> {
const genericSkills = await decodeUiDc6('data/global/ui/SPELLS/Skillicon.DC6')
const gsStrip = stitchHorizontalStrip(genericSkills.groups[0]!.frames)
savePng('generic-skills.png', gsStrip.width, gsStrip.height, gsStrip.pixels)
const attackFrame = genericSkills.groups[0]!.frames[0]!
savePng('icon_0.png', attackFrame.width, attackFrame.height, attackFrame.indices)
try {
const skillsOut = join(process.cwd(), 'public', 'skills')
mkdirSync(skillsOut, { recursive: true })
const attackPng = encodeIndexedPng({
width: attackFrame.width,
height: attackFrame.height,
pixels: attackFrame.indices,
palette: pl2.rgb,
transparentIndex: 0,
})
writeFileSync(join(skillsOut, 'icon_0.png'), attackPng)
} catch {
// optional copy to public/skills
}
const ohand = await decodeUiDc6('data/global/ui/CURSOR/ohand.dc6')
savePng('cursor-hand.png', ohand.groups[0]!.frames[0]!.width, ohand.groups[0]!.frames[0]!.height, ohand.groups[0]!.frames[0]!.indices)
@ -456,6 +529,30 @@ async function main(): Promise<void> {
}
}
// Register authentic ring DC6 icons (invrin1..invrin5 per ItemTypes.txt VarInvGfx = 5)
for (let i = 1; i <= 5; i++) {
const ringInv = `invrin${i}`
if (archives.has(`data/global/items/${ringInv}.dc6`)) {
uniqueInvFiles.add(ringInv)
}
}
// Register authentic amulet DC6 icons (invamu1..invamu3 per ItemTypes.txt VarInvGfx = 3)
for (let i = 1; i <= 3; i++) {
const amuInv = `invamu${i}`
if (archives.has(`data/global/items/${amuInv}.dc6`)) {
uniqueInvFiles.add(amuInv)
}
}
// Register authentic jewel DC6 icons (invjw1..invjw6 per ItemTypes.txt VarInvGfx = 6)
for (let i = 1; i <= 6; i++) {
const jwInv = `invjw${i}`
if (archives.has(`data/global/items/${jwInv}.dc6`)) {
uniqueInvFiles.add(jwInv)
}
}
// Identity mapping for every invfile: codeToInvFile[inv] = inv
for (const inv of uniqueInvFiles) {
codeToInvFile[inv] = inv
@ -661,7 +758,9 @@ async function main(): Promise<void> {
levelBtn: '/ui/level-btn.png',
levelSocket: '/ui/level-socket.png',
skillPoints: '/ui/skillpoints.png',
skillTabs: '/ui/skill-tree-tabs.png',
genericSkills: '/ui/generic-skills.png',
attackIcon: '/ui/icon_0.png',
cursorHand: '/ui/cursor-hand.png',
cursorAtlas: '/ui/cursor.png',
questTabs: '/ui/quest-tabs.png',

View File

@ -0,0 +1,168 @@
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { MountedArchives } from '../src/mpq/mount.ts'
import { loadActTables } from '../src/game/acts.ts'
import { SKILLS_CATALOG } from '../src/data/skills-catalog.ts'
import { writeFileSync } from 'node:fs'
import { join } from 'node:path'
async function run() {
console.log('Mounting MPQs to extract 1.13c ToHit and auraLenCalc...')
const archives = new MountedArchives()
for (const m of ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
archives.add(m, await MpqArchive.open(await fileSource('samples/d2/' + m)))
}
const tables = await loadActTables(archives)
const st = tables.skills!
const toHitIdx = st.header.indexOf('ToHit')
const levToHitIdx = st.header.indexOf('LevToHit')
const toHitCalcIdx = st.header.indexOf('ToHitCalc')
const auraLenCalcIdx = st.header.indexOf('auralencalc')
const startmanaIdx = st.header.indexOf('startmana')
const updatedCatalog = SKILLS_CATALOG.map(skill => {
const row = st.rows.find(r => Number(r[1]) === skill.id)
const th = row ? (Number(row[toHitIdx]) || 0) : 0
const lth = row ? (Number(row[levToHitIdx]) || 0) : 0
const rawThc = row ? row[toHitCalcIdx] : undefined
const thc = rawThc && rawThc.trim() !== '' ? rawThc.trim() : undefined
const rawAlc = row ? row[auraLenCalcIdx] : undefined
const alc = rawAlc && rawAlc.trim() !== '' ? rawAlc.trim() : undefined
const sm = row ? (Number(row[startmanaIdx]) || 0) : 0
return {
...skill,
toHit: th,
toHitLev: lth,
...(thc ? { toHitCalc: thc } : {}),
...(alc ? { auraLenCalc: alc } : {}),
startmana: sm,
}
})
const tsContent = `// Auto-generated Diablo II 1.13c 210-Skill Catalog
// Ground truth parsed from Skills.txt, SkillDesc.txt, string.tbl, and MPQs.
import type { CharacterClassCode } from '../game/classes.ts'
export interface SynergyBonusInfo {
readonly providerId: number
readonly providerName: string
readonly percentPerLevel: number
readonly type: 'damage' | 'elemental' | 'duration'
}
export interface SynergyGivenInfo {
readonly targetId: number
readonly targetName: string
readonly targetNameZh: string
readonly percentPerLevel: number
}
export interface DescLineDefinition {
readonly lineType: number
readonly textaKey?: string | undefined
readonly textbKey?: string | undefined
readonly textaEn?: string | undefined
readonly textaZh?: string | undefined
readonly textbEn?: string | undefined
readonly textbZh?: string | undefined
readonly calca?: string | undefined
readonly calcb?: string | undefined
}
export interface MinionBaseStats {
readonly code: string
readonly minHP: number
readonly maxHP: number
readonly minDam: number
readonly maxDam: number
}
export interface SkillCatalogEntry {
readonly id: number
readonly name: string
readonly nameZh: string
readonly classCode: CharacterClassCode
readonly classNameZh: string
readonly tabIndex: number
readonly tabName: string
readonly tabNameZh: string
readonly row: number
readonly col: number
readonly reqlevel: number
readonly reqskills: readonly number[]
readonly dependents: readonly number[]
readonly mana: number
readonly lvlmana: number
readonly minmana: number
readonly manashift: number
readonly delay: number
readonly descdam?: string | undefined
readonly hitshift: number
readonly srcDamage: number
readonly minDam: number
readonly maxDam: number
readonly minLevDam: readonly number[]
readonly maxLevDam: readonly number[]
readonly eType: string
readonly eMin: number
readonly eMax: number
readonly eMinLev: readonly number[]
readonly eMaxLev: readonly number[]
readonly eLen: number
readonly eLevLen: readonly number[]
readonly toHit: number
readonly toHitLev: number
readonly toHitCalc?: string | undefined
readonly auraLenCalc?: string | undefined
readonly startmana: number
readonly params: Readonly<Record<string, number>>
readonly synergyFormulas: {
readonly physical?: string | undefined
readonly elemental?: string | undefined
readonly duration?: string | undefined
}
readonly synergiesReceived: readonly SynergyBonusInfo[]
readonly synergiesGiven: readonly SynergyGivenInfo[]
readonly masteries: readonly number[]
readonly descLines?: readonly DescLineDefinition[] | undefined
readonly dsc2Lines?: readonly DescLineDefinition[] | undefined
readonly minionStats?: MinionBaseStats | undefined
readonly shortDesc: string
readonly shortDescZh: string
readonly longDesc: string
readonly longDescZh: string
readonly icon: string
readonly iconActive: string
}
export const SKILLS_CATALOG: readonly SkillCatalogEntry[] = Object.freeze(${JSON.stringify(updatedCatalog, null, 2)})
export const SKILLS_BY_ID: Readonly<Record<number, SkillCatalogEntry>> = Object.freeze(
SKILLS_CATALOG.reduce<Record<number, SkillCatalogEntry>>((acc, cur) => {
acc[cur.id] = cur
return acc
}, {})
)
export const SKILLS_BY_CLASS: Readonly<Record<CharacterClassCode, readonly SkillCatalogEntry[]>> = Object.freeze({
ama: SKILLS_CATALOG.filter(s => s.classCode === 'ama'),
sor: SKILLS_CATALOG.filter(s => s.classCode === 'sor'),
nec: SKILLS_CATALOG.filter(s => s.classCode === 'nec'),
pal: SKILLS_CATALOG.filter(s => s.classCode === 'pal'),
bar: SKILLS_CATALOG.filter(s => s.classCode === 'bar'),
dru: SKILLS_CATALOG.filter(s => s.classCode === 'dru'),
ass: SKILLS_CATALOG.filter(s => s.classCode === 'ass'),
})
`
const outPath = join(process.cwd(), 'src', 'data', 'skills-catalog.ts')
writeFileSync(outPath, tsContent, 'utf-8')
console.log(`Successfully updated ${outPath} with 1.13c ToHit and auraLenCalc!`)
}
run().catch(err => {
console.error(err)
process.exit(1)
})

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -12,6 +12,7 @@
*/
import type { CharacterClassCode } from './classes.ts'
import { compute3BandDuration } from './engine/calc-ast.ts'
import {
SKILLS_CATALOG,
SKILLS_BY_ID,
@ -480,7 +481,13 @@ export function calculateDetailedDamage(
baseMax = evaluateLevelBand(skill.eMax, skill.eMaxLev, lvl)
if (skill.eLen > 0 || (skill.eLevLen && skill.eLevLen.length > 0)) {
durationFrames = evaluateLevelBand(skill.eLen, skill.eLevLen, lvl)
durationFrames = compute3BandDuration(
lvl,
skill.eLen,
skill.eLevLen?.[0] ?? 0,
skill.eLevLen?.[1] ?? 0,
skill.eLevLen?.[2] ?? 0,
)
}
} else if (skill.minDam > 0 || skill.maxDam > 0 || (skill.minLevDam && skill.minLevDam.some(x => x > 0))) {
type = 'physical'
@ -658,22 +665,42 @@ export function evaluateSkillEffects(
const dm56 = evalDiminishing(p.par5 ?? 0, p.par6 ?? 0, lvl)
const dm78 = evalDiminishing(p.par7 ?? 0, p.par8 ?? 0, lvl)
// Determine duration len
let durationFrames = skill.eLen + (lvl - 1) * (skill.eLevLen?.[0] ?? 0)
if (durationFrames === 0 && (skill.params.par1 ?? 0) > 0 && (skill.params.par2 ?? 0) > 0) {
if (skill.name === 'Battle Orders' || skill.name === 'Shout' || skill.name === 'Battle Command') {
// Determine duration len using authentic 1.13c 3-band duration scaling
let durationFrames = compute3BandDuration(
lvl,
skill.eLen,
skill.eLevLen?.[0] ?? 0,
skill.eLevLen?.[1] ?? 0,
skill.eLevLen?.[2] ?? 0,
)
if (durationFrames === 0) {
if (
skill.id === 138 ||
skill.id === 149 ||
skill.id === 155 ||
skill.name === 'Battle Orders' ||
skill.name === 'Shout' ||
skill.name === 'Battle Command'
) {
const synShout = state.hardPoints[138] ?? 0
const synBC = state.hardPoints[155] ?? 0
const synBO = state.hardPoints[149] ?? 0
let synBonus = 0
if (skill.id === 149) synBonus = (synShout + synBC) * (skill.params.par8 ?? 125)
else if (skill.id === 138) synBonus = (synBO + synBC) * (skill.params.par8 ?? 125)
else if (skill.id === 155) synBonus = (synShout + synBO) * (skill.params.par8 ?? 125)
durationFrames = (skill.params.par1 ?? 0) + (lvl - 1) * (skill.params.par2 ?? 0) + synBonus
if (skill.id === 149 || skill.name === 'Battle Orders') {
synBonus = (synShout + synBC) * (skill.params.par8 ?? 125)
durationFrames = (skill.params.par1 ?? 0) + (lvl - 1) * (skill.params.par2 ?? 0) + synBonus
} else if (skill.id === 138 || skill.name === 'Shout') {
synBonus = (synBO + synBC) * (skill.params.par8 ?? 125)
durationFrames = (skill.params.par3 ?? 0) + (lvl - 1) * (skill.params.par4 ?? 0) + synBonus
} else if (skill.id === 155 || skill.name === 'Battle Command') {
synBonus = (synShout + synBO) * (skill.params.par8 ?? 125)
durationFrames = (skill.params.par1 ?? 0) + (lvl - 1) * (skill.params.par2 ?? 0) + synBonus
}
}
}
const ctx: SkillFormulaContext = {
const shiftMultiplier = 2 ** ((skill.hitshift ?? 8) - 8)
const baseCtx: SkillFormulaContext = {
lvl,
params: skill.params,
ln12: (skill.params.par1 ?? 0) + (lvl - 1) * (skill.params.par2 ?? 0),
@ -684,15 +711,24 @@ export function evaluateSkillEffects(
dm34,
dm56,
dm78,
toht: (skill.params.tohit ?? 40) + (lvl - 1) * 5,
toht: 0,
len: durationFrames,
edmn: evaluateLevelBand(skill.eMin, skill.eMinLev, lvl),
edmx: evaluateLevelBand(skill.eMax, skill.eMaxLev, lvl),
edmn: Math.round(evaluateLevelBand(skill.eMin, skill.eMinLev, lvl) * shiftMultiplier),
edmx: Math.round(evaluateLevelBand(skill.eMax, skill.eMaxLev, lvl) * shiftMultiplier),
mana: calculateManaCost(skill, lvl),
hardPoints: state.hardPoints,
effLevels,
}
const toht = skill.toHitCalc
? evalSkillDescFormula(skill.toHitCalc, baseCtx)
: (skill.toHit ?? 0) + (lvl - 1) * (skill.toHitLev ?? 0)
const ctx: SkillFormulaContext = {
...baseCtx,
toht,
}
const results: FormattedDescLine[] = []
const lines = [...(skill.descLines || []), ...(skill.dsc2Lines || [])]
for (const line of lines) {
@ -861,7 +897,7 @@ export function buildTooltipViewModel(
const hard = state.hardPoints[skillId] ?? 0
const eff = getEffectiveLevel(state, skillId)
const isLearned = hard > 0
const isLearned = eff > 0
const levelMet = playerLevel >= skill.reqlevel
const canAlloc = canAllocate(state, skillId)
@ -883,10 +919,10 @@ export function buildTooltipViewModel(
const currentDamage = calculateDetailedDamage(state, skill, displayLevel)
const currentEffects = evaluateSkillEffects(state, skill, displayLevel)
// Next Level stats
// Next Level stats: only show when learned and not maxed
let nextLevel: TooltipViewModel['nextLevel'] = null
if (hard < 20) {
const nextLvl = isLearned ? eff + 1 : 1
if (isLearned && hard < 20) {
const nextLvl = eff + 1
nextLevel = {
level: nextLvl,
manaCost: calculateManaCost(skill, nextLvl),

View File

@ -21,6 +21,7 @@ import {
ORTHO_CELL_HEIGHT, ORTHO_CELL_WIDTH, ORTHO_SUB_TILE_HEIGHT, ORTHO_SUB_TILE_WIDTH,
cellAt, cellCentre, findIsoSpawn, isBlockedAt, levelSeed,
} from '../game/d2map.ts'
import type { CharacterClassCode } from '../game/classes.ts'
import type { CollisionGrid, IsoDraw } from '../game/d2map.ts'
import {
updateAnimatableTiles,
@ -66,6 +67,7 @@ import { findMatchingVariant, getDefaultTownVariant, oppositeSide, type Side } f
import { Minimap } from '../ui/minimap.ts'
import type { MinimapLevel, MinimapMarker } from '../ui/minimap.ts'
import { HudManager } from '../ui/hud-manager.ts'
import type { InventoryPanel } from '../ui/inventory.ts'
/** Base locations tried in order when `?base=` is absent. */
const DEFAULT_BASES = ['samples/d2', '/diablo2/data']
@ -687,8 +689,214 @@ export function isPassiveSkill(skillId: number): boolean {
return def?.type === 'passive'
}
export function getSkillRange(skillId: number): number {
if (skillId === 0) return 52 // playerReach melee
export type AttackWeaponKind = 'unarmed' | 'melee' | 'ranged'
export type AttackWeaponSubtype =
| 'fist'
| 'sword'
| 'axe'
| 'mace'
| 'dagger'
| 'staff'
| 'polearm'
| 'spear'
| 'bow'
| 'crossbow'
| 'other'
export interface AttackWeaponInfo {
readonly kind: AttackWeaponKind
readonly subtype: AttackWeaponSubtype
readonly isRanged: boolean
readonly ammoType?: 'arrow' | 'bolt' | undefined
readonly damageMin: number
readonly damageMax: number
readonly reach: number
readonly cooldown: number
readonly swingTicks: number
}
const BOW_CODES = new Set([
'sbw', 'hbw', 'lbw', 'cbw', 'sbb', 'lbb', 'swb', 'lwb',
'am1', 'am2', 'am6', 'am7', 'amb',
'8sb', '8hb', '8lb', '8cb', '8s8', '8l8', '8sw', '8lw',
'6sb', '6hb', '6lb', '6cb', '6s7', '6l7', '6sw', '6lw',
'amc', 'amd', 'ame', 'amj', 'amk',
])
const XBOW_CODES = new Set([
'lxb', 'mxb', 'hxb', 'rxb',
'8lx', '8mx', '8hx', '8rx',
'6lx', '6mx', '6hx', '6rx',
])
export interface AttackWeaponInputItem {
code?: string | undefined
type?: string | undefined
wclass?: string | undefined
name?: string | undefined
nameZh?: string | undefined
damage?: string | undefined
}
export function getAttackWeaponInfo(item?: AttackWeaponInputItem | null | undefined): AttackWeaponInfo {
if (!item || !item.code) {
return {
kind: 'unarmed',
subtype: 'fist',
isRanged: false,
damageMin: 1,
damageMax: 2,
reach: 52,
cooldown: 12,
swingTicks: 6,
}
}
const code = item.code.trim().toLowerCase()
const type = (item.type ?? '').trim().toLowerCase()
const wclass = (item.wclass ?? '').trim().toLowerCase()
const name = (item.name ?? '').toLowerCase()
const nameZh = (item.nameZh ?? '').toLowerCase()
let dMin = 3
let dMax = 8
if (item.damage) {
const m = item.damage.match(/(\d+)\s*(?:to|-)\s*(\d+)/)
if (m) {
dMin = parseInt(m[1], 10)
dMax = parseInt(m[2], 10)
}
}
// 1. Crossbow (checked before bow because 'crossbow' contains 'bow')
if (type === 'xbow' || wclass === 'xbw' || XBOW_CODES.has(code) || /crossbow|xbow|十字弓|弩/i.test(name) || /十字弓|弩/i.test(nameZh)) {
return {
kind: 'ranged',
subtype: 'crossbow',
isRanged: true,
ammoType: 'bolt',
damageMin: dMin,
damageMax: dMax,
reach: 450,
cooldown: 16,
swingTicks: 8,
}
}
// 2. Bow
if (type === 'bow' || type === 'abow' || wclass === 'bow' || BOW_CODES.has(code) || /\bbow\b|弓/i.test(name) || /弓/i.test(nameZh)) {
return {
kind: 'ranged',
subtype: 'bow',
isRanged: true,
ammoType: 'arrow',
damageMin: dMin,
damageMax: dMax,
reach: 450,
cooldown: 14,
swingTicks: 7,
}
}
// 3. Melee Weapon Subtypes: sword, axe, mace, dagger, staff, polearm, spear
let subtype: AttackWeaponSubtype | null = null
let reach = 52
if (type === 'swor' || /sword|blade|scimitar|saber|falchion|claymore|broadsword|crystal sword|剑/i.test(name) || /剑/i.test(nameZh)) {
subtype = 'sword'
reach = 52
} else if (type === 'axe' || type === 'taxe' || /axe|hache|cleaver|斧/i.test(name) || /斧/i.test(nameZh)) {
subtype = 'axe'
reach = 52
} else if (type === 'mace' || type === 'club' || type === 'hamm' || type === 'scep' || /mace|club|hammer|scepter|flail|morning star|maul|锤|棍|杖|锏/i.test(name) || /锤|锏/i.test(nameZh)) {
subtype = 'mace'
reach = 52
} else if (type === 'knif' || type === 'tkni' || /dagger|dirk|kris|blade|knife|匕首|短刀/i.test(name) || /匕首|短刀/i.test(nameZh)) {
subtype = 'dagger'
reach = 48
} else if (type === 'staf' || /staff|长杖|法杖/i.test(name) || /长杖|法杖/i.test(nameZh)) {
subtype = 'staff'
reach = 52
} else if (type === 'pole' || /polearm|bardiche|voulge|scythe|halberd|war scythe|长柄|长镰/i.test(name) || /长柄|长镰/i.test(nameZh)) {
subtype = 'polearm'
reach = 60
} else if (type === 'spea' || type === 'aspe' || type === 'jave' || type === 'ajav' || /spear|trident|brandistock|spetum|pike|javelin|长矛|标枪/i.test(name) || /长矛|标枪/i.test(nameZh)) {
subtype = 'spear'
reach = 60
}
if (subtype !== null) {
return {
kind: 'melee',
subtype,
isRanged: false,
damageMin: dMin,
damageMax: dMax,
reach,
cooldown: 14,
swingTicks: 7,
}
}
// Non-melee item (e.g. orb, wand) or unarmed fallback timing: cooldown 12, swingTicks 6
return {
kind: 'unarmed',
subtype: 'fist',
isRanged: false,
damageMin: dMin > 3 ? dMin : 1,
damageMax: dMax > 8 ? dMax : 2,
reach: 52,
cooldown: 12,
swingTicks: 6,
}
}
export function checkAmmunition(
ammoType: 'arrow' | 'bolt',
offhandItem?: { code?: string | undefined; name?: string | undefined; nameZh?: string | undefined } | null | undefined,
inventory?: { items?: readonly any[] | undefined; grid?: readonly any[] | undefined; gridItems?: readonly any[] | undefined } | InventoryPanel | null | undefined,
): boolean {
// 1. Check offhand equipment (weapon2)
if (offhandItem && offhandItem.code) {
const code = offhandItem.code.toLowerCase().trim()
const name = (offhandItem.name ?? '').toLowerCase()
const nameZh = (offhandItem.nameZh ?? '').toLowerCase()
if (ammoType === 'arrow' && (code === 'aqv' || code === '0qv' || /arrow/i.test(name) || /箭矢|箭/i.test(nameZh))) return true
if (ammoType === 'bolt' && (code === 'cqv' || code === '1qv' || /bolt/i.test(name) || /十字弓弹|弩箭/i.test(nameZh))) return true
}
// 2. Check inventory grid / items
if (inventory) {
const invAny = inventory as any
const list: any[] = invAny.gridItems
? invAny.gridItems.map((g: any) => g.item ?? g)
: invAny.grid
? invAny.grid.map((g: any) => g.item ?? g)
: (invAny.items ?? [])
for (const it of list) {
if (!it || !it.code) continue
const code = String(it.code).toLowerCase().trim()
const name = String(it.name ?? '').toLowerCase()
const nameZh = String(it.nameZh ?? '').toLowerCase()
if (ammoType === 'arrow' && (code === 'aqv' || code === '0qv' || /arrow/i.test(name) || /箭矢|箭/i.test(nameZh))) return true
if (ammoType === 'bolt' && (code === 'cqv' || code === '1qv' || /bolt/i.test(name) || /十字弓弹|弩箭/i.test(nameZh))) return true
}
}
return false
}
export function getSkillRange(
skillId: number,
hudManager?: HudManager | null | undefined,
equippedWeapon?: AttackWeaponInputItem | null | undefined,
): number {
if (skillId === 0) {
const hud = hudManager ?? (typeof window !== 'undefined' ? (window as any).__d2webHudInstance : null)
const weapon = equippedWeapon ?? hud?.inventory?.equipped?.weapon1
const info = getAttackWeaponInfo(weapon)
return info.reach
}
const def = getBatch1SkillDef(skillId)
if (def !== undefined) {
if (def.type === 'melee') return 52
@ -739,6 +947,9 @@ export function castSkill(
status: HTMLElement
playerAnimator?: ActorAnimator | undefined
character?: LoadedCharacter | null | undefined
equippedWeapon?: any | null | undefined
equippedAmmo?: any | null | undefined
hasAmmo?: boolean | undefined
},
): boolean {
const { engine, runtime, hudManager, status, playerAnimator, character } = context
@ -783,16 +994,58 @@ export function castSkill(
// 5. Dispatch skill
if (skillId === 0) {
// Normal Melee Attack
player.cooldown = 12
player.swingTicks = 6
const weapon = context.equippedWeapon ?? hudManager?.inventory?.equipped?.weapon1 ?? (typeof window !== 'undefined' ? (window as any).__d2webHudInstance?.inventory?.equipped?.weapon1 : null)
const weaponInfo = getAttackWeaponInfo(weapon)
if (weaponInfo.isRanged) {
// Ranged Attack (Bow / Crossbow)
const ammo = context.equippedAmmo ?? hudManager?.inventory?.equipped?.weapon2 ?? (typeof window !== 'undefined' ? (window as any).__d2webHudInstance?.inventory?.equipped?.weapon2 : null)
const hasAmmo = context.hasAmmo ?? checkAmmunition(weaponInfo.ammoType!, ammo, hudManager?.inventory)
if (!hasAmmo) {
status.textContent = weaponInfo.ammoType === 'arrow' ? '缺少箭矢!' : '缺少十字弓弹!'
return false
}
player.cooldown = weaponInfo.cooldown
player.swingTicks = weaponInfo.swingTicks
const dx = targetX - player.x
const dy = targetY - player.y
const len = Math.hypot(dx, dy)
const speed = 500
const range = weaponInfo.reach
const perTick = speed / 25
const ttl = Math.max(1, Math.round(range / perTick))
const damage = Math.round((weaponInfo.damageMin + weaponInfo.damageMax) / 2)
const projectile: Projectile = {
skillId: '0',
x: player.x,
y: player.y,
vx: len > 0 ? (dx / len) * perTick : perTick,
vy: len > 0 ? (dy / len) * perTick : 0,
damage,
ttl,
fromPlayer: true,
}
engine.projectiles.push(projectile)
engine.metrics.casts += 1
status.textContent = weaponInfo.subtype === 'bow' ? '普通攻击:发射箭矢' : '普通攻击:发射弩箭'
return true
}
// Unarmed or Melee Attack
player.cooldown = weaponInfo.cooldown
player.swingTicks = weaponInfo.swingTicks
let targetMonster: Monster | null = null
// Prioritize monster near target cursor within melee reach of player
let bestTargetDist = 36
for (const monster of engine.world.monsters) {
if (monster.state === 'dead' || monster.hp <= 0) continue
const distToPlayer = Math.hypot(monster.x - player.x, monster.y - player.y)
if (distToPlayer <= 52) {
if (distToPlayer <= weaponInfo.reach) {
const distFeet = Math.hypot(monster.x - targetX, monster.y - targetY)
const distBody = Math.hypot(monster.x - targetX, (monster.y - 16) - targetY)
const minD = Math.min(distFeet, distBody)
@ -802,9 +1055,8 @@ export function castSkill(
}
}
}
// Fallback: closest monster to player within melee reach
if (targetMonster === null) {
let bestDist = 52
let bestDist = weaponInfo.reach
for (const monster of engine.world.monsters) {
if (monster.state === 'dead' || monster.hp <= 0) continue
const dist = Math.hypot(monster.x - player.x, monster.y - player.y)
@ -814,10 +1066,13 @@ export function castSkill(
}
}
}
const damage = Math.round((weaponInfo.damageMin + weaponInfo.damageMax) / 2)
if (targetMonster !== null) {
damageMonster(engine.world, targetMonster.index, 7)
damageMonster(engine.world, targetMonster.index, damage)
engine.metrics.playerHits += 1
status.textContent = `普通攻击命中:${targetMonster.stats.name || targetMonster.stats.id}`
const attackLabel = weaponInfo.kind === 'unarmed' ? '普通攻击(空手拳击)' : '普通攻击'
status.textContent = `${attackLabel}命中:${targetMonster.stats.name || targetMonster.stats.id}`
} else {
engine.world.events.push({ kind: 'playerHit', x: player.x, y: player.y, amount: 0, text: 'whiff' })
}
@ -985,7 +1240,7 @@ export type PendingInteraction =
| { readonly kind: 'npc'; readonly npc: NpcEntity }
| { readonly kind: 'waypoint'; readonly waypoint: SceneWaypoint; readonly worldPos: { readonly x: number; readonly y: number } }
| { readonly kind: 'stash'; readonly stash: TownStashEntity }
| { readonly kind: 'monster'; readonly monster: Monster }
| { readonly kind: 'monster'; readonly monster: Monster; readonly skillId?: number }
declare global {
interface HTMLCanvasElement {
@ -1125,7 +1380,17 @@ export class SceneMouseController {
}
} else if (e.button === 2) {
this.isRightMouseDown = true
this.handleRightClickWorld(worldX, worldY)
if (this.input.shiftHeld) {
this.activeClickType = 'none'
this.navTarget = null
this.pendingInteraction = null
const player = this.engine.world.player
player.facing = facingOf(worldX - player.x, worldY - player.y)
const rightSkillId = this.hudManager ? this.hudManager.hotkeys.rightSkillId : 64
this.castSkill(rightSkillId, worldX, worldY)
} else {
this.handleRightClickWorld(worldX, worldY)
}
}
}
@ -1144,7 +1409,7 @@ export class SceneMouseController {
this.navTarget = { x: worldX, y: worldY }
} else if (this.isRightMouseDown) {
const rightSkillId = this.hudManager ? this.hudManager.hotkeys.rightSkillId : 64
if (rightSkillId === 0 || isPassiveSkill(rightSkillId)) {
if (isPassiveSkill(rightSkillId) || (rightSkillId === 0 && !this.input.shiftHeld && this.activeClickType === 'ground')) {
const { x: worldX, y: worldY } = this.getPointerWorldCoords(e.clientX, e.clientY)
this.navTarget = { x: worldX, y: worldY }
}
@ -1166,14 +1431,63 @@ export class SceneMouseController {
handleRightClickWorld(worldX: number, worldY: number): void {
const rightSkillId = this.hudManager ? this.hudManager.hotkeys.rightSkillId : 64
if (rightSkillId === 0 || isPassiveSkill(rightSkillId)) {
if (isPassiveSkill(rightSkillId)) {
// Walk/Run fallback
this.pendingInteraction = null
this.navTarget = { x: worldX, y: worldY }
this.lastDist = Math.hypot(worldX - this.engine.world.player.x, worldY - this.engine.world.player.y)
this.stuckTicks = 0
return
}
const player = this.engine.world.player
const range = getSkillRange(rightSkillId, this.hudManager)
// 1. Check Monster: living monster within ~36 px
let clickedMonster: Monster | null = null
let bestMonDist = 36
for (const m of this.engine.world.monsters) {
if (m.state === 'dead' || m.hp <= 0) continue
const distFeet = Math.hypot(m.x - worldX, m.y - worldY)
const distBody = Math.hypot(m.x - worldX, (m.y - 16) - worldY)
const minD = Math.min(distFeet, distBody)
if (minD <= bestMonDist) {
bestMonDist = minD
clickedMonster = m
}
}
if (clickedMonster !== null) {
this.activeClickType = 'monster'
const distToPlayer = Math.hypot(clickedMonster.x - player.x, clickedMonster.y - player.y)
if (distToPlayer <= range) {
this.navTarget = null
this.pendingInteraction = null
player.facing = facingOf(clickedMonster.x - player.x, clickedMonster.y - player.y)
this.castSkill(rightSkillId, clickedMonster.x, clickedMonster.y)
} else {
this.pendingInteraction = { kind: 'monster', monster: clickedMonster, skillId: rightSkillId }
this.navTarget = { x: clickedMonster.x, y: clickedMonster.y }
}
return
}
// 2. Ground clicked with active right skill
if (rightSkillId === 0) {
const weapon = this.hudManager?.inventory?.equipped?.weapon1
const weaponInfo = getAttackWeaponInfo(weapon)
if (weaponInfo.isRanged) {
this.navTarget = null
this.pendingInteraction = null
this.castSkill(rightSkillId, worldX, worldY)
} else {
this.activeClickType = 'ground'
this.pendingInteraction = null
this.navTarget = { x: worldX, y: worldY }
this.lastDist = Math.hypot(worldX - player.x, worldY - player.y)
this.stuckTicks = 0
}
} else {
// Active skill execution: halt movement and cancel pending interactions
this.navTarget = null
this.pendingInteraction = null
this.castSkill(rightSkillId, worldX, worldY)
@ -1280,7 +1594,7 @@ export class SceneMouseController {
if (clickedMonster !== null) {
this.activeClickType = 'monster'
const leftSkillId = this.hudManager ? this.hudManager.hotkeys.leftSkillId : 47
const range = getSkillRange(leftSkillId)
const range = getSkillRange(leftSkillId, this.hudManager)
const distToPlayer = Math.hypot(clickedMonster.x - player.x, clickedMonster.y - player.y)
if (distToPlayer <= range) {
this.navTarget = null
@ -1288,7 +1602,7 @@ export class SceneMouseController {
player.facing = facingOf(clickedMonster.x - player.x, clickedMonster.y - player.y)
this.castSkill(leftSkillId, clickedMonster.x, clickedMonster.y)
} else {
this.pendingInteraction = { kind: 'monster', monster: clickedMonster }
this.pendingInteraction = { kind: 'monster', monster: clickedMonster, skillId: leftSkillId }
this.navTarget = { x: clickedMonster.x, y: clickedMonster.y }
}
return
@ -1318,7 +1632,16 @@ export class SceneMouseController {
})
}
castSkill(skillId: number, targetX: number, targetY: number): boolean {
castSkill(
skillId: number,
targetX: number,
targetY: number,
options?: {
equippedWeapon?: any
equippedAmmo?: any
hasAmmo?: boolean
}
): boolean {
return castSkill(skillId, targetX, targetY, {
engine: this.engine,
runtime: this.getRuntime(),
@ -1326,6 +1649,9 @@ export class SceneMouseController {
status: this.status,
playerAnimator: this.playerAnimator,
character: this.getCharacter ? this.getCharacter() : undefined,
equippedWeapon: options?.equippedWeapon,
equippedAmmo: options?.equippedAmmo,
hasAmmo: options?.hasAmmo,
})
}
@ -1379,7 +1705,10 @@ export class SceneMouseController {
}
} else if (this.isRightMouseDown && this.lastClientPos !== null) {
const rightSkillId = this.hudManager ? this.hudManager.hotkeys.rightSkillId : 64
if (rightSkillId === 0 || isPassiveSkill(rightSkillId)) {
if (isPassiveSkill(rightSkillId)) {
const { x: worldX, y: worldY } = this.getPointerWorldCoords(this.lastClientPos.x, this.lastClientPos.y)
this.navTarget = { x: worldX, y: worldY }
} else if (rightSkillId === 0 && !this.input.shiftHeld && this.activeClickType === 'ground') {
const { x: worldX, y: worldY } = this.getPointerWorldCoords(this.lastClientPos.x, this.lastClientPos.y)
this.navTarget = { x: worldX, y: worldY }
} else if (player.cooldown === 0) {
@ -1437,14 +1766,14 @@ export class SceneMouseController {
this.navTarget = null
this.pendingInteraction = null
} else {
const leftSkillId = this.hudManager ? this.hudManager.hotkeys.leftSkillId : 47
const range = getSkillRange(leftSkillId)
const skillId = this.pendingInteraction.skillId ?? (this.hudManager ? this.hudManager.hotkeys.leftSkillId : 47)
const range = getSkillRange(skillId, this.hudManager)
const dist = Math.hypot(monster.x - player.x, monster.y - player.y)
if (dist <= range) {
this.navTarget = null
this.pendingInteraction = null
player.facing = facingOf(monster.x - player.x, monster.y - player.y)
this.castSkill(leftSkillId, monster.x, monster.y)
this.castSkill(skillId, monster.x, monster.y)
} else {
this.navTarget = { x: monster.x, y: monster.y }
}
@ -2653,24 +2982,7 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
state.character = true
state.characterMembers = nextChar.members
if (hudManager !== null) {
if (hudManager.charSheet) {
hudManager.charSheet.attrs.name = nextInfo.charIdentity.name
hudManager.charSheet.attrs.classNameZh = nextInfo.charIdentity.classNameZh
hudManager.charSheet.attrs.classNameEn = nextInfo.charIdentity.classNameEn
hudManager.charSheet.attrs.str = nextInfo.charIdentity.str
hudManager.charSheet.attrs.dex = nextInfo.charIdentity.dex
hudManager.charSheet.attrs.vit = nextInfo.charIdentity.vit
hudManager.charSheet.attrs.ene = nextInfo.charIdentity.ene
hudManager.charSheet.attrs.baseMaxHp = nextInfo.charIdentity.baseMaxHp
hudManager.charSheet.attrs.baseMaxMana = nextInfo.charIdentity.baseMaxMana
}
hudManager.maxHp = nextInfo.charIdentity.baseMaxHp
hudManager.hp = nextInfo.charIdentity.baseMaxHp
hudManager.maxMana = nextInfo.charIdentity.baseMaxMana
hudManager.mana = nextInfo.charIdentity.baseMaxMana
hudManager.hotkeys.leftSkillId = nextInfo.defaultLeftSkill
hudManager.hotkeys.rightSkillId = nextInfo.defaultRightSkill
hudManager.syncPublishedState()
hudManager.switchClass(nextInfo.code as CharacterClassCode)
}
if (heroSelect !== null && heroSelect.value !== nextToken) {
heroSelect.value = nextToken
@ -2871,6 +3183,8 @@ if (typeof window !== 'undefined') {
if (hudManager !== null) {
try {
await hudManager.init('/ui')
const initialHeroInfo = HERO_CLASSES[currentHeroToken] ?? HERO_CLASSES.so
hudManager.switchClass(initialHeroInfo.code as CharacterClassCode)
} catch (err) {
console.error('Failed to initialize HudManager:', err)
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -24,6 +24,11 @@ import { CharacterSheetPanel, type BaseStatKey } from './character-sheet.ts'
import { SkillTreePanel, SORCERESS_SKILL_TREE } from './skill-tree-panel.ts'
import { WorldPanelsHud, type LeftDockPanelKind } from './world-panels.ts'
import { CursorController, type CursorState } from './cursor.ts'
import type { CharacterClassCode } from '../game/classes.ts'
import {
createStarterProfileForClass,
type ClassProfileState,
} from '../game/class-starter-profiles.ts'
export interface PublishedHudState {
ready: boolean
@ -63,6 +68,7 @@ export interface PublishedHudState {
maxMana: number
stamina: number
maxStamina: number
docking: DockingLayout
}
declare global {
@ -90,6 +96,17 @@ export interface HudLayout {
readonly physicalOffsetY: number
}
export interface DockingLayout extends HudLayout {
marginW: number
leftDockX: number
rightDockX: number
channelLeft: number
channelRight: number
channelWidthLogical: number
channelWidthCss: number
isWidescreen: boolean
}
/**
* Computes 1:1 uniform aspect ratio scaling and horizontal centering margins
* for the canonical 800x600 Diablo II HUD viewport onto an arbitrary window/canvas size.
@ -113,6 +130,38 @@ export function computeHudLayout(cssW: number, cssH: number, dpr: number = 1): H
}
}
export { CHAR_PANEL_ORIGIN } from './character-sheet.ts'
export { INV_PANEL_ORIGIN, INV_GRID_ORIGIN } from './inventory.ts'
export { SKILL_PANEL_ORIGIN } from './skill-tree-panel.ts'
export { STASH_PANEL_ORIGIN, STASH_GRID_ORIGIN } from './world-panels.ts'
/**
* Computes widescreen panel docking layout metrics, margins, and central vision corridor.
*/
export function computeDockingLayout(cssW: number, cssH: number, dpr: number = 1): DockingLayout {
const layout = computeHudLayout(cssW, cssH, dpr)
const marginW = layout.uiScale > 0 ? layout.offsetX / layout.uiScale : 0
const leftDockX = -marginW || 0
const rightDockX = 400 + marginW
const channelLeft = 400 - marginW
const channelRight = 400 + marginW
const channelWidthLogical = 2 * marginW
const channelWidthCss = 2 * layout.offsetX
const isWidescreen = marginW > 0
return {
...layout,
marginW,
leftDockX,
rightDockX,
channelLeft,
channelRight,
channelWidthLogical,
channelWidthCss,
isWidescreen,
}
}
export function clientToLogical(
clientX: number,
clientY: number,
@ -227,8 +276,12 @@ export class HudManager {
maxMana: 940,
stamina: 465,
maxStamina: 465,
docking: computeDockingLayout(800, 600, 1),
}
currentClass: CharacterClassCode | null = null
private classProfiles = new Map<CharacterClassCode, ClassProfileState>()
constructor(
private readonly hudCanvas: HTMLCanvasElement,
private readonly callbacks: {
@ -238,6 +291,7 @@ export class HudManager {
onDropItemToGround?: (name: string) => void
},
) {
this.hotkeys.tooltipResolver = (skillId: number) => this.skillTree.getTooltipViewModel(skillId)
if (this.hudCanvas) {
this.hudCanvas.style.cursor = 'none'
}
@ -247,6 +301,84 @@ export class HudManager {
}
}
/**
* Switches active character class with complete isolation and persistence across
* inventory, exclusive signature sets on body ONLY, stash treasures (runes/gems/jewels/gold),
* attributes, 30-skill trees, and hotkeys (Parity with Issue #379).
*/
switchClass(classCode: CharacterClassCode): void {
// 1. Snapshot current class state if currentClass is already initialized
if (this.currentClass && this.classProfiles.has(this.currentClass)) {
const currentProfile: ClassProfileState = {
classCode: this.currentClass,
equipped: { ...this.inventory.equipped },
bagItems: this.inventory.gridItems.map(gp => ({ ...gp, item: { ...gp.item } })),
stashItems: this.worldPanels.stashItems.map(gp => ({ ...gp, item: { ...gp.item } })),
stashGold: this.worldPanels.stashGold,
attrs: { ...this.charSheet.attrs },
hardPoints: new Map(this.skillTree.hardPoints),
unspentSkillPoints: this.skillTree.unspentSkillPoints,
activeSkillTab: this.skillTree.activeTab,
defaultLeftSkill: this.hotkeys.leftSkillId,
defaultRightSkill: this.hotkeys.rightSkillId,
availableSkills: [...this.hotkeys.availableSkills],
hp: this.hp,
mana: this.mana,
}
this.classProfiles.set(this.currentClass, currentProfile)
}
// 2. Fetch target profile or create fresh starter profile
let targetProfile = this.classProfiles.get(classCode)
if (!targetProfile) {
targetProfile = createStarterProfileForClass(classCode)
this.classProfiles.set(classCode, targetProfile)
}
// 3. Apply profile to current systems
this.currentClass = classCode
// 3.1 Inventory & Equipment: Body has ONLY exclusive set items
for (const key of Object.keys(this.inventory.equipped) as EquipSlotId[]) {
delete this.inventory.equipped[key]
}
Object.assign(this.inventory.equipped, targetProfile.equipped)
this.inventory.gridItems = targetProfile.bagItems.map(gp => ({ ...gp, item: { ...gp.item } }))
// 3.2 Stash: gems, runes, jewels, and gold
this.worldPanels.stashItems = targetProfile.stashItems.map(gp => ({ ...gp, item: { ...gp.item } }))
this.worldPanels.stashGold = targetProfile.stashGold
// 3.3 Character Sheet
this.charSheet.attrs = { ...targetProfile.attrs }
// 3.4 Skill Tree
this.skillTree.loadProfile(
targetProfile.classCode,
targetProfile.hardPoints,
targetProfile.unspentSkillPoints,
targetProfile.activeSkillTab,
)
// 3.5 Hotkeys
this.hotkeys.setAvailableSkills(
targetProfile.availableSkills,
targetProfile.defaultLeftSkill,
targetProfile.defaultRightSkill,
)
this.hotkeys.hasEquippedWeapon = Boolean(this.inventory.equipped.weapon1 || this.inventory.equipped.weapon2)
// 3.6 Published vitals
this.maxHp = targetProfile.attrs.baseMaxHp
this.hp = Math.min(targetProfile.hp, this.maxHp)
this.maxMana = targetProfile.attrs.baseMaxMana
this.mana = Math.min(targetProfile.mana, this.maxMana)
this.stamina = targetProfile.attrs.baseMaxStamina
this.maxStamina = targetProfile.attrs.baseMaxStamina
this.syncPublishedState()
}
async init(baseUrl = '/ui'): Promise<void> {
this.hotkeys.preloadSkillIcons()
this.skillTree.preloadAssets()
@ -292,6 +424,10 @@ export class HudManager {
levelBtn: `${baseUrl}/level-btn.png`,
levelSocket: `${baseUrl}/level-socket.png`,
skillPoints: `${baseUrl}/skillpoints.png`,
skillTabs: `${baseUrl}/skill-tree-tabs.png`,
waypointTabs: `${baseUrl}/waypoint-tabs.png`,
waypointIcons: `${baseUrl}/waypoint-icons.png`,
questTabs: `${baseUrl}/quest-tabs.png`,
genericSkills: `${baseUrl}/generic-skills.png`,
cursorHand: `${baseUrl}/cursor-hand.png`,
cursorAtlas: `${baseUrl}/cursor.png`,
@ -386,24 +522,42 @@ export class HudManager {
return logicalToClient(logicalX, logicalY, this.hudCanvas.getBoundingClientRect())
}
/**
* Returns current widescreen panel docking layout metrics.
*/
getDockingLayout(): DockingLayout {
const hasWindow = typeof window !== 'undefined'
const dpr = Math.min(hasWindow && window.devicePixelRatio ? window.devicePixelRatio : 1, 2)
const rect = this.hudCanvas?.getBoundingClientRect?.()
const canvasW = this.hudCanvas?.clientWidth || rect?.width || (this.hudCanvas?.width ?? 0)
const canvasH = this.hudCanvas?.clientHeight || rect?.height || (this.hudCanvas?.height ?? 0)
const winW = hasWindow && window.innerWidth ? window.innerWidth : 800
const winH = hasWindow && window.innerHeight ? window.innerHeight : 600
const cssW = Math.max(320, canvasW || winW)
const cssH = Math.max(240, canvasH || winH)
return computeDockingLayout(cssW, cssH, dpr)
}
/**
* Whether a logical `(x, y)` point hits an active HUD panel or control bar,
* so scene clicks/walks underneath are suppressed.
*
* Out-of-bounds coordinates (< 0 or > 800 / 600) fall in the letterbox/pillarbox
* widescreen margins and must return false so scene navigation is not blocked.
* Out-of-bounds coordinates (< -marginW or > 800 + marginW / 600) fall outside the canvas
* and must return false so scene navigation is not blocked.
* Central vision corridor (400 - marginW < logicalX < 400 + marginW) remains open and returns false.
*/
isPointInterceptedByHud(logicalX: number, logicalY: number): boolean {
if (logicalX < 0 || logicalX > 800 || logicalY < 0 || logicalY > 600) return false
if (logicalY >= 540) return true
if (logicalX <= 117 && logicalY >= 496) return true
if (logicalX >= 683 && logicalY >= 496) return true
const docking = this.getDockingLayout()
const { marginW } = docking
if (logicalX < -marginW || logicalX > 800 + marginW || logicalY < 0 || logicalY > 600) return false
if (logicalY >= 540 && logicalX >= 0 && logicalX <= 800) return true
if (logicalX >= 0 && logicalX <= 117 && logicalY >= 496) return true
if (logicalX >= 683 && logicalX <= 800 && logicalY >= 496) return true
if (this.controlBar.miniPanelOpen && logicalX >= 312 && logicalX <= 488 && logicalY >= 522) return true
if (this.belt.isExpanded && logicalX >= 420 && logicalX <= 550 && logicalY >= 460) return true
if (this.hotkeys.openPopup !== null) return true
if (this.leftPanel !== 'none' && logicalX <= 400 && logicalY <= 553) return true
if (this.rightPanel !== 'none' && logicalX >= 400 && logicalY <= 553) return true
if (this.leftPanel !== 'none' && logicalX >= -marginW && logicalX <= 400 - marginW && logicalY <= 553) return true
if (this.rightPanel !== 'none' && logicalX >= 400 + marginW && logicalX <= 800 + marginW && logicalY <= 553) return true
return false
}
@ -481,12 +635,23 @@ export class HudManager {
this.belt.handleMouseMove(pt.x, pt.y)
this.hotkeys.handleMouseMove(pt.x, pt.y)
this.controlBar.handleMouseMove(pt.x, pt.y)
this.inventory.handleMouseMove(pt.x, pt.y)
this.skillTree.handleMouseMove(pt.x, pt.y)
const docking = this.getDockingLayout()
const deltaLeft = -docking.marginW
const deltaRight = docking.marginW
this.inventory.handleMouseMove(pt.x - deltaRight, pt.y)
if (this.inventory.hoveredItem) {
this.inventory.hoveredItem.x += deltaRight
}
this.skillTree.handleMouseMove(pt.x - deltaRight, pt.y)
if (this.leftPanel === 'stash') {
this.worldPanels.handleMouseMove(pt.x, pt.y)
this.worldPanels.handleMouseMove(pt.x - deltaLeft, pt.y)
if (this.worldPanels.hoveredStashItem) {
this.inventory.hoveredItem = this.worldPanels.hoveredStashItem
this.inventory.hoveredItem = {
...this.worldPanels.hoveredStashItem,
x: this.worldPanels.hoveredStashItem.x + deltaLeft,
}
}
}
})
@ -559,10 +724,15 @@ export class HudManager {
return
}
// Left-Half Dock Click (`0..400`)
if (pt.x <= 400) {
const docking = this.getDockingLayout()
const deltaLeft = -docking.marginW
const deltaRight = docking.marginW
// Left-Half Dock Click
if (pt.x <= 400 + deltaLeft) {
const leftX = pt.x - deltaLeft
if (this.leftPanel === 'char') {
this.charSheet.handleClick(pt.x, pt.y, (key: BaseStatKey) => {
this.charSheet.handleClick(leftX, pt.y, (key: BaseStatKey) => {
if (key === 'vit') {
this.maxHp = this.charSheet.attrs.baseMaxHp
this.hp = this.maxHp
@ -577,7 +747,7 @@ export class HudManager {
this.syncPublishedState()
return
} else if (this.leftPanel !== 'none') {
this.worldPanels.handleLeftDockClick(this.leftPanel, pt.x, pt.y, {
this.worldPanels.handleLeftDockClick(this.leftPanel, leftX, pt.y, {
onClose: () => {
this.leftPanel = 'none'
},
@ -593,17 +763,18 @@ export class HudManager {
}
}
// Right-Half Dock Click (`400..800`)
if (pt.x >= 400) {
// Right-Half Dock Click
if (pt.x >= 400 + deltaRight) {
const rightX = pt.x - deltaRight
if (this.rightPanel === 'inv') {
if (this.leftPanel === 'stash' && (e.shiftKey || e.button === 2)) {
if (
pt.x >= INV_GRID_ORIGIN.x &&
pt.x < INV_GRID_ORIGIN.x + INV_GRID_ORIGIN.cols * INV_GRID_ORIGIN.cellPx &&
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((pt.x - INV_GRID_ORIGIN.x) / 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 hit = this.inventory.gridItems.find(
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
@ -615,7 +786,7 @@ export class HudManager {
}
}
}
this.inventory.handleClick(pt.x, pt.y, {
this.inventory.handleClick(rightX, pt.y, {
onOpenCube: () => this.toggleLeftPanel('cube'),
onCastTownPortal: () => this.callbacks.onCastTownPortal?.(),
})
@ -623,7 +794,7 @@ export class HudManager {
this.syncPublishedState()
return
} else if (this.rightPanel === 'skill') {
this.skillTree.handleClick(pt.x, pt.y)
this.skillTree.handleClick(rightX, pt.y)
if (!this.skillTree.visible) this.rightPanel = 'none'
this.syncPublishedState()
return
@ -683,6 +854,7 @@ export class HudManager {
this.state.maxMana = Math.round(this.maxMana)
this.state.stamina = Math.round(this.stamina)
this.state.maxStamina = Math.round(this.maxStamina)
this.state.docking = this.getDockingLayout()
}
/**
@ -709,7 +881,10 @@ export class HudManager {
this.hudCanvas.height = targetH
}
const { uiScale, offsetX, offsetY } = computeHudLayout(cssW, cssH)
const docking = computeDockingLayout(cssW, cssH, dpr)
const { uiScale, offsetX, offsetY, marginW } = docking
const deltaLeft = -marginW
const deltaRight = marginW
// Enable pointer events on HUD canvas only when hovering HUD/Panels or holding cursorItem
const intercept =
@ -730,6 +905,8 @@ export class HudManager {
const rightNode = SORCERESS_SKILL_TREE.find(n => n.skillId === this.hotkeys.rightSkillId)
const lEff = Math.max(1, this.skillTree.getEffectiveLevel(this.hotkeys.leftSkillId))
const rEff = Math.max(1, this.skillTree.getEffectiveLevel(this.hotkeys.rightSkillId))
ctx.save()
ctx.translate(deltaLeft, 0)
this.charSheet.draw(
ctx,
{
@ -754,7 +931,10 @@ export class HudManager {
},
this.font,
)
ctx.restore()
} else if (this.leftPanel !== 'none') {
ctx.save()
ctx.translate(deltaLeft, 0)
this.worldPanels.drawLeftDockPanel(
ctx,
this.leftPanel,
@ -768,13 +948,19 @@ export class HudManager {
buySellBtnImg: this.images.get('buySellBtn') ?? null,
questsAtlasImg: this.images.get('questsAtlas') ?? null,
itemsAtlasImg: this.images.get('itemsAtlas') ?? null,
waypointTabsImg: this.images.get('waypointTabs') ?? null,
waypointIconsImg: this.images.get('waypointIcons') ?? null,
questTabsImg: this.images.get('questTabs') ?? null,
},
this.font,
)
ctx.restore()
}
// 2. Right-Half Dock Panel (`400..800, 0..553`)
if (this.rightPanel === 'inv') {
ctx.save()
ctx.translate(deltaRight, 0)
this.inventory.draw(
ctx,
{
@ -788,16 +974,21 @@ export class HudManager {
},
this.font,
)
ctx.restore()
} else if (this.rightPanel === 'skill') {
ctx.save()
ctx.translate(deltaRight, 0)
this.skillTree.draw(
ctx,
{
borderRightImg: this.images.get('borderRight') ?? null,
skillPointsImg: this.images.get('skillPoints') ?? null,
skillTabsImg: this.images.get('skillTabs') ?? null,
buySellBtnImg: this.images.get('buySellBtn') ?? null,
},
this.font,
)
ctx.restore()
}
// 3. Bottom 800px Control Panel (`800CtrlPnl7.dc6`)
@ -836,7 +1027,12 @@ export class HudManager {
)
// 5. Left & Right Active Skill Slots & Speedbar Popup (`spellsel.cpp`)
this.hotkeys.draw(ctx, this.images.get('genericSkills') ?? null, this.font)
this.hotkeys.draw(
ctx,
this.images.get('genericSkills') ?? null,
this.font,
Boolean(this.inventory.equipped.weapon1),
)
// 6. 4x4 Belt & Pop-up Belt (`ctrlpnl_popbelt.dc6`)
this.belt.draw(

View File

@ -103,6 +103,8 @@ export interface ResolvableItem {
readonly invFile?: string | undefined
readonly invtransform?: string | undefined
readonly chrtransform?: string | undefined
readonly variant?: number | undefined
readonly id?: string | undefined
readonly invWidth?: number | undefined
readonly invHeight?: number | undefined
readonly width?: number | undefined
@ -125,6 +127,10 @@ export function resolveItemSpriteRect(
// Tier 0.5: Authentic Diablo II 1.13c Charm Resolution (cm1, cm2, cm3, Annihilus, Torch, Gheed's)
if (item.code === 'cm1' || item.code === 'anni' || item.name?.includes('Annihilus')) {
const validSmall = ['invch1', 'invch4', 'invch7', 'invmss']
if (item.variant !== undefined && !item.name?.includes('Annihilus') && item.code !== 'anni') {
const v = ['invch1', 'invch4', 'invch7'][Math.abs(Math.floor(item.variant)) % 3]
if (itemRects[v]) return itemRects[v]
}
const file = item.invFile?.toLowerCase()
const direct = file && validSmall.includes(file) ? (itemRects[item.invFile!] ?? itemRects[file]) : undefined
if (item.code === 'anni' || item.name?.includes('Annihilus')) {
@ -134,6 +140,10 @@ export function resolveItemSpriteRect(
}
if (item.code === 'cm2' || item.code === 'torch' || item.name?.includes('Torch')) {
const validLarge = ['invch2', 'invch5', 'invch8', 'invtrch']
if (item.variant !== undefined && !item.name?.includes('Torch') && item.code !== 'torch') {
const v = ['invch2', 'invch5', 'invch8'][Math.abs(Math.floor(item.variant)) % 3]
if (itemRects[v]) return itemRects[v]
}
const file = item.invFile?.toLowerCase()
const direct = file && validLarge.includes(file) ? (itemRects[item.invFile!] ?? itemRects[file]) : undefined
if (item.code === 'torch' || item.name?.includes('Torch')) {
@ -143,11 +153,85 @@ export function resolveItemSpriteRect(
}
if (item.code === 'cm3' || item.code === 'gheeds' || item.name?.includes('Gheed')) {
const validGrand = ['invch3', 'invch6', 'invch9']
if (item.variant !== undefined) {
const v = validGrand[Math.abs(Math.floor(item.variant)) % validGrand.length]
if (itemRects[v]) return itemRects[v]
}
const file = item.invFile?.toLowerCase()
const direct = file && validGrand.includes(file) ? (itemRects[item.invFile!] ?? itemRects[file]) : undefined
return direct ?? itemRects['invch3'] ?? itemRects['invch6'] ?? itemRects['invch9'] ?? null
}
// Tier 0.6: Authentic Diablo II 1.13c ItemTypes.txt VarInvGfx (Ring: 5, Amulet: 3, Jewel: 6)
if (item.code === 'rin' || item.code === 'ring' || (item.invFile && /^invrin\d?$/i.test(item.invFile))) {
const ringVariants = ['invrin1', 'invrin2', 'invrin3', 'invrin4', 'invrin5']
if (item.variant !== undefined) {
const idx = Math.abs(Math.floor(item.variant)) % ringVariants.length
const key = ringVariants[idx]
if (itemRects[key]) return itemRects[key]
}
if (item.invFile && item.invFile.toLowerCase() !== 'invrin' && itemRects[item.invFile]) {
return itemRects[item.invFile]
}
if (item.id) {
let hash = 0
for (let i = 0; i < item.id.length; i++) {
hash = ((hash << 5) - hash + item.id.charCodeAt(i)) | 0
}
const idx = Math.abs(hash) % ringVariants.length
const key = ringVariants[idx]
if (itemRects[key]) return itemRects[key]
}
return itemRects['invrin1'] ?? itemRects['invrin'] ?? null
}
if (item.code === 'amu' || item.code === 'amul' || (item.invFile && /^invamu\d?$/i.test(item.invFile))) {
const amuVariants = ['invamu1', 'invamu2', 'invamu3']
if (item.variant !== undefined) {
const idx = Math.abs(Math.floor(item.variant)) % amuVariants.length
const key = amuVariants[idx]
if (itemRects[key]) return itemRects[key]
}
if (item.invFile && item.invFile.toLowerCase() !== 'invamu' && itemRects[item.invFile]) {
return itemRects[item.invFile]
}
if (item.name && BAKED_UI_MANIFEST.codeToInvFile[item.name] && itemRects[BAKED_UI_MANIFEST.codeToInvFile[item.name]]) {
return itemRects[BAKED_UI_MANIFEST.codeToInvFile[item.name]]
}
if (item.id) {
let hash = 0
for (let i = 0; i < item.id.length; i++) {
hash = ((hash << 5) - hash + item.id.charCodeAt(i)) | 0
}
const idx = Math.abs(hash) % amuVariants.length
const key = amuVariants[idx]
if (itemRects[key]) return itemRects[key]
}
return itemRects['invamu1'] ?? itemRects['invamu'] ?? null
}
if (item.code === 'jew' || item.code === 'jewl' || (item.invFile && /^(invjw|invjew)\d?$/i.test(item.invFile))) {
const jewelVariants = ['invjw1', 'invjw2', 'invjw3', 'invjw4', 'invjw5', 'invjw6']
if (item.variant !== undefined) {
const idx = Math.abs(Math.floor(item.variant)) % jewelVariants.length
const key = jewelVariants[idx]
if (itemRects[key]) return itemRects[key]
}
if (item.invFile && itemRects[item.invFile]) {
return itemRects[item.invFile]
}
if (item.id) {
let hash = 0
for (let i = 0; i < item.id.length; i++) {
hash = ((hash << 5) - hash + item.id.charCodeAt(i)) | 0
}
const idx = Math.abs(hash) % jewelVariants.length
const key = jewelVariants[idx]
if (itemRects[key]) return itemRects[key]
}
return itemRects['invjw1'] ?? null
}
// Tier 1: Direct invFile (ignore misc.txt placeholder invfiles for charms)
const isCharmPlaceholder =
(item.code === 'cm3' && item.invFile?.toLowerCase() === 'invsst') ||

View File

@ -8,7 +8,21 @@
* point allocation, prerequisite checks, and rich hover tooltips.
*/
import type { D2FontRenderer } from './font.ts'
import type { D2FontRenderer, D2ColorCode } from './font.ts'
import type { CharacterClassCode } from '../game/classes.ts'
import { SKILLS_CATALOG } from '../data/skills-catalog.ts'
import {
buildTooltipViewModel,
type TooltipViewModel,
type SkillCalcState,
} from '../game/skill-calc-engine.ts'
export interface FormattedTooltipLine {
readonly text: string
readonly color: D2ColorCode
readonly font: 'fontexocet10' | 'font8'
readonly align?: 'left' | 'center'
}
export interface SkillNodeDef {
readonly skillId: number
@ -29,6 +43,10 @@ export interface SkillNodeDef {
export const SKILL_PANEL_ORIGIN = { x: 400, y: 60, width: 320, height: 432 } as const
export const SKILL_CLOSE_BTN_BOUNDS = { x: 400 + 272, y: 60 + 388, w: 32, h: 32 } as const
/** Authentic D2Client.dll 1.13c ground truth grid offsets inside panel (ox=400, oy=60). */
export const SKILL_TREE_COL_OFFSETS_X = [15, 84, 153] as const
export const SKILL_TREE_ROW_OFFSETS_Y = [14, 82, 150, 218, 287, 355] as const
/** Authentic Sorceress 30 skills (`Skills.txt` / `SkillDesc.txt` row & column coordinates). */
export const SORCERESS_SKILL_TREE: readonly SkillNodeDef[] = [
// Tab 2: Cold Spells (Top vertical tab in D2 UI)
@ -68,12 +86,93 @@ export const SORCERESS_SKILL_TREE: readonly SkillNodeDef[] = [
{ skillId: 62, tab: 0, row: 6, col: 3, reqLevel: 30, prereqs: [52], nameEn: 'Hydra', nameZh: '九头海蛇', descZh: '召唤多头喷火巨蛇攻击周围敌人', baseMana: 20, baseDmgMin: 24, baseDmgMax: 32, dmgPerLevel: 18 },
]
export interface SkillTreeTabInfo {
readonly tab: 0 | 1 | 2
readonly titleZh: string
readonly subZh: string
readonly y: number
}
export const CLASS_VERTICAL_TABS: Record<CharacterClassCode, readonly SkillTreeTabInfo[]> = {
ama: [
{ tab: 0, titleZh: '弓和', subZh: '十字弓', y: 95 },
{ tab: 1, titleZh: '被动和', subZh: '魔法', y: 195 },
{ tab: 2, titleZh: '标枪和', subZh: '长矛', y: 295 },
],
sor: [
{ tab: 2, titleZh: '冰冷', subZh: '法术', y: 95 },
{ tab: 1, titleZh: '闪电', subZh: '法术', y: 195 },
{ tab: 0, titleZh: '火焰', subZh: '法术', y: 295 },
],
nec: [
{ tab: 2, titleZh: '召唤', subZh: '法术', y: 95 },
{ tab: 1, titleZh: '毒素和', subZh: '白骨', y: 195 },
{ tab: 0, titleZh: '诅咒', subZh: '法术', y: 295 },
],
pal: [
{ tab: 0, titleZh: '作战', subZh: '技能', y: 95 },
{ tab: 1, titleZh: '攻击', subZh: '灵气', y: 195 },
{ tab: 2, titleZh: '防御', subZh: '灵气', y: 295 },
],
bar: [
{ tab: 0, titleZh: '战斗', subZh: '技能', y: 95 },
{ tab: 1, titleZh: '战斗', subZh: '专家', y: 195 },
{ tab: 2, titleZh: '呐喊', subZh: '技能', y: 295 },
],
dru: [
{ tab: 2, titleZh: '元素', subZh: '技能', y: 95 },
{ tab: 1, titleZh: '变形', subZh: '技能', y: 195 },
{ tab: 0, titleZh: '召唤', subZh: '技能', y: 295 },
],
ass: [
{ tab: 2, titleZh: '武学', subZh: '艺术', y: 95 },
{ tab: 1, titleZh: '暗影', subZh: '武艺', y: 195 },
{ tab: 0, titleZh: '陷阱', subZh: '技能', y: 295 },
],
}
function buildClassSkillTrees(): Record<CharacterClassCode, readonly SkillNodeDef[]> {
const result: Record<CharacterClassCode, SkillNodeDef[]> = {
ama: [],
sor: [],
nec: [],
pal: [],
bar: [],
dru: [],
ass: [],
}
for (const s of SKILLS_CATALOG) {
const list = result[s.classCode]
if (!list) continue
list.push({
skillId: s.id,
tab: s.tabIndex as 0 | 1 | 2,
row: s.row as 1 | 2 | 3 | 4 | 5 | 6,
col: s.col as 1 | 2 | 3,
reqLevel: s.reqlevel,
prereqs: s.reqskills,
nameEn: s.name,
nameZh: s.nameZh,
descZh: s.shortDescZh || s.longDescZh || '',
baseMana: s.mana,
baseDmgMin: s.minDam,
baseDmgMax: s.maxDam,
dmgPerLevel: 5,
})
}
return result
}
export const CLASS_SKILL_TREES: Record<CharacterClassCode, readonly SkillNodeDef[]> = buildClassSkillTrees()
export class SkillTreePanel {
visible = false
/** Active skill tab: 2 = Cold (Top), 1 = Lightning (Middle), 0 = Fire (Bottom). */
/** Active skill tab: 2 = Cold (Top), 1 = Lightning (Middle), 0 = Fire (Bottom) for Sorceress. */
activeTab: 0 | 1 | 2 = 2
unspentSkillPoints = 12
bonusAllSkills = 8
characterClass: CharacterClassCode = 'sor'
characterLevel = 85
readonly hardPoints = new Map<number, number>([
[39, 1], [40, 1], [44, 1], [45, 5], [55, 10], [59, 20], [64, 20], [65, 10],
[38, 1], [42, 1], [43, 1], [49, 5], [54, 1],
@ -83,21 +182,59 @@ export class SkillTreePanel {
private tabBgs = new Map<number, HTMLImageElement>()
private skillIcons = new Map<number, HTMLImageElement>()
private tabBgClass: CharacterClassCode = 'sor'
getSkillTree(): readonly SkillNodeDef[] {
return CLASS_SKILL_TREES[this.characterClass] ?? SORCERESS_SKILL_TREE
}
getVerticalTabs(): readonly SkillTreeTabInfo[] {
return CLASS_VERTICAL_TABS[this.characterClass] ?? CLASS_VERTICAL_TABS.sor
}
preloadAssets(): void {
if (typeof Image === 'undefined') return
for (const tab of [0, 1, 2]) {
const bg = new Image()
bg.src = `/skills/bg_sor_${tab}.png`
this.tabBgs.set(tab, bg)
if (!this.tabBgs.has(tab) || this.tabBgClass !== this.characterClass) {
const bg = new Image()
bg.src = `/skills/bg_${this.characterClass}_${tab}.png`
this.tabBgs.set(tab, bg)
}
}
for (const node of SORCERESS_SKILL_TREE) {
const icon = new Image()
icon.src = `/skills/icon_${node.skillId}.png`
this.skillIcons.set(node.skillId, icon)
this.tabBgClass = this.characterClass
for (const node of this.getSkillTree()) {
if (!this.skillIcons.has(node.skillId)) {
const icon = new Image()
icon.src = `/skills/icon_${node.skillId}.png`
this.skillIcons.set(node.skillId, icon)
}
}
}
setClass(classCode: CharacterClassCode): void {
this.characterClass = classCode
const vTabs = this.getVerticalTabs()
this.activeTab = vTabs[0]?.tab ?? 0
this.preloadAssets()
}
loadProfile(
classCode: CharacterClassCode,
hardPoints: Map<number, number>,
unspent: number,
activeTab?: 0 | 1 | 2,
): void {
this.characterClass = classCode
this.hardPoints.clear()
for (const [id, pts] of hardPoints.entries()) {
this.hardPoints.set(id, pts)
}
this.unspentSkillPoints = unspent
const vTabs = this.getVerticalTabs()
this.activeTab = activeTab !== undefined ? activeTab : (vTabs[0]?.tab ?? 0)
this.preloadAssets()
}
getHardPoints(skillId: number): number {
return this.hardPoints.get(skillId) ?? 0
}
@ -107,9 +244,27 @@ export class SkillTreePanel {
return hard > 0 ? hard + this.bonusAllSkills : 0
}
getSkillCalcState(): SkillCalcState {
const hardPoints: Record<number, number> = {}
for (const [id, pts] of this.hardPoints.entries()) {
if (pts > 0) hardPoints[id] = pts
}
return {
classCode: this.characterClass,
hardPoints,
tabSoftPoints: { 0: 0, 1: 0, 2: 0 },
allSkillsSoftPoints: this.bonusAllSkills,
specificSoftPoints: {},
}
}
getTooltipViewModel(skillId: number): TooltipViewModel | null {
return buildTooltipViewModel(this.getSkillCalcState(), skillId, this.characterLevel)
}
canAllocate(skillId: number, charLevel = 85): boolean {
if (this.unspentSkillPoints <= 0) return false
const node = SORCERESS_SKILL_TREE.find(n => n.skillId === skillId)
const node = this.getSkillTree().find(n => n.skillId === skillId)
if (!node) return false
const cur = this.getHardPoints(skillId)
if (cur >= 20) return false
@ -125,15 +280,17 @@ export class SkillTreePanel {
}
static nodeScreenPos(node: SkillNodeDef): { x: number; y: number; w: number; h: number } {
const x = SKILL_PANEL_ORIGIN.x + 21 + (node.col - 1) * 69
const y = SKILL_PANEL_ORIGIN.y + 18 + (node.row - 1) * 68
const colIdx = Math.max(0, Math.min(2, node.col - 1))
const rowIdx = Math.max(0, Math.min(5, node.row - 1))
const x = SKILL_PANEL_ORIGIN.x + (SKILL_TREE_COL_OFFSETS_X[colIdx] ?? (15 + colIdx * 69))
const y = SKILL_PANEL_ORIGIN.y + (SKILL_TREE_ROW_OFFSETS_Y[rowIdx] ?? (14 + rowIdx * 68))
return { x, y, w: 48, h: 48 }
}
handleMouseMove(logicalX: number, logicalY: number): void {
this.hoveredSkill = null
if (!this.visible) return
const tabNodes = SORCERESS_SKILL_TREE.filter(n => n.tab === this.activeTab)
const tabNodes = this.getSkillTree().filter(n => n.tab === this.activeTab)
for (const node of tabNodes) {
const r = SkillTreePanel.nodeScreenPos(node)
if (logicalX >= r.x && logicalX <= r.x + r.w && logicalY >= r.y && logicalY <= r.y + r.h) {
@ -160,22 +317,23 @@ export class SkillTreePanel {
// Right vertical tabs (`x = 656..720`)
if (logicalX >= 656 && logicalX <= 720) {
if (logicalY >= 60 + 95 && logicalY < 60 + 195) {
this.activeTab = 2 // Cold
const vTabs = this.getVerticalTabs()
if (logicalY >= 60 + 95 && logicalY < 60 + 195 && vTabs[0]) {
this.activeTab = vTabs[0].tab
return true
}
if (logicalY >= 60 + 195 && logicalY < 60 + 295) {
this.activeTab = 1 // Lightning
if (logicalY >= 60 + 195 && logicalY < 60 + 295 && vTabs[1]) {
this.activeTab = vTabs[1].tab
return true
}
if (logicalY >= 60 + 295 && logicalY < 60 + 385) {
this.activeTab = 0 // Fire
if (logicalY >= 60 + 295 && logicalY < 60 + 385 && vTabs[2]) {
this.activeTab = vTabs[2].tab
return true
}
}
// Skill nodes
const tabNodes = SORCERESS_SKILL_TREE.filter(n => n.tab === this.activeTab)
const tabNodes = this.getSkillTree().filter(n => n.tab === this.activeTab)
for (const node of tabNodes) {
const r = SkillTreePanel.nodeScreenPos(node)
if (logicalX >= r.x && logicalX <= r.x + r.w && logicalY >= r.y && logicalY <= r.y + r.h) {
@ -195,6 +353,7 @@ export class SkillTreePanel {
borderRightImg: HTMLImageElement | null
skillPointsImg: HTMLImageElement | null
buySellBtnImg: HTMLImageElement | null
skillTabsImg?: HTMLImageElement | null
},
font: D2FontRenderer,
): void {
@ -218,10 +377,18 @@ export class SkillTreePanel {
}
// 3. Draw Right 64x432 Vertical Tab Column (`x = 656..720`)
ctx.fillStyle = '#14110d'
ctx.fillRect(ox + 256, oy, 64, 432)
ctx.strokeStyle = '#5a4a32'
ctx.strokeRect(ox + 256.5, oy + 0.5, 63, 431)
const verticalTabs = this.getVerticalTabs()
const tabColIdx = verticalTabs.findIndex(vt => vt.tab === this.activeTab)
const safeColIdx = tabColIdx >= 0 ? tabColIdx : 0
if (assets.skillTabsImg && assets.skillTabsImg.complete && assets.skillTabsImg.naturalWidth > 0) {
ctx.drawImage(assets.skillTabsImg, safeColIdx * 64, 0, 64, 432, ox + 256, oy, 64, 432)
} else {
ctx.fillStyle = '#14110d'
ctx.fillRect(ox + 256, oy, 64, 432)
ctx.strokeStyle = '#5a4a32'
ctx.strokeRect(ox + 256.5, oy + 0.5, 63, 431)
}
// Remaining Skill Points Header Box
font.drawText(ctx, '剩余技能点', ox + 288, oy + 26, {
@ -239,19 +406,29 @@ export class SkillTreePanel {
align: 'center',
})
// 3 Vertical Tabs: Cold (2), Lightning (1), Fire (0)
const verticalTabs: readonly { tab: 0 | 1 | 2; titleZh: string; subZh: string; y: number }[] = [
{ tab: 2, titleZh: '冰冷', subZh: '法术', y: 95 },
{ tab: 1, titleZh: '闪电', subZh: '法术', y: 195 },
{ tab: 0, titleZh: '火焰', subZh: '法术', y: 295 },
]
// Remaining Skill Points Banner (Authentic D2 skillpoints.dc6 banner at bottom of tree)
if (this.unspentSkillPoints > 0 && assets.skillPointsImg && assets.skillPointsImg.complete && assets.skillPointsImg.naturalWidth > 0) {
const spX = ox + Math.round((256 - 135) / 2)
const spY = oy + 395
ctx.drawImage(assets.skillPointsImg, spX, spY, 135, 23)
font.drawText(ctx, String(this.unspentSkillPoints), spX + 112, spY + 16, {
font: 'font16',
color: 'red',
align: 'center',
})
}
// 3 Vertical Tabs for Active Class
for (const vt of verticalTabs) {
const active = this.activeTab === vt.tab
ctx.fillStyle = active ? '#32281c' : '#1b1610'
ctx.fillRect(ox + 258, oy + vt.y, 60, 96)
ctx.strokeStyle = active ? '#e8c26b' : '#4a3c28'
ctx.lineWidth = active ? 2 : 1
ctx.strokeRect(ox + 258.5, oy + vt.y + 0.5, 59, 95)
if (!assets.skillTabsImg || !assets.skillTabsImg.complete || assets.skillTabsImg.naturalWidth === 0) {
// Authentic stone-beveled fallback when texture atlas is unavailable
ctx.fillStyle = active ? '#382e22' : '#1b1610'
ctx.fillRect(ox + 258, oy + vt.y, 60, 96)
ctx.strokeStyle = active ? '#c8a15a' : '#4a3c28'
ctx.lineWidth = active ? 2 : 1
ctx.strokeRect(ox + 258.5, oy + vt.y + 0.5, 59, 95)
}
font.drawText(ctx, vt.titleZh, ox + 288, oy + vt.y + 42, {
font: 'fontexocet10',
color: active ? 'gold' : 'tan',
@ -265,7 +442,7 @@ export class SkillTreePanel {
}
// 4. Draw Skill Nodes & Level Sockets on Active Tab
const tabNodes = SORCERESS_SKILL_TREE.filter(n => n.tab === this.activeTab)
const tabNodes = this.getSkillTree().filter(n => n.tab === this.activeTab)
for (const node of tabNodes) {
const pos = SkillTreePanel.nodeScreenPos(node)
const hard = this.getHardPoints(node.skillId)
@ -317,51 +494,259 @@ export class SkillTreePanel {
// 6. Hover Tooltip for Skill Node
if (this.hoveredSkill) {
const s = this.hoveredSkill
const pos = SkillTreePanel.nodeScreenPos(s)
const eff = Math.max(1, this.getEffectiveLevel(s.skillId))
const hard = this.getHardPoints(s.skillId)
const minDmg = s.baseDmgMin + (eff - 1) * s.dmgPerLevel
const maxDmg = s.baseDmgMax + (eff - 1) * Math.round(s.dmgPerLevel * 1.25)
const tw = 240
const th = 112
const tx = Math.max(404, Math.min(800 - tw - 8, pos.x - 80))
const ty = Math.max(12, pos.y - th - 6)
this.drawSkillTooltip(ctx, font)
}
}
ctx.fillStyle = 'rgba(6, 6, 6, 0.94)'
ctx.fillRect(tx, ty, tw, th)
ctx.strokeStyle = '#c8a15a'
ctx.strokeRect(tx + 0.5, ty + 0.5, tw - 1, th - 1)
static formatSkillTooltipLines(vm: TooltipViewModel): FormattedTooltipLine[] {
const lines: FormattedTooltipLine[] = []
font.drawText(ctx, `${s.nameZh} (${s.nameEn})`, tx + tw / 2, ty + 20, {
font: 'fontexocet10',
color: 'gold',
align: 'center',
})
font.drawText(ctx, s.descZh, tx + tw / 2, ty + 38, {
// 1. Title: Skill Name in Gold (#c8a15a, fontexocet10, center)
lines.push({
text: `${vm.skill.nameZh} (${vm.skill.name})`,
color: 'gold',
font: 'fontexocet10',
align: 'center',
})
// 2. Requirements: Red (#ff4d4d) if player level < reqlevel or unmet prereqs
if (!vm.levelMet) {
lines.push({
text: `需要角色等级: ${vm.reqlevel}`,
color: 'red',
font: 'font8',
color: 'white',
align: 'center',
})
font.drawText(ctx, `当前等级: ${eff} (基础 ${hard} + 装备 ${this.bonusAllSkills})`, tx + tw / 2, ty + 58, {
font: 'font8',
color: 'blue',
align: 'center',
})
if (s.baseDmgMax > 0) {
font.drawText(ctx, `法术效果/伤害: ${minDmg} - ${maxDmg}`, tx + tw / 2, ty + 76, {
}
for (const prereq of vm.prereqs) {
if (!prereq.met) {
lines.push({
text: `需要前置技能: ${prereq.nameZh}`,
color: 'red',
font: 'font8',
color: 'white',
align: 'center',
})
}
font.drawText(
ctx,
`法力消耗: ${s.baseMana} · 需要等级: ${s.reqLevel} (左键点击加点)`,
tx + tw / 2,
ty + 96,
{ font: 'font8', color: 'tan', align: 'center' },
)
}
// 3. Narrative: Short/Long description in White (#e8e4dc, font8)
const desc = vm.skill.shortDescZh || vm.shortDescZh || vm.skill.longDescZh || vm.longDescZh
if (desc) {
lines.push({
text: desc,
color: 'white',
font: 'font8',
align: 'center',
})
}
// 4. Current Level header: Blue/White
if (vm.isLearned) {
const soft = vm.effectiveLevel - vm.hardPoints
const lvlText = `当前技能等级: ${vm.effectiveLevel}${soft > 0 ? ` (基础 ${vm.hardPoints} + 装备 +${soft})` : ''}`
lines.push({
text: lvlText,
color: soft > 0 ? 'blue' : 'white',
font: 'font8',
align: 'center',
})
} else {
lines.push({
text: '未习得 (预览 1 级效果)',
color: 'gray',
font: 'font8',
align: 'center',
})
}
// 5. Current Stats: Mana cost, damage ranges (with synergies and masteries), attack bonus, duration, cast delay in Blue/White (#6868ff / #e8e4dc, font8)
if (vm.current.manaCost > 0 || vm.skill.mana > 0) {
lines.push({
text: `法力消耗: ${vm.current.manaCost}`,
color: 'white',
font: 'font8',
align: 'center',
})
}
if (vm.current.delaySeconds > 0) {
lines.push({
text: `施法延迟: ${vm.current.delaySeconds} 秒`,
color: 'white',
font: 'font8',
align: 'center',
})
}
if (vm.current.damage.hasDamage) {
const d = vm.current.damage
lines.push({
text: `${d.typeZh || '伤害'}: ${d.finalMin} - ${d.finalMax}`,
color: 'blue',
font: 'font8',
align: 'center',
})
if (d.durationSeconds !== undefined && d.durationSeconds > 0) {
lines.push({
text: `持续时间: ${d.durationSeconds} 秒`,
color: 'white',
font: 'font8',
align: 'center',
})
}
if (d.weaponDamagePercent !== undefined && d.weaponDamagePercent > 0) {
lines.push({
text: `武器伤害: ${d.weaponDamagePercent}%`,
color: 'white',
font: 'font8',
align: 'center',
})
}
}
for (const eff of vm.current.effects) {
if (eff.textZh) {
lines.push({
text: eff.textZh,
color: 'blue',
font: 'font8',
align: 'center',
})
}
}
// 6. Next Level Preview: Header [下一等级] (Lv X) in Green (#00fc00, font8), followed by next level values. Omitted if unlearned or maxed (20 base points).
if (vm.nextLevel && vm.isLearned && vm.hardPoints < 20) {
lines.push({
text: `[下一等级] (Lv ${vm.nextLevel.level})`,
color: 'green',
font: 'font8',
align: 'center',
})
if (vm.nextLevel.manaCost !== vm.current.manaCost) {
lines.push({
text: `法力消耗: ${vm.nextLevel.manaCost}`,
color: 'green',
font: 'font8',
align: 'center',
})
}
if (vm.nextLevel.damage.hasDamage) {
const nd = vm.nextLevel.damage
lines.push({
text: `${nd.typeZh || '伤害'}: ${nd.finalMin} - ${nd.finalMax}`,
color: 'green',
font: 'font8',
align: 'center',
})
if (nd.durationSeconds !== undefined && nd.durationSeconds > 0 && nd.durationSeconds !== vm.current.damage.durationSeconds) {
lines.push({
text: `持续时间: ${nd.durationSeconds} 秒`,
color: 'green',
font: 'font8',
align: 'center',
})
}
if (nd.weaponDamagePercent !== undefined && nd.weaponDamagePercent !== vm.current.damage.weaponDamagePercent) {
lines.push({
text: `武器伤害: ${nd.weaponDamagePercent}%`,
color: 'green',
font: 'font8',
align: 'center',
})
}
}
for (const eff of vm.nextLevel.effects) {
if (eff.textZh) {
lines.push({
text: eff.textZh,
color: 'green',
font: 'font8',
align: 'center',
})
}
}
} else if (vm.hardPoints >= 20) {
lines.push({
text: '[已达技能点投入上限 (20/20)]',
color: 'gold',
font: 'font8',
align: 'center',
})
}
// 7. Synergies Section: Header [协同技能加成] in Blue (fontexocet10), followed by each synergy provider line: • <Name>: 每个有效技能点增加 <X>% 伤害 (当前 +<Y>%)
if (vm.synergiesReceived && vm.synergiesReceived.length > 0) {
lines.push({
text: '[协同技能加成]',
color: 'blue',
font: 'fontexocet10',
align: 'center',
})
for (const syn of vm.synergiesReceived) {
const text = syn.hardPoints > 0
? `• ${syn.providerNameZh}: 每个有效技能点增加 ${syn.percentPerLevel}% 伤害 (当前 +${syn.currentBonusPct}%)`
: `• ${syn.providerNameZh}: 每个有效技能点增加 ${syn.percentPerLevel}% 伤害`
lines.push({
text,
color: syn.hardPoints > 0 ? 'blue' : 'gray',
font: 'font8',
align: 'left',
})
}
}
if (vm.masteryInfo && vm.masteryInfo.active) {
lines.push({
text: `• ${vm.masteryInfo.descriptionZh}`,
color: 'blue',
font: 'font8',
align: 'left',
})
}
return lines
}
static computeTooltipPlacement(
pos: { x: number; y: number; w: number; h: number },
lines: readonly FormattedTooltipLine[],
font: D2FontRenderer,
): { x: number; y: number; w: number; h: number } {
const maxLineWidth = lines.reduce((max, l) => {
const w = typeof font?.measureText === 'function' ? font.measureText(l.text, l.font) : l.text.length * 8
return Math.max(max, w)
}, 0)
const boxW = Math.max(240, maxLineWidth + 28)
const boxH = lines.length * 17 + 24
const rawX = pos.x - boxW - 8
const clampedX = Math.max(8, Math.min(800 - boxW - 8, rawX))
const rawY = Math.round(pos.y + 24 - boxH / 2)
const clampedY = Math.max(8, Math.min(552 - boxH, rawY))
return { x: clampedX, y: clampedY, w: boxW, h: boxH }
}
drawSkillTooltip(ctx: CanvasRenderingContext2D, font: D2FontRenderer): void {
if (!this.hoveredSkill) return
const pos = SkillTreePanel.nodeScreenPos(this.hoveredSkill)
const vm = this.getTooltipViewModel(this.hoveredSkill.skillId)
if (!vm) return
const lines = SkillTreePanel.formatSkillTooltipLines(vm)
if (lines.length === 0) return
const placement = SkillTreePanel.computeTooltipPlacement(pos, lines, font)
ctx.fillStyle = 'rgba(4, 4, 4, 0.94)'
ctx.fillRect(placement.x, placement.y, placement.w, placement.h)
ctx.strokeStyle = '#8c7244'
ctx.lineWidth = 1
ctx.strokeRect(placement.x + 0.5, placement.y + 0.5, placement.w - 1, placement.h - 1)
lines.forEach((l, idx) => {
const lineY = placement.y + 12 + 10 + idx * 17
const lineX = l.align === 'center' ? placement.x + Math.round(placement.w / 2) : placement.x + 14
font.drawText(ctx, l.text, lineX, lineY, {
font: l.font,
color: l.color,
align: l.align ?? 'center',
})
})
}
}

View File

@ -23,14 +23,41 @@ export const STASH_CLOSE_BTN_BOUNDS = { x: 80 + 272, y: 60 + 388, w: 32, h: 32 }
export const STASH_DEPOSIT_BTN_BOUNDS = { x: 80 + 68, y: 60 + 386, w: 76, h: 26 } as const
export const STASH_WITHDRAW_BTN_BOUNDS = { x: 80 + 154, y: 60 + 386, w: 76, h: 26 } as const
import {
CANONICAL_QUESTS_BY_ACT,
type CanonicalQuestItem,
type CanonicalQuestStage,
} from '../data/canonical-quest-data.ts'
export interface QuestEntry {
readonly id: string
readonly act: number
readonly index: number
readonly titleEn: string
readonly titleZh: string
readonly titleKey: string
readonly status: 'completed' | 'active' | 'locked'
readonly description: string
readonly objectiveKey: string
readonly objectiveEn: string
readonly objectiveZh: string
readonly completedKey: string
readonly completedEn: string
readonly completedZh: string
readonly rewardSummaryEn: string
readonly rewardSummaryZh: string
readonly stages: readonly CanonicalQuestStage[]
}
export type QuestTblLookup = (key: string) => string | undefined
export interface QuestDisplayText {
readonly title: string
readonly titleKey: string
readonly body: string
readonly status: 'completed' | 'active' | 'locked'
readonly statusTag: string
readonly rewardText: string
}
export interface WaypointEntry {
@ -44,44 +71,25 @@ export interface WaypointEntry {
readonly unlocked: boolean
}
const DEFAULT_COMPLETED_QUEST_IDS = new Set(['a1q1', 'a1q2', 'a1q3'])
function createQuestEntry(item: CanonicalQuestItem): QuestEntry {
const isCompleted = DEFAULT_COMPLETED_QUEST_IDS.has(item.id)
return {
...item,
status: isCompleted ? 'completed' : 'active',
description: isCompleted
? `${item.completedZh} (奖励: ${item.rewardSummaryZh})`
: `${item.objectiveZh} (目标: ${item.rewardSummaryZh})`,
}
}
export const ACT_QUESTS: Readonly<Record<number, readonly QuestEntry[]>> = {
1: [
{ id: 'a1q1', act: 1, index: 0, titleEn: 'Den of Evil', titleZh: '邪恶洞窟', status: 'completed', description: '阿卡拉委托你肃清鲜血荒地地下的邪恶洞窟。任务已完成,奖励 +1 技能点。' },
{ id: 'a1q2', act: 1, index: 1, titleEn: "Sisters' Burial Grounds", titleZh: '修女埋骨之地', status: 'completed', description: '在冰冷之原的埋骨之地击败堕落的血鸟 (Blood Raven),卡夏为你配备了罗格佣兵。' },
{ id: 'a1q3', act: 1, index: 2, titleEn: 'The Search for Cain', titleZh: '凯恩的搜寻', status: 'completed', description: '从黑暗森林取得艾尼弗斯卷轴,激活乱石旷野的五根石柱,前往崔斯特瑞姆救出迪卡·凯恩。' },
{ id: 'a1q4', act: 1, index: 3, titleEn: 'The Forgotten Tower', titleZh: '遗忘之塔', status: 'active', description: '探索黑色荒地中的五层遗忘之塔,击败女伯爵 (The Countess) 夺回财宝与符文。' },
{ id: 'a1q5', act: 1, index: 4, titleEn: 'Tools of the Trade', titleZh: '交易的工具', status: 'active', description: '深入修道院兵营从铁匠手中夺回赫拉迪姆之锤,并交还给恰西进行灌魔。' },
{ id: 'a1q6', act: 1, index: 5, titleEn: 'Sisters to the Slaughter', titleZh: '屠杀修女', status: 'active', description: '进入地下墓穴第四层,消灭痛苦之王安达利尔 (Andariel),打通前往鲁高因的道路。' },
],
2: [
{ id: 'a2q1', act: 2, index: 0, titleEn: "Radament's Lair", titleZh: '罗达门特的巢穴', status: 'active', description: '在鲁·高因下水道第三层消灭罗达门特,夺回复仇之书(+1 技能点)与赫拉迪姆卷轴。' },
{ id: 'a2q2', act: 2, index: 1, titleEn: 'The Horadric Staff', titleZh: '赫拉迪克之杖', status: 'active', description: '在死亡之殿取得赫拉迪克方块,在遥远绿洲取得国王之杖,在利爪蝮蛇神殿取得蝮蛇护符,将其在方块中合成。' },
{ id: 'a2q3', act: 2, index: 2, titleEn: 'Tainted Sun', titleZh: '堕落的太阳', status: 'active', description: '利爪蝮蛇的邪恶仪式遮蔽了太阳,深入利爪蝮蛇神殿摧毁太阳祭坛,恢复鲁·高因的光明。' },
{ id: 'a2q4', act: 2, index: 3, titleEn: 'Arcane Sanctuary', titleZh: '神秘避难所', status: 'active', description: '穿过杰海因王宫底层的传送门,进入大法师赫拉森的神秘避难所,搜寻真正的塔·拉夏古墓印记。' },
{ id: 'a2q5', act: 2, index: 4, titleEn: 'The Summoner', titleZh: '召唤者', status: 'active', description: '在神秘避难所尽头击败堕落的召唤者,开启通往术士峡谷的传送门。' },
{ id: 'a2q6', act: 2, index: 5, titleEn: 'The Seven Tombs', titleZh: '七座古墓', status: 'active', description: '在术士峡谷搜寻带有真正印记的塔·拉夏古墓,用赫拉迪克之杖开启密室,击败督瑞尔 (Duriel)。' },
],
3: [
{ id: 'a3q1', act: 3, index: 0, titleEn: 'The Golden Bird', titleZh: '黄金鸟', status: 'active', description: '在库拉斯特丛林击败独特怪物取得玉制小人,交还给马席夫换取黄金鸟雕像,阿尔卡特为你配制永久增加 20 点生命上限的药剂。' },
{ id: 'a3q2', act: 3, index: 1, titleEn: 'Blade of the Old Religion', titleZh: '古代宗教之刃', status: 'active', description: '在剥皮丛林吉得宾祭坛夺回附魔匕首吉得宾,将其带给奥玛斯以加固海港魔法屏障。' },
{ id: 'a3q3', act: 3, index: 2, titleEn: "Khalim's Will", titleZh: '克林姆的意志', status: 'active', description: '搜集克林姆的眼球(蜘蛛洞穴)、脑(剥皮地窖)、心脏(库拉斯特下水道)与连枷,合成克林姆的意志击碎强制之球。' },
{ id: 'a3q4', act: 3, index: 3, titleEn: "Lam Esen's Tome", titleZh: '蓝·森的古书', status: 'active', description: '在库拉斯特商场的废弃神庙中搜寻蓝·森的黑书并交给艾席拉,获得 +5 属性点奖励。' },
{ id: 'a3q5', act: 3, index: 4, titleEn: 'The Blackened Temple', titleZh: '黑暗神殿', status: 'active', description: '在崔凡克消灭最高评议会的堕落狂热信徒,用克林姆的意志击碎强制之球,打开通往憎恨囚牢的大门。' },
{ id: 'a3q6', act: 3, index: 5, titleEn: 'The Guardian', titleZh: '守卫者', status: 'active', description: '深入憎恨囚牢第三层,击败憎恨之王墨菲斯托 (Mephisto),夺取他的灵魂石并穿越地狱之门。' },
],
4: [
{ id: 'a4q1', act: 4, index: 0, titleEn: 'The Fallen Angel', titleZh: '堕落天使', status: 'active', description: '泰瑞尔委托你进入绝望平原,击败被恶魔囚禁扭曲的堕落大天使衣卒尔 (Izual),释放其灵魂并获得 +2 技能点。' },
{ id: 'a4q2', act: 4, index: 1, titleEn: "Hell's Forge", titleZh: '地狱熔炉', status: 'active', description: '护送墨菲斯托的灵魂石前往火焰之河的地狱熔炉,击败铁匠海法斯特夺取地狱熔炉之锤,粉碎邪恶灵魂石。' },
{ id: 'a4q3', act: 4, index: 2, titleEn: "Terror's End", titleZh: '恐怖的终结', status: 'active', description: '深入混沌避难所激活五座封印,击败三位庇护所守卫,最终消灭恐惧之王迪亚波罗 (Diablo)。' },
],
5: [
{ id: 'a5q1', act: 5, index: 0, titleEn: 'Siege on Harrogath', titleZh: '围攻哈洛加斯', status: 'active', description: '在血腥丘陵击败督军山克 (Shenk the Overseer),解除恶魔投石机对哈洛加斯的围攻,拉苏克将为你的一件装备打孔。' },
{ id: 'a5q2', act: 5, index: 1, titleEn: 'Rescue on Mount Arreat', titleZh: '亚瑞特山救援', status: 'active', description: '在冰冻高地寻找并释放被恶魔囚禁的 15 名野蛮人斥候,夸尔凯克将奖励你符文组合与招募野蛮人佣兵的特权。' },
{ id: 'a5q3', act: 5, index: 2, titleEn: 'Prison of Ice', titleZh: '冰之囚', status: 'active', description: '在冰河深处寻找被尼拉塞克封冻的安亚 (Anya),使用马拉配制的解冻药水解救她,安亚将赠予全职业抗性卷轴(+10% 全抗)。' },
{ id: 'a5q4', act: 5, index: 3, titleEn: 'Betrayal of Harrogath', titleZh: '哈洛加斯的背叛', status: 'active', description: '穿过安亚开启的红门进入瓦特之厅,击败背叛者尼拉塞克 (Nihlathak),阻止他将圣山遗物交给巴尔。' },
{ id: 'a5q5', act: 5, index: 4, titleEn: 'Rite of Passage', titleZh: '通过祭典', status: 'active', description: '登上亚瑞特山顶触碰古代人祭坛,在不返回城镇的情况下同时击败三位远古野蛮人守卫(科力克、马道克、塔力克),获得海量经验晋级。' },
{ id: 'a5q6', act: 5, index: 5, titleEn: 'Eve of Destruction', titleZh: '毁灭前夕', status: 'active', description: '深入世界之石要塞与毁灭王座,清除巴尔的五波恶魔仆从,追击毁灭之王巴尔进入世界之石大厅并将其消灭。' },
],
1: CANONICAL_QUESTS_BY_ACT[1]!.map(createQuestEntry),
2: CANONICAL_QUESTS_BY_ACT[2]!.map(createQuestEntry),
3: CANONICAL_QUESTS_BY_ACT[3]!.map(createQuestEntry),
4: CANONICAL_QUESTS_BY_ACT[4]!.map(createQuestEntry),
5: CANONICAL_QUESTS_BY_ACT[5]!.map(createQuestEntry),
}
/** Flat list of all 27 canonical Diablo II quests across Acts 1..5. */
@ -145,6 +153,9 @@ export const ACT_WAYPOINTS: Readonly<Record<number, readonly WaypointEntry[]>> =
],
}
/** Authentic D2Client.dll 1.13c waypoint row Y offsets table at VA 0x6fb8e6d8. */
export const WAYPOINT_ROW_OFFSETS_Y: readonly number[] = [60, 96, 132, 168, 205, 241, 277, 313, 349] as const
export function findWaypointBySlug(slug: string): WaypointEntry | undefined {
for (const list of Object.values(ACT_WAYPOINTS)) {
const found = list.find(w => w.slug === slug)
@ -180,6 +191,57 @@ export class WorldPanelsHud {
this.questStatuses.set(questId, status)
}
private questTblLookup?: QuestTblLookup | undefined
private questStringTableChain?: { get(key: string): string | undefined } | null | undefined
setQuestTblLookup(lookup: QuestTblLookup | null | undefined): void {
this.questTblLookup = lookup ?? undefined
}
setQuestStringTableChain(chain: { get(key: string): string | undefined } | null | undefined): void {
this.questStringTableChain = chain ?? undefined
}
lookupQuestString(key: string): string | undefined {
if (this.questTblLookup) {
const val = this.questTblLookup(key)
if (val !== undefined && val.trim().length > 0) return val.trim()
}
if (this.questStringTableChain) {
const val = this.questStringTableChain.get(key)
if (val !== undefined && val.trim().length > 0) return val.trim()
}
return undefined
}
getQuestDisplayText(quest: QuestEntry): QuestDisplayText {
const status = this.getQuestStatus(quest)
const statusTag = status === 'completed' ? '[已完成]' : status === 'active' ? '[进行中]' : '[未开启]'
const title = this.lookupQuestString(quest.titleKey) ?? quest.titleZh ?? quest.titleEn
let body = ''
const rewardText = quest.rewardSummaryZh || quest.rewardSummaryEn
if (status === 'completed') {
const tblCompleted = this.lookupQuestString(quest.completedKey)
body = tblCompleted || quest.completedZh || quest.completedEn || quest.description
} else if (status === 'active') {
const tblObjective = this.lookupQuestString(quest.objectiveKey)
body = tblObjective || quest.objectiveZh || quest.objectiveEn || quest.description
} else {
body = '此任务尚未开启。与城镇居民对话或探索荒野以开启任务。'
}
return {
title,
titleKey: quest.titleKey,
body,
status,
statusTag,
rewardText,
}
}
private getQuestsAtlasImage(): HTMLImageElement | null {
if (this.cachedQuestsAtlasImg) return this.cachedQuestsAtlasImg
if (typeof Image !== 'undefined') {
@ -200,6 +262,7 @@ export class WorldPanelsHud {
stashItems: GridPlacement[] = []
hoveredStashItem: { item: UiInventoryItem; x: number; y: number } | null = null
hoveredWaypointIdx: number | null = null
/**
* Check whether an item of (w, h) can be placed at (col, row) in the 6x8 stash grid.
@ -308,6 +371,22 @@ export class WorldPanelsHud {
handleMouseMove(logicalX: number, logicalY: number): void {
this.hoveredStashItem = null
this.hoveredWaypointIdx = null
const ox = 80
const oy = 60
if (logicalX >= ox + 17 && logicalX <= ox + 298) {
const wps = ACT_WAYPOINTS[this.selectedActTab] ?? ACT_WAYPOINTS[1]!
for (let i = 0; i < wps.length; i++) {
const rowY = WAYPOINT_ROW_OFFSETS_Y[i] ?? (60 + i * 36)
const wy = oy + rowY
if (logicalY >= wy && logicalY <= wy + 31) {
this.hoveredWaypointIdx = i
break
}
}
}
if (
logicalX >= STASH_GRID_ORIGIN.x &&
logicalX < STASH_GRID_ORIGIN.x + STASH_GRID_ORIGIN.cols * STASH_GRID_ORIGIN.cellPx &&
@ -380,9 +459,11 @@ export class WorldPanelsHud {
} else if (kind === 'waypoint') {
const list = ACT_WAYPOINTS[this.selectedActTab] ?? ACT_WAYPOINTS[1]!
for (let i = 0; i < list.length; i++) {
const wy = oy + 62 + i * 36
if (logicalX >= ox + 22 && logicalX <= ox + 298 && logicalY >= wy && logicalY <= wy + 32) {
const rowY = WAYPOINT_ROW_OFFSETS_Y[i] ?? (60 + i * 36)
const wy = oy + rowY
if (logicalX >= ox + 17 && logicalX <= ox + 298 && logicalY >= wy && logicalY <= wy + 32) {
const wp = list[i]!
if (!wp.unlocked) return true
callbacks.onWaypointTeleport(wp.act, wp.slug, wp.levelId)
callbacks.onClose()
return true
@ -473,6 +554,9 @@ export class WorldPanelsHud {
buySellBtnImg: HTMLImageElement | null
questsAtlasImg?: HTMLImageElement | null
itemsAtlasImg?: HTMLImageElement | null
waypointTabsImg?: HTMLImageElement | null
waypointIconsImg?: HTMLImageElement | null
questTabsImg?: HTMLImageElement | null
},
font: D2FontRenderer,
): void {
@ -486,7 +570,7 @@ export class WorldPanelsHud {
if (kind === 'quest') {
if (assets.questBgImg) ctx.drawImage(assets.questBgImg, ox, oy)
this.drawActTabs(ctx, ox, oy, font)
this.drawActTabs(ctx, ox, oy, font, assets.questTabsImg)
const quests = ACT_QUESTS[this.selectedActTab] ?? ACT_QUESTS[1]!
if (this.selectedQuestIdx >= quests.length) {
@ -542,38 +626,55 @@ export class WorldPanelsHud {
const activeQ = quests[this.selectedQuestIdx] ?? quests[0]!
if (activeQ) {
const status = this.getQuestStatus(activeQ)
const qInfo = this.getQuestDisplayText(activeQ)
ctx.fillStyle = 'rgba(8, 7, 6, 0.92)'
ctx.fillRect(ox + 18, oy + 292, 284, 86)
ctx.fillRect(ox + 18, oy + 264, 284, 116)
ctx.strokeStyle = '#6c5838'
ctx.strokeRect(ox + 18.5, oy + 292.5, 283, 85)
ctx.strokeRect(ox + 18.5, oy + 264.5, 283, 115)
const statusTag = status === 'completed' ? '[已完成]' : status === 'active' ? '[进行中]' : '[未开启]'
font.drawText(ctx, `${activeQ.titleZh} (${activeQ.titleEn}) ${statusTag}`, ox + 160, oy + 314, {
font.drawText(ctx, `${qInfo.title} (${activeQ.titleEn}) ${qInfo.statusTag}`, ox + 160, oy + 282, {
font: 'fontexocet10',
color: status === 'completed' ? 'gold' : 'white',
color: qInfo.status === 'completed' ? 'gold' : 'white',
align: 'center',
})
this.drawWrappedText(ctx, font, activeQ.description, ox + 28, oy + 336, 264, 18)
const rewardSuffix = qInfo.status === 'completed'
? (qInfo.rewardText ? `\n[奖励: ${qInfo.rewardText}]` : '')
: (qInfo.rewardText ? `\n[目标: ${qInfo.rewardText}]` : '')
this.drawWrappedText(ctx, font, qInfo.body + rewardSuffix, ox + 26, oy + 300, 268, 14, 5)
}
} else if (kind === 'waypoint') {
if (assets.waypointBgImg) ctx.drawImage(assets.waypointBgImg, ox, oy)
this.drawActTabs(ctx, ox, oy, font)
this.drawActTabs(ctx, ox, oy, font, assets.waypointTabsImg)
const wps = ACT_WAYPOINTS[this.selectedActTab] ?? ACT_WAYPOINTS[1]!
const activeIconIdx = Math.max(0, Math.min(4, this.selectedActTab - 1))
const iconSx = activeIconIdx * 30
for (let i = 0; i < wps.length; i++) {
const wp = wps[i]!
const wy = oy + 62 + i * 36
ctx.fillStyle = 'rgba(18, 16, 13, 0.85)'
ctx.fillRect(ox + 22, wy, 276, 31)
ctx.strokeStyle = '#5a4a32'
ctx.strokeRect(ox + 22.5, wy + 0.5, 275, 30)
const rowY = WAYPOINT_ROW_OFFSETS_Y[i] ?? (60 + i * 36)
const wy = oy + rowY
const isHovered = this.hoveredWaypointIdx === i
// Blue active waypoint diamond icon
ctx.fillStyle = '#4888ff'
ctx.fillRect(ox + 32, wy + 9, 12, 12)
font.drawText(ctx, `${wp.nameZh} · ${wp.nameEn}`, ox + 56, wy + 21, {
ctx.fillStyle = isHovered ? 'rgba(42, 34, 22, 0.88)' : 'rgba(18, 16, 13, 0.85)'
ctx.fillRect(ox + 17, wy, 281, 31)
ctx.strokeStyle = isHovered ? '#e8c26b' : '#5a4a32'
ctx.strokeRect(ox + 17.5, wy + 0.5, 280, 30)
// Authentic D2 waypoint icon from waygateicons.dc6 (30x30)
if (assets.waypointIconsImg && assets.waypointIconsImg.complete && assets.waypointIconsImg.naturalWidth > 0) {
ctx.save()
if (!wp.unlocked) ctx.globalAlpha = 0.35
ctx.drawImage(assets.waypointIconsImg, iconSx, 0, 30, 30, ox + 17, wy + 1, 30, 30)
ctx.restore()
} else {
// Fallback stone diamond
ctx.fillStyle = wp.unlocked ? '#4888ff' : '#4a4a4a'
ctx.fillRect(ox + 26, wy + 9, 12, 12)
}
font.drawText(ctx, `${wp.nameZh} · ${wp.nameEn}`, ox + 80, wy + 21, {
font: 'fontexocet10',
color: 'gold',
color: wp.unlocked ? (isHovered ? 'white' : 'gold') : 'gray',
})
}
} else if (kind === 'stash') {
@ -704,7 +805,25 @@ export class WorldPanelsHud {
}
}
private drawActTabs(ctx: CanvasRenderingContext2D, ox: number, oy: number, font: D2FontRenderer): void {
private drawActTabs(
ctx: CanvasRenderingContext2D,
ox: number,
oy: number,
font: D2FontRenderer,
tabsImg?: HTMLImageElement | null,
): void {
if (tabsImg && tabsImg.complete && tabsImg.naturalWidth > 0) {
for (let i = 0; i < 5; i++) {
const act = i + 1
const active = this.selectedActTab === act
const frameIdx = active ? 5 + i : i
const sx = frameIdx * 63
const tx = ox + 4 + i * 62
ctx.drawImage(tabsImg, sx, 0, 63, 31, tx, oy + 8, 63, 31)
}
return
}
const numerals = ['I', 'II', 'III', 'IV', 'V'] as const
for (let i = 0; i < 5; i++) {
const act = i + 1
@ -730,20 +849,36 @@ export class WorldPanelsHud {
y: number,
maxW: number,
lineH: number,
maxLines = 10,
): void {
let line = ''
const paragraphs = text.split(/\r?\n/)
let curY = y
for (const ch of text) {
if (font.measureText(line + ch, 'font8') > maxW) {
font.drawText(ctx, line, x, curY, { font: 'font8', color: 'white' })
line = ch
curY += lineH
} else {
line += ch
let linesDrawn = 0
for (const p of paragraphs) {
if (linesDrawn >= maxLines) break
let line = ''
for (let i = 0; i < p.length; i++) {
const ch = p[i]!
if (font.measureText(line + ch, 'font8') > maxW) {
if (linesDrawn + 1 >= maxLines) {
const truncated = line.slice(0, Math.max(0, line.length - 2)) + '...'
font.drawText(ctx, truncated, x, curY, { font: 'font8', color: 'white' })
return
}
font.drawText(ctx, line, x, curY, { font: 'font8', color: 'white' })
linesDrawn++
line = ch
curY += lineH
} else {
line += ch
}
}
if (line && linesDrawn < maxLines) {
font.drawText(ctx, line, x, curY, { font: 'font8', color: 'white' })
linesDrawn++
curY += lineH
}
}
if (line) {
font.drawText(ctx, line, x, curY, { font: 'font8', color: 'white' })
}
}

392
tests/attack-skill.test.ts Normal file
View File

@ -0,0 +1,392 @@
/**
* Tests for Normal Attack Icon Completion & Dual Slot Binding Mechanics (Issue #151).
*/
import { describe, expect, it, beforeEach } from 'vitest'
import { readFileSync, existsSync } from 'node:fs'
import { join } from 'node:path'
import {
DEFAULT_SORCERESS_SKILLS,
SkillHotkeysHud,
resolveSkillIcon,
getAvailableSkillsForSide,
} from '../src/ui/hotkeys.ts'
import {
getAttackWeaponInfo,
checkAmmunition,
getSkillRange,
castSkill,
type AttackWeaponInputItem,
} from '../src/scene/act-scene.ts'
import { GameEngine, type GameEngineOptions } from '../src/game/engine.ts'
import type { Monster } from '../src/game/combat.ts'
describe('Issue #151: Normal Attack Icon Completion & Dual Slot Binding', () => {
describe('Skill 0 Definition & Dual-Palette Usability', () => {
it('defines Skill 0 Attack with leftUsable: true and rightUsable: true', () => {
const attackSkill = DEFAULT_SORCERESS_SKILLS.find((s) => s.skillId === 0)
expect(attackSkill).toBeDefined()
expect(attackSkill!.name).toBe('Attack')
expect(attackSkill!.nameZh).toBe('普通攻击')
expect(attackSkill!.leftUsable).toBe(true)
expect(attackSkill!.rightUsable).toBe(true)
expect(attackSkill!.manaCost).toBe(0)
})
it('includes Skill 0 in both left and right available skill palettes', () => {
const leftSkills = getAvailableSkillsForSide('left')
const rightSkills = getAvailableSkillsForSide('right')
const leftAttack = leftSkills.find((s) => s.skillId === 0)
const rightAttack = rightSkills.find((s) => s.skillId === 0)
expect(leftAttack).toBeDefined()
expect(rightAttack).toBeDefined()
expect(leftAttack!.name).toBe('Attack')
expect(rightAttack!.name).toBe('Attack')
})
it('allows assigning Skill 0 to left slot, right slot, or both simultaneously (dual binding)', () => {
const hud = new SkillHotkeysHud()
// Initial defaults
expect(hud.leftSkillId).toBe(47) // Fire Ball
expect(hud.rightSkillId).toBe(64) // Frozen Orb
// Assign Attack to left slot
hud.assignSkill('left', 0)
expect(hud.leftSkillId).toBe(0)
expect(hud.rightSkillId).toBe(64)
// Assign Attack to right slot
hud.assignSkill('right', 0)
expect(hud.leftSkillId).toBe(0)
expect(hud.rightSkillId).toBe(0)
// Change left slot back to Fire Ball
hud.assignSkill('left', 47)
expect(hud.leftSkillId).toBe(47)
expect(hud.rightSkillId).toBe(0)
// Use helper setDualSkill
hud.setDualSkill(0)
expect(hud.leftSkillId).toBe(0)
expect(hud.rightSkillId).toBe(0)
})
})
describe('Icon Asset & DC6 Frame 0 Resolution', () => {
it('resolves Skill 0 to authentic Skillicon.DC6 frame 0 icon', () => {
const iconMapping = resolveSkillIcon(0, false)
expect(iconMapping).toBeDefined()
expect(iconMapping.skillId).toBe(0)
expect(iconMapping.name).toBe('Attack')
expect(iconMapping.dc6Source).toBe('data/global/ui/SPELLS/Skillicon.DC6')
expect(iconMapping.frameIndex).toBe(0)
expect(iconMapping.atlasRect.x).toBe(0)
expect(iconMapping.atlasRect.y).toBe(0)
expect(iconMapping.atlasRect.width).toBe(48)
expect(iconMapping.atlasRect.height).toBe(48)
})
it('verifies public/skills/icon_0.png exists and is a valid 48x48 PNG image', () => {
const iconPath = join(process.cwd(), 'public', 'skills', 'icon_0.png')
expect(existsSync(iconPath)).toBe(true)
const buf = readFileSync(iconPath)
// Check PNG magic header: \x89PNG\r\n\x1a\n
expect(buf[0]).toBe(0x89)
expect(buf[1]).toBe(0x50) // P
expect(buf[2]).toBe(0x4e) // N
expect(buf[3]).toBe(0x47) // G
expect(buf[4]).toBe(0x0d) // \r
expect(buf[5]).toBe(0x0a) // \n
expect(buf[6]).toBe(0x1a)
expect(buf[7]).toBe(0x0a) // \n
// Check IHDR dimensions (width at bytes 16-19, height at bytes 20-23)
const width = buf.readUInt32BE(16)
const height = buf.readUInt32BE(20)
expect(width).toBe(48)
expect(height).toBe(48)
})
it('verifies public/ui/icon_0.png exists for baked UI references', () => {
const iconPath = join(process.cwd(), 'public', 'ui', 'icon_0.png')
expect(existsSync(iconPath)).toBe(true)
const buf = readFileSync(iconPath)
expect(buf.readUInt32BE(16)).toBe(48)
expect(buf.readUInt32BE(20)).toBe(48)
})
})
describe('Weapon Timing, Damage, and Ammunition Checks', () => {
it('evaluates unarmed attack characteristics correctly', () => {
const unarmed = getAttackWeaponInfo(null)
expect(unarmed.kind).toBe('unarmed')
expect(unarmed.subtype).toBe('fist')
expect(unarmed.isRanged).toBe(false)
expect(unarmed.damageMin).toBe(1)
expect(unarmed.damageMax).toBe(2)
expect(unarmed.reach).toBe(52)
expect(unarmed.cooldown).toBe(12)
expect(unarmed.swingTicks).toBe(6)
})
it('evaluates melee weapon attack timing and damage correctly', () => {
const crystalSword: AttackWeaponInputItem = {
code: 'crs',
name: 'Crystal Sword',
damage: '5 to 15',
}
const swordInfo = getAttackWeaponInfo(crystalSword)
expect(swordInfo.kind).toBe('melee')
expect(swordInfo.subtype).toBe('sword')
expect(swordInfo.isRanged).toBe(false)
expect(swordInfo.damageMin).toBe(5)
expect(swordInfo.damageMax).toBe(15)
expect(swordInfo.reach).toBe(52)
expect(swordInfo.cooldown).toBe(14)
expect(swordInfo.swingTicks).toBe(7)
// Test extended reach for polearm
const polearm: AttackWeaponInputItem = {
code: 'vou',
name: 'Voulge',
damage: '6 - 21',
}
const polearmInfo = getAttackWeaponInfo(polearm)
expect(polearmInfo.subtype).toBe('polearm')
expect(polearmInfo.reach).toBe(60)
})
it('evaluates ranged weapon (bow/crossbow) timing and ammo requirement', () => {
const shortBow: AttackWeaponInputItem = {
code: 'sbw',
name: 'Short Bow',
damage: '1 to 4',
}
const bowInfo = getAttackWeaponInfo(shortBow)
expect(bowInfo.kind).toBe('ranged')
expect(bowInfo.subtype).toBe('bow')
expect(bowInfo.isRanged).toBe(true)
expect(bowInfo.ammoType).toBe('arrow')
expect(bowInfo.reach).toBe(450)
expect(bowInfo.cooldown).toBe(14)
const lightCrossbow: AttackWeaponInputItem = {
code: 'lxb',
name: 'Light Crossbow',
damage: '6 to 9',
}
const xbowInfo = getAttackWeaponInfo(lightCrossbow)
expect(xbowInfo.kind).toBe('ranged')
expect(xbowInfo.subtype).toBe('crossbow')
expect(xbowInfo.isRanged).toBe(true)
expect(xbowInfo.ammoType).toBe('bolt')
expect(xbowInfo.reach).toBe(450)
expect(xbowInfo.cooldown).toBe(16)
})
it('verifies checkAmmunition for bows and crossbows', () => {
// Offhand arrow equipped
expect(checkAmmunition('arrow', { code: 'aqv', name: 'Arrows' })).toBe(true)
// Offhand bolt equipped when bow needs arrow -> false
expect(checkAmmunition('arrow', { code: 'cqv', name: 'Bolts' })).toBe(false)
// Offhand bolt equipped when crossbow needs bolt -> true
expect(checkAmmunition('bolt', { code: 'cqv', name: 'Bolts' })).toBe(true)
// Ammo in inventory grid
const invWithArrows = {
gridItems: [{ item: { code: 'aqv', nameZh: '箭矢' } }],
}
expect(checkAmmunition('arrow', null, invWithArrows)).toBe(true)
expect(checkAmmunition('bolt', null, invWithArrows)).toBe(false)
const invWithBolts = {
items: [{ code: 'cqv', nameZh: '十字弓弹' }],
}
expect(checkAmmunition('bolt', null, invWithBolts)).toBe(true)
expect(checkAmmunition('arrow', null, invWithBolts)).toBe(false)
// Empty inventory & offhand
expect(checkAmmunition('arrow', null, null)).toBe(false)
expect(checkAmmunition('bolt', null, { items: [] })).toBe(false)
})
})
describe('Combat Execution via castSkill', () => {
let engine: GameEngine
let statusElem: HTMLElement
const dummyRuntime: any = {
level: 'test',
grid: { width: 100, height: 100, cells: new Uint8Array(10000) },
waypoints: [],
}
beforeEach(() => {
const dummyTerrain = {
widthPx: 1000,
heightPx: 1000,
overlap: () => 0,
}
const dummyOpts: GameEngineOptions = {
spawn: { x: 100, y: 100 },
stats: [],
xpTable: [0, 100],
itemBases: [],
prefixAffixes: [],
suffixAffixes: [],
skills: [],
npcDefs: [],
questDefs: [],
monsterCount: 0,
combatOptions: {
playerSpeed: 100,
playerReach: 50,
playerCooldownTicks: 10,
playerDamage: 5,
playerManaPerAttack: 0,
respawnTicks: 100,
},
talkRadius: 80,
pickupRadius: 40,
inventoryCols: 10,
inventoryRows: 4,
}
engine = new GameEngine(dummyTerrain, dummyOpts)
engine.world.player.x = 100
engine.world.player.y = 100
engine.world.player.cooldown = 0
statusElem = { textContent: '' } as any
})
it('executes unarmed attack hitting a nearby monster with base physical damage and unarmed timing', () => {
const monster: Monster = {
index: 0,
x: 120,
y: 100,
hp: 50,
cooldown: 0,
hitFlash: 0,
corpseTicks: 0,
state: 'idle',
facing: 0,
stats: { id: 'zombie', name: 'Zombie', hp: 50, damage: 2, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 2, xp: 10 },
}
engine.world.monsters = [monster]
const success = castSkill(0, 120, 100, {
engine,
runtime: dummyRuntime,
hudManager: null,
status: statusElem,
equippedWeapon: null, // unarmed
})
expect(success).toBe(true)
expect(engine.world.player.cooldown).toBe(12)
expect(engine.world.player.swingTicks).toBe(6)
expect(monster.hp).toBeLessThan(50) // damaged
expect(engine.metrics.playerHits).toBe(1)
expect(statusElem.textContent).toContain('普通攻击(空手拳击)命中')
})
it('executes melee weapon attack with equipped weapon timing and damage', () => {
const monster: Monster = {
index: 0,
x: 130,
y: 100,
hp: 100,
cooldown: 0,
hitFlash: 0,
corpseTicks: 0,
state: 'idle',
facing: 0,
stats: { id: 'skeleton', name: 'Skeleton', hp: 100, damage: 5, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 2, xp: 15 },
}
engine.world.monsters = [monster]
const success = castSkill(0, 130, 100, {
engine,
runtime: dummyRuntime,
hudManager: null,
status: statusElem,
equippedWeapon: {
code: 'crs',
name: 'Crystal Sword',
damage: '20 to 30',
},
})
expect(success).toBe(true)
expect(engine.world.player.cooldown).toBe(14)
expect(engine.world.player.swingTicks).toBe(7)
// Damage should be (20 + 30) / 2 = 25
expect(monster.hp).toBe(75)
expect(statusElem.textContent).toContain('普通攻击命中')
})
it('fails fast on ranged attack when ammunition is missing', () => {
const success = castSkill(0, 200, 100, {
engine,
runtime: dummyRuntime,
hudManager: null,
status: statusElem,
equippedWeapon: {
code: 'sbw',
name: 'Short Bow',
damage: '1 to 4',
},
hasAmmo: false,
})
expect(success).toBe(false)
expect(engine.projectiles.length).toBe(0)
expect(statusElem.textContent).toBe('缺少箭矢!')
})
it('launches basic arrow projectile on ranged attack when ammunition is present', () => {
const success = castSkill(0, 300, 100, {
engine,
runtime: dummyRuntime,
hudManager: null,
status: statusElem,
equippedWeapon: {
code: 'sbw',
name: 'Short Bow',
damage: '10 to 20',
},
hasAmmo: true,
})
expect(success).toBe(true)
expect(engine.world.player.cooldown).toBe(14)
expect(engine.projectiles.length).toBe(1)
const proj = engine.projectiles[0]
expect(proj.skillId).toBe('0')
expect(proj.fromPlayer).toBe(true)
expect(proj.damage).toBe(15) // average of 10 and 20
expect(proj.vx).toBeGreaterThan(0)
expect(statusElem.textContent).toContain('发射箭矢')
})
it('records whiff event when melee attack is performed with no monster in reach', () => {
engine.world.monsters = [] // no monsters
const success = castSkill(0, 120, 100, {
engine,
runtime: dummyRuntime,
hudManager: null,
status: statusElem,
equippedWeapon: null, // unarmed
})
expect(success).toBe(true)
expect(engine.world.player.cooldown).toBe(12)
const whiffEvent = engine.world.events.find((e) => e.kind === 'playerHit' && (e as any).text === 'whiff')
expect(whiffEvent).toBeDefined()
})
})
})

View File

@ -0,0 +1,712 @@
import { describe, it, expect, beforeEach } from 'vitest'
import {
CANONICAL_TOWN_STASHES,
type TownStashEntity,
SceneMouseController,
type MapRuntime,
} from '../src/scene/act-scene.ts'
import {
WorldPanelsHud,
STASH_GOLD_CAP,
PLAYER_GOLD_CAP,
STASH_DEPOSIT_BTN_BOUNDS,
STASH_WITHDRAW_BTN_BOUNDS,
} from '../src/ui/world-panels.ts'
import { HudManager } from '../src/ui/hud-manager.ts'
import { GameEngine } from '../src/game/engine.ts'
import { createIsoTerrain } from '../src/game/iso-terrain.ts'
import { ViewportCamera } from '../src/sim/camera.ts'
import { KeyboardInput } from '../src/sim/input.ts'
import { WaypointNetwork } from '../src/game/portal.ts'
class MockWindow extends EventTarget {
innerWidth = 1920
innerHeight = 1080
devicePixelRatio = 1
}
if (typeof globalThis.window === 'undefined') {
// @ts-ignore
globalThis.window = new MockWindow()
}
if (typeof globalThis.HTMLInputElement === 'undefined') {
// @ts-ignore
globalThis.HTMLInputElement = class HTMLInputElement {}
}
if (typeof globalThis.HTMLSelectElement === 'undefined') {
// @ts-ignore
globalThis.HTMLSelectElement = class HTMLSelectElement {}
}
describe('Challenger M5 Empirical Stress Test: Town Stash Entities, Interaction Geometry & Gold Mechanics', () => {
describe('1. 5-Act Town Stash Entity Verification', () => {
it('verifies all 5 town acts spawn a stash entity with ID 267 and token "B6"', () => {
for (let act = 1; act <= 5; act++) {
const stash = CANONICAL_TOWN_STASHES[act]
expect(stash, `Act ${act} stash must be defined`).toBeDefined()
expect(stash.id).toBe(267)
expect(stash.objectsTxtId).toBe(267)
expect(stash.token).toBe('B6')
expect(stash.act).toBe(act)
expect(stash.nameZh).toBe('储物箱')
expect(stash.nameEn).toBe('Stash')
expect(stash.clickRadiusPx).toBe(48)
expect(stash.interactionRangePx).toBe(80)
}
})
it('verifies exact coordinates in each act matching authentic 1.13c ground truth', () => {
const expectedCoordinates: Record<number, { x: number; y: number; subTileX: number; subTileY: number }> = {
1: { x: 4512, y: 1720, subTileX: 112, subTileY: 86 }, // Rogue Encampment
2: { x: 5328, y: 1568, subTileX: 133, subTileY: 78 }, // Lut Gholein
3: { x: 5264, y: 1584, subTileX: 131, subTileY: 79 }, // Kurast Docks
4: { x: 704, y: 552, subTileX: 17, subTileY: 27 }, // Pandemonium Fortress
5: { x: 3792, y: 1448, subTileX: 94, subTileY: 72 }, // Harrogath
}
for (let act = 1; act <= 5; act++) {
const stash = CANONICAL_TOWN_STASHES[act]
const expected = expectedCoordinates[act]!
expect(stash.x, `Act ${act} stash X`).toBe(expected.x)
expect(stash.y, `Act ${act} stash Y`).toBe(expected.y)
expect(stash.subTileX, `Act ${act} stash subTileX`).toBe(expected.subTileX)
expect(stash.subTileY, `Act ${act} stash subTileY`).toBe(expected.subTileY)
}
})
it('verifies sub-tile conversion alignment against authentic DS1 town origins', () => {
const townOrigins: Record<number, { originX: number; originY: number }> = {
1: { originX: 2720, originY: 344 }, // Act 1 Rogue Encampment
2: { originX: 3200, originY: 320 }, // Act 2 Lut Gholein
3: { originX: 3168, originY: 320 }, // Act 3 Kurast Docks
4: { originX: 432, originY: 120 }, // Act 4 Pandemonium Fortress
5: { originX: 2288, originY: 296 }, // Act 5 Harrogath
}
for (let act = 1; act <= 5; act++) {
const stash = CANONICAL_TOWN_STASHES[act]!
const origin = townOrigins[act]!
// Exact 1.13c DS1 coordinate mapping: worldPos = origin + subTile * 16
expect(origin.originX + stash.subTileX * 16, `Act ${act} stash world X`).toBe(stash.x)
expect(origin.originY + stash.subTileY * 16, `Act ${act} stash world Y`).toBe(stash.y)
}
})
it('verifies safe zone coverage around town stashes', () => {
for (let act = 1; act <= 5; act++) {
const stash = CANONICAL_TOWN_STASHES[act]
// Stash safe zones are configured with radius 160px in runScene
expect(stash.x).toBeGreaterThan(0)
expect(stash.y).toBeGreaterThan(0)
expect(stash.clickRadiusPx).toBe(48)
expect(stash.interactionRangePx).toBe(80)
}
})
it('verifies multi-act scene runtime integration: all 5 acts resolve their canonical stash', () => {
const townLevels: Record<number, { levelId: number; name: string; slug: string }> = {
1: { levelId: 1, name: 'Rogue Encampment', slug: 'act1-towne1' },
2: { levelId: 40, name: 'Lut Gholein', slug: 'act2-lutn' },
3: { levelId: 75, name: 'Kurast Docks', slug: 'act3-docktown3' },
4: { levelId: 103, name: 'The Pandemonium Fortress', slug: 'act4-fortress' },
5: { levelId: 109, name: 'Harrogath', slug: 'act5-townwest' },
}
for (let act = 1; act <= 5; act++) {
const info = townLevels[act]!
const canonical = CANONICAL_TOWN_STASHES[act]!
const runtimeStashes: TownStashEntity[] = [canonical]
expect(runtimeStashes.length).toBe(1)
const stash = runtimeStashes[0]!
expect(stash.id).toBe(267)
expect(stash.objectsTxtId).toBe(267)
expect(stash.token).toBe('B6')
expect(stash.act).toBe(act)
expect(stash.nameZh).toBe('储物箱')
expect(stash.nameEn).toBe('Stash')
}
})
})
describe('2. Interaction Geometry Stress Testing', () => {
let engine: GameEngine
let camera: ViewportCamera
let input: KeyboardInput
let waypointNetwork: WaypointNetwork
let statusElem: HTMLElement
let controller: SceneMouseController
let runtime: MapRuntime
let stashOpenCount: number
let mockHudManager: any
beforeEach(() => {
stashOpenCount = 0
const grid = {
originX: 0,
originY: 0,
cellsX: 200,
cellsY: 200,
blocked: new Uint8Array(1000 * 1000),
gridWidth: 1000,
}
const terrain = createIsoTerrain(grid, 10000, 5000, { width: 20, height: 10 })
engine = new GameEngine(terrain, {
spawn: { x: 4500, y: 1700 },
stats: [],
monsterCount: 0,
questDefs: [],
npcDefs: [],
} as any)
const mockCanvas = {
getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }),
clientWidth: 800,
clientHeight: 600,
width: 800,
height: 600,
addEventListener: () => {},
removeEventListener: () => {},
getContext: () => null,
style: { cursor: '' },
} as unknown as HTMLCanvasElement
camera = new ViewportCamera(mockCanvas, 10000, 5000)
input = new KeyboardInput()
waypointNetwork = new WaypointNetwork()
statusElem = { textContent: '' } as unknown as HTMLElement
mockHudManager = {
leftPanel: 'none',
toggleLeftPanel: (panel: any) => {
if (panel === 'stash') stashOpenCount++
mockHudManager.leftPanel = panel
},
clientToLogical: (x: number, y: number) => ({ x, y }),
isPointInterceptedByHud: () => false,
} as unknown as HudManager
runtime = {
source: 'pack',
base: 'samples/d2-packs',
level: 'Rogue Encampment',
act: 1,
levelId: 1,
entryPath: 'act1-towne1',
entrances: [],
warps: [],
waypoints: [],
stashes: [
CANONICAL_TOWN_STASHES[1]!,
CANONICAL_TOWN_STASHES[2]!,
CANONICAL_TOWN_STASHES[3]!,
CANONICAL_TOWN_STASHES[4]!,
CANONICAL_TOWN_STASHES[5]!,
],
variants: ['towne1'],
variantParam: 'level',
variant: 'towne1',
quadrants: ['towne1'],
quadrantParam: 'level',
quadrant: 'towne1',
cellsX: 200,
cellsY: 200,
widthPx: 10000,
heightPx: 5000,
grid,
floors: [],
walls: [],
roofs: [],
objectDrawables: [],
spawn: { x: 4500, y: 1700 },
npcs: [],
monsterPacks: [],
monsterTypes: [],
objects: 5,
frames: 0,
notes: [],
pages: [],
objectPages: [],
charBases: [],
palette: null,
priorityPages: [],
loadOrder: [],
loadPages: async () => {},
}
controller = new SceneMouseController({
canvas: mockCanvas,
engine,
camera,
input,
getRuntime: () => runtime,
hudManager: mockHudManager,
waypointNetwork,
status: statusElem,
})
})
describe('Click radius (<= 48px hit, > 48px miss)', () => {
const stash1 = CANONICAL_TOWN_STASHES[1]! // (4512, 1720)
it('asserts exact center click hits the stash', () => {
expect(controller.findNearestStash(stash1.x, stash1.y, 48)).toEqual(stash1)
})
it('asserts clicks at distance <= 48px hit the stash in all cardinal directions', () => {
expect(controller.findNearestStash(stash1.x + 48, stash1.y, 48)).toEqual(stash1)
expect(controller.findNearestStash(stash1.x - 48, stash1.y, 48)).toEqual(stash1)
expect(controller.findNearestStash(stash1.x, stash1.y + 48, 48)).toEqual(stash1)
expect(controller.findNearestStash(stash1.x, stash1.y - 48, 48)).toEqual(stash1)
})
it('asserts 36-angle radial sweep at radius 47.5px (strictly <= 48px) all hit the stash', () => {
for (let i = 0; i < 36; i++) {
const angle = (i * 2 * Math.PI) / 36
const x = stash1.x + 47.5 * Math.cos(angle)
const y = stash1.y + 47.5 * Math.sin(angle)
const hit = controller.findNearestStash(x, y, 48)
expect(hit, `Angle ${i * 10}deg at distance <= 48 should hit stash`).toEqual(stash1)
}
})
it('asserts clicks at distance > 48px do NOT hit the stash', () => {
// Just beyond boundary (49px, 50px, 60px)
expect(controller.findNearestStash(stash1.x + 49, stash1.y, 48)).toBeNull()
expect(controller.findNearestStash(stash1.x - 49, stash1.y, 48)).toBeNull()
expect(controller.findNearestStash(stash1.x, stash1.y + 49, 48)).toBeNull()
expect(controller.findNearestStash(stash1.x, stash1.y - 49, 48)).toBeNull()
// Diagonal beyond boundary (35, 35) -> dist = 49.5px > 48px
expect(controller.findNearestStash(stash1.x + 35, stash1.y + 35, 48)).toBeNull()
expect(controller.findNearestStash(stash1.x - 35, stash1.y - 35, 48)).toBeNull()
// Distant clicks
expect(controller.findNearestStash(stash1.x + 100, stash1.y, 48)).toBeNull()
expect(controller.findNearestStash(0, 0, 48)).toBeNull()
})
it('asserts 36-angle radial sweep at radius 50px all miss the stash', () => {
for (let i = 0; i < 36; i++) {
const angle = (i * 2 * Math.PI) / 36
const x = stash1.x + 50 * Math.cos(angle)
const y = stash1.y + 50 * Math.sin(angle)
const hit = controller.findNearestStash(x, y, 48)
expect(hit, `Angle ${i * 10}deg at distance 50px must not hit stash`).toBeNull()
}
})
it('discriminates between closest stash when multiple stashes are present', () => {
const stash3 = CANONICAL_TOWN_STASHES[3]! // (5264, 1584)
const stash2 = CANONICAL_TOWN_STASHES[2]! // (5328, 1568)
// Click near stash3
expect(controller.findNearestStash(stash3.x + 10, stash3.y, 48)).toEqual(stash3)
// Click near stash2
expect(controller.findNearestStash(stash2.x + 10, stash2.y, 48)).toEqual(stash2)
})
it('tests custom maxRadius overrides (e.g. 24px, 64px)', () => {
// At 30px away: misses with maxRadius=24, hits with maxRadius=48 and maxRadius=64
expect(controller.findNearestStash(stash1.x + 30, stash1.y, 24)).toBeNull()
expect(controller.findNearestStash(stash1.x + 30, stash1.y, 48)).toEqual(stash1)
expect(controller.findNearestStash(stash1.x + 60, stash1.y, 64)).toEqual(stash1)
})
})
describe('Distance threshold (<= 80px immediate open, > 80px pathfind)', () => {
const stash = CANONICAL_TOWN_STASHES[1]! // (4512, 1720)
it('player at distance <= 80px opens stash immediately without movement', () => {
const testDistances = [0, 20, 50, 75, 80]
for (const dist of testDistances) {
stashOpenCount = 0
mockHudManager.leftPanel = 'none'
engine.world.player.x = stash.x - dist
engine.world.player.y = stash.y
controller.handleLeftClickWorld(stash.x, stash.y)
expect(controller.activeClickType).toBe('stash')
expect(controller.pendingInteraction).toBeNull()
expect(controller.navTarget).toBeNull()
expect(stashOpenCount).toBe(1)
expect(statusElem.textContent).toContain('储物箱')
}
})
it('player at distance > 80px schedules pathfinding/walk and does not open stash immediately', () => {
const testDistances = [81, 100, 250, 500]
for (const dist of testDistances) {
stashOpenCount = 0
mockHudManager.leftPanel = 'none'
engine.world.player.x = stash.x - dist
engine.world.player.y = stash.y
controller.handleLeftClickWorld(stash.x, stash.y)
expect(controller.activeClickType).toBe('stash')
expect(controller.pendingInteraction).toEqual({ kind: 'stash', stash })
expect(controller.navTarget).toEqual({ x: stash.x, y: stash.y })
expect(stashOpenCount).toBe(0)
}
})
it('player walks toward stash and opens automatically upon reaching <= 80px in tick()', () => {
engine.world.player.x = stash.x - 300
engine.world.player.y = stash.y
controller.handleLeftClickWorld(stash.x, stash.y)
expect(stashOpenCount).toBe(0)
expect(controller.pendingInteraction).toEqual({ kind: 'stash', stash })
expect(controller.navTarget).toEqual({ x: stash.x, y: stash.y })
// Step 1: Walk to 200px away -> still too far
engine.world.player.x = stash.x - 200
controller.tick()
expect(stashOpenCount).toBe(0)
expect(controller.pendingInteraction).not.toBeNull()
expect(controller.navTarget).toEqual({ x: stash.x, y: stash.y })
// Step 2: Walk to 90px away -> still outside 80px
engine.world.player.x = stash.x - 90
controller.tick()
expect(stashOpenCount).toBe(0)
expect(controller.pendingInteraction).not.toBeNull()
// Step 3: Arrive at 75px away (<= 80px) -> triggers open!
engine.world.player.x = stash.x - 75
controller.tick()
expect(stashOpenCount).toBe(1)
expect(controller.pendingInteraction).toBeNull()
expect(controller.navTarget).toBeNull()
expect(statusElem.textContent).toContain('储物箱')
})
it('cancels pending stash interaction if user left-clicks ground or elsewhere', () => {
engine.world.player.x = stash.x - 200
engine.world.player.y = stash.y
controller.handleLeftClickWorld(stash.x, stash.y)
expect(controller.pendingInteraction).toEqual({ kind: 'stash', stash })
// Click ground at an unrelated point
controller.handleLeftClickWorld(stash.x + 500, stash.y + 500)
expect(controller.activeClickType).toBe('ground')
expect(controller.pendingInteraction).toBeNull()
})
})
})
describe('3. Gold Mechanics Stress Testing', () => {
let panels: WorldPanelsHud
let inv: { gold: number }
beforeEach(() => {
panels = new WorldPanelsHud()
inv = { gold: 500_000 }
panels.stashGold = 100_000
})
it('deposit and withdraw valid amounts: character gold + stash gold remains strictly constant', () => {
const initialTotal = inv.gold + panels.stashGold // 600,000
const transferSequence = [10_000, 50_000, 100_000, 25_000, 1_000, 777]
for (const amt of transferSequence) {
const deposited = panels.depositGold(amt, inv)
expect(deposited).toBe(amt)
expect(inv.gold + panels.stashGold).toBe(initialTotal)
}
const withdrawSequence = [5_000, 20_000, 80_000, 500, 123]
for (const amt of withdrawSequence) {
const withdrawn = panels.withdrawGold(amt, inv)
expect(withdrawn).toBe(amt)
expect(inv.gold + panels.stashGold).toBe(initialTotal)
}
})
it('stress tests 100 random valid transactions preserving gold conservation law', () => {
const initialTotal = inv.gold + panels.stashGold // 600,000
let seed = 123456789
const nextRand = () => {
seed = (seed * 1664525 + 1013904223) >>> 0
return seed / 4294967296
}
for (let i = 0; i < 100; i++) {
if (nextRand() > 0.5) {
// Deposit random amount up to player's current gold
const amt = Math.floor(nextRand() * (inv.gold + 1))
panels.depositGold(amt, inv)
} else {
// Withdraw random amount up to stash's current gold
const amt = Math.floor(nextRand() * (panels.stashGold + 1))
panels.withdrawGold(amt, inv)
}
// Strict conservation invariant:
expect(inv.gold + panels.stashGold).toBe(initialTotal)
expect(inv.gold).toBeGreaterThanOrEqual(0)
expect(panels.stashGold).toBeGreaterThanOrEqual(0)
}
})
it('negative values (e.g. -500 gold): must be rejected or no-op without balance mutation', () => {
const initialInv = inv.gold
const initialStash = panels.stashGold
const negativeAmounts = [-1, -500, -10_000, -2_500_000, -1e9, Number.MIN_SAFE_INTEGER]
for (const neg of negativeAmounts) {
expect(panels.depositGold(neg, inv)).toBe(0)
expect(inv.gold).toBe(initialInv)
expect(panels.stashGold).toBe(initialStash)
expect(panels.withdrawGold(neg, inv)).toBe(0)
expect(inv.gold).toBe(initialInv)
expect(panels.stashGold).toBe(initialStash)
}
})
it('zero and -0 value transfers: must be rejected as no-op', () => {
expect(panels.depositGold(0, inv)).toBe(0)
expect(panels.depositGold(-0, inv)).toBe(0)
expect(panels.withdrawGold(0, inv)).toBe(0)
expect(panels.withdrawGold(-0, inv)).toBe(0)
expect(inv.gold).toBe(500_000)
expect(panels.stashGold).toBe(100_000)
})
it('exceeding player gold on deposit: deposits max available without creating negative player gold', () => {
inv.gold = 35_000
panels.stashGold = 50_000
const deposited = panels.depositGold(100_000, inv)
expect(deposited).toBe(35_000)
expect(inv.gold).toBe(0)
expect(panels.stashGold).toBe(85_000)
// Further deposit when player gold is 0 yields 0
expect(panels.depositGold(10_000, inv)).toBe(0)
expect(inv.gold).toBe(0)
expect(panels.stashGold).toBe(85_000)
})
it('exceeding stash gold on withdraw: withdraws max available without creating negative stash gold', () => {
panels.stashGold = 42_000
inv.gold = 10_000
const withdrawn = panels.withdrawGold(100_000, inv)
expect(withdrawn).toBe(42_000)
expect(panels.stashGold).toBe(0)
expect(inv.gold).toBe(52_000)
// Further withdrawal when stash gold is 0 yields 0
expect(panels.withdrawGold(10_000, inv)).toBe(0)
expect(panels.stashGold).toBe(0)
expect(inv.gold).toBe(52_000)
})
it('gold cap enforcement: character and stash gold cannot exceed 2,500,000', () => {
expect(STASH_GOLD_CAP).toBe(2_500_000)
expect(PLAYER_GOLD_CAP).toBe(2_500_000)
// Stash cap clamping on deposit
panels.stashGold = 2_450_000
inv.gold = 200_000
const dep1 = panels.depositGold(100_000, inv)
expect(dep1).toBe(50_000)
expect(panels.stashGold).toBe(2_500_000)
expect(inv.gold).toBe(150_000)
// Further deposits clamped to 0
expect(panels.depositGold(50_000, inv)).toBe(0)
expect(panels.stashGold).toBe(2_500_000)
expect(inv.gold).toBe(150_000)
// Player cap clamping on withdrawal
inv.gold = 2_480_000
panels.stashGold = 500_000
const w1 = panels.withdrawGold(100_000, inv)
expect(w1).toBe(20_000)
expect(inv.gold).toBe(2_500_000)
expect(panels.stashGold).toBe(480_000)
// Further withdrawals clamped to 0
expect(panels.withdrawGold(10_000, inv)).toBe(0)
expect(inv.gold).toBe(2_500_000)
expect(panels.stashGold).toBe(480_000)
})
it('boundary caps: handles both character and stash at 2,500,000 or both at 0', () => {
// Both at zero
inv.gold = 0
panels.stashGold = 0
expect(panels.depositGold(10_000, inv)).toBe(0)
expect(panels.withdrawGold(10_000, inv)).toBe(0)
expect(inv.gold).toBe(0)
expect(panels.stashGold).toBe(0)
// Both at maximum 2,500,000
inv.gold = 2_500_000
panels.stashGold = 2_500_000
expect(panels.depositGold(10_000, inv)).toBe(0)
expect(panels.withdrawGold(10_000, inv)).toBe(0)
expect(inv.gold).toBe(2_500_000)
expect(panels.stashGold).toBe(2_500_000)
})
it('infinity and extreme numbers: safely clamps or rejects without corrupting finite state', () => {
inv.gold = 300_000
panels.stashGold = 100_000
// Deposit Infinity: clamped to player's entire available gold
const depInf = panels.depositGold(Infinity, inv)
expect(depInf).toBe(300_000)
expect(inv.gold).toBe(0)
expect(panels.stashGold).toBe(400_000)
// Withdraw Infinity: clamped to stash's entire available gold
const wInf = panels.withdrawGold(Infinity, inv)
expect(wInf).toBe(400_000)
expect(inv.gold).toBe(400_000)
expect(panels.stashGold).toBe(0)
// Negative infinity: rejected as <= 0
expect(panels.depositGold(-Infinity, inv)).toBe(0)
expect(panels.withdrawGold(-Infinity, inv)).toBe(0)
expect(inv.gold).toBe(400_000)
expect(panels.stashGold).toBe(0)
// Number.MAX_SAFE_INTEGER
inv.gold = 50_000
const depMaxSafe = panels.depositGold(Number.MAX_SAFE_INTEGER, inv)
expect(depMaxSafe).toBe(50_000)
expect(inv.gold).toBe(0)
expect(panels.stashGold).toBe(50_000)
})
it('handles simulated UI button clicks for deposit and withdraw', () => {
inv.gold = 100_000
panels.stashGold = 200_000
const clickCallbacks = {
onClose: () => {},
onWaypointTeleport: () => {},
inventory: inv as any,
}
// Click deposit button (center of STASH_DEPOSIT_BTN_BOUNDS)
const depX = STASH_DEPOSIT_BTN_BOUNDS.x + 10
const depY = STASH_DEPOSIT_BTN_BOUNDS.y + 10
const handledDep = panels.handleLeftDockClick('stash', depX, depY, clickCallbacks)
expect(handledDep).toBe(true)
expect(panels.stashGold).toBe(250_000) // Default batch is 50,000
expect(inv.gold).toBe(50_000)
// Click withdraw button (center of STASH_WITHDRAW_BTN_BOUNDS)
const wX = STASH_WITHDRAW_BTN_BOUNDS.x + 10
const wY = STASH_WITHDRAW_BTN_BOUNDS.y + 10
const handledW = panels.handleLeftDockClick('stash', wX, wY, clickCallbacks)
expect(handledW).toBe(true)
expect(panels.stashGold).toBe(200_000)
expect(inv.gold).toBe(100_000)
})
})
describe('4. Dual-Dock Panel State Consistency', () => {
let hudCanvas: HTMLCanvasElement
let hud: HudManager
beforeEach(() => {
hudCanvas = {
getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }),
clientWidth: 800,
clientHeight: 600,
width: 800,
height: 600,
addEventListener: () => {},
removeEventListener: () => {},
getContext: () => null,
style: { cursor: '' },
} as unknown as HTMLCanvasElement
hud = new HudManager(hudCanvas, {
onWaypointTeleport: () => {},
onToggleAutomap: () => {},
})
})
it('opening stash automatically opens left dock as "stash" and right dock as "inv"', () => {
expect(hud.leftPanel).toBe('none')
expect(hud.rightPanel).toBe('none')
expect(hud.inventory.visible).toBe(false)
hud.toggleLeftPanel('stash')
expect(hud.leftPanel).toBe('stash')
expect(hud.rightPanel).toBe('inv')
expect(hud.inventory.visible).toBe(true)
})
it('opening stash when inventory is already open preserves inventory on right dock', () => {
hud.toggleRightPanel('inv')
expect(hud.leftPanel).toBe('none')
expect(hud.rightPanel).toBe('inv')
hud.toggleLeftPanel('stash')
expect(hud.leftPanel).toBe('stash')
expect(hud.rightPanel).toBe('inv')
expect(hud.inventory.visible).toBe(true)
})
it('closing stash via toggle restores left dock to "none"', () => {
hud.toggleLeftPanel('stash')
expect(hud.leftPanel).toBe('stash')
hud.toggleLeftPanel('stash')
expect(hud.leftPanel).toBe('none')
})
it('closeAllPanels closes both stash and inventory simultaneously with clean state', () => {
hud.toggleLeftPanel('stash')
expect(hud.leftPanel).toBe('stash')
expect(hud.rightPanel).toBe('inv')
expect(hud.inventory.visible).toBe(true)
const closed = hud.closeAllPanels()
expect(closed).toBe(true)
expect(hud.leftPanel).toBe('none')
expect(hud.rightPanel).toBe('none')
expect(hud.inventory.visible).toBe(false)
expect(hud.charSheet.visible).toBe(false)
expect(hud.skillTree.visible).toBe(false)
})
it('switching from stash to another left panel (e.g. quest) cleanly deactivates stash', () => {
hud.toggleLeftPanel('stash')
expect(hud.leftPanel).toBe('stash')
hud.toggleLeftPanel('quest')
expect(hud.leftPanel).toBe('quest')
expect(hud.rightPanel).toBe('inv') // Inventory remains open on right dock
hud.toggleLeftPanel('quest')
expect(hud.leftPanel).toBe('none')
})
it('cycling through all left dock panels (char, quest, waypoint, stash) maintains dock invariant', () => {
const leftPanels: ('char' | 'quest' | 'waypoint' | 'stash')[] = ['char', 'quest', 'waypoint', 'stash']
for (const p of leftPanels) {
hud.toggleLeftPanel(p)
expect(hud.leftPanel).toBe(p)
if (p === 'char') expect(hud.charSheet.visible).toBe(true)
if (p === 'stash') {
expect(hud.rightPanel).toBe('inv')
expect(hud.inventory.visible).toBe(true)
}
}
hud.closeAllPanels()
expect(hud.leftPanel).toBe('none')
expect(hud.rightPanel).toBe('none')
})
})
})

View File

@ -0,0 +1,651 @@
/**
* tests/challenger-m5-stash-grid.test.ts
*
* Empirical Challenger Stress Test Suite for Milestone M5 (Issue #150):
* Stash Storage Grid (6x8), Item Placement, Bounds Checking, Collision Detection,
* Item Picking/Swapping, and Quick Transfer Mechanics.
*/
import { describe, expect, it, beforeEach } from 'vitest'
import {
STASH_GRID_ORIGIN,
STASH_PANEL_ORIGIN,
WorldPanelsHud,
} from '../src/ui/world-panels.ts'
import {
InventoryPanel,
type UiInventoryItem,
INV_GRID_ORIGIN,
} from '../src/ui/inventory.ts'
class MockWindow extends EventTarget {
innerWidth = 1920
innerHeight = 1080
devicePixelRatio = 1
}
if (typeof globalThis.window === 'undefined') {
// @ts-ignore
globalThis.window = new MockWindow()
}
if (typeof globalThis.HTMLInputElement === 'undefined') {
// @ts-ignore
globalThis.HTMLInputElement = class HTMLInputElement {}
}
if (typeof globalThis.HTMLSelectElement === 'undefined') {
// @ts-ignore
globalThis.HTMLSelectElement = class HTMLSelectElement {}
}
function createItem(id: string, code: string, w: number, h: number, nameZh: string): UiInventoryItem {
return {
id,
code,
invFile: code,
name: nameZh,
nameZh,
baseNameZh: nameZh,
quality: 'normal',
invWidth: w,
invHeight: h,
allowedSlots: [],
stats: [{ text: `Stress item ${id}`, color: 'white' }],
}
}
describe('Challenger M5 Empirical Stress Tests: 6x8 Stash Storage Grid & Mechanics', () => {
let panels: WorldPanelsHud
let inv: InventoryPanel
beforeEach(() => {
panels = new WorldPanelsHud()
inv = new InventoryPanel()
// Clear default inventory items so we start with a clean slate
inv.gridItems = []
})
describe('1. Comprehensive Multi-Size Item Placement & Coordinate Bounds', () => {
const itemSizes: Array<{ name: string; code: string; w: number; h: number }> = [
{ name: '1x1 Ring', code: 'rin', w: 1, h: 1 },
{ name: '1x2 Potion', code: 'hp1', w: 1, h: 2 },
{ name: '2x2 Helm', code: 'cap', w: 2, h: 2 },
{ name: '2x3 Crystal Sword', code: 'crs', w: 2, h: 3 },
{ name: '2x4 Pike', code: 'pik', w: 2, h: 4 },
]
for (const { name, code, w, h } of itemSizes) {
const maxCol = STASH_GRID_ORIGIN.cols - w
const maxRow = STASH_GRID_ORIGIN.rows - h
const totalValid = (maxCol + 1) * (maxRow + 1)
it(`exhaustively places ${name} (${w}x${h}) at all ${totalValid} valid coordinates on 6x8 grid`, () => {
let testedCount = 0
for (let col = 0; col <= maxCol; col++) {
for (let row = 0; row <= maxRow; row++) {
const freshHud = new WorldPanelsHud()
const item = createItem(`item-${code}-${col}-${row}`, code, w, h, `${name} (${col},${row})`)
// Must be accepted
expect(freshHud.canPlaceInStash(col, row, w, h)).toBe(true)
// Place item
freshHud.stashItems.push({ item, col, row })
expect(freshHud.stashItems).toHaveLength(1)
// Verify getStashOverlaps identifies this exact item
const overlaps = freshHud.getStashOverlaps(col, row, w, h)
expect(overlaps).toHaveLength(1)
expect(overlaps[0]!.item.id).toBe(item.id)
// Check every single sub-cell occupied by this item
for (let dc = 0; dc < w; dc++) {
for (let dr = 0; dr < h; dr++) {
expect(freshHud.canPlaceInStash(col + dc, row + dr, 1, 1)).toBe(false)
}
}
testedCount++
}
}
expect(testedCount).toBe(totalValid)
})
}
it('enforces exact boundary conditions for 2x2 item: (4,6) passes; (5,6) and (4,7) rejected', () => {
// (4, 6) -> col+2 = 6 <= 6, row+2 = 8 <= 8 (Valid bottom-rightmost coordinate for 2x2)
expect(panels.canPlaceInStash(4, 6, 2, 2)).toBe(true)
expect(() => panels.getStashOverlaps(4, 6, 2, 2)).not.toThrow()
// (5, 6) -> col+2 = 7 > 6 (Exceeds columns)
expect(panels.canPlaceInStash(5, 6, 2, 2)).toBe(false)
expect(() => panels.getStashOverlaps(5, 6, 2, 2)).toThrow(RangeError)
// (4, 7) -> row+2 = 9 > 8 (Exceeds rows)
expect(panels.canPlaceInStash(4, 7, 2, 2)).toBe(false)
expect(() => panels.getStashOverlaps(4, 7, 2, 2)).toThrow(RangeError)
// (5, 7) -> Exceeds both
expect(panels.canPlaceInStash(5, 7, 2, 2)).toBe(false)
expect(() => panels.getStashOverlaps(5, 7, 2, 2)).toThrow(RangeError)
})
it('enforces boundary conditions for all other multi-cell sizes', () => {
// 1x1: (5, 7) passes; (6, 7), (5, 8) rejected
expect(panels.canPlaceInStash(5, 7, 1, 1)).toBe(true)
expect(panels.canPlaceInStash(6, 7, 1, 1)).toBe(false)
expect(panels.canPlaceInStash(5, 8, 1, 1)).toBe(false)
// 1x2: (5, 6) passes; (6, 6), (5, 7) rejected
expect(panels.canPlaceInStash(5, 6, 1, 2)).toBe(true)
expect(panels.canPlaceInStash(6, 6, 1, 2)).toBe(false)
expect(panels.canPlaceInStash(5, 7, 1, 2)).toBe(false)
// 2x3: (4, 5) passes; (5, 5), (4, 6) rejected
expect(panels.canPlaceInStash(4, 5, 2, 3)).toBe(true)
expect(panels.canPlaceInStash(5, 5, 2, 3)).toBe(false)
expect(panels.canPlaceInStash(4, 6, 2, 3)).toBe(false)
// 2x4: (4, 4) passes; (5, 4), (4, 5) rejected
expect(panels.canPlaceInStash(4, 4, 2, 4)).toBe(true)
expect(panels.canPlaceInStash(5, 4, 2, 4)).toBe(false)
expect(panels.canPlaceInStash(4, 5, 2, 4)).toBe(false)
})
it('rejects negative and out-of-grid coordinates unconditionally', () => {
const negativeCoords = [
[-1, 0],
[0, -1],
[-1, -1],
[-5, 2],
[3, -4],
[-100, -100],
]
for (const [col, row] of negativeCoords) {
expect(panels.canPlaceInStash(col, row, 1, 1)).toBe(false)
expect(() => panels.getStashOverlaps(col, row, 1, 1)).toThrow(RangeError)
}
const outOfGridCoords = [
[6, 0],
[0, 8],
[6, 8],
[7, 0],
[0, 9],
[10, 10],
[100, 100],
]
for (const [col, row] of outOfGridCoords) {
expect(panels.canPlaceInStash(col, row, 1, 1)).toBe(false)
expect(() => panels.getStashOverlaps(col, row, 1, 1)).toThrow(RangeError)
}
})
})
describe('2. Collision Detection & Overlap Prevention', () => {
it('verifies that an item cannot overlap even a single cell of an existing placed item', () => {
// Place a 2x3 sword at (col: 2, row: 2).
// Occupied cells:
// (2,2), (3,2)
// (2,3), (3,3)
// (2,4), (3,4)
const sword = createItem('sword-main', 'crs', 2, 3, '水晶剑 (Crystal Sword)')
panels.stashItems.push({ item: sword, col: 2, row: 2 })
// 1. Every occupied cell cannot host a 1x1 item
for (let c = 2; c <= 3; c++) {
for (let r = 2; r <= 4; r++) {
expect(panels.canPlaceInStash(c, r, 1, 1)).toBe(false)
const overlaps = panels.getStashOverlaps(c, r, 1, 1)
expect(overlaps).toHaveLength(1)
expect(overlaps[0]!.item.id).toBe('sword-main')
}
}
// 2. Multi-cell items overlapping only a single corner cell
// Top-Left corner: 2x2 at (1, 1) overlaps sword ONLY at (2, 2)
expect(panels.canPlaceInStash(1, 1, 2, 2)).toBe(false)
expect(panels.getStashOverlaps(1, 1, 2, 2)).toHaveLength(1)
// Top-Right corner: 2x2 at (3, 1) overlaps sword ONLY at (3, 2)
expect(panels.canPlaceInStash(3, 1, 2, 2)).toBe(false)
expect(panels.getStashOverlaps(3, 1, 2, 2)).toHaveLength(1)
// Bottom-Left corner: 2x2 at (1, 4) overlaps sword ONLY at (2, 4)
expect(panels.canPlaceInStash(1, 4, 2, 2)).toBe(false)
expect(panels.getStashOverlaps(1, 4, 2, 2)).toHaveLength(1)
// Bottom-Right corner: 2x2 at (3, 4) overlaps sword ONLY at (3, 4)
expect(panels.canPlaceInStash(3, 4, 2, 2)).toBe(false)
expect(panels.getStashOverlaps(3, 4, 2, 2)).toHaveLength(1)
// 1x2 overlapping only (2, 2) from top at (2, 1)
expect(panels.canPlaceInStash(2, 1, 1, 2)).toBe(false)
// 1x2 overlapping only (3, 4) from bottom at (3, 4)
expect(panels.canPlaceInStash(3, 4, 1, 2)).toBe(false)
// 3. Immediately adjacent non-touching cells are fully free
expect(panels.canPlaceInStash(1, 2, 1, 1)).toBe(true) // left
expect(panels.canPlaceInStash(1, 3, 1, 1)).toBe(true)
expect(panels.canPlaceInStash(1, 4, 1, 1)).toBe(true)
expect(panels.canPlaceInStash(4, 2, 1, 1)).toBe(true) // right
expect(panels.canPlaceInStash(4, 3, 1, 1)).toBe(true)
expect(panels.canPlaceInStash(4, 4, 1, 1)).toBe(true)
expect(panels.canPlaceInStash(2, 1, 1, 1)).toBe(true) // top
expect(panels.canPlaceInStash(3, 1, 1, 1)).toBe(true)
expect(panels.canPlaceInStash(2, 5, 1, 1)).toBe(true) // bottom
expect(panels.canPlaceInStash(3, 5, 1, 1)).toBe(true)
})
it('fills stash to maximum capacity (48 1x1 items) and asserts canPlaceInStash & autoPlaceInStash return false', () => {
// 6 columns * 8 rows = 48 items
for (let c = 0; c < 6; c++) {
for (let r = 0; r < 8; r++) {
const item = createItem(`gem-${c}-${r}`, 'gem', 1, 1, `宝石 (${c},${r})`)
expect(panels.canPlaceInStash(c, r, 1, 1)).toBe(true)
panels.stashItems.push({ item, col: c, row: r })
}
}
expect(panels.stashItems).toHaveLength(48)
// Now every coordinate and size must return false
for (let c = 0; c < 6; c++) {
for (let r = 0; r < 8; r++) {
expect(panels.canPlaceInStash(c, r, 1, 1)).toBe(false)
}
}
// Any attempt to place any size must fail
const testItem1x1 = createItem('overflow-1x1', 'rin', 1, 1, '溢出戒指')
const testItem2x2 = createItem('overflow-2x2', 'cap', 2, 2, '溢出头盔')
const testItem2x4 = createItem('overflow-2x4', 'pik', 2, 4, '溢出长枪')
expect(panels.autoPlaceInStash(testItem1x1)).toBe(false)
expect(panels.autoPlaceInStash(testItem2x2)).toBe(false)
expect(panels.autoPlaceInStash(testItem2x4)).toBe(false)
expect(panels.stashItems).toHaveLength(48)
})
it('tiles stash completely with 12 2x2 items and asserts zero further placements', () => {
// 3 cols of 2x2 (cols 0, 2, 4) * 4 rows of 2x2 (rows 0, 2, 4, 6) = 12 items (48 cells)
for (let c = 0; c < 6; c += 2) {
for (let r = 0; r < 8; r += 2) {
const shield = createItem(`shield-${c}-${r}`, 'kit', 2, 2, `盾牌 (${c},${r})`)
expect(panels.canPlaceInStash(c, r, 2, 2)).toBe(true)
panels.stashItems.push({ item: shield, col: c, row: r })
}
}
expect(panels.stashItems).toHaveLength(12)
// Stash is 100% saturated
for (let c = 0; c < 6; c++) {
for (let r = 0; r < 8; r++) {
expect(panels.canPlaceInStash(c, r, 1, 1)).toBe(false)
}
}
expect(panels.autoPlaceInStash(createItem('fail-1x1', 'rin', 1, 1, '戒指'))).toBe(false)
expect(panels.autoPlaceInStash(createItem('fail-2x2', 'cap', 2, 2, '头盔'))).toBe(false)
})
it('tiles stash completely with 6 2x4 items and asserts zero further placements', () => {
// 3 cols of 2x4 (cols 0, 2, 4) * 2 rows of 2x4 (rows 0, 4) = 6 items (48 cells)
for (let c = 0; c < 6; c += 2) {
for (let r = 0; r < 8; r += 4) {
const pike = createItem(`pike-${c}-${r}`, 'pik', 2, 4, `长枪 (${c},${r})`)
expect(panels.canPlaceInStash(c, r, 2, 4)).toBe(true)
panels.stashItems.push({ item: pike, col: c, row: r })
}
}
expect(panels.stashItems).toHaveLength(6)
// Fully covered
for (let c = 0; c < 6; c++) {
for (let r = 0; r < 8; r++) {
expect(panels.canPlaceInStash(c, r, 1, 1)).toBe(false)
}
}
expect(panels.autoPlaceInStash(createItem('fail-1x1', 'rin', 1, 1, '戒指'))).toBe(false)
})
})
describe('3. Item Picking, Swapping, and Drag-and-Drop', () => {
it('picks up multi-cell item onto cursor from any constituent cell and verifies vacancy', () => {
const sword = createItem('sword-pick', 'crs', 2, 3, '水晶剑')
panels.stashItems.push({ item: sword, col: 1, row: 2 })
// The sword spans cols 1..2, rows 2..4 (6 sub-cells).
// Test that clicking ANY of these 6 cells with empty cursor picks up the sword.
const constituentCells = [
[1, 2], [2, 2],
[1, 3], [2, 3],
[1, 4], [2, 4],
]
for (const [col, row] of constituentCells) {
// Reset stash with the sword
panels.stashItems = [{ item: sword, col: 1, row: 2 }]
const pickRes = panels.clickStashCell(col, row, null)
expect(pickRes.handled).toBe(true)
expect(pickRes.newCursorItem).toBeDefined()
expect(pickRes.newCursorItem?.id).toBe('sword-pick')
expect(panels.stashItems).toHaveLength(0)
// All 6 cells must now be vacant
for (const [c, r] of constituentCells) {
expect(panels.canPlaceInStash(c, r, 1, 1)).toBe(true)
}
}
// Clicking empty cell returns handled: false
const emptyRes = panels.clickStashCell(0, 0, null)
expect(emptyRes.handled).toBe(false)
expect(emptyRes.newCursorItem).toBeNull()
})
it('drops item from cursor onto empty cells', () => {
const helm = createItem('helm-drop', 'cap', 2, 2, '军帽')
const placeRes = panels.clickStashCell(3, 4, helm)
expect(placeRes.handled).toBe(true)
expect(placeRes.newCursorItem).toBeNull()
expect(panels.stashItems).toHaveLength(1)
expect(panels.stashItems[0]!.item.id).toBe('helm-drop')
expect(panels.stashItems[0]!.col).toBe(3)
expect(panels.stashItems[0]!.row).toBe(4)
})
it('swaps items of equal size (2x2 with 2x2)', () => {
const helmA = createItem('helm-a', 'cap', 2, 2, '暗金军帽')
const helmB = createItem('helm-b', 'ghm', 2, 2, '野蛮人头盔')
panels.stashItems.push({ item: helmA, col: 1, row: 1 })
// Click at (1, 1) holding helmB
const swapRes = panels.clickStashCell(1, 1, helmB)
expect(swapRes.handled).toBe(true)
expect(swapRes.newCursorItem?.id).toBe('helm-a')
expect(panels.stashItems).toHaveLength(1)
expect(panels.stashItems[0]!.item.id).toBe('helm-b')
expect(panels.stashItems[0]!.col).toBe(1)
expect(panels.stashItems[0]!.row).toBe(1)
})
it('swaps items of different sizes (1x1 with 2x2) when surrounding cells are free', () => {
const ring = createItem('ring-swap', 'rin', 1, 1, '乔丹之石')
const shield = createItem('shield-swap', 'kit', 2, 2, '暴风之盾')
panels.stashItems.push({ item: ring, col: 2, row: 2 })
// Shield (2x2) dropped on (2, 2). Only ring is in that 2x2 area.
const swapRes = panels.clickStashCell(2, 2, shield)
expect(swapRes.handled).toBe(true)
expect(swapRes.newCursorItem?.id).toBe('ring-swap')
expect(panels.stashItems).toHaveLength(1)
expect(panels.stashItems[0]!.item.id).toBe('shield-swap')
expect(panels.stashItems[0]!.col).toBe(2)
expect(panels.stashItems[0]!.row).toBe(2)
// Reverse swap: drop ring (1x1) on shield (2x2) at (2, 2)
const reverseSwap = panels.clickStashCell(2, 2, swapRes.newCursorItem)
expect(reverseSwap.handled).toBe(true)
expect(reverseSwap.newCursorItem?.id).toBe('shield-swap')
expect(panels.stashItems).toHaveLength(1)
expect(panels.stashItems[0]!.item.id).toBe('ring-swap')
expect(panels.stashItems[0]!.col).toBe(2)
expect(panels.stashItems[0]!.row).toBe(2)
})
it('safely prevents swap if placement collides with a third item (multiple overlaps)', () => {
const ringA = createItem('ring-a', 'rin', 1, 1, '戒指 A')
const ringC = createItem('ring-c', 'rin', 1, 1, '戒指 C')
const shield = createItem('shield-multi', 'kit', 2, 2, '大盾牌')
// Place ringA at (2, 2) and ringC at (3, 2)
panels.stashItems.push({ item: ringA, col: 2, row: 2 })
panels.stashItems.push({ item: ringC, col: 3, row: 2 })
// Attempt to place shield (2x2) at (2, 2). It overlaps BOTH ringA and ringC.
const failSwap = panels.clickStashCell(2, 2, shield)
expect(failSwap.handled).toBe(false)
expect(failSwap.newCursorItem?.id).toBe('shield-multi') // Still held on cursor!
// Both rings remain completely untouched
expect(panels.stashItems).toHaveLength(2)
expect(panels.stashItems.map(p => p.item.id).sort()).toEqual(['ring-a', 'ring-c'])
// Add a third item: ringD at (2, 3) -> 3 overlaps
const ringD = createItem('ring-d', 'rin', 1, 1, '戒指 D')
panels.stashItems.push({ item: ringD, col: 2, row: 3 })
const failSwap3 = panels.clickStashCell(2, 2, shield)
expect(failSwap3.handled).toBe(false)
expect(failSwap3.newCursorItem?.id).toBe('shield-multi')
expect(panels.stashItems).toHaveLength(3)
})
it('safely prevents swap or drop if placement would cause out-of-bounds', () => {
const ring = createItem('ring-corner', 'rin', 1, 1, '角隅戒指')
const shield = createItem('shield-oob', 'kit', 2, 2, '盾牌')
panels.stashItems.push({ item: ring, col: 5, row: 7 })
// Attempt to drop shield at (5, 7) -> 5+2=7 > 6, 7+2=9 > 8
const failRes = panels.clickStashCell(5, 7, shield)
expect(failRes.handled).toBe(false)
expect(failRes.newCursorItem?.id).toBe('shield-oob')
expect(panels.stashItems).toHaveLength(1)
expect(panels.stashItems[0]!.item.id).toBe('ring-corner')
// Negative coordinates with held item
const negRes = panels.clickStashCell(-1, 0, shield)
expect(negRes.handled).toBe(false)
expect(negRes.newCursorItem?.id).toBe('shield-oob')
})
})
describe('4. Quick Transfer (Shift+Click / Right-Click) Stress Testing', () => {
it('rapidly transfers 20 items from inventory to stash with zero loss and zero duplication', () => {
// 10 1x1 items + 10 1x2 items = 30 cells (fits comfortably in 10x4 inventory)
const generatedItems: UiInventoryItem[] = []
for (let i = 0; i < 10; i++) {
const item1x1 = createItem(`burst-1x1-${i}`, 'rin', 1, 1, `戒指 ${i}`)
generatedItems.push(item1x1)
expect(inv.autoPlaceInGrid(item1x1)).toBe(true)
const item1x2 = createItem(`burst-1x2-${i}`, 'hp1', 1, 2, `药剂 ${i}`)
generatedItems.push(item1x2)
expect(inv.autoPlaceInGrid(item1x2)).toBe(true)
}
expect(inv.gridItems).toHaveLength(20)
expect(panels.stashItems).toHaveLength(0)
// Rapidly transfer all 20 items to stash
for (const item of generatedItems) {
const ok = panels.quickTransferToStash(item, inv)
expect(ok, `Item ${item.id} should successfully transfer`).toBe(true)
}
// Assert inventory is completely empty, stash has all 20 items
expect(inv.gridItems).toHaveLength(0)
expect(panels.stashItems).toHaveLength(20)
// Invariance check: verify exact set of item IDs
const stashItemIds = panels.stashItems.map(p => p.item.id).sort()
const expectedIds = generatedItems.map(i => i.id).sort()
expect(stashItemIds).toEqual(expectedIds)
expect(new Set(stashItemIds).size).toBe(20) // Zero duplicates
})
it('rejects larger items (2x2) when stash has only 1x1 slots free, while 1x1 items successfully transfer', () => {
// Create a fragmented stash layout:
// Fill cols 0..3 for all rows 0..7 (32 cells) with four 2x4 pikes
panels.stashItems.push({ item: createItem('fill-p1', 'pik', 2, 4, '长枪 1'), col: 0, row: 0 })
panels.stashItems.push({ item: createItem('fill-p2', 'pik', 2, 4, '长枪 2'), col: 0, row: 4 })
panels.stashItems.push({ item: createItem('fill-p3', 'pik', 2, 4, '长枪 3'), col: 2, row: 0 })
panels.stashItems.push({ item: createItem('fill-p4', 'pik', 2, 4, '长枪 4'), col: 2, row: 4 })
// Now cols 4..5 are remaining (2 cols * 8 rows = 16 cells).
// We fill rows 0..5 with three 2x2 shields at row 0, row 2, row 4
panels.stashItems.push({ item: createItem('fill-s1', 'kit', 2, 2, '盾牌 1'), col: 4, row: 0 })
panels.stashItems.push({ item: createItem('fill-s2', 'kit', 2, 2, '盾牌 2'), col: 4, row: 2 })
panels.stashItems.push({ item: createItem('fill-s3', 'kit', 2, 2, '盾牌 3'), col: 4, row: 4 })
// Now only rows 6 and 7 in cols 4..5 are left (4 cells: (4,6), (5,6), (4,7), (5,7)).
// Place two 1x1 gems at (4,6) and (5,7) in a checkerboard pattern!
panels.stashItems.push({ item: createItem('gem-4-6', 'gem', 1, 1, '宝石 1'), col: 4, row: 6 })
panels.stashItems.push({ item: createItem('gem-5-7', 'gem', 1, 1, '宝石 2'), col: 5, row: 7 })
// Stash has only TWO isolated 1x1 cells free: (5, 6) and (4, 7).
// Verify no 2x2 or 1x2 or 2x1 item can fit!
expect(panels.canPlaceInStash(5, 6, 1, 1)).toBe(true)
expect(panels.canPlaceInStash(4, 7, 1, 1)).toBe(true)
expect(panels.canPlaceInStash(4, 6, 2, 2)).toBe(false)
expect(panels.canPlaceInStash(5, 6, 1, 2)).toBe(false)
expect(panels.canPlaceInStash(4, 7, 2, 1)).toBe(false)
// Setup inventory with:
// 1 2x2 shield
// 2 1x1 rings (fit perfectly into remaining 2 slots)
// 1 1x1 rune (will overflow once those 2 slots are filled)
const bigShield = createItem('big-shield', 'kit', 2, 2, '巨大盾牌')
const ring1 = createItem('ring-fit-1', 'rin', 1, 1, '契合戒指 1')
const ring2 = createItem('ring-fit-2', 'rin', 1, 1, '契合戒指 2')
const ringOverflow = createItem('ring-overflow', 'rin', 1, 1, '溢出戒指')
inv.autoPlaceInGrid(bigShield)
inv.autoPlaceInGrid(ring1)
inv.autoPlaceInGrid(ring2)
inv.autoPlaceInGrid(ringOverflow)
expect(inv.gridItems).toHaveLength(4)
const initialStashCount = panels.stashItems.length
// 1. Attempt to quick-transfer 2x2 shield: MUST be rejected!
const shieldTransfer = panels.quickTransferToStash(bigShield, inv)
expect(shieldTransfer).toBe(false)
expect(inv.gridItems.some(p => p.item.id === 'big-shield')).toBe(true)
expect(panels.stashItems).toHaveLength(initialStashCount)
// 2. Transfer first 1x1 ring: MUST succeed
const ring1Transfer = panels.quickTransferToStash(ring1, inv)
expect(ring1Transfer).toBe(true)
expect(inv.gridItems.some(p => p.item.id === 'ring-fit-1')).toBe(false)
expect(panels.stashItems).toHaveLength(initialStashCount + 1)
// 3. Transfer second 1x1 ring: MUST succeed
const ring2Transfer = panels.quickTransferToStash(ring2, inv)
expect(ring2Transfer).toBe(true)
expect(inv.gridItems.some(p => p.item.id === 'ring-fit-2')).toBe(false)
expect(panels.stashItems).toHaveLength(initialStashCount + 2)
// 4. Stash is now 100% full (48/48 cells).
// Attempt to transfer third 1x1 ring: MUST fail!
const overflowTransfer = panels.quickTransferToStash(ringOverflow, inv)
expect(overflowTransfer).toBe(false)
expect(inv.gridItems.some(p => p.item.id === 'ring-overflow')).toBe(true)
expect(panels.stashItems).toHaveLength(initialStashCount + 2)
// Zero duplication, zero loss check
expect(inv.gridItems).toHaveLength(2) // bigShield and ringOverflow remain
expect(panels.stashItems.find(p => p.item.id === 'ring-fit-1')).toBeDefined()
expect(panels.stashItems.find(p => p.item.id === 'ring-fit-2')).toBeDefined()
})
it('executes Shift+Click and Right-Click quick transfer via handleLeftDockClick', () => {
const ring = createItem('shift-ring', 'rin', 1, 1, '快捷戒指')
panels.stashItems.push({ item: ring, col: 0, row: 0 })
// Calculate pixel coordinates for stash grid (0, 0)
const cellPx = STASH_GRID_ORIGIN.cellPx
const clickX = STASH_GRID_ORIGIN.x + cellPx / 2
const clickY = STASH_GRID_ORIGIN.y + cellPx / 2
// 1. Shift+Click transfers from stash to inventory
const shiftHandled = panels.handleLeftDockClick('stash', clickX, clickY, {
onClose: () => {},
onWaypointTeleport: () => {},
inventory: inv,
isShiftClick: true,
isRightClick: false,
})
expect(shiftHandled).toBe(true)
expect(panels.stashItems).toHaveLength(0)
expect(inv.gridItems).toHaveLength(1)
expect(inv.gridItems[0]!.item.id).toBe('shift-ring')
// Put it back in stash for Right-Click test
panels.quickTransferToStash(ring, inv)
expect(panels.stashItems).toHaveLength(1)
expect(inv.gridItems).toHaveLength(0)
// 2. Right-Click transfers from stash to inventory
const rightHandled = panels.handleLeftDockClick('stash', clickX, clickY, {
onClose: () => {},
onWaypointTeleport: () => {},
inventory: inv,
isShiftClick: false,
isRightClick: true,
})
expect(rightHandled).toBe(true)
expect(panels.stashItems).toHaveLength(0)
expect(inv.gridItems).toHaveLength(1)
expect(inv.gridItems[0]!.item.id).toBe('shift-ring')
// 3. Shift+Click on empty cell does nothing gracefully
const emptyClickHandled = panels.handleLeftDockClick('stash', clickX, clickY, {
onClose: () => {},
onWaypointTeleport: () => {},
inventory: inv,
isShiftClick: true,
isRightClick: false,
})
expect(emptyClickHandled).toBe(true)
expect(panels.stashItems).toHaveLength(0)
expect(inv.gridItems).toHaveLength(1)
})
it('performs bidirectional ping-pong transfer (15 items x 10 cycles) maintaining 100% invariance', () => {
const items: UiInventoryItem[] = []
// 5 1x1 + 5 1x2 + 5 2x2 = 5 + 10 + 20 = 35 cells (fits in 40-cell inventory)
for (let i = 0; i < 5; i++) {
const i1 = createItem(`ping-1x1-${i}`, 'rin', 1, 1, `戒指 ${i}`)
const i2 = createItem(`ping-1x2-${i}`, 'hp1', 1, 2, `药水 ${i}`)
const i3 = createItem(`ping-2x2-${i}`, 'cap', 2, 2, `头盔 ${i}`)
items.push(i1, i2, i3)
inv.autoPlaceInGrid(i1)
inv.autoPlaceInGrid(i2)
inv.autoPlaceInGrid(i3)
}
expect(items).toHaveLength(15)
expect(inv.gridItems).toHaveLength(15)
expect(panels.stashItems).toHaveLength(0)
const originalItemIds = items.map(it => it.id).sort()
// 10 Round-Trip Cycles
for (let cycle = 1; cycle <= 10; cycle++) {
// Transfer all from Inventory to Stash
for (const item of items) {
const success = panels.quickTransferToStash(item, inv)
expect(success, `Cycle ${cycle}: Quick transfer to stash failed for ${item.id}`).toBe(true)
}
expect(inv.gridItems).toHaveLength(0)
expect(panels.stashItems).toHaveLength(15)
const stashIds = panels.stashItems.map(p => p.item.id).sort()
expect(stashIds).toEqual(originalItemIds)
// Transfer all from Stash to Inventory
for (const item of items) {
const success = panels.quickTransferFromStash(item, inv)
expect(success, `Cycle ${cycle}: Quick transfer from stash failed for ${item.id}`).toBe(true)
}
expect(panels.stashItems).toHaveLength(0)
expect(inv.gridItems).toHaveLength(15)
const invIds = inv.gridItems.map(p => p.item.id).sort()
expect(invIds).toEqual(originalItemIds)
}
// Final verification: 0 items lost, 0 duplicates
expect(inv.gridItems).toHaveLength(15)
expect(panels.stashItems).toHaveLength(0)
})
})
})

View File

@ -0,0 +1,451 @@
/**
* Empirical Challenger Stress Test Suite for Milestone M6:
* Normal Attack Icon Resolution, Dual Slot Binding Permutations, and Hotkey Switching.
*
* Ground Truth: Diablo II v1.13c Skill System Parity (Issue #151)
*/
import { describe, expect, it, beforeEach } from 'vitest'
import { readFileSync, existsSync, statSync } from 'node:fs'
import { join } from 'node:path'
import {
DEFAULT_SORCERESS_SKILLS,
SkillHotkeysHud,
resolveSkillIcon,
getAvailableSkillsForSide,
LEFT_SKILL_BOUNDS,
RIGHT_SKILL_BOUNDS,
type HotkeySkillEntry,
} from '../src/ui/hotkeys.ts'
describe('Empirical Challenger Stress Suite: M6 Normal Attack & Dual Slot Bindings', () => {
const rootDir = process.cwd()
// =========================================================================
// 1. Icon Resolution & Asset Existence
// =========================================================================
describe('1. Icon Resolution & Disk Asset Integrity', () => {
it('verifies resolveSkillIcon(0) resolves to authentic Attack sword icon metadata', () => {
const mapping = resolveSkillIcon(0)
expect(mapping).toBeDefined()
expect(mapping.skillId).toBe(0)
expect(mapping.name).toBe('Attack')
expect(mapping.iconPath).toBe('/skills/icon_0.png')
expect(mapping.isGeneric).toBe(true)
expect(mapping.dc6Path).toBe('data/global/ui/SPELLS/Skillicon.DC6')
expect(mapping.dc6Source).toBe('data/global/ui/SPELLS/Skillicon.DC6')
expect(mapping.frameIndex).toBe(2)
expect(mapping.atlasRect).toEqual({
x: 96,
y: 0,
width: 48,
height: 48,
w: 48,
h: 48,
})
})
it('verifies resolveSkillIcon handles numeric variations consistently', () => {
const zeroPos = resolveSkillIcon(+0)
const zeroNeg = resolveSkillIcon(-0)
const zeroNum = resolveSkillIcon(Number(0))
expect(zeroPos).toEqual(zeroNeg)
expect(zeroPos).toEqual(zeroNum)
expect(zeroPos.skillId).toBe(0)
expect(zeroPos.name).toBe('Attack')
// Contrast with non-zero skills
const skill47 = resolveSkillIcon(47)
expect(skill47.skillId).toBe(47)
expect(skill47.isGeneric).toBe(false)
expect(skill47.iconPath).toBe('/skills/icon_47.png')
})
const targetPngs = [
{
path: join(rootDir, 'public', 'skills', 'icon_0.png'),
name: 'icon_0.png (skills)',
expectedWidth: 48,
expectedHeight: 48,
},
{
path: join(rootDir, 'public', 'skills', 'icon_0_active.png'),
name: 'icon_0_active.png (skills)',
expectedWidth: 48,
expectedHeight: 48,
},
{
path: join(rootDir, 'public', 'ui', 'icon_0.png'),
name: 'icon_0.png (ui)',
expectedWidth: 48,
expectedHeight: 48,
},
]
for (const file of targetPngs) {
it(`verifies ${file.name} exists on disk and is a valid 48x48 PNG with authentic headers`, () => {
expect(existsSync(file.path)).toBe(true)
const stat = statSync(file.path)
expect(stat.size).toBeGreaterThan(100) // valid png is larger than dummy stub
expect(stat.size).toBeLessThan(1024 * 1024) // sanity upper bound
const buf = readFileSync(file.path)
// 1. PNG 8-byte Magic Signature (\x89PNG\r\n\x1a\n)
const magic = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
for (let i = 0; i < 8; i++) {
expect(buf[i]).toBe(magic[i])
}
// 2. IHDR Chunk Header
const ihdrLength = buf.readUInt32BE(8)
expect(ihdrLength).toBe(13) // standard IHDR length
const ihdrType = buf.toString('ascii', 12, 16)
expect(ihdrType).toBe('IHDR')
// 3. Exact 48x48 Dimensions
const width = buf.readUInt32BE(16)
const height = buf.readUInt32BE(20)
expect(width).toBe(file.expectedWidth)
expect(height).toBe(file.expectedHeight)
// 4. Bit depth and valid color type
const bitDepth = buf[24]
expect([8, 16]).toContain(bitDepth)
const colorType = buf[25]
expect([0, 2, 3, 4, 6]).toContain(colorType)
// 5. IEND Chunk at file end
const endChunkType = buf.toString('ascii', buf.length - 8, buf.length - 4)
expect(endChunkType).toBe('IEND')
})
}
})
// =========================================================================
// 2. Slot Binding Permutations & Invariance
// =========================================================================
describe('2. Slot Binding Permutations & Invariance', () => {
let hud: SkillHotkeysHud
beforeEach(() => {
hud = new SkillHotkeysHud()
})
it('verifies getAvailableSkillsForSide includes Skill 0 (Attack) in both left and right palettes', () => {
const leftSkills = getAvailableSkillsForSide('left')
const rightSkills = getAvailableSkillsForSide('right')
const leftAttack = leftSkills.find((s) => s.skillId === 0)
const rightAttack = rightSkills.find((s) => s.skillId === 0)
expect(leftAttack).toBeDefined()
expect(rightAttack).toBeDefined()
expect(leftAttack!.name).toBe('Attack')
expect(leftAttack!.nameZh).toBe('普通攻击')
expect(leftAttack!.leftUsable).toBe(true)
expect(leftAttack!.rightUsable).toBe(true)
expect(rightAttack!.name).toBe('Attack')
expect(rightAttack!.nameZh).toBe('普通攻击')
expect(rightAttack!.leftUsable).toBe(true)
expect(rightAttack!.rightUsable).toBe(true)
})
it('assigns Attack to left slot and right slot simultaneously (Dual Attack)', () => {
hud.assignSkill('left', 0)
hud.assignSkill('right', 0)
expect(hud.leftSkillId).toBe(0)
expect(hud.rightSkillId).toBe(0)
// Test setDualSkill convenience method
hud.assignSkill('left', 47)
hud.assignSkill('right', 64)
expect(hud.leftSkillId).toBe(47)
expect(hud.rightSkillId).toBe(64)
hud.setDualSkill(0)
expect(hud.leftSkillId).toBe(0)
expect(hud.rightSkillId).toBe(0)
})
it('tests asymmetric permutations: Attack left / Spells right, and Spells left / Attack right', () => {
const availableLeft = hud.getAvailableSkillsForSide('left').map((s) => s.skillId)
const availableRight = hud.getAvailableSkillsForSide('right').map((s) => s.skillId)
// Permutation A: Attack on Left, cycle every available Right skill
hud.assignSkill('left', 0)
for (const rSkill of availableRight) {
hud.assignSkill('right', rSkill)
expect(hud.leftSkillId).toBe(0)
expect(hud.rightSkillId).toBe(rSkill)
}
// Permutation B: Attack on Right, cycle every available Left skill
hud.assignSkill('right', 0)
for (const lSkill of availableLeft) {
hud.assignSkill('left', lSkill)
expect(hud.leftSkillId).toBe(lSkill)
expect(hud.rightSkillId).toBe(0)
}
})
it('rigorously verifies slot state orthogonality across full Cartesian product of skills', () => {
const leftSkills = hud.getAvailableSkillsForSide('left').map((s) => s.skillId)
const rightSkills = hud.getAvailableSkillsForSide('right').map((s) => s.skillId)
for (const l of leftSkills) {
for (const r of rightSkills) {
hud.assignSkill('left', l)
expect(hud.leftSkillId).toBe(l)
hud.assignSkill('right', r)
// Crucial check: assigning to right slot MUST NOT mutate left slot
expect(hud.leftSkillId).toBe(l)
expect(hud.rightSkillId).toBe(r)
// Re-assign left slot: assigning left MUST NOT mutate right slot
hud.assignSkill('left', 0)
expect(hud.leftSkillId).toBe(0)
expect(hud.rightSkillId).toBe(r)
}
}
})
it('stress tests 1000 randomized slot mutations against an independent reference oracle', () => {
const leftCandidates = hud.getAvailableSkillsForSide('left').map((s) => s.skillId)
const rightCandidates = hud.getAvailableSkillsForSide('right').map((s) => s.skillId)
let oracleLeft = hud.leftSkillId
let oracleRight = hud.rightSkillId
// Pseudo-random deterministic LCG generator
let seed = 1337
function nextRand(): number {
seed = (seed * 1664525 + 1013904223) >>> 0
return seed / 4294967296
}
for (let i = 0; i < 1000; i++) {
const action = Math.floor(nextRand() * 3)
if (action === 0) {
// Mutate left
const idx = Math.floor(nextRand() * leftCandidates.length)
const chosen = leftCandidates[idx]
hud.assignSkill('left', chosen)
oracleLeft = chosen
} else if (action === 1) {
// Mutate right
const idx = Math.floor(nextRand() * rightCandidates.length)
const chosen = rightCandidates[idx]
hud.assignSkill('right', chosen)
oracleRight = chosen
} else {
// Dual assign (frequently attack = 0)
const chosen = nextRand() > 0.5 ? 0 : leftCandidates[Math.floor(nextRand() * leftCandidates.length)]
hud.setDualSkill(chosen)
oracleLeft = chosen
oracleRight = chosen
}
expect(hud.leftSkillId).toBe(oracleLeft)
expect(hud.rightSkillId).toBe(oracleRight)
}
})
})
// =========================================================================
// 3. Hotkey Assignment, Rapid Switching, & Speedbar Selection
// =========================================================================
describe('3. Hotkey Assignment & Rapid Switching Stress', () => {
let hud: SkillHotkeysHud
beforeEach(() => {
hud = new SkillHotkeysHud()
})
it('binds F1 through F8 to Attack (0) and verifies instant key activation', () => {
const keys = ['F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8']
for (const key of keys) {
// Set F-key to Attack for right slot
hud.setHotkey(key, 0, 'right')
expect(hud.getHotkey(key)).toBe(0)
// Set right skill to something else first
hud.assignSkill('right', 64)
expect(hud.rightSkillId).toBe(64)
// Press hotkey
const handled = hud.handleKeyDown(key)
expect(handled).toBe(true)
expect(hud.rightSkillId).toBe(0)
}
// Also test left slot binding
for (const key of keys) {
hud.bindHotkey(key, 'left', 0)
hud.assignSkill('left', 47)
expect(hud.leftSkillId).toBe(47)
const handled = hud.handleKeyDown(key)
expect(handled).toBe(true)
expect(hud.leftSkillId).toBe(0)
}
})
it('verifies slot state invariance during hotkey trigger', () => {
// Bind F1 to right Attack (0) and F2 to left Attack (0)
hud.bindHotkey('F1', 'right', 0)
hud.bindHotkey('F2', 'left', 0)
hud.assignSkill('left', 47)
hud.assignSkill('right', 64)
// Triggering F1 (right attack) must not alter left skill
hud.handleKeyDown('F1')
expect(hud.rightSkillId).toBe(0)
expect(hud.leftSkillId).toBe(47)
// Triggering F2 (left attack) must not alter right skill
hud.handleKeyDown('F2')
expect(hud.leftSkillId).toBe(0)
expect(hud.rightSkillId).toBe(0)
})
it('asserts leftUsable: true and rightUsable: true remain immutable on Skill 0', () => {
const attackDef = DEFAULT_SORCERESS_SKILLS.find((s) => s.skillId === 0)
expect(attackDef).toBeDefined()
expect(attackDef!.leftUsable).toBe(true)
expect(attackDef!.rightUsable).toBe(true)
// Perform a flurry of mutations, speedbar interactions, and bindings
for (let i = 0; i < 200; i++) {
hud.openSpeedbar('left')
hud.handleClick(LEFT_SKILL_BOUNDS.x, LEFT_SKILL_BOUNDS.y)
hud.openSpeedbar('right')
hud.handleClick(RIGHT_SKILL_BOUNDS.x, RIGHT_SKILL_BOUNDS.y)
hud.assignSkill('left', 0)
hud.assignSkill('right', 0)
hud.bindHotkey('F1', 'left', 0)
hud.handleKeyDown('F1')
}
// Check DEFAULT_SORCERESS_SKILLS
const attackDefAfter = DEFAULT_SORCERESS_SKILLS.find((s) => s.skillId === 0)
expect(attackDefAfter!.leftUsable).toBe(true)
expect(attackDefAfter!.rightUsable).toBe(true)
// Check hud.availableSkills
const hudAttack = hud.availableSkills.find((s) => s.skillId === 0)
expect(hudAttack!.leftUsable).toBe(true)
expect(hudAttack!.rightUsable).toBe(true)
})
it('stress tests rapid hotkey switching with 5000 key events simulating hyper APM', () => {
// Configure F1..F8 layout
const config: Array<{ key: string; side: 'left' | 'right'; skillId: number }> = [
{ key: 'F1', side: 'right', skillId: 0 }, // Attack
{ key: 'F2', side: 'right', skillId: 64 }, // Frozen Orb
{ key: 'F3', side: 'right', skillId: 59 }, // Blizzard
{ key: 'F4', side: 'right', skillId: 54 }, // Teleport
{ key: 'F5', side: 'left', skillId: 0 }, // Attack
{ key: 'F6', side: 'left', skillId: 47 }, // Fire Ball
{ key: 'F7', side: 'left', skillId: 36 }, // Fire Bolt
{ key: 'F8', side: 'right', skillId: 40 }, // Frozen Armor
]
for (const item of config) {
hud.bindHotkey(item.key, item.side, item.skillId)
}
let expectedLeft = hud.leftSkillId
let expectedRight = hud.rightSkillId
let seed = 424242
function randInt(max: number): number {
seed = (seed * 1103515245 + 12345) & 0x7fffffff
return seed % max
}
for (let step = 0; step < 5000; step++) {
const choice = config[randInt(config.length)]
const res = hud.triggerFunctionKey(choice.key)
expect(res).not.toBeNull()
expect(res!.skillId).toBe(choice.skillId)
expect(res!.side).toBe(choice.side)
if (choice.side === 'left') {
expectedLeft = choice.skillId
} else {
expectedRight = choice.skillId
}
expect(hud.leftSkillId).toBe(expectedLeft)
expect(hud.rightSkillId).toBe(expectedRight)
}
})
it('stress tests speedbar popup selection for Normal Attack', () => {
// 1. Left Speedbar
hud.openSpeedbar('left')
expect(hud.selectionOpen).toBe('left')
const leftGrid = hud.getSpeedbarSkills('left')
const leftAttack = leftGrid.find((cell) => cell.skill.skillId === 0)
expect(leftAttack).toBeDefined()
// Click within Attack cell bounds
const leftClicked = hud.handleClick(leftAttack!.x + 16, leftAttack!.y + 16)
expect(leftClicked).toBe(true)
expect(hud.leftSkillId).toBe(0)
expect(hud.selectionOpen).toBeNull() // closed automatically
// 2. Right Speedbar
hud.openSpeedbar('right')
expect(hud.selectionOpen).toBe('right')
const rightGrid = hud.getSpeedbarSkills('right')
const rightAttack = rightGrid.find((cell) => cell.skill.skillId === 0)
expect(rightAttack).toBeDefined()
// Click within Attack cell bounds
const rightClicked = hud.handleClick(rightAttack!.x + 16, rightAttack!.y + 16)
expect(rightClicked).toBe(true)
expect(hud.rightSkillId).toBe(0)
expect(hud.selectionOpen).toBeNull()
})
it('tests speedbar interactive hover and in-palette hotkey rebinding', () => {
hud.openSpeedbar('right')
const rightGrid = hud.getSpeedbarSkills('right')
const attackCell = rightGrid.find((cell) => cell.skill.skillId === 0)!
// Move mouse over Attack in speedbar
hud.handleMouseMove(attackCell.x + 10, attackCell.y + 10)
expect(hud.hoverSpeedbarSkillId).toBe(0)
expect(hud.hoveredPopupSkill).toEqual({ side: 'right', skillId: 0 })
// Press F5 while hovering -> rebinds F5 to Attack on right
const boundRes = hud.triggerFunctionKey('F5')
expect(boundRes).toEqual({ side: 'right', skillId: 0 })
expect(hud.getHotkey('F5')).toBe(0)
// Close speedbar
hud.handleClick(10, 10)
expect(hud.selectionOpen).toBeNull()
// Reset right skill to Blizzard (59)
hud.assignSkill('right', 59)
expect(hud.rightSkillId).toBe(59)
// Trigger F5
hud.handleKeyDown('F5')
expect(hud.rightSkillId).toBe(0)
})
})
})

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,488 @@
/**
* Diablo II v1.13c Skill Formula & Preview Adversarial Stress Test Suite.
*
* Authored by: teamwork_preview_challenger
* Milestone: M7 (Issue #152 — 技能 Tooltip 未对齐原版)
*
* Adversarially challenges:
* 1. Attack Rating (ToHit) bonus scaling: Jab, Sacrifice, Zeal (1.13c ground truth vs worker claims).
* 2. Duration scaling: Shout (defense vs duration disambiguation), Battle Orders (base vs synergized duration),
* and 3-band duration progression (piecewise scaling across bands 1-8, 9-16, 17+).
* 3. Next-Level Preview Invariant: unlearned (null next), intermediate (eff + 1), and capped at 20 (null next).
* 4. 1.13c Marrowwalk Fix Synergy Isolation: hard points vs soft points (+skills / charges).
* 5. Differential fuzzing & property validation across 1000 generated inputs.
*/
import { describe, it, expect } from 'vitest'
import {
createInitialState,
getEffectiveLevel,
allocatePoint,
evaluateSynergies,
evaluateMasteries,
calculateManaCost,
calculateDetailedDamage,
buildTooltipViewModel,
evaluateSkillEffects,
evalSkillDescFormula,
type SkillCalcState,
} from '../src/game/skill-calc-engine.ts'
import { compute3BandDuration } from '../src/game/engine/calc-ast.ts'
import { SKILLS_CATALOG, SKILLS_BY_ID } from '../src/data/skills-catalog.ts'
import { SkillTreePanel } from '../src/ui/skill-tree-panel.ts'
describe('Challenger M7: 1.13c Skill Formulas, Scaling & Tooltip Stress Tests', () => {
// ==========================================================================
// 1. Attack Rating Bonus Scaling
// ==========================================================================
describe('1. Attack Rating (ToHit) Bonus Scaling', () => {
it('verifies Jab (id 10) scaling: level 1 (+10%), level 10 (+91%), level 20 (+181%)', () => {
const jab = SKILLS_BY_ID[10]!
expect(jab).toBeDefined()
expect(jab.toHit).toBe(10)
expect(jab.toHitLev).toBe(9)
// Level 1: 10 + (1 - 1) * 9 = 10%
const state1 = createInitialState('ama')
state1.hardPoints[10] = 1
const vm1 = buildTooltipViewModel(state1, 10, 99)
const ar1 = vm1?.current.effects.find(e => e.textZh.includes('準確率') || e.textZh.includes('准确率'))
expect(ar1).toBeDefined()
expect(ar1?.value).toBe(10)
expect(ar1?.textZh).toBe('準確率:+10%')
// Level 10: 10 + (10 - 1) * 9 = 91%
const state10 = createInitialState('ama')
state10.hardPoints[10] = 10
const vm10 = buildTooltipViewModel(state10, 10, 99)
const ar10 = vm10?.current.effects.find(e => e.textZh.includes('準確率') || e.textZh.includes('准确率'))
expect(ar10).toBeDefined()
expect(ar10?.value).toBe(91)
expect(ar10?.textZh).toBe('準確率:+91%')
// Level 20: 10 + (20 - 1) * 9 = 181%
const state20 = createInitialState('ama')
state20.hardPoints[10] = 20
const vm20 = buildTooltipViewModel(state20, 10, 99)
const ar20 = vm20?.current.effects.find(e => e.textZh.includes('準確率') || e.textZh.includes('准确率'))
expect(ar20).toBeDefined()
expect(ar20?.value).toBe(181)
expect(ar20?.textZh).toBe('準確率:+181%')
})
it('verifies Sacrifice (id 96) scaling: level 1 (+20%), level 10 (+83%), level 20 (+153%)', () => {
const sacrifice = SKILLS_BY_ID[96]!
expect(sacrifice).toBeDefined()
expect(sacrifice.toHit).toBe(20)
expect(sacrifice.toHitLev).toBe(7)
// Level 1: 20 + (1 - 1) * 7 = 20%
const state1 = createInitialState('pal')
state1.hardPoints[96] = 1
const vm1 = buildTooltipViewModel(state1, 96, 99)
const ar1 = vm1?.current.effects.find(e => e.textZh.includes('準確率'))
expect(ar1).toBeDefined()
expect(ar1?.value).toBe(20)
expect(ar1?.textZh).toBe('變動準確率:+20%')
// Level 10: 20 + (10 - 1) * 7 = 83%
const state10 = createInitialState('pal')
state10.hardPoints[96] = 10
const vm10 = buildTooltipViewModel(state10, 96, 99)
const ar10 = vm10?.current.effects.find(e => e.textZh.includes('準確率'))
expect(ar10).toBeDefined()
expect(ar10?.value).toBe(83)
expect(ar10?.textZh).toBe('變動準確率:+83%')
// Level 20: 20 + (20 - 1) * 7 = 153%
const state20 = createInitialState('pal')
state20.hardPoints[96] = 20
const vm20 = buildTooltipViewModel(state20, 96, 99)
const ar20 = vm20?.current.effects.find(e => e.textZh.includes('準確率'))
expect(ar20).toBeDefined()
expect(ar20?.value).toBe(153)
expect(ar20?.textZh).toBe('變動準確率:+153%')
})
it('adversarially challenges Zeal (id 106): tests 1.13c MPQ ground truth (lvl 1=+10%, lvl 20=+200%, lvl 21=+210%) vs worker handoff claim (lvl 1=0%, lvl 21=+200%)', () => {
const zeal = SKILLS_BY_ID[106]!
expect(zeal).toBeDefined()
// Authentic 1.13c Skills.txt row 106 ground truth: ToHit = 10, LevToHit = 10
expect(zeal.toHit).toBe(10)
expect(zeal.toHitLev).toBe(10)
// Empirical Level 1: 10 + (1 - 1) * 10 = +10%
const state1 = createInitialState('pal')
state1.hardPoints[106] = 1
const vm1 = buildTooltipViewModel(state1, 106, 99)
const ar1 = vm1?.current.effects.find(e => e.textZh.includes('攻擊加成') || e.textZh.includes('準確率'))
expect(ar1).toBeDefined()
expect(ar1?.value).toBe(10)
expect(ar1?.textZh).toBe('攻擊加成:+10%')
// Empirical Level 20: 10 + (20 - 1) * 10 = +200%
const state20 = createInitialState('pal')
state20.hardPoints[106] = 20
const vm20 = buildTooltipViewModel(state20, 106, 99)
const ar20 = vm20?.current.effects.find(e => e.textZh.includes('攻擊加成') || e.textZh.includes('準確率'))
expect(ar20).toBeDefined()
expect(ar20?.value).toBe(200)
expect(ar20?.textZh).toBe('攻擊加成:+200%')
// Empirical Level 21: 10 + (21 - 1) * 10 = +210%
const state21 = createInitialState('pal')
state21.hardPoints[106] = 20
state21.allSkillsSoftPoints = 1 // 20 hard + 1 soft = eff 21
const vm21 = buildTooltipViewModel(state21, 106, 99)
const ar21 = vm21?.current.effects.find(e => e.textZh.includes('攻擊加成') || e.textZh.includes('準確率'))
expect(ar21).toBeDefined()
expect(ar21?.value).toBe(210)
expect(ar21?.textZh).toBe('攻擊加成:+210%')
// Note on Challenger Finding:
// Worker M7's handoff text stated: "Zeal (id 106) with lineType 2 StrSkill30 starts at 0 and adds +10%/lvl, giving +200% at level 21."
// That was a documentation typo in Worker M7's report. The actual code extracted from 1.13c MPQ has ToHit=10,
// which yields +10% at lvl 1, +200% at lvl 20, and +210% at lvl 21.
expect(ar1?.value).not.toBe(0)
expect(ar21?.value).not.toBe(200)
})
})
// ==========================================================================
// 2. Duration Scaling
// ==========================================================================
describe('2. Duration Scaling & Formula Disambiguation', () => {
it('verifies Shout (id 138) duration and defense are NOT conflated: level 1 dur=20s def=+100%, level 20 dur=210s def=+290%', () => {
const shout = SKILLS_BY_ID[138]!
expect(shout).toBeDefined()
// Param1/Param2 (ln12) = Defense bonus: 100 + (lvl - 1) * 10
// Param3/Param4 (ln34) = Duration frames: 500 + (lvl - 1) * 250
expect(shout.params.par1).toBe(100)
expect(shout.params.par2).toBe(10)
expect(shout.params.par3).toBe(500)
expect(shout.params.par4).toBe(250)
// Level 1
const state1 = createInitialState('bar')
state1.hardPoints[138] = 1
const vm1 = buildTooltipViewModel(state1, 138, 99)
const dur1 = vm1?.current.effects.find(e => e.lineType === 12)
const def1 = vm1?.current.effects.find(e => e.textZh.includes('防禦'))
expect(dur1).toBeDefined()
expect(dur1?.value).toBe(500) // 500 frames
expect(dur1?.textZh).toBe('持續:20 秒') // 500 / 25 = 20s
expect(def1).toBeDefined()
expect(def1?.value).toBe(100)
expect(def1?.textZh).toBe('防禦:+100%')
// Level 20
const state20 = createInitialState('bar')
state20.hardPoints[138] = 20
const vm20 = buildTooltipViewModel(state20, 138, 99)
const dur20 = vm20?.current.effects.find(e => e.lineType === 12)
const def20 = vm20?.current.effects.find(e => e.textZh.includes('防禦'))
expect(dur20).toBeDefined()
// 500 + 19 * 250 = 5250 frames
expect(dur20?.value).toBe(5250)
expect(dur20?.textZh).toBe('持續:210 秒') // 5250 / 25 = 210s
expect(def20).toBeDefined()
// 100 + 19 * 10 = 290%
expect(def20?.value).toBe(290)
expect(def20?.textZh).toBe('防禦:+290%')
})
it('verifies Battle Orders (id 149) duration scaling: level 1 = 30s, level 20 base = 220s, level 20 with Shout synergy = 240s', () => {
const bo = SKILLS_BY_ID[149]!
expect(bo).toBeDefined()
// Param1 = 750 frames (30s), Param2 = 250 frames (10s), Param8 = 125 frames (5s) per synergy point
expect(bo.params.par1).toBe(750)
expect(bo.params.par2).toBe(250)
expect(bo.params.par8).toBe(125)
// Level 1: 750 frames = 30 seconds
const state1 = createInitialState('bar')
state1.hardPoints[149] = 1
const vm1 = buildTooltipViewModel(state1, 149, 99)
const dur1 = vm1?.current.effects.find(e => e.lineType === 12)
expect(dur1).toBeDefined()
expect(dur1?.value).toBe(750)
expect(dur1?.textZh).toBe('持續:30 秒')
// Level 20 (base without synergy): 750 + 19 * 250 = 5500 frames = 220 seconds
const state20 = createInitialState('bar')
state20.hardPoints[149] = 20
const vm20 = buildTooltipViewModel(state20, 149, 99)
const dur20 = vm20?.current.effects.find(e => e.lineType === 12)
expect(dur20).toBeDefined()
expect(dur20?.value).toBe(5500)
expect(dur20?.textZh).toBe('持續:220 秒')
// Level 20 with 4 points in Shout synergy (4 * 125 = 500 frames = 20s): 5500 + 500 = 6000 frames = 240s!
const state20Syn = createInitialState('bar')
state20Syn.hardPoints[149] = 20
state20Syn.hardPoints[138] = 4 // 4 points in Shout
const vm20Syn = buildTooltipViewModel(state20Syn, 149, 99)
const dur20Syn = vm20Syn?.current.effects.find(e => e.lineType === 12)
expect(dur20Syn).toBeDefined()
expect(dur20Syn?.value).toBe(6000)
expect(dur20Syn?.textZh).toBe('持續:240 秒')
// Level 22 (base without synergy): 750 + 21 * 250 = 6000 frames = 240 seconds
const state22 = createInitialState('bar')
state22.hardPoints[149] = 20
state22.allSkillsSoftPoints = 2
const vm22 = buildTooltipViewModel(state22, 149, 99)
const dur22 = vm22?.current.effects.find(e => e.lineType === 12)
expect(dur22?.value).toBe(6000)
expect(dur22?.textZh).toBe('持續:240 秒')
})
it('verifies 3-band duration progression across levels 1-8, 9-16, and 17+ on authentic elemental skills', () => {
// 1. Shiver Armor (id 50): eLen = 100, eLevLen = [0, 25, 50]
// Band 1 (slvl 1-8): rate +0
expect(compute3BandDuration(1, 100, 0, 25, 50)).toBe(100)
expect(compute3BandDuration(8, 100, 0, 25, 50)).toBe(100)
// Band 2 (slvl 9-16): rate +25
expect(compute3BandDuration(9, 100, 0, 25, 50)).toBe(125)
expect(compute3BandDuration(16, 100, 0, 25, 50)).toBe(300)
// Band 3 (slvl 17+): rate +50
expect(compute3BandDuration(17, 100, 0, 25, 50)).toBe(350)
expect(compute3BandDuration(20, 100, 0, 25, 50)).toBe(500)
expect(compute3BandDuration(30, 100, 0, 25, 50)).toBe(1000)
// 2. Stun (id 139): eLen = 30, eLevLen = [5, 5, 2]
// Band 1 (slvl 1-8): rate +5
expect(compute3BandDuration(1, 30, 5, 5, 2)).toBe(30)
expect(compute3BandDuration(8, 30, 5, 5, 2)).toBe(30 + 7 * 5) // 65
// Band 2 (slvl 9-16): rate +5
expect(compute3BandDuration(9, 30, 5, 5, 2)).toBe(65 + 1 * 5) // 70
expect(compute3BandDuration(16, 30, 5, 5, 2)).toBe(65 + 8 * 5) // 105
// Band 3 (slvl 17+): rate +2
expect(compute3BandDuration(17, 30, 5, 5, 2)).toBe(105 + 1 * 2) // 107
expect(compute3BandDuration(20, 30, 5, 5, 2)).toBe(105 + 4 * 2) // 113
// 3. Poison Javelin (id 15): eLen = 200, eLevLen = [50, 50, 50]
expect(compute3BandDuration(1, 200, 50, 50, 50)).toBe(200)
expect(compute3BandDuration(8, 200, 50, 50, 50)).toBe(550)
expect(compute3BandDuration(9, 200, 50, 50, 50)).toBe(600)
expect(compute3BandDuration(16, 200, 50, 50, 50)).toBe(950)
expect(compute3BandDuration(17, 200, 50, 50, 50)).toBe(1000)
expect(compute3BandDuration(20, 200, 50, 50, 50)).toBe(1150)
})
})
// ==========================================================================
// 3. Next-Level Preview Invariant
// ==========================================================================
describe('3. Next-Level Preview Invariant', () => {
it('enforces slvl === 0 invariant: nextLevel === null, isLearned === false, previewing level 1 in current stats', () => {
const testSkills = [
{ code: 'sor', id: 47 }, // Fire Ball
{ code: 'ama', id: 10 }, // Jab
{ code: 'pal', id: 106 }, // Zeal
{ code: 'bar', id: 149 }, // Battle Orders
{ code: 'nec', id: 84 }, // Bone Spear
{ code: 'dru', id: 245 }, // Tornado
{ code: 'ass', id: 271 }, // Lightning Sentry
] as const
for (const { code, id } of testSkills) {
const state = createInitialState(code)
const vm = buildTooltipViewModel(state, id, 99)
expect(vm, `Skill ${id} VM must not be null`).not.toBeNull()
expect(vm!.isLearned).toBe(false)
expect(vm!.effectiveLevel).toBe(0)
expect(vm!.hardPoints).toBe(0)
expect(vm!.current.level).toBe(1)
expect(vm!.nextLevel).toBeNull()
}
})
it('enforces 1 <= hardPoints < 20 invariant: nextLevel !== null, level === effectiveLevel + 1', () => {
const state = createInitialState('sor')
state.hardPoints[36] = 1 // Fire Bolt req
state.hardPoints[47] = 5 // Fire Ball 5 hard points
state.allSkillsSoftPoints = 3 // +3 soft skills -> eff 8
const vm = buildTooltipViewModel(state, 47, 99)
expect(vm).not.toBeNull()
expect(vm!.isLearned).toBe(true)
expect(vm!.hardPoints).toBe(5)
expect(vm!.effectiveLevel).toBe(8)
expect(vm!.current.level).toBe(8)
expect(vm!.nextLevel).not.toBeNull()
expect(vm!.nextLevel!.level).toBe(9)
expect(vm!.nextLevel!.damage.finalMin).toBeGreaterThan(vm!.current.damage.finalMin)
})
it('enforces hardPoints === 20 invariant: nextLevel === null regardless of soft points', () => {
const state = createInitialState('sor')
state.hardPoints[36] = 1
state.hardPoints[47] = 20 // Max base points
// Check with 0 soft points
let vm = buildTooltipViewModel(state, 47, 99)
expect(vm).not.toBeNull()
expect(vm!.isLearned).toBe(true)
expect(vm!.hardPoints).toBe(20)
expect(vm!.effectiveLevel).toBe(20)
expect(vm!.nextLevel).toBeNull()
// Check with +15 soft points
state.allSkillsSoftPoints = 15
vm = buildTooltipViewModel(state, 47, 99)
expect(vm!.hardPoints).toBe(20)
expect(vm!.effectiveLevel).toBe(35)
expect(vm!.current.level).toBe(35)
expect(vm!.nextLevel).toBeNull()
})
})
// ==========================================================================
// 4. 1.13c Marrowwalk Fix Synergy Isolation
// ==========================================================================
describe('4. 1.13c Marrowwalk Fix Synergy Isolation', () => {
it('verifies Fire Ball (id 47) receives exactly +140% from 10 hard points in Fire Bolt, and soft points do NOT increase multiplier', () => {
const state = createInitialState('sor')
const fb = SKILLS_BY_ID[47]!
// 0 hard points -> 0% synergy
let syn = evaluateSynergies(state, fb)
expect(syn.totalPercent).toBe(0)
expect(syn.multiplier).toBe(1.0)
// Allocate 10 hard points to Fire Bolt (id 36) -> 10 * 14% = 140%
allocatePoint(state, 36, 10)
syn = evaluateSynergies(state, fb)
expect(syn.totalPercent).toBe(140)
expect(syn.multiplier).toBe(2.4)
// Add soft points: +10 all skills, +10 specific soft points, +10 tab soft points
state.allSkillsSoftPoints = 10
state.specificSoftPoints[36] = 10
state.tabSoftPoints[0] = 10
// Effective level of Fire Bolt is now 10 + 10 + 10 + 10 = 40!
expect(getEffectiveLevel(state, 36)).toBe(40)
// BUT synergy multiplier MUST remain strictly +140% (multiplier 2.4)
syn = evaluateSynergies(state, fb)
expect(syn.totalPercent).toBe(140)
expect(syn.multiplier).toBe(2.4)
// Verify via buildTooltipViewModel
allocatePoint(state, 47, 1)
const vm = buildTooltipViewModel(state, 47, 99)
const synInfo = vm?.synergiesReceived.find(s => s.providerId === 36)
expect(synInfo).toBeDefined()
expect(synInfo?.hardPoints).toBe(10)
expect(synInfo?.currentBonusPct).toBe(140)
})
it('verifies Bone Spear (id 84) synergies are isolated from item charges and soft skill points', () => {
const state = createInitialState('nec')
const bs = SKILLS_BY_ID[84]!
// Teeth (67), Bone Wall (78), Bone Prison (88), Bone Spirit (93)
// 7% magic damage per level
allocatePoint(state, 67, 20) // Teeth 20
allocatePoint(state, 68, 1) // Bone Armor prerequisite for Bone Wall
allocatePoint(state, 78, 20) // Bone Wall 20
let syn = evaluateSynergies(state, bs)
// 20 * 7% + 20 * 7% = 140% + 140% = 280%
expect(syn.totalPercent).toBe(280)
expect(syn.multiplier).toBe(3.8)
// Add Marrowwalk pseudo-charges (+33 Bone Prison charges via soft points)
state.specificSoftPoints[88] = 33 // Bone Prison is id 88
state.allSkillsSoftPoints = 10
syn = evaluateSynergies(state, bs)
// Synergy MUST remain exactly 280%, ignoring soft points on Bone Prison
expect(syn.totalPercent).toBe(280)
expect(syn.multiplier).toBe(3.8)
})
})
// ==========================================================================
// 5. Differential Oracles & Property Fuzzing
// ==========================================================================
describe('5. Differential Oracles & Property Fuzzing', () => {
it('fuzzes 1000 random inputs for 3-band duration against independent mathematical oracle', () => {
// Independent naive oracle
function oracle3Band(lvl: number, eLen: number, l1: number, l2: number, l3: number): number {
if (lvl <= 0 || (eLen === 0 && l1 === 0 && l2 === 0 && l3 === 0)) return 0
let res = eLen
for (let l = 2; l <= lvl; l++) {
if (l <= 8) res += l1
else if (l <= 16) res += l2
else res += l3
}
return res
}
// Pseudo-random LCG generator for reproducible fuzzing
let seed = 1337
function lcg(): number {
seed = (seed * 1664525 + 1013904223) % 4294967296
return seed / 4294967296
}
for (let i = 0; i < 1000; i++) {
const lvl = Math.floor(lcg() * 60) + 1 // 1..60
const eLen = Math.floor(lcg() * 200) // 0..199
const l1 = Math.floor(lcg() * 50)
const l2 = Math.floor(lcg() * 50)
const l3 = Math.floor(lcg() * 50)
const expected = oracle3Band(lvl, eLen, l1, l2, l3)
const actual = compute3BandDuration(lvl, eLen, l1, l2, l3)
expect(actual, `Mismatch at lvl=${lvl}, eLen=${eLen}, l1=${l1}, l2=${l2}, l3=${l3}`).toBe(expected)
}
})
it('validates mana cost floor constraint: calculateManaCost >= minmana across all catalog skills', () => {
for (const skill of SKILLS_CATALOG) {
for (let lvl = 1; lvl <= 30; lvl++) {
const cost = calculateManaCost(skill, lvl)
const minmana = skill.minmana ?? 0
if (skill.mana > 0 || skill.lvlmana > 0) {
expect(cost, `Skill ${skill.id} at lvl ${lvl} cost ${cost} < minmana ${minmana}`).toBeGreaterThanOrEqual(minmana)
}
}
}
})
it('verifies tooltip placement bounding box invariants: always inside canvas [8, 800] x [8, 552]', () => {
const mockFont = {
measureText: (str: string) => str.length * 7,
} as any
const dummyPositions = [
{ x: 30, y: 30, w: 48, h: 48 },
{ x: 400, y: 300, w: 48, h: 48 },
{ x: 750, y: 500, w: 48, h: 48 },
{ x: 10, y: 550, w: 48, h: 48 },
]
for (const pos of dummyPositions) {
// Test varying line counts from 5 to 25
for (let lineCount = 5; lineCount <= 25; lineCount++) {
const lines = Array.from({ length: lineCount }, (_, i) => ({
text: `Line ${i + 1}: Sample Tooltip Text Description`,
color: 'white' as const,
font: 'font8' as const,
}))
const placement = SkillTreePanel.computeTooltipPlacement(pos, lines, mockFont)
expect(placement.w).toBeGreaterThanOrEqual(240)
expect(placement.h).toBe(lineCount * 17 + 24)
expect(placement.x).toBeGreaterThanOrEqual(8)
expect(placement.x + placement.w).toBeLessThanOrEqual(800)
expect(placement.y).toBeGreaterThanOrEqual(8)
expect(placement.y + placement.h).toBeLessThanOrEqual(552)
}
}
})
})
})

View File

@ -0,0 +1,544 @@
/**
* Empirical Stress Test Suite for Milestone M7 (Issue #152)
*
* Adversarially tests:
* 1. SkillTreePanel tooltip placement and canvas boundary clamping across all 18 skill grid nodes (rows 1..6, cols 1..3).
* 2. Multi-line formatting, authentic color coding, text measurement, and hierarchy.
* 3. Non-damaging skills (Warmth, Teleport) suppressing undefined/blank damage lines.
* 4. Maxed skill point allocation (20/20) omitting next-level preview and showing gold max notice.
* 5. Unlearned skill requirement warnings (red highlighting for player level < reqlevel and missing prereqs).
*/
import { describe, expect, it } from 'vitest'
import {
SkillTreePanel,
SORCERESS_SKILL_TREE,
SKILL_PANEL_ORIGIN,
SKILL_TREE_COL_OFFSETS_X,
SKILL_TREE_ROW_OFFSETS_Y,
type FormattedTooltipLine,
type SkillNodeDef,
} from '../src/ui/skill-tree-panel.ts'
import {
buildTooltipViewModel,
createInitialState,
allocatePoint,
} from '../src/game/skill-calc-engine.ts'
import { D2FontRenderer, type D2ColorCode } from '../src/ui/font.ts'
const VALID_D2_COLOR_CODES: readonly D2ColorCode[] = [
'gold',
'white',
'blue',
'green',
'red',
'tan',
'gray',
]
describe('Milestone M7 Challenger Stress Tests: Tooltip Clamping & Line Formatting (Issue #152)', () => {
const mockFont = {
measureText: (txt: string, font?: string) => {
// Exocet is wider than standard font8
const charW = font === 'fontexocet10' ? 11 : 8
return txt.length * charW
},
} as unknown as D2FontRenderer
// Helper to construct synthetic grid node at row (1..6) and col (1..3)
function makeGridNode(row: 1 | 2 | 3 | 4 | 5 | 6, col: 1 | 2 | 3): SkillNodeDef {
return {
skillId: 9000 + row * 10 + col,
tab: 0,
row,
col,
reqLevel: row === 1 ? 1 : (row - 1) * 6,
prereqs: [],
nameEn: `Test Skill R${row}C${col}`,
nameZh: `测试技能R${row}C${col}`,
descZh: `测试描述行`,
baseMana: 10,
baseDmgMin: 10,
baseDmgMax: 20,
dmgPerLevel: 5,
}
}
describe('1. Placement & Boundary Clamping across All 18 Skill Grid Nodes', () => {
const gridRows: readonly (1 | 2 | 3 | 4 | 5 | 6)[] = [1, 2, 3, 4, 5, 6]
const gridCols: readonly (1 | 2 | 3)[] = [1, 2, 3]
it('enforces authentic grid origin and exact spacing per D2Client.dll', () => {
expect(SKILL_PANEL_ORIGIN).toEqual({ x: 400, y: 60, width: 320, height: 432 })
// Verify exact screen coordinates for all 18 grid combinations
for (const row of gridRows) {
for (const col of gridCols) {
const node = makeGridNode(row, col)
const pos = SkillTreePanel.nodeScreenPos(node)
const expectedX = 400 + SKILL_TREE_COL_OFFSETS_X[col - 1]!
const expectedY = 60 + SKILL_TREE_ROW_OFFSETS_Y[row - 1]!
expect(pos.x).toBe(expectedX)
expect(pos.y).toBe(expectedY)
expect(pos.w).toBe(48)
expect(pos.h).toBe(48)
}
}
})
it('clamps tooltip within canvas viewport [8, 800 - 8] and strictly above control bar (y <= 552) across all 18 nodes with real skill lines', () => {
// Prepare a realistic, rich skill tooltip (Fire Ball with synergies and next level)
const state = createInitialState('sor')
allocatePoint(state, 36, 5) // Fire Bolt (synergy)
allocatePoint(state, 47, 10) // Fire Ball (main)
allocatePoint(state, 56, 1) // Meteor (synergy)
const vm = buildTooltipViewModel(state, 47, 85)
expect(vm).not.toBeNull()
const realLines = SkillTreePanel.formatSkillTooltipLines(vm!)
expect(realLines.length).toBeGreaterThanOrEqual(10)
let testedNodeCount = 0
for (const row of gridRows) {
for (const col of gridCols) {
testedNodeCount++
const node = makeGridNode(row, col)
const nodePos = SkillTreePanel.nodeScreenPos(node)
const placement = SkillTreePanel.computeTooltipPlacement(nodePos, realLines, mockFont)
// 1. Box width invariants
expect(placement.w).toBeGreaterThanOrEqual(240)
expect(placement.w).toBeLessThanOrEqual(800 - 16) // <= 784
// 2. Horizontal clamping: tx >= 8 and tx + boxW <= 800 - 8 (792)
expect(placement.x).toBeGreaterThanOrEqual(8)
expect(placement.x + placement.w).toBeLessThanOrEqual(800 - 8)
// 3. Vertical clamping: ty >= 8 and ty + boxH <= 552 (strictly above control bar)
expect(placement.y).toBeGreaterThanOrEqual(8)
expect(placement.y + placement.h).toBeLessThanOrEqual(552)
// 4. Box height matches exact line formula
expect(placement.h).toBe(realLines.length * 17 + 24)
}
}
expect(testedNodeCount).toBe(18)
})
it('guarantees clamping for short tooltips (min-width 240px clamp) across all 18 nodes', () => {
const shortLines: FormattedTooltipLine[] = [
{ text: '火弹', color: 'gold', font: 'fontexocet10', align: 'center' },
{ text: '当前等级: 1', color: 'white', font: 'font8', align: 'center' },
]
for (const row of gridRows) {
for (const col of gridCols) {
const node = makeGridNode(row, col)
const nodePos = SkillTreePanel.nodeScreenPos(node)
const placement = SkillTreePanel.computeTooltipPlacement(nodePos, shortLines, mockFont)
expect(placement.w).toBe(240)
expect(placement.h).toBe(2 * 17 + 24) // 58px
expect(placement.x).toBeGreaterThanOrEqual(8)
expect(placement.x + placement.w).toBeLessThanOrEqual(792)
expect(placement.y).toBeGreaterThanOrEqual(8)
expect(placement.y + placement.h).toBeLessThanOrEqual(552)
}
}
})
it('guarantees bottom boundary containment for high-line-count tooltips (up to 25 lines) on row 6', () => {
// 25 lines generates boxH = 25 * 17 + 24 = 449px
const tallLines: FormattedTooltipLine[] = Array.from({ length: 25 }, (_, i) => ({
text: `测试详尽技能描述行 ${i + 1}: 增加各项抗性与效果`,
color: 'white' as const,
font: 'font8' as const,
align: 'center' as const,
}))
for (const col of gridCols) {
const node = makeGridNode(6, col)
const nodePos = SkillTreePanel.nodeScreenPos(node)
const placement = SkillTreePanel.computeTooltipPlacement(nodePos, tallLines, mockFont)
expect(placement.h).toBe(25 * 17 + 24)
expect(placement.w).toBeGreaterThanOrEqual(240)
expect(placement.w).toBeLessThanOrEqual(784)
expect(placement.x).toBeGreaterThanOrEqual(8)
expect(placement.x + placement.w).toBeLessThanOrEqual(792)
// Row 6 is near the bottom; verify it does not push below control bar (y=552)
expect(placement.y).toBeGreaterThanOrEqual(8)
expect(placement.y + placement.h).toBeLessThanOrEqual(552)
// Verify it pinned to the bottom limit
expect(placement.y + placement.h).toBe(552)
}
})
it('guarantees top boundary containment for high-line-count tooltips on row 1', () => {
const tallLines: FormattedTooltipLine[] = Array.from({ length: 20 }, (_, i) => ({
text: `测试顶部技能描述行 ${i + 1}`,
color: 'white' as const,
font: 'font8' as const,
align: 'center' as const,
}))
for (const col of gridCols) {
const node = makeGridNode(1, col)
const nodePos = SkillTreePanel.nodeScreenPos(node)
const placement = SkillTreePanel.computeTooltipPlacement(nodePos, tallLines, mockFont)
expect(placement.x).toBeGreaterThanOrEqual(8)
expect(placement.x + placement.w).toBeLessThanOrEqual(792)
// Row 1 node y is 78. Center is 102. rawY would be 102 - 364/2 = -80.
// Clamping must pin ty to 8
expect(placement.y).toBe(8)
expect(placement.y + placement.h).toBeLessThanOrEqual(552)
}
})
it('handles font-less fallback measurement cleanly', () => {
const lines: FormattedTooltipLine[] = [
{ text: '测试无字体对象降级宽度计算', color: 'gold', font: 'fontexocet10', align: 'center' },
]
const nodePos = SkillTreePanel.nodeScreenPos(makeGridNode(2, 2))
const placement = SkillTreePanel.computeTooltipPlacement(nodePos, lines, null as unknown as D2FontRenderer)
// Fallback is text.length * 8 = 13 * 8 = 104 -> clamped to min 240
expect(placement.w).toBe(240)
expect(placement.x).toBeGreaterThanOrEqual(8)
expect(placement.x + placement.w).toBeLessThanOrEqual(792)
expect(placement.y).toBeGreaterThanOrEqual(8)
expect(placement.y + placement.h).toBeLessThanOrEqual(552)
})
})
describe('2. Text Measurement & Multi-Line Hierarchy for Diverse Skills', () => {
it('verifies formatted lines for Fire Ball, Warmth, Blizzard, Meteor, and Shiver Armor contain valid color codes', () => {
const targetSkills = [
{ id: 47, name: 'Fire Ball', prereqs: [36] },
{ id: 37, name: 'Warmth', prereqs: [] },
{ id: 59, name: 'Blizzard', prereqs: [39, 44, 45, 55] },
{ id: 56, name: 'Meteor', prereqs: [36, 41, 46, 47, 51] },
{ id: 50, name: 'Shiver Armor', prereqs: [39, 40] },
]
for (const target of targetSkills) {
const state = createInitialState('sor')
// Allocate prerequisites
for (const prereq of target.prereqs) {
allocatePoint(state, prereq, 1)
}
allocatePoint(state, target.id, 5)
const vm = buildTooltipViewModel(state, target.id, 85)
expect(vm, `ViewModel for ${target.name} should exist`).not.toBeNull()
const lines = SkillTreePanel.formatSkillTooltipLines(vm!)
expect(lines.length, `${target.name} should have formatted lines`).toBeGreaterThan(0)
// Title must be gold fontexocet10 centered
expect(lines[0]!.color).toBe('gold')
expect(lines[0]!.font).toBe('fontexocet10')
expect(lines[0]!.align).toBe('center')
// Every line must use a valid D2 color code, valid font, and have non-empty text
for (const line of lines) {
expect(
VALID_D2_COLOR_CODES,
`Line "${line.text}" in ${target.name} has invalid color: ${line.color}`,
).toContain(line.color)
expect(['font8', 'fontexocet10']).toContain(line.font)
expect(line.text.trim().length).toBeGreaterThan(0)
expect(line.text).not.toContain('undefined')
expect(line.text).not.toContain('NaN')
expect(line.text).not.toContain('null')
}
}
})
it('verifies all 30 skills in SORCERESS_SKILL_TREE produce valid colors across unlearned, learned, and maxed levels', () => {
for (const node of SORCERESS_SKILL_TREE) {
for (const lvl of [0, 1, 10, 20]) {
const state = createInitialState('sor')
if (lvl > 0) {
// Allocate prerequisites so it can be learned
for (const p of node.prereqs) {
state.hardPoints[p] = 1
}
state.hardPoints[node.skillId] = lvl
}
const vm = buildTooltipViewModel(state, node.skillId, 85)
if (!vm) continue
const lines = SkillTreePanel.formatSkillTooltipLines(vm)
for (const line of lines) {
expect(
VALID_D2_COLOR_CODES,
`Skill ${node.nameEn} (lvl ${lvl}) has invalid color code "${line.color}"`,
).toContain(line.color)
expect(line.text).not.toContain('undefined')
expect(line.text).not.toContain('NaN')
}
}
}
})
it('verifies non-damaging skills (Warmth, Teleport) do NOT output blank or undefined damage lines', () => {
// 1. Warmth (passive mana regeneration)
const warmthState = createInitialState('sor')
allocatePoint(warmthState, 37, 5)
const warmthVm = buildTooltipViewModel(warmthState, 37, 85)
expect(warmthVm).not.toBeNull()
expect(warmthVm!.current.damage.hasDamage).toBe(false)
expect(warmthVm!.nextLevel?.damage.hasDamage).toBe(false)
const warmthLines = SkillTreePanel.formatSkillTooltipLines(warmthVm!)
for (const line of warmthLines) {
expect(line.text).not.toContain('undefined')
expect(line.text).not.toContain('NaN')
expect(line.text.trim().length).toBeGreaterThan(0)
// Must not contain damage lines
expect(line.text).not.toMatch(/^(火焰|冰冷|闪电|魔法|物理|毒素)?伤害:/)
expect(line.text).not.toMatch(/Damage:/)
}
// Must contain mana regeneration rate in blue (distinguish effect stat line from narrative description)
const regenLine = warmthLines.find(l => l.text.startsWith('法力回復速度:+'))
expect(regenLine).toBeDefined()
expect(regenLine?.color).toBe('blue')
// 2. Teleport (movement spell)
const teleState = createInitialState('sor')
allocatePoint(teleState, 43, 1) // Telekinesis
allocatePoint(teleState, 54, 5) // Teleport
const teleVm = buildTooltipViewModel(teleState, 54, 85)
expect(teleVm).not.toBeNull()
expect(teleVm!.current.damage.hasDamage).toBe(false)
expect(teleVm!.nextLevel?.damage.hasDamage).toBe(false)
const teleLines = SkillTreePanel.formatSkillTooltipLines(teleVm!)
for (const line of teleLines) {
expect(line.text).not.toContain('undefined')
expect(line.text).not.toContain('NaN')
expect(line.text.trim().length).toBeGreaterThan(0)
expect(line.text).not.toMatch(/^(火焰|冰冷|闪电|魔法|物理|毒素)?伤害:/)
}
// Must contain mana cost
const manaLine = teleLines.find(l => l.text.includes('法力消耗'))
expect(manaLine).toBeDefined()
expect(manaLine?.color).toBe('white')
})
it('verifies maxed skill (20 base points) contains max points notice [已达技能点投入上限 (20/20)] and NO green next level section', () => {
const testSkills = [
{ id: 47, name: 'Fire Ball', prereqs: [36] },
{ id: 37, name: 'Warmth', prereqs: [] },
{ id: 59, name: 'Blizzard', prereqs: [39, 44, 45, 55] },
{ id: 56, name: 'Meteor', prereqs: [36, 41, 46, 47, 51] },
{ id: 50, name: 'Shiver Armor', prereqs: [39, 40] },
]
for (const t of testSkills) {
const state = createInitialState('sor')
for (const p of t.prereqs) state.hardPoints[p] = 1
state.hardPoints[t.id] = 20 // 20 hard points
const vm = buildTooltipViewModel(state, t.id, 85)
expect(vm, `${t.name} ViewModel should exist`).not.toBeNull()
expect(vm!.hardPoints).toBe(20)
expect(vm!.nextLevel).toBeNull() // Engine must suppress nextLevel
const lines = SkillTreePanel.formatSkillTooltipLines(vm!)
// 1. Must contain gold max notice
const maxNotice = lines.find(l => l.text === '[已达技能点投入上限 (20/20)]')
expect(maxNotice, `${t.name} must show max points notice`).toBeDefined()
expect(maxNotice?.color).toBe('gold')
expect(maxNotice?.font).toBe('font8')
// 2. Must NOT contain any next-level header
expect(lines.some(l => l.text.includes('[下一等级]'))).toBe(false)
// 3. Must NOT contain any green lines
const greenLines = lines.filter(l => l.color === 'green')
expect(greenLines.length, `${t.name} must not have any green lines when maxed`).toBe(0)
}
})
it('verifies maxed skill invariant holds when soft points (+skills gear) are present', () => {
const state = createInitialState('sor')
state.hardPoints[47] = 20 // 20 base points in Fire Ball
state.allSkillsSoftPoints = 8 // +8 all skills from gear
state.tabSoftPoints[0] = 3 // +3 fire tab
const vm = buildTooltipViewModel(state, 47, 85)
expect(vm).not.toBeNull()
expect(vm!.effectiveLevel).toBe(31) // 20 + 8 + 3
expect(vm!.hardPoints).toBe(20)
const lines = SkillTreePanel.formatSkillTooltipLines(vm!)
// Current level line displays breakdown in blue
const lvlLine = lines.find(l => l.text.includes('当前技能等级: 31'))
expect(lvlLine).toBeDefined()
expect(lvlLine?.color).toBe('blue')
expect(lvlLine?.text).toContain('(基础 20 + 装备 +11)')
// Max notice is still present in gold
const maxNotice = lines.find(l => l.text === '[已达技能点投入上限 (20/20)]')
expect(maxNotice).toBeDefined()
expect(maxNotice?.color).toBe('gold')
// Next level section is completely omitted
expect(lines.some(l => l.text.includes('[下一等级]'))).toBe(false)
expect(lines.filter(l => l.color === 'green').length).toBe(0)
})
it('verifies unlearned skill contains red warning when player level < reqlevel', () => {
const state = createInitialState('sor')
// 1. Level 1 Sorceress inspecting Frozen Orb (id 64, reqlevel 30)
const orbVm = buildTooltipViewModel(state, 64, 1)
expect(orbVm).not.toBeNull()
expect(orbVm!.levelMet).toBe(false)
const orbLines = SkillTreePanel.formatSkillTooltipLines(orbVm!)
const orbReqLevel = orbLines.find(l => l.text === '需要角色等级: 30')
expect(orbReqLevel).toBeDefined()
expect(orbReqLevel?.color).toBe('red')
// 2. Level 10 Sorceress inspecting Blizzard (id 59, reqlevel 24)
const blizzVm = buildTooltipViewModel(state, 59, 10)
expect(blizzVm).not.toBeNull()
expect(blizzVm!.levelMet).toBe(false)
const blizzLines = SkillTreePanel.formatSkillTooltipLines(blizzVm!)
const blizzReqLevel = blizzLines.find(l => l.text === '需要角色等级: 24')
expect(blizzReqLevel).toBeDefined()
expect(blizzReqLevel?.color).toBe('red')
// 3. Level 1 Sorceress inspecting Fire Bolt (id 36, reqlevel 1): requirements met
const boltVm = buildTooltipViewModel(state, 36, 1)
expect(boltVm).not.toBeNull()
expect(boltVm!.levelMet).toBe(true)
const boltLines = SkillTreePanel.formatSkillTooltipLines(boltVm!)
expect(boltLines.some(l => l.text.includes('需要角色等级'))).toBe(false)
})
it('verifies unlearned skill contains red warning when missing prerequisites', () => {
const state = createInitialState('sor')
// Sorceress lvl 35 inspecting Frozen Orb (id 64) with 0 points in Blizzard
const orbVm = buildTooltipViewModel(state, 64, 35)
expect(orbVm).not.toBeNull()
expect(orbVm!.levelMet).toBe(true)
expect(orbVm!.prereqs.some(p => !p.met)).toBe(true)
const orbLines = SkillTreePanel.formatSkillTooltipLines(orbVm!)
// Level is met, so no level warning
expect(orbLines.some(l => l.text.includes('需要角色等级'))).toBe(false)
// Prerequisite warning is red
const prereqWarning = orbLines.find(l => l.text.includes('需要前置技能: 暴風雪'))
expect(prereqWarning).toBeDefined()
expect(prereqWarning?.color).toBe('red')
// Unlearned status line is gray
const unlearnedNotice = orbLines.find(l => l.text === '未习得 (预览 1 级效果)')
expect(unlearnedNotice).toBeDefined()
expect(unlearnedNotice?.color).toBe('gray')
// Unlearned skill MUST NOT display [下一等级] preview
expect(orbLines.some(l => l.text.includes('[下一等级]'))).toBe(false)
})
it('verifies unlearned skill displays BOTH red warnings when level < reqlevel AND prerequisites are missing', () => {
const state = createInitialState('sor')
// Level 5 Sorceress inspecting Meteor (id 56, reqlevel 24, prereqs: Fire Bolt, Inferno, Blaze, Fire Ball, Fire Wall)
const meteorVm = buildTooltipViewModel(state, 56, 5)
expect(meteorVm).not.toBeNull()
expect(meteorVm!.levelMet).toBe(false)
expect(meteorVm!.prereqs.some(p => !p.met)).toBe(true)
const lines = SkillTreePanel.formatSkillTooltipLines(meteorVm!)
const reqLvlLine = lines.find(l => l.text === '需要角色等级: 24')
expect(reqLvlLine).toBeDefined()
expect(reqLvlLine?.color).toBe('red')
const redPrereqs = lines.filter(l => l.color === 'red' && l.text.includes('需要前置技能'))
expect(redPrereqs.length).toBeGreaterThanOrEqual(1)
// Unlearned status line is gray
expect(lines.some(l => l.text === '未习得 (预览 1 级效果)' && l.color === 'gray')).toBe(true)
})
})
describe('3. Adversarial Edge Cases: Pathological Coordinates & Extreme Strings', () => {
it('clamps gracefully even if node coordinates are placed outside canvas viewport', () => {
const dummyLines: FormattedTooltipLine[] = [
{ text: '测试极端坐标边界截断', color: 'gold', font: 'fontexocet10', align: 'center' },
{ text: '数值测试行', color: 'white', font: 'font8', align: 'center' },
]
// Test extreme positions: top-left (-100, -100), far-right (1000, 200), bottom-right (900, 700)
const pathologicalPositions = [
{ x: -100, y: -100, w: 48, h: 48 },
{ x: 0, y: 0, w: 48, h: 48 },
{ x: 1000, y: 200, w: 48, h: 48 },
{ x: 500, y: 700, w: 48, h: 48 },
{ x: 900, y: 900, w: 48, h: 48 },
]
for (const pos of pathologicalPositions) {
const placement = SkillTreePanel.computeTooltipPlacement(pos, dummyLines, mockFont)
expect(placement.w).toBeGreaterThanOrEqual(240)
expect(placement.w).toBeLessThanOrEqual(784)
expect(placement.x).toBeGreaterThanOrEqual(8)
expect(placement.x + placement.w).toBeLessThanOrEqual(792)
expect(placement.y).toBeGreaterThanOrEqual(8)
expect(placement.y + placement.h).toBeLessThanOrEqual(552)
}
})
it('correctly styles active vs inactive synergies in multi-line formatting', () => {
const state = createInitialState('sor')
allocatePoint(state, 36, 10) // Fire Bolt +10 (active synergy)
// Meteor has 0 points (inactive synergy)
allocatePoint(state, 47, 1) // Fire Ball
const vm = buildTooltipViewModel(state, 47, 85)
expect(vm).not.toBeNull()
const lines = SkillTreePanel.formatSkillTooltipLines(vm!)
const synHeader = lines.find(l => l.text === '[协同技能加成]')
expect(synHeader).toBeDefined()
expect(synHeader?.color).toBe('blue')
expect(synHeader?.font).toBe('fontexocet10')
// Active synergy for Fire Bolt: blue, shows (当前 +X%)
const activeSyn = lines.find(l => l.text.includes('火彈') && l.text.includes('(当前 +'))
expect(activeSyn).toBeDefined()
expect(activeSyn?.color).toBe('blue')
// Inactive synergy for Meteor: gray, does NOT show (当前 +X%)
const inactiveSyn = lines.find(l => l.text.includes('隕石') && !l.text.includes('(当前 +'))
expect(inactiveSyn).toBeDefined()
expect(inactiveSyn?.color).toBe('gray')
})
})
})

View File

@ -0,0 +1,360 @@
/**
* Empirical Challenger Stress Test Suite for Milestone M8:
* Widescreen Docking Mathematics, Boundary Conditions, and Resolution Scaling.
*
* @author teamwork_preview_challenger
* Target: `src/ui/hud-manager.ts` (`computeDockingLayout`, `computeHudLayout`)
*/
import { describe, it, expect } from 'vitest'
import {
computeDockingLayout,
computeHudLayout,
type DockingLayout,
} from '../src/ui/hud-manager.ts'
// Mock minimal window/DOM globals if running in headless node environment
class MockWindow extends EventTarget {
innerWidth = 1920
innerHeight = 1080
devicePixelRatio = 1
}
if (typeof globalThis.window === 'undefined') {
// @ts-ignore
globalThis.window = new MockWindow()
}
if (typeof globalThis.HTMLInputElement === 'undefined') {
// @ts-ignore
globalThis.HTMLInputElement = class HTMLInputElement {}
}
if (typeof globalThis.HTMLSelectElement === 'undefined') {
// @ts-ignore
globalThis.HTMLSelectElement = class HTMLSelectElement {}
}
// Deterministic 32-bit PRNG (Mulberry32) for reproducible fuzzing
function createPrng(seed: number = 0xdeadbeef) {
let s = seed >>> 0
return function nextFloat(): number {
s = (s + 0x6d2b79f5) | 0
let t = Math.imul(s ^ (s >>> 15), 1 | s)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
// Independent Reference Oracle for Differential Testing
function referenceDockingOracle(w: number, h: number, dpr: number = 1): DockingLayout {
const safeW = Number.isFinite(w) && w > 0 ? w : 1
const safeH = Number.isFinite(h) && h > 0 ? h : 1
const safeDpr = Number.isFinite(dpr) && dpr > 0 ? dpr : 1
const uiScale = Math.min(safeW / 800, safeH / 600)
const offsetX = Math.max(0, (safeW - 800 * uiScale) / 2)
const offsetY = Math.max(0, safeH - 600 * uiScale)
const marginW = uiScale > 0 ? offsetX / uiScale : 0
const leftDockX = -marginW || 0
const rightDockX = 400 + marginW
const channelLeft = 400 - marginW
const channelRight = 400 + marginW
const channelWidthLogical = 2 * marginW
const channelWidthCss = 2 * offsetX
const isWidescreen = marginW > 0
return {
uiScale,
offsetX,
offsetY,
dpr: safeDpr,
physicalScale: uiScale * safeDpr,
physicalOffsetX: Math.round(offsetX * safeDpr),
physicalOffsetY: Math.round(offsetY * safeDpr),
marginW,
leftDockX,
rightDockX,
channelLeft,
channelRight,
channelWidthLogical,
channelWidthCss,
isWidescreen,
}
}
describe('Challenger M8: Widescreen Docking Mathematics Stress Test Suite', () => {
describe('1. Full Spectrum Resolution Matrix Stress Testing', () => {
const STANDARD_RESOLUTIONS: Array<{
name: string
w: number
h: number
expectedAspect: string
expectedWidescreen: boolean
}> = [
{ name: 'SVGA 4:3 (Classic)', w: 800, h: 600, expectedAspect: '4:3', expectedWidescreen: false },
{ name: 'XGA 4:3', w: 1024, h: 768, expectedAspect: '4:3', expectedWidescreen: false },
{ name: 'HD 16:9', w: 1280, h: 720, expectedAspect: '16:9', expectedWidescreen: true },
{ name: 'WXGA 16:10', w: 1280, h: 800, expectedAspect: '16:10', expectedWidescreen: true },
{ name: 'HD+ 16:9', w: 1366, h: 768, expectedAspect: '16:9', expectedWidescreen: true },
{ name: 'WSXGA 16:10', w: 1440, h: 900, expectedAspect: '16:10', expectedWidescreen: true },
{ name: 'HD+ 16:9', w: 1600, h: 900, expectedAspect: '16:9', expectedWidescreen: true },
{ name: 'FHD 1080p 16:9', w: 1920, h: 1080, expectedAspect: '16:9', expectedWidescreen: true },
{ name: 'QHD 1440p 16:9', w: 2560, h: 1440, expectedAspect: '16:9', expectedWidescreen: true },
{ name: 'UWQHD 21:9', w: 3440, h: 1440, expectedAspect: '21:9', expectedWidescreen: true },
{ name: '4K UHD 16:9', w: 3840, h: 2160, expectedAspect: '16:9', expectedWidescreen: true },
{ name: 'Super Ultrawide 32:9', w: 5120, h: 1440, expectedAspect: '32:9', expectedWidescreen: true },
]
it('evaluates computeDockingLayout accurately across all 12 standard resolutions', () => {
for (const res of STANDARD_RESOLUTIONS) {
const layout = computeDockingLayout(res.w, res.h)
expect(layout.isWidescreen, `${res.name} (${res.w}x${res.h}) widescreen flag`).toBe(
res.expectedWidescreen,
)
// Basic sanity
expect(layout.uiScale).toBeGreaterThan(0)
expect(layout.marginW).toBeGreaterThanOrEqual(0)
// Invariant: channelRight >= channelLeft and channelWidthLogical >= 0 (zero panel overlap)
expect(layout.channelRight).toBeGreaterThanOrEqual(layout.channelLeft)
expect(layout.channelWidthLogical).toBeGreaterThanOrEqual(0)
const overlap = Math.max(0, layout.channelLeft - layout.channelRight)
expect(overlap, `${res.name} panel overlap`).toBe(0)
// Invariant: leftDockX == -marginW and rightDockX == 400 + marginW
expect(layout.leftDockX).toBeCloseTo(-layout.marginW, 6)
expect(layout.rightDockX).toBeCloseTo(400 + layout.marginW, 6)
// Invariant: channelWidthCss == 2 * offsetX == cssW - 800 * uiScale
if (res.w / res.h >= 4 / 3 - 1e-6) {
expect(layout.channelWidthCss).toBeCloseTo(2 * layout.offsetX, 6)
expect(layout.channelWidthCss).toBeCloseTo(res.w - 800 * layout.uiScale, 6)
}
}
})
it('fuzzes across 300 randomly generated resolutions (widths 320 to 7680, heights 240 to 4320)', () => {
const prng = createPrng(0x1337c0de)
const FUZZ_COUNT = 300
for (let i = 0; i < FUZZ_COUNT; i++) {
// Generate diverse viewport sizes: widths 320..7680, heights 240..4320
const cssW = Math.round(320 + prng() * (7680 - 320))
const cssH = Math.round(240 + prng() * (4320 - 240))
const dpr = 0.5 + prng() * 3.5
const layout = computeDockingLayout(cssW, cssH, dpr)
const oracle = referenceDockingOracle(cssW, cssH, dpr)
// Differential assertion against oracle
expect(layout.uiScale).toBeCloseTo(oracle.uiScale, 8)
expect(layout.offsetX).toBeCloseTo(oracle.offsetX, 8)
expect(layout.offsetY).toBeCloseTo(oracle.offsetY, 8)
expect(layout.marginW).toBeCloseTo(oracle.marginW, 8)
expect(layout.leftDockX).toBeCloseTo(oracle.leftDockX, 8)
expect(layout.rightDockX).toBeCloseTo(oracle.rightDockX, 8)
expect(layout.channelLeft).toBeCloseTo(oracle.channelLeft, 8)
expect(layout.channelRight).toBeCloseTo(oracle.channelRight, 8)
expect(layout.channelWidthLogical).toBeCloseTo(oracle.channelWidthLogical, 8)
expect(layout.channelWidthCss).toBeCloseTo(oracle.channelWidthCss, 8)
expect(layout.isWidescreen).toBe(oracle.isWidescreen)
// Invariant 1: channelRight >= channelLeft and channelWidthLogical >= 0 (zero overlap)
expect(layout.channelRight).toBeGreaterThanOrEqual(layout.channelLeft)
expect(layout.channelWidthLogical).toBeGreaterThanOrEqual(0)
const overlap = Math.max(0, layout.channelLeft - layout.channelRight)
expect(overlap).toBe(0)
// Invariant 2: channelWidthCss == 2 * offsetX == cssW - 800 * uiScale for aspect >= 4:3
const aspectRatio = cssW / cssH
if (aspectRatio >= 4 / 3) {
expect(Math.abs(layout.channelWidthCss - 2 * layout.offsetX)).toBeLessThan(1e-6)
expect(Math.abs(layout.channelWidthCss - (cssW - 800 * layout.uiScale))).toBeLessThan(1e-6)
}
// Invariant 3: leftDockX == -marginW and rightDockX == 400 + marginW
expect(Math.abs(layout.leftDockX - (-layout.marginW))).toBeLessThan(1e-6)
expect(Math.abs(layout.rightDockX - (400 + layout.marginW))).toBeLessThan(1e-6)
}
})
})
describe('2. Classic 800x600 & 4:3 Bit-Exact Parity', () => {
it('verifies bit-exact D2 classic parity at 800x600', () => {
const docking = computeDockingLayout(800, 600)
// Bit-exact value assertions per spec
expect(docking.marginW).toBe(0)
expect(docking.leftDockX).toBe(0)
expect(docking.rightDockX).toBe(400)
expect(docking.channelWidthLogical).toBe(0)
expect(docking.channelLeft).toBe(400)
expect(docking.channelRight).toBe(400)
expect(docking.channelWidthCss).toBe(0)
expect(docking.isWidescreen).toBe(false)
expect(docking.uiScale).toBe(1.0)
expect(docking.offsetX).toBe(0)
expect(docking.offsetY).toBe(0)
})
it('verifies bit-exact 4:3 scaling parity at 1024x768', () => {
const docking = computeDockingLayout(1024, 768)
// Bit-exact value assertions per spec
expect(docking.marginW).toBe(0)
expect(docking.leftDockX).toBe(0)
expect(docking.rightDockX).toBe(400)
expect(docking.channelWidthLogical).toBe(0)
expect(docking.channelLeft).toBe(400)
expect(docking.channelRight).toBe(400)
expect(docking.channelWidthCss).toBe(0)
expect(docking.isWidescreen).toBe(false)
expect(docking.uiScale).toBe(1.28)
expect(docking.offsetX).toBe(0)
expect(docking.offsetY).toBe(0)
})
it('verifies 4:3 aspect ratio maintains zero margin across multiple standard 4:3 resolutions', () => {
const fourByThreeList = [
[640, 480],
[800, 600],
[1024, 768],
[1152, 864],
[1280, 960],
[1400, 1050],
[1600, 1200],
[2048, 1536],
]
for (const [w, h] of fourByThreeList) {
const d = computeDockingLayout(w, h)
expect(d.marginW).toBe(0)
expect(d.leftDockX).toBe(0)
expect(d.rightDockX).toBe(400)
expect(d.channelLeft).toBe(400)
expect(d.channelRight).toBe(400)
expect(d.channelWidthLogical).toBe(0)
expect(d.channelWidthCss).toBe(0)
expect(d.offsetX).toBe(0)
expect(d.isWidescreen).toBe(false)
}
})
})
describe('3. Extreme Viewport Boundary Cases', () => {
it('handles narrow and portrait viewports without negative marginW or NaN', () => {
const portraitViewports = [
{ name: '600x800', w: 600, h: 800 },
{ name: 'Mobile 360x640', w: 360, h: 640 },
{ name: 'Mobile FHD 1080x1920', w: 1080, h: 1920 },
{ name: 'Mobile HD 720x1280', w: 720, h: 1280 },
{ name: 'iPhone 390x844', w: 390, h: 844 },
{ name: 'Ultra-thin column 100x1000', w: 100, h: 1000 },
]
for (const vp of portraitViewports) {
const layout = computeDockingLayout(vp.w, vp.h)
expect(Number.isNaN(layout.marginW), `${vp.name} marginW is NaN`).toBe(false)
expect(Number.isFinite(layout.marginW), `${vp.name} marginW is finite`).toBe(true)
expect(layout.marginW, `${vp.name} marginW === 0`).toBe(0)
expect(layout.leftDockX, `${vp.name} leftDockX === 0`).toBe(0)
expect(layout.rightDockX, `${vp.name} rightDockX === 400`).toBe(400)
expect(layout.channelLeft, `${vp.name} channelLeft === 400`).toBe(400)
expect(layout.channelRight, `${vp.name} channelRight === 400`).toBe(400)
expect(layout.channelWidthLogical, `${vp.name} channelWidthLogical === 0`).toBe(0)
expect(layout.channelWidthCss, `${vp.name} channelWidthCss === 0`).toBe(0)
expect(layout.isWidescreen, `${vp.name} isWidescreen is false`).toBe(false)
expect(layout.offsetX, `${vp.name} offsetX === 0`).toBe(0)
expect(layout.offsetY, `${vp.name} offsetY >= 0`).toBeGreaterThanOrEqual(0)
}
})
it('handles zero, negative, and invalid numerical inputs gracefully without crashes or division by zero', () => {
const adversarialInputs = [
{ name: '0x0', w: 0, h: 0 },
{ name: '-100x600', w: -100, h: 600 },
{ name: '800x-600', w: 800, h: -600 },
{ name: '-500x-500', w: -500, h: -500 },
{ name: 'NaN x 600', w: NaN, h: 600 },
{ name: '800 x NaN', w: 800, h: NaN },
{ name: 'Infinity x 600', w: Infinity, h: 600 },
{ name: '800 x -Infinity', w: 800, h: -Infinity },
]
for (const adv of adversarialInputs) {
expect(() => {
const layout = computeDockingLayout(adv.w, adv.h)
expect(Number.isFinite(layout.uiScale), `${adv.name} uiScale is finite`).toBe(true)
expect(layout.uiScale, `${adv.name} uiScale > 0`).toBeGreaterThan(0)
expect(Number.isFinite(layout.marginW), `${adv.name} marginW is finite`).toBe(true)
expect(layout.marginW, `${adv.name} marginW >= 0`).toBeGreaterThanOrEqual(0)
expect(Number.isFinite(layout.leftDockX), `${adv.name} leftDockX is finite`).toBe(true)
expect(Number.isFinite(layout.rightDockX), `${adv.name} rightDockX is finite`).toBe(true)
expect(Number.isFinite(layout.channelLeft), `${adv.name} channelLeft is finite`).toBe(true)
expect(Number.isFinite(layout.channelRight), `${adv.name} channelRight is finite`).toBe(true)
expect(layout.channelRight).toBeGreaterThanOrEqual(layout.channelLeft)
}).not.toThrow()
}
})
it('scales layout dimensions cleanly across fractional DPIs (1.25, 1.5, 1.75, 2.0, 2.625)', () => {
const fractionalDprs = [1.25, 1.5, 1.75, 2.0, 2.625]
const baseResolutions = [
[800, 600],
[1920, 1080],
[2560, 1440],
[3440, 1440],
]
for (const [w, h] of baseResolutions) {
for (const dpr of fractionalDprs) {
const layout = computeDockingLayout(w, h, dpr)
// Verification of physical scaling metrics
expect(layout.dpr).toBe(dpr)
expect(layout.physicalScale).toBeCloseTo(layout.uiScale * dpr, 6)
expect(layout.physicalOffsetX).toBe(Math.round(layout.offsetX * dpr))
expect(layout.physicalOffsetY).toBe(Math.round(layout.offsetY * dpr))
// DPR should not alter logical docking metrics
const baseLayout = computeDockingLayout(w, h, 1)
expect(layout.marginW).toBeCloseTo(baseLayout.marginW, 8)
expect(layout.leftDockX).toBeCloseTo(baseLayout.leftDockX, 8)
expect(layout.rightDockX).toBeCloseTo(baseLayout.rightDockX, 8)
expect(layout.channelLeft).toBeCloseTo(baseLayout.channelLeft, 8)
expect(layout.channelRight).toBeCloseTo(baseLayout.channelRight, 8)
expect(layout.channelWidthLogical).toBeCloseTo(baseLayout.channelWidthLogical, 8)
expect(layout.channelWidthCss).toBeCloseTo(baseLayout.channelWidthCss, 8)
}
}
})
it('verifies extreme gargantuan (8K, 16K) and microscopic viewports', () => {
// 8K UHD: 7680x4320 (16:9)
const uhd8k = computeDockingLayout(7680, 4320)
expect(uhd8k.uiScale).toBeCloseTo(7.2, 4)
expect(uhd8k.offsetX).toBe(960)
expect(uhd8k.marginW).toBeCloseTo(133.333333, 4)
expect(uhd8k.channelWidthCss).toBe(1920)
expect(uhd8k.channelWidthLogical).toBeCloseTo(266.666667, 4)
// 16K: 15360x8640 (16:9)
const uhd16k = computeDockingLayout(15360, 8640)
expect(uhd16k.uiScale).toBeCloseTo(14.4, 4)
expect(uhd16k.offsetX).toBe(1920)
expect(uhd16k.marginW).toBeCloseTo(133.333333, 4)
expect(uhd16k.channelWidthCss).toBe(3840)
// Microscopic viewports
const micro = computeDockingLayout(1, 1)
expect(micro.uiScale).toBeGreaterThan(0)
expect(micro.marginW).toBe(0)
expect(micro.channelWidthLogical).toBe(0)
expect(micro.channelWidthCss).toBe(0)
})
})
})

View File

@ -0,0 +1,834 @@
/**
* Challenger M8 Stress Test Suite: Pointer Hit-Testing, Coordinate Translation,
* Quick Transfer, and HUD Interception under Widescreen Docking.
*
* Implements empirical challenger verification following the solution-stress-testing
* methodology (generator, oracle, property invariants, edge-case probing).
*/
import { describe, it, expect, beforeEach } from 'vitest'
import {
HudManager,
computeDockingLayout,
computeHudLayout,
clientToLogical,
logicalToClient,
CHAR_PANEL_ORIGIN,
INV_PANEL_ORIGIN,
INV_GRID_ORIGIN,
SKILL_PANEL_ORIGIN,
} from '../src/ui/hud-manager.ts'
import {
InventoryPanel,
EQUIP_SLOTS_LAYOUT,
INV_CLOSE_BTN_BOUNDS,
type EquipSlotId,
type UiInventoryItem,
} from '../src/ui/inventory.ts'
import { CHAR_CLOSE_BTN_BOUNDS } from '../src/ui/character-sheet.ts'
import { STASH_GRID_ORIGIN, STASH_PANEL_ORIGIN } from '../src/ui/world-panels.ts'
class MockWindow extends EventTarget {
innerWidth = 1920
innerHeight = 1080
devicePixelRatio = 1
}
if (typeof (globalThis as any).window === 'undefined') {
;(globalThis as any).window = new MockWindow()
}
if (typeof (globalThis as any).HTMLInputElement === 'undefined') {
;(globalThis as any).HTMLInputElement = class HTMLInputElement {}
}
if (typeof (globalThis as any).HTMLSelectElement === 'undefined') {
;(globalThis as any).HTMLSelectElement = class HTMLSelectElement {}
}
class MockCanvas extends EventTarget {
clientWidth: number
clientHeight: number
width: number
height: number
style = { pointerEvents: 'none' }
constructor(width: number, height: number) {
super()
this.clientWidth = width
this.clientHeight = height
this.width = width
this.height = height
}
getBoundingClientRect() {
return { left: 0, top: 0, width: this.clientWidth, height: this.clientHeight }
}
getContext() {
return null
}
}
class SyntheticMouseEvent extends Event {
readonly clientX: number
readonly clientY: number
readonly button: number
readonly shiftKey: boolean
constructor(
type: string,
init: { clientX: number; clientY: number; button?: number; shiftKey?: boolean },
) {
super(type, { cancelable: true, bubbles: true })
this.clientX = init.clientX
this.clientY = init.clientY
this.button = init.button ?? 0
this.shiftKey = init.shiftKey ?? false
}
}
function createDummyItem(
id: string,
code: string,
invWidth: number,
invHeight: number,
nameZh: string,
allowedSlots: readonly EquipSlotId[] = [],
): UiInventoryItem {
return {
id,
code,
invFile: code,
name: nameZh,
nameZh,
baseNameZh: nameZh,
quality: 'normal',
invWidth,
invHeight,
allowedSlots,
stats: [],
}
}
const WIDESCREEN_RESOLUTIONS = [
{ name: '1280x720 (16:9)', width: 1280, height: 720 },
{ name: '1920x1080 (16:9 Full HD)', width: 1920, height: 1080 },
{ name: '2560x1440 (16:9 QHD)', width: 2560, height: 1440 },
{ name: '3440x1440 (21:9 Ultrawide)', width: 3440, height: 1440 },
] as const
describe('Challenger M8: Pointer Hit-Testing & Coordinate Translation under Widescreen Docking', () => {
describe('1. Pointer Hit-Testing Round-Trip Accuracy Across Widescreen Resolutions', () => {
for (const res of WIDESCREEN_RESOLUTIONS) {
describe(`Resolution ${res.name}`, () => {
let canvas: MockCanvas
let hud: HudManager
beforeEach(() => {
const win = new MockWindow()
win.innerWidth = res.width
win.innerHeight = res.height
;(globalThis as any).window = win
canvas = new MockCanvas(res.width, res.height)
hud = new HudManager(canvas as unknown as HTMLCanvasElement, {
onToggleAutomap: () => {},
onWaypointTeleport: () => {},
})
hud['bindEvents']()
hud.rightPanel = 'inv'
hud.inventory.visible = true
})
it(`tests Inventory Grid (10x4) hit-testing at all 40 cells with dynamic offset (rightDockX + 40 + col * 29)`, () => {
const docking = hud.getDockingLayout()
expect(docking.isWidescreen).toBe(true)
const deltaRight = docking.marginW
for (let col = 0; col < INV_GRID_ORIGIN.cols; col++) {
for (let row = 0; row < INV_GRID_ORIGIN.rows; row++) {
// Formula specified by user request: rightDockX + 40 + col * 29
const dynamicLogicalX = docking.rightDockX + 40 + col * INV_GRID_ORIGIN.cellPx
const dynamicLogicalY = INV_GRID_ORIGIN.y + 14.5 + row * INV_GRID_ORIGIN.cellPx
// Convert logical to client coordinates
const clientPos = logicalToClient(dynamicLogicalX, dynamicLogicalY, canvas.getBoundingClientRect())
// Test 1: Mathematical round-trip translation
const pt = clientToLogical(clientPos.x, clientPos.y, canvas.getBoundingClientRect())
const translatedRightX = pt.x - deltaRight
const translatedLogicalY = pt.y
const hitCol = Math.floor((translatedRightX - INV_GRID_ORIGIN.x) / INV_GRID_ORIGIN.cellPx)
const hitRow = Math.floor((translatedLogicalY - INV_GRID_ORIGIN.y) / INV_GRID_ORIGIN.cellPx)
// Assert translated rightX hits exact corresponding col and row with 0 pixel drift
expect(hitCol).toBe(col)
expect(hitRow).toBe(row)
const cellBaseX = INV_GRID_ORIGIN.x + col * INV_GRID_ORIGIN.cellPx
const offsetInCell = translatedRightX - cellBaseX
// Since rightDockX = 400 + marginW and INV_GRID_ORIGIN.x = 418:
// translatedRightX - INV_GRID_ORIGIN.x = (400 + 40 + col*29) - 418 = 22 + col*29
// So offsetInCell must be exactly 22px, with zero drift
expect(offsetInCell).toBeCloseTo(22, 5)
// Test 2: Empirical click round-trip (pick up and put back item at this cell)
const testItem = createDummyItem(`item-${col}-${row}`, 'rin', 1, 1, `TestRing-${col}-${row}`)
hud.inventory.gridItems = [{ item: testItem, col, row }]
hud.inventory.cursorItem = null
canvas.dispatchEvent(
new SyntheticMouseEvent('mousedown', {
clientX: clientPos.x,
clientY: clientPos.y,
button: 0,
shiftKey: false,
}),
)
// Item must have been picked up from exact cell into cursorItem
expect(hud.inventory.cursorItem).not.toBeNull()
expect((hud.inventory.cursorItem as UiInventoryItem | null)?.id).toBe(`item-${col}-${row}`)
expect(hud.inventory.gridItems).toHaveLength(0)
// Click again at same cell to drop it back
canvas.dispatchEvent(
new SyntheticMouseEvent('mousedown', {
clientX: clientPos.x,
clientY: clientPos.y,
button: 0,
shiftKey: false,
}),
)
expect(hud.inventory.cursorItem).toBeNull()
expect(hud.inventory.gridItems).toHaveLength(1)
expect(hud.inventory.gridItems[0]!.col).toBe(col)
expect(hud.inventory.gridItems[0]!.row).toBe(row)
}
}
})
it('tests Equipment Slots (all 10 slots) bounding box hit-testing accuracy', () => {
const docking = hud.getDockingLayout()
const deltaRight = docking.marginW
const slotIds: EquipSlotId[] = [
'helm',
'amulet',
'weapon1',
'armor',
'weapon2',
'gloves',
'ring1',
'belt',
'ring2',
'boots',
]
for (const slotId of slotIds) {
const rect = EQUIP_SLOTS_LAYOUT[slotId]
const canonicalCenterX = rect.x + rect.w / 2
const canonicalCenterY = rect.y + rect.h / 2
// Visual position in widescreen right dock
const visualX = canonicalCenterX + deltaRight
const visualY = canonicalCenterY
const clientPos = logicalToClient(visualX, visualY, canvas.getBoundingClientRect())
// Mathematical translation check
const pt = clientToLogical(clientPos.x, clientPos.y, canvas.getBoundingClientRect())
const rightX = pt.x - deltaRight
expect(rightX).toBeGreaterThanOrEqual(rect.x)
expect(rightX).toBeLessThanOrEqual(rect.x + rect.w)
expect(pt.y).toBeGreaterThanOrEqual(rect.y)
expect(pt.y).toBeLessThanOrEqual(rect.y + rect.h)
// Empirical click interaction check
const eqItem = createDummyItem(`eq-${slotId}`, 'cap', 2, 2, `Item-${slotId}`, [slotId])
hud.inventory.equipped[slotId] = eqItem
hud.inventory.cursorItem = null
canvas.dispatchEvent(
new SyntheticMouseEvent('mousedown', {
clientX: clientPos.x,
clientY: clientPos.y,
button: 0,
shiftKey: false,
}),
)
// Item unequipped to cursor
expect((hud.inventory.cursorItem as UiInventoryItem | null)?.id).toBe(`eq-${slotId}`)
expect(hud.inventory.equipped[slotId]).toBeUndefined()
// Click slot again to re-equip
canvas.dispatchEvent(
new SyntheticMouseEvent('mousedown', {
clientX: clientPos.x,
clientY: clientPos.y,
button: 0,
shiftKey: false,
}),
)
expect(hud.inventory.cursorItem).toBeNull()
expect(hud.inventory.equipped[slotId]?.id).toBe(`eq-${slotId}`)
}
})
it('tests Character Sheet close button (left bezel) and Inventory close button (right bezel)', () => {
hud.leftPanel = 'char'
hud.charSheet.visible = true
hud.rightPanel = 'inv'
hud.inventory.visible = true
const docking = hud.getDockingLayout()
const deltaLeft = -docking.marginW
const deltaRight = docking.marginW
// 1. Character Sheet Close Button
const charCloseCenterLogicalX = CHAR_CLOSE_BTN_BOUNDS.x + CHAR_CLOSE_BTN_BOUNDS.w / 2 + deltaLeft
const charCloseCenterLogicalY = CHAR_CLOSE_BTN_BOUNDS.y + CHAR_CLOSE_BTN_BOUNDS.h / 2
const charClientPos = logicalToClient(charCloseCenterLogicalX, charCloseCenterLogicalY, canvas.getBoundingClientRect())
const charPt = clientToLogical(charClientPos.x, charClientPos.y, canvas.getBoundingClientRect())
const translatedLeftX = charPt.x - deltaLeft
expect(translatedLeftX).toBeGreaterThanOrEqual(CHAR_CLOSE_BTN_BOUNDS.x)
expect(translatedLeftX).toBeLessThanOrEqual(CHAR_CLOSE_BTN_BOUNDS.x + CHAR_CLOSE_BTN_BOUNDS.w)
expect(charPt.y).toBeGreaterThanOrEqual(CHAR_CLOSE_BTN_BOUNDS.y)
expect(charPt.y).toBeLessThanOrEqual(CHAR_CLOSE_BTN_BOUNDS.y + CHAR_CLOSE_BTN_BOUNDS.h)
canvas.dispatchEvent(
new SyntheticMouseEvent('mousedown', {
clientX: charClientPos.x,
clientY: charClientPos.y,
button: 0,
}),
)
expect(hud.leftPanel).toBe('none')
expect(hud.charSheet.visible).toBe(false)
// 2. Inventory Close Button
const invCloseCenterLogicalX = INV_CLOSE_BTN_BOUNDS.x + INV_CLOSE_BTN_BOUNDS.w / 2 + deltaRight
const invCloseCenterLogicalY = INV_CLOSE_BTN_BOUNDS.y + INV_CLOSE_BTN_BOUNDS.h / 2
const invClientPos = logicalToClient(invCloseCenterLogicalX, invCloseCenterLogicalY, canvas.getBoundingClientRect())
const invPt = clientToLogical(invClientPos.x, invClientPos.y, canvas.getBoundingClientRect())
const translatedRightX = invPt.x - deltaRight
expect(translatedRightX).toBeGreaterThanOrEqual(INV_CLOSE_BTN_BOUNDS.x)
expect(translatedRightX).toBeLessThanOrEqual(INV_CLOSE_BTN_BOUNDS.x + INV_CLOSE_BTN_BOUNDS.w)
expect(invPt.y).toBeGreaterThanOrEqual(INV_CLOSE_BTN_BOUNDS.y)
expect(invPt.y).toBeLessThanOrEqual(INV_CLOSE_BTN_BOUNDS.y + INV_CLOSE_BTN_BOUNDS.h)
canvas.dispatchEvent(
new SyntheticMouseEvent('mousedown', {
clientX: invClientPos.x,
clientY: invClientPos.y,
button: 0,
}),
)
expect(hud.rightPanel).toBe('none')
expect(hud.inventory.visible).toBe(false)
})
})
}
})
describe('2. Central Vision Corridor Passthrough Stress Testing (1920x1080, marginW = 133.33)', () => {
let canvas: MockCanvas
let hud: HudManager
beforeEach(() => {
const win = new MockWindow()
win.innerWidth = 1920
win.innerHeight = 1080
;(globalThis as any).window = win
canvas = new MockCanvas(1920, 1080)
hud = new HudManager(canvas as unknown as HTMLCanvasElement, {
onToggleAutomap: () => {},
onWaypointTeleport: () => {},
})
hud['bindEvents']()
})
const panelPairs: { left: 'char' | 'stash' | 'quest'; right: 'inv' | 'skill' }[] = [
{ left: 'char', right: 'inv' },
{ left: 'stash', right: 'inv' },
{ left: 'quest', right: 'skill' },
]
for (const pair of panelPairs) {
describe(`Dual Panels: '${pair.left}' + '${pair.right}'`, () => {
beforeEach(() => {
hud.leftPanel = pair.left
if (pair.left === 'char') hud.charSheet.visible = true
hud.rightPanel = pair.right
if (pair.right === 'inv') hud.inventory.visible = true
if (pair.right === 'skill') hud.skillTree.visible = true
})
it('probes points strictly inside central corridor (400 - marginW, 400 + marginW): returns false', () => {
const docking = hud.getDockingLayout()
const marginW = docking.marginW // 133.333...
const leftBound = 400 - marginW
const rightBound = 400 + marginW
// Dense sweep of points across corridor width
const testXs = [
leftBound + 0.5,
leftBound + 5,
leftBound + 20,
300,
350,
400, // Screen midpoint
450,
500,
rightBound - 20,
rightBound - 5,
rightBound - 0.5,
]
// Sweep across heights in normal gameplay viewport (above bottom control bar)
const testYs = [50, 150, 250, 300, 350, 450, 490]
for (const x of testXs) {
for (const y of testYs) {
expect(
hud.isPointInterceptedByHud(x, y),
`Expected isPointInterceptedByHud(${x}, ${y}) to be false in central corridor`,
).toBe(false)
}
}
})
it('probes points inside left panel [-marginW, 400 - marginW]: returns true', () => {
const docking = hud.getDockingLayout()
const marginW = docking.marginW
const leftPanelRightEdge = 400 - marginW
const testXs = [
-marginW,
-marginW + 5,
-marginW + 50,
0,
50,
100,
200,
leftPanelRightEdge - 10,
leftPanelRightEdge - 1,
leftPanelRightEdge,
]
for (const x of testXs) {
expect(
hud.isPointInterceptedByHud(x, 300),
`Expected isPointInterceptedByHud(${x}, 300) to be true on left panel`,
).toBe(true)
}
})
it('probes points inside right panel [400 + marginW, 800 + marginW]: returns true', () => {
const docking = hud.getDockingLayout()
const marginW = docking.marginW
const rightPanelLeftEdge = 400 + marginW
const rightPanelRightEdge = 800 + marginW
const testXs = [
rightPanelLeftEdge,
rightPanelLeftEdge + 1,
rightPanelLeftEdge + 10,
600,
700,
800,
rightPanelRightEdge - 20,
rightPanelRightEdge - 1,
rightPanelRightEdge,
]
for (const x of testXs) {
expect(
hud.isPointInterceptedByHud(x, 300),
`Expected isPointInterceptedByHud(${x}, 300) to be true on right panel`,
).toBe(true)
}
})
it('probes points out of horizontal bounds (< -marginW or > 800 + marginW): returns false', () => {
const docking = hud.getDockingLayout()
const marginW = docking.marginW
expect(hud.isPointInterceptedByHud(-marginW - 1, 300)).toBe(false)
expect(hud.isPointInterceptedByHud(-marginW - 100, 300)).toBe(false)
expect(hud.isPointInterceptedByHud(800 + marginW + 1, 300)).toBe(false)
expect(hud.isPointInterceptedByHud(800 + marginW + 200, 300)).toBe(false)
})
})
}
})
describe('3. Quick Transfer Mechanics Under Widescreen Docking (1920x1080)', () => {
let canvas: MockCanvas
let hud: HudManager
beforeEach(() => {
const win = new MockWindow()
win.innerWidth = 1920
win.innerHeight = 1080
;(globalThis as any).window = win
canvas = new MockCanvas(1920, 1080)
hud = new HudManager(canvas as unknown as HTMLCanvasElement, {
onToggleAutomap: () => {},
onWaypointTeleport: () => {},
})
hud['bindEvents']()
hud.leftPanel = 'stash'
hud.rightPanel = 'inv'
hud.inventory.visible = true
hud.inventory.gridItems = []
hud.worldPanels.stashItems = []
})
it('transfers multiple varied-size items cleanly from Inventory to Stash via Shift+Click with 0 loss and 0 dupes', () => {
const docking = hud.getDockingLayout()
const deltaRight = docking.marginW
// Setup 5 varied-dimension items in Inventory grid
const itemsToTest = [
createDummyItem('item-1x1', 'rin', 1, 1, '乔丹之石'),
createDummyItem('item-1x2', 'amu', 1, 2, '马拉的万花筒'),
createDummyItem('item-2x2', 'cap', 2, 2, '军帽 (Shako)'),
createDummyItem('item-2x3', 'crs', 2, 3, '水晶剑 (Crystal Sword)'),
createDummyItem('item-2x4', 'gpa', 2, 4, '无限 (Infinity)'),
]
hud.inventory.gridItems = [
{ item: itemsToTest[0]!, col: 0, row: 0 },
{ item: itemsToTest[1]!, col: 1, row: 0 },
{ item: itemsToTest[2]!, col: 2, row: 0 },
{ item: itemsToTest[3]!, col: 4, row: 0 },
{ item: itemsToTest[4]!, col: 6, row: 0 },
]
expect(hud.inventory.gridItems).toHaveLength(5)
expect(hud.worldPanels.stashItems).toHaveLength(0)
// Shift+Click each item in Inventory grid
for (const targetItem of itemsToTest) {
const placement = hud.inventory.gridItems.find(p => p.item.id === targetItem.id)
expect(placement).toBeDefined()
const visualLogicalX = INV_GRID_ORIGIN.x + (placement!.col + 0.5) * INV_GRID_ORIGIN.cellPx + deltaRight
const visualLogicalY = INV_GRID_ORIGIN.y + (placement!.row + 0.5) * INV_GRID_ORIGIN.cellPx
const clientPos = logicalToClient(visualLogicalX, visualLogicalY, canvas.getBoundingClientRect())
canvas.dispatchEvent(
new SyntheticMouseEvent('mousedown', {
clientX: clientPos.x,
clientY: clientPos.y,
button: 0,
shiftKey: true,
}),
)
}
// Assertions: 0 item duplication, 0 item loss
expect(hud.inventory.gridItems).toHaveLength(0)
expect(hud.worldPanels.stashItems).toHaveLength(5)
const stashItemIds = hud.worldPanels.stashItems.map(p => p.item.id)
expect(new Set(stashItemIds).size).toBe(5)
for (const expected of itemsToTest) {
expect(stashItemIds).toContain(expected.id)
}
// Assert zero coordinate translation errors: all items properly placed in 6x8 stash grid
for (const p of hud.worldPanels.stashItems) {
expect(p.col).toBeGreaterThanOrEqual(0)
expect(p.col + p.item.invWidth).toBeLessThanOrEqual(STASH_GRID_ORIGIN.cols)
expect(p.row).toBeGreaterThanOrEqual(0)
expect(p.row + p.item.invHeight).toBeLessThanOrEqual(STASH_GRID_ORIGIN.rows)
}
// Assert no overlapping items in Stash
for (let i = 0; i < hud.worldPanels.stashItems.length; i++) {
for (let j = i + 1; j < hud.worldPanels.stashItems.length; j++) {
const a = hud.worldPanels.stashItems[i]!
const b = hud.worldPanels.stashItems[j]!
const xOverlap = a.col < b.col + b.item.invWidth && a.col + a.item.invWidth > b.col
const yOverlap = a.row < b.row + b.item.invHeight && a.row + a.item.invHeight > b.row
expect(xOverlap && yOverlap).toBe(false)
}
}
})
it('transfers multiple items cleanly from Stash to Inventory via Shift+Click with 0 loss and 0 dupes', () => {
const docking = hud.getDockingLayout()
const deltaLeft = -docking.marginW
const itemsInStash = [
createDummyItem('stash-1x1', 'rin', 1, 1, '布尔凯索婚戒'),
createDummyItem('stash-1x2', 'tbk', 1, 2, '回城之书'),
createDummyItem('stash-2x2', 'box', 2, 2, '赫拉迪克方块'),
createDummyItem('stash-2x3', 'crs', 2, 3, '橡树之心'),
]
hud.worldPanels.stashItems = [
{ item: itemsInStash[0]!, col: 0, row: 0 },
{ item: itemsInStash[1]!, col: 1, row: 0 },
{ item: itemsInStash[2]!, col: 2, row: 0 },
{ item: itemsInStash[3]!, col: 4, row: 0 },
]
expect(hud.worldPanels.stashItems).toHaveLength(4)
expect(hud.inventory.gridItems).toHaveLength(0)
for (const targetItem of itemsInStash) {
const placement = hud.worldPanels.stashItems.find(p => p.item.id === targetItem.id)
expect(placement).toBeDefined()
const visualLogicalX = STASH_GRID_ORIGIN.x + (placement!.col + 0.5) * STASH_GRID_ORIGIN.cellPx + deltaLeft
const visualLogicalY = STASH_GRID_ORIGIN.y + (placement!.row + 0.5) * STASH_GRID_ORIGIN.cellPx
const clientPos = logicalToClient(visualLogicalX, visualLogicalY, canvas.getBoundingClientRect())
canvas.dispatchEvent(
new SyntheticMouseEvent('mousedown', {
clientX: clientPos.x,
clientY: clientPos.y,
button: 0,
shiftKey: true,
}),
)
}
// Assertions: 0 item duplication, 0 item loss
expect(hud.worldPanels.stashItems).toHaveLength(0)
expect(hud.inventory.gridItems).toHaveLength(4)
const invItemIds = hud.inventory.gridItems.map(p => p.item.id)
expect(new Set(invItemIds).size).toBe(4)
for (const expected of itemsInStash) {
expect(invItemIds).toContain(expected.id)
}
// Assert all items legally positioned in 10x4 inventory grid without overlap
for (const p of hud.inventory.gridItems) {
expect(p.col).toBeGreaterThanOrEqual(0)
expect(p.col + p.item.invWidth).toBeLessThanOrEqual(INV_GRID_ORIGIN.cols)
expect(p.row).toBeGreaterThanOrEqual(0)
expect(p.row + p.item.invHeight).toBeLessThanOrEqual(INV_GRID_ORIGIN.rows)
}
})
it('handles destination full gracefully without item loss or state corruption', () => {
const docking = hud.getDockingLayout()
const deltaRight = docking.marginW
// Completely pack stash 6x8 grid with 48 1x1 items
for (let col = 0; col < STASH_GRID_ORIGIN.cols; col++) {
for (let row = 0; row < STASH_GRID_ORIGIN.rows; row++) {
hud.worldPanels.stashItems.push({
item: createDummyItem(`fill-${col}-${row}`, 'rin', 1, 1, 'Filler'),
col,
row,
})
}
}
expect(hud.worldPanels.stashItems).toHaveLength(48)
// Place 1 item in inventory
const extraItem = createDummyItem('overflow-ring', 'rin', 1, 1, '溢出之戒')
hud.inventory.gridItems = [{ item: extraItem, col: 0, row: 0 }]
const visualLogicalX = INV_GRID_ORIGIN.x + 14.5 + deltaRight
const visualLogicalY = INV_GRID_ORIGIN.y + 14.5
const clientPos = logicalToClient(visualLogicalX, visualLogicalY, canvas.getBoundingClientRect())
canvas.dispatchEvent(
new SyntheticMouseEvent('mousedown', {
clientX: clientPos.x,
clientY: clientPos.y,
button: 0,
shiftKey: true,
}),
)
// Transfer must fail gracefully: item stays in inventory, stash remains full
expect(hud.worldPanels.stashItems).toHaveLength(48)
expect(hud.inventory.gridItems).toHaveLength(1)
expect(hud.inventory.gridItems[0]!.item.id).toBe('overflow-ring')
})
it('transfers items cleanly from Inventory to Stash and Stash to Inventory via Right-Click (button: 2)', () => {
const docking = hud.getDockingLayout()
const deltaRight = docking.marginW
const deltaLeft = -docking.marginW
const rightClickItem = createDummyItem('rc-item', 'rin', 1, 1, '右键转移戒指')
hud.inventory.gridItems = [{ item: rightClickItem, col: 0, row: 0 }]
expect(hud.worldPanels.stashItems).toHaveLength(0)
// Right click item in Inventory (button: 2)
const visualX = INV_GRID_ORIGIN.x + 14.5 + deltaRight
const visualY = INV_GRID_ORIGIN.y + 14.5
const clientPos = logicalToClient(visualX, visualY, canvas.getBoundingClientRect())
canvas.dispatchEvent(
new SyntheticMouseEvent('mousedown', {
clientX: clientPos.x,
clientY: clientPos.y,
button: 2,
shiftKey: false,
}),
)
expect(hud.inventory.gridItems).toHaveLength(0)
expect(hud.worldPanels.stashItems).toHaveLength(1)
expect(hud.worldPanels.stashItems[0]!.item.id).toBe('rc-item')
// Right click item back in Stash (button: 2)
const stashP = hud.worldPanels.stashItems[0]!
const stashVisualX = STASH_GRID_ORIGIN.x + (stashP.col + 0.5) * STASH_GRID_ORIGIN.cellPx + deltaLeft
const stashVisualY = STASH_GRID_ORIGIN.y + (stashP.row + 0.5) * STASH_GRID_ORIGIN.cellPx
const stashClientPos = logicalToClient(stashVisualX, stashVisualY, canvas.getBoundingClientRect())
canvas.dispatchEvent(
new SyntheticMouseEvent('mousedown', {
clientX: stashClientPos.x,
clientY: stashClientPos.y,
button: 2,
shiftKey: false,
}),
)
expect(hud.worldPanels.stashItems).toHaveLength(0)
expect(hud.inventory.gridItems).toHaveLength(1)
expect(hud.inventory.gridItems[0]!.item.id).toBe('rc-item')
})
})
describe('4. Tooltip Anchoring Compensation Across Resolutions', () => {
for (const res of WIDESCREEN_RESOLUTIONS) {
describe(`Resolution ${res.name}`, () => {
let canvas: MockCanvas
let hud: HudManager
beforeEach(() => {
const win = new MockWindow()
win.innerWidth = res.width
win.innerHeight = res.height
;(globalThis as any).window = win
canvas = new MockCanvas(res.width, res.height)
hud = new HudManager(canvas as unknown as HTMLCanvasElement, {
onToggleAutomap: () => {},
onWaypointTeleport: () => {},
})
hud['bindEvents']()
hud.leftPanel = 'stash'
hud.rightPanel = 'inv'
hud.inventory.visible = true
})
it('asserts hovered item tooltip on Inventory item has visual anchor properly compensated by +deltaRight', () => {
const docking = hud.getDockingLayout()
const deltaRight = docking.marginW
const item = createDummyItem('inv-tooltip-test', 'cap', 2, 2, '军帽')
hud.inventory.gridItems = [{ item, col: 3, row: 1 }]
// Canonical center anchor for (col: 3, row: 1, w: 2, h: 2):
// x = INV_GRID_ORIGIN.x + (col + w/2) * cellPx = 418 + (3 + 1) * 29 = 418 + 116 = 534
// y = INV_GRID_ORIGIN.y + row * cellPx = 316 + 29 = 345
const canonicalAnchorX = INV_GRID_ORIGIN.x + (3 + 2 / 2) * INV_GRID_ORIGIN.cellPx
const canonicalAnchorY = INV_GRID_ORIGIN.y + 1 * INV_GRID_ORIGIN.cellPx
const expectedVisualAnchorX = canonicalAnchorX + deltaRight
// Mouse is over this item at visual position
const visualMouseLogicalX = canonicalAnchorX + deltaRight
const visualMouseLogicalY = canonicalAnchorY + 15
const clientPos = logicalToClient(visualMouseLogicalX, visualMouseLogicalY, canvas.getBoundingClientRect())
;(globalThis as any).window.dispatchEvent(
new SyntheticMouseEvent('mousemove', {
clientX: clientPos.x,
clientY: clientPos.y,
}),
)
expect(hud.inventory.hoveredItem).not.toBeNull()
expect(hud.inventory.hoveredItem?.item.id).toBe('inv-tooltip-test')
// Assert visual anchor x is compensated by deltaRight
expect(hud.inventory.hoveredItem?.x).toBeCloseTo(expectedVisualAnchorX, 2)
expect(hud.inventory.hoveredItem?.y).toBeCloseTo(canonicalAnchorY, 2)
})
it('asserts hovered item tooltip on Stash item has visual anchor properly compensated by +deltaLeft', () => {
const docking = hud.getDockingLayout()
const deltaLeft = -docking.marginW
const item = createDummyItem('stash-tooltip-test', 'amu', 1, 1, '马拉万花筒')
hud.worldPanels.stashItems = [{ item, col: 2, row: 3 }]
// Canonical center anchor for (col: 2, row: 3, w: 1, h: 1):
// x = STASH_GRID_ORIGIN.x + (col + w/2) * cellPx = 154 + (2 + 0.5) * 29 = 154 + 72.5 = 226.5
// y = STASH_GRID_ORIGIN.y + row * cellPx = 142 + 3 * 29 = 142 + 87 = 229
const canonicalAnchorX = STASH_GRID_ORIGIN.x + (2 + 0.5) * STASH_GRID_ORIGIN.cellPx
const canonicalAnchorY = STASH_GRID_ORIGIN.y + 3 * STASH_GRID_ORIGIN.cellPx
const expectedVisualAnchorX = canonicalAnchorX + deltaLeft
const visualMouseLogicalX = canonicalAnchorX + deltaLeft
const visualMouseLogicalY = canonicalAnchorY + 14.5
const clientPos = logicalToClient(visualMouseLogicalX, visualMouseLogicalY, canvas.getBoundingClientRect())
;(globalThis as any).window.dispatchEvent(
new SyntheticMouseEvent('mousemove', {
clientX: clientPos.x,
clientY: clientPos.y,
}),
)
expect(hud.inventory.hoveredItem).not.toBeNull()
expect(hud.inventory.hoveredItem?.item.id).toBe('stash-tooltip-test')
// Assert visual anchor x is compensated by deltaLeft
expect(hud.inventory.hoveredItem?.x).toBeCloseTo(expectedVisualAnchorX, 2)
expect(hud.inventory.hoveredItem?.y).toBeCloseTo(canonicalAnchorY, 2)
})
it('asserts hovered item tooltip on Equipment slot has visual anchor compensated by +deltaRight', () => {
const docking = hud.getDockingLayout()
const deltaRight = docking.marginW
const helmItem = createDummyItem('eq-helm-tooltip', 'cap', 2, 2, '暗金头盔', ['helm'])
hud.inventory.equipped.helm = helmItem
const rect = EQUIP_SLOTS_LAYOUT.helm
const canonicalAnchorX = rect.x + rect.w / 2
const canonicalAnchorY = rect.y
const expectedVisualAnchorX = canonicalAnchorX + deltaRight
const visualMouseLogicalX = canonicalAnchorX + deltaRight
const visualMouseLogicalY = canonicalAnchorY + rect.h / 2
const clientPos = logicalToClient(visualMouseLogicalX, visualMouseLogicalY, canvas.getBoundingClientRect())
;(globalThis as any).window.dispatchEvent(
new SyntheticMouseEvent('mousemove', {
clientX: clientPos.x,
clientY: clientPos.y,
}),
)
expect(hud.inventory.hoveredItem).not.toBeNull()
expect(hud.inventory.hoveredItem?.item.id).toBe('eq-helm-tooltip')
expect(hud.inventory.hoveredItem?.x).toBeCloseTo(expectedVisualAnchorX, 2)
expect(hud.inventory.hoveredItem?.y).toBeCloseTo(canonicalAnchorY, 2)
})
})
}
})
})

View File

@ -0,0 +1,328 @@
import { describe, it, expect } from 'vitest'
import {
CLASS_EXCLUSIVE_SETS,
STARTER_STASH_ITEMS,
CLASS_STARTER_ATTRS,
CLASS_STARTER_HARD_POINTS,
CLASS_DEFAULT_SKILLS,
createStarterProfileForClass,
} from '../src/game/class-starter-profiles.ts'
import { HudManager } from '../src/ui/hud-manager.ts'
import { SkillTreePanel, CLASS_VERTICAL_TABS, CLASS_SKILL_TREES } from '../src/ui/skill-tree-panel.ts'
import { resolveItemSpriteRect, type EquipSlotId } from '../src/ui/inventory.ts'
import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts'
import type { CharacterClassCode } from '../src/game/classes.ts'
describe('Issue #379: 7-Class Starter Profiles, Exclusive Sets, Stash Treasures & Class Switching', () => {
const CLASSES: readonly CharacterClassCode[] = ['ama', 'sor', 'nec', 'pal', 'bar', 'dru', 'ass']
const itemRects = BAKED_UI_MANIFEST.itemRects
describe('1. Class Exclusive Signature Sets (Body ONLY)', () => {
it('defines authentic signature sets for all 7 classes with exclusive body slots', () => {
// Amazon: M'avina's Battle Hymn (helm, armor, weapon1, gloves, belt)
const ama = CLASS_EXCLUSIVE_SETS.ama
expect(ama.helm?.name).toContain("M'avina's True Sight")
expect(ama.armor?.name).toContain("M'avina's Embrace")
expect(ama.weapon1?.name).toContain("M'avina's Caster")
expect(ama.gloves?.name).toContain("M'avina's Icy Clutch")
expect(ama.belt?.name).toContain("M'avina's Tenet")
expect(ama.weapon2).toBeUndefined()
expect(ama.boots).toBeUndefined()
expect(ama.amulet).toBeUndefined()
expect(ama.ring1).toBeUndefined()
expect(ama.ring2).toBeUndefined()
// Sorceress: Tal Rasha's Wrappings (helm, armor, weapon1, belt, amulet)
const sor = CLASS_EXCLUSIVE_SETS.sor
expect(sor.helm?.name).toContain("Tal Rasha's Horadric Crest")
expect(sor.armor?.name).toContain("Tal Rasha's Guardianship")
expect(sor.weapon1?.name).toContain("Tal Rasha's Lidless Eye")
expect(sor.belt?.name).toContain("Tal Rasha's Fine-Spun Cloth")
expect(sor.amulet?.name).toContain("Tal Rasha's Adjudication")
expect(sor.weapon2).toBeUndefined()
expect(sor.gloves).toBeUndefined()
expect(sor.boots).toBeUndefined()
expect(sor.ring1).toBeUndefined()
expect(sor.ring2).toBeUndefined()
// Necromancer: Trang-Oul's Avatar (helm, armor, weapon2/shield, gloves, belt)
const nec = CLASS_EXCLUSIVE_SETS.nec
expect(nec.helm?.name).toContain("Trang-Oul's Guise")
expect(nec.armor?.name).toContain("Trang-Oul's Scales")
expect(nec.weapon2?.name).toContain("Trang-Oul's Wing")
expect(nec.gloves?.name).toContain("Trang-Oul's Claws")
expect(nec.belt?.name).toContain("Trang-Oul's Girth")
expect(nec.weapon1).toBeUndefined()
expect(nec.boots).toBeUndefined()
expect(nec.amulet).toBeUndefined()
expect(nec.ring1).toBeUndefined()
expect(nec.ring2).toBeUndefined()
// Paladin: Griswold's Legacy (helm, armor, weapon1, weapon2/shield)
const pal = CLASS_EXCLUSIVE_SETS.pal
expect(pal.helm?.name).toContain("Griswold's Valor")
expect(pal.armor?.name).toContain("Griswold's Heart")
expect(pal.weapon1?.name).toContain("Griswold's Redemption")
expect(pal.weapon2?.name).toContain("Griswold's Honor")
expect(pal.gloves).toBeUndefined()
expect(pal.belt).toBeUndefined()
expect(pal.boots).toBeUndefined()
expect(pal.amulet).toBeUndefined()
expect(pal.ring1).toBeUndefined()
expect(pal.ring2).toBeUndefined()
// Barbarian: Immortal King (helm, armor, weapon1, gloves, belt, boots)
const bar = CLASS_EXCLUSIVE_SETS.bar
expect(bar.helm?.name).toContain("Immortal King's Will")
expect(bar.armor?.name).toContain("Immortal King's Soul Cage")
expect(bar.weapon1?.name).toContain("Immortal King's Stone Crusher")
expect(bar.gloves?.name).toContain("Immortal King's Forge")
expect(bar.belt?.name).toContain("Immortal King's Detail")
expect(bar.boots?.name).toContain("Immortal King's Pillar")
expect(bar.weapon2).toBeUndefined()
expect(bar.amulet).toBeUndefined()
expect(bar.ring1).toBeUndefined()
expect(bar.ring2).toBeUndefined()
// Druid: Aldur's Watchtower (helm, armor, weapon1, boots)
const dru = CLASS_EXCLUSIVE_SETS.dru
expect(dru.helm?.name).toContain("Aldur's Stony Gaze")
expect(dru.armor?.name).toContain("Aldur's Deception")
expect(dru.weapon1?.name).toContain("Aldur's Rhythm")
expect(dru.boots?.name).toContain("Aldur's Advance")
expect(dru.weapon2).toBeUndefined()
expect(dru.gloves).toBeUndefined()
expect(dru.belt).toBeUndefined()
expect(dru.amulet).toBeUndefined()
expect(dru.ring1).toBeUndefined()
expect(dru.ring2).toBeUndefined()
// Assassin: Natalya's Odium (helm, armor, weapon1, boots)
const ass = CLASS_EXCLUSIVE_SETS.ass
expect(ass.helm?.name).toContain("Natalya's Totem")
expect(ass.armor?.name).toContain("Natalya's Shadow")
expect(ass.weapon1?.name).toContain("Natalya's Mark")
expect(ass.boots?.name).toContain("Natalya's Soul")
expect(ass.weapon2).toBeUndefined()
expect(ass.gloves).toBeUndefined()
expect(ass.belt).toBeUndefined()
expect(ass.amulet).toBeUndefined()
expect(ass.ring1).toBeUndefined()
expect(ass.ring2).toBeUndefined()
})
it('resolves all 33 set items across 7 classes to authentic DC6 sprite rectangles', () => {
let totalItems = 0
for (const cls of CLASSES) {
const set = CLASS_EXCLUSIVE_SETS[cls]
for (const [slot, item] of Object.entries(set)) {
if (!item) continue
totalItems++
const rect = resolveItemSpriteRect(item, itemRects)
expect(rect, `Set item ${cls} ${slot} (${item.name}) must resolve`).not.toBeNull()
expect(rect!.w).toBeGreaterThan(0)
expect(rect!.h).toBeGreaterThan(0)
}
}
expect(totalItems).toBe(33) // 5 + 5 + 5 + 4 + 6 + 4 + 4 = 33
})
})
describe('2. Stash Treasures: Runes, Gems, and Jewels', () => {
it('contains 30 authentic treasure items (12 Runes, 12 Gems/Skulls, 6 Jewels)', () => {
expect(STARTER_STASH_ITEMS.length).toBe(30)
const runes = STARTER_STASH_ITEMS.filter((gp) => gp.item.quality === 'rune')
expect(runes.length).toBe(12) // Jah, Ber, Lo, Ohm, Vex, Cham, Zod, Sur, Gul, Ist, Mal, Um
const gems = STARTER_STASH_ITEMS.filter((gp) => gp.item.quality === 'normal')
expect(gems.length).toBe(12) // 6 Perfect + 6 Flawless
const jewels = STARTER_STASH_ITEMS.filter((gp) => gp.item.code === 'jew')
expect(jewels.length).toBe(6) // 3 Facets, 2 Magic, 1 Rare
})
it('all 30 stash items fit inside 6x8 stash grid without out-of-bounds or overlapping cells', () => {
const occupied = new Set<string>()
for (const gp of STARTER_STASH_ITEMS) {
expect(gp.col).toBeGreaterThanOrEqual(0)
expect(gp.row).toBeGreaterThanOrEqual(0)
expect(gp.col + gp.item.invWidth).toBeLessThanOrEqual(6)
expect(gp.row + gp.item.invHeight).toBeLessThanOrEqual(8)
for (let c = gp.col; c < gp.col + gp.item.invWidth; c++) {
for (let r = gp.row; r < gp.row + gp.item.invHeight; r++) {
const key = `${c},${r}`
expect(occupied.has(key), `Cell (${key}) should not overlap with item ${gp.item.name}`).toBe(false)
occupied.add(key)
}
}
}
})
it('resolves all 30 stash items to authentic DC6 sprite rectangles', () => {
for (const gp of STARTER_STASH_ITEMS) {
const rect = resolveItemSpriteRect(gp.item, itemRects)
expect(rect, `Stash item (${gp.item.name}) must resolve`).not.toBeNull()
expect(rect!.w).toBeGreaterThan(0)
expect(rect!.h).toBeGreaterThan(0)
}
})
})
describe('3. Multi-Class Skill Trees & Tabs', () => {
it('provides exactly 30 authentic skills for each of the 7 classes', () => {
for (const cls of CLASSES) {
const tree = CLASS_SKILL_TREES[cls]
expect(tree, `Skill tree for ${cls} must exist`).toBeDefined()
expect(tree.length, `${cls} must have 30 skills`).toBe(30)
for (let tab = 0; tab < 3; tab++) {
const tabSkills = tree.filter((s) => s.tab === tab)
expect(tabSkills.length, `${cls} tab ${tab} must have 10 skills`).toBe(10)
}
}
})
it('defines authentic 3 vertical tabs with Chinese names for all 7 classes', () => {
for (const cls of CLASSES) {
const tabs = CLASS_VERTICAL_TABS[cls]
expect(tabs.length).toBe(3)
expect(tabs[0].y).toBe(95)
expect(tabs[1].y).toBe(195)
expect(tabs[2].y).toBe(295)
expect(tabs[0].titleZh).toBeTruthy()
expect(tabs[0].subZh).toBeTruthy()
}
})
it('SkillTreePanel dynamically switches class, tabs, and allocations', () => {
const panel = new SkillTreePanel()
expect(panel.characterClass).toBe('sor')
// Switch to Paladin
panel.setClass('pal')
expect(panel.characterClass).toBe('pal')
expect(panel.getSkillTree().length).toBe(30)
expect(panel.getVerticalTabs()[0].titleZh).toBe('作战')
// Load specific profile state
const profile = createStarterProfileForClass('bar')
panel.loadProfile(profile.classCode, profile.hardPoints, profile.unspentSkillPoints, profile.activeSkillTab)
expect(panel.characterClass).toBe('bar')
expect(panel.getHardPoints(151)).toBe(20) // Whirlwind 20
expect(panel.unspentSkillPoints).toBe(12)
})
})
describe('4. HudManager Multi-Class State Switching & Persistence', () => {
const mockCanvas = {
width: 800,
height: 600,
style: { cursor: '' },
addEventListener: () => {},
removeEventListener: () => {},
getContext: () => null,
} as unknown as HTMLCanvasElement
it('initializes with Sorceress exclusive set and stash treasures', () => {
const hud = new HudManager(mockCanvas, {
onToggleAutomap: () => {},
onWaypointTeleport: () => {},
})
hud.switchClass('sor')
expect(hud.currentClass).toBe('sor')
// Body has Tal Rasha set only
expect(hud.inventory.equipped.helm?.name).toContain("Tal Rasha's Horadric Crest")
expect(hud.inventory.equipped.armor?.name).toContain("Tal Rasha's Guardianship")
expect(hud.inventory.equipped.weapon1?.name).toContain("Tal Rasha's Lidless Eye")
expect(hud.inventory.equipped.weapon2).toBeUndefined()
expect(hud.inventory.equipped.gloves).toBeUndefined()
expect(hud.inventory.equipped.boots).toBeUndefined()
// Stash has 30 treasure items and 2.5m gold
expect(hud.worldPanels.stashItems.length).toBe(30)
expect(hud.worldPanels.stashGold).toBe(2_500_000)
})
it('seamlessly switches between classes, updating inventory, stash, skills, and attributes', () => {
const hud = new HudManager(mockCanvas, {
onToggleAutomap: () => {},
onWaypointTeleport: () => {},
})
// Switch to Paladin
hud.switchClass('pal')
expect(hud.currentClass).toBe('pal')
expect(hud.charSheet.attrs.classNameZh).toBe('圣骑士')
expect(hud.inventory.equipped.helm?.name).toContain("Griswold's Valor")
expect(hud.inventory.equipped.weapon1?.name).toContain("Griswold's Redemption")
expect(hud.inventory.equipped.weapon2?.name).toContain("Griswold's Honor")
expect(hud.inventory.equipped.gloves).toBeUndefined()
expect(hud.hotkeys.leftSkillId).toBe(96) // Sacrifice
expect(hud.hotkeys.rightSkillId).toBe(112) // Blessed Hammer
expect(hud.worldPanels.stashItems.length).toBe(30)
// Switch to Barbarian
hud.switchClass('bar')
expect(hud.currentClass).toBe('bar')
expect(hud.charSheet.attrs.classNameZh).toBe('野蛮人')
expect(hud.inventory.equipped.helm?.name).toContain("Immortal King's Will")
expect(hud.inventory.equipped.weapon1?.name).toContain("Immortal King's Stone Crusher")
expect(hud.inventory.equipped.boots?.name).toContain("Immortal King's Pillar")
expect(hud.skillTree.getHardPoints(151)).toBe(20) // Whirlwind
// Switch to Amazon
hud.switchClass('ama')
expect(hud.currentClass).toBe('ama')
expect(hud.inventory.equipped.weapon1?.name).toContain("M'avina's Caster")
// Switch to Necromancer
hud.switchClass('nec')
expect(hud.currentClass).toBe('nec')
expect(hud.inventory.equipped.weapon2?.name).toContain("Trang-Oul's Wing")
// Switch to Druid
hud.switchClass('dru')
expect(hud.currentClass).toBe('dru')
expect(hud.inventory.equipped.weapon1?.name).toContain("Aldur's Rhythm")
// Switch to Assassin
hud.switchClass('ass')
expect(hud.currentClass).toBe('ass')
expect(hud.inventory.equipped.weapon1?.name).toContain("Natalya's Mark")
})
it('preserves individual class modifications across switches', () => {
const hud = new HudManager(mockCanvas, {
onToggleAutomap: () => {},
onWaypointTeleport: () => {},
})
// Paladin: deposit gold and spend a skill point
hud.switchClass('pal')
hud.worldPanels.stashGold = 1_800_000
hud.skillTree.unspentSkillPoints = 5
// Switch to Sorceress: has her own gold & skill points
hud.switchClass('sor')
expect(hud.worldPanels.stashGold).toBe(2_500_000)
expect(hud.skillTree.unspentSkillPoints).toBe(12)
// Modify Sorceress
hud.worldPanels.stashGold = 2_000_000
hud.skillTree.unspentSkillPoints = 10
// Switch back to Paladin: modifications were preserved!
hud.switchClass('pal')
expect(hud.worldPanels.stashGold).toBe(1_800_000)
expect(hud.skillTree.unspentSkillPoints).toBe(5)
// Switch back to Sorceress: modifications were preserved!
hud.switchClass('sor')
expect(hud.worldPanels.stashGold).toBe(2_000_000)
expect(hud.skillTree.unspentSkillPoints).toBe(10)
})
})
})

View File

@ -0,0 +1,370 @@
/**
* Diablo II v1.13c Quickbar & Speedbar Parity Tests (Issue #382).
*
* Requirements:
* 1. 技能在cd中的时候显示红色不可用遮罩 (Red unavailable overlay on cooldown)
* 2. 光环技能图标黄色 (Aura skill icon yellow border & tint)
* 3. 充能类技能显示数字 (Charged skills display remaining charges number)
* 4. 被动技能不进入快捷栏 (Passive skills do not enter quickbar speedbar)
*/
import { describe, expect, it, beforeEach, vi } from 'vitest'
import {
SkillHotkeysHud,
LEFT_SKILL_BOUNDS,
RIGHT_SKILL_BOUNDS,
DEFAULT_SORCERESS_SKILLS,
PASSIVE_SKILL_IDS,
PALADIN_AURA_SKILL_IDS,
isPassiveSkill,
isAuraSkill,
getAvailableSkillsForSide,
type HotkeySkillEntry,
} from '../src/ui/hotkeys.ts'
import { D2FontRenderer } from '../src/ui/font.ts'
describe('Diablo II v1.13c Quickbar & Speedbar Authentic Mechanics (Issue #382)', () => {
let hud: SkillHotkeysHud
let font: D2FontRenderer
beforeEach(() => {
hud = new SkillHotkeysHud()
font = new D2FontRenderer()
})
describe('1. Passive Skills Filtering (被动技能不进入快捷栏)', () => {
it('identifies all 23 canonical Diablo II 1.13c passive skills', () => {
// Amazon passives
expect(isPassiveSkill(9)).toBe(true) // Critical Strike
expect(isPassiveSkill(14)).toBe(true) // Penetrate
expect(isPassiveSkill(17)).toBe(true) // Dodge
expect(isPassiveSkill(22)).toBe(true) // Avoid
expect(isPassiveSkill(27)).toBe(true) // Evade
expect(isPassiveSkill(29)).toBe(true) // Pierce
// Sorceress passives
expect(isPassiveSkill(37)).toBe(true) // Warmth
expect(isPassiveSkill(61)).toBe(true) // Fire Mastery
expect(isPassiveSkill(63)).toBe(true) // Lightning Mastery
expect(isPassiveSkill(65)).toBe(true) // Cold Mastery
// Necromancer passives
expect(isPassiveSkill(69)).toBe(true) // Skeleton Mastery
expect(isPassiveSkill(79)).toBe(true) // Golem Mastery
expect(isPassiveSkill(89)).toBe(true) // Summon Resist
// Barbarian passives
expect(isPassiveSkill(127)).toBe(true) // Sword Mastery
expect(isPassiveSkill(141)).toBe(true) // Increased Stamina
expect(isPassiveSkill(145)).toBe(true) // Iron Skin
expect(isPassiveSkill(148)).toBe(true) // Increased Speed
expect(isPassiveSkill(153)).toBe(true) // Natural Resistance
// Druid & Assassin passives
expect(isPassiveSkill(224)).toBe(true) // Lycanthropy
expect(isPassiveSkill(252)).toBe(true) // Claw Mastery
expect(isPassiveSkill(263)).toBe(true) // Weapon Block
// Active skills should return false
expect(isPassiveSkill(0)).toBe(false) // Attack
expect(isPassiveSkill(36)).toBe(false) // Fire Bolt
expect(isPassiveSkill(47)).toBe(false) // Fire Ball
expect(isPassiveSkill(54)).toBe(false) // Teleport
expect(isPassiveSkill(64)).toBe(false) // Frozen Orb
expect(isPassiveSkill(98)).toBe(false) // Might (Aura)
})
it('filters out passive skills from getAvailableSkillsForSide', () => {
const mixedSkills: HotkeySkillEntry[] = [
{ skillId: 0, name: 'Attack', nameZh: '普通攻击', level: 1, manaCost: 0, leftUsable: true, rightUsable: true },
{ skillId: 37, name: 'Warmth', nameZh: '暖气', level: 10, manaCost: 0, leftUsable: false, rightUsable: true, isPassive: true },
{ skillId: 47, name: 'Fire Ball', nameZh: '火球', level: 8, manaCost: 9, leftUsable: true, rightUsable: true },
{ skillId: 61, name: 'Fire Mastery', nameZh: '火焰强化', level: 5, manaCost: 0, leftUsable: true, rightUsable: true },
]
const leftList = getAvailableSkillsForSide('left', mixedSkills)
const rightList = getAvailableSkillsForSide('right', mixedSkills)
expect(leftList.map(s => s.skillId)).toEqual([0, 47])
expect(rightList.map(s => s.skillId)).toEqual([0, 47])
})
it('strips passive skills when setAvailableSkills is called', () => {
const skillsWithPassives: HotkeySkillEntry[] = [
{ skillId: 0, name: 'Attack', nameZh: '普通攻击', level: 1, manaCost: 0, leftUsable: true, rightUsable: true },
{ skillId: 37, name: 'Warmth', nameZh: '暖气', level: 1, manaCost: 0, leftUsable: false, rightUsable: true },
{ skillId: 47, name: 'Fire Ball', nameZh: '火球', level: 1, manaCost: 9, leftUsable: true, rightUsable: true },
{ skillId: 127, name: 'Sword Mastery', nameZh: '剑术支配', level: 1, manaCost: 0, leftUsable: true, rightUsable: true },
]
hud.setAvailableSkills(skillsWithPassives)
expect(hud.availableSkills.some(s => isPassiveSkill(s.skillId))).toBe(false)
expect(hud.availableSkills.map(s => s.skillId)).toEqual([0, 47])
// Speedbar popup should contain 0 passives
const speedbarLeft = hud.getSpeedbarSkills('left')
const speedbarRight = hud.getSpeedbarSkills('right')
expect(speedbarLeft.some(c => isPassiveSkill(c.skill.skillId))).toBe(false)
expect(speedbarRight.some(c => isPassiveSkill(c.skill.skillId))).toBe(false)
})
it('rejects assigning passive skills to left or right action slots', () => {
hud.leftSkillId = 47
hud.rightSkillId = 64
// Attempt assigning Warmth (37)
const leftAssigned = hud.assignSkill('left', 37)
expect(leftAssigned).toBe(false)
expect(hud.leftSkillId).toBe(47)
const rightAssigned = hud.assignSkill('right', 61)
expect(rightAssigned).toBe(false)
expect(hud.rightSkillId).toBe(64)
// Attempt setDualSkill
const dualAssigned = hud.setDualSkill(127)
expect(dualAssigned).toBe(false)
expect(hud.leftSkillId).toBe(47)
expect(hud.rightSkillId).toBe(64)
})
})
describe('2. Aura Skills Mechanics & Yellow Styling (光环技能图标黄色)', () => {
it('identifies all 20 Paladin Defensive and Offensive Auras', () => {
expect(PALADIN_AURA_SKILL_IDS.size).toBe(20)
// Defensive Auras
expect(isAuraSkill(99)).toBe(true) // Prayer
expect(isAuraSkill(100)).toBe(true) // Resist Fire
expect(isAuraSkill(104)).toBe(true) // Defiance
expect(isAuraSkill(105)).toBe(true) // Resist Cold
expect(isAuraSkill(109)).toBe(true) // Cleansing
expect(isAuraSkill(110)).toBe(true) // Resist Lightning
expect(isAuraSkill(115)).toBe(true) // Vigor
expect(isAuraSkill(120)).toBe(true) // Meditation
expect(isAuraSkill(124)).toBe(true) // Redemption
expect(isAuraSkill(125)).toBe(true) // Salvation
// Offensive Auras
expect(isAuraSkill(98)).toBe(true) // Might
expect(isAuraSkill(102)).toBe(true) // Holy Fire
expect(isAuraSkill(103)).toBe(true) // Thorns
expect(isAuraSkill(108)).toBe(true) // Blessed Aim
expect(isAuraSkill(113)).toBe(true) // Concentration
expect(isAuraSkill(114)).toBe(true) // Holy Freeze
expect(isAuraSkill(118)).toBe(true) // Holy Shock
expect(isAuraSkill(119)).toBe(true) // Sanctuary
expect(isAuraSkill(122)).toBe(true) // Fanaticism
expect(isAuraSkill(123)).toBe(true) // Conviction
// Non-auras
expect(isAuraSkill(96)).toBe(false) // Sacrifice (Combat)
expect(isAuraSkill(112)).toBe(false) // Blessed Hammer (Combat)
expect(isAuraSkill(47)).toBe(false) // Fire Ball
})
it('renders yellow border and yellow tint for aura skills', () => {
const fillRectCalls: { x: number; y: number; w: number; h: number; style: string }[] = []
const strokeRectCalls: { x: number; y: number; w: number; h: number; style: string }[] = []
const mockCtx = {
save: vi.fn(),
restore: vi.fn(),
drawImage: vi.fn(),
fillRect: vi.fn(function (this: any, x, y, w, h) {
fillRectCalls.push({ x, y, w, h, style: String(mockCtx.fillStyle) })
}),
strokeRect: vi.fn(function (this: any, x, y, w, h) {
strokeRectCalls.push({ x, y, w, h, style: String(mockCtx.strokeStyle) })
}),
fillText: vi.fn(),
measureText: vi.fn(() => ({ width: 40 })),
fillStyle: '#000',
strokeStyle: '#000',
lineWidth: 1,
} as unknown as CanvasRenderingContext2D
hud.rightSkillId = 98 // Might (Aura)
hud.draw(mockCtx, null, font)
// Check yellow aura tint over right skill icon (x=635)
const yellowTint = fillRectCalls.find(
c => c.x === RIGHT_SKILL_BOUNDS.x + 2 && c.style.includes('rgba(255, 215, 0')
)
expect(yellowTint).toBeDefined()
// Check yellow border (#ffd700) around right skill button
const yellowBorder = strokeRectCalls.find(
c => c.x === RIGHT_SKILL_BOUNDS.x + 0.5 && c.style === '#ffd700'
)
expect(yellowBorder).toBeDefined()
})
it('includes aura narrative tag in tooltip', () => {
hud.setAvailableSkills([
{ skillId: 98, name: 'Might', nameZh: '力量灵气', level: 5, manaCost: 0, leftUsable: false, rightUsable: true, isAura: true },
])
const lines = hud.formatTooltipLines(98, 'right', false)
const auraTag = lines.find(l => l.text.includes('灵气技能') && l.color === 'yellow')
expect(auraTag).toBeDefined()
})
})
describe('3. Skill Cooldown (CD) Red Unavailable Mask (技能在cd中的时候显示红色不可用遮罩)', () => {
it('manages skill cooldown durations accurately', () => {
const now = 10000
expect(hud.isSkillOnCooldown(56, now)).toBe(false) // Meteor
hud.setSkillCooldown(56, 1200, now) // 1.2s cooldown
expect(hud.isSkillOnCooldown(56, now + 500)).toBe(true)
expect(hud.getSkillCooldownRemaining(56, now + 500)).toBe(700)
// After cooldown expires
expect(hud.isSkillOnCooldown(56, now + 1200)).toBe(false)
expect(hud.getSkillCooldownRemaining(56, now + 1200)).toBe(0)
})
it('renders red unavailable overlay (rgba(220, 20, 20, 0.45)) when skill is on cooldown', () => {
const fillRectCalls: { x: number; y: number; w: number; h: number; style: string }[] = []
const mockCtx = {
save: vi.fn(),
restore: vi.fn(),
drawImage: vi.fn(),
fillRect: vi.fn(function (this: any, x, y, w, h) {
fillRectCalls.push({ x, y, w, h, style: String(mockCtx.fillStyle) })
}),
strokeRect: vi.fn(),
fillText: vi.fn(),
measureText: vi.fn(() => ({ width: 40 })),
fillStyle: '#000',
strokeStyle: '#000',
lineWidth: 1,
} as unknown as CanvasRenderingContext2D
hud.leftSkillId = 47 // Fire Ball
hud.rightSkillId = 56 // Meteor
const now = 20000
// Put Meteor on cooldown
hud.setSkillCooldown(56, 1200, now)
// Draw at now + 300ms (still on CD)
hud.draw(mockCtx, null, font, true, now + 300)
// Right slot (Meteor) must have red mask
const meteorRedMask = fillRectCalls.find(
c => c.x === RIGHT_SKILL_BOUNDS.x + 1 && c.style.includes('rgba(220, 20, 20')
)
expect(meteorRedMask).toBeDefined()
// Left slot (Fire Ball, not on CD) must NOT have red mask
const fireBallRedMask = fillRectCalls.find(
c => c.x === LEFT_SKILL_BOUNDS.x + 1 && c.style.includes('rgba(220, 20, 20')
)
expect(fireBallRedMask).toBeUndefined()
})
it('clears cooldown and removes red mask', () => {
hud.setSkillCooldown(56, 2000, 1000)
expect(hud.isSkillOnCooldown(56, 1500)).toBe(true)
hud.clearSkillCooldown(56)
expect(hud.isSkillOnCooldown(56, 1500)).toBe(false)
})
it('formats remaining cooldown in tooltip', () => {
hud.setSkillCooldown(56, 1500, 5000)
const lines = hud.formatTooltipLines(56, 'right', false)
const cdLine = lines.find(l => l.text.includes('冷却中') && l.color === 'red')
expect(cdLine).toBeDefined()
})
})
describe('4. Charged Skills Number Display (充能类技能显示数字)', () => {
it('sets and retrieves skill charges and max charges', () => {
hud.setSkillCharges(54, 20, 25) // Teleport charges 20/25
expect(hud.getSkillCharges(54)).toBe(20)
expect(hud.getSkillMaxCharges(54)).toBe(25)
hud.setSkillCharges(54, 0, 25) // depleted
expect(hud.getSkillCharges(54)).toBe(0)
})
it('draws remaining charges count on the skill icon', () => {
const drawTextCalls: { text: string; x: number; y: number; color?: string | undefined }[] = []
vi.spyOn(font, 'drawText').mockImplementation((ctx, text, x, y, opts) => {
drawTextCalls.push({ text: String(text), x, y, color: opts?.color })
return 20
})
const mockCtx = {
save: vi.fn(),
restore: vi.fn(),
drawImage: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn(),
fillText: vi.fn(),
measureText: vi.fn(() => ({ width: 40 })),
fillStyle: '#000',
strokeStyle: '#000',
lineWidth: 1,
} as unknown as CanvasRenderingContext2D
hud.rightSkillId = 54 // Teleport
hud.setSkillCharges(54, 18, 20)
hud.draw(mockCtx, null, font)
// Charge number '18' drawn at bottom-left corner of right slot (x = 635 + 4 = 639)
const chargeTextCall = drawTextCalls.find(
c => c.text === '18' && c.x === RIGHT_SKILL_BOUNDS.x + 4 && c.y === RIGHT_SKILL_BOUNDS.y + 44
)
expect(chargeTextCall).toBeDefined()
expect(chargeTextCall!.color).toBe('white')
})
it('renders red unavailable mask when charges reach 0', () => {
const fillRectCalls: { x: number; y: number; w: number; h: number; style: string }[] = []
const mockCtx = {
save: vi.fn(),
restore: vi.fn(),
drawImage: vi.fn(),
fillRect: vi.fn(function (this: any, x, y, w, h) {
fillRectCalls.push({ x, y, w, h, style: String(mockCtx.fillStyle) })
}),
strokeRect: vi.fn(),
fillText: vi.fn(),
measureText: vi.fn(() => ({ width: 40 })),
fillStyle: '#000',
strokeStyle: '#000',
lineWidth: 1,
} as unknown as CanvasRenderingContext2D
hud.rightSkillId = 54
hud.setSkillCharges(54, 0, 20) // 0 charges remaining
hud.draw(mockCtx, null, font)
const redMask = fillRectCalls.find(
c => c.x === RIGHT_SKILL_BOUNDS.x + 1 && c.style.includes('rgba(220, 20, 20')
)
expect(redMask).toBeDefined()
})
it('formats charge status in tooltip', () => {
hud.setSkillCharges(54, 15, 20)
const lines = hud.formatTooltipLines(54, 'right', false)
const chargeLine = lines.find(l => l.text.includes('剩余充能: 15 / 20'))
expect(chargeLine).toBeDefined()
expect(chargeLine!.color).toBe('blue')
hud.setSkillCharges(54, 0, 20)
const depletedLines = hud.formatTooltipLines(54, 'right', false)
const depletedLine = depletedLines.find(l => l.text.includes('剩余充能: 0 / 20'))
expect(depletedLine).toBeDefined()
expect(depletedLine!.color).toBe('red')
})
})
})

257
tests/hotkeys.test.ts Normal file
View File

@ -0,0 +1,257 @@
/**
* Tests for SkillHotkeysHud, speedbars, keybindings, and Attack slot binding (Issue #151).
*/
import { describe, expect, it, beforeEach } from 'vitest'
import {
SkillHotkeysHud,
LEFT_SKILL_BOUNDS,
RIGHT_SKILL_BOUNDS,
DEFAULT_SORCERESS_SKILLS,
resolveSkillIcon,
getAvailableSkillsForSide,
} from '../src/ui/hotkeys.ts'
describe('SkillHotkeysHud & Dual Slot Mechanics', () => {
let hud: SkillHotkeysHud
beforeEach(() => {
hud = new SkillHotkeysHud()
})
it('initializes with expected default skills and state', () => {
expect(hud.leftSkillId).toBe(47) // Fire Ball
expect(hud.rightSkillId).toBe(64) // Frozen Orb
expect(hud.selectionOpen).toBeNull()
expect(hud.availableSkills.length).toBeGreaterThanOrEqual(10)
// Skill 0 is available
const attack = hud.availableSkills.find((s) => s.skillId === 0)
expect(attack).toBeDefined()
expect(attack!.leftUsable).toBe(true)
expect(attack!.rightUsable).toBe(true)
})
it('opens and closes left selection speedbar on click', () => {
// Click center of left skill icon
const leftX = LEFT_SKILL_BOUNDS.x + 24
const leftY = LEFT_SKILL_BOUNDS.y + 24
const handled = hud.handleClick(leftX, leftY)
expect(handled).toBe(true)
expect(hud.selectionOpen).toBe('left')
// Click again to close
hud.handleClick(leftX, leftY)
expect(hud.selectionOpen).toBeNull()
})
it('opens and closes right selection speedbar on click', () => {
// Click center of right skill icon
const rightX = RIGHT_SKILL_BOUNDS.x + 24
const rightY = RIGHT_SKILL_BOUNDS.y + 24
const handled = hud.handleClick(rightX, rightY)
expect(handled).toBe(true)
expect(hud.selectionOpen).toBe('right')
// Click outside to close
hud.handleClick(100, 100)
expect(hud.selectionOpen).toBeNull()
})
it('allows selecting Normal Attack from left speedbar palette', () => {
hud.openSpeedbar('left')
expect(hud.selectionOpen).toBe('left')
// Find Attack in speedbar layout
const speedbarSkills = hud.getSpeedbarSkills('left')
const attackEntry = speedbarSkills.find((e) => e.skill.skillId === 0)
expect(attackEntry).toBeDefined()
// Click on Attack icon in speedbar
const clicked = hud.handleClick(attackEntry!.x + 10, attackEntry!.y + 10)
expect(clicked).toBe(true)
expect(hud.leftSkillId).toBe(0)
expect(hud.selectionOpen).toBeNull() // closed after selection
})
it('allows selecting Normal Attack from right speedbar palette', () => {
hud.openSpeedbar('right')
expect(hud.selectionOpen).toBe('right')
const speedbarSkills = hud.getSpeedbarSkills('right')
const attackEntry = speedbarSkills.find((e) => e.skill.skillId === 0)
expect(attackEntry).toBeDefined()
const clicked = hud.handleClick(attackEntry!.x + 10, attackEntry!.y + 10)
expect(clicked).toBe(true)
expect(hud.rightSkillId).toBe(0)
expect(hud.selectionOpen).toBeNull()
})
it('supports dual slot binding with Attack on both slots', () => {
hud.assignSkill('left', 0)
hud.assignSkill('right', 0)
expect(hud.leftSkillId).toBe(0)
expect(hud.rightSkillId).toBe(0)
// Verify resolveSkillIcon works for both slots
const leftIcon = resolveSkillIcon(hud.leftSkillId)
const rightIcon = resolveSkillIcon(hud.rightSkillId)
expect(leftIcon.skillId).toBe(0)
expect(rightIcon.skillId).toBe(0)
expect(leftIcon.name).toBe('Attack')
expect(rightIcon.name).toBe('Attack')
expect(leftIcon.iconPath).toBe('/skills/icon_0.png')
expect(rightIcon.iconPath).toBe('/skills/icon_0.png')
})
it('manages F1-F8 hotkey bindings and key presses', () => {
// Bind F1 to Attack (0)
hud.setHotkey('F1', 0)
expect(hud.getHotkey('F1')).toBe(0)
// Bind F2 to Frozen Orb (64)
hud.setHotkey('F2', 64)
expect(hud.getHotkey('F2')).toBe(64)
// Pressing F1 assigns right skill to 0
const handledF1 = hud.handleKeyDown('F1')
expect(handledF1).toBe(true)
expect(hud.rightSkillId).toBe(0)
// Pressing F2 assigns right skill to 64
const handledF2 = hud.handleKeyDown('F2')
expect(handledF2).toBe(true)
expect(hud.rightSkillId).toBe(64)
})
it('handles mouse hover over skill buttons and speedbar icons', () => {
hud.handleMouseMove(LEFT_SKILL_BOUNDS.x + 10, LEFT_SKILL_BOUNDS.y + 10)
expect(hud.hoverSkillId).toBe(hud.leftSkillId)
hud.handleMouseMove(RIGHT_SKILL_BOUNDS.x + 10, RIGHT_SKILL_BOUNDS.y + 10)
expect(hud.hoverSkillId).toBe(hud.rightSkillId)
hud.openSpeedbar('left')
const speedbarSkills = hud.getSpeedbarSkills('left')
const attackEntry = speedbarSkills.find((e) => e.skill.skillId === 0)
expect(attackEntry).toBeDefined()
hud.handleMouseMove(attackEntry!.x + 10, attackEntry!.y + 10)
expect(hud.hoverSpeedbarSkillId).toBe(0)
})
it('includes Throw and Unsummon as default universal skills with correct usability (Issue #376)', () => {
const throwSkill = hud.availableSkills.find((s) => s.skillId === 2)
const unsummonSkill = hud.availableSkills.find((s) => s.skillId === 3)
expect(throwSkill).toBeDefined()
expect(throwSkill!.name).toBe('Throw')
expect(throwSkill!.nameZh).toBe('投掷')
expect(throwSkill!.leftUsable).toBe(true)
expect(throwSkill!.rightUsable).toBe(true)
expect(unsummonSkill).toBeDefined()
expect(unsummonSkill!.name).toBe('Unsummon')
expect(unsummonSkill!.nameZh).toBe('取消召唤')
expect(unsummonSkill!.leftUsable).toBe(false)
expect(unsummonSkill!.rightUsable).toBe(true)
})
it('resolves authentic D2 icons: sword with weapon, fist unarmed, throw and unsummon (Issue #376)', () => {
// Attack with weapon: sword (frame 2)
const swordIcon = resolveSkillIcon(0, true)
expect(swordIcon.iconPath).toBe('/skills/icon_0.png')
expect(swordIcon.frameIndex).toBe(2)
expect(swordIcon.atlasRect.x).toBe(96)
// Attack without weapon: fist (frame 0)
const fistIcon = resolveSkillIcon(0, false)
expect(fistIcon.iconPath).toBe('/skills/icon_0_fist.png')
expect(fistIcon.frameIndex).toBe(0)
expect(fistIcon.atlasRect.x).toBe(0)
// Throw (skill 2): frame 6
const throwIcon = resolveSkillIcon(2)
expect(throwIcon.iconPath).toBe('/skills/icon_2.png')
expect(throwIcon.frameIndex).toBe(6)
expect(throwIcon.atlasRect.x).toBe(288)
// Unsummon (skill 3): frame 4
const unsummonIcon = resolveSkillIcon(3)
expect(unsummonIcon.iconPath).toBe('/skills/icon_3.png')
expect(unsummonIcon.frameIndex).toBe(4)
expect(unsummonIcon.atlasRect.x).toBe(192)
})
it('renders sword when weapon equipped and fist when unarmed (Issue #376)', () => {
const drawImageCalls: any[] = []
const ctx = {
fillRect: () => {},
strokeRect: () => {},
drawImage: (...args: any[]) => {
drawImageCalls.push(args)
},
} as unknown as CanvasRenderingContext2D
const mockAtlas = {
complete: true,
naturalWidth: 1152,
naturalHeight: 48,
} as unknown as HTMLImageElement
const mockFont = { drawText: () => {} } as any
hud.leftSkillId = 0
// 1. Armed attack: should draw sword (sx = 96, frame 2)
drawImageCalls.length = 0
hud.draw(ctx, mockAtlas, mockFont, true)
const armedDraw = drawImageCalls.find((c) => c[0] === mockAtlas && c[1] === 96)
expect(armedDraw).toBeDefined()
// 2. Unarmed attack: should draw fist (sx = 0, frame 0)
drawImageCalls.length = 0
hud.draw(ctx, mockAtlas, mockFont, false)
const unarmedDraw = drawImageCalls.find((c) => c[0] === mockAtlas && c[1] === 0)
expect(unarmedDraw).toBeDefined()
// 3. Throw (skill 2): should draw throw (sx = 288, frame 6)
hud.leftSkillId = 2
drawImageCalls.length = 0
hud.draw(ctx, mockAtlas, mockFont, true)
const throwDraw = drawImageCalls.find((c) => c[0] === mockAtlas && c[1] === 288)
expect(throwDraw).toBeDefined()
// 4. Unsummon (skill 3): should draw unsummon (sx = 192, frame 4)
hud.rightSkillId = 3
drawImageCalls.length = 0
hud.draw(ctx, mockAtlas, mockFont, true)
const unsummonDraw = drawImageCalls.find((c) => c[0] === mockAtlas && c[1] === 192)
expect(unsummonDraw).toBeDefined()
})
it('allows binding Throw to left/right slot and Unsummon to right slot only (Issue #376)', () => {
// Left speedbar should include Throw but NOT Unsummon
const leftSkills = hud.getSpeedbarSkills('left')
expect(leftSkills.some((e) => e.skill.skillId === 2)).toBe(true)
expect(leftSkills.some((e) => e.skill.skillId === 3)).toBe(false)
// Right speedbar should include both Throw and Unsummon
const rightSkills = hud.getSpeedbarSkills('right')
expect(rightSkills.some((e) => e.skill.skillId === 2)).toBe(true)
expect(rightSkills.some((e) => e.skill.skillId === 3)).toBe(true)
// Assign Throw to left
hud.assignSkill('left', 2)
expect(hud.leftSkillId).toBe(2)
// Assign Unsummon to right
hud.assignSkill('right', 3)
expect(hud.rightSkillId).toBe(3)
})
})

View File

@ -302,5 +302,75 @@ describe('Milestone M4 (Issue #149): Base item invfile audit & charm icons', ()
}
}
})
describe('Issue #375: ItemTypes.txt VarInvGfx variants for rings, amulets, jewels, and charms', () => {
it('bakes all ring (5), amulet (3), and jewel (6) graphic variants into itemRects', () => {
// Rings: invrin1 to invrin5
for (let i = 1; i <= 5; i++) {
const key = `invrin${i}`
expect(itemRects[key], `Expected ${key} in itemRects`).toBeDefined()
expect(itemRects[key]!.w).toBe(28)
expect(itemRects[key]!.h).toBe(28)
}
// Amulets: invamu1 to invamu3
for (let i = 1; i <= 3; i++) {
const key = `invamu${i}`
expect(itemRects[key], `Expected ${key} in itemRects`).toBeDefined()
expect(itemRects[key]!.w).toBe(28)
expect(itemRects[key]!.h).toBe(28)
}
// Jewels: invjw1 to invjw6
for (let i = 1; i <= 6; i++) {
const key = `invjw${i}`
expect(itemRects[key], `Expected ${key} in itemRects`).toBeDefined()
expect(itemRects[key]!.w).toBe(28)
expect(itemRects[key]!.h).toBe(28)
}
})
it('resolves all ring variants 0..4 correctly', () => {
for (let v = 0; v < 5; v++) {
const rect = resolveItemSpriteRect({ code: 'rin', variant: v }, itemRects)
expect(rect).toEqual(itemRects[`invrin${v + 1}`])
}
// Direct invFile
expect(resolveItemSpriteRect({ code: 'rin', invFile: 'invrin3' }, itemRects)).toEqual(itemRects['invrin3'])
})
it('resolves all amulet variants 0..2 correctly', () => {
for (let v = 0; v < 3; v++) {
const rect = resolveItemSpriteRect({ code: 'amu', variant: v }, itemRects)
expect(rect).toEqual(itemRects[`invamu${v + 1}`])
}
// Direct invFile
expect(resolveItemSpriteRect({ code: 'amu', invFile: 'invamu2' }, itemRects)).toEqual(itemRects['invamu2'])
})
it('resolves all jewel variants 0..5 correctly', () => {
for (let v = 0; v < 6; v++) {
const rect = resolveItemSpriteRect({ code: 'jew', variant: v }, itemRects)
expect(rect).toEqual(itemRects[`invjw${v + 1}`])
}
// Direct invFile
expect(resolveItemSpriteRect({ code: 'jew', invFile: 'invjw5' }, itemRects)).toEqual(itemRects['invjw5'])
})
it('resolves charm variants when variant property is specified', () => {
expect(resolveItemSpriteRect({ code: 'cm1', variant: 0 }, itemRects)).toEqual(itemRects['invch1'])
expect(resolveItemSpriteRect({ code: 'cm1', variant: 1 }, itemRects)).toEqual(itemRects['invch4'])
expect(resolveItemSpriteRect({ code: 'cm1', variant: 2 }, itemRects)).toEqual(itemRects['invch7'])
expect(resolveItemSpriteRect({ code: 'cm2', variant: 0 }, itemRects)).toEqual(itemRects['invch2'])
expect(resolveItemSpriteRect({ code: 'cm2', variant: 1 }, itemRects)).toEqual(itemRects['invch5'])
expect(resolveItemSpriteRect({ code: 'cm2', variant: 2 }, itemRects)).toEqual(itemRects['invch8'])
expect(resolveItemSpriteRect({ code: 'cm3', variant: 0 }, itemRects)).toEqual(itemRects['invch3'])
expect(resolveItemSpriteRect({ code: 'cm3', variant: 1 }, itemRects)).toEqual(itemRects['invch6'])
expect(resolveItemSpriteRect({ code: 'cm3', variant: 2 }, itemRects)).toEqual(itemRects['invch9'])
})
})
})
})

229
tests/quest-tbl.test.ts Normal file
View File

@ -0,0 +1,229 @@
/**
* Diablo II v1.13c Quest TBL Parity & Dynamic Resolution Tests (Issue #381).
*
* Verifies:
* 1. All 27 canonical Diablo II quests across Acts 1..5 match Blizzard's
* string.tbl / expansionstring.tbl / patchstring.tbl keys and metadata.
* 2. WorldPanelsHud dynamic TBL lookup and string table chain resolution.
* 3. Fallback resolution when TBL archives are not mounted.
* 4. Multi-stage objective directives and completion speech rendering.
* 5. Quest parchment layout bounds and text wrapping within 1.13c UI sockets.
*/
import { describe, expect, it, vi } from 'vitest'
import {
CANONICAL_QUEST_DATA,
CANONICAL_QUEST_BY_ID,
CANONICAL_QUESTS_BY_ACT,
} from '../src/data/canonical-quest-data.ts'
import {
WorldPanelsHud,
ACT_QUESTS,
ALL_QUESTS,
type QuestEntry,
} from '../src/ui/world-panels.ts'
import { D2FontRenderer } from '../src/ui/font.ts'
describe('Diablo II v1.13c Quest TBL Ground Truth (Issue #381)', () => {
it('contains exactly 27 canonical quests with authentic Blizzard 1.13c TBL keys', () => {
expect(CANONICAL_QUEST_DATA.length).toBe(27)
expect(ALL_QUESTS.length).toBe(27)
// Act 1: 6, Act 2: 6, Act 3: 6, Act 4: 3, Act 5: 6
expect(CANONICAL_QUESTS_BY_ACT[1]?.length).toBe(6)
expect(CANONICAL_QUESTS_BY_ACT[2]?.length).toBe(6)
expect(CANONICAL_QUESTS_BY_ACT[3]?.length).toBe(6)
expect(CANONICAL_QUESTS_BY_ACT[4]?.length).toBe(3)
expect(CANONICAL_QUESTS_BY_ACT[5]?.length).toBe(6)
// Verify Blizzard 1.13c socket key ordering parity
// Act 1: Cain is socket 2 (a1q3) -> qstsa1q4; Tools of Trade is socket 4 (a1q5) -> qstsa1q3
const a1q1 = CANONICAL_QUEST_BY_ID['a1q1']!
expect(a1q1.titleKey).toBe('qstsa1q1')
expect(a1q1.objectiveKey).toBe('qstsa1q11')
expect(a1q1.completedKey).toBe('A1Q1SuccessfulAkara')
expect(a1q1.titleEn).toBe('Den of Evil')
expect(a1q1.titleZh).toBe('邪惡洞穴')
const a1q3 = CANONICAL_QUEST_BY_ID['a1q3']!
expect(a1q3.titleKey).toBe('qstsa1q4') // Blizzard swapped qstsa1q3 and qstsa1q4
expect(a1q3.titleEn).toBe('The Search for Cain')
const a1q5 = CANONICAL_QUEST_BY_ID['a1q5']!
expect(a1q5.titleKey).toBe('qstsa1q3') // Tools of the Trade
expect(a1q5.titleEn).toBe('Tools of the Trade')
// Act 3: Golden Bird (a3q1) -> qstsa3q4; Blade (a3q2) -> qstsa3q3; Khalim (a3q3) -> qstsa3q2; Lam Esen (a3q4) -> qstsa3q1
const a3q1 = CANONICAL_QUEST_BY_ID['a3q1']!
expect(a3q1.titleKey).toBe('qstsa3q4')
expect(a3q1.titleEn).toBe('The Golden Bird')
const a3q4 = CANONICAL_QUEST_BY_ID['a3q4']!
expect(a3q4.titleKey).toBe('qstsa3q1')
expect(a3q4.titleEn).toBe("Lam Esen's Tome")
// Act 4: Fallen Angel -> qstsa4q1, Hell's Forge -> qstsa4q3, Terror's End -> qstsa4q2
const a4q1 = CANONICAL_QUEST_BY_ID['a4q1']!
expect(a4q1.titleKey).toBe('qstsa4q1')
expect(a4q1.titleEn).toBe('The Fallen Angel')
const a4q2 = CANONICAL_QUEST_BY_ID['a4q2']!
expect(a4q2.titleKey).toBe('qstsa4q3')
expect(a4q2.titleEn).toBe("Hell's Forge")
const a4q3 = CANONICAL_QUEST_BY_ID['a4q3']!
expect(a4q3.titleKey).toBe('qstsa4q2')
expect(a4q3.titleEn).toBe("Terror's End")
// Act 5: Siege (a5q1) -> qstsa5q1, Rescue (a5q2) -> qstsa5q2, Prison (a5q3) -> qstsa5q3,
// Betrayal (a5q4) -> qstsa5q4, Rite (a5q5) -> qstsa5q5, Eve of Destruction (a5q6) -> qstsa5q6
const a5q6 = CANONICAL_QUEST_BY_ID['a5q6']!
expect(a5q6.titleKey).toBe('qstsa5q6')
expect(a5q6.titleEn).toBe('Eve of Destruction')
})
it('provides multi-stage objective progression data for all 27 quests', () => {
for (const q of CANONICAL_QUEST_DATA) {
expect(q.stages.length).toBeGreaterThan(0)
for (const stage of q.stages) {
expect(stage.key).toBeTruthy()
expect(stage.stage).toBeGreaterThan(0)
expect(stage.textEn.length).toBeGreaterThan(0)
}
}
})
it('resolves quest display text using default fallback when no TBL is provided', () => {
const hud = new WorldPanelsHud()
const a1q1 = ACT_QUESTS[1]![0]!
expect(a1q1.id).toBe('a1q1')
// Default status is completed for a1q1
const displayCompleted = hud.getQuestDisplayText(a1q1)
expect(displayCompleted.status).toBe('completed')
expect(displayCompleted.statusTag).toBe('[已完成]')
expect(displayCompleted.title).toBe('邪惡洞穴')
expect(displayCompleted.titleKey).toBe('qstsa1q1')
expect(displayCompleted.body).toContain('清除')
expect(displayCompleted.rewardText).toContain('技能点')
// Toggle status to active
hud.setQuestStatus('a1q1', 'active')
const displayActive = hud.getQuestDisplayText(a1q1)
expect(displayActive.status).toBe('active')
expect(displayActive.statusTag).toBe('[进行中]')
expect(displayActive.body).toContain('找尋一個洞穴')
// Toggle status to locked
hud.setQuestStatus('a1q1', 'locked')
const displayLocked = hud.getQuestDisplayText(a1q1)
expect(displayLocked.status).toBe('locked')
expect(displayLocked.statusTag).toBe('[未开启]')
expect(displayLocked.body).toContain('尚未开启')
})
it('dynamically resolves strings via setQuestTblLookup', () => {
const hud = new WorldPanelsHud()
const customTbl = new Map<string, string>([
['qstsa1q1', '洞穴肃清行动 (Custom TBL)'],
['qstsa1q11', '前往鲜血荒地消灭全部恶魔。'],
['A1Q1SuccessfulAkara', '感谢你,阿卡拉赐予你技能。'],
])
hud.setQuestTblLookup(key => customTbl.get(key))
const a1q1 = ACT_QUESTS[1]![0]!
hud.setQuestStatus('a1q1', 'active')
const display = hud.getQuestDisplayText(a1q1)
expect(display.title).toBe('洞穴肃清行动 (Custom TBL)')
expect(display.body).toBe('前往鲜血荒地消灭全部恶魔。')
hud.setQuestStatus('a1q1', 'completed')
const completedDisplay = hud.getQuestDisplayText(a1q1)
expect(completedDisplay.body).toBe('感谢你,阿卡拉赐予你技能。')
})
it('dynamically resolves strings via setQuestStringTableChain', () => {
const hud = new WorldPanelsHud()
const fakeChain = {
get(k: string) {
if (k === 'qstsa2q1') return "Radament's Hideout"
if (k === 'qstsa2q11') return 'Search the Sewers beneath Lut Gholein.'
return undefined
},
}
hud.setQuestStringTableChain(fakeChain)
const a2q1 = ACT_QUESTS[2]![0]!
hud.setQuestStatus('a2q1', 'active')
const display = hud.getQuestDisplayText(a2q1)
expect(display.title).toBe("Radament's Hideout")
expect(display.body).toBe('Search the Sewers beneath Lut Gholein.')
})
it('renders the quest log parchment without overlapping bottom bezels', () => {
const hud = new WorldPanelsHud()
const font = new D2FontRenderer()
const drawCalls: { text: string; x: number; y: number }[] = []
const originalDrawText = font.drawText.bind(font)
vi.spyOn(font, 'drawText').mockImplementation((ctx, text, x, y, opts) => {
drawCalls.push({ text: String(text), x, y })
return originalDrawText(ctx, text, x, y, opts)
})
const mockCtx = {
save: vi.fn(),
restore: vi.fn(),
drawImage: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn(),
beginPath: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
stroke: vi.fn(),
fill: vi.fn(),
fillText: vi.fn(),
measureText: vi.fn(() => ({ width: 100 })),
fillStyle: '#000',
strokeStyle: '#000',
lineWidth: 1,
globalAlpha: 1,
} as unknown as CanvasRenderingContext2D
hud.selectedActTab = 1
hud.selectedQuestIdx = 0
// Render Quest Log Panel
hud.drawLeftDockPanel(
mockCtx,
'quest',
{
borderLeftImg: null,
questBgImg: null,
waypointBgImg: null,
stashBgImg: null,
cubeBgImg: null,
vendorBgImg: null,
buySellBtnImg: null,
},
font,
)
// Parchment title should be drawn around oy + 282 (60 + 282 = 342)
const titleCall = drawCalls.find(c => c.text.includes('(Den of Evil)'))
expect(titleCall).toBeDefined()
expect(titleCall!.y).toBe(60 + 282)
// Body should start around oy + 300 (60 + 300 = 360)
const bodyCall = drawCalls.find(c => c.y >= 60 + 300 && c.y <= 60 + 380)
expect(bodyCall).toBeDefined()
// No text should be drawn below oy + 380 into the close button zone
const outOfBoundsCall = drawCalls.find(c => c.y > 60 + 380 && c.text.includes('洞穴'))
expect(outOfBoundsCall).toBeUndefined()
})
})

View File

@ -268,7 +268,100 @@ describe('Diablo II Skill Calculator Engine (Issue #26)', () => {
expect(vm?.prereqs[0].name).toBe('Fire Bolt')
expect(vm?.prereqs[0].met).toBe(false)
expect(vm?.current.damage.hasDamage).toBe(true)
// For slvl === 0 (unlearned), nextLevel must be null while main body previews Lv 1
expect(vm?.isLearned).toBe(false)
expect(vm?.current.level).toBe(1)
expect(vm?.nextLevel).toBeNull()
})
it('previews nextLevel when learned (slvl >= 1) and hardPoints < 20', () => {
allocatePoint(state, 36, 1) // Fire Bolt prerequisite
allocatePoint(state, 47, 5) // Fire Ball lvl 5
state.allSkillsSoftPoints = 2 // +2 from gear -> effLevel 7
const vm = buildTooltipViewModel(state, 47, 99)
expect(vm).not.toBeNull()
expect(vm?.isLearned).toBe(true)
expect(vm?.hardPoints).toBe(5)
expect(vm?.effectiveLevel).toBe(7)
expect(vm?.current.level).toBe(7)
expect(vm?.nextLevel).not.toBeNull()
expect(vm?.nextLevel?.level).toBe(8)
expect(vm?.nextLevel?.damage.finalMin).toBeGreaterThan(vm!.current.damage.finalMin)
})
it('omits nextLevel preview when skill has reached maximum base allocation (hardPoints === 20)', () => {
allocatePoint(state, 36, 1)
allocatePoint(state, 47, 20) // Max 20 hard points
state.allSkillsSoftPoints = 5 // +5 gear -> effLevel 25
const vm = buildTooltipViewModel(state, 47, 99)
expect(vm).not.toBeNull()
expect(vm?.isLearned).toBe(true)
expect(vm?.hardPoints).toBe(20)
expect(vm?.effectiveLevel).toBe(25)
expect(vm?.current.level).toBe(25)
expect(vm?.nextLevel).toBeNull()
})
it('evaluates authentic 1.13c Attack Rating (ToHit) scaling for combat skills', () => {
// Jab (10): ToHit = 10, LevToHit = 9 -> 10 + (lvl - 1) * 9
const amaState = createInitialState('ama')
amaState.hardPoints[10] = 1
let vmLvl1 = buildTooltipViewModel(amaState, 10, 99)
let arLine1 = vmLvl1?.current.effects.find(e => e.textZh.includes('準確率') || e.textZh.includes('准确率'))
expect(arLine1?.textZh).toBe('準確率:+10%')
amaState.hardPoints[10] = 10
let vmLvl10 = buildTooltipViewModel(amaState, 10, 99)
let arLine10 = vmLvl10?.current.effects.find(e => e.textZh.includes('準確率') || e.textZh.includes('准确率'))
expect(arLine10?.textZh).toBe('準確率:+91%')
amaState.hardPoints[10] = 20
let vmLvl20 = buildTooltipViewModel(amaState, 10, 99)
let arLine20 = vmLvl20?.current.effects.find(e => e.textZh.includes('準確率') || e.textZh.includes('准确率'))
expect(arLine20?.textZh).toBe('準確率:+181%')
// Zeal (106): ToHit = 10, LevToHit = 10 -> 10 + (lvl - 1) * 10 = +200%
const palState = createInitialState('pal')
palState.hardPoints[106] = 20
const vmZeal = buildTooltipViewModel(palState, 106, 99)
const zealAr = vmZeal?.current.effects.find(e => e.textZh.includes('攻擊加成') || e.textZh.includes('準確率'))
expect(zealAr?.textZh).toBe('攻擊加成:+200%')
// Sacrifice (96): ToHit = 20, LevToHit = 7 -> 20 + (lvl - 1) * 7 = +83%
palState.hardPoints[96] = 10
const vmSacrifice = buildTooltipViewModel(palState, 96, 99)
const sacAr = vmSacrifice?.current.effects.find(e => e.textZh.includes('準確率'))
expect(sacAr?.textZh).toBe('變動準確率:+83%')
})
it('evaluates authentic 1.13c Shout warcry duration using Param3/Param4 (ln34)', () => {
const barState = createInitialState('bar')
barState.hardPoints[138] = 1 // Shout Level 1: Param3 = 500 frames = 20 seconds
let vmShout = buildTooltipViewModel(barState, 138, 99)
let durLine = vmShout?.current.effects.find(e => e.lineType === 12)
expect(durLine?.textZh).toBe('持續:20 秒')
barState.hardPoints[138] = 20 // Shout Level 20: 500 + 19 * 250 = 5250 frames = 210 seconds
vmShout = buildTooltipViewModel(barState, 138, 99)
durLine = vmShout?.current.effects.find(e => e.lineType === 12)
expect(durLine?.textZh).toBe('持續:210 秒')
})
it('strictly enforces that synergies ignore soft gear points (1.13c Marrowwalk fix)', () => {
const sorState1 = createInitialState('sor')
sorState1.hardPoints[36] = 20 // 20 hard points in Fire Bolt
const vmHard = buildTooltipViewModel(sorState1, 47, 99)
const synHard = vmHard?.synergiesReceived.find(s => s.providerId === 36)
expect(synHard?.hardPoints).toBe(20)
expect(synHard?.currentBonusPct).toBe(280) // 20 * 14% = 280%
const sorState2 = createInitialState('sor')
sorState2.hardPoints[36] = 0 // 0 hard points
sorState2.allSkillsSoftPoints = 20 // +20 soft points from gear
const vmSoft = buildTooltipViewModel(sorState2, 47, 99)
const synSoft = vmSoft?.synergiesReceived.find(s => s.providerId === 36)
expect(synSoft?.hardPoints).toBe(0)
expect(synSoft?.currentBonusPct).toBe(0) // Soft points give 0% synergy
})
})

View File

@ -0,0 +1,346 @@
import { describe, it, expect, beforeEach } from 'vitest'
import {
SkillHotkeysHud,
LEFT_SKILL_BOUNDS,
RIGHT_SKILL_BOUNDS,
DEFAULT_SORCERESS_SKILLS,
} from '../src/ui/hotkeys.ts'
import { HudManager } from '../src/ui/hud-manager.ts'
import { D2FontRenderer, type D2ColorCode } from '../src/ui/font.ts'
import { SKILLS_BY_ID } from '../src/data/skills-catalog.ts'
describe('Issue #380: Authentic Diablo II 1.13c Skill Quickbar & Speedbar Tooltips', () => {
let hud: SkillHotkeysHud
const mockFont = {
measureText: (txt: string, font?: string) => {
const charW = font === 'fontexocet10' ? 11 : 8
return txt.length * charW
},
drawText: () => {},
} as unknown as D2FontRenderer
beforeEach(() => {
hud = new SkillHotkeysHud()
})
describe('1. Default Universal Skills (Attack 0, Throw 2, Unsummon 3)', () => {
it('formats authentic Attack tooltip with weapon/barehanded narrative and 0 mana', () => {
const lines = hud.formatTooltipLines(0, 'left', false)
expect(lines.length).toBeGreaterThanOrEqual(3)
// Title
expect(lines[0]!.text).toBe('普通攻击 (Attack)')
expect(lines[0]!.color).toBe('gold')
expect(lines[0]!.font).toBe('fontexocet10')
// Narrative
const narrativeLine = lines.find(l => l.text.includes('物理攻击'))
expect(narrativeLine).toBeDefined()
expect(narrativeLine!.color).toBe('white')
// Mana
const manaLine = lines.find(l => l.text.includes('法力消耗: 0'))
expect(manaLine).toBeDefined()
expect(manaLine!.color).toBe('white')
})
it('formats authentic Throw tooltip with throw weapon narrative and 0 mana', () => {
const lines = hud.formatTooltipLines(2, 'left', false)
expect(lines[0]!.text).toBe('投掷 (Throw)')
expect(lines[0]!.color).toBe('gold')
const narrativeLine = lines.find(l => l.text.includes('投掷类武器'))
expect(narrativeLine).toBeDefined()
const manaLine = lines.find(l => l.text.includes('法力消耗: 0'))
expect(manaLine).toBeDefined()
})
it('formats authentic Unsummon tooltip with minion dismissal narrative and 0 mana', () => {
const lines = hud.formatTooltipLines(3, 'right', false)
expect(lines[0]!.text).toBe('取消召唤 (Unsummon)')
expect(lines[0]!.color).toBe('gold')
const narrativeLine = lines.find(l => l.text.includes('解散'))
expect(narrativeLine).toBeDefined()
const manaLine = lines.find(l => l.text.includes('法力消耗: 0'))
expect(manaLine).toBeDefined()
})
})
describe('2. Hotkey Binding Prompts & Labels', () => {
it('displays hotkey binding prompt in speedbar popup when unbound', () => {
// Skill 36 (Fire Bolt) not bound initially
const lines = hud.formatTooltipLines(36, 'left', true)
const hotkeyLine = lines.find(l => l.text.includes('按 F1-F8 键设置快捷键'))
expect(hotkeyLine).toBeDefined()
expect(hotkeyLine!.color).toBe('tan')
})
it('displays bound hotkey and rebind prompt in speedbar popup when bound', () => {
// F5 is bound to Fire Ball (47) on left side by default
const lines = hud.formatTooltipLines(47, 'left', true)
const hotkeyLine = lines.find(l => l.text.includes('当前快捷键: F5 (按 F1-F8 键重新绑定)'))
expect(hotkeyLine).toBeDefined()
expect(hotkeyLine!.color).toBe('tan')
})
it('displays bound hotkey on main control bar slot', () => {
// Left slot is Fire Ball (47) with F5
const lines = hud.formatTooltipLines(47, 'left', false)
const hotkeyLine = lines.find(l => l.text.includes('快捷键: F5'))
expect(hotkeyLine).toBeDefined()
expect(hotkeyLine!.color).toBe('tan')
})
})
describe('3. Class Skills & ViewModel Integration', () => {
it('integrates with TooltipViewModel from skill engine displaying level, mana, damage, and synergies', () => {
// Mock tooltip resolver
hud.tooltipResolver = (skillId: number) => {
if (skillId === 47) {
return {
skill: SKILLS_BY_ID[47]!,
hardPoints: 15,
effectiveLevel: 23,
isLearned: true,
canAllocateNow: true,
reqlevel: 12,
levelMet: true,
prereqs: [],
current: {
level: 23,
manaCost: 11,
delaySeconds: 0,
damage: {
hasDamage: true,
typeZh: '火焰伤害',
finalMin: 345,
finalMax: 388,
},
effects: [{ textZh: '爆炸半径: 2.6 码' }],
},
nextLevel: null,
synergiesReceived: [
{
providerId: 36,
providerName: 'Fire Bolt',
providerNameZh: '火弹',
percentPerLevel: 14,
hardPoints: 5,
currentBonusPct: 70,
},
],
masteryInfo: null,
} as any
}
return null
}
const lines = hud.formatTooltipLines(47, 'left', false)
// Title
expect(lines[0]!.text).toBe('火球 (Fire Ball)')
expect(lines[0]!.color).toBe('gold')
// Level with soft bonus
const lvlLine = lines.find(l => l.text.includes('当前技能等级: 23 (基础 15 + 装备 +8)'))
expect(lvlLine).toBeDefined()
expect(lvlLine!.color).toBe('blue')
// Mana cost
const manaLine = lines.find(l => l.text.includes('法力消耗: 11'))
expect(manaLine).toBeDefined()
// Narrative description
const descLine = lines.find(l => l.text.includes('爆炸') || l.text.includes('火球'))
expect(descLine).toBeDefined()
// Damage
const dmgLine = lines.find(l => l.text.includes('火焰伤害: 345 - 388'))
expect(dmgLine).toBeDefined()
expect(dmgLine!.color).toBe('blue')
// Effect
const effLine = lines.find(l => l.text.includes('爆炸半径: 2.6 码'))
expect(effLine).toBeDefined()
// Synergy section header and lines
const synHeader = lines.find(l => l.text.includes('[协同技能加成]'))
expect(synHeader).toBeDefined()
expect(synHeader!.color).toBe('blue')
const synLine = lines.find(l => l.text.includes('火弹: 每个有效技能点增加 14% 伤害 (当前 +70%)'))
expect(synLine).toBeDefined()
expect(synLine!.color).toBe('blue')
})
it('falls back cleanly to catalog data when no TooltipViewModel is supplied', () => {
hud.tooltipResolver = undefined
const lines = hud.formatTooltipLines(47, 'left', false)
expect(lines[0]!.text).toBe('火球 (Fire Ball)')
expect(lines[0]!.color).toBe('gold')
const lvlLine = lines.find(l => l.text.includes('当前技能等级: 8'))
expect(lvlLine).toBeDefined()
const manaLine = lines.find(l => l.text.includes('法力消耗: 8.5'))
expect(manaLine).toBeDefined()
})
})
describe('4. Tooltip Viewport Boundary Clamping & Placement', () => {
it('centers and clamps tooltip over Left Skill Slot without spilling off screen', () => {
const lines = hud.formatTooltipLines(47, 'left', false)
const placement = SkillHotkeysHud.computeTooltipPlacement(LEFT_SKILL_BOUNDS, lines, mockFont)
// Horizontally clamped inside [8, 800 - 8]
expect(placement.x).toBeGreaterThanOrEqual(8)
expect(placement.x + placement.w).toBeLessThanOrEqual(792)
// Vertically placed strictly above the control bar slot (y <= 551 - 6 - h)
expect(placement.y + placement.h).toBeLessThanOrEqual(551)
expect(placement.y).toBeGreaterThanOrEqual(8)
})
it('centers and clamps tooltip over Right Skill Slot without spilling off screen', () => {
const lines = hud.formatTooltipLines(64, 'right', false)
const placement = SkillHotkeysHud.computeTooltipPlacement(RIGHT_SKILL_BOUNDS, lines, mockFont)
expect(placement.x).toBeGreaterThanOrEqual(8)
expect(placement.x + placement.w).toBeLessThanOrEqual(792)
expect(placement.y + placement.h).toBeLessThanOrEqual(551)
expect(placement.y).toBeGreaterThanOrEqual(8)
})
it('places tooltip above speedbar popup cell and clamps within viewport', () => {
hud.openSpeedbar('left')
const cells = hud.getPopupCells('left')
const cell = cells[0]!
const lines = hud.formatTooltipLines(cell.skill.skillId, 'left', true)
const placement = SkillHotkeysHud.computeTooltipPlacement(cell, lines, mockFont)
expect(placement.x).toBeGreaterThanOrEqual(8)
expect(placement.x + placement.w).toBeLessThanOrEqual(792)
expect(placement.y).toBeGreaterThanOrEqual(8)
})
})
describe('5. Mouse Hover Transitions & Drawing', () => {
it('sets hoveredSlot and hoverSkillId when hovering left skill button', () => {
hud.handleMouseMove(LEFT_SKILL_BOUNDS.x + 10, LEFT_SKILL_BOUNDS.y + 10)
expect(hud.hoverSkillId).toBe(hud.leftSkillId)
expect(hud.hoveredSlot).toBe('left')
expect(hud.hoveredPopupSkill).toBeNull()
// Move away
hud.handleMouseMove(400, 300)
expect(hud.hoverSkillId).toBeNull()
expect(hud.hoveredSlot).toBeNull()
})
it('sets hoveredSlot and hoverSkillId when hovering right skill button', () => {
hud.handleMouseMove(RIGHT_SKILL_BOUNDS.x + 10, RIGHT_SKILL_BOUNDS.y + 10)
expect(hud.hoverSkillId).toBe(hud.rightSkillId)
expect(hud.hoveredSlot).toBe('right')
expect(hud.hoveredPopupSkill).toBeNull()
// Move away
hud.handleMouseMove(400, 300)
expect(hud.hoverSkillId).toBeNull()
expect(hud.hoveredSlot).toBeNull()
})
it('sets hoveredPopupSkill when hovering inside open speedbar', () => {
hud.openSpeedbar('right')
const cells = hud.getPopupCells('right')
const cell = cells[1]!
hud.handleMouseMove(cell.x + 10, cell.y + 10)
expect(hud.hoveredPopupSkill).toEqual({
side: 'right',
skillId: cell.skill.skillId,
})
// Move away
hud.handleMouseMove(10, 10)
expect(hud.hoveredPopupSkill).toBeNull()
})
it('renders authentic D2 double borders and dark translucent fill when tooltip drawn', () => {
const fillRects: any[] = []
const strokeRects: any[] = []
const drawnTexts: any[] = []
const mockCtx = {
fillStyle: '',
strokeStyle: '',
lineWidth: 1,
fillRect: (...args: any[]) => fillRects.push({ style: mockCtx.fillStyle, args }),
strokeRect: (...args: any[]) => strokeRects.push({ style: mockCtx.strokeStyle, args }),
} as unknown as CanvasRenderingContext2D
const testFont = {
measureText: () => 120,
drawText: (...args: any[]) => drawnTexts.push(args),
} as unknown as D2FontRenderer
// Hover left slot
hud.handleMouseMove(LEFT_SKILL_BOUNDS.x + 10, LEFT_SKILL_BOUNDS.y + 10)
hud.drawSkillTooltip(mockCtx, testFont)
// Verify dark background
const darkFill = fillRects.find(f => f.style === 'rgba(4, 4, 4, 0.94)')
expect(darkFill).toBeDefined()
// Verify outer bronze border (#8c7244) and inner dark border (#3d2b1a)
const outerBorder = strokeRects.find(s => s.style === '#8c7244')
const innerBorder = strokeRects.find(s => s.style === '#3d2b1a')
expect(outerBorder).toBeDefined()
expect(innerBorder).toBeDefined()
// Verify texts drawn (title in gold)
expect(drawnTexts.length).toBeGreaterThanOrEqual(3)
const titleDraw = drawnTexts.find(d => d[1].includes('火球') && d[4].color === 'gold')
expect(titleDraw).toBeDefined()
})
})
describe('6. HudManager End-to-End Class Tooltip Resolution', () => {
const mockCanvas = {
width: 800,
height: 600,
style: { cursor: '' },
addEventListener: () => {},
removeEventListener: () => {},
getContext: () => null,
} as unknown as HTMLCanvasElement
it('attaches skillTree.getTooltipViewModel to hotkeys in HudManager and updates on class switch', () => {
const hudMgr = new HudManager(mockCanvas, {
onToggleAutomap: () => {},
onWaypointTeleport: () => {},
})
// Switch to Paladin: Left is Sacrifice (96), Right is Blessed Hammer (112)
hudMgr.switchClass('pal')
expect(hudMgr.hotkeys.tooltipResolver).toBeDefined()
const sacLines = hudMgr.hotkeys.formatTooltipLines(96, 'left', false)
expect(sacLines[0]!.text).toContain('Sacrifice')
expect(sacLines.some(l => l.text.includes('当前技能等级:'))).toBe(true)
const hammerLines = hudMgr.hotkeys.formatTooltipLines(112, 'right', false)
expect(hammerLines[0]!.text).toContain('Blessed Hammer')
expect(hammerLines.some(l => l.text.includes('当前技能等级:') && l.text.includes('20'))).toBe(true)
// Switch to Barbarian: Whirlwind (151)
hudMgr.switchClass('bar')
const wwLines = hudMgr.hotkeys.formatTooltipLines(151, 'right', false)
expect(wwLines[0]!.text).toContain('Whirlwind')
expect(wwLines.some(l => l.text.includes('当前技能等级:') && l.text.includes('20'))).toBe(true)
})
})
})

View File

@ -0,0 +1,198 @@
import { describe, it, expect, vi } from 'vitest'
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { SkillTreePanel, SKILL_PANEL_ORIGIN } from '../src/ui/skill-tree-panel.ts'
import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts'
import type { D2FontRenderer } from '../src/ui/font.ts'
describe('Issue #377: Authentic D2 v1.13c Skill Tree Tabs & Skill Points Banner', () => {
it('manifest contains skillTabs and skillPoints image references', () => {
expect(BAKED_UI_MANIFEST.images.skillTabs).toBe('/ui/skill-tree-tabs.png')
expect(BAKED_UI_MANIFEST.images.skillPoints).toBe('/ui/skillpoints.png')
const tabsPath = join(process.cwd(), 'public', 'ui', 'skill-tree-tabs.png')
const spPath = join(process.cwd(), 'public', 'ui', 'skillpoints.png')
expect(existsSync(tabsPath), 'skill-tree-tabs.png must exist in public/ui').toBe(true)
expect(existsSync(spPath), 'skillpoints.png must exist in public/ui').toBe(true)
})
it('skill-tree-tabs.png has authentic 192x432 strip dimensions (3 states x 64x432)', () => {
const tabsPath = join(process.cwd(), 'public', 'ui', 'skill-tree-tabs.png')
const buf = readFileSync(tabsPath)
// PNG width and height at bytes 16..24
const width = buf.readUInt32BE(16)
const height = buf.readUInt32BE(20)
expect(width).toBe(192)
expect(height).toBe(432)
})
it('skillpoints.png has authentic 135x23 dimensions', () => {
const spPath = join(process.cwd(), 'public', 'ui', 'skillpoints.png')
const buf = readFileSync(spPath)
const width = buf.readUInt32BE(16)
const height = buf.readUInt32BE(20)
expect(width).toBe(135)
expect(height).toBe(23)
})
it('draws authentic 64x432 tab column slice according to activeTab', () => {
const panel = new SkillTreePanel()
panel.visible = true
const mockCtx = {
save: vi.fn(),
restore: vi.fn(),
drawImage: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn(),
fillStyle: '',
strokeStyle: '',
lineWidth: 1,
globalAlpha: 1,
} as unknown as CanvasRenderingContext2D
const mockFont = {
drawText: vi.fn(),
measureText: vi.fn(() => 50),
} as unknown as D2FontRenderer
const mockTabsImg = {
complete: true,
naturalWidth: 192,
naturalHeight: 432,
} as unknown as HTMLImageElement
const mockSpImg = {
complete: true,
naturalWidth: 135,
naturalHeight: 23,
} as unknown as HTMLImageElement
// 1. Tab 2 (Cold Spells, Top Tab): slice sx = 0
panel.activeTab = 2
panel.draw(mockCtx, {
borderRightImg: null,
skillPointsImg: mockSpImg,
buySellBtnImg: null,
skillTabsImg: mockTabsImg,
}, mockFont)
expect(mockCtx.drawImage).toHaveBeenCalledWith(
mockTabsImg,
0, // sx = 0
0,
64,
432,
SKILL_PANEL_ORIGIN.x + 256,
SKILL_PANEL_ORIGIN.y,
64,
432,
)
// 2. Tab 1 (Lightning Spells, Middle Tab): slice sx = 64
vi.clearAllMocks()
panel.activeTab = 1
panel.draw(mockCtx, {
borderRightImg: null,
skillPointsImg: mockSpImg,
buySellBtnImg: null,
skillTabsImg: mockTabsImg,
}, mockFont)
expect(mockCtx.drawImage).toHaveBeenCalledWith(
mockTabsImg,
64, // sx = 64
0,
64,
432,
SKILL_PANEL_ORIGIN.x + 256,
SKILL_PANEL_ORIGIN.y,
64,
432,
)
// 3. Tab 0 (Fire Spells, Bottom Tab): slice sx = 128
vi.clearAllMocks()
panel.activeTab = 0
panel.draw(mockCtx, {
borderRightImg: null,
skillPointsImg: mockSpImg,
buySellBtnImg: null,
skillTabsImg: mockTabsImg,
}, mockFont)
expect(mockCtx.drawImage).toHaveBeenCalledWith(
mockTabsImg,
128, // sx = 128
0,
64,
432,
SKILL_PANEL_ORIGIN.x + 256,
SKILL_PANEL_ORIGIN.y,
64,
432,
)
})
it('draws authentic skillpoints.png banner when unspentSkillPoints > 0', () => {
const panel = new SkillTreePanel()
panel.visible = true
panel.unspentSkillPoints = 5
const mockCtx = {
save: vi.fn(),
restore: vi.fn(),
drawImage: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn(),
fillStyle: '',
strokeStyle: '',
lineWidth: 1,
globalAlpha: 1,
} as unknown as CanvasRenderingContext2D
const mockFont = {
drawText: vi.fn(),
measureText: vi.fn(() => 50),
} as unknown as D2FontRenderer
const mockSpImg = {
complete: true,
naturalWidth: 135,
naturalHeight: 23,
} as unknown as HTMLImageElement
panel.draw(mockCtx, {
borderRightImg: null,
skillPointsImg: mockSpImg,
buySellBtnImg: null,
}, mockFont)
// Verify skillpoints banner drawn at (ox + 60, oy + 395)
expect(mockCtx.drawImage).toHaveBeenCalledWith(
mockSpImg,
SKILL_PANEL_ORIGIN.x + Math.round((256 - 135) / 2),
SKILL_PANEL_ORIGIN.y + 395,
135,
23,
)
// Verify when unspentSkillPoints = 0, banner is NOT drawn
vi.clearAllMocks()
panel.unspentSkillPoints = 0
panel.draw(mockCtx, {
borderRightImg: null,
skillPointsImg: mockSpImg,
buySellBtnImg: null,
}, mockFont)
expect(mockCtx.drawImage).not.toHaveBeenCalledWith(
mockSpImg,
expect.anything(),
expect.anything(),
expect.anything(),
expect.anything(),
)
})
})

View File

@ -37,6 +37,11 @@ import {
LEVEL_NAMES_ZH,
LEVEL_NAMES_EN,
} from '../src/game/level-names-zh.ts'
import {
buildTooltipViewModel,
createInitialState,
allocatePoint,
} from '../src/game/skill-calc-engine.ts'
describe('Diablo II v1.13c UI / HUD (Issue #27)', () => {
it('verifies offline DC6 UI asset extraction manifest has 0 decode failures', () => {
@ -635,4 +640,122 @@ describe('Diablo II v1.13c UI / HUD (Issue #27)', () => {
expect(sceneNameZh(null as any)).toBe('Unknown Area')
expect(sceneNameEn(null as any)).toBe('Unknown Area')
})
it('renders authentic multi-line skill tooltips with 1.13c next-level preview and styling rules (Issue #152)', () => {
// 1. Unlearned skill with requirements not met (Sorceress lvl 1 looking at Fire Ball lvl 12)
const sorcBuild = createInitialState('sor')
const unlearnedFireBall = buildTooltipViewModel(sorcBuild, 47, 1) // Fire Ball requires clvl 12, Fire Bolt
expect(unlearnedFireBall).not.toBeNull()
const unlearnedLines = SkillTreePanel.formatSkillTooltipLines(unlearnedFireBall!)
// Title: Fire Ball in Gold
expect(unlearnedLines[0]).toEqual({ text: '火球 (Fire Ball)', color: 'gold', font: 'fontexocet10', align: 'center' })
// Required Level: red when character level < reqlevel
const reqLevelLine = unlearnedLines.find(l => l.text.includes('需要角色等级: 12'))
expect(reqLevelLine).toBeDefined()
expect(reqLevelLine?.color).toBe('red')
// Prerequisites: red when unmet
const prereqLine = unlearnedLines.find(l => l.text.includes('需要前置技能: 火彈'))
expect(prereqLine).toBeDefined()
expect(prereqLine?.color).toBe('red')
// Unlearned skill MUST NOT include [下一等级] preview
expect(unlearnedLines.some(l => l.text.includes('[下一等级]'))).toBe(false)
// 2. Unlearned skill with requirements met (Sorceress lvl 1 looking at Fire Bolt lvl 1)
const fireBolt = buildTooltipViewModel(sorcBuild, 36, 1)
expect(fireBolt).not.toBeNull()
const fireBoltLines = SkillTreePanel.formatSkillTooltipLines(fireBolt!)
// When level is met, reqlevel line is omitted from tooltip
expect(fireBoltLines.some(l => l.text.includes('需要角色等级'))).toBe(false)
expect(fireBoltLines.some(l => l.text.includes('[下一等级]'))).toBe(false)
// 3. Learned skill (slvl = 1) with next-level preview in green
const learnedBuild = createInitialState('sor')
allocatePoint(learnedBuild, 36) // Fire Bolt +1
const learnedFireBolt = buildTooltipViewModel(learnedBuild, 36, 1)
expect(learnedFireBolt).not.toBeNull()
const learnedLines = SkillTreePanel.formatSkillTooltipLines(learnedFireBolt!)
const currLevelLine = learnedLines.find(l => l.text.includes('当前技能等级: 1'))
expect(currLevelLine).toBeDefined()
expect(currLevelLine?.color).toBe('white')
const nextLevelHeader = learnedLines.find(l => l.text.includes('[下一等级] (Lv 2)'))
expect(nextLevelHeader).toBeDefined()
expect(nextLevelHeader?.color).toBe('green')
// Next level damage preview in green
const nextLevelDmg = learnedLines.find(l => l.color === 'green' && l.text.includes('火焰伤害'))
expect(nextLevelDmg).toBeDefined()
// 4. Maxed skill (hardPoints === 20): omits [下一等级] and shows max allocation notice in gold
const maxedBuild = createInitialState('sor')
allocatePoint(maxedBuild, 36, 20)
const maxedFireBolt = buildTooltipViewModel(maxedBuild, 36, 20)
expect(maxedFireBolt).not.toBeNull()
const maxedLines = SkillTreePanel.formatSkillTooltipLines(maxedFireBolt!)
expect(maxedLines.some(l => l.text.includes('[下一等级]'))).toBe(false)
const maxedNotice = maxedLines.find(l => l.text.includes('[已达技能点投入上限 (20/20)]'))
expect(maxedNotice).toBeDefined()
expect(maxedNotice?.color).toBe('gold')
// 5. Synergy section formatting
const fireballBuild = createInitialState('sor')
allocatePoint(fireballBuild, 36) // Fire Bolt +1
const fbVm = buildTooltipViewModel(fireballBuild, 47, 12)
const fbLines = SkillTreePanel.formatSkillTooltipLines(fbVm!)
const synHeader = fbLines.find(l => l.text.includes('[协同技能加成]'))
expect(synHeader).toBeDefined()
expect(synHeader?.color).toBe('blue')
const synFireBolt = fbLines.find(l => l.text.includes('火彈: 每个有效技能点增加 14% 伤害'))
expect(synFireBolt).toBeDefined()
expect(synFireBolt?.color).toBe('blue') // active synergy (Fire Bolt has 1 hard point)
const synMeteor = fbLines.find(l => l.text.includes('隕石: 每个有效技能点增加 14% 伤害'))
expect(synMeteor).toBeDefined()
expect(synMeteor?.color).toBe('gray') // inactive synergy (Meteor has 0 hard points)
})
it('clamps tooltip placement within canvas viewport and strictly above the control bar (Issue #152)', () => {
const dummyLines = [
{ text: '火球', color: 'white' as const, font: 'fontexocet10' as const, align: 'center' as const },
{ text: '当前等级: 1', color: 'white' as const, font: 'font8' as const, align: 'center' as const },
{ text: '火焰伤害: 6 - 14', color: 'blue' as const, font: 'font8' as const, align: 'center' as const },
{ text: '[下一等级] (Lv 2)', color: 'green' as const, font: 'font8' as const, align: 'center' as const },
{ text: '火焰伤害: 10 - 20', color: 'green' as const, font: 'font8' as const, align: 'center' as const },
{ text: '[协同技能加成]', color: 'blue' as const, font: 'fontexocet10' as const, align: 'center' as const },
{ text: '• 火弹: 每个有效技能点增加 14% 伤害 (当前 +14%)', color: 'blue' as const, font: 'font8' as const, align: 'left' as const },
{ text: '• 陨石: 每个有效技能点增加 14% 伤害', color: 'gray' as const, font: 'font8' as const, align: 'left' as const },
]
const mockFont = {
measureText: (txt: string) => txt.length * 8,
} as any
// Test extreme positions across the 800x600 screen
const testPositions = [
{ x: 420, y: 80, w: 48, h: 48 }, // Top-left node in right-dock panel
{ x: 700, y: 120, w: 48, h: 48 }, // Far-right node
{ x: 450, y: 400, w: 48, h: 48 }, // Bottom node
{ x: 500, y: 500, w: 48, h: 48 }, // Extreme bottom node near control bar
{ x: 50, y: 200, w: 48, h: 48 }, // Far left node
]
for (const pos of testPositions) {
const placement = SkillTreePanel.computeTooltipPlacement(pos, dummyLines, mockFont)
// Viewport bounds: x >= 8, x + w <= 792 (800 - 8)
expect(placement.x).toBeGreaterThanOrEqual(8)
expect(placement.x + placement.w).toBeLessThanOrEqual(792)
// Vertical bounds: y >= 8, y + h <= 552 (strictly above control bar y=552)
expect(placement.y).toBeGreaterThanOrEqual(8)
expect(placement.y + placement.h).toBeLessThanOrEqual(552)
// Minimum width is 240
expect(placement.w).toBeGreaterThanOrEqual(240)
// Height matches line count * 17 + 24
expect(placement.h).toBe(dummyLines.length * 17 + 24)
}
})
})

View File

@ -0,0 +1,467 @@
import { describe, it, expect, beforeEach } from 'vitest'
import {
HudManager,
computeDockingLayout,
computeHudLayout,
CHAR_PANEL_ORIGIN,
INV_PANEL_ORIGIN,
INV_GRID_ORIGIN,
SKILL_PANEL_ORIGIN,
} from '../src/ui/hud-manager.ts'
import { STASH_PANEL_ORIGIN, STASH_GRID_ORIGIN } from '../src/ui/world-panels.ts'
import type { UiInventoryItem } from '../src/ui/inventory.ts'
class MockWindow extends EventTarget {
innerWidth = 1920
innerHeight = 1080
devicePixelRatio = 1
}
if (typeof globalThis.window === 'undefined') {
// @ts-ignore
globalThis.window = new MockWindow()
}
if (typeof globalThis.HTMLInputElement === 'undefined') {
// @ts-ignore
globalThis.HTMLInputElement = class HTMLInputElement {}
}
if (typeof globalThis.HTMLSelectElement === 'undefined') {
// @ts-ignore
globalThis.HTMLSelectElement = class HTMLSelectElement {}
}
function createMockCanvas(width: number, height: number): HTMLCanvasElement {
return {
clientWidth: width,
clientHeight: height,
width,
height,
style: { pointerEvents: 'none' },
getBoundingClientRect: () => ({ left: 0, top: 0, width, height }),
getContext: () => null,
addEventListener: () => {},
removeEventListener: () => {},
} as unknown as HTMLCanvasElement
}
function createDummyItem(id: string, code: string, invWidth: number, invHeight: number, nameZh: string): UiInventoryItem {
return {
id,
code,
invFile: code,
name: nameZh,
nameZh,
baseNameZh: nameZh,
quality: 'normal',
invWidth,
invHeight,
allowedSlots: [],
stats: [],
}
}
describe('Widescreen UI Panels Left/Right Docking & Central Vision Corridor (Issue #153)', () => {
describe('Docking Layout Mathematics & Metrics Across Resolutions', () => {
it('computes 800x600 classic layout with 0px margin and 0px central channel (classic D2 parity)', () => {
const docking = computeDockingLayout(800, 600)
expect(docking.uiScale).toBe(1.0)
expect(docking.offsetX).toBe(0)
expect(docking.offsetY).toBe(0)
expect(docking.marginW).toBe(0)
expect(docking.leftDockX).toBe(0)
expect(docking.rightDockX).toBe(400)
expect(docking.channelLeft).toBe(400)
expect(docking.channelRight).toBe(400)
expect(docking.channelWidthLogical).toBe(0)
expect(docking.channelWidthCss).toBe(0)
expect(docking.isWidescreen).toBe(false)
})
it('computes 1024x768 4:3 layout with 0px margin and 0px central channel', () => {
const docking = computeDockingLayout(1024, 768)
expect(docking.uiScale).toBe(1.28)
expect(docking.offsetX).toBe(0)
expect(docking.offsetY).toBe(0)
expect(docking.marginW).toBe(0)
expect(docking.leftDockX).toBe(0)
expect(docking.rightDockX).toBe(400)
expect(docking.channelLeft).toBe(400)
expect(docking.channelRight).toBe(400)
expect(docking.channelWidthLogical).toBe(0)
expect(docking.channelWidthCss).toBe(0)
expect(docking.isWidescreen).toBe(false)
})
it('computes 1280x720 16:9 layout with 320px CSS central channel', () => {
const docking = computeDockingLayout(1280, 720)
expect(docking.uiScale).toBeCloseTo(1.2, 3)
expect(docking.offsetX).toBe(160)
expect(docking.offsetY).toBe(0)
expect(docking.marginW).toBeCloseTo(133.333, 2)
expect(docking.leftDockX).toBeCloseTo(-133.333, 2)
expect(docking.rightDockX).toBeCloseTo(533.333, 2)
expect(docking.channelLeft).toBeCloseTo(266.667, 2)
expect(docking.channelRight).toBeCloseTo(533.333, 2)
expect(docking.channelWidthLogical).toBeCloseTo(266.667, 2)
expect(docking.channelWidthCss).toBe(320)
expect(docking.isWidescreen).toBe(true)
})
it('computes 1920x1080 Full HD 16:9 layout with 480px CSS central channel', () => {
const docking = computeDockingLayout(1920, 1080)
expect(docking.uiScale).toBeCloseTo(1.8, 3)
expect(docking.offsetX).toBe(240)
expect(docking.offsetY).toBe(0)
expect(docking.marginW).toBeCloseTo(133.333, 2)
expect(docking.leftDockX).toBeCloseTo(-133.333, 2)
expect(docking.rightDockX).toBeCloseTo(533.333, 2)
expect(docking.channelLeft).toBeCloseTo(266.667, 2)
expect(docking.channelRight).toBeCloseTo(533.333, 2)
expect(docking.channelWidthLogical).toBeCloseTo(266.667, 2)
expect(docking.channelWidthCss).toBe(480)
expect(docking.isWidescreen).toBe(true)
})
it('computes 2560x1440 QHD 16:9 layout with 640px CSS central channel', () => {
const docking = computeDockingLayout(2560, 1440)
expect(docking.uiScale).toBeCloseTo(2.4, 3)
expect(docking.offsetX).toBe(320)
expect(docking.offsetY).toBe(0)
expect(docking.marginW).toBeCloseTo(133.333, 2)
expect(docking.channelWidthLogical).toBeCloseTo(266.667, 2)
expect(docking.channelWidthCss).toBe(640)
expect(docking.isWidescreen).toBe(true)
})
it('computes 3440x1440 Ultrawide 21:9 layout with 1520px CSS central channel', () => {
const docking = computeDockingLayout(3440, 1440)
expect(docking.uiScale).toBeCloseTo(2.4, 3)
expect(docking.offsetX).toBe(760)
expect(docking.offsetY).toBe(0)
expect(docking.marginW).toBeCloseTo(316.667, 2)
expect(docking.channelWidthLogical).toBeCloseTo(633.333, 2)
expect(docking.channelWidthCss).toBe(1520)
expect(docking.isWidescreen).toBe(true)
})
it('guarantees channelRight >= channelLeft and zero panel overlap across all resolutions', () => {
const resolutions = [
[800, 600],
[1024, 768],
[1280, 720],
[1280, 800],
[1366, 768],
[1600, 900],
[1920, 1080],
[1920, 1200],
[2560, 1440],
[3440, 1440],
[3840, 2160],
[5120, 1440],
]
for (const [w, h] of resolutions) {
const d = computeDockingLayout(w, h)
expect(d.channelRight).toBeGreaterThanOrEqual(d.channelLeft)
expect(d.channelWidthLogical).toBeGreaterThanOrEqual(0)
expect(d.channelWidthCss).toBeGreaterThanOrEqual(0)
// Left panel right edge = channelLeft. Right panel left edge = channelRight.
// Overlap amount = Math.max(0, channelLeft - channelRight) must be 0.
const overlap = Math.max(0, d.channelLeft - d.channelRight)
expect(overlap).toBe(0)
}
})
})
describe('Invariant Preservation', () => {
it('preserves canonical panel and grid origin constants unchanged', () => {
expect(CHAR_PANEL_ORIGIN).toEqual({ x: 80, y: 60, width: 320, height: 432 })
expect(INV_PANEL_ORIGIN).toEqual({ x: 400, y: 60, width: 320, height: 432 })
expect(INV_GRID_ORIGIN).toEqual({ x: 418, y: 316, cols: 10, rows: 4, cellPx: 29 })
expect(SKILL_PANEL_ORIGIN).toEqual({ x: 400, y: 60, width: 320, height: 432 })
expect(STASH_PANEL_ORIGIN).toEqual({ x: 80, y: 60, width: 320, height: 432, w: 320, h: 432 })
expect(STASH_GRID_ORIGIN).toEqual({ x: 154, y: 142, cols: 6, rows: 8, cellPx: 29 })
})
})
describe('Central Vision Corridor & Input Interception (isPointInterceptedByHud)', () => {
it('returns false at (400, 300) when dual panels are open on widescreen (1920x1080)', () => {
const canvas = createMockCanvas(1920, 1080)
const hud = new HudManager(canvas, {
onToggleAutomap: () => {},
onWaypointTeleport: () => {},
})
hud.leftPanel = 'char'
hud.rightPanel = 'inv'
// Screen center (400, 300) in logical coordinates is in the open central corridor
expect(hud.isPointInterceptedByHud(400, 300)).toBe(false)
// Test across the entire central corridor: 400 - marginW < x < 400 + marginW
const docking = hud.getDockingLayout()
expect(docking.isWidescreen).toBe(true)
const channelMid = (docking.channelLeft + docking.channelRight) / 2
expect(channelMid).toBeCloseTo(400, 2)
expect(hud.isPointInterceptedByHud(channelMid, 250)).toBe(false)
expect(hud.isPointInterceptedByHud(docking.channelLeft + 10, 250)).toBe(false)
expect(hud.isPointInterceptedByHud(docking.channelRight - 10, 250)).toBe(false)
// Points inside left panel are intercepted
expect(hud.isPointInterceptedByHud(docking.channelLeft - 20, 250)).toBe(true)
expect(hud.isPointInterceptedByHud(docking.leftDockX + 10, 250)).toBe(true)
// Points inside right panel are intercepted
expect(hud.isPointInterceptedByHud(docking.channelRight + 20, 250)).toBe(true)
expect(hud.isPointInterceptedByHud(800 + docking.marginW - 10, 250)).toBe(true)
})
it('returns true at (400, 300) when dual panels are open in 800x600 (classic D2)', () => {
const canvas = createMockCanvas(800, 600)
const hud = new HudManager(canvas, {
onToggleAutomap: () => {},
onWaypointTeleport: () => {},
})
hud.leftPanel = 'char'
hud.rightPanel = 'inv'
// In 800x600, panels touch at 400 with 0px corridor, intercepting x=400
expect(hud.isPointInterceptedByHud(400, 300)).toBe(true)
// When panels are closed, x=400 is not intercepted
hud.leftPanel = 'none'
hud.rightPanel = 'none'
expect(hud.isPointInterceptedByHud(400, 300)).toBe(false)
})
})
describe('HudManager getDockingLayout and syncPublishedState', () => {
it('exposes getDockingLayout() matching current canvas dimensions', () => {
const canvas = createMockCanvas(1920, 1080)
const hud = new HudManager(canvas, {
onToggleAutomap: () => {},
onWaypointTeleport: () => {},
})
const layout = hud.getDockingLayout()
expect(layout.isWidescreen).toBe(true)
expect(layout.channelWidthCss).toBe(480)
expect(layout.marginW).toBeCloseTo(133.333, 2)
})
it('includes docking metrics in published state', () => {
const canvas = createMockCanvas(1920, 1080)
const hud = new HudManager(canvas, {
onToggleAutomap: () => {},
onWaypointTeleport: () => {},
})
hud.syncPublishedState()
expect(hud.state.docking).toBeDefined()
expect(hud.state.docking.isWidescreen).toBe(true)
expect(hud.state.docking.channelWidthCss).toBe(480)
expect(hud.state.docking.channelLeft).toBeCloseTo(266.667, 2)
expect(hud.state.docking.channelRight).toBeCloseTo(533.333, 2)
})
})
describe('Pointer Event Coordinate Translation in Widescreen', () => {
class MockCanvas extends EventTarget {
clientWidth = 1920
clientHeight = 1080
width = 1920
height = 1080
style = { pointerEvents: 'none' }
getBoundingClientRect() {
return { left: 0, top: 0, width: this.clientWidth, height: this.clientHeight }
}
getContext() {
return null
}
}
class SyntheticMouseEvent extends Event {
readonly clientX: number
readonly clientY: number
readonly button: number
readonly shiftKey: boolean
constructor(
type: string,
init: { clientX: number; clientY: number; button?: number; shiftKey?: boolean },
) {
super(type, { cancelable: true, bubbles: true })
this.clientX = init.clientX
this.clientY = init.clientY
this.button = init.button ?? 0
this.shiftKey = init.shiftKey ?? false
}
}
let canvas: MockCanvas
let hud: HudManager
beforeEach(() => {
canvas = new MockCanvas()
hud = new HudManager(canvas as unknown as HTMLCanvasElement, {
onToggleAutomap: () => {},
onWaypointTeleport: () => {},
})
hud['bindEvents']()
})
it('translates mousemove coordinates for right dock panels and adjusts item tooltip hover position', () => {
hud.toggleRightPanel('inv')
const item = createDummyItem('item1', 'rin', 1, 1, '乔丹之石')
hud.inventory.gridItems = [{ item, col: 0, row: 0 }]
const docking = hud.getDockingLayout()
const deltaRight = docking.marginW // ~133.333
// Target item in col 0: canonical logical position is INV_GRID_ORIGIN.x + 14.5 = 418 + 14.5 = 432.5
// In widescreen, visual position is 432.5 + deltaRight.
const visualLogicalX = INV_GRID_ORIGIN.x + 14.5 + deltaRight
const clientX = visualLogicalX * docking.uiScale + docking.offsetX
const clientY = (INV_GRID_ORIGIN.y + 14.5) * docking.uiScale
window.dispatchEvent(new SyntheticMouseEvent('mousemove', { clientX, clientY }))
// Inventory should have detected hover
expect(hud.inventory.hoveredItem).not.toBeNull()
expect(hud.inventory.hoveredItem?.item.id).toBe('item1')
// hoveredItem.x must be translated by +deltaRight so the tooltip renders over visual position
expect(hud.inventory.hoveredItem?.x).toBeCloseTo(visualLogicalX, 1)
})
it('translates mousemove coordinates for left dock stash items', () => {
hud.leftPanel = 'stash'
const item = createDummyItem('stash-item1', 'rin', 1, 1, '储物箱戒指')
hud.worldPanels.stashItems = [{ item, col: 0, row: 0 }]
const docking = hud.getDockingLayout()
const deltaLeft = -docking.marginW // ~ -133.333
// Stash col 0 cell center: STASH_GRID_ORIGIN.x + 14.5 = 154 + 14.5 = 168.5
// In widescreen, visual position is 168.5 + deltaLeft = 168.5 - 133.333 = 35.167
const visualLogicalX = STASH_GRID_ORIGIN.x + 14.5 + deltaLeft
const clientX = visualLogicalX * docking.uiScale + docking.offsetX
const clientY = (STASH_GRID_ORIGIN.y + 14.5) * docking.uiScale
window.dispatchEvent(new SyntheticMouseEvent('mousemove', { clientX, clientY }))
expect(hud.inventory.hoveredItem).not.toBeNull()
expect(hud.inventory.hoveredItem?.item.id).toBe('stash-item1')
expect(hud.inventory.hoveredItem?.x).toBeCloseTo(visualLogicalX, 1)
})
it('executes quick transfer from Inventory to Stash via Shift+Click in widescreen', () => {
hud.leftPanel = 'stash'
hud.rightPanel = 'inv'
const item = createDummyItem('quick1', 'rin', 1, 1, '传送戒指')
hud.inventory.gridItems = [{ item, col: 0, row: 0 }]
expect(hud.worldPanels.stashItems).toHaveLength(0)
const docking = hud.getDockingLayout()
const deltaRight = docking.marginW
// Visual position of item in Inventory col 0, row 0
const visualLogicalX = INV_GRID_ORIGIN.x + 10 + deltaRight
const visualLogicalY = INV_GRID_ORIGIN.y + 10
const clientX = visualLogicalX * docking.uiScale + docking.offsetX
const clientY = visualLogicalY * docking.uiScale + docking.offsetY
canvas.dispatchEvent(
new SyntheticMouseEvent('mousedown', {
clientX,
clientY,
button: 0,
shiftKey: true,
}),
)
// Quick transfer should succeed: item moved from inventory to stash
expect(hud.inventory.gridItems).toHaveLength(0)
expect(hud.worldPanels.stashItems).toHaveLength(1)
expect(hud.worldPanels.stashItems[0]!.item.id).toBe('quick1')
})
it('executes quick transfer from Stash to Inventory via Shift+Click in widescreen', () => {
hud.leftPanel = 'stash'
hud.rightPanel = 'inv'
const item = createDummyItem('stash-quick1', 'rin', 1, 1, '马拉的万花筒')
hud.worldPanels.stashItems = [{ item, col: 0, row: 0 }]
hud.inventory.gridItems = []
const docking = hud.getDockingLayout()
const deltaLeft = -docking.marginW
// Visual position of item in Stash col 0, row 0
const visualLogicalX = STASH_GRID_ORIGIN.x + 10 + deltaLeft
const visualLogicalY = STASH_GRID_ORIGIN.y + 10
const clientX = visualLogicalX * docking.uiScale + docking.offsetX
const clientY = visualLogicalY * docking.uiScale + docking.offsetY
canvas.dispatchEvent(
new SyntheticMouseEvent('mousedown', {
clientX,
clientY,
button: 0,
shiftKey: true,
}),
)
// Quick transfer should succeed: item moved from stash to inventory
expect(hud.worldPanels.stashItems).toHaveLength(0)
expect(hud.inventory.gridItems).toHaveLength(1)
expect(hud.inventory.gridItems[0]!.item.id).toBe('stash-quick1')
})
it('closes left and right panels via close buttons at widescreen coordinates', () => {
hud.leftPanel = 'char'
hud.rightPanel = 'inv'
const docking = hud.getDockingLayout()
const deltaLeft = -docking.marginW
const deltaRight = docking.marginW
// Left panel close button: CHAR_CLOSE_BTN_BOUNDS: x = 208, y = 448
const visualLeftCloseX = 208 + deltaLeft
const visualLeftCloseY = 448
const clientLeftX = visualLeftCloseX * docking.uiScale + docking.offsetX
const clientLeftY = visualLeftCloseY * docking.uiScale + docking.offsetY
canvas.dispatchEvent(
new SyntheticMouseEvent('mousedown', {
clientX: clientLeftX,
clientY: clientLeftY,
button: 0,
shiftKey: false,
}),
)
expect(hud.leftPanel).toBe('none')
// Right panel close button: INV_CLOSE_BTN_BOUNDS: x = 418, y = 445
const visualRightCloseX = 418 + deltaRight
const visualRightCloseY = 445
const clientRightX = visualRightCloseX * docking.uiScale + docking.offsetX
const clientRightY = visualRightCloseY * docking.uiScale + docking.offsetY
canvas.dispatchEvent(
new SyntheticMouseEvent('mousedown', {
clientX: clientRightX,
clientY: clientRightY,
button: 0,
shiftKey: false,
}),
)
expect(hud.rightPanel).toBe('none')
})
})
})

184
tests/world-panels.test.ts Normal file
View File

@ -0,0 +1,184 @@
/**
* tests/world-panels.test.ts
*
* Unit tests for Diablo II 1.13c Waypoint and World Panels UI Parity (Issue #378).
*/
import { describe, expect, it, vi } from 'vitest'
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { WorldPanelsHud, ACT_WAYPOINTS } from '../src/ui/world-panels.ts'
import { D2FontRenderer } from '../src/ui/font.ts'
function getPngDimensions(filePath: string): { width: number; height: number } {
const buf = readFileSync(filePath)
if (buf.toString('ascii', 1, 4) !== 'PNG') throw new Error('Not a PNG file')
const width = buf.readUInt32BE(16)
const height = buf.readUInt32BE(20)
return { width, height }
}
describe('Issue #378: Diablo II 1.13c Waypoint Panel Tabs & Icons', () => {
it('verifies waypoint-tabs.png, waypoint-icons.png, and quest-tabs.png asset dimensions', () => {
const waypointTabsPath = join(process.cwd(), 'public/ui/waypoint-tabs.png')
const waypointIconsPath = join(process.cwd(), 'public/ui/waypoint-icons.png')
const questTabsPath = join(process.cwd(), 'public/ui/quest-tabs.png')
expect(existsSync(waypointTabsPath)).toBe(true)
expect(existsSync(waypointIconsPath)).toBe(true)
expect(existsSync(questTabsPath)).toBe(true)
// 10 frames of 63x31 = 630x31
const wpTabsDim = getPngDimensions(waypointTabsPath)
expect(wpTabsDim.width).toBe(630)
expect(wpTabsDim.height).toBe(31)
// 5 frames of 30x30 = 150x30
const wpIconsDim = getPngDimensions(waypointIconsPath)
expect(wpIconsDim.width).toBe(150)
expect(wpIconsDim.height).toBe(30)
// 10 frames of 63x31 = 630x31
const qTabsDim = getPngDimensions(questTabsPath)
expect(qTabsDim.width).toBe(630)
expect(qTabsDim.height).toBe(31)
})
it('renders waypoint tabs and icons using drawImage slices when assets are present', () => {
const hud = new WorldPanelsHud()
const font = new D2FontRenderer()
const drawImageCalls: any[] = []
const ctx = {
drawImage: vi.fn((...args: any[]) => {
drawImageCalls.push(args)
}),
fillRect: vi.fn(),
strokeRect: vi.fn(),
fillText: vi.fn(),
save: vi.fn(),
restore: vi.fn(),
measureText: vi.fn(() => ({ width: 60 })),
fillStyle: '',
strokeStyle: '',
globalAlpha: 1,
} as unknown as CanvasRenderingContext2D
const mockTabsImg = {
complete: true,
naturalWidth: 630,
naturalHeight: 31,
} as unknown as HTMLImageElement
const mockIconsImg = {
complete: true,
naturalWidth: 150,
naturalHeight: 30,
} as unknown as HTMLImageElement
hud.selectedActTab = 2
hud.drawLeftDockPanel(
ctx,
'waypoint',
{
questBgImg: null,
waypointBgImg: null,
borderLeftImg: null,
stashBgImg: null,
cubeBgImg: null,
vendorBgImg: null,
buySellBtnImg: null,
waypointTabsImg: mockTabsImg,
waypointIconsImg: mockIconsImg,
},
font,
)
// Verify 5 tab draws from mockTabsImg:
// Act 1 (inactive, frame 0 -> sx 0)
// Act 2 (active, frame 6 -> sx 6 * 63 = 378)
// Act 3 (inactive, frame 2 -> sx 2 * 63 = 126)
// Act 4 (inactive, frame 3 -> sx 3 * 63 = 189)
// Act 5 (inactive, frame 4 -> sx 4 * 63 = 252)
const tabDraws = drawImageCalls.filter((c) => c[0] === mockTabsImg)
expect(tabDraws.length).toBe(5)
expect(tabDraws[0][1]).toBe(0) // Act 1 sx
expect(tabDraws[1][1]).toBe(6 * 63) // Act 2 (active) sx = 378
expect(tabDraws[2][1]).toBe(2 * 63) // Act 3 sx = 126
// Verify waypoint icons drawn from mockIconsImg for Act 2 (frame index 1 -> sx 30)
const iconDraws = drawImageCalls.filter((c) => c[0] === mockIconsImg)
const act2WpCount = (ACT_WAYPOINTS[2] ?? []).length
expect(iconDraws.length).toBe(act2WpCount)
for (const call of iconDraws) {
expect(call[1]).toBe(30) // sx = 30 for Act 2
expect(call[3]).toBe(30) // sw = 30
expect(call[4]).toBe(30) // sh = 30
}
})
it('updates hoveredWaypointIdx on mouse move and highlights hovered waypoint', () => {
const hud = new WorldPanelsHud()
hud.selectedActTab = 1
const ox = 80
const oy = 60
// Hover over first waypoint (i = 0): wy = oy + 62 = 122 .. 153
hud.handleMouseMove(ox + 50, oy + 70)
expect(hud.hoveredWaypointIdx).toBe(0)
// Move away
hud.handleMouseMove(ox + 50, oy + 500)
expect(hud.hoveredWaypointIdx).toBeNull()
})
it('handles clicking waypoint tabs and locked vs unlocked waypoints', () => {
const hud = new WorldPanelsHud()
const onWaypointTeleport = vi.fn()
const onClose = vi.fn()
const ox = 80
const oy = 60
// 1. Click Act 3 tab (tab 3: logicalX approx ox + 8 + 2 * 61 + 10 = ox + 140, logicalY oy + 20)
const tabClickHandled = hud.handleLeftDockClick('waypoint', ox + 140, oy + 20, {
onWaypointTeleport,
onClose,
})
expect(tabClickHandled).toBe(true)
expect(hud.selectedActTab).toBe(3)
// 2. In Act 3:
// First waypoint (Kurast Docks) is unlocked
const wps = ACT_WAYPOINTS[3]!
expect(wps[0]!.unlocked).toBe(true)
// Temporarily mark index 1 as locked
;(wps[1] as any).unlocked = false
try {
// Click locked waypoint: should NOT trigger teleport or close
const lockedWy = oy + 62 + 1 * 36 + 10
const lockedClick = hud.handleLeftDockClick('waypoint', ox + 50, lockedWy, {
onWaypointTeleport,
onClose,
})
expect(lockedClick).toBe(true)
expect(onWaypointTeleport).not.toHaveBeenCalled()
expect(onClose).not.toHaveBeenCalled()
// Click unlocked waypoint (index 0): should trigger teleport and close
const unlockedWy = oy + 62 + 0 * 36 + 10
const unlockedClick = hud.handleLeftDockClick('waypoint', ox + 50, unlockedWy, {
onWaypointTeleport,
onClose,
})
expect(unlockedClick).toBe(true)
expect(onWaypointTeleport).toHaveBeenCalledWith(3, wps[0]!.slug, wps[0]!.levelId)
expect(onClose).toHaveBeenCalled()
} finally {
;(wps[1] as any).unlocked = true
}
})
})