diff --git a/README.md b/README.md index 9581695..30d741d 100644 --- a/README.md +++ b/README.md @@ -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__.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_.png`)与激活/按下高光态(`icon__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//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 ?live=1 不走资源包,直接按 HTTP Range 读 MPQ 解码(慢) ?pack=<目录> 换一个资源包目录 ?base=<目录> 换一个归档目录 +?arena=1&class=&skill=&slvl=&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 完整测试套件 ``` ## 数据与许可 diff --git a/public/skills/bg_ama_0.png b/public/skills/bg_ama_0.png index eb26d12..773a952 100644 Binary files a/public/skills/bg_ama_0.png and b/public/skills/bg_ama_0.png differ diff --git a/public/skills/bg_ama_1.png b/public/skills/bg_ama_1.png index d3f9ecb..82f0ce1 100644 Binary files a/public/skills/bg_ama_1.png and b/public/skills/bg_ama_1.png differ diff --git a/public/skills/bg_ama_2.png b/public/skills/bg_ama_2.png index fef55f9..549569e 100644 Binary files a/public/skills/bg_ama_2.png and b/public/skills/bg_ama_2.png differ diff --git a/public/skills/bg_ass_0.png b/public/skills/bg_ass_0.png index d618bca..b0847e4 100644 Binary files a/public/skills/bg_ass_0.png and b/public/skills/bg_ass_0.png differ diff --git a/public/skills/bg_ass_1.png b/public/skills/bg_ass_1.png index 490e462..97b68b6 100644 Binary files a/public/skills/bg_ass_1.png and b/public/skills/bg_ass_1.png differ diff --git a/public/skills/bg_ass_2.png b/public/skills/bg_ass_2.png index 19b6cab..e6f30cd 100644 Binary files a/public/skills/bg_ass_2.png and b/public/skills/bg_ass_2.png differ diff --git a/public/skills/bg_bar_0.png b/public/skills/bg_bar_0.png index 2e35a9e..b4e85c4 100644 Binary files a/public/skills/bg_bar_0.png and b/public/skills/bg_bar_0.png differ diff --git a/public/skills/bg_bar_1.png b/public/skills/bg_bar_1.png index 3ac0669..687d93c 100644 Binary files a/public/skills/bg_bar_1.png and b/public/skills/bg_bar_1.png differ diff --git a/public/skills/bg_bar_2.png b/public/skills/bg_bar_2.png index b449835..0334fd2 100644 Binary files a/public/skills/bg_bar_2.png and b/public/skills/bg_bar_2.png differ diff --git a/public/skills/bg_dru_0.png b/public/skills/bg_dru_0.png index 00ee6dd..269c1a8 100644 Binary files a/public/skills/bg_dru_0.png and b/public/skills/bg_dru_0.png differ diff --git a/public/skills/bg_dru_1.png b/public/skills/bg_dru_1.png index ab2c1a7..eea7e2c 100644 Binary files a/public/skills/bg_dru_1.png and b/public/skills/bg_dru_1.png differ diff --git a/public/skills/bg_dru_2.png b/public/skills/bg_dru_2.png index 69b08c8..f22006c 100644 Binary files a/public/skills/bg_dru_2.png and b/public/skills/bg_dru_2.png differ diff --git a/public/skills/bg_nec_0.png b/public/skills/bg_nec_0.png index 3736daa..f144927 100644 Binary files a/public/skills/bg_nec_0.png and b/public/skills/bg_nec_0.png differ diff --git a/public/skills/bg_nec_1.png b/public/skills/bg_nec_1.png index 9d0feed..361e638 100644 Binary files a/public/skills/bg_nec_1.png and b/public/skills/bg_nec_1.png differ diff --git a/public/skills/bg_nec_2.png b/public/skills/bg_nec_2.png index 97cdb0f..c5b524b 100644 Binary files a/public/skills/bg_nec_2.png and b/public/skills/bg_nec_2.png differ diff --git a/public/skills/bg_pal_0.png b/public/skills/bg_pal_0.png index dbd974b..1c9ab05 100644 Binary files a/public/skills/bg_pal_0.png and b/public/skills/bg_pal_0.png differ diff --git a/public/skills/bg_pal_1.png b/public/skills/bg_pal_1.png index 5d5af48..6738875 100644 Binary files a/public/skills/bg_pal_1.png and b/public/skills/bg_pal_1.png differ diff --git a/public/skills/bg_pal_2.png b/public/skills/bg_pal_2.png index c00d11b..9bd8c17 100644 Binary files a/public/skills/bg_pal_2.png and b/public/skills/bg_pal_2.png differ diff --git a/public/skills/bg_sor_0.png b/public/skills/bg_sor_0.png index 1aa49ad..96353fa 100644 Binary files a/public/skills/bg_sor_0.png and b/public/skills/bg_sor_0.png differ diff --git a/public/skills/bg_sor_1.png b/public/skills/bg_sor_1.png index b9ca2a9..53eeb32 100644 Binary files a/public/skills/bg_sor_1.png and b/public/skills/bg_sor_1.png differ diff --git a/public/skills/bg_sor_2.png b/public/skills/bg_sor_2.png index 60d54df..6fd05ed 100644 Binary files a/public/skills/bg_sor_2.png and b/public/skills/bg_sor_2.png differ diff --git a/public/ui/skill-tree-tabs.png b/public/ui/skill-tree-tabs.png index 508e870..3645dbc 100644 Binary files a/public/ui/skill-tree-tabs.png and b/public/ui/skill-tree-tabs.png differ diff --git a/scripts/pack-skill-assets.ts b/scripts/pack-skill-assets.ts index 5dcce65..b233080 100644 --- a/scripts/pack-skill-assets.ts +++ b/scripts/pack-skill-assets.ts @@ -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, diff --git a/scripts/pack-ui.ts b/scripts/pack-ui.ts index 60ecf9a..eb70449 100644 --- a/scripts/pack-ui.ts +++ b/scripts/pack-ui.ts @@ -361,6 +361,7 @@ async function main(): Promise { 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 { } } } - // 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 } } diff --git a/src/ui/skill-tree-panel.ts b/src/ui/skill-tree-panel.ts index 1b52be6..239b6fa 100644 --- a/src/ui/skill-tree-panel.ts +++ b/src/ui/skill-tree-panel.ts @@ -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 } } diff --git a/src/ui/world-panels.ts b/src/ui/world-panels.ts index 06b0839..589537f 100644 --- a/src/ui/world-panels.ts +++ b/src/ui/world-panels.ts @@ -153,6 +153,9 @@ export const ACT_WAYPOINTS: Readonly> = ], } +/** 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', }) diff --git a/tests/challenger-m5-stash-entities-gold.test.ts b/tests/challenger-m5-stash-entities-gold.test.ts new file mode 100644 index 0000000..e6154e0 --- /dev/null +++ b/tests/challenger-m5-stash-entities-gold.test.ts @@ -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 = { + 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 = { + 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 = { + 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') + }) + }) +}) diff --git a/tests/challenger-m5-stash-grid.test.ts b/tests/challenger-m5-stash-grid.test.ts new file mode 100644 index 0000000..3b613d6 --- /dev/null +++ b/tests/challenger-m5-stash-grid.test.ts @@ -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) + }) + }) +}) diff --git a/tests/challenger-m6-attack-bindings.test.ts b/tests/challenger-m6-attack-bindings.test.ts new file mode 100644 index 0000000..e8a9abc --- /dev/null +++ b/tests/challenger-m6-attack-bindings.test.ts @@ -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) + }) + }) +}) diff --git a/tests/challenger-m6-attack-combat.test.ts b/tests/challenger-m6-attack-combat.test.ts new file mode 100644 index 0000000..313c488 --- /dev/null +++ b/tests/challenger-m6-attack-combat.test.ts @@ -0,0 +1,1188 @@ +/** + * Challenger Stress Test Suite: Milestone M6 + * + * Adversarially stress-tests: + * 1. Unarmed vs equipped melee weapon combat timing & reach: + * - Unarmed: cooldown (12), swingTicks (6), reach (52), damage (1-2) + * - Dagger: reach (48), cooldown (14), swingTicks (7) + * - Sword/axe/mace/staff: reach (52), cooldown (14), swingTicks (7) + * - Polearm/spear: reach (60), cooldown (14), swingTicks (7) + * 2. Ranged weapons & ammunition requirements: + * - Bow with arrows (aqv): executes attack and launches arrow missile + * - Bow without arrows: rejects attack and alerts missing ammo + * - Crossbow with bolts (cqv): executes attack and launches bolt missile + * - Crossbow without bolts: rejects attack and alerts missing ammo + * 3. Stand-ground Shift-click attack: + * - Shift + left-click and Shift + right-click execute attack in-place towards cursor direction without pathfinding or moving the player + * 4. Continuous attack hold: + * - Holding right-click attacks continuously at cooldown intervals + */ + +import { describe, expect, it, beforeEach } from 'vitest' +import { + getAttackWeaponInfo, + checkAmmunition, + getSkillRange, + castSkill, + SceneMouseController, + screenToWorld, + facingOf, + type AttackWeaponInputItem, + type AttackWeaponInfo, + type MapRuntime, +} from '../src/scene/act-scene.ts' +import { GameEngine, type GameEngineOptions, type WorldMapProvider } from '../src/game/engine.ts' +import type { Projectile } from '../src/game/skills.ts' +import { damageMonster, type Monster } from '../src/game/combat.ts' +import { HudManager } from '../src/ui/hud-manager.ts' +import { ViewportCamera } from '../src/sim/camera.ts' +import { KeyboardInput } from '../src/sim/input.ts' +import { WaypointNetwork } from '../src/game/portal.ts' + +// DOM mocks for headless testing environment +class MockWindow extends EventTarget { + innerWidth = 1920 + innerHeight = 1080 + devicePixelRatio = 1 +} + +class MockCanvas extends EventTarget { + clientWidth = 1920 + clientHeight = 1080 + width = 1920 + height = 1080 + style = { pointerEvents: 'none' } + getBoundingClientRect() { + return { left: 0, top: 0, width: 1920, height: 1080 } + } + getContext() { + return null + } +} + +class SyntheticKeyboardEvent extends Event { + readonly code: string + readonly key: string + + constructor(type: string, init: { code: string; key: string }) { + super(type, { cancelable: true }) + this.code = init.code + this.key = init.key + } +} + +class SyntheticPointerEvent extends Event { + readonly clientX: number + readonly clientY: number + readonly button: number + readonly buttons: number + readonly shiftKey: boolean + private _defaultPrevented = false + + constructor(type: string, init: { clientX: number; clientY: number; button?: number; buttons?: number; shiftKey?: boolean; cancelable?: boolean }) { + super(type, { cancelable: init.cancelable ?? true }) + this.clientX = init.clientX + this.clientY = init.clientY + this.button = init.button ?? 0 + this.buttons = init.buttons !== undefined ? init.buttons : (1 << (init.button ?? 0)) + this.shiftKey = init.shiftKey ?? false + } + + override get defaultPrevented(): boolean { + return this._defaultPrevented + } + + override preventDefault() { + super.preventDefault() + this._defaultPrevented = true + } +} + +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 createCombatTestScene(options?: { + playerPos?: { x: number; y: number } + leftSkillId?: number + rightSkillId?: number + equippedWeapon?: AttackWeaponInputItem | null + equippedAmmo?: { code?: string; name?: string; nameZh?: string } | null + hasAmmo?: boolean + monsters?: Array<{ id: string; name: string; x: number; y: number; hp: number }> +}) { + const canvas = new MockCanvas() as unknown as HTMLCanvasElement + const win = new MockWindow() + const input = new KeyboardInput(win as unknown as Window) + input.attach() + + const dummyTerrain: WorldMapProvider = { + widthPx: 2000, + heightPx: 2000, + overlap: () => 0, + } + + const dummyOpts: GameEngineOptions = { + spawn: options?.playerPos ?? { x: 500, y: 500 }, + stats: (options?.monsters ?? []).map((m) => ({ + id: m.id, + name: m.name, + hp: m.hp, + damage: 10, + cooldownTicks: 100, + reach: 20, + aggroRadius: 100, + speed: 0, + xp: 10, + })), + xpTable: [0, 0, 10, 30, 60], + itemBases: [], + prefixAffixes: [], + suffixAffixes: [], + skills: [], + npcDefs: [], + questDefs: [], + combatOptions: { + playerSpeed: 100, + playerReach: 52, + playerCooldownTicks: 1, + playerDamage: 10, + playerManaPerAttack: 0, + respawnTicks: 100, + }, + lootSeed: 12345, + talkRadius: 80, + pickupRadius: 50, + inventoryCols: 10, + inventoryRows: 4, + } + + const engine = new GameEngine(dummyTerrain, dummyOpts) + if (options?.monsters) { + for (let i = 0; i < options.monsters.length; i++) { + engine.world.monsters[i]!.x = options.monsters[i]!.x + engine.world.monsters[i]!.y = options.monsters[i]!.y + } + } + + const camera = new ViewportCamera(canvas, 2000, 2000) + camera.zoom = 1 + + const hudManager = new HudManager(canvas, { + onToggleAutomap: () => {}, + onWaypointTeleport: () => {}, + }) + hudManager.mana = 200 + hudManager.hotkeys.leftSkillId = options?.leftSkillId ?? 0 + hudManager.hotkeys.rightSkillId = options?.rightSkillId ?? 0 + + if (options?.equippedWeapon !== undefined) { + if (options.equippedWeapon === null) { + delete hudManager.inventory.equipped.weapon1 + } else { + hudManager.inventory.equipped.weapon1 = options.equippedWeapon as any + } + } + if (options?.equippedAmmo !== undefined) { + if (options.equippedAmmo === null) { + delete hudManager.inventory.equipped.weapon2 + } else { + hudManager.inventory.equipped.weapon2 = options.equippedAmmo as any + } + } + + hudManager.syncPublishedState() + + const waypointNetwork = new WaypointNetwork() + const status = { textContent: '' } as HTMLElement + + const runtime: MapRuntime = { + base: 'samples/d2-packs', + act: 1, + level: 'Rogue Encampment', + levelId: 1, + variant: 'default', + variants: ['default'], + quadrant: 'default', + quadrants: ['default'], + cellsX: 100, + cellsY: 100, + widthPx: 2000, + heightPx: 2000, + floors: [], + walls: [], + roofs: [], + objectDrawables: [], + pages: [], + priorityPages: [], + objectPages: [], + grid: { + originX: 0, + originY: 0, + cellsX: 100, + cellsY: 100, + blocked: new Uint8Array(500 * 500), + gridWidth: 500, + }, + npcs: [], + entrances: [], + warps: [], + waypoints: [], + animatedDrawables: [], + animSpeed: 1, + frameDurationMs: 40, + palette: null, + charBases: [], + monsterTypes: [], + monsterPacks: [], + entryPath: '', + loadPages: async () => {}, + } as unknown as MapRuntime + + const controller = new SceneMouseController({ + canvas, + engine, + camera, + input, + getRuntime: () => runtime, + hudManager, + waypointNetwork, + status, + }) + controller.attach() + + return { + canvas, + win, + input, + engine, + camera, + hudManager, + runtime, + status, + controller, + } +} + +function tickScene(scene: ReturnType) { + const mouseStep = scene.controller.tick() + scene.engine.tick({ + movement: mouseStep.movement, + attacking: false, + pickingUp: false, + talking: false, + saving: false, + loading: false, + digits: [], + }) + return mouseStep +} + +describe('Challenger M6 Empirical Stress Test Suite: Attack Execution Mechanics', () => { + // ========================================================================= + // 1. Unarmed vs Equipped Melee Weapon Combat Timing & Reach + // ========================================================================= + describe('1. Unarmed vs Equipped Melee Weapon Combat Timing & Reach', () => { + let engine: GameEngine + let statusElem: HTMLElement + const dummyRuntime: any = { + level: 'test', + grid: { width: 100, height: 100, cells: new Uint8Array(10000) }, + waypoints: [], + } + + beforeEach(() => { + const dummyTerrain = { + widthPx: 1000, + heightPx: 1000, + overlap: () => 0, + } + const dummyOpts: GameEngineOptions = { + spawn: { x: 100, y: 100 }, + stats: [], + xpTable: [0, 100], + itemBases: [], + prefixAffixes: [], + suffixAffixes: [], + skills: [], + npcDefs: [], + questDefs: [], + monsterCount: 0, + combatOptions: { + playerSpeed: 100, + playerReach: 52, + playerCooldownTicks: 10, + playerDamage: 5, + playerManaPerAttack: 0, + respawnTicks: 100, + }, + talkRadius: 80, + pickupRadius: 40, + inventoryCols: 10, + inventoryRows: 4, + } + engine = new GameEngine(dummyTerrain, dummyOpts) + engine.world.player.x = 100 + engine.world.player.y = 100 + engine.world.player.cooldown = 0 + statusElem = { textContent: '' } as any + }) + + it('asserts unarmed timing: cooldown (12), swingTicks (6), reach (52), damage (1-2)', () => { + // Test various null/undefined/empty representations of unarmed + const unarmedCases = [null, undefined, {}, { code: '' }, { code: ' ' }] + for (const c of unarmedCases) { + const info = getAttackWeaponInfo(c as any) + expect(info.kind).toBe('unarmed') + expect(info.subtype).toBe('fist') + expect(info.isRanged).toBe(false) + expect(info.cooldown).toBe(12) + expect(info.swingTicks).toBe(6) + expect(info.reach).toBe(52) + expect(info.damageMin).toBe(1) + expect(info.damageMax).toBe(2) + } + + // In-game execution: monster within 52 reach (distance = 40) + const monster: Monster = { + index: 0, + x: 140, + y: 100, + hp: 50, + cooldown: 0, + hitFlash: 0, + corpseTicks: 0, + state: 'idle', + facing: 0, + stats: { id: 'dummy', name: 'Dummy', hp: 50, damage: 1, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 0, xp: 1 }, + } + engine.world.monsters = [monster] + + const success = castSkill(0, 140, 100, { + engine, + runtime: dummyRuntime, + hudManager: null, + status: statusElem, + equippedWeapon: null, + }) + expect(success).toBe(true) + expect(engine.world.player.cooldown).toBe(12) + expect(engine.world.player.swingTicks).toBe(6) + // Damage should be 1 or 2 (avg 1.5 rounded to 2 or 1) + expect(monster.hp).toBeGreaterThanOrEqual(48) + expect(monster.hp).toBeLessThan(50) + expect(statusElem.textContent).toContain('普通攻击(空手拳击)命中') + + // Whiff outside 52 reach (distance = 53) + engine.world.player.cooldown = 0 + monster.x = 153 + const whiffSuccess = castSkill(0, 153, 100, { + engine, + runtime: dummyRuntime, + hudManager: null, + status: statusElem, + equippedWeapon: null, + }) + expect(whiffSuccess).toBe(true) + expect(engine.world.player.cooldown).toBe(12) + const whiffEvent = engine.world.events.find((e) => e.kind === 'playerHit' && (e as any).text === 'whiff') + expect(whiffEvent).toBeDefined() + }) + + it('asserts dagger category: reach (48), cooldown (14), swingTicks (7)', () => { + const daggers: AttackWeaponInputItem[] = [ + { code: 'dgr', name: 'Dagger', damage: '1-4' }, + { code: 'dir', name: 'Dirk', damage: '3 to 9' }, + { code: 'kri', name: 'Kris', damage: '2-11' }, + { code: 'knf', name: 'Knife', damage: '1 to 5' }, + { type: 'knif', code: 'custom_dagger', nameZh: '匕首', damage: '5-10' }, + ] + + for (const d of daggers) { + const info = getAttackWeaponInfo(d) + expect(info.kind).toBe('melee') + expect(info.subtype).toBe('dagger') + expect(info.isRanged).toBe(false) + expect(info.reach).toBe(48) + expect(info.cooldown).toBe(14) + expect(info.swingTicks).toBe(7) + } + + // Reach boundary verification: distance 46 px (within 48) hits + const monster: Monster = { + index: 0, + x: 146, + y: 100, + hp: 100, + cooldown: 0, + hitFlash: 0, + corpseTicks: 0, + state: 'idle', + facing: 0, + stats: { id: 'd1', name: 'Target', hp: 100, damage: 1, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 0, xp: 1 }, + } + engine.world.monsters = [monster] + + const hit = castSkill(0, 146, 100, { + engine, + runtime: dummyRuntime, + hudManager: null, + status: statusElem, + equippedWeapon: daggers[0], + }) + expect(hit).toBe(true) + expect(engine.world.player.cooldown).toBe(14) + expect(engine.world.player.swingTicks).toBe(7) + expect(monster.hp).toBeLessThan(100) + + // Reach boundary verification: distance 50 px (> 48 reach of dagger, but < 52 of sword) + engine.world.player.cooldown = 0 + monster.x = 150 + monster.hp = 100 + engine.world.events = [] + castSkill(0, 150, 100, { + engine, + runtime: dummyRuntime, + hudManager: null, + status: statusElem, + equippedWeapon: daggers[0], // dagger reach 48 + }) + expect(monster.hp).toBe(100) // undamaged! Whiffed due to 48 reach + const whiffEvent = engine.world.events.find((e) => e.kind === 'playerHit' && (e as any).text === 'whiff') + expect(whiffEvent).toBeDefined() + }) + + it('asserts sword / axe / mace / staff category: reach (52), cooldown (14), swingTicks (7)', () => { + const weapons: AttackWeaponInputItem[] = [ + // Swords + { code: 'crs', name: 'Crystal Sword', type: 'swor', damage: '5 to 15' }, + { code: 'bsd', name: 'Broad Sword', damage: '7-14' }, + { code: 'ssd', name: 'Short Sword', damage: '2 to 7' }, + { code: 'clm', name: 'Claymore', damage: '13-30' }, + { code: 'custom_sw', nameZh: '长剑', type: 'swor' }, + // Axes + { code: 'hax', name: 'Hand Axe', type: 'axe', damage: '3-6' }, + { code: '2ax', name: 'Double Axe', damage: '5 to 13' }, + { code: 'gax', name: 'Great Axe', damage: '9-30' }, + { code: 'custom_ax', nameZh: '狂战士斧', type: 'axe' }, + // Maces + { code: 'clb', name: 'Club', type: 'club', damage: '1-6' }, + { code: 'mac', name: 'Mace', type: 'mace', damage: '3-10' }, + { code: 'mst', name: 'Morning Star', damage: '7 to 16' }, + { code: 'fla', name: 'Flail', damage: '1-24' }, + { code: 'whm', name: 'War Hammer', type: 'hamm', damage: '19 to 29' }, + { code: 'spt', name: 'Scepter', type: 'scep', damage: '6-11' }, + { code: 'custom_mc', nameZh: '神使之杖', type: 'scep' }, + // Staffs + { code: 'sst', name: 'Short Staff', type: 'staf', damage: '1-5' }, + { code: 'lst', name: 'Long Staff', damage: '6 to 13' }, + { code: 'cst', name: 'Composite Staff', damage: '4-12' }, + { code: 'wst', name: 'War Staff', damage: '12-28' }, + { code: 'custom_st', nameZh: '法师长杖', type: 'staf' }, + ] + + for (const w of weapons) { + const info = getAttackWeaponInfo(w) + expect(info.kind).toBe('melee') + expect(info.isRanged).toBe(false) + expect(info.reach).toBe(52) + expect(info.cooldown).toBe(14) + expect(info.swingTicks).toBe(7) + expect(['sword', 'axe', 'mace', 'staff']).toContain(info.subtype) + } + + // Distance 50 px is within reach 52: hits + const monster: Monster = { + index: 0, + x: 150, + y: 100, + hp: 100, + cooldown: 0, + hitFlash: 0, + corpseTicks: 0, + state: 'idle', + facing: 0, + stats: { id: 'd2', name: 'Target', hp: 100, damage: 1, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 0, xp: 1 }, + } + engine.world.monsters = [monster] + + const sword = weapons[0] + const hit = castSkill(0, 150, 100, { + engine, + runtime: dummyRuntime, + hudManager: null, + status: statusElem, + equippedWeapon: sword, + }) + expect(hit).toBe(true) + expect(engine.world.player.cooldown).toBe(14) + expect(engine.world.player.swingTicks).toBe(7) + expect(monster.hp).toBe(90) // 100 - avg(5, 15) = 90 + + // Distance 55 px is outside reach 52: whiffs for sword + engine.world.player.cooldown = 0 + monster.x = 155 + monster.hp = 100 + engine.world.events = [] + castSkill(0, 155, 100, { + engine, + runtime: dummyRuntime, + hudManager: null, + status: statusElem, + equippedWeapon: sword, + }) + expect(monster.hp).toBe(100) // undamaged + expect(engine.world.events.some((e) => (e as any).text === 'whiff')).toBe(true) + }) + + it('asserts polearm / spear category: reach (60), cooldown (14), swingTicks (7)', () => { + const polearmSpears: AttackWeaponInputItem[] = [ + // Polearms + { code: 'bar', name: 'Bardiche', type: 'pole', damage: '1-27' }, + { code: 'vou', name: 'Voulge', damage: '6 to 21' }, + { code: 'scy', name: 'Scythe', damage: '8-20' }, + { code: 'hal', name: 'Halberd', damage: '9 to 35' }, + { code: 'wsc', name: 'War Scythe', damage: '15-36' }, + { code: 'custom_pole', nameZh: '长镰', type: 'pole' }, + // Spears + { code: 'spr', name: 'Spear', type: 'spea', damage: '3-15' }, + { code: 'tri', name: 'Trident', damage: '9 to 15' }, + { code: 'brn', name: 'Brandistock', damage: '7-16' }, + { code: 'spt', name: 'Spetum', damage: '15 to 23' }, + { code: 'pik', name: 'Pike', damage: '14-63' }, + { code: 'jav', name: 'Javelin', type: 'jave', damage: '6 to 14' }, + { code: 'custom_sp', nameZh: '战矛', type: 'spea' }, + ] + + for (const ps of polearmSpears) { + const info = getAttackWeaponInfo(ps) + expect(info.kind).toBe('melee') + expect(info.isRanged).toBe(false) + expect(info.reach).toBe(60) + expect(info.cooldown).toBe(14) + expect(info.swingTicks).toBe(7) + expect(['polearm', 'spear']).toContain(info.subtype) + } + + // Distance 55 px: hits with polearm (reach 60) where sword (reach 52) whiffs! + const monster: Monster = { + index: 0, + x: 155, + y: 100, + hp: 100, + cooldown: 0, + hitFlash: 0, + corpseTicks: 0, + state: 'idle', + facing: 0, + stats: { id: 'd3', name: 'Target', hp: 100, damage: 1, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 0, xp: 1 }, + } + engine.world.monsters = [monster] + + const voulge = polearmSpears[1] + const hit = castSkill(0, 155, 100, { + engine, + runtime: dummyRuntime, + hudManager: null, + status: statusElem, + equippedWeapon: voulge, + }) + expect(hit).toBe(true) + expect(engine.world.player.cooldown).toBe(14) + expect(engine.world.player.swingTicks).toBe(7) + expect(monster.hp).toBeLessThan(100) + + // Distance 65 px: whiffs even for polearm (reach 60) + engine.world.player.cooldown = 0 + monster.x = 165 + monster.hp = 100 + engine.world.events = [] + castSkill(0, 165, 100, { + engine, + runtime: dummyRuntime, + hudManager: null, + status: statusElem, + equippedWeapon: voulge, + }) + expect(monster.hp).toBe(100) + expect(engine.world.events.some((e) => (e as any).text === 'whiff')).toBe(true) + }) + }) + + // ========================================================================= + // 2. Ranged Weapons & Ammunition Requirements + // ========================================================================= + describe('2. Ranged Weapons & Ammunition Requirements', () => { + let engine: GameEngine + let statusElem: HTMLElement + const dummyRuntime: any = { + level: 'test', + grid: { width: 100, height: 100, cells: new Uint8Array(10000) }, + waypoints: [], + } + + beforeEach(() => { + const dummyTerrain = { + widthPx: 1000, + heightPx: 1000, + overlap: () => 0, + } + const dummyOpts: GameEngineOptions = { + spawn: { x: 100, y: 100 }, + stats: [], + xpTable: [0, 100], + itemBases: [], + prefixAffixes: [], + suffixAffixes: [], + skills: [], + npcDefs: [], + questDefs: [], + monsterCount: 0, + combatOptions: { + playerSpeed: 100, + playerReach: 52, + playerCooldownTicks: 10, + playerDamage: 5, + playerManaPerAttack: 0, + respawnTicks: 100, + }, + talkRadius: 80, + pickupRadius: 40, + inventoryCols: 10, + inventoryRows: 4, + } + engine = new GameEngine(dummyTerrain, dummyOpts) + engine.world.player.x = 100 + engine.world.player.y = 100 + engine.world.player.cooldown = 0 + statusElem = { textContent: '' } as any + }) + + it('bow with arrows (aqv): executes attack and launches arrow missile', () => { + const bow: AttackWeaponInputItem = { + code: 'sbw', + name: 'Short Bow', + damage: '10 to 20', + } + const arrows = { code: 'aqv', name: 'Arrows', nameZh: '箭矢' } + + const success = castSkill(0, 300, 100, { + engine, + runtime: dummyRuntime, + hudManager: null, + status: statusElem, + equippedWeapon: bow, + equippedAmmo: arrows, + }) + + expect(success).toBe(true) + expect(engine.world.player.cooldown).toBe(14) + expect(engine.world.player.swingTicks).toBe(7) + expect(engine.projectiles.length).toBe(1) + + const proj = engine.projectiles[0] + expect(proj.skillId).toBe('0') + expect(proj.fromPlayer).toBe(true) + expect(proj.damage).toBe(15) // average of 10 and 20 + expect(proj.vx).toBeGreaterThan(0) + expect(proj.vy).toBe(0) + expect(statusElem.textContent).toContain('发射箭矢') + }) + + it('bow without arrows: rejects attack and alerts missing ammo', () => { + const bow: AttackWeaponInputItem = { + code: 'sbw', + name: 'Short Bow', + damage: '10 to 20', + } + + // No ammo provided + const success = castSkill(0, 300, 100, { + engine, + runtime: dummyRuntime, + hudManager: null, + status: statusElem, + equippedWeapon: bow, + equippedAmmo: null, + hasAmmo: false, + }) + + expect(success).toBe(false) + expect(engine.world.player.cooldown).toBe(0) // cooldown NOT consumed + expect(engine.projectiles.length).toBe(0) + expect(statusElem.textContent).toBe('缺少箭矢!') + }) + + it('crossbow with bolts (cqv): executes attack and launches bolt missile', () => { + const crossbow: AttackWeaponInputItem = { + code: 'lxb', + name: 'Light Crossbow', + damage: '12 to 24', + } + const bolts = { code: 'cqv', name: 'Bolts', nameZh: '十字弓弹' } + + const success = castSkill(0, 300, 100, { + engine, + runtime: dummyRuntime, + hudManager: null, + status: statusElem, + equippedWeapon: crossbow, + equippedAmmo: bolts, + }) + + expect(success).toBe(true) + expect(engine.world.player.cooldown).toBe(16) // crossbow cooldown 16 + expect(engine.world.player.swingTicks).toBe(8) // crossbow swingTicks 8 + expect(engine.projectiles.length).toBe(1) + + const proj = engine.projectiles[0] + expect(proj.skillId).toBe('0') + expect(proj.fromPlayer).toBe(true) + expect(proj.damage).toBe(18) // average of 12 and 24 + expect(proj.vx).toBeGreaterThan(0) + expect(statusElem.textContent).toContain('发射弩箭') + }) + + it('crossbow without bolts: rejects attack and alerts missing ammo', () => { + const crossbow: AttackWeaponInputItem = { + code: 'lxb', + name: 'Light Crossbow', + damage: '12 to 24', + } + + const success = castSkill(0, 300, 100, { + engine, + runtime: dummyRuntime, + hudManager: null, + status: statusElem, + equippedWeapon: crossbow, + equippedAmmo: null, + hasAmmo: false, + }) + + expect(success).toBe(false) + expect(engine.world.player.cooldown).toBe(0) + expect(engine.projectiles.length).toBe(0) + expect(statusElem.textContent).toBe('缺少十字弓弹!') + }) + + it('strictly enforces ammo cross-check: bow rejects bolts, crossbow rejects arrows', () => { + const bow: AttackWeaponInputItem = { code: 'sbw', name: 'Short Bow' } + const crossbow: AttackWeaponInputItem = { code: 'lxb', name: 'Light Crossbow' } + const arrows = { code: 'aqv', name: 'Arrows' } + const bolts = { code: 'cqv', name: 'Bolts' } + + // Bow + Bolts -> Rejected! + const bowWithBolts = castSkill(0, 300, 100, { + engine, + runtime: dummyRuntime, + hudManager: null, + status: statusElem, + equippedWeapon: bow, + equippedAmmo: bolts, + }) + expect(bowWithBolts).toBe(false) + expect(statusElem.textContent).toBe('缺少箭矢!') + expect(engine.projectiles.length).toBe(0) + + // Crossbow + Arrows -> Rejected! + const xbowWithArrows = castSkill(0, 300, 100, { + engine, + runtime: dummyRuntime, + hudManager: null, + status: statusElem, + equippedWeapon: crossbow, + equippedAmmo: arrows, + }) + expect(xbowWithArrows).toBe(false) + expect(statusElem.textContent).toBe('缺少十字弓弹!') + expect(engine.projectiles.length).toBe(0) + }) + + it('detects ammunition across inventory grid, items list, and offhand slot', () => { + // 1. Offhand slot + expect(checkAmmunition('arrow', { code: 'aqv' }, null)).toBe(true) + expect(checkAmmunition('bolt', { code: 'cqv' }, null)).toBe(true) + expect(checkAmmunition('arrow', { code: '0qv' }, null)).toBe(true) + expect(checkAmmunition('bolt', { code: '1qv' }, null)).toBe(true) + + // 2. Inventory gridItems + const invWithGrid = { + gridItems: [{ item: { code: 'aqv', name: 'Arrows' } }], + } + expect(checkAmmunition('arrow', null, invWithGrid)).toBe(true) + expect(checkAmmunition('bolt', null, invWithGrid)).toBe(false) + + // 3. Inventory items array + const invWithItems = { + items: [{ code: 'cqv', nameZh: '十字弓弹' }], + } + expect(checkAmmunition('bolt', null, invWithItems)).toBe(true) + expect(checkAmmunition('arrow', null, invWithItems)).toBe(false) + + // 4. Completely empty + expect(checkAmmunition('arrow', null, { items: [], gridItems: [] })).toBe(false) + }) + }) + + // ========================================================================= + // 3. Stand-Ground Shift-Click Attack + // ========================================================================= + describe('3. Stand-Ground Shift-Click Attack (In-Place Execution)', () => { + it('executes Shift + left-click attack in-place towards cursor direction without pathfinding or moving player', () => { + const scene = createCombatTestScene({ + playerPos: { x: 500, y: 500 }, + leftSkillId: 0, // Attack + equippedWeapon: { + code: 'crs', + name: 'Crystal Sword', + damage: '10 to 20', + }, + }) + + // Hold Shift + scene.win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'ShiftLeft', key: 'Shift' })) + expect(scene.input.shiftHeld).toBe(true) + + // Click East: clientX = 960 + 150 = 1110, clientY = 556 + scene.canvas.dispatchEvent( + new SyntheticPointerEvent('pointerdown', { + clientX: 1110, + clientY: 556, + button: 0, + shiftKey: true, + }) + ) + + // Invariants: + // 1. Navigation target must be strictly null (no pathfinding) + expect(scene.controller.navTarget).toBeNull() + // 2. Pending interaction must be strictly null + expect(scene.controller.pendingInteraction).toBeNull() + // 3. Player faces cursor direction (East = 6) + expect(scene.engine.world.player.facing).toBe(6) + // 4. Attack was executed (player cooldown set to 14) + expect(scene.engine.world.player.cooldown).toBe(14) + // 5. Player world position unchanged + expect(scene.engine.world.player.x).toBe(500) + expect(scene.engine.world.player.y).toBe(500) + + // Tick scene: movement must be strictly zero + const tick = tickScene(scene) + expect(tick.movement).toEqual({ x: 0, y: 0 }) + expect(scene.engine.world.player.x).toBe(500) + expect(scene.engine.world.player.y).toBe(500) + }) + + it('executes Shift + right-click attack in-place towards cursor direction without pathfinding or moving player', () => { + const scene = createCombatTestScene({ + playerPos: { x: 500, y: 500 }, + rightSkillId: 0, // Attack + equippedWeapon: { + code: 'crs', + name: 'Crystal Sword', + damage: '10 to 20', + }, + }) + + // Hold Shift + scene.win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'ShiftRight', key: 'Shift' })) + expect(scene.input.shiftHeld).toBe(true) + + // Click North: clientX = 960, clientY = 556 - 150 = 406 + scene.canvas.dispatchEvent( + new SyntheticPointerEvent('pointerdown', { + clientX: 960, + clientY: 406, + button: 2, + shiftKey: true, + }) + ) + + // Invariants: + expect(scene.controller.navTarget).toBeNull() + expect(scene.controller.pendingInteraction).toBeNull() + // Player faces cursor direction (North = 4) + expect(scene.engine.world.player.facing).toBe(4) + expect(scene.engine.world.player.cooldown).toBe(14) + expect(scene.engine.world.player.x).toBe(500) + expect(scene.engine.world.player.y).toBe(500) + + const tick = tickScene(scene) + expect(tick.movement).toEqual({ x: 0, y: 0 }) + expect(scene.engine.world.player.x).toBe(500) + expect(scene.engine.world.player.y).toBe(500) + }) + + it('does not pathfind or move towards distant monster when Shift-clicking on monster', () => { + const scene = createCombatTestScene({ + playerPos: { x: 500, y: 500 }, + leftSkillId: 0, + equippedWeapon: { + code: 'crs', + name: 'Crystal Sword', + damage: '10 to 20', + }, + monsters: [{ id: 'far_mon', name: 'Far Skeleton', x: 800, y: 800, hp: 100 }], + }) + + // Hold Shift + scene.win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'ShiftLeft', key: 'Shift' })) + + // Click towards distant monster (300px away) + const clientX = 960 + (800 - 500) + const clientY = 556 + (800 - 500) + scene.canvas.dispatchEvent( + new SyntheticPointerEvent('pointerdown', { + clientX, + clientY, + button: 0, + shiftKey: true, + }) + ) + + // Stand-ground prevents pathfinding + expect(scene.controller.navTarget).toBeNull() + expect(scene.controller.pendingInteraction).toBeNull() + + // Ticking does not move player towards monster + for (let i = 0; i < 10; i++) { + const tick = tickScene(scene) + expect(tick.movement).toEqual({ x: 0, y: 0 }) + } + expect(scene.engine.world.player.x).toBe(500) + expect(scene.engine.world.player.y).toBe(500) + }) + }) + + // ========================================================================= + // 4. Continuous Attack Hold + // ========================================================================= + describe('4. Continuous Attack Hold (Cooldown Interval Cadence)', () => { + it('attacks continuously at 14-tick cooldown intervals when holding right-click with bow', () => { + const scene = createCombatTestScene({ + playerPos: { x: 500, y: 500 }, + rightSkillId: 0, + equippedWeapon: { + code: 'sbw', + name: 'Short Bow', + damage: '10 to 20', + }, + equippedAmmo: { + code: 'aqv', + name: 'Arrows', + }, + }) + + // Mouse down right-click at East (1110, 556) + scene.canvas.dispatchEvent( + new SyntheticPointerEvent('pointerdown', { + clientX: 1110, + clientY: 556, + button: 2, + }) + ) + + // Attack #1 fired immediately on pointerdown (tick 0) + expect(scene.engine.metrics.casts).toBe(1) + expect(scene.engine.world.player.cooldown).toBe(14) + + // Ticks 1..14: Cooldown is ticking down. No new attack must fire! + for (let tick = 1; tick <= 14; tick++) { + tickScene(scene) + expect(scene.engine.metrics.casts, `Tick ${tick} should not fire early`).toBe(1) + } + expect(scene.engine.world.player.cooldown).toBe(0) + + // Tick 15: Exactly 14 ticks after tick 1. Continuous attack #2 fires! + tickScene(scene) + expect(scene.engine.metrics.casts).toBe(2) + expect(scene.engine.world.player.cooldown).toBe(13) // reset to 14, minus 1 + + // Ticks 16..28: 13 ticks of cooldown + for (let tick = 16; tick <= 28; tick++) { + tickScene(scene) + expect(scene.engine.metrics.casts).toBe(2) + } + expect(scene.engine.world.player.cooldown).toBe(0) + + // Tick 29: Exactly 14 ticks after tick 15. Continuous attack #3 fires! + tickScene(scene) + expect(scene.engine.metrics.casts).toBe(3) + + // Release right mouse button + scene.canvas.dispatchEvent( + new SyntheticPointerEvent('pointerup', { + clientX: 1110, + clientY: 556, + button: 2, + }) + ) + + // Subsequent ticks should NOT fire any more attacks + for (let tick = 30; tick <= 50; tick++) { + tickScene(scene) + } + expect(scene.engine.metrics.casts).toBe(3) + }) + + it('attacks continuously at 14-tick cooldown intervals when holding right-click on a melee monster target', () => { + const scene = createCombatTestScene({ + playerPos: { x: 500, y: 500 }, + rightSkillId: 0, + equippedWeapon: { + code: 'crs', + name: 'Crystal Sword', + damage: '20 to 30', // avg damage = 25 + }, + monsters: [{ id: 'dummy_mon', name: 'Dummy Monster', x: 530, y: 500, hp: 100 }], + }) + + const monster = scene.engine.world.monsters[0]! + expect(monster.hp).toBe(100) + + // Click directly on monster (distance 30 <= reach 52) + const clientX = 960 + 30 + const clientY = 556 + scene.canvas.dispatchEvent( + new SyntheticPointerEvent('pointerdown', { + clientX, + clientY, + button: 2, + }) + ) + + // Hit #1 fired on pointerdown + expect(monster.hp).toBe(75) // 100 - 25 + expect(scene.engine.world.player.cooldown).toBe(14) + + // Ticks 1..14: cooldown ticks down, no extra damage + for (let tick = 1; tick <= 14; tick++) { + tickScene(scene) + expect(monster.hp).toBe(75) + } + expect(scene.engine.world.player.cooldown).toBe(0) + + // Tick 15: Hit #2 fires (interval of 14 ticks) + tickScene(scene) + expect(monster.hp).toBe(50) // 75 - 25 + expect(scene.engine.world.player.cooldown).toBe(13) + + // Ticks 16..28: cooldown ticks down + for (let tick = 16; tick <= 28; tick++) { + tickScene(scene) + expect(monster.hp).toBe(50) + } + expect(scene.engine.world.player.cooldown).toBe(0) + + // Tick 29: Hit #3 fires (interval of 14 ticks) + tickScene(scene) + expect(monster.hp).toBe(25) // 50 - 25 + + // Release right mouse button + scene.canvas.dispatchEvent( + new SyntheticPointerEvent('pointerup', { + clientX, + clientY, + button: 2, + }) + ) + + // Ticks 30..50: No further attacks + for (let tick = 30; tick <= 50; tick++) { + tickScene(scene) + } + expect(monster.hp).toBe(25) + }) + + it('attacks continuously at exact 12-tick interval for unarmed combat', () => { + const scene = createCombatTestScene({ + playerPos: { x: 500, y: 500 }, + rightSkillId: 0, + equippedWeapon: null, // unarmed (cooldown 12) + monsters: [{ id: 'punching_bag', name: 'Punching Bag', x: 530, y: 500, hp: 100 }], + }) + + const monster = scene.engine.world.monsters[0]! + let hitsCount = 0 + + // Pointer down on monster + scene.canvas.dispatchEvent( + new SyntheticPointerEvent('pointerdown', { + clientX: 960 + 30, + clientY: 556, + button: 2, + }) + ) + hitsCount++ // hit #1 at tick 0 + expect(scene.engine.world.player.cooldown).toBe(12) + + // Simulate 37 ticks: hits should occur at tick 13 (after 12 ticks), tick 25 (+12 ticks), tick 37 (+12 ticks) + const hitTicks: number[] = [] + for (let tick = 1; tick <= 37; tick++) { + const prevHp = monster.hp + tickScene(scene) + if (monster.hp < prevHp) { + hitTicks.push(tick) + hitsCount++ + } + } + + expect(hitsCount).toBe(4) // ticks 0, 13, 25, 37 + expect(hitTicks).toEqual([13, 25, 37]) + // Assert each interval between consecutive hits is exactly 12 ticks + expect(hitTicks[1] - hitTicks[0]).toBe(12) + expect(hitTicks[2] - hitTicks[1]).toBe(12) + }) + + it('attacks continuously at exact 16-tick interval for crossbow combat', () => { + const scene = createCombatTestScene({ + playerPos: { x: 500, y: 500 }, + rightSkillId: 0, + equippedWeapon: { + code: 'lxb', + name: 'Light Crossbow', + damage: '10 to 20', + }, + equippedAmmo: { + code: 'cqv', + name: 'Bolts', + }, + }) + + scene.canvas.dispatchEvent( + new SyntheticPointerEvent('pointerdown', { + clientX: 1110, + clientY: 556, + button: 2, + }) + ) + + expect(scene.engine.metrics.casts).toBe(1) // projectile #1 at tick 0 + expect(scene.engine.world.player.cooldown).toBe(16) + + // Simulate 33 ticks: new projectile should fire at tick 17 (+16 ticks) and tick 33 (+16 ticks) + const fireTicks: number[] = [] + for (let tick = 1; tick <= 33; tick++) { + const prevCount = scene.engine.metrics.casts + tickScene(scene) + if (scene.engine.metrics.casts > prevCount) { + fireTicks.push(tick) + } + } + + expect(scene.engine.metrics.casts).toBe(3) + expect(fireTicks).toEqual([17, 33]) + expect(fireTicks[1] - fireTicks[0]).toBe(16) + }) + }) +}) diff --git a/tests/challenger-m7-skill-formulas.test.ts b/tests/challenger-m7-skill-formulas.test.ts new file mode 100644 index 0000000..708741e --- /dev/null +++ b/tests/challenger-m7-skill-formulas.test.ts @@ -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) + } + } + }) + }) +}) diff --git a/tests/challenger-m7-tooltip-clamping.test.ts b/tests/challenger-m7-tooltip-clamping.test.ts new file mode 100644 index 0000000..2263297 --- /dev/null +++ b/tests/challenger-m7-tooltip-clamping.test.ts @@ -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') + }) + }) +}) diff --git a/tests/challenger-m8-docking-stress.test.ts b/tests/challenger-m8-docking-stress.test.ts new file mode 100644 index 0000000..a4f2630 --- /dev/null +++ b/tests/challenger-m8-docking-stress.test.ts @@ -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) + }) + }) +}) diff --git a/tests/challenger-m8-input-routing.test.ts b/tests/challenger-m8-input-routing.test.ts new file mode 100644 index 0000000..574d9aa --- /dev/null +++ b/tests/challenger-m8-input-routing.test.ts @@ -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) + }) + }) + } + }) +})