docs: update README with art assets, JSON data specifications, and UI parity

This commit is contained in:
troytt 2026-09-22 13:12:25 +00:00
parent 4f6eadea6c
commit 8f8ee010ca
35 changed files with 5426 additions and 39 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

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

@ -361,6 +361,7 @@ async function main(): Promise<void> {
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]!
@ -379,27 +380,36 @@ async function main(): Promise<void> {
}
}
}
// Col 0: Tab 2 active (Top / Cold)
// Col 0: Top Tab Active (y = 108..216, stF13)
pasteTabCol(0)
// Col 1: Tab 1 active (Middle / Lightning)
pasteTabCol(1)
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 + (64 + x)] = idx
if (idx !== 0) sttPixels[y * 192 + (0 + x)] = idx
}
}
// Col 2: Tab 0 active (Bottom / Fire)
pasteTabCol(2)
// 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 + (128 + x)] = idx
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
}
}

View File

@ -43,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)
@ -276,8 +280,10 @@ 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 }
}

View File

@ -153,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)
@ -372,10 +375,11 @@ export class WorldPanelsHud {
const ox = 80
const oy = 60
if (logicalX >= ox + 22 && logicalX <= ox + 298) {
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 wy = oy + 62 + i * 36
const rowY = WAYPOINT_ROW_OFFSETS_Y[i] ?? (60 + i * 36)
const wy = oy + rowY
if (logicalY >= wy && logicalY <= wy + 31) {
this.hoveredWaypointIdx = i
break
@ -455,8 +459,9 @@ 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)
@ -646,27 +651,28 @@ export class WorldPanelsHud {
for (let i = 0; i < wps.length; i++) {
const wp = wps[i]!
const wy = oy + 62 + i * 36
const rowY = WAYPOINT_ROW_OFFSETS_Y[i] ?? (60 + i * 36)
const wy = oy + rowY
const isHovered = this.hoveredWaypointIdx === i
ctx.fillStyle = isHovered ? 'rgba(42, 34, 22, 0.88)' : 'rgba(18, 16, 13, 0.85)'
ctx.fillRect(ox + 22, wy, 276, 31)
ctx.fillRect(ox + 17, wy, 281, 31)
ctx.strokeStyle = isHovered ? '#e8c26b' : '#5a4a32'
ctx.strokeRect(ox + 22.5, wy + 0.5, 275, 30)
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 + 24, wy + 1, 30, 30)
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 + 32, wy + 9, 12, 12)
ctx.fillRect(ox + 26, wy + 9, 12, 12)
}
font.drawText(ctx, `${wp.nameZh} · ${wp.nameEn}`, ox + 58, wy + 21, {
font.drawText(ctx, `${wp.nameZh} · ${wp.nameEn}`, ox + 80, wy + 21, {
font: 'fontexocet10',
color: wp.unlocked ? (isHovered ? 'white' : 'gold') : 'gray',
})

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