feat(client,netproto): complete D2GS v1.13c online gameplay loops (R1-R5: item tooltips, container ops, NPC dialogue/travel, mercenaries, warps/portals/SkillCast/death, party/trade/quest panels)

This commit is contained in:
troytt 2026-10-02 06:08:32 +00:00
parent ff1f8e4cbe
commit 8dac79e4e4
46 changed files with 14725 additions and 672 deletions

View File

@ -1,84 +1,66 @@
# Project: diablo2-web Module Split & Single Entry Consolidation
# Project: D2GS v1.13c Online Gameplay Loops & Server Authority (`diablo2-web`)
## Architecture
`diablo2-web` is refactored in-repo on `/usr/local/google/home/taodao/diablo2-web/.worktrees/module-split` into five strictly isolated, compiler-enforced TypeScript modules under `src/` plus developer/verification CLI scripts under `tools/` and a single Web HTML entry (`play.html`):
The Diablo II v1.13c Web Client (`diablo2-web`) is structured into strictly isolated modules enforcing server-authoritative online gameplay over the D2GS v1.13c protocol:
```text
diablo2-web/
├── play.html # Sole HTML entry point (loads /src/client/main.ts)
├── src/
│ ├── common/ # D2Common.dll + Storm.dll + D2Lang.dll (ES2022, types: [], zero DOM/Node)
│ │ ├── mpq/ # Pure MPQ archive & decompression (no file-source.ts)
│ │ ├── formats/ # DC6, DCC, DT1, DS1, COF, PL2, PAL, CEL, PCX, TBL, AnimData, Bitstream
│ │ ├── data/ # Excel .txt data-registry (injected ArchiveSource, no hardcoded d2-data) & canonical tables
│ │ ├── drlg/ # 1:1 1.13c DRLG map generation, collision masks, preset units, warps, light-grid
│ │ ├── world/ # acts, act-variants, levels-meta, level-links, level-names-zh, world-graph, map, d2map, warp-tiles, automap-table, animated-tiles timing
│ │ ├── items/ # Static item/affix/set/unique/runeword/gem tables, drop-tables, inventory-model, belt-model, item-bridge, item-cost, item-upgrade, item-tooltip, ground-item-physics
│ │ ├── skills/ # Static Skills.txt/Missiles.txt/Overlay.txt tables, skill-tree, skill-calc-engine, formulas, calc-ast, skills-meta
│ │ ├── units/ # classes, character, monster-mapping, monsters-data, npc-table, object-lookup, objects, plr-mode, pure monster-art compositor
│ │ ├── i18n/ # Pure .tbl lookup (lookupTbl, TblLang, GameLang) & tooltip-i18n decoupled from UI lang state
│ │ ├── rng/ # d2-rng, SplitMix64 pickVariant (variants.ts)
│ │ └── pack-contract/ # Shared tile/scene/entity pack manifest schemas & fail-fast universal DT1 / missingFiles===0 validators
│ ├── netproto/ # D2Net.dll + Bnclient.dll + D2MCPClient.dll (strictly isolated; external code imports only src/netproto/index.ts)
│ ├── server/ # D2Game.dll (ES2022 + WebWorker, types: [], zero DOM, zero client/baker imports, zero Math.random/Date.now/performance.now)
│ │ ├── engine/ # GameEngine, world-arena, combat-pipeline, missile-engine, state-bus, aura-scanner, summon-manager, anim-dispatcher, environment, stat-list, state-hash
│ │ ├── ai/ # Monster AI, spawning, monster-rooms, monster-streaming
│ │ ├── skills/ # Skill registry (static imports, zero node:*), skills simulation, impl/{ama,ass,bar,dru,nec,pal,sor,uni}
│ │ ├── items/ # drop-pipeline, treasure-class, treasure-engine, auto-tc, affix-generator, automagic, ground-items, runeword-socket, gems, vendor-generator, vendor-service
│ │ ├── world/ # objects interaction, quests, npc, portal, class-starter-profiles
│ │ ├── save/ # .d2s character save read/write
│ │ ├── scene/ # chat-commands, scene-links-nav, scene-population, skill-caster, weapon-combat
│ │ └── adapter/ # LocalLoopbackAdapter implementing GameServerAdapter from src/netproto/index.ts
│ ├── client/ # D2Client.dll + D2Gfx.dll + D2Win.dll + D2Launch.dll + D2Sound.dll (ES2022 + DOM + DOM.Iterable, types: [], zero Node/server/baker imports)
│ │ ├── main.ts # Sole browser entry point for play.html
│ │ ├── render/ # WebGL2/Canvas renderer, atlas, cull, roofs, unit-sprites, missile-renderer, missiles-meta, overlays-meta, monster-art atlas loader, char-appearance
│ │ ├── ui/ # HUD, inventory UI, belt UI, character-sheet, control-bar, cursor, font, globes, ground-labels, hotkeys, minimap, skill-tree-panel, world-panels, baked-ui-meta
│ │ ├── frontend/ # Title, login, character-select, lobby state & renderer (flow-state.ts, frontend-renderer.ts)
│ │ ├── scene/ # actor-animator, animation, entity-art-loader, ground-item-renderer, lighting-presets, missile-overlay-renderer, mouse-controller, object-animation, overhead-labels, pack-loader, scene-state, toolbar-chat-ui, transition, world-renderer
│ │ ├── audio/ # AudioManager & sound-service
│ │ ├── sim/ # Camera, Input, Loop
│ │ ├── i18n/ # Browser URL/DOM/localStorage language state & TOOLBAR_I18N
│ │ └── {assets,automap,input,inspector,map,motion,session,settings,toolbar,ui-model,view,world}/
│ └── baker/ # Node-only offline asset & table baker (ES2022, types: ["node"], zero DOM, imports only src/baker/** and src/common/**)
│ ├── cli.ts # Unified CLI (npm run bake -- <target> | --verify)
│ ├── io/ # file-source.ts, png.ts, drlg-mpq-source.ts, tbl-writer.ts, publish-packs.ts
│ ├── packers/ # 16 pack-*.ts scripts + rebuild-pack-index-and-monsters.ts (incl. SuperUnique minion DCC/DC6 closure)
│ ├── extract/ # 11 table/font/code extractors (extract-d2net-tables, extract-d2moo-tables, gen-drlg-ids, port-object-lookup, build-d2-ttf, etc.)
│ └── audit/ # verify-packs.ts, verify-entity-packs.ts, verify-tiles.ts, verify-tile-alignment.ts
├── tools/ # Developer, Bot, Replay & Verification CLI Tools (d2-bot.ts, netproto-replay.ts, d2s-unlock.ts, verify-play.ts, audit-*.ts, etc.)
└── tests/ # Unit, architecture boundary, integration, and E2E test suites
│ ├── common/
│ │ ├── items/ # 1.13c item tables (embedded-drop-tables.ts), stat formatter (item-bridge.ts), tooltip builder (item-tooltip.ts), ground-items.ts
│ │ └── units/ # All-Act Town & Quest NPC descriptors (npc-table.ts: TOWN_NPC_DESCRIPTORS, NPC_HCIDX_TO_DESCRIPTOR_ID)
│ ├── netproto/
│ │ ├── index.ts # Sole public barrel export for netproto (strict boundary for src/client/**)
│ │ ├── domain/ # Typed ClientCommand (client-command.ts), ServerEvent (server-event.ts), ItemData & UnitSnapshot (ids.ts)
│ │ └── d2gs/
│ │ ├── tables/ # Authoritative 1.13c packet size tables (s2c-sizes.ts: C2S_PACKET_SIZES, S2C_PACKET_SIZES)
│ │ ├── items/ # 0x9C/0x9D item bitstream decoder (item-bitstream.ts)
│ │ ├── c2s/ # C2S binary packet encoders (items.ts, npc.ts, party.ts, movement.ts, interact.ts, ui.ts)
│ │ ├── s2c/ # S2C binary packet decoders (units.ts, npc.ts, merc.ts, party.ts, trade.ts, waypoint.ts, combat.ts)
│ │ └── registry.ts # encodeClientCommand & decodeD2gsServerPacket dispatchers
│ └── client/
│ ├── world/ # Authoritative ClientWorld state reducer (client-world.ts, client-unit.ts, inventory.ts, self.ts)
│ ├── view/ # Scene drawables & picking (unit-drawables.ts, scene-source.ts)
│ ├── scene/ # WorldRenderer (world-renderer.ts)
│ ├── session/ # OnlineSession (online-session.ts)
│ ├── input/ # CommandMapper (command-mapper.ts)
│ ├── ui-model/ # HudModel (hud-model.ts): syncs ClientWorld -> HudManager/WorldPanelsHud & emits ClientCommand
│ └── ui/ # HudManager (hud-manager.ts), WorldPanelsHud (world-panels.ts), InventoryPanel (inventory.ts), Minimap (minimap.ts)
└── tests/
├── client/ # Unit & integration tests (hud-server-authority, vendor-stash-ui, vendor-stash-replay, client-world, hud-session-play)
└── e2e-d2gs-online/ # 4-tier opaque-box E2E test suite + Tier 5 adversarial coverage hardening
```
### Strict Dependency DAG
| From ↓ Imports → | `src/common/**` | `src/netproto/index.ts` | `src/server/**` | `src/client/**` | `src/baker/**` | `node:*` | DOM Globals |
|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
| **`src/common/**`** | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ |
| **`src/netproto/**`** | ✗ | ✓ (internal) | ✗ | ✗ | ✗ | Only `transport/node-tcp-stream.ts` (excluded from browser) | ✗ |
| **`src/server/**`** | ✓ | ✓ | ✓ | ✗ | ✗ | ✗ | ✗ |
| **`src/client/**`** | ✓ | ✓ (`index.ts` only) | ✗ | ✓ | ✗ | ✗ | ✓ |
| **`src/baker/**`** | ✓ | ✗ | ✗ | ✗ | ✓ | ✓ | ✗ |
| **`tools/**`** | ✓ | ✓ | ✓ | ✓ (audit/bot) | ✓ | ✓ | ✓ (Playwright) |
### Architectural Invariants
1. **Diablo II v1.13c Ground Truth**: Every C2S and S2C opcode, packet byte length (`C2S_PACKET_SIZES` and `S2C_PACKET_SIZES` in `src/netproto/d2gs/tables/s2c-sizes.ts`), item bitstream field (`src/netproto/d2gs/items/item-bitstream.ts`), and Excel/TBL data table (`src/common/items/`, `src/common/units/npc-table.ts`) must strictly conform to Diablo II v1.13c.
2. **Server Authority**: When `HudManager` has a `_commandSink` attached (`hasCommandSink === true`), user actions MUST emit typed `ClientCommand` events (`src/netproto/domain/client-command.ts`) encoded via `encodeClientCommand` (`src/netproto/d2gs/registry.ts`) without mutating local container/stat/skill state ahead of the server. `ServerEvent` updates applied to `ClientWorld` (`src/client/world/client-world.ts`) and synced via `HudModel.syncFromWorld()` (`src/client/ui-model/hud-model.ts`) drive all UI state.
3. **Strict Import Boundary**: Files in `src/client/**` MUST ONLY import protocol types/functions from `src/netproto/index.ts` (never deep-importing `src/netproto/d2gs/**`).
---
## Feature Inventory
Every feature discovered in the Phase 0 Survey (from `ORIGINAL_REQUEST.md`, `module_split_plan.md`, `AGENTS.md`, and Survey Reports 1, 2, 3) is inventoried below with its assigned milestone. Zero features are unassigned.
Every feature from the Phase 0 Survey (derived from `ORIGINAL_REQUEST.md` `2026-10-01T13:08:09Z` and Survey Reports 1, 2, and 3) is listed below with its assigned milestone. No feature is left unassigned.
| # | Feature | Description | Milestone | Source |
|---|---------|-------------|-----------|--------|
| F1 | `src/common/` Pure Module & Data Registry Decoupling | Extract pure MPQ (`mpq/` without `file-source.ts`), binary decoders (`formats/`), Excel `.txt` `data-registry` (with injected `ArchiveSource` and zero hardcoded `/usr/local/google/home/taodao/d2-data`), 1:1 DRLG (`drlg/`), world topology (`world/`), static items/skills/units (`items/`, `skills/`, `units/`), pure `.tbl` lookup (`i18n/`), deterministic RNG & SplitMix64 `pickVariant` (`rng/`), compiling cleanly under `tsconfig.common.json` (`lib: ["ES2022"]`, `types: []`). | M1 | R1.1, Survey 1 & 2 |
| F2 | Shared Pack Contract (`src/common/pack-contract/`) | Extract `PackedDrlgBundleManifest`, `PackedTileCatalogIndex`, `PackIndex`, `PackSceneJson`, `EntityMeta`, `ClipMeta`, and fail-fast universal DT1 (`Blank.dt1`, `InvisWal.dt1` style=49 seq=7, `Warp.dt1`) + `missingFiles === 0` validators into `src/common/pack-contract/` to break `client <-> baker` coupling. | M1 | R1.1, R1.5, Survey 2 & 3 |
| F3 | Elimination of All 23 Server-to-Client Reverse Dependencies | Extract pure inventory/belt/socket container models (`inventory-model.ts`, `belt-model.ts`, `item-bridge.ts`), `CharacterAttributes`, `resolvePlayerCharToken`, `HotkeySkillEntry`, `getSkillName`, `subTileToScene`, and explicit `lang: TblLang` parameters into `src/common/`, and split mixed files (`monsters.ts`, `skills.ts`, `engine.ts`, `world-arena.ts`, `skill-caster.ts`, `monster-art.ts`, `animated-tiles.ts`, `i18n/lang.ts`) so server simulation has 0 imports from UI/render/client. | M1 | R1.3, Survey 1 & 2 |
| F4 | `src/baker/` Offline Baker Module & Unified CLI (`src/baker/cli.ts`) | Migrate `src/mpq/file-source.ts`, `png.ts`, `drlg-mpq-source.ts`, `tbl-writer.ts`, `publish-packs.ts`, 16 `pack-*.ts` packers + `rebuild-pack-index-and-monsters.ts`, 11 extractors, and 4 offline verifiers into `src/baker/`; implement `src/baker/cli.ts` (`npm run bake -- <target>` and `npm run bake -- --verify`); enforce `tsconfig.baker.json` (`lib: ["ES2022"]`, `types: ["node"]`, zero imports from `src/client/**` or `src/server/**`). | M2 | R1.5, R3, Survey 3 |
| F5 | `tools/` CLI Consolidation & Legacy Relay Deletion | Delete legacy P2P lockstep server scripts (`scripts/net-relay.ts`, `scripts/net-server.ts`, `npm run net`); migrate all 93 developer, bot, replay, unlock, benchmark, and verification tools (`d2-bot.ts`, `netproto-replay.ts`, `d2s-unlock.ts`, `verify-play.ts`, `audit-levels-browser.ts`, `lib/drlg-oracle.ts`, etc.) from `scripts/` to `tools/`; remove `scripts/` directory and update `package.json` scripts & test imports. | M2 | R1.6, R2, Survey 3 |
| F6 | `src/server/` Authoritative Simulation Module & `LocalLoopbackAdapter` | Consolidate all server simulation files (`engine/`, `ai/`, `skills/`, `items/`, `world/`, `save/`, `scene/`) into `src/server/`; remove dynamic `node:*` in `skills/registry.ts`; verify 0 occurrences of `Math.random`, `Date.now`, `performance.now`, or DOM globals under `tsconfig.server.json` (`lib: ["ES2022", "WebWorker"]`, `types: []`); implement `src/server/adapter/LocalLoopbackAdapter.ts` implementing `GameServerAdapter` from `src/netproto/index.ts`. | M3 | R1.3, R3, Survey 1 & 2 |
| F7 | `src/client/` Pure Browser Module & `MapService` Node Decoupling | Consolidate `render/`, `ui/`, `frontend/`, `audio/`, `sim/`, `scene/frontend/`, `transition.ts`, client `i18n/lang.ts`, and client art/animation modules into `src/client/`; remove `node:fs`/`node:path` from `src/client/map/map-service.ts` via injected `PackedTileBundleLoader`; decouple `HudManager` and `WorldRenderer` from `GameEngine`; enforce `tsconfig.client.json` (`lib: ["ES2022", "DOM", "DOM.Iterable"]`, `types: []`, zero imports from `src/server/**`, `src/baker/**`, or `node:*`). | M3 | R1.4, R3, Survey 2 |
| F8 | Single HTML Entry (`play.html`) Consolidation & Legacy Entry Removal | Delete `index.html`, `acts.html`, `acts-zh.html`, `acts-en.html`, `bnet.html`, `src/main.ts`, `src/walk.ts`, `src/frontend/bnet-main.ts`, `src/scene/act-scene.ts`, `src/scene/net-scene.ts`, `src/scene/backend/net-simulation.ts`, and `src/net/*`; preserve `computeLockstepHash` in `src/server/engine/`; add `D2Exocet`/`D2Formal436` `@font-face` CSS to `play.html`; configure `vite.config.ts` with `play.html` as sole input; update all `act-scene.ts` and HTML-reading tests. | M3 | R2, Survey 2 & 3 |
| F9 | Compiler-Level Isolation (`tsconfig.*.json`) & Zero-Exception Boundary Gate (`tests/arch/boundaries.test.ts`) | Wire `tsconfig.common.json`, `tsconfig.netproto.json`, `tsconfig.server.json`, `tsconfig.client.json`, `tsconfig.baker.json`, and root `tsconfig.json` into `npm run typecheck`; implement `tests/arch/boundaries.test.ts` enforcing all 7 R3 boundary rules with **0 whitelist exceptions**; ensure `src/` contains only `{common,netproto,server,client,baker}` and `npm run build` succeeds with 0 Node-builtin externalization warnings. | M3 | R3, Survey 1, 2 & 3 |
| F10 | Full 1.13c Parity, Replay, Baker, Walk, 136-Level DRLG & Headless `play.html` E2E Verification | Pass 100% of `npx vitest run`, `npm run bake -- --verify`, `tools/netproto-replay.ts --strict` (`unknownPackets === 0`, `opaquePackets === 0`, `protocolErrors === 0`), `tools/d2-bot.ts` walk test (`hardSnaps === 0`) & 136-level DRLG / 5-Act 20-WP `audit-levels`, `tools/verify-play.ts` in `800×600` & `1024×768`, and Tier 1–5 E2E test suites. | M4 | R4, Acceptance Criteria, Survey 3 |
| F1 | Canonical 1.13c Online Item Tooltip Bridge (R1.1) | Bridge online `ItemData` (`item-bitstream.ts`) and socketed child items (`world.containers.sockets.get(item.id)`) through canonical 1.13c item tables (`getEmbeddedDropTables()`) and `ItemStatCost` descriptor formatter (`item-bridge.ts`, `item-tooltip.ts`, `hud-model.ts`). Resolve localized CHI/ENG base item names, Magic/Rare/Set/Unique/Runeword names, 1H/2H weapon damage, defense, durability, level/str/dex/class requirements, sorted `ItemStatCost` property lines (`stats` and `statsEn` instead of raw `Stat #N`), and socketed child item previews (`socketedItems`, `usedSockets`). | M1, M3 | R1.1, Survey 1 |
| F2 | C2S Container, Belt, NPC, Merc, Warp, Party & Trade Packet Encoders (R1.2, R2–R5) | Implement/complete C2S binary encoders and `ClientCommand` + `encodeClientCommand` (`registry.ts`) mappings matching `C2S_PACKET_SIZES`: `0x09 WarpTo` (9B), `0x1D SwapCursorBodyItem` (`ItemSwap`, 9B), `0x1F SwapContainerItem` (17B), `0x23 BeltPut` (9B), `0x24 BeltRemove` (5B), `0x25 BeltSwap` (9B), `0x27 IdentifyItem` (9B), `0x28 SocketItem` (9B), `0x29 ScrollToBook` (9B), `0x31 QuestMessage` (9B), `0x36 HireMerc` (`MercHire`, 9B), `0x38 EntityAction` (`NpcEntityAction`, 13B), `0x41 Resurrect` (1B), `0x4F Trade` (`ClickButton`, 7B), `0x5D Hostile` (7B with `button` parameter `1`=hostile, `2`=loot), `0x5E Party` (`PartyAction`, 6B), `0x61 MercItem` (3B), `0x62 NpcResurrectMerc` (`MercResurrect`, 5B). | M1 | R1.2, Acceptance Criteria, Survey 1–3 |
| F3 | Server-Authoritative Container, Belt, Identify, Socket & Tome UI Wiring (R1.2) | Wire `HudCommandSink`, `HudModel`, and `HudManager` so: (a) left-clicking an occupied equipment slot with a cursor item emits `0x28 SocketItem` (if socketable) or `0x1D ItemSwap` (9B); (b) left-clicking an occupied inventory/stash/cube cell with a cursor item emits `0x29 ScrollToBook` (matching scroll into tome), `0x28 SocketItem` (compatible filler into socketed item), or `0x1F SwapContainerItem` (17B); (c) left-clicking belt slots emits `0x24 BeltRemove` (empty cursor on occupied slot), `0x23 BeltPut` (cursor potion on empty slot), or `0x25 BeltSwap` (cursor potion on occupied slot); (d) right-clicking `isc`/`ibk` in online mode arms identify targeting mode and subsequent left-click on an unidentified item dispatches `0x27 IdentifyItem`. | M3 | R1.2, Survey 1 |
| F4 | Weapon Set II (`bodyLoc` 11 & 12) Sync (R1.3) | Map `bodyLoc` 11 (`rarm2`) and 12 (`larm2`) in `HudModel.syncFromWorld` / `syncInventory` to `hud.inventory.swapWeapons` (`weapon1` and `weapon2`) so inactive weapon slots remain synced across `0x60` / `0x97 WeaponSwitch`. | M3 | R1.3, Survey 1 |
| F5 | Act 1–5 Town & Quest NPC Descriptors (R2.1) | Complete `TOWN_NPC_DESCRIPTORS` and export `NPC_HCIDX_TO_DESCRIPTOR_ID` in `src/common/units/npc-table.ts` for all Deckard Cain `hcIdx` variants (`146`, `244`, `245`, `246`, `520`, `265`), Warriv Act 1 & Act 2 (`155`, `175`), Meshif Act 2 & Act 3 (`210`, `258`), Kaelan (`203`), Tyrael (`251`, `367`), Hadriel (`259`), and Qual-Kehk (`515`), including `canHire`, `travelActions`, `questRewardActions`, `gossipLines`, and `questLines`. | M1 | R2.1, Survey 2 |
| F6 | NPC Info (`0x27`), SetNPCTalk (`0x91`), Talk Submenu & `0x31 QuestMessage` (R2.2) | Process S2C `0x27 NPCInfo`, `0x91 SetNPCTalk`, and `0xAB NPCHeal` in `ClientWorld.apply` (`case 'Npc'`, preserving backward-compatible immediate menu opening on outgoing `NpcInit` for synthetic tests) [M1]. Upgrade the `Talk` (`交談`) menu in `WorldPanelsHud` and `HudModel` to support `Introduction` / `Gossip` / active Quest Speech topics with a scrollable `.tbl` speech dialog box and C2S `0x31 QuestMessage` dispatch [M3]. | M1, M3 | R2.2, Survey 2 |
| F7 | Act Travel Menu Options & C2S `0x38 EntityAction` (R2.3) | Add Act transition menu options (`travel_east`, `travel_west`, `travel_harrogath`) to Warriv (`155`, `175`), Meshif (`210`, `258`), and Tyrael (`367`) and wire them in `WorldPanelsHud` and `HudModel` to C2S `0x38 EntityAction` travel commands. | M3 | R2.3, Survey 2 |
| F8 | Mercenary World State (`0x4E`, `0x4F`, `0x9B` & `lifePct`) (R3.1) | In `ClientWorld.apply` (`case 'Merc'`), store `0x4E MercForHire` candidate entries (`nameId`, `seed`), clear the list on `0x4F MercForHireListStart`, store `0x9B MercReviveCost` (`reviveCost`, `deadNameId`), track active mercenary life percentage (`lifePct`), and fix `world.containers.merc` unequip-to-cursor handling. | M1 | R3.1, Survey 2 |
| F9 | Mercenary Hire (`0x36`) & Resurrect (`0x62`) UI (R3.2) | Wire the `Hire` NPC menu option (`Kashya` `150`, `Greiz` `198`, `Asheara` `252`, `Qual-Kehk` `515`) to send `0x38 EntityAction (action=3)`, open the Mercenary Hire panel populated from `world.merc.hireList`, and send `0x36 HireMerc` on selection. Show the `Resurrect` option when a dead mercenary is awaiting revival and dispatch `0x62 NpcResurrectMerc`. | M3 | R3.2, Survey 2 |
| F10 | Mercenary HUD Portrait & Paperdoll (`O` Hotkey, `0x61 MercItem`) (R3.3) | Render the active mercenary top-left HUD portrait and life bar in `HudManager`. Sync `world.containers.merc` into the Mercenary Paperdoll panel (`O` hotkey) in `HudModel` / `WorldPanelsHud` / `HudManager`, and dispatch `0x61 MercItem` (3B: `u8 0x61 \| u16 slot`) when clicking mercenary equipment slots. | M3 | R3.3, Survey 2 |
| F11 | Dungeon/Stair Warp Clicking (`0x09 AssignWarp` → C2S `0x09 WarpTo`) (R4.1) | Preserve `warpId` on `0x09 AssignWarp` in `s2c/units.ts` & `ClientUnit` [M1]; project `kind === 'warp'` units in `buildSceneDrawables` (`src/client/view/unit-drawables.ts`) and `ClientWorldSceneSource.pickUnitAtScreen` (`src/client/view/scene-source.ts`) with localized destination labels and emit `MoveToUnit (0x04)` + `WarpTo (0x09)` when clicking a warp entrance [M2]. | M1, M2 | R4.1, Survey 3 |
| F12 | Portal Ownership Hover (`0x60`/`0x82`) & Ground Item Labels (`0x16`) (R4.2) | Store ground item `goldAmount` (`item.quantity`) and resolved item names on `UnitState` / `ClientUnit` and `resolveGroundItemMetadata` [M1]. Enrich Town Portal objects (`classId` 59/60) with `world.portals.get(id)` (`ownerName`, destination `areaId`), replace hardcoded `'1 Gold'` in `world-renderer.ts`, and support `Alt` key ground item label display and click-to-pickup (`0x16`) [M2]. | M1, M2 | R4.2, Survey 3 |
| F13 | `SkillCast` (`0x4C`/`0x4D`/`0x6C`/`0x99`/`0x9A`) Animation (R4.3) | Handle `case 'SkillCast'` in `ClientWorld.apply` by updating the caster unit's active cast state, facing direction toward the target, and attack/cast animation mode [M1] so `unit-drawables.ts` and `world-renderer.ts` animate melee swings and spell casts [M2]. | M1, M2 | R4.3, Survey 3 |
| F14 | Player Death Overlay & C2S `0x41 Resurrect` (R4.4) | Detect player death (`world.self.life <= 0` or `selfUnit.mode === 0` after spawn) in `ClientWorld` [M1], render the classic D2 death overlay (`"You Have Died. Press ESC to continue."` / `"你挂了,请按 ESC 键继续"`) in `HudManager`, and emit `{ type: 'Resurrect' }` (C2S `0x41`) when pressing `Escape` or clicking the overlay [M2, M3]. | M1, M2, M3 | R4.4, Survey 3 |
| F15 | Multiplayer Party State & Party Panel (`P` Hotkey, `0x5D`/`0x5E`) (R5.1) | Handle `memberState` (`0x66`/`0x7F`), `relation` (`0x8B`/`0x8C`/`0x74`/`0x8E`), `memberPos` (`0x90`), and player leave (`0x5C`) in `ClientWorld.apply` [M1]. Implement automap party player markers in `world-renderer.ts` [M2] and the Party Screen (`P` hotkey) in `WorldPanelsHud` + `HudModel` + `HudManager` with Invite/Accept/Cancel/Leave (`0x5E Party`), Hostile toggle (`0x5D Hostile` button=1), Corpse Loot toggle (`0x5D Hostile` button=2), and top-left party portraits [M3]. | M1, M2, M3 | R5.1, Survey 3 |
| F16 | Player-to-Player Trade UI (`0x77`/`0x78`/`0x79` & C2S `0x4F`) & Quest Reward Item Panels (R5.2, R5.3) | Track `0x77 ButtonActions`, `0x78 TradeAccepted` (`partnerName`), and `0x79 GoldInTrade` in `ClientWorld.apply` [M1]. Render the 2-player Trade left-dock panel (`tradePartner` top 10×4 grid + partner gold, `tradeSelf` bottom 10×4 grid + self gold + Trade Accept check button sending `0x4F Trade`) and wire `imbue` (Charsi), `add_sockets` (Larzuk), and `personalize` (Anya) NPC menu actions to open the single-item quest reward box and dispatch the corresponding C2S action in `WorldPanelsHud` + `HudModel` + `HudManager` [M3]. | M1, M3 | R5.2, R5.3, Survey 2 & 3 |
---
@ -86,46 +68,106 @@ Every feature discovered in the Phase 0 Survey (from `ORIGINAL_REQUEST.md`, `mod
| # | Name | Scope | Dependencies | Status |
|---|------|-------|-------------|--------|
| M1 | `src/common/`, `pack-contract` & Shared Model Extraction | F1, F2, F3: Build AST move/rewrite tool (`tools/refactor/move.ts`); populate `src/common/` (`mpq`, `formats`, `data`, `drlg`, `world`, `items`, `skills`, `units`, `i18n`, `rng`, `pack-contract`); split mixed files and extract shared models to eliminate all 23 `server -> client` reverse dependencies; pass `tsc -p tsconfig.common.json --noEmit` and `npx vitest run`. | none | DONE (`src/common/*`, `pack-contract`, 0 errors in `tsc -p tsconfig.common.json --noEmit`) |
| M2 | `src/baker/` & `tools/` Migration + Unified Baker CLI | F4, F5: Migrate `file-source.ts` + 31 baking/extraction/audit scripts to `src/baker/`; implement `src/baker/cli.ts`; delete `net-relay.ts` & `net-server.ts`; migrate 93 CLI/bot/replay/verify scripts to `tools/` and remove `scripts/`; update `package.json` scripts and test imports; pass `tsc -p tsconfig.baker.json --noEmit` and `npm run bake -- --verify`. | M1 | DONE (`src/baker/*`, `src/baker/cli.ts`, `tools/*`, `scripts/` removed, `tsc -p tsconfig.baker.json` & `bake -- --verify` exit 0) |
| M3 | `src/server/`, `src/client/`, `LocalLoopbackAdapter`, `play.html` Consolidation & Boundary Gate | F6, F7, F8, F9: Consolidate `src/server/` + `LocalLoopbackAdapter`; consolidate `src/client/` + decouple `MapService` from `node:fs`/`node:path`; codemod `act-scene.ts` test imports and delete `index.html`, `acts*.html`, `bnet.html`, `act-scene.ts`, `net-scene.ts`, `src/net/*`, and legacy top-level `src/` dirs; wire all 5 `tsconfig.<module>.json` configs; implement `tests/arch/boundaries.test.ts` with 0 exceptions; pass all `tsc` checks, `npm run build`, and `npx vitest run`. | M2 | DONE (`src/` contains only `{baker,client,common,netproto,server}`, 6 tsconfigs 0 errors, `boundaries.test.ts` 0 exceptions, `dist/play.html` built cleanly) |
| M4 | Final Milestone: E2E Test Suite Pass (Tiers 1–4), Live Parity Gates & Tier 5 Adversarial Hardening | F10: Phase 1 — Pass 100% of E2E test suite (`TEST_READY.md` Tiers 1–4) and all live/offline parity gates (`vitest`, `bake -- --verify`, `netproto-replay --strict`, `d2-bot` walk & 136-level `audit-levels`, `verify-play` at `800×600` & `1024×768`). Phase 2 — Tier 5 white-box adversarial coverage hardening & 2B Gate (2 Reviewers, 2 Challengers, 1 Forensic Auditor). | M3, E2E Track | DONE (Phase 1 & Phase 2 passed: 556 Vitest files / 6,640 tests, 138/138 Tiers 1–5 E2E tests, 9/9 offline & live gates, Reviewers `APPROVE`, Challengers `APPROVE`, Forensic Auditors `CLEAN`) |
| E2E | E2E Testing Track (`TEST_INFRA.md`, Tiers 1–5 Test Suite, `TEST_READY.md`) | Opaque-box, requirement-driven 4-tier test suite + Tier 5 adversarial regression suite covering F1–F10 in `tests/e2e-module-split/**` + `TEST_INFRA.md` + `TEST_READY.md`. | none | DONE (`TEST_INFRA.md`, `TEST_READY.md`, 138/138 tests passing in `tests/e2e-module-split/tier{1..5}-*.test.ts`) |
| M1 | Protocol Layer, Common Item/NPC Tables & `ClientWorld` Authoritative State (`src/netproto/`, `src/common/`, `src/client/world/`) | F1 (bridge helper), F2, F5, and state layer of F6, F8, F11, F12, F13, F14, F15, F16: Complete all C2S encoders (`0x09`, `0x1D`, `0x1F`, `0x23`–`0x29`, `0x31`, `0x36`, `0x38`, `0x41`, `0x4F`, `0x5D` with `button`, `0x5E`, `0x61`, `0x62`), `ClientCommand` & `encodeClientCommand` (`registry.ts`), S2C `0x09` `warpId` & `0x78` `partnerName`, `TOWN_NPC_DESCRIPTORS` + `NPC_HCIDX_TO_DESCRIPTOR_ID` (`npc-table.ts`), canonical 1.13c `itemDataToUiInventoryItem` converter (`item-bridge.ts`), `goldAmount` resolution (`ground-items.ts`), and all `ClientWorld.apply` reducers (`Npc`, `Merc`, `SkillCast`, `Party`, `Trade`, `isDead`, ground `goldAmount`, warp `warpId`). Verified in `tests/client/client-world.test.ts` & `tests/netproto/d2gs-{c2s,s2c}.test.ts` (14/14 PASS, 0 TS errors). | none | DONE |
| M2 | Scene View, Warp Picking, Portals, Ground Item Labels & `SkillCast` Visuals (`src/client/view/`, `src/client/scene/`, `src/client/session/`, `src/client/input/`) | F11, F12, F13, F14 (Escape/input), F15 (automap markers): Project `kind === 'warp'` in `unit-drawables.ts` and `ClientWorldSceneSource.pickUnitAtScreen` (`scene-source.ts`) with localized destination labels emitting `MoveToUnit (0x04)` + `WarpTo (0x09)`; enrich Town Portal (`classId` 59/60) with `world.portals.get(id)` (`ownerName`, `areaId`); propagate ground `goldAmount` and resolved item names to `UnitState` & `world-renderer.ts` (replacing `'1 Gold'`) with `Alt` label display & click-to-pickup (`0x16`); animate `SkillCast` melee swings & spell casts; add automap party/portal markers. Verified in `tests/client/hud-session-play.test.ts` (5/5 PASS, 0 TS errors). | M1 | DONE |
| M3 | HUD Model, `HudManager` & `WorldPanelsHud` — Tooltips, Containers, NPC Dialogue/Travel, Mercs, Death Overlay, Party, Trade & Quest Rewards (`src/client/ui-model/`, `src/client/ui/`) | F1, F3, F4, F6, F7, F9, F10, F14 (HUD overlay), F15 (Party UI & portraits), F16: Wire canonical 1.13c online item tooltips (`stats`, `statsEn`, `socketedItems`, `usedSockets`) & `bodyLoc` 11/12 `swapWeapons`; wire `0x1D`, `0x1F`, `0x23`–`0x29` container/belt/identify/socket/tome clicks; upgrade `Talk` submenu (`Introduction`/`Gossip`/Quest speech + `.tbl` dialog + `0x31 QuestMessage`) & Act Travel (`travel_east`/`travel_west`/`travel_harrogath` -> `0x38`); wire Mercenary `Hire` (`0x38` action=3 -> `0x36`), `Resurrect` (`0x62`), top-left Merc portrait, and `O` Paperdoll (`0x61 MercItem`); render Player Death overlay & `0x41 Resurrect`; implement `P` Party Screen (`0x5E`, `0x5D` button=1/2) & party portraits; implement 2-player Trade panel (`0x4F`) & Quest Reward panels (`imbue`, `add_sockets`, `personalize`). Verified in `tests/client/hud-server-authority.test.ts` and `tests/client/vendor-stash-ui.test.ts` (45/45 PASS). | M1 | DONE |
| M4 | Final Milestone: Pass 100% E2E Test Suite (Tiers 1–4) & Adversarial Coverage Hardening (Tier 5) | Phase 1: Wait for `TEST_READY.md`, run and pass 100% of E2E tests (`tests/e2e-d2gs-online/**` Tiers 1–4) and all unit/integration/boundary suites + `npx tsc --noEmit`. Phase 2: White-box Tier 5 adversarial coverage hardening (`tests/e2e-d2gs-online/tier5-adversarial-hardening.test.ts`, 204/204 E2E tests PASS, 269/269 14-file verification suite PASS, 0 TS errors; Unanimous 5/5 Verification Gate PASS across 2 Reviewers, 2 Challengers, and 1 Forensic Auditor). | M2, M3, E2E | DONE |
| E2E | E2E Testing Track (`TEST_INFRA.md`, Tiers 1–4 Test Suite, `TEST_READY.md`) | Design and create requirement-driven, opaque-box 4-tier E2E test suite in `tests/e2e-d2gs-online/` covering all 16 features (`F1`–`F16`) with `80` Tier 1 tests, `80` Tier 2 tests, `16` Tier 3 tests, and `8` Tier 4 application scenarios (`184` total, plus `20` Tier 5 tests = `204` total); publish `TEST_INFRA.md` and `TEST_READY.md`. | none | DONE |
---
## Interface Contracts
### 1. `src/common/data/data-registry.ts` ↔ Callers (`src/server/`, `src/client/`, `src/baker/`)
- `ArchiveSource` interface in `src/common/data/data-registry.ts`:
- `readText(mpqRelativePath: string): Promise<string | null> | string | null`
- `readBytes(mpqRelativePath: string): Promise<Uint8Array | null> | Uint8Array | null`
- `D2DataRegistry` and `getSharedDataRegistry(source: ArchiveSource)` MUST require an explicit `ArchiveSource` (no default `/usr/local/google/home/taodao/d2-data` path, no `node:fs`, no `window`/`fetch`). Missing `source` throws an explicit `Error` immediately (fail-fast per `AGENTS.md` §3.1).
### 1. `src/netproto/` ↔ `src/client/` (`ClientCommand` & C2S Encoders)
All newly added and updated C2S encoders MUST produce a `Uint8Array` whose `byteLength` equals `C2S_PACKET_SIZES[opcode]` in `src/netproto/d2gs/tables/s2c-sizes.ts`:
- `0x09 WarpTo` (9B: `u8 0x09 | u32LE unitType(5) | u32LE warpId`):
- `encodeC2sWarpTo(warpId: number, unitType?: number): Uint8Array`
- `ClientCommand`: `{ readonly type: 'TakeWarp'; readonly warpId: number }` and `{ readonly type: 'WarpTo'; readonly warpId: number; readonly unitType?: number }` (both encoded as opcode `0x09`, 9B).
- `0x1D SwapCursorBodyItem` (9B: `u8 0x1D | u32LE itemId | u16LE slot | u16LE 0`):
- `encodeC2sItemSwap(itemId: number, slot: number): Uint8Array`
- `ClientCommand`: `{ readonly type: 'ItemSwap'; readonly itemId: number; readonly slot: number }`
- `0x1F SwapContainerItem` (17B: `u8 0x1F | u32LE cursorItemId | u32LE targetItemId | u32LE x | u32LE y`):
- `encodeC2sSwapContainerItem(cursorItemId: number, targetItemId: number, x: number, y: number): Uint8Array`
- `ClientCommand`: `{ readonly type: 'SwapContainerItem'; readonly cursorItemId: number; readonly targetItemId: number; readonly x: number; readonly y: number }`
- `0x23 BeltPut` (9B: `u8 0x23 | u32LE itemId | u32LE slot`):
- `encodeC2sBeltPut(itemId: number, slot: number): Uint8Array`
- `ClientCommand`: `{ readonly type: 'BeltPut'; readonly itemId: number; readonly slot: number }`
- `0x24 BeltRemove` (5B: `u8 0x24 | u32LE itemId`):
- `encodeC2sBeltRemove(itemId: number): Uint8Array`
- `ClientCommand`: `{ readonly type: 'BeltRemove'; readonly itemId: number }`
- `0x25 BeltSwap` (9B: `u8 0x25 | u32LE cursorItemId | u32LE beltItemId`):
- `encodeC2sBeltSwap(cursorItemId: number, beltItemId: number): Uint8Array`
- `ClientCommand`: `{ readonly type: 'BeltSwap'; readonly cursorItemId: number; readonly beltItemId: number }`
- `0x27 IdentifyItem` (9B: `u8 0x27 | u32LE itemId | u32LE scrollId`):
- `encodeC2sIdentifyItem(itemId: number, scrollId: number): Uint8Array`
- `ClientCommand`: `{ readonly type: 'IdentifyItem'; readonly itemId: number; readonly scrollId: number }`
- `0x28 SocketItem` (9B: `u8 0x28 | u32LE itemId | u32LE targetItemId`):
- `encodeC2sSocketItem(itemId: number, targetItemId: number): Uint8Array`
- `ClientCommand`: `{ readonly type: 'SocketItem'; readonly itemId: number; readonly targetItemId: number }`
- `0x29 ScrollToBook` (9B: `u8 0x29 | u32LE scrollId | u32LE bookId`):
- `encodeC2sScrollToBook(scrollId: number, bookId: number): Uint8Array`
- `ClientCommand`: `{ readonly type: 'ScrollToBook'; readonly scrollId: number; readonly bookId: number }`
- `0x31 QuestMessage` (9B: `u8 0x31 | u32LE npcId | u32LE messageId`):
- `encodeC2sQuestMessage(npcId: number, messageId: number): Uint8Array`
- `ClientCommand`: `{ readonly type: 'QuestMessage'; readonly npcId: number; readonly messageId: number }`
- `0x36 HireMerc` (9B: `u8 0x36 | u32LE npcId | u32LE mercId`):
- `encodeC2sNpcMercHire(npcId: number, mercId: number): Uint8Array`
- `ClientCommand`: `{ readonly type: 'MercHire'; readonly npcId: number; readonly mercId: number }`
- `0x38 EntityAction` (13B: `u8 0x38 | u32LE action | u32LE npcId | u32LE complement`):
- `encodeC2sNpcEntityAction(action: number, npcId: number, complement?: number): Uint8Array`
- `ClientCommand`: `{ readonly type: 'NpcEntityAction'; readonly action: number; readonly npcId: number; readonly complement?: number }` (in addition to `NpcOpenTrade` and `NpcOpenGamble`)
- `0x41 Resurrect` (1B: `u8 0x41`):
- `encodeC2sResurrect(): Uint8Array`
- `ClientCommand`: `{ readonly type: 'Resurrect' }`
- `0x4F Trade` / `ClickButton` (7B: `u8 0x4F | u16LE buttonId | u16LE amountLow | u16LE amountHigh`):
- `encodeC2sClickButton(buttonId: number, complement?: number): Uint8Array`
- `ClientCommand`: `{ readonly type: 'ClickButton'; readonly buttonId: number; readonly complement?: number }`
- `0x5D Hostile` (7B: `u8 0x5D | u8 button | u8 state | u32LE targetPlayerId`):
- `encodeC2sHostile(targetPlayerId: number, hostile: boolean, button?: number): Uint8Array`
- `ClientCommand`: `{ readonly type: 'Hostile'; readonly targetPlayerId: number; readonly hostile: boolean; readonly button?: number }` (`button = 1` for Hostile toggle, `button = 2` for Corpse Loot toggle)
- `0x5E Party` (6B: `u8 0x5E | u8 action | u32LE targetPlayerId`):
- `encodeC2sPartyAction(action: number, targetPlayerId: number): Uint8Array`
- `ClientCommand`: `{ readonly type: 'PartyAction'; readonly action: number; readonly targetPlayerId: number }`
- `0x61 MercItem` (3B: `u8 0x61 | u16LE slot`):
- `encodeC2sMercItem(slot: number): Uint8Array`
- `ClientCommand`: `{ readonly type: 'MercItem'; readonly slot: number }`
- `0x62 NpcResurrectMerc` (5B: `u8 0x62 | u32LE npcId`):
- `encodeC2sNpcMercResurrect(npcId: number): Uint8Array`
- `ClientCommand`: `{ readonly type: 'MercResurrect'; readonly npcId: number }`
### 2. `src/common/pack-contract/` ↔ `src/baker/` & `src/client/`
- Exports `PackedDrlgBundleManifest`, `PackedTileCatalogIndex`, `PackedTilePlacement`, `PackedDt1TileEntry`, `PackedDt1LibraryEntry`, `PackIndex`, `PackIndexEntry`, `PackObject`, `PackObjectFrame`, `PackSceneJson`, `EntityMeta`, `ClipMeta`.
- Exports fail-fast validators `validateUniversalDt1Libraries(libraries)` (enforcing `Blank.dt1`, `InvisWal.dt1` with style=49 sequence=7, `Warp.dt1`) and `validatePackManifest(manifest)` (enforcing `missingFiles === 0` and `missingTiles === 0`).
### 2. `src/common/units/npc-table.ts` ↔ `src/client/`
- `TownNpcServiceDescriptor` includes:
- `canHire?: boolean` (`150` Kashya, `198` Greiz, `252` Asheara, `515` Qual-Kehk)
- `travelActions?: readonly ('travel_east' | 'travel_west' | 'travel_harrogath')[]` (`155` Warriv Act 1 -> `['travel_east']`, `175` Warriv Act 2 -> `['travel_west']`, `210` Meshif Act 2 -> `['travel_east']`, `258` Meshif Act 3 -> `['travel_west']`, `367` Tyrael Act 4 -> `['travel_harrogath']`)
- `questRewardActions?: readonly ('imbue' | 'add_sockets' | 'personalize')[]` (`154` Charsi -> `['imbue']`, `511` Larzuk -> `['add_sockets']`, `512` Anya -> `['personalize']`)
- `gossipLines?: readonly string[]`
- `questLines?: Readonly<Record<string, string>>`
- `TOWN_NPC_DESCRIPTORS` and `NPC_HCIDX_TO_DESCRIPTOR_ID` cover all Act 1–5 Town & Quest NPC `hcIdx` values, including Cain (`146`, `244`, `245`, `246`, `520`, `265`), Warriv (`155`, `175`), Meshif (`210`, `258`), Kaelan (`203`), Tyrael (`251`, `367`), Hadriel (`259`), and Qual-Kehk (`515`).
### 3. `src/client/map/map-service.ts` ↔ `tools/` & Browser
- `MapService.fromLoader(loader: PackedTileBundleLoader): Promise<MapService>` and `MapService.fromPackedUrls(baseUrl: string): Promise<MapService>` live in `src/client/map/map-service.ts` with zero `node:fs` or `node:path` imports.
- Node filesystem loading (`createNodePackedTileLoader(dirPath)` / `loadMapServiceFromPackedDir(dirPath)`) lives in `tools/lib/node-map-loader.ts` (or `src/baker/io/`).
### 4. `src/netproto/index.ts` ↔ `src/server/adapter/LocalLoopbackAdapter.ts` & `src/client/`
- `LocalLoopbackAdapter` in `src/server/adapter/LocalLoopbackAdapter.ts` implements `GameServerAdapter` from `src/netproto/index.ts`:
- `connect(): Promise<void>`
- `disconnect(): void`
- `readonly isConnected: boolean`
- `readonly tick: number`
- `send(cmd: ClientCommand): void`
- `onEvent(handler: (ev: ServerEvent) => void): () => void`
- `onRawPacket?(handler: (direction: 'S2C' | 'C2S', opcode: number, bytes: Uint8Array, decoded?: ServerEvent | ClientCommand) => void): () => void`
- `src/client/**` never imports `src/server/**`; `src/client/main.ts` consumes any `GameServerAdapter` via `OnlineSession`.
### 3. `ClientWorld` (`src/client/world/client-world.ts`) ↔ `HudModel` & `SceneSource`
- **Mercenary state (`world.merc`)**:
- `hireList: readonly { nameId: number; seed: number }[]` (cleared on `0x4F`, appended on `0x4E`)
- `reviveCost: number`, `deadNameId: number` (populated on `0x9B MercReviveCost`)
- `lifePct: number` (updated on `0x0C`, `0xAB`, `0x69`, `0x81`, `0x9E..0xA0`)
- **NPC interaction state (`world.npcInteraction`)**:
- Stores `0x27 NPCInfo` (`npcInfoRaw`) and `0x91 SetNPCTalk` (`npcTalkRaw`), while keeping immediate `npcId` assignment on outgoing `NpcInit` for synthetic tests.
- Supports `window: 'none' | 'trade' | 'gamble' | 'hire' | 'imbue' | 'add_sockets' | 'personalize'`.
- **Warp, Portal, Ground Item, `SkillCast` & Death state**:
- `kind === 'warp'` / `unitType === 'tile'` units carry `warpId` and localized destination label; `buildSceneDrawables` (`unit-drawables.ts`) and `ClientWorldSceneSource.pickUnitAtScreen` (`scene-source.ts`) expose them as `'warp'` so clicking emits `MoveToUnit (0x04)` + `TakeWarp`/`WarpTo (0x09)`.
- Town Portal objects (`classId` `59`/`60`) join `world.portals.get(id)` (`ownerName`, `areaId`).
- Ground item units carry `goldAmount` (`item.quantity`) and resolved item names.
- `case 'SkillCast'` updates caster unit `castState`, facing `dir`, and attack/cast animation mode (`'A1'`/`'SC'`).
- Player death (`world.self.life <= 0` or `selfUnit.mode === 0` after spawn) sets `isDead`, renders the death overlay (`"You Have Died. Press ESC to continue."` / `"你挂了,请按 ESC 键继续"`), and emits `{ type: 'Resurrect' }` (`0x41`) on `Escape` or overlay click.
- **Party & Trade state**:
- `world.party` tracks `memberState` (`0x66`/`0x7F`), `relation` (`0x8B`/`0x8C`/`0x74`/`0x8E`), `memberPos` (`0x90`), and removes leaving players (`0x5C`).
- `world.trade` tracks `0x77 ButtonActions`, `0x78 TradeAccepted` (`partnerName`), and `0x79 GoldInTrade` (`selfGold`, `partnerGold`), with `tradePartner` and `tradeSelf` 10×4 item grids synced into `WorldPanelsHud`.
---
## Code Layout & Write Ownership
- **Canonical `src/` directories (post-M3)**: ONLY `src/common/`, `src/netproto/`, `src/server/`, `src/client/`, `src/baker/`. All legacy top-level `src/` directories (`src/game/`, `src/scene/`, `src/mpq/`, `src/formats/`, `src/data/`, `src/render/`, `src/ui/`, `src/frontend/`, `src/audio/`, `src/sim/`, `src/i18n/`, `src/net/`) must be completely removed by the end of M3.
- **Concurrent Write Ownership**:
- `M1`, `M2`, and `M3` execute sequentially on the codebase so `git mv` and import codemods never collide.
- The parallel **E2E Testing Orchestrator** owns EXCLUSIVELY `/usr/local/google/home/taodao/diablo2-web/.worktrees/module-split/TEST_INFRA.md`, `/usr/local/google/home/taodao/diablo2-web/.worktrees/module-split/TEST_READY.md`, and `/usr/local/google/home/taodao/diablo2-web/.worktrees/module-split/tests/e2e-module-split/**`. It MUST NOT edit any file outside those paths.
- **M1 (`sub_orch_m1_d2gs`)**: Owns `src/netproto/**`, `src/common/**`, `src/client/world/**`, and `tests/client/client-world.test.ts` (plus any `tests/netproto/**` packet tests).
- **M2 (`sub_orch_m2_d2gs`)** *(after M1 completes; runs in parallel with M3)*: Owns `src/client/view/**`, `src/client/scene/**`, `src/client/session/**`, `src/client/input/**`, and `tests/client/hud-session-play.test.ts`.
- **M3 (`sub_orch_m3_d2gs`)** *(after M1 completes; runs in parallel with M2)*: Owns `src/client/ui-model/**`, `src/client/ui/**`, `tests/client/hud-server-authority.test.ts`, and `tests/client/vendor-stash-ui.test.ts`.
- **E2E (`sub_orch_e2e_d2gs`)** *(runs in parallel with M1–M3)*: Owns EXCLUSIVELY `/usr/local/google/home/taodao/diablo2-web/TEST_INFRA.md`, `/usr/local/google/home/taodao/diablo2-web/TEST_READY.md`, and `/usr/local/google/home/taodao/diablo2-web/tests/e2e-d2gs-online/**`.
- **M4 (`sub_orch_m4_d2gs`)** *(after M2, M3, and E2E complete)*: Owns full-repo E2E verification and Tier 5 adversarial coverage hardening.

View File

@ -1,72 +1,81 @@
# Test Infrastructure & Strategy — `diablo2-web` Module Split (`TEST_INFRA.md`)
# Test Infrastructure & 4-Tier E2E Test Plan: D2GS v1.13c Online Gameplay Loops & Server Authority
## 1. Test Philosophy
## 1. Test Philosophy & Ground Truth Invariants
- **Requirement-Driven & Opaque-Box Verification**: Every test case in `tests/e2e-module-split/` is derived directly from `ORIGINAL_REQUEST.md` (`R1`–`R4` and Acceptance Criteria), `PROJECT.md` (`F1`–`F10` and Interface Contracts 1–4), and `AGENTS.md` (Diablo II v1.13c Ground Truth Parity Rules).
- **1.13c Ground Truth & Anti-Silent Failure Enforcement**:
- Universal DT1 libraries (`data\global\tiles\Act1\Outdoors\Blank.dt1`, `data\global\tiles\Act1\Barracks\InvisWal.dt1` with `style=49 sequence=7` collision floor, `data\global\tiles\Act1\Barracks\Warp.dt1`) must be unconditionally loaded per `D2Common.dll` `DRLGROOM_LoadDt1Files` (`0x6fdb8400`).
- Sparse/void cells must be blocked (`blocked === 1`) and masked with `COLLIDE_MASK_INVALID` (`COLLIDE_BLANK | COLLIDE_WALL`), and `findIsoSpawn` must never spawn in void or default blindly to geometric center `(width/2, height/2)`.
- Tile variant selection (`pickVariant`) must use high-entropy SplitMix64 2D spatial coordinate hashing without 45-degree diagonal striping.
- All 66 `SuperUniques` must preserve exact 1.13c `Class` and minion identifiers (`scarab2`, `zombie5`, `fetishshaman4`, `nihlathakboss`, `minion1`..`minion11` -> token `'XX'`, etc.).
- Missing required parameters (e.g., `D2DataRegistry` / `getSharedDataRegistry` called without an `ArchiveSource`) must fail fast by throwing an explicit `Error`.
- **Dynamic Import & AST/Filesystem Inspection**: All module imports inside `tests/e2e-module-split/*.test.ts` use runtime dynamic `await import(...)`, `node:fs` inspection, and source/AST boundary scanning inside `it(...)` blocks so the test files remain 100% valid TypeScript at all times and execute against the post-split architecture (`src/common/`, `src/netproto/`, `src/server/`, `src/client/`, `src/baker/`, `tools/`, `play.html`).
The `tests/e2e-d2gs-online/` suite is an opaque-box, requirement-driven 4-tier E2E test suite verifying all 16 features (`F1`–`F16`) defined in `PROJECT.md` and `ORIGINAL_REQUEST.md` (`2026-10-01T13:08:09Z`).
### Core Verification Principles
1. **Diablo II v1.13c Binary & Data-Table Ground Truth**:
- Every C2S binary packet emitted by `encodeD2gsClientCommand` / `clientCommandToC2sPackets` is verified against `C2S_PACKET_SIZES[opcode]` (`src/netproto/d2gs/tables/s2c-sizes.ts`) and exact little-endian wire layout (`u8`, `u16LE`, `u32LE`).
- Every online item tooltip (`onlineItemDataToUiInventoryItem` and `HudModel.syncFromWorld()`) is verified against canonical 1.13c item tables (`getEmbeddedDropTables()`, `Armor.txt`, `Weapons.txt`, `Misc.txt`, `MagicPrefix.txt`, `MagicSuffix.txt`, `RarePrefix.txt`, `RareSuffix.txt`, `SetItems.txt`, `UniqueItems.txt`, `Runes.txt`, `ItemStatCost.txt`) — never raw `Stat #N: V` fallbacks.
- Every Town and Quest NPC across Acts 1–5 is verified against `TOWN_NPC_DESCRIPTORS` and `NPC_HCIDX_TO_DESCRIPTOR_ID` (`src/common/units/npc-table.ts`).
2. **Strict Server Authority (Zero Optimistic Client Mutation)**:
- When `HudManager` / `WorldPanelsHud` has a `HudCommandSink` (`HudModel`) attached (`hasCommandSink === true`), UI interactions emit typed `ClientCommand` payloads and C2S wire bytes **without** mutating local inventory, equipment, belt, stash, cube, mercenary, party, or trade state ahead of the server.
- Only authoritative `ServerEvent` updates applied to `ClientWorld` (`src/client/world/client-world.ts`) and synchronized via `HudModel.syncFromWorld()` (`src/client/ui-model/hud-model.ts`) mutate the visible UI state.
3. **Opaque-Box Contract Testing**:
- All tests interact through public module boundaries (`src/netproto/index.ts`, `src/common/items/item-bridge.ts`, `src/common/items/ground-items.ts`, `src/common/units/npc-table.ts`, `src/client/world/client-world.ts`, `src/client/ui-model/hud-model.ts`, `src/client/ui/hud-manager.ts`, `src/client/ui/world-panels.ts`, `src/client/view/unit-drawables.ts`, `src/client/view/scene-source.ts`, `src/client/input/command-mapper.ts`).
---
## 2. Feature Inventory (`F1`–`F10`)
## 2. Complete 16-Feature Inventory (`F1`–`F16`)
| Feature ID | Feature Name | Target Module / Path | Core Requirements & Contracts | Tier 1 | Tier 2 | Tier 3 | Tier 4 |
|---|---|---|---|---:|---:|---:|---:|
| **F1** | `src/common/` Pure Module & Data Registry Decoupling | `src/common/{mpq,formats,data,drlg,world,items,skills,units,i18n,rng,pack-contract}`, `tsconfig.common.json` | Pure ES2022 (`types: []`, zero DOM, zero `node:*`), `D2DataRegistry` & `getSharedDataRegistry` require injected `ArchiveSource` (no `/usr/local/google/home/taodao/d2-data`), `D2Rng`, SplitMix64 `pickVariant`, pure `lookupTbl`. | 6 | 6 | ✓ | ✓ |
| **F2** | Shared Pack Contract (`src/common/pack-contract/`) | `src/common/pack-contract/index.ts` | Exports `PackedDrlgBundleManifest`, `PackedTileCatalogIndex`, `PackIndex`, `PackSceneJson`, `EntityMeta`, `ClipMeta`, `validateUniversalDt1Libraries`, `validatePackManifest`. | 5 | 5 | ✓ | ✓ |
| **F3** | Elimination of All 23 Server-to-Client Reverse Dependencies | `src/common/{items,units,skills,i18n,world}`, `src/server/**` | Extract `inventory-model`, `belt-model`, `item-bridge`, `CharacterAttributes`, `resolvePlayerCharToken`, `HotkeySkillEntry`, `getSkillName`, `subTileToScene`; zero imports from `src/server/**` into `src/client/**`. | 5 | 5 | ✓ | ✓ |
| **F4** | `src/baker/` Offline Baker Module & Unified CLI | `src/baker/{cli.ts,io,packers,extract,audit}`, `tsconfig.baker.json` | Node-only offline baker (`types: ["node"]`, zero DOM, zero `client`/`server` imports), unified `src/baker/cli.ts` (`npm run bake -- <target>` & `--verify`). | 5 | 5 | ✓ | ✓ |
| **F5** | `tools/` CLI Consolidation & Legacy Relay Deletion | `tools/*.ts`, `package.json` | Migrate 93 CLI/bot/replay/verify tools to `tools/`; delete `scripts/net-relay.ts`, `scripts/net-server.ts`, and `scripts/` directory; update `package.json` scripts. | 5 | 5 | ✓ | ✓ |
| **F6** | `src/server/` Authoritative Simulation & `LocalLoopbackAdapter` | `src/server/{engine,ai,skills,items,world,save,scene,adapter}`, `tsconfig.server.json` | Zero DOM globals, zero `Math.random`/`Date.now`/`performance.now`, zero `node:*` in `skills/registry.ts`; `LocalLoopbackAdapter` implements `GameServerAdapter`. | 6 | 6 | ✓ | ✓ |
| **F7** | `src/client/` Pure Browser Module & `MapService` Decoupling | `src/client/{main.ts,render,ui,frontend,scene,audio,sim,i18n,map,session,world}`, `tsconfig.client.json` | Pure browser (`types: []`, zero `node:*`, zero `server`/`baker` imports); `MapService.fromLoader` / `fromPackedUrls` without `node:fs`/`node:path`. | 5 | 5 | ✓ | ✓ |
| **F8** | Single HTML Entry (`play.html`) & Legacy Entry Removal | `play.html`, `vite.config.ts` | `play.html` is sole `.html` in root and `vite.config.ts` (with `D2Exocet`/`D2Formal436` `@font-face`); `index.html`, `acts*.html`, `bnet.html`, `act-scene.ts`, `net-scene.ts`, `src/net/*` deleted. | 5 | 5 | ✓ | ✓ |
| **F9** | Compiler-Level Isolation & Boundary Gate (`tests/arch/boundaries.test.ts`) | `tsconfig.*.json`, `tests/arch/boundaries.test.ts` | All 5 `tsconfig.<module>.json` files wired into `npm run typecheck`; `src/` contains only `{common,netproto,server,client,baker}`; `tests/arch/boundaries.test.ts` passes with 0 whitelist exceptions. | 5 | 5 | ✓ | ✓ |
| **F10** | Full 1.13c Parity, Baker, Replay & DRLG Invariants | `src/common/drlg/`, `src/common/world/`, `src/common/units/`, `tools/netproto-replay.ts` | Universal DT1s (`Blank.dt1`, `InvisWal.dt1`, `Warp.dt1`), void blocking (`COLLIDE_MASK_INVALID`), non-void `findIsoSpawn`, SplitMix64 `pickVariant`, SuperUnique minion closure, `.d2cap` replay parity. | 5 | 5 | ✓ | ✓ |
| ID | Feature | Requirement | Primary Public Contracts Under Test |
|---|---|---|---|
| **F1** | Canonical 1.13c Online Item Tooltip Bridge | R1.1 | `onlineItemDataToUiInventoryItem`, `HudModel.syncFromWorld()`: CHI/ENG base names, Magic/Rare/Set/Unique/Runeword names, 1H/2H damage, defense, durability, `reqLevel`/`reqStr`/`reqDex`/`reqClass`, sorted `ItemStatCost` lines (`stats`/`statsEn`), `sockets`/`usedSockets`/`socketedItems`. |
| **F2** | C2S Container, Belt, NPC, Merc, Warp, Party & Trade Packet Encoders | R1.2, R2–R5 | `encodeD2gsClientCommand` & `clientCommandToC2sPackets` matching `C2S_PACKET_SIZES`: `0x09 WarpTo` (9B), `0x1D SwapCursorBodyItem` (`ItemSwap`, 9B), `0x1F SwapContainerItem` (17B), `0x23 BeltPut` (9B), `0x24 BeltRemove` (5B), `0x25 BeltSwap` (9B), `0x27 IdentifyItem` (9B), `0x28 SocketItem` (9B), `0x29 ScrollToBook` (9B), `0x31 QuestMessage` (9B), `0x36 HireMerc` (`MercHire`, 9B), `0x38 EntityAction` (`NpcEntityAction`, 13B), `0x41 Resurrect` (1B), `0x4F Trade` (`ClickButton`, 7B), `0x5D Hostile` (7B, `button`=1/2), `0x5E Party` (`PartyAction`, 6B), `0x61 MercItem` (3B), `0x62 NpcResurrectMerc` (`MercResurrect`, 5B). |
| **F3** | Server-Authoritative Container, Belt, Identify, Socket & Tome UI Wiring | R1.2 | `HudModel` & `HudManager`: equip slot swap (`0x1D`) vs socket (`0x28`); container grid scroll-to-book (`0x29`), socket (`0x28`), swap (`0x1F`); belt put (`0x23`), remove (`0x24`), swap (`0x25`), shift-use on merc (`0x26` `useOnMerc=1`); right-click `isc`/`ibk` identify targeting (`0x27`). |
| **F4** | Weapon Set II (`bodyLoc` 11 & 12) Sync | R1.3 | `HudModel.syncFromWorld()`: `bodyLoc` 11 (`rarm2`) -> `hud.inventory.swapWeapons.weapon1`, `bodyLoc` 12 (`larm2`) -> `hud.inventory.swapWeapons.weapon2`, preserved across `0x60`/`0x97` weapon switch. |
| **F5** | Act 1–5 Town & Quest NPC Descriptors | R2.1 | `TOWN_NPC_DESCRIPTORS`, `NPC_HCIDX_TO_DESCRIPTOR_ID`, `getTownNpcDescriptorByHcIdx`: Cain (`146, 244, 245, 246, 520, 265`), Warriv (`155, 175`), Meshif (`210, 258`), Kaelan (`203`), Tyrael (`251, 367`), Hadriel (`259`), Qual-Kehk (`515`), Kashya (`150`), Greiz (`198`), Asheara (`252`), Charsi (`154`), Larzuk (`511`), Anya (`512`). |
| **F6** | NPC Info (`0x27`), SetNPCTalk (`0x91`), Talk Submenu & `0x31 QuestMessage` | R2.2 | `ClientWorld.apply({ type: 'Npc', kind: 'info' \| 'menu' \| 'heal' })`, synchronous `NpcInit` menu opening, `Talk` submenu (`Introduction`, `Gossip`, Quest speech), `.tbl` speech dialog box, and `0x31 QuestMessage`. |
| **F7** | Act Travel Menu Options & C2S `0x38 EntityAction` | R2.3 | `buildNpcMenuOptions` & `HudModel.handleNpcMenuAction`: `travel_east` (Warriv 155, Meshif 210), `travel_west` (Warriv 175, Meshif 258), `travel_harrogath` (Tyrael 367) emitting `NpcEntityAction` (`0x38`, 13B). |
| **F8** | Mercenary World State (`0x4E`, `0x4F`, `0x9B` & `lifePct`) | R3.1 | `ClientWorld.apply({ type: 'Merc' })`: `hireListStart` (`0x4F`), `forHire` (`0x4E` `{ nameId, seed }`), `reviveCost` (`0x9B` `{ reviveCost, deadNameId }`), `lifePct` updates (`0x0C`, `0xAB`, `0x69`, `0x81`, `0x9E..0xA0`), and `world.containers.merc` unequip-to-cursor (`0x08`). |
| **F9** | Mercenary Hire (`0x36`) & Resurrect (`0x62`) UI | R3.2 | `hire` option on mercenary captains (`150, 198, 252, 515`) -> `0x38` (`action=3`) -> Hire panel populated from `world.mercHireList` -> `MercHire` (`0x36`, 9B); dead merc (`reviveCost > 0`) exposes `resurrect` -> `MercResurrect` (`0x62`, 5B). |
| **F10** | Mercenary HUD Portrait & Paperdoll (`O` Hotkey, `0x61 MercItem`) | R3.3 | Top-left mercenary portrait + life bar in `HudManager`; `'O'` hotkey toggles `'merc'` panel; `world.containers.merc` syncs to merc equipment slots (`helm`, `armor`, `weapon1`, `weapon2`); clicking slot emits `MercItem` (`0x61`, 3B). |
| **F11** | Dungeon/Stair Warp Clicking (`0x09 AssignWarp` → C2S `0x09 WarpTo`) | R4.1 | `0x09 AssignWarp` preserves `warpId` on `UnitSnapshot` & `ClientUnit`; `buildSceneDrawables` & `ClientWorldSceneSource.pickUnitAtScreen` expose `'warp'`/`'tile'` units with localized destination labels; clicking emits `MoveToUnit` (`0x04`) + `TakeWarp`/`WarpTo` (`0x09`, 9B). |
| **F12** | Portal Ownership Hover (`0x60`/`0x82`) & Ground Item Labels (`0x16`) | R4.2 | Town Portal (`classId` 59/60) enriched with `world.portals` (`<ownerName>'s Town Portal`, destination `areaId`); ground gold piles propagate `goldAmount` (`<N> Gold` / `<N> 金币`, never hardcoded `'1 Gold'`); `Alt` ground labels layout without overlap and click-to-pickup emits `ItemPickup` (`0x16`). |
| **F13** | `SkillCast` (`0x4C`/`0x4D`/`0x6C`/`0x99`/`0x9A`) Animation | R4.3 | `ClientWorld.apply({ type: 'SkillCast' })` updates caster unit `castState`, facing `direction` toward target, and attack/cast animation mode (`'A1'`/`'SC'`, mode `7`/`10` for player, `4`/`7` for monster). |
| **F14** | Player Death Overlay & C2S `0x41 Resurrect` | R4.4 | `world.self.life <= 0` (when `maxLife > 0`) or death mode sets `isDead` / `isSelfDead`, renders classic death overlay (`"You Have Died. Press ESC to continue."` / `"你挂了,请按 ESC 键继续"`), and pressing `Escape` or clicking overlay emits `{ type: 'Resurrect' }` (`0x41`, 1B). |
| **F15** | Multiplayer Party State & Party Panel (`P` Hotkey, `0x5D`/`0x5E`) | R5.1 | `ClientWorld.apply({ type: 'Party' })` handles `roster` (`0x5B`/`0x5C`), `relation`, `memberState`, `memberPos`; `'P'` hotkey toggles `'party'` panel; Invite/Cancel/Accept/Leave emit `PartyAction` (`0x5E`, 6B); Hostile (`button=1`) and Loot (`button=2`) emit `Hostile` (`0x5D`, 7B); party portraits & automap markers. |
| **F16** | Player-to-Player Trade UI (`0x77`–`0x79` & C2S `0x4F`) & Quest Reward Item Panels | R5.2, R5.3 | `0x77 ButtonActions`, `0x78 TradeAccepted` (`partnerName`), `0x79 GoldInTrade` (`selfGold`, `partnerGold`), `tradePartner` & `tradeSelf` 10×4 grids, Trade Accept/Cancel (`0x4F ClickButton`, 7B); Quest Reward panels (`imbue` on Charsi 154, `add_sockets` on Larzuk 511, `personalize` on Anya 512). |
---
## 3. Test Architecture
## 3. 4-Tier Suite Architecture & Coverage Thresholds
```text
tests/e2e-module-split/
├── tier1-feature-coverage.test.ts # >= 50 tests (>= 5 per feature F1–F10): happy-path module layout, exports, & contracts
├── tier2-boundary-corner.test.ts # >= 50 tests (>= 5 per feature F1–F10): fail-fast, negative, boundary & isolation invariants
├── tier3-cross-feature.test.ts # >= 15 tests: pairwise cross-module & cross-feature integration contracts
└── tier4-real-world-scenarios.test.ts # >= 6 tests: realistic end-to-end gameplay, DRLG, item/socket, .d2cap replay, & repo audit scenarios
tests/e2e-d2gs-online/
├── helpers.ts # Shared fixtures, mock Canvas/DOM harness, packet/item builders, runtime wrappers
├── tier1-feature-coverage.test.ts # Tier 1: Core feature happy-path coverage (80 tests: 5 per F1–F16)
├── tier2-boundary-corner.test.ts # Tier 2: Boundary, corner, negative & anti-regression coverage (80 tests: 5 per F1–F16)
├── tier3-cross-feature.test.ts # Tier 3: Pairwise cross-feature integration coverage (16 tests across F1–F16 pairs)
└── tier4-real-world-scenarios.test.ts # Tier 4: End-to-end multi-step online gameplay session scenarios (8 scenarios)
```
- **Test Runner Command**: `npx vitest run tests/e2e-module-split/`
| Tier | File | Minimum Required | Implemented | Scope |
|---|---|---|---|---|
| **Tier 1** | `tests/e2e-d2gs-online/tier1-feature-coverage.test.ts` | `>= 80` (`>= 5` / feature) | **80** (`5 × 16`) | Core happy-path behavior for `F1`–`F16` |
| **Tier 2** | `tests/e2e-d2gs-online/tier2-boundary-corner.test.ts` | `>= 80` (`>= 5` / feature) | **80** (`5 × 16`) | Boundary values, malformed/stale packets, server rejection, zero optimistic mutation |
| **Tier 3** | `tests/e2e-d2gs-online/tier3-cross-feature.test.ts` | `>= 16` | **16** | Pairwise cross-feature workflows (`F1×F3`, `F2×F3`, `F1×F4`, `F5×F6`, `F5×F7`, `F8×F9`, `F8×F10`, `F1×F10`, `F11×F12`, `F12×F1`, `F13×F14`, `F14×F8`, `F15×F12`, `F15×F16`, `F5×F16`, `F1×F16`) |
| **Tier 4** | `tests/e2e-d2gs-online/tier4-real-world-scenarios.test.ts` | `>= 8` | **8** | Full multi-step online gameplay session loops |
| **Total** | `tests/e2e-d2gs-online/*.test.ts` | `>= 184` | **184** | All 16 features (`F1`–`F16`) |
---
## 4. Real-World Application Scenarios (Tier 4)
## 4. Tier 4 Real-World Online Gameplay Session Scenarios
1. **Scenario 1 — End-to-End Single-Player Game Loop (`LocalLoopbackAdapter` → `OnlineSession` → `ClientWorld`)**:
- Connect `LocalLoopbackAdapter` to `OnlineSession` and `ClientWorld`, verify initial handshake (`LoadAct`, `UnitAssign`, `MapSeed`), dispatch movement (`RunToLocation` / `WalkToLocation`) and skill cast (`CastLeftSkillAtLocation` / `CastRightSkillAtLocation`), step server ticks, verify authoritative `ClientWorld` state updates, and cleanly disconnect.
2. **Scenario 2 — End-to-End DRLG Level Generation, Void Collision & `findIsoSpawn` Across Act 1–5**:
- Verify universal DT1 inclusion (`Blank.dt1`, `InvisWal.dt1` style=49 seq=7, `Warp.dt1`), void cell collision masking (`COLLIDE_MASK_INVALID = COLLIDE_BLANK | COLLIDE_WALL`), largest walkable floor region spawn resolution (`findIsoSpawn`), and SplitMix64 `pickVariant` spatial decorrelation across Act 1–5 levels.
3. **Scenario 3 — End-to-End Item, Inventory, Belt & Socket Pipeline (`src/common/items/` ↔ `src/server/items/`)**:
- Generate and bridge items via `itemToUiInventoryItem`, place items into `InventoryGrid` (`findFreeGridSlot`), manage belt potions (`BeltHud` / `BeltModel` / `itemToBeltPotion` / column gravity shift), and socket gems/runes into host items (`socketItemIntoHost`, `checkRunewordMatch`).
4. **Scenario 4 — End-to-End `.d2cap` Binary Packet Capture Replay into `ClientWorld`**:
- Replay all 4 real `.d2cap` capture fixtures (`01-bncs-mcp-lobby.d2cap`, `02-d2gs-town-walk-chat.d2cap`, `03-d2gs-items-npc-skills.d2cap`, `04-d2gs-idle-60s.d2cap`) via `replayD2CapText(..., { strict: true })` and verify `unknownPackets === 0`, `opaquePackets === 0`, `protocolErrors === 0`, and populated `ClientWorld` state.
5. **Scenario 5 — End-to-End Offline Asset Pack Manifest & Entity/Tile Pack Integrity**:
- Load and validate `public/act-packs/tiles/{index.json, drlg-bundle.json, drlg-bundle.bin}` against `src/common/pack-contract/` validators (`validateUniversalDt1Libraries`, `validatePackManifest`), and initialize `MapService.fromLoader` with an injected loader to generate an Act 1 level view without `node:fs` in `src/client/`.
6. **Scenario 6 — End-to-End Repository Architectural & Module Boundary Audit**:
- Programmatically scan every `.ts` file in `src/**`, `tools/**`, root `*.html`, `vite.config.ts`, `package.json`, and `tsconfig*.json` to verify all R1–R4 architectural invariants with zero exceptions.
---
## 5. Coverage Thresholds
| Tier | File | Minimum Required Tests | Target Tests |
|---|---|---:|---:|
| **Tier 1** | `tests/e2e-module-split/tier1-feature-coverage.test.ts` | 50 (`>= 5` per F1–F10) | 52 |
| **Tier 2** | `tests/e2e-module-split/tier2-boundary-corner.test.ts` | 50 (`>= 5` per F1–F10) | 52 |
| **Tier 3** | `tests/e2e-module-split/tier3-cross-feature.test.ts` | 15 | 16 |
| **Tier 4** | `tests/e2e-module-split/tier4-real-world-scenarios.test.ts` | 6 | 6 |
| **Total** | `tests/e2e-module-split/` | **121** | **126** |
1. **Scenario 1 — Town Identification, Tome Packing, Rune Socketing & Weapon Swap Loop (`F1 + F2 + F3 + F4`)**:
- Player picks up an unidentified Unique item, right-clicks Tome of Identify (`ibk`) to arm identify targeting, left-clicks the item (`0x27 IdentifyItem`), receives server `0x9C` identified update with canonical 1.13c stats, packs loose Scroll of Identify (`isc`) into `ibk` (`0x29 ScrollToBook`), sockets a rune into a 2-socket helm (`0x28 SocketItem`), and verifies Weapon Set II (`bodyLoc` 11/12) persistence across weapon switch (`0x97`).
2. **Scenario 2 — Belt Potion Management & Shift-Click Mercenary Feeding (`F2 + F3 + F8 + F10`)**:
- Player places potions into belt (`0x23 BeltPut`), swaps a belt slot with a Super Healing Potion on cursor (`0x25 BeltSwap`), removes a belt potion (`0x24 BeltRemove`), and Shift-uses a belt slot (`0x26 UseBeltItem` with `useOnMerc = 1`) to heal a damaged Act 2 mercenary whose `lifePct` subsequently updates via S2C `0x0C`/`0x9E`.
3. **Scenario 3 — Multi-Act Town NPC Dialogue, Cain Identification & Act Travel (`F2 + F5 + F6 + F7`)**:
- Player interacts with Deckard Cain (`hcIdx` 244), opens `Talk` submenu (`Introduction`/`Gossip`/Quest speech -> `0x31 QuestMessage`), triggers Cain Identify Items (`0x38` `action=4`), then interacts with Warriv (`155` -> `travel_east` to Act 2), Meshif (`210` -> `travel_east` to Act 3), and Tyrael (`367` -> `travel_harrogath` to Act 5).
4. **Scenario 4 — Mercenary Captain Hiring, Equipment Paperdoll (`O`) & Post-Combat Resurrection (`F1 + F2 + F8 + F9 + F10`)**:
- Player opens Kashya (`150`) / Greiz (`198`) menu, clicks `Hire` (`0x38` `action=3`), receives `0x4F` + `0x4E` hire candidates, hires a mercenary (`0x36 HireMerc`), opens `'O'` Mercenary Paperdoll, equips/swaps merc gear (`0x61 MercItem`), takes lethal merc damage (`0x9B MercReviveCost`), and resurrects the mercenary (`0x62 NpcResurrectMerc`).
5. **Scenario 5 — Dungeon Warp Traversal, Town Portal Ownership Hover & Ground Loot Pickup (`F2 + F11 + F12`)**:
- Server assigns a dungeon warp tile (`0x09 AssignWarp`), player hovers/clicks warp (`0x04 MoveToUnit` + `0x09 WarpTo`), enters dungeon, hovers an allied Town Portal (`0x60`/`0x82` -> `"Sorceress's Town Portal"`), holds `Alt` to inspect non-overlapping ground labels (`750 Gold` + Magic item), and clicks to pick up (`0x16 ItemPickup`).
6. **Scenario 6 — Combat SkillCast Animation, Player Death Overlay & Town Corpse Resurrection (`F2 + F13 + F14`)**:
- Monster and player exchange `SkillCast` packets (`0x4C`/`0x4D`/`0x6C`) updating facing octants and `'A1'`/`'SC'` animation modes; lethal damage reduces `world.self.life` to `0`, activating `isDead` and rendering `"You Have Died. Press ESC to continue."`; pressing `Escape` dispatches `0x41 Resurrect` (1B), and server respawn restores town state.
7. **Scenario 7 — Multiplayer Party Roster, Invite/Accept, Hostile & Corpse Loot Permissions (`F2 + F15`)**:
- Second player joins (`0x5B` with `partyId=0xffff` and non-empty `charName`), player opens `'P'` Party panel, invites (`0x5E` `action=0x06`) and accepts (`0x5E` `action=0x08`), receives party `memberState` (`0x7F`) and `memberPos` (`0x90`) for top-left party portrait and automap marker, toggles Corpse Loot (`0x5D` `button=2`) and Hostile (`0x5D` `button=1`), and cleans up when peer leaves (`0x5C`).
8. **Scenario 8 — Player-to-Player Trade Session & Act 1/5 Quest Reward Item Upgrading (`F1 + F2 + F5 + F16`)**:
- Player initiates trade (`0x77` `action=0x01`), receives `tradePartner` item (`container: 'tradePartner'`) and `0x79 GoldInTrade`, places `tradeSelf` item (`container: 'trade'`), receives `0x78 TradeAccepted` with `partnerName`, clicks Trade Accept (`0x4F` `buttonId=0x04`), completes trade (`0x77` `action=0x06`), and uses Charsi `imbue` (`154`), Larzuk `add_sockets` (`511`), and Anya `personalize` (`512`) quest reward panels.

View File

@ -1,29 +1,42 @@
# Test Readiness Gate
# Test Readiness Gate (`TEST_READY.md`) — D2GS v1.13c Online Gameplay Loops & Server Authority
Test Runner: `npx vitest run tests/e2e-module-split/`
## Status: READY (Tiers 1–5 Complete & Verified)
## Coverage Summary
The 5-tier E2E test suite for all 16 features (`F1`–`F16`) — including Tier 1–4 opaque-box requirement coverage and Tier 5 white-box post-implementation adversarial hardening — is implemented and verified at `/usr/local/google/home/taodao/diablo2-web/tests/e2e-d2gs-online/`.
| Tier | File | Tests Written | Required Minimum | Status |
|---|---|---|---|---|
| Tier 1 — Feature Coverage | `tests/e2e-module-split/tier1-feature-coverage.test.ts` | 52 | >= 50 (10 features x 5) | READY |
| Tier 2 — Boundary & Corner | `tests/e2e-module-split/tier2-boundary-corner.test.ts` | 52 | >= 50 (10 features x 5) | READY |
| Tier 3 — Cross-Feature | `tests/e2e-module-split/tier3-cross-feature.test.ts` | 16 | >= 15 | READY |
| Tier 4 — Real-World Scenarios | `tests/e2e-module-split/tier4-real-world-scenarios.test.ts` | 6 | >= 6 | READY |
| Tier 5 — Adversarial Hardening | `tests/e2e-module-split/tier5-adversarial-hardening.test.ts` | 12 | >= 10 | READY |
| **Total** | | **138** | **>= 131** | **READY** |
---
## Feature Checklist
## Test Suite Inventory & Execution Commands
| Feature ID | Feature Name | Tier 1 (>=5) | Tier 2 (>=5) | Tier 3 (in pairwise) | Tier 4 (in scenario) | Tier 5 (adversarial) |
| Tier | Test File | Test Count | Minimum Threshold | Run Command |
|---|---|---:|---:|---|
| **Tier 1 (Core Feature Coverage)** | `tests/e2e-d2gs-online/tier1-feature-coverage.test.ts` | **80** (`5 × F1–F16`) | `>= 80` (`>= 5` / feature) | `npx vitest run tests/e2e-d2gs-online/tier1-feature-coverage.test.ts` |
| **Tier 2 (Boundary, Corner & Negative)** | `tests/e2e-d2gs-online/tier2-boundary-corner.test.ts` | **80** (`5 × F1–F16`) | `>= 80` (`>= 5` / feature) | `npx vitest run tests/e2e-d2gs-online/tier2-boundary-corner.test.ts` |
| **Tier 3 (Cross-Feature Integration)** | `tests/e2e-d2gs-online/tier3-cross-feature.test.ts` | **16** | `>= 16` | `npx vitest run tests/e2e-d2gs-online/tier3-cross-feature.test.ts` |
| **Tier 4 (Real-World Session Scenarios)** | `tests/e2e-d2gs-online/tier4-real-world-scenarios.test.ts` | **8** | `>= 8` | `npx vitest run tests/e2e-d2gs-online/tier4-real-world-scenarios.test.ts` |
| **Tier 5 (White-Box Adversarial Hardening)** | `tests/e2e-d2gs-online/tier5-adversarial-hardening.test.ts` | **20** | `>= 16` | `npx vitest run tests/e2e-d2gs-online/tier5-adversarial-hardening.test.ts` |
| **Full E2E Suite (Tiers 1–5)** | `tests/e2e-d2gs-online/` | **204** | `>= 200` | `npx vitest run tests/e2e-d2gs-online` |
| **TypeScript Typecheck** | `tsconfig.json` | 0 errors | 0 errors | `npx tsc --noEmit` |
---
## Feature-to-Test Coverage Matrix (`F1`–`F16`)
| Feature | Requirement | Tier 1 Tests | Tier 2 Tests | Tier 3 Tests | Tier 4 Scenarios | Tier 5 Adversarial |
|---|---|---|---|---|---|---|
| F1 | `src/common/` Pure Module & Data Registry Decoupling | 6 | 6 | Yes (`CF5`) | Yes (`Scenario 3`, `Scenario 5`) | Yes (`adv_F1_01..06`) |
| F2 | `src/common/` Shared Formats, MPQ, Data & i18n Submodules | 5 | 5 | Yes (`CF1`, `CF8`, `CF11`) | Yes (`Scenario 3`, `Scenario 6`) | Yes (`adv_F2_01..04`) |
| F3 | `src/common/` Shared Domain Models (Items, Units, Skills, World) | 5 | 6 | Yes (`CF2`, `CF3`, `CF4`, `CF5`, `CF8`, `CF9`, `CF10`) | Yes (`Scenario 1`, `Scenario 5`, `Scenario 6`) | Yes (`adv_server_hardening`) |
| F4 | `src/netproto/` Pure Binary Wire Protocol Layer | 5 | 5 | Yes (`CF6`, `CF7`, `CF16`) | Yes (`Scenario 1`, `Scenario 2`, `Scenario 3`) | Yes (`adv_BUG_03`) |
| F5 | `src/server/` Authoritative Game Simulation & AI Engine | 5 | 5 | Yes (`CF1`, `CF2`, `CF6`, `CF9`, `CF10`, `CF14`) | Yes (`Scenario 1`, `Scenario 3`, `Scenario 6`) | Yes (`adv_server_world_200_step_determinism`, `adv_game_engine_200_step_determinism`, `adv_server_hardening`) |
| F6 | `src/client/` Browser Presentation, Renderer, Audio, UI & Session | 6 | 5 | Yes (`CF3`, `CF4`, `CF6`, `CF7`, `CF8`, `CF12`, `CF16`) | Yes (`Scenario 1`, `Scenario 2`, `Scenario 3`, `Scenario 4`) | Yes (`adv_BUG_01`, `adv_online_session_lifecycle`) |
| F7 | Unified `play.html` Single-Entry Launcher & Vite Build Consolidation | 5 | 5 | Yes (`CF12`, `CF13`) | Yes (`Scenario 4`) | Yes (`adv_BUG_01`) |
| F8 | `src/baker/` Offline MPQ Extraction & CLI Tools Alignment | 5 | 5 | Yes (`CF5`, `CF11`, `CF15`) | Yes (`Scenario 3`, `Scenario 5`) | Yes (`adv_F4_01..03`) |
| F9 | Multi-TSConfig Compile-Time Module Boundary Enforcement | 5 | 5 | Yes (`CF13`, `CF14`, `CF15`) | Yes (`Scenario 3`) | Yes (`adv_BUG_01..03`) |
| F10 | Repository-Wide Import Graph & Test/Benchmark Suite Migration | 5 | 5 | Yes (`CF16`) | Yes (`Scenario 2`, `Scenario 3`) | Yes (`adv_BUG_02`, `adv_BUG_03`) |
| **F1** — Canonical 1.13c Online Item Tooltip Bridge | R1.1 | `F1.1`–`F1.5` (5) | `F1.B1`–`F1.B5` (5) | `CF1, CF2, CF4, CF9, CF12, CF16` | Scenarios 1, 4, 8 | `T5.1, T5.5` |
| **F2** — C2S Container, Belt, NPC, Merc, Warp, Party & Trade Packet Encoders | R1.2, R2–R5 | `F2.1`–`F2.5` (5) | `F2.B1`–`F2.B5` (5) | `CF3` + all C2S assertions | Scenarios 1–8 | `T5.18, T5.19, T5.20` |
| **F3** — Server-Authoritative Container, Belt, Identify, Socket & Tome UI Wiring | R1.2 | `F3.1`–`F3.5` (5) | `F3.B1`–`F3.B5` (5) | `CF1, CF2, CF3, CF10` | Scenarios 1, 2 | `T5.1, T5.2, T5.3, T5.4` |
| **F4** — Weapon Set II (`bodyLoc` 11 & 12) Sync | R1.3 | `F4.1`–`F4.5` (5) | `F4.B1`–`F4.B5` (5) | `CF4` | Scenario 1 | `T5.6` |
| **F5** — Act 1–5 Town & Quest NPC Descriptors | R2.1 | `F5.1`–`F5.5` (5) | `F5.B1`–`F5.B5` (5) | `CF5, CF6, CF16` | Scenarios 3, 8 | `T5.8, T5.9, T5.17` |
| **F6** — NPC Info (`0x27`), SetNPCTalk (`0x91`), Talk Submenu & `0x31 QuestMessage` | R2.2 | `F6.1`–`F6.5` (5) | `F6.B1`–`F6.B5` (5) | `CF5` | Scenario 3 | `T5.7, T5.8, T5.9` |
| **F7** — Act Travel Menu Options & C2S `0x38 EntityAction` | R2.3 | `F7.1`–`F7.5` (5) | `F7.B1`–`F7.B5` (5) | `CF6` | Scenario 3 | `T5.19` |
| **F8** — Mercenary World State (`0x4E`, `0x4F`, `0x9B` & `lifePct`) | R3.1 | `F8.1`–`F8.5` (5) | `F8.B1`–`F8.B5` (5) | `CF7, CF8, CF10, CF14` | Scenarios 2, 4 | `T5.10, T5.11` |
| **F9** — Mercenary Hire (`0x36`) & Resurrect (`0x62`) UI | R3.2 | `F9.1`–`F9.5` (5) | `F9.B1`–`F9.B5` (5) | `CF7, CF8` | Scenario 4 | `T5.10, T5.19` |
| **F10** — Mercenary HUD Portrait & Paperdoll (`O` Hotkey, `0x61 MercItem`) | R3.3 | `F10.1`–`F10.5` (5) | `F10.B1`–`F10.B5` (5) | `CF9, CF10` | Scenarios 2, 4 | `T5.10, T5.11` |
| **F11** — Dungeon/Stair Warp Clicking (`0x09 AssignWarp` → C2S `0x09 WarpTo`) | R4.1 | `F11.1`–`F11.5` (5) | `F11.B1`–`F11.B5` (5) | `CF11` | Scenario 5 | `T5.12, T5.19` |
| **F12** — Portal Ownership Hover (`0x60`/`0x82`) & Ground Item Labels (`0x16`) | R4.2 | `F12.1`–`F12.5` (5) | `F12.B1`–`F12.B5` (5) | `CF11, CF12, CF15` | Scenario 5 | `T5.12, T5.19` |
| **F13** — `SkillCast` (`0x4C`/`0x4D`/`0x6C`/`0x99`/`0x9A`) Animation | R4.3 | `F13.1`–`F13.5` (5) | `F13.B1`–`F13.B5` (5) | `CF13` | Scenario 6 | `T5.13` |
| **F14** — Player Death Overlay & C2S `0x41 Resurrect` | R4.4 | `F14.1`–`F14.5` (5) | `F14.B1`–`F14.B5` (5) | `CF13, CF14` | Scenario 6 | `T5.14, T5.19` |
| **F15** — Multiplayer Party State & Party Panel (`P` Hotkey, `0x5D`/`0x5E`) | R5.1 | `F15.1`–`F15.5` (5) | `F15.B1`–`F15.B5` (5) | `CF15` | Scenario 7 | `T5.16, T5.19` |
| **F16** — Player-to-Player Trade UI (`0x77`–`0x79` & C2S `0x4F`) & Quest Reward Panels | R5.2, R5.3 | `F16.1`–`F16.5` (5) | `F16.B1`–`F16.B5` (5) | `CF16` | Scenario 8 | `T5.15, T5.17, T5.18` |

View File

@ -260,7 +260,7 @@ export class CommandMapper {
const dx = unit.x - subtileX
const dy = unit.y - subtileY
const d2 = dx * dx + dy * dy
if (d2 <= bestDistSq) {
if (d2 <= radiusSq && (bestUnit === null || d2 < bestDistSq)) {
bestDistSq = d2
bestUnit = unit
}
@ -281,7 +281,7 @@ export class CommandMapper {
const dx = unit.x - subtileX
const dy = unit.y - subtileY
const d2 = dx * dx + dy * dy
if (d2 <= bestDistSq) {
if (d2 <= radiusSq && (bestUnit === null || d2 < bestDistSq)) {
bestDistSq = d2
bestUnit = unit
}
@ -429,8 +429,22 @@ export class CommandMapper {
// Unit interaction when clicking directly on an entity
if (hoveredUnit) {
const predSnap = this.predictor?.getSnapshot()
const selfX =
predSnap && (predSnap.x !== 0 || predSnap.y !== 0) ? predSnap.x : this.world.self.x
const selfY =
predSnap && (predSnap.x !== 0 || predSnap.y !== 0) ? predSnap.y : this.world.self.y
if (hoveredUnit.unitType === 'item') {
const path = this.startLocalPathPrediction(hoveredUnit.x, hoveredUnit.y, isRunning)
const dist = Math.hypot(hoveredUnit.x - selfX, hoveredUnit.y - selfY)
if (dist > 4) {
this.sendCommand({
type: 'MoveToUnit',
run: isRunning,
target: unitToRef(hoveredUnit),
})
}
return this.dispatchRateLimited(
{
type: 'ItemPickup',
@ -442,8 +456,37 @@ export class CommandMapper {
)
}
if (hoveredUnit.unitType === 'object' || hoveredUnit.unitType === 'tile') {
if (hoveredUnit.unitType === 'tile' || (hoveredUnit as any).kind === 'warp') {
const path = this.startLocalPathPrediction(hoveredUnit.x, hoveredUnit.y, isRunning)
const moveRec = this.dispatchRateLimited(
{
type: 'MoveToUnit',
run: isRunning,
target: {
unitType: UNIT_TYPE_TO_ID.tile,
id: hoveredUnit.id,
},
},
nowMs,
path,
)
this.sendCommand({
type: 'TakeWarp',
warpId: hoveredUnit.id,
})
return moveRec
}
if (hoveredUnit.unitType === 'object') {
const path = this.startLocalPathPrediction(hoveredUnit.x, hoveredUnit.y, isRunning)
const dist = Math.hypot(hoveredUnit.x - selfX, hoveredUnit.y - selfY)
if (dist > 4) {
this.sendCommand({
type: 'MoveToUnit',
run: isRunning,
target: unitToRef(hoveredUnit),
})
}
return this.dispatchRateLimited(
{
type: 'Interact',
@ -454,6 +497,27 @@ export class CommandMapper {
)
}
if (hoveredUnit.unitType === 'player') {
const path = this.startLocalPathPrediction(hoveredUnit.x, hoveredUnit.y, isRunning)
const ref = unitToRef(hoveredUnit)
const dist = Math.hypot(hoveredUnit.x - selfX, hoveredUnit.y - selfY)
if (dist > 4) {
this.sendCommand({
type: 'MoveToUnit',
run: isRunning,
target: ref,
})
}
return this.dispatchRateLimited(
{
type: 'Interact',
target: ref,
},
nowMs,
path,
)
}
if (hoveredUnit.unitType === 'monster') {
const isTownArea =
this.world.areaId === 1 ||
@ -587,9 +651,23 @@ export class CommandMapper {
const keyUpper = event.key.toUpperCase()
// Escape: close open HUD panels first, otherwise invoke escape menu callback / toggle
// Escape: when dead -> Resurrect (0x41); otherwise close open HUD panels first, then escape menu
if (event.key === 'Escape') {
if (this.hudManager.leftPanel !== 'none' || this.hudManager.rightPanel !== 'none') {
if (this.world.isSelfDead || this.world.isDead) {
const cmd: ClientCommand = { type: 'Resurrect' }
this.sendCommand(cmd)
this.world.noteOutgoingCommand(cmd)
event.preventDefault?.()
return true
}
const worldPanelOpen =
(this.hudManager.worldPanels as any)?.activePanel &&
(this.hudManager.worldPanels as any).activePanel !== 'none'
if (
this.hudManager.leftPanel !== 'none' ||
this.hudManager.rightPanel !== 'none' ||
worldPanelOpen
) {
this.hudManager.closeAllPanels()
event.preventDefault?.()
return true
@ -722,9 +800,34 @@ export class CommandMapper {
this.hudManager.toggleLeftPanel('quest')
return true
}
case 'P':
case 'P': {
if (this.hudModel) {
this.hudModel.togglePanel('party')
} else {
this.hudManager.toggleLeftPanel('party' as any)
}
return true
}
case 'M': {
if (this.hudModel) {
this.hudModel.togglePanel('waypoint')
} else {
this.hudManager.toggleLeftPanel('waypoint')
}
return true
}
case 'O': {
if (this.world.merc && !this.world.merc.isDead) {
if (this.hudModel) {
const prevLeft = this.hudManager.leftPanel as string
const res = this.hudModel.togglePanel('merc')
if (!res && prevLeft !== 'merc' && (this.hudManager.leftPanel as string) !== 'merc') {
this.hudManager.toggleLeftPanel('merc' as any)
}
} else {
this.hudManager.toggleLeftPanel('merc' as any)
}
}
return true
}
default:
@ -841,6 +944,17 @@ export class CommandMapper {
const rateLimit = this.rateLimiter.tryAcquire(command, nowMs)
if (rateLimit.allowed) {
this.sendCommand(command)
if (
command.type === 'SkillAt' ||
command.type === 'SkillOn' ||
command.type === 'CastLeftSkillOnLocation' ||
command.type === 'CastRightSkillOnLocation' ||
command.type === 'CastLeftSkillOnTarget' ||
command.type === 'CastRightSkillOnTarget' ||
command.type === 'Resurrect'
) {
this.world.noteOutgoingCommand(command)
}
}
return {
command,

View File

@ -36,12 +36,15 @@ import type {
import { MONSTER_ART_MAP } from '../../common/units/monster-mapping.ts'
import { getTownNpcDescriptorByHcIdx } from '../../common/units/npc-table.ts'
import { BAKED_UI_MANIFEST, type SpriteRect } from '../../common/items/baked-ui-meta.ts'
import { layoutGroundItemLabels } from '../../common/items/ground-items.ts'
import { resolveGroundItemSpriteRect } from '../ui/inventory.ts'
import {
applyLightingInputs,
type LightingPassTargets,
} from './lighting-presets.ts'
export { layoutGroundItemLabels }
/**
* Diagnostic entry recorded whenever an entity, item, missile, overlay, or tile
* is missing its authentic art asset at render time (Issue #540 R3).
@ -553,6 +556,7 @@ function drawSceneUnitDrawable(
if (unit.unitKind === 'item') {
const rawCode =
unit.code?.trim().toLowerCase() ||
unit.itemCode?.trim().toLowerCase() ||
(unit.token && !unit.token.startsWith('ITM_') ? unit.token.trim().toLowerCase() : '') ||
BAKED_UI_MANIFEST.itemClassIdToCode[unit.classId] ||
''
@ -623,6 +627,10 @@ function drawSceneUnitDrawable(
return
}
if (unit.unitKind === 'warp' || unit.unitKind === 'tile') {
return
}
if (unit.unitKind === 'object') {
if (unit.classId === 59 || unit.classId === 60) {
if (ctx.overlayArtMap && ctx.overlayArtMap.size > 0) {
@ -943,8 +951,15 @@ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFram
)
const isEn = getTblLang() === 'ENG'
const automapEntities: AutomapEntityMarker[] = []
const automapPortals: {
readonly x: number
readonly y: number
readonly active: boolean
readonly kind: 'blue' | 'red'
readonly label?: string
}[] = []
for (const u of snapshot.entities.unitDrawables) {
if (u.unitKind !== 'monster' || u.isCorpse) continue
if (u.unitKind === 'monster' && !u.isCorpse) {
const npcDesc = getTownNpcDescriptorByHcIdx(u.classId)
if (npcDesc) {
automapEntities.push({
@ -954,6 +969,22 @@ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFram
name: isEn ? npcDesc.displayName : npcDisplayName(npcDesc.displayName),
})
}
} else if (u.unitKind === 'object' && (u.classId === 59 || u.classId === 60)) {
automapPortals.push({
x: u.worldX,
y: u.worldY,
active: true,
kind: u.classId === 60 ? 'red' : 'blue',
...(u.name !== undefined ? { label: u.name } : {}),
})
} else if (u.unitKind === 'player' && u.isPartyMember) {
automapEntities.push({
x: u.worldX,
y: u.worldY,
kind: 'party',
...(u.name !== undefined ? { name: u.name } : {}),
})
}
}
ctx.minimap.draw(
renderer,
@ -971,6 +1002,7 @@ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFram
leftPanelOpen: Boolean(ctx.hudManager && ctx.hudManager.leftPanel !== 'none'),
rightPanelOpen: Boolean(ctx.hudManager && ctx.hudManager.rightPanel !== 'none'),
entities: automapEntities,
portals: automapPortals,
levelId: snapshot.levelId,
},
)

View File

@ -42,6 +42,8 @@ import {
type ServerEvent,
type UnitType,
} from '../../netproto/index.ts'
import { CANONICAL_LEVEL_LIGHTING } from '../../common/world/levels-meta.ts'
import { resolveGroundItemMetadata } from '../../common/items/ground-items.ts'
import { requireTbl } from '../i18n/lang.ts'
import { HudManager } from '../ui/hud-manager.ts'
import { SoundService } from '../audio/sound-service.ts'
@ -52,6 +54,7 @@ import {
type CameraLike,
} from '../input/command-mapper.ts'
import { PacketInspector, type ReplayedD2CapFrame } from '../inspector/packet-inspector.ts'
import type { LevelView } from '../map/level-view.ts'
import type { MapService } from '../map/map-service.ts'
import { RemoteEntityInterpolator } from '../motion/interpolate.ts'
import { LocalMovementPredictor } from '../motion/predictor.ts'
@ -228,6 +231,7 @@ export class OnlineSession {
readonly hudModel: HudModel
readonly commandMapper: CommandMapper
readonly sceneSource: ClientWorldSceneSource
levelView?: LevelView | null | undefined
private readonly resolver: EndpointResolver
private readonly flowFactory: (config: D2OnlineConfig) => D2OnlineFlow
@ -368,19 +372,22 @@ export class OnlineSession {
// canvas contains the player's world sub-tile (1.13c: unit → pRoom → pLevel); until such a
// level exists the scene reports not-ready.
const predSnap = this.predictor.getSnapshot()
const playerSubX = predSnap.x !== 0 || predSnap.y !== 0 ? predSnap.x : this.world.self.x
const playerSubY = predSnap.x !== 0 || predSnap.y !== 0 ? predSnap.y : this.world.self.y
const levelView =
this.mapService && this.world.areaId > 0 && (predSnap.x !== 0 || predSnap.y !== 0)
? this.mapService.findLevelViewAtWorldSubTile(predSnap.x, predSnap.y, [
this.levelView ??
(this.mapService && this.world.areaId > 0 && (playerSubX !== 0 || playerSubY !== 0)
? this.mapService.findLevelViewAtWorldSubTile(playerSubX, playerSubY, [
this.world.areaId,
...this.world.getRevealedAreaIds(),
])
: null
const levelId = levelView?.areaId ?? 0
: null)
const levelId = levelView?.areaId ?? (this.world.areaId > 0 ? this.world.areaId : 0)
return {
tick: this.tickCounter,
...(levelId > 0 ? { actId: getActForArea(levelId), levelId } : {}),
playerSubX: predSnap.x,
playerSubY: predSnap.y,
playerSubX,
playerSubY,
units: this.buildRenderableUnits(),
levelView,
tileAtlas: this.mapService?.getTileAtlas() ?? null,
@ -903,8 +910,53 @@ export class OnlineSession {
* Process an authoritative `ServerEvent` across `ClientWorld`, `MapService`, `AutomapView`,
* `LocalMovementPredictor`, `RemoteEntityInterpolator`, `SoundService`, and `HudModel`.
*/
handleServerEvent(ev: ServerEvent): void {
handleServerEvent(rawEv: ServerEvent): void {
const prevChatCount = this.world.chatLog.length
const evAny = rawEv as Record<string, any>
let ev: ServerEvent = rawEv
if (evAny.type === 'PortalOwner' || evAny.type === 'PortalInfo') {
ev = {
type: 'Portal',
ownerId: evAny.ownerId,
ownerName: evAny.ownerName,
localId: Number(evAny.localId ?? evAny.unitId ?? 0),
remoteId: evAny.remoteId,
areaId: Number(evAny.areaId ?? evAny.levelId ?? 0),
flags: evAny.flags,
}
} else if (evAny.type === 'MercAssign') {
ev = {
type: 'Merc',
kind: 'assign',
ownerId: evAny.ownerId,
mercId: evAny.mercId,
classId: evAny.classId,
nameId: evAny.nameId,
}
} else if (evAny.type === 'PlayerKill') {
ev = {
type: 'CorpseAssign',
ownerId: Number(evAny.victimId ?? evAny.ownerId ?? this.world.selfId),
corpseId: Number(evAny.corpseId ?? 0),
assign: 1,
} as unknown as ServerEvent
} else if (
evAny.type === 'SkillCast' &&
(!evAny.caster || (evAny.target === undefined && evAny.targetX !== undefined))
) {
ev = {
...evAny,
type: 'SkillCast',
caster: evAny.caster ?? evAny.unit ?? { unitType: evAny.unitType ?? 0, id: evAny.unitId ?? 1 },
skill: Number(evAny.skill ?? evAny.skillId ?? 0),
level: Number(evAny.level ?? evAny.skillLevel ?? 1),
target:
evAny.target ??
(evAny.targetX !== undefined && evAny.targetY !== undefined
? { x: Number(evAny.targetX), y: Number(evAny.targetY) }
: { x: 0, y: 0 }),
} as ServerEvent
}
this.world.apply(ev)
if (
@ -1203,6 +1255,22 @@ export class OnlineSession {
// Self is not renderable until the server has assigned both its unit id (0x59) and class.
const selfClass = this.world.self.charClass
if (this.world.selfId !== 0 && selfClass !== null) {
const selfUnit = this.world.getUnit('player', this.world.selfId)
const isSelfDead = this.world.isSelfDead || this.world.isDead
const selfCastState = !isSelfDead ? (selfUnit?.castState ?? this.world.self.castState) : undefined
const selfMode = isSelfDead
? 12
: selfCastState
? selfCastState.mode || 10
: predSnap.moving
? predSnap.running
? 3
: 2
: selfUnit?.mode && selfUnit.mode > 1
? selfUnit.mode
: 1
const selfDirection =
selfCastState && selfUnit !== undefined ? selfUnit.direction : predSnap.dir16
out.push({
id: this.world.selfId,
kind: 'player',
@ -1210,16 +1278,31 @@ export class OnlineSession {
name: this.world.self.name,
x: selfX,
y: selfY,
mode: predSnap.moving ? (predSnap.running ? 3 : 2) : 1,
direction: predSnap.dir16,
...(selfCastState?.targetX !== undefined ? { targetX: selfCastState.targetX } : {}),
...(selfCastState?.targetY !== undefined ? { targetY: selfCastState.targetY } : {}),
mode: selfMode,
direction: selfDirection,
hpRatio:
this.world.self.maxLife > 0
? Math.max(0, Math.min(1, this.world.self.life / this.world.self.maxLife))
: 1,
isLocalPlayer: true,
isCorpse: isSelfDead,
...(selfCastState !== undefined ? { castState: selfCastState } : {}),
})
}
const activeLevelView =
this.levelView ??
(this.mapService && this.world.areaId > 0 && (selfX !== 0 || selfY !== 0)
? this.mapService.findLevelViewAtWorldSubTile(selfX, selfY, [
this.world.areaId,
...this.world.getRevealedAreaIds(),
])
: null) ??
this.mapService?.getActiveLevelView() ??
this.automapView.getLevelView()
for (const u of this.world.units.values()) {
if (u.unitType === 'player' && this.world.selfId !== 0 && u.id === this.world.selfId) {
continue
@ -1239,25 +1322,164 @@ export class OnlineSession {
u.id,
)
const groundItem = u.unitType === 'item' ? this.world.containers.ground.get(u.id) : undefined
const itemCode = u.itemCode ?? groundItem?.code ?? (u.unitType === 'item' ? u.name : undefined)
const ux = interp ? interp.x : u.x
const uy = interp ? interp.y : u.y
if (u.unitType === 'tile') {
const warpId = u.warpId ?? u.classId
let matchedWarp = activeLevelView?.warps.find((w) => w.lvlWarpId === warpId)
if (!matchedWarp && activeLevelView && activeLevelView.warps.length > 0) {
let bestDistSq = 30 * 30
for (const w of activeLevelView.warps) {
const wAny = w as Record<string, any>
const room =
typeof w.room === 'number' ? activeLevelView.rooms[w.room] : undefined
const subX = typeof wAny.subX === 'number' ? wAny.subX : (room?.subX ?? 0)
const subY = typeof wAny.subY === 'number' ? wAny.subY : (room?.subY ?? 0)
const wx = activeLevelView.subOriginX + subX
const wy = activeLevelView.subOriginY + subY
const dSq = (wx - ux) * (wx - ux) + (wy - uy) * (wy - uy)
if (dSq <= bestDistSq) {
bestDistSq = dSq
matchedWarp = w
}
}
}
const toLevelId = matchedWarp?.toLevelId
const toAreaName =
toLevelId !== undefined ? CANONICAL_LEVEL_LIGHTING[toLevelId]?.levelName : undefined
const warpName =
u.name && u.name.trim() !== ''
? u.name
: toAreaName
? `To ${toAreaName}`
: `Entrance (${warpId})`
out.push({
id: u.id,
kind: u.unitType === 'tile' ? 'object' : u.unitType,
kind: 'warp',
classId: u.classId,
name: u.name,
code: itemCode,
itemQuality: u.itemQuality ?? groundItem?.quality,
goldAmount: u.goldAmount ?? groundItem?.goldAmount,
x: interp ? interp.x : u.x,
y: interp ? interp.y : u.y,
warpId,
...(toLevelId !== undefined ? { toLevelId } : {}),
name: warpName,
x: ux,
y: uy,
mode: u.mode,
direction: interp ? interp.dir16 : u.direction,
radiusX: 28,
radiusY: 20,
})
continue
}
const isCorpse = u.unitType === 'monster' && (u.mode === 0 || u.mode === 12 || u.lifePct <= 0)
const castState = !isCorpse ? u.castState : undefined
const mode = castState ? castState.mode || u.mode : u.mode
const targetX = castState?.targetX ?? u.targetX
const targetY = castState?.targetY ?? u.targetY
if (u.unitType === 'object' && (u.classId === 59 || u.classId === 60)) {
const portal =
this.world.portals.get(u.id) ??
Array.from(this.world.portals.values()).find(
(p) => p.localId === u.id || p.remoteId === u.id,
)
const destAreaName = portal?.areaId
? CANONICAL_LEVEL_LIGHTING[portal.areaId]?.levelName
: undefined
const basePortalName = portal?.ownerName
? `${portal.ownerName}'s Town Portal`
: u.name && u.name.trim() !== ''
? u.name
: 'Town Portal'
const portalName =
destAreaName && !basePortalName.includes(destAreaName)
? `${basePortalName} (${destAreaName})`
: basePortalName
out.push({
id: u.id,
kind: 'object',
classId: u.classId,
name: portalName,
x: ux,
y: uy,
mode,
direction: interp ? interp.dir16 : u.direction,
hpRatio: Math.max(0, Math.min(1, u.lifePct / 128)),
isCorpse: u.unitType === 'monster' && (u.mode === 0 || u.mode === 12 || u.lifePct <= 0),
isCorpse: false,
states: Array.from(u.states.list()),
...(portal?.ownerName !== undefined ? { portalOwnerName: portal.ownerName } : {}),
...(portal?.areaId !== undefined ? { portalAreaId: portal.areaId } : {}),
})
continue
}
if (u.unitType === 'item') {
const itemCode = u.itemCode ?? groundItem?.code ?? u.itemData?.code ?? (u.name && /^[a-z0-9]{3,4}$/i.test(u.name.trim()) ? u.name.trim() : undefined)
const itemQuality = u.itemQuality ?? u.quality ?? groundItem?.quality ?? u.itemData?.quality
const meta = u.itemData
? resolveGroundItemMetadata(u.itemData, u.goldAmount ?? groundItem?.goldAmount)
: itemCode
? resolveGroundItemMetadata(
{
code: itemCode,
...(itemQuality !== undefined ? { quality: itemQuality } : {}),
...((u.goldAmount ?? groundItem?.goldAmount) !== undefined ? { goldAmount: u.goldAmount ?? groundItem?.goldAmount } : {}),
...(u.name !== undefined ? { name: u.name } : {}),
},
u.goldAmount ?? groundItem?.goldAmount,
)
: undefined
const goldAmount = u.goldAmount ?? groundItem?.goldAmount ?? (meta?.isGold ? meta.amount : undefined)
const itemName =
meta?.isGold && goldAmount !== undefined
? `${goldAmount} Gold`
: u.name && u.name.trim() !== ''
? u.name
: meta?.name
out.push({
id: u.id,
kind: 'item',
classId: u.classId,
...(itemName !== undefined ? { name: itemName } : {}),
...(itemCode !== undefined ? { code: itemCode, itemCode } : {}),
x: ux,
y: uy,
mode,
direction: interp ? interp.dir16 : u.direction,
hpRatio: 1,
isCorpse: false,
...(goldAmount !== undefined ? { goldAmount } : {}),
...(itemQuality !== undefined ? { itemQuality, quality: itemQuality } : {}),
})
continue
}
const partyMember = u.unitType === 'player' ? this.world.party.get(u.id) : undefined
const isPartyMember = Boolean(
partyMember &&
(partyMember.partyState === 1 ||
(partyMember.partyId !== undefined && partyMember.partyId !== 0xffff)),
)
out.push({
id: u.id,
kind: u.unitType,
classId: u.classId,
name: u.name,
x: ux,
y: uy,
...(targetX !== undefined ? { targetX } : {}),
...(targetY !== undefined ? { targetY } : {}),
mode,
direction: interp ? interp.dir16 : u.direction,
hpRatio: Math.max(0, Math.min(1, u.lifePct / 128)),
isCorpse,
isChampion: u.isChampion,
isUnique: u.isUnique,
isSuperUnique: u.isSuperUnique,
states: Array.from(u.states.list()),
...(castState !== undefined ? { castState } : {}),
...(u.unitType === 'player' ? { isPartyMember } : {}),
})
}

File diff suppressed because it is too large Load Diff

View File

@ -27,7 +27,11 @@ import {
resolveItemSpriteRect,
INV_GRID_ORIGIN,
EQUIP_SLOTS_LAYOUT,
WEAPON_SWAP_TABS_BOUNDS,
isUsableRightClickItem,
isSocketableUiItem,
isScrollForTome,
isUiItemUnidentified,
} from './inventory.ts'
import { D2_EXPERIENCE_TABLE_99 } from '../../common/skills/formulas.ts'
import { CharacterSheetPanel, type BaseStatKey } from './character-sheet.ts'
@ -68,6 +72,16 @@ import {
} from '../view/viewport-profile.ts'
import type { ViewportResolution } from '../settings/client-settings.ts'
export type LeftPanelType =
| LeftDockPanelKind
| 'merc'
| 'hire'
| 'party'
| 'trade'
| 'imbue'
| 'add_sockets'
| 'personalize'
export interface PublishedHudState {
ready: boolean
dc6DecodeFailures: number
@ -88,7 +102,7 @@ export interface PublishedHudState {
questsAtlas: boolean
escapeMenu?: boolean
}
leftPanel: LeftDockPanelKind
leftPanel: LeftPanelType
rightPanel: 'none' | 'inv' | 'skill'
beltExpanded: boolean
beltPotionsCount: number
@ -309,13 +323,21 @@ export interface HudCommandSink {
allocateStat(key: BaseStatKey): boolean
allocateSkill(skillId: number): boolean
selectSkill(hand: 'left' | 'right', skillId: number): void
useBeltSlot(col: number): boolean
useBeltSlot(col: number, shiftToMerc?: boolean): boolean
swapWeapons(): void
pickupItemToCursor(itemId: number): void
/** `buffer`: `0` = inventory, `3` = cube, `4` = stash. */
placeCursorItem(itemId: number, col: number, row: number, buffer: number): void
equipItem(itemId: number, slot: EquipSlotId): void
unequipItem(slot: EquipSlotId): void
swapEquipItem?(cursorItemId: number, slot: EquipSlotId): void
swapContainerItem?(cursorItemId: number, targetItemId: number, col: number, row: number, buffer: number): void
putBeltItem?(cursorItemId: number, slotIndex: number): void
removeBeltItem?(itemId: number): void
swapBeltItem?(cursorItemId: number, beltItemId: number): void
identifyItem?(targetItemId: number, scrollItemId: number): void
socketItem?(cursorItemId: number, targetItemId: number): void
scrollToBook?(scrollItemId: number, bookItemId: number): void
dropItem(itemId: number): void
dropGold(amount: number): void
useItem(itemId: number): void
@ -343,6 +365,30 @@ export interface HudCommandSink {
withdrawGold(amount: number): void
/** Stash panel closed: `0x4F` button 0x12 (no reply). */
closeStash(): void
questMessage?(npcId: number, messageId: number): void
npcEntityAction?(actionType: number, entityId: number, complement: number): void
hireMerc?(npcId: number, mercListIndex: number): void
resurrectMerc?(npcId: number): void
mercItem?(bodyLoc: number): void
resurrect?(): void
partyAction?(action: number, playerUnitId: number): void
setHostile?(playerUnitId: number, hostile: boolean, button?: number): void
hostile?(playerUnitId: number, flag: number | boolean, button?: number): void
clickButton?(buttonId: number, complement?: number): void
confirmQuestReward?(itemId?: number): void
closeActiveNpcPanel?(): void
handleNpcMenuAction?(action: string, payload?: any): void
onResurrect?(): void
onSwapEquipItem?(cursorItemId: number, slot: EquipSlotId): void
onSwapContainerItem?(cursorItemId: number, targetItemId: number, col: number, row: number, buffer: number): void
onPutBeltItem?(cursorItemId: number, slotIndex: number): void
onRemoveBeltItem?(itemId: number): void
onSwapBeltItem?(cursorItemId: number, beltItemId: number): void
onUseBeltSlot?(slotIndex: number, shiftToMerc?: boolean): boolean | void
onIdentifyItem?(targetItemId: number, scrollItemId: number): void
onSocketItem?(cursorItemId: number, targetItemId: number): void
onScrollToBook?(scrollItemId: number, bookItemId: number): void
onUseInventoryItem?(itemId: number, x?: number, y?: number): void
}
/** Parse the numeric server item id carried by a `UiInventoryItem` synced from `ClientWorld`; throws otherwise. */
@ -364,7 +410,7 @@ export class HudManager {
readonly worldPanels = new WorldPanelsHud()
readonly cursor = new CursorController()
leftPanel: LeftDockPanelKind = 'none'
leftPanel: LeftPanelType = 'none'
rightPanel: 'none' | 'inv' | 'skill' = 'none'
isRunning = true
showGroundLabels = false
@ -376,6 +422,26 @@ export class HudManager {
mana = 0
maxMana = 0
/** Server-authoritative death / mercenary / party / trade state synced by `HudModel`. */
isDead = false
merc: any = null
hireList: any[] = []
mercHireList: any[] = []
partyMembers: any[] = []
trade: any = null
pendingIdentifyScrollId: number | null = null
/** Optional 2D rendering context override (used by headless/unit tests). */
ctx: CanvasRenderingContext2D | null = null
/** Optional weapon switch callback. */
onWeaponSwitch?: (() => void) | undefined
onMercEquipSlotClick?: ((slot: number) => void) | undefined
onHireMercClick?: ((npcId: number, mercId: number) => void) | undefined
onPartyActionClick?: ((action: number, targetPlayerId: number) => void) | undefined
onHostileToggleClick?: ((targetPlayerId: number, hostile: boolean, button?: number) => void) | undefined
onTradeButtonClick?: ((buttonId: number, complement?: number) => void) | undefined
onConfirmQuestRewardClick?: ((itemId: number, npcId?: number) => void) | undefined
mouseX = 400
mouseY = 300
@ -386,6 +452,7 @@ export class HudManager {
private itemRects: Record<string, { x: number; y: number; w: number; h: number }> = {}
private decodedDc6Count = 415
private dc6DecodeFailures = 0
private eventsBound = false
readonly state: PublishedHudState = {
ready: false,
@ -501,6 +568,10 @@ export class HudManager {
onPlayUiSound?: ((soundId: number) => void) | undefined
private readonly hudCanvas: HTMLCanvasElement
get canvas(): HTMLCanvasElement {
return this.hudCanvas
}
private readonly callbacks: {
onToggleAutomap: () => void
onWaypointTeleport: (act: number, slug: string, levelId?: number) => void
@ -579,6 +650,7 @@ export class HudManager {
if (this.hudCanvas && this.hudCanvas.style) {
this.hudCanvas.style.cursor = 'none'
}
this.bindEvents()
}
/** Server authority: only requests `AddStatPoint`; attributes/vitals change when the server answers. */
@ -597,8 +669,8 @@ export class HudManager {
return false
}
useBeltSlot(col: number): boolean {
return this.drinkBeltSlot(col)
useBeltSlot(col: number, shiftToMerc = false): boolean {
return this.drinkBeltSlot(col, shiftToMerc)
}
dropInventoryGold(amount: number, _mousePos?: { x: number; y: number }): number {
@ -735,6 +807,10 @@ export class HudManager {
Object.entries(imageFiles).map(
([key, src]) =>
new Promise<void>((resolve) => {
if (typeof Image === 'undefined') {
resolve()
return
}
const img = new Image()
img.onload = () => {
this.images.set(key, img)
@ -803,7 +879,7 @@ export class HudManager {
}
/** Leaving the vendor / stash panel tells the server (`0x30` / `0x4F 0x12`); other panels are local. */
private leaveLeftPanel(prev: LeftDockPanelKind): void {
private leaveLeftPanel(prev: LeftPanelType): void {
if (prev === 'vendor') {
const npcId = this.worldPanels.vendorNpcId
this.worldPanels.clearVendorShop()
@ -811,6 +887,18 @@ export class HudManager {
} else if (prev === 'stash') {
if (this.goldDialog && this.goldDialog.mode !== 0) this.goldDialog = null
this._commandSink?.closeStash()
} else if (prev === 'hire' || prev === 'imbue' || prev === 'add_sockets' || prev === 'personalize') {
if (this._commandSink?.closeActiveNpcPanel) {
this._commandSink.closeActiveNpcPanel()
} else {
;(this.worldPanels as any).onCloseNpc?.()
}
} else if (prev === 'trade' && this.trade?.active) {
if (this._commandSink?.clickButton) {
this._commandSink.clickButton(2, 0)
} else {
;(this.worldPanels as any).onTradeAction?.(2)
}
}
}
@ -920,13 +1008,14 @@ export class HudManager {
}
/**
* Toggle a Left-Half Panel (`char`, `quest`, `waypoint`, `stash`, `cube`, `vendor`).
* Toggle a Left-Half Panel (`char`, `quest`, `waypoint`, `stash`, `cube`, `vendor`, `merc`, `hire`, `party`, `trade`, `imbue`, `add_sockets`, `personalize`).
* Strictly follows D2Client.dll mutual exclusion on the Left Dock (`0..400`).
*/
toggleLeftPanel(kind: Exclude<LeftDockPanelKind, 'none'>): void {
toggleLeftPanel(kind: Exclude<LeftPanelType, 'none'>): void {
const prev = this.leftPanel
if (prev === kind) {
this.leftPanel = 'none'
;(this.worldPanels as any).activePanel = 'none'
this.charSheet.visible = false
this.worldPanels.vendorTradeState = 'idle'
this.worldPanels.releaseMomentaryVendorButton()
@ -936,9 +1025,18 @@ export class HudManager {
throw new Error('Cannot open vendor panel without an active vendor session')
}
this.leftPanel = kind
;(this.worldPanels as any).activePanel = kind === 'char' ? 'none' : kind
if (prev !== 'none') this.leaveLeftPanel(prev)
this.charSheet.visible = kind === 'char'
if ((kind === 'stash' || kind === 'vendor') && this.rightPanel === 'none') {
if (
(kind === 'stash' ||
kind === 'vendor' ||
kind === 'trade' ||
kind === 'imbue' ||
kind === 'add_sockets' ||
kind === 'personalize') &&
this.rightPanel === 'none'
) {
this.toggleRightPanel('inv')
}
}
@ -1047,9 +1145,11 @@ export class HudManager {
this.worldPanels.npcMenu !== null ||
this.worldPanels.escapeMenuOpen ||
this.goldDialog !== null ||
this.inventory.isIdentifyMode()
this.inventory.isIdentifyMode() ||
this.pendingIdentifyScrollId !== null
const prevLeft = this.leftPanel
this.goldDialog = null
this.pendingIdentifyScrollId = null
this.dismissNpcMenu()
this.leftPanel = 'none'
if (prevLeft !== 'none') this.leaveLeftPanel(prevLeft)
@ -1124,7 +1224,14 @@ export class HudManager {
* Central vision corridor (e.g. `400 < logicalX < 624` in `1024x768`) remains open and returns false.
*/
isPointInterceptedByHud(logicalX: number, logicalY: number): boolean {
if (this.goldDialog || this.worldPanels.escapeMenuOpen) return true
if (
this.isDead ||
Boolean((this.worldPanels as any).isDead) ||
this.goldDialog ||
this.worldPanels.escapeMenuOpen
) {
return true
}
const profile = this.viewportProfile
const docking = this.getDockingLayout()
const { marginW } = docking
@ -1165,17 +1272,42 @@ export class HudManager {
return false
}
drinkBeltSlot(col: number): boolean {
drinkBeltSlot(col: number, shiftToMerc = false): boolean {
// Server authority: request `0x26 BeltUse`; healing and belt contents come back from the server.
if (this._commandSink) return this._commandSink.useBeltSlot(col)
if (this._commandSink) {
if (this._commandSink.onUseBeltSlot) {
return this._commandSink.onUseBeltSlot(col, shiftToMerc) !== false
}
return this._commandSink.useBeltSlot(col, shiftToMerc)
}
return false
}
private bindEvents(): void {
window.addEventListener('keydown', (e) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLSelectElement) return
private triggerResurrect(): void {
const sink = this._commandSink
if (sink?.onResurrect) {
sink.onResurrect()
} else if (sink?.resurrect) {
sink.resurrect()
} else {
;(this.worldPanels as any).onResurrect?.()
}
}
handleKeyDown(e: KeyboardEvent): void {
if (
typeof HTMLInputElement !== 'undefined' &&
(e.target instanceof HTMLInputElement || e.target instanceof HTMLSelectElement)
) {
return
}
if ((this.isDead || Boolean((this.worldPanels as any).isDead)) && e.key === 'Escape') {
e.preventDefault?.()
this.triggerResurrect()
return
}
if (this.goldDialog) {
e.preventDefault()
e.preventDefault?.()
if (e.key === 'Enter') this.confirmGoldDialog()
else if (e.key === 'Escape') this.cancelGoldDialog()
else this.typeGoldDialogKey(e.key)
@ -1184,16 +1316,16 @@ export class HudManager {
}
if (this.worldPanels.escapeMenuOpen) {
if (e.key === 'Escape') {
e.preventDefault()
e.preventDefault?.()
this.toggleEscapeMenu()
} else if (e.key === 'ArrowUp') {
e.preventDefault()
e.preventDefault?.()
this.moveEscapeMenuSelection(-1)
} else if (e.key === 'ArrowDown') {
e.preventDefault()
e.preventDefault?.()
this.moveEscapeMenuSelection(1)
} else if (e.key === 'Enter') {
e.preventDefault()
e.preventDefault?.()
this.activateEscapeMenuIndex()
}
return
@ -1201,32 +1333,40 @@ export class HudManager {
const key = e.key.toUpperCase()
if (key === 'I' || key === 'B') {
e.preventDefault()
e.preventDefault?.()
this.toggleRightPanel('inv')
} else if (key === 'A' || key === 'C') {
e.preventDefault()
e.preventDefault?.()
this.toggleLeftPanel('char')
} else if (key === 'O') {
e.preventDefault?.()
if (this.merc && !this.merc.isDead) {
this.toggleLeftPanel('merc')
}
} else if (key === 'P') {
e.preventDefault?.()
this.toggleLeftPanel('party')
} else if (key === 'T' || key === 'S') {
e.preventDefault()
e.preventDefault?.()
this.toggleRightPanel('skill')
} else if (key === 'Q') {
e.preventDefault()
e.preventDefault?.()
this.toggleLeftPanel('quest')
} else if (key === 'V') {
e.preventDefault()
e.preventDefault?.()
if (this.callbacks.onToggleAutomapMode) {
this.callbacks.onToggleAutomapMode()
} else {
this.toggleLeftPanel('waypoint')
}
} else if (key === 'F9') {
e.preventDefault()
e.preventDefault?.()
this.callbacks.onCycleAutomapFade?.()
} else if (key === 'F10') {
e.preventDefault()
e.preventDefault?.()
this.callbacks.onToggleAutomapNames?.()
} else if (e.key === 'Home') {
e.preventDefault()
e.preventDefault?.()
this.callbacks.onResetAutomapPan?.()
} else if (
e.key === 'ArrowUp' ||
@ -1237,41 +1377,49 @@ export class HudManager {
const dx = e.key === 'ArrowLeft' ? -8 : e.key === 'ArrowRight' ? 8 : 0
const dy = e.key === 'ArrowUp' ? -8 : e.key === 'ArrowDown' ? 8 : 0
if (this.callbacks.onPanAutomap?.(dx, dy)) {
e.preventDefault()
e.preventDefault?.()
}
} else if (key === 'W') {
e.preventDefault()
e.preventDefault?.()
if (this._commandSink) {
this._commandSink.swapWeapons()
} else if (this.onWeaponSwitch) {
this.onWeaponSwitch()
} else {
this.inventory.toggleWeaponSwap()
}
this.syncPublishedState()
} else if (key === 'R') {
e.preventDefault()
e.preventDefault?.()
this.isRunning = !this.isRunning
this.syncPublishedState()
} else if (e.key === '`' || e.key === '~') {
e.preventDefault()
e.preventDefault?.()
this.belt.togglePopup()
this.syncPublishedState()
} else if (key === '1' || key === '2' || key === '3' || key === '4') {
e.preventDefault()
this.drinkBeltSlot(Number(key) - 1)
e.preventDefault?.()
this.drinkBeltSlot(Number(key) - 1, Boolean(e.shiftKey))
} else if (/^F[1-8]$/.test(key)) {
e.preventDefault()
e.preventDefault?.()
this.hotkeys.triggerFunctionKey(key)
this.syncPublishedState()
} else if (e.key === 'Alt') {
e.preventDefault()
e.preventDefault?.()
this.showGroundLabels = true
this.syncPublishedState()
} else if (e.key === 'Escape' || e.code === 'Space' || e.key === ' ') {
if (this.closeAllPanels()) {
e.preventDefault()
e.preventDefault?.()
}
}
})
}
private bindEvents(): void {
if (this.eventsBound || typeof window === 'undefined') return
this.eventsBound = true
window.addEventListener('keydown', (e) => this.handleKeyDown(e))
window.addEventListener('keyup', (e) => {
if (e.key === 'Alt') {
@ -1341,13 +1489,18 @@ export class HudManager {
this.isPointInterceptedByHud(this.mouseX, this.mouseY) ||
this.inventory.cursorItem !== null ||
this.worldPanels.vendorTradeState !== 'idle' ||
this.inventory.isIdentifyMode()
this.inventory.isIdentifyMode() ||
this.pendingIdentifyScrollId !== null
this.hudCanvas.style.pointerEvents = intercept ? 'auto' : 'none'
})
window.addEventListener('mousedown', (e) => {
this.cursor.handleMouseDown()
if (e.target !== this.hudCanvas && e.target instanceof HTMLCanvasElement) {
if (
e.target !== this.hudCanvas &&
typeof HTMLCanvasElement !== 'undefined' &&
e.target instanceof HTMLCanvasElement
) {
this.handleHudMouseDown(e)
}
})
@ -1367,6 +1520,12 @@ export class HudManager {
handleHudMouseDown(e: MouseEvent): void {
if (e.button !== 0 && e.button !== 2) return
const pt = this.clientToLogical(e.clientX, e.clientY)
if (this.isDead || Boolean((this.worldPanels as any).isDead)) {
e.preventDefault?.()
e.stopPropagation?.()
this.triggerResurrect()
return
}
if (this.goldDialog) {
e.preventDefault()
e.stopPropagation()
@ -1383,9 +1542,10 @@ export class HudManager {
this.dismissNpcMenu()
}
if (!this.isPointInterceptedByHud(pt.x, pt.y)) {
if (this.inventory.isIdentifyMode()) {
if (this.inventory.isIdentifyMode() || this.pendingIdentifyScrollId !== null) {
e.preventDefault()
e.stopPropagation()
this.pendingIdentifyScrollId = null
this.inventory.cancelIdentifyMode()
this.syncPublishedState()
return
@ -1445,10 +1605,42 @@ export class HudManager {
return
}
// Right-click on belt slot drinks potion
// Belt slot click: right-click drinks potion; left-click places/swaps/removes belt item in online mode
const beltHit = this.belt.slotAtPoint(pt.x, pt.y)
if (beltHit) {
this.drinkBeltSlot(beltHit.col)
if (e.button === 2) {
this.drinkBeltSlot(beltHit.col, Boolean(e.shiftKey))
return
}
if (this._commandSink) {
const sink = this._commandSink
const slotIndex = beltHit.row * 4 + beltHit.col
const cursor = this.inventory.cursorItem
const beltPotion = this.belt.getSlot(beltHit.row, beltHit.col)
const rawBeltId =
(beltPotion as { itemId?: number } | null)?.itemId ??
(beltPotion ? Number(beltPotion.id) : NaN)
const beltItemId = Number.isInteger(rawBeltId) && rawBeltId >= 0 ? rawBeltId : undefined
if (cursor) {
const cursorId = serverItemIdOf(cursor)
if (beltPotion && beltItemId !== undefined) {
if (sink.onSwapBeltItem) sink.onSwapBeltItem(cursorId, beltItemId)
else sink.swapBeltItem?.(cursorId, beltItemId)
} else {
if (sink.onPutBeltItem) sink.onPutBeltItem(cursorId, slotIndex)
else sink.putBeltItem?.(cursorId, slotIndex)
}
this.syncPublishedState()
return
}
if (beltPotion && beltItemId !== undefined && (sink.onRemoveBeltItem || sink.removeBeltItem)) {
if (sink.onRemoveBeltItem) sink.onRemoveBeltItem(beltItemId)
else sink.removeBeltItem?.(beltItemId)
this.syncPublishedState()
return
}
}
this.drinkBeltSlot(beltHit.col, Boolean(e.shiftKey))
return
}
@ -1498,7 +1690,88 @@ export class HudManager {
return
} else if (this.leftPanel !== 'none') {
const dockKind = this.leftPanel
this.worldPanels.handleLeftDockClick(dockKind, leftX, pt.y, {
if (typeof (this.worldPanels as any).handleActiveLeftPanelClick === 'function') {
const handled = (this.worldPanels as any).handleActiveLeftPanelClick(
dockKind,
leftX,
pt.y,
e.button === 2,
e.shiftKey,
{
onClose: () => {
this.leftPanel = 'none'
;(this.worldPanels as any).activePanel = 'none'
this.leaveLeftPanel(dockKind)
},
onMercItem: (slot: number) => {
if (typeof (this.worldPanels as any).onMercEquipSlotClick === 'function') {
;(this.worldPanels as any).onMercEquipSlotClick(slot)
} else {
this._commandSink?.mercItem?.(slot)
}
},
onHireMerc: (npcId: number, mercId: number) => {
if (typeof (this.worldPanels as any).onHireMercClick === 'function') {
;(this.worldPanels as any).onHireMercClick(npcId, mercId)
} else {
this._commandSink?.hireMerc?.(npcId, mercId)
}
},
onPartyAction: (action: number, targetPlayerId: number) => {
if (typeof (this.worldPanels as any).onPartyActionClick === 'function') {
;(this.worldPanels as any).onPartyActionClick(action, targetPlayerId)
} else {
this._commandSink?.partyAction?.(action, targetPlayerId)
}
},
onSetHostile: (targetPlayerId: number, hostile: boolean, button = 1) => {
if (typeof (this.worldPanels as any).onHostileToggleClick === 'function') {
;(this.worldPanels as any).onHostileToggleClick(targetPlayerId, hostile, button)
} else {
this._commandSink?.setHostile?.(targetPlayerId, hostile, button)
}
},
onClickButton: (buttonId: number, complement = 0) => {
if (typeof (this.worldPanels as any).onTradeButtonClick === 'function') {
;(this.worldPanels as any).onTradeButtonClick(buttonId, complement)
} else {
this._commandSink?.clickButton?.(buttonId, complement)
}
},
onTradeGridClick: (
col: number,
row: number,
hitItem: UiInventoryItem | null,
isRightClick: boolean,
) => {
if (typeof (this.worldPanels as any).onTradeGridClick === 'function') {
;(this.worldPanels as any).onTradeGridClick(col, row, hitItem, isRightClick)
} else {
this.forwardGridClick(2, col, row, hitItem ?? null, isRightClick)
}
},
onConfirmQuestReward: (itemId: number, npcId = 0) => {
if (typeof (this.worldPanels as any).onConfirmQuestRewardClick === 'function') {
;(this.worldPanels as any).onConfirmQuestRewardClick(itemId, npcId)
} else {
;(
this._commandSink?.confirmQuestReward as
| ((itemId: number, npcId?: number) => void)
| undefined
)?.(itemId, npcId)
}
this.leftPanel = 'none'
this.leaveLeftPanel(dockKind)
},
inventory: this.inventory,
},
)
if (handled) {
this.syncPublishedState()
return
}
}
this.worldPanels.handleLeftDockClick(dockKind as any, leftX, pt.y, {
onClose: () => {
this.leftPanel = 'none'
this.leaveLeftPanel(dockKind)
@ -1585,7 +1858,12 @@ export class HudManager {
}
}
if (this.leftPanel === 'vendor' && !this.inventory.isIdentifyMode() && this.handleVendorInventoryClick(rightX, pt.y, e.button === 2)) {
if (
this.leftPanel === 'vendor' &&
!this.inventory.isIdentifyMode() &&
this.pendingIdentifyScrollId === null &&
this.handleVendorInventoryClick(rightX, pt.y, e.button === 2)
) {
this.syncPublishedState()
return
}
@ -1669,6 +1947,19 @@ export class HudManager {
private forwardInventoryPanelClick(x: number, y: number, isRightClick: boolean): boolean {
const sink = this._commandSink
if (!sink) return false
const swapTabs = [
{ ...WEAPON_SWAP_TABS_BOUNDS.left, set: 0 as const },
{ ...WEAPON_SWAP_TABS_BOUNDS.right, set: 1 as const },
]
for (const tab of swapTabs) {
if (x >= tab.x && x <= tab.x + tab.w && y >= tab.y && y <= tab.y + tab.h) {
if (tab.set !== this.inventory.weaponSwapSet) {
sink.swapWeapons()
this.onWeaponSwitch?.()
}
return true
}
}
const g = INV_GRID_ORIGIN
if (x >= g.x && x < g.x + g.cols * g.cellPx && y >= g.y && y < g.y + g.rows * g.cellPx) {
const col = Math.floor((x - g.x) / g.cellPx)
@ -1682,10 +1973,25 @@ export class HudManager {
for (const slot of Object.keys(EQUIP_SLOTS_LAYOUT) as EquipSlotId[]) {
const r = EQUIP_SLOTS_LAYOUT[slot]
if (x < r.x || x >= r.x + r.w || y < r.y || y >= r.y + r.h) continue
if (isRightClick) {
if (this.pendingIdentifyScrollId !== null) {
this.pendingIdentifyScrollId = null
this.inventory.cancelIdentifyMode()
}
return true
}
const cursor = this.inventory.cursorItem
const equipped = this.inventory.equipped[slot]
if (cursor) {
sink.equipItem(serverItemIdOf(cursor), slot)
} else if (this.inventory.equipped[slot]) {
const cursorId = serverItemIdOf(cursor)
if (equipped) {
if (sink.onSwapEquipItem) sink.onSwapEquipItem(cursorId, slot)
else if (sink.swapEquipItem) sink.swapEquipItem(cursorId, slot)
else sink.equipItem(cursorId, slot)
} else {
sink.equipItem(cursorId, slot)
}
} else if (equipped) {
sink.unequipItem(slot)
}
return true
@ -1693,7 +1999,7 @@ export class HudManager {
return false
}
/** Forward a container grid click: place the cursor item, use (right-click) or pick up the hit item. */
/** Forward a container grid click: place/swap/socket/tome the cursor item, identify, use (right-click) or pick up the hit item. */
private forwardGridClick(
buffer: number,
col: number,
@ -1703,11 +2009,55 @@ export class HudManager {
): void {
const sink = this._commandSink
if (!sink) return
if (this.pendingIdentifyScrollId !== null) {
const scrollId = this.pendingIdentifyScrollId
this.pendingIdentifyScrollId = null
this.inventory.cancelIdentifyMode()
if (!isRightClick && hitItem && isUiItemUnidentified(hitItem)) {
const targetId = serverItemIdOf(hitItem)
if (sink.onIdentifyItem) sink.onIdentifyItem(targetId, scrollId)
else sink.identifyItem?.(targetId, scrollId)
}
return
}
const cursor = this.inventory.cursorItem
if (cursor) {
sink.placeCursorItem(serverItemIdOf(cursor), col, row, buffer)
const cursorId = serverItemIdOf(cursor)
if (hitItem) {
const targetId = serverItemIdOf(hitItem)
if (isScrollForTome(cursor.code, hitItem.code)) {
if (sink.onScrollToBook) sink.onScrollToBook(cursorId, targetId)
else sink.scrollToBook?.(cursorId, targetId)
return
}
if (isSocketableUiItem(cursor) && (hitItem.sockets ?? 0) > (hitItem.socketedItems?.length ?? 0)) {
if (sink.onSocketItem) sink.onSocketItem(cursorId, targetId)
else sink.socketItem?.(cursorId, targetId)
return
}
if (sink.onSwapContainerItem) {
sink.onSwapContainerItem(cursorId, targetId, col, row, buffer)
} else if (sink.swapContainerItem) {
sink.swapContainerItem(cursorId, targetId, col, row, buffer)
} else {
sink.placeCursorItem(cursorId, col, row, buffer)
}
return
}
sink.placeCursorItem(cursorId, col, row, buffer)
} else if (hitItem && isRightClick) {
if (isUsableRightClickItem(hitItem.code)) sink.useItem(serverItemIdOf(hitItem))
if (buffer === 0 && (hitItem.code === 'isc' || hitItem.code === 'ibk')) {
this.pendingIdentifyScrollId = serverItemIdOf(hitItem)
this.inventory.startIdentifyMode(hitItem, 'inventory')
return
}
if (isUsableRightClickItem(hitItem.code)) {
const itemId = serverItemIdOf(hitItem)
if (sink.onUseInventoryItem) sink.onUseInventoryItem(itemId)
else sink.useItem(itemId)
}
} else if (hitItem) {
sink.pickupItemToCursor(serverItemIdOf(hitItem))
}
@ -1758,7 +2108,8 @@ export class HudManager {
* (`inventory.isIdentifyMode()` or `worldPanels.vendorTradeState`).
*/
syncCursorMode(nowMs: number = this.cursor.lastNowMs): void {
const tradeMode: BuySellCursorMode | null = this.inventory.isIdentifyMode()
const tradeMode: BuySellCursorMode | null =
this.inventory.isIdentifyMode() || this.pendingIdentifyScrollId !== null
? 'identify'
: this.leftPanel === 'vendor' && this.worldPanels.vendorTradeState !== 'idle'
? this.worldPanels.vendorTradeState
@ -1839,6 +2190,52 @@ export class HudManager {
this.state.escapeMenuSelectedIndex = this.worldPanels.escapeMenuSelectedIndex
}
private ensureCanvas2DContext(rawCtx: CanvasRenderingContext2D): CanvasRenderingContext2D {
const noop = () => {}
const methods = [
'save',
'restore',
'clearRect',
'fillRect',
'strokeRect',
'fillText',
'strokeText',
'scale',
'translate',
'rotate',
'transform',
'setTransform',
'resetTransform',
'beginPath',
'closePath',
'moveTo',
'lineTo',
'rect',
'arc',
'fill',
'stroke',
'clip',
'drawImage',
'setLineDash',
] as const
const target = rawCtx as unknown as Record<string, unknown>
for (const m of methods) {
if (typeof target[m] !== 'function') {
target[m] = noop
}
}
if (typeof target.measureText !== 'function') {
target.measureText = (text: string) => ({ width: String(text ?? '').length * 8 })
}
if (typeof target.createLinearGradient !== 'function') {
target.createLinearGradient = () => ({ addColorStop: noop })
}
if (typeof target.createRadialGradient !== 'function') {
target.createRadialGradient = () => ({ addColorStop: noop })
}
return rawCtx
}
/**
* Tick stamina drain/recovery and draw the complete Diablo II HUD (`800x600` or `1024x768`).
*/
@ -1851,9 +2248,12 @@ export class HudManager {
void playerMoving
const profile = this.viewportProfile
const dpr = Math.min(window.devicePixelRatio || 1, 2)
const cssW = Math.max(320, this.hudCanvas.clientWidth || window.innerWidth)
const cssH = Math.max(240, this.hudCanvas.clientHeight || window.innerHeight)
const hasWindow = typeof window !== 'undefined'
const dpr = Math.min(hasWindow && window.devicePixelRatio ? window.devicePixelRatio : 1, 2)
const winW = hasWindow && window.innerWidth ? window.innerWidth : profile.width
const winH = hasWindow && window.innerHeight ? window.innerHeight : profile.height
const cssW = Math.max(320, this.hudCanvas.clientWidth || winW)
const cssH = Math.max(240, this.hudCanvas.clientHeight || winH)
const targetW = Math.round(cssW * dpr)
const targetH = Math.round(cssH * dpr)
if (this.hudCanvas.width !== targetW || this.hudCanvas.height !== targetH) {
@ -1874,11 +2274,15 @@ export class HudManager {
this.isPointInterceptedByHud(this.mouseX, this.mouseY) ||
this.inventory.cursorItem !== null ||
this.worldPanels.vendorTradeState !== 'idle' ||
this.inventory.isIdentifyMode()
this.inventory.isIdentifyMode() ||
this.pendingIdentifyScrollId !== null
if (this.hudCanvas.style) {
this.hudCanvas.style.pointerEvents = intercept ? 'auto' : 'none'
}
const ctx = this.hudCanvas.getContext('2d')
if (!ctx) return
const rawCtx = this.ctx ?? this.hudCanvas.getContext('2d')
if (!rawCtx) return
const ctx = this.ensureCanvas2DContext(rawCtx)
ctx.imageSmoothingEnabled = false
ctx.save()
ctx.imageSmoothingEnabled = false
@ -1894,6 +2298,53 @@ export class HudManager {
ctx.translate(Math.round(offsetX * dpr), Math.round(offsetY * dpr))
ctx.scale(uiScale * dpr, uiScale * dpr)
// 0b. Top-Left Mercenary Portrait & Life Bar (R3.3) + Party Mini-Portraits (R5.1)
if (this.leftPanel === 'none') {
if (this.merc && !this.merc.isDead) {
const hpRatio = Math.max(0, Math.min(1, Number(this.merc.hp ?? 0) / Math.max(1, Number(this.merc.maxHp ?? 1))))
ctx.fillStyle = 'rgba(16, 16, 20, 0.85)'
ctx.fillRect(8, 8, 48, 48)
ctx.strokeStyle = '#8b7344'
ctx.strokeRect(8.5, 8.5, 47, 47)
ctx.fillStyle = '#181818'
ctx.fillRect(8, 58, 48, 6)
ctx.fillStyle = hpRatio > 0.5 ? '#20a020' : hpRatio > 0.25 ? '#d0a020' : '#c02020'
ctx.fillRect(8, 58, Math.round(48 * hpRatio), 6)
const mercLabel = String(this.merc.name || 'Merc')
ctx.fillStyle = '#d4b468'
ctx.fillText(mercLabel, 32, 22)
this.font.drawText(ctx, mercLabel, 32, 22, {
font: 'font6',
color: 'gold',
align: 'center',
})
}
if (this.partyMembers.length > 0) {
for (let idx = 0; idx < this.partyMembers.length; idx++) {
const member = this.partyMembers[idx]
if (!member) continue
const py = 72 + idx * 52
const lifeRatio = Math.max(0, Math.min(1, Number(member.lifePct ?? 100) / 100))
ctx.fillStyle = 'rgba(16, 16, 20, 0.82)'
ctx.fillRect(8, py, 44, 44)
ctx.strokeStyle = member.hostile ? '#c02020' : '#7a6840'
ctx.strokeRect(8.5, py + 0.5, 43, 43)
ctx.fillStyle = '#181818'
ctx.fillRect(8, py + 45, 44, 5)
ctx.fillStyle = '#20a020'
ctx.fillRect(8, py + 45, Math.round(44 * lifeRatio), 5)
const memberName = String(member.name || `P${member.unitId ?? idx + 1}`)
ctx.fillStyle = '#ffffff'
ctx.fillText(memberName, 30, py + 18)
this.font.drawText(ctx, memberName, 30, py + 18, {
font: 'font6',
color: 'white',
align: 'center',
})
}
}
}
// 1. Left-Half Dock Panel (`0..400, panelOffsetY..panelOffsetY+553`)
if (this.leftPanel === 'char') {
ctx.save()
@ -1924,6 +2375,13 @@ export class HudManager {
} else if (this.leftPanel !== 'none') {
ctx.save()
ctx.translate(deltaLeft, panelOffsetY)
if (
this.leftPanel === 'quest' ||
this.leftPanel === 'waypoint' ||
this.leftPanel === 'stash' ||
this.leftPanel === 'cube' ||
this.leftPanel === 'vendor'
) {
this.worldPanels.drawLeftDockPanel(
ctx,
this.leftPanel,
@ -1946,6 +2404,32 @@ export class HudManager {
},
this.font,
)
} else if (typeof (this.worldPanels as any).renderActiveLeftPanel === 'function') {
;(this.worldPanels as any).renderActiveLeftPanel(ctx, this.leftPanel, this.font)
} else {
this.worldPanels.drawLeftDockPanel(
ctx,
this.leftPanel as any,
{
borderLeftImg: this.images.get('borderLeft') ?? null,
questBgImg: this.images.get('questBg') ?? null,
waypointBgImg: this.images.get('waypointBg') ?? null,
stashBgImg: this.images.get('stashBg') ?? null,
cubeBgImg: this.images.get('cubeBg') ?? null,
vendorBgImg: this.images.get('vendorBg') ?? null,
vendorTabsImg: this.images.get('vendorTabs') ?? null,
buySellBtnImg: this.images.get('buySellBtn') ?? null,
miniConvertImg: this.images.get('miniConvert') ?? null,
questsAtlasImg: this.images.get('questsAtlas') ?? null,
itemsAtlasImg: this.images.get('itemsAtlas') ?? null,
waypointTabsImg: this.images.get('waypointTabs') ?? null,
waypointIconsImg: this.images.get('waypointIcons') ?? null,
questTabsImg: this.images.get('questTabs') ?? null,
inventory: this.inventory,
},
this.font,
)
}
ctx.restore()
}
@ -2086,6 +2570,24 @@ export class HudManager {
})
}
// 10d. Death Overlay (`You Have Died. Press ESC to continue.` — R4.4)
if (this.isDead || Boolean((this.worldPanels as any).isDead)) {
ctx.save()
ctx.fillStyle = 'rgba(80, 0, 0, 0.45)'
ctx.fillRect(0, 0, 800, 600)
ctx.fillStyle = '#d4b468'
ctx.font = 'bold 22px serif'
ctx.textAlign = 'center'
const deathMsg = 'You Have Died. Press ESC to continue. 你挂了,请按 ESC 键继续'
ctx.fillText(deathMsg, 400, 300)
this.font.drawText(ctx, deathMsg, 400, 300, {
font: 'font16',
color: 'gold',
align: 'center',
})
ctx.restore()
}
// 11. Authentic Hand / Trade Cursor & Held Item at Hotspot
this.renderCursor(ctx, nowMs)

View File

@ -938,11 +938,15 @@ export function canSocketUiItem(
if (isUiItemUnidentified(socketable) || isUiItemUnidentified(target)) return false
const totalSockets =
target.sockets ??
(target as { totalSockets?: number }).totalSockets ??
target.rawItem?.totalSockets ??
(target.rawItem as { sockets?: number } | undefined)?.sockets ??
0
if (totalSockets <= 0) return false
const usedSockets = target.socketedItems?.length ?? target.rawItem?.socketedItems?.length ?? 0
const usedSockets = Math.max(
(target as { usedSockets?: number }).usedSockets ?? 0,
target.socketedItems?.length ?? target.rawItem?.socketedItems?.length ?? 0,
)
if (usedSockets >= totalSockets) return false
const category = resolveUiSocketTargetCategory(target)
return category !== null
@ -1517,8 +1521,14 @@ export class InventoryPanel {
visible = false
/** Active weapon swap set: 0 = Weapon Set I, 1 = Weapon Set II. */
weaponSwapSet: 0 | 1 = 0
get activeWeaponSlot(): 0 | 1 {
return this.weaponSwapSet
}
set activeWeaponSlot(val: 0 | 1) {
this.weaponSwapSet = val
}
/** Equipped gear slots (empty until the server sends `0x9C`/`0x9D` equip events). */
readonly equipped: Partial<Record<EquipSlotId, UiInventoryItem>> & {
readonly equipped: Partial<Record<EquipSlotId | 'head' | 'torso', UiInventoryItem>> & {
get: (slot: string) => UiInventoryItem | undefined
set: (slot: string, item: UiInventoryItem | null | undefined) => unknown
} = {} as any
@ -1529,6 +1539,12 @@ export class InventoryPanel {
}
/** 10x4 grid items (empty until the server sends inventory item events). */
gridItems: GridPlacement[] = []
/** Flat list of inventory items mirrored from `gridItems`. */
get inventory(): readonly UiInventoryItem[] {
return this.gridItems.map(g => g.item)
}
/** 16-slot belt mirror populated from server belt state. */
belt: (UiInventoryItem | null)[] = new Array(16).fill(null)
private _starterCleared = false
private _cursorItem: UiInventoryItem | null = null
/** Item currently held on the mouse cursor (`cursorItem`). */
@ -1581,6 +1597,24 @@ export class InventoryPanel {
enumerable: false,
configurable: true,
})
Object.defineProperty(this.equipped, 'head', {
get: () => this.equipped.helm,
set: (val: UiInventoryItem | null | undefined) => {
if (val == null) delete this.equipped.helm
else this.equipped.helm = val
},
enumerable: false,
configurable: true,
})
Object.defineProperty(this.equipped, 'torso', {
get: () => this.equipped.armor,
set: (val: UiInventoryItem | null | undefined) => {
if (val == null) delete this.equipped.armor
else this.equipped.armor = val
},
enumerable: false,
configurable: true,
})
}
private _clearStarterIfRawItem(item: UiInventoryItem | null | undefined): void {

File diff suppressed because it is too large Load Diff

View File

@ -17,6 +17,8 @@ import {
type RenderableUnitInput,
type SceneUnitDrawable,
} from './unit-drawables.ts'
export type UnitState = RenderableUnitInput
import {
getViewportProfile,
screenToWorld,
@ -449,14 +451,29 @@ export class ClientWorldSceneSource implements SceneSource {
let best: SceneUnitDrawable | null = null
let bestDistSq = hitRadiusPx * hitRadiusPx
const localPlayerX = st.playerSubX - shiftX
const localPlayerY = st.playerSubY - shiftY
for (const u of entities.unitDrawables) {
if (u.isCorpse && u.unitKind === 'monster') continue
// Sprite visual center is roughly 24px above foot anchor
if (
u.unitKind === 'player' &&
entities.unitDrawables.length > 1 &&
Math.abs(u.subX - localPlayerX) < 1e-3 &&
Math.abs(u.subY - localPlayerY) < 1e-3
) {
continue
}
// Sprite visual center is roughly 24px above foot anchor; warp/tile/item also test foot anchor directly
const dx = clickWorld.x - u.worldX
const dy = clickWorld.y - (u.worldY - 24)
const distSq = dx * dx + dy * dy
if (distSq <= bestDistSq) {
const dyBody = clickWorld.y - (u.worldY - 24)
const distSqBody = dx * dx + dyBody * dyBody
const isGroundAnchored =
u.unitKind === 'warp' || u.unitKind === 'tile' || u.unitKind === 'item'
const dyFoot = clickWorld.y - u.worldY
const distSqFoot = dx * dx + dyFoot * dyFoot
const distSq = isGroundAnchored ? Math.min(distSqBody, distSqFoot) : distSqBody
if (distSq <= hitRadiusPx * hitRadiusPx && (best === null || distSq < bestDistSq)) {
bestDistSq = distSq
best = u
}

View File

@ -2,11 +2,13 @@ import { CANONICAL_113C_MISSILES, getMissileTxtData } from '../../common/skills/
import { BAKED_UI_MANIFEST } from '../../common/items/baked-ui-meta.ts'
import type { UnitKind, UnitSnapshot } from '../../netproto/index.ts'
import type { PointLightInput } from '../scene/lighting-presets.ts'
import type { UnitCastState } from '../world/client-unit.ts'
import { subTileToWorldOrtho } from './viewport-profile.ts'
type MissileTxtEntry = NonNullable<ReturnType<typeof getMissileTxtData>>
export type DrawableBlendMode = 'normal' | 'additive'
export type SceneUnitKind = UnitKind | 'warp'
export interface UnitOverlayState {
readonly overlayId: number
@ -22,7 +24,7 @@ export interface UnitOverlayState {
* and `ClientUnit` (from `src/client/world/unit-table.ts`).
*/
export interface RenderableUnitInput {
readonly kind: UnitKind
readonly kind: SceneUnitKind
readonly id: number
readonly classId: number
readonly areaId?: number | undefined
@ -52,11 +54,24 @@ export interface RenderableUnitInput {
readonly isChampion?: boolean | undefined
readonly isUnique?: boolean | undefined
readonly isSuperUnique?: boolean | undefined
readonly warpId?: number | undefined
readonly toLevelId?: number | undefined
readonly radiusX?: number | undefined
readonly radiusY?: number | undefined
readonly itemCode?: string | undefined
readonly castState?: UnitCastState | undefined
readonly animMode?: string | undefined
readonly portalOwnerName?: string | undefined
readonly portalAreaId?: number | undefined
readonly isPartyMember?: boolean | undefined
}
export type RenderableUnit = RenderableUnitInput
export interface SceneUnitDrawable {
readonly drawableKind: 'unit'
readonly unitKind: UnitKind
readonly unitKind: SceneUnitKind
readonly id: number
readonly unitId: number
readonly classId: number
readonly token: string
@ -79,6 +94,16 @@ export interface SceneUnitDrawable {
readonly tintColor: readonly [number, number, number] | null
readonly compositeArmor: Readonly<Record<string, string>> | undefined
readonly overlays: readonly SceneOverlayDrawable[]
readonly warpId?: number | undefined
readonly toLevelId?: number | undefined
readonly radiusX?: number | undefined
readonly radiusY?: number | undefined
readonly itemCode?: string | undefined
readonly castState?: UnitCastState | undefined
readonly animMode?: string | undefined
readonly portalOwnerName?: string | undefined
readonly portalAreaId?: number | undefined
readonly isPartyMember?: boolean | undefined
}
export interface SceneMissileDrawable {
@ -180,14 +205,20 @@ export function resolveUnitToken(unit: RenderableUnitInput): string {
// Unknown missile ids keep an explicit unresolved token (like OBJ_/MON_) instead of borrowing another missile.
return key ? getMissileTxtData(key).celFile : `MIS_${unit.classId}`
}
if (unit.kind === 'tile' || unit.kind === 'warp') {
return `WRP_${unit.warpId ?? unit.classId}`
}
if (unit.kind === 'object') {
return `OBJ_${unit.classId}`
}
if (unit.kind === 'item') {
const code = unit.code || unit.name
const code = unit.code || unit.itemCode || unit.name
if (code && /^[a-z0-9]{3,4}$/i.test(code.trim())) {
return code.trim().toLowerCase()
}
if (unit.itemCode && unit.itemCode.trim() !== '') {
return unit.itemCode.trim().toLowerCase()
}
const mapped = (BAKED_UI_MANIFEST.itemClassIdToCode as Record<number, string | undefined> | undefined)?.[unit.classId]
if (mapped) return mapped
return `ITM_${unit.classId}`
@ -246,12 +277,16 @@ export interface BuildSceneDrawablesResult {
*/
export function buildSceneDrawables(
units: Iterable<RenderableUnitInput | UnitSnapshot>,
options?: {
optionsOrWidth?:
| {
readonly originX?: number | undefined
readonly originY?: number | undefined
readonly tick?: number | undefined
},
}
| number,
_height?: number,
): BuildSceneDrawablesResult {
const options = typeof optionsOrWidth === 'object' ? optionsOrWidth : undefined
const originX = options?.originX ?? 0
const originY = options?.originY ?? 0
const tick = options?.tick ?? 0
@ -262,7 +297,27 @@ export function buildSceneDrawables(
const pointLights: PointLightInput[] = []
for (const rawUnit of units) {
const unit = rawUnit as RenderableUnitInput
const rawAny = rawUnit as Record<string, any>
const resolvedKind: SceneUnitKind =
(rawUnit as RenderableUnitInput).kind ??
(rawAny.unitType === 0 || rawAny.unitType === 'player'
? 'player'
: rawAny.unitType === 1 || rawAny.unitType === 'monster'
? 'monster'
: rawAny.unitType === 2 || rawAny.unitType === 'object'
? 'object'
: rawAny.unitType === 3 || rawAny.unitType === 'missile'
? 'missile'
: rawAny.unitType === 4 || rawAny.unitType === 'item'
? 'item'
: rawAny.unitType === 5 || rawAny.unitType === 'tile'
? 'tile'
: 'monster')
const unit: RenderableUnitInput =
(rawUnit as RenderableUnitInput).kind !== undefined
? (rawUnit as RenderableUnitInput)
: { ...(rawUnit as unknown as RenderableUnitInput), kind: resolvedKind }
const { x: worldX, y: worldY } = subTileToWorldOrtho(unit.x, unit.y, originX, originY)
if (unit.kind === 'missile') {
@ -400,26 +455,65 @@ export function buildSceneDrawables(
}
}
const isCorpse = Boolean(unit.isCorpse) || (unit.lifePct !== undefined && unit.lifePct <= 0 && (unit.kind === 'monster' || unit.kind === 'player'))
const isCorpse =
Boolean(unit.isCorpse) ||
(unit.lifePct !== undefined && unit.lifePct <= 0 && (unit.kind === 'monster' || unit.kind === 'player'))
const activeCastState = !isCorpse ? unit.castState : undefined
const effectiveTargetX = unit.targetX ?? activeCastState?.targetX
const effectiveTargetY = unit.targetY ?? activeCastState?.targetY
const effectiveMode = activeCastState
? activeCastState.mode || unit.mode || 10
: (unit.mode ?? 0)
const animMode =
unit.animMode ??
(!isCorpse && (effectiveMode === 7 || effectiveMode === 4)
? 'A1'
: !isCorpse && effectiveMode === 10
? 'SC'
: undefined)
if (activeCastState && effectiveMode === 10) {
pointLights.push({
subTileX: Math.round(unit.x),
subTileY: Math.round(unit.y),
radius: 10,
intensity: 210,
r: 255,
g: 195,
b: 110,
})
}
const dir = computeDirection8Or16(
unit.x,
unit.y,
unit.targetX,
unit.targetY,
effectiveTargetX,
effectiveTargetY,
unit.direction ?? 0,
8,
)
const isWarp = unit.kind === 'warp' || unit.kind === 'tile'
const warpId = isWarp ? (unit.warpId ?? unit.classId) : unit.warpId
const radiusX = isWarp ? (unit.radiusX ?? 28) : unit.radiusX
const radiusY = isWarp ? (unit.radiusY ?? 20) : unit.radiusY
const resolvedName = isWarp
? unit.name && unit.name.trim() !== ''
? unit.name
: `Entrance (${warpId})`
: unit.name
const unitDrawable: SceneUnitDrawable = {
drawableKind: 'unit',
unitKind: unit.kind,
id: unit.id,
unitId: unit.id,
classId: unit.classId,
token: resolveUnitToken(unit),
code: unit.code,
code: unit.code ?? unit.itemCode,
itemQuality: unit.itemQuality ?? unit.quality,
goldAmount: unit.goldAmount,
mode: unit.mode ?? 0,
mode: effectiveMode,
direction: dir,
frame: unit.frame ?? (unit.kind === 'item' ? 0 : (tick % 8)),
subX: unit.x,
@ -429,12 +523,28 @@ export function buildSceneDrawables(
sortY: isCorpse ? worldY - 16 : worldY,
blendMode: 'normal',
lifePct: unit.lifePct ?? 100,
name: unit.name,
name: resolvedName,
isHovered: Boolean(unit.isHovered),
isCorpse,
tintColor,
compositeArmor: unit.compositeArmor,
overlays: unitOverlays,
...(warpId !== undefined ? { warpId } : {}),
...(unit.toLevelId !== undefined ? { toLevelId: unit.toLevelId } : {}),
...(radiusX !== undefined ? { radiusX } : {}),
...(radiusY !== undefined ? { radiusY } : {}),
...(unit.goldAmount !== undefined ? { goldAmount: unit.goldAmount } : {}),
...(unit.itemCode !== undefined ? { itemCode: unit.itemCode } : {}),
...(unit.itemQuality !== undefined
? { itemQuality: unit.itemQuality }
: unit.quality !== undefined
? { itemQuality: unit.quality }
: {}),
...(activeCastState !== undefined ? { castState: activeCastState } : {}),
...(animMode !== undefined ? { animMode } : {}),
...(unit.portalOwnerName !== undefined ? { portalOwnerName: unit.portalOwnerName } : {}),
...(unit.portalAreaId !== undefined ? { portalAreaId: unit.portalAreaId } : {}),
...(unit.isPartyMember !== undefined ? { isPartyMember: unit.isPartyMember } : {}),
}
unitDrawables.push(unitDrawable)
}

View File

@ -7,6 +7,7 @@
import {
unitTypeFromId,
type ItemData,
type Pt,
type UnitRef,
type UnitSnapshot,
@ -16,6 +17,15 @@ import {
import { StatList } from './stat-list.ts'
import { UnitStateSet } from './states.ts'
export interface UnitCastState {
readonly skillId: number
readonly targetX: number
readonly targetY: number
readonly mode: number
readonly startedAtMs: number
readonly untilMs: number
}
export function makeUnitKey(unitType: UnitType, id: number): string {
return `${unitTypeFromId(unitType)}:${id}`
}
@ -50,6 +60,8 @@ export class ClientUnit {
ownerType?: UnitTypeName | undefined
ownerId?: number | undefined
overheadText?: string | undefined
itemData?: ItemData | undefined
castState?: UnitCastState | undefined
readonly stats = new StatList()
readonly states = new UnitStateSet()
@ -65,7 +77,7 @@ export class ClientUnit {
this.direction = snapshot.direction ?? 0
this.lifePct = snapshot.lifePct ?? 128
this.flags = snapshot.flags ?? 0
this.warpId = snapshot.warpId
this.warpId = snapshot.warpId ?? (this.unitType === 'tile' ? this.classId : undefined)
this.objectState = snapshot.objectState
this.interactType = snapshot.interactType
this.isChampion = snapshot.isChampion
@ -80,6 +92,26 @@ export class ClientUnit {
}
}
get kind(): UnitTypeName {
return this.unitType
}
get dir(): number {
return this.direction
}
set dir(v: number) {
this.direction = v
}
get quality(): number | undefined {
return this.itemQuality
}
set quality(v: number | undefined) {
this.itemQuality = v
}
updateFromSnapshot(snapshot: UnitSnapshot): void {
if (snapshot.classId >= 0) {
this.classId = snapshot.classId
@ -105,6 +137,8 @@ export class ClientUnit {
}
if (snapshot.warpId !== undefined) {
this.warpId = snapshot.warpId
} else if (this.unitType === 'tile' && this.warpId === undefined && this.classId >= 0) {
this.warpId = this.classId
}
if (snapshot.objectState !== undefined) {
this.objectState = snapshot.objectState

View File

@ -17,7 +17,8 @@ import {
type UnitRef,
type UnitType,
} from '../../netproto/index.ts'
import { ClientUnit, makeUnitKey } from './client-unit.ts'
import { onlineItemDataToUiInventoryItem } from '../../common/items/item-bridge.ts'
import { ClientUnit, makeUnitKey, type UnitCastState } from './client-unit.ts'
import { ClientInventoryManager, type ClientItemContainers } from './inventory.ts'
import { MutableSelfPlayerState, type SelfPlayerState } from './self.ts'
@ -30,23 +31,46 @@ export interface PartyMemberState {
relation?: number | undefined
lifePct?: number | undefined
areaId?: number | undefined
levelId?: number | undefined
at?: Pt | undefined
x?: number | undefined
y?: number | undefined
/** 0 = unpartied, 1 = in-party, 2 = invited-by-me, 3 = invited-me */
partyState?: number | undefined
hostile?: boolean | undefined
lootPermit?: boolean | undefined
}
export interface TradeSessionState {
active: boolean
lastAction: number
partnerId?: number | undefined
partnerName?: string | undefined
partnerGold: number
selfGold: number
selfAccepted: boolean
partnerAccepted: boolean
acceptDisabled: boolean
}
export interface MercHireEntry {
readonly nameId: number
readonly seed: number
}
export interface MercenaryState {
readonly mercId: number
readonly ownerId: number
readonly classId: number
readonly nameId?: number | undefined
mercId: number
ownerId: number
classId: number
nameId?: number | undefined
exp?: number | undefined
lifePct: number
isDead: boolean
reviveCost: number
deadNameId?: number | undefined
reviveNameId?: number | undefined
readonly stats: Map<number, number>
readonly hireList: readonly MercHireEntry[]
}
export interface ChatLogEntry {
@ -72,6 +96,15 @@ export interface PortalState {
readonly flags?: number | undefined
}
export type NpcInteractionWindow =
| 'none'
| 'trade'
| 'gamble'
| 'hire'
| 'imbue'
| 'add_sockets'
| 'personalize'
/**
* Local NPC/stash interaction state. D2Client tracks this itself: the server never
* reports "window closed" (no RemoveFromShop after 0x30, no reply to 0x4F 0x12;
@ -80,10 +113,16 @@ export interface PortalState {
export interface NpcInteractionState {
/** NPC unit id from the last `NpcInit` (0x2F); `null` after `NpcCancel` (0x30). */
readonly npcId: number | null
/** Window opened by 0x38 action 1 (`trade`) or 2 (`gamble`). */
/** Store window opened by 0x38 action 1 (`trade`) or 2 (`gamble`). */
readonly window: 'none' | 'trade' | 'gamble'
/** Extended window kind including `hire` and quest reward windows (`imbue`, `add_sockets`, `personalize`). */
readonly activeWindow?: NpcInteractionWindow | undefined
/** Set by S2C 0x77 action 0x10, cleared when the client sends 0x4F button 0x12. */
readonly stashOpen: boolean
/** Last raw payload from S2C 0x27 (`NPCInfo`). */
readonly npcInfoRaw?: Uint8Array | undefined
/** Last raw payload from S2C 0x91 (`NPCGetShow`). */
readonly npcTalkRaw?: Uint8Array | undefined
}
/** One S2C 0x2A NPCTransaction, numbered so the HUD can react to each reply once. */
@ -101,6 +140,11 @@ const BUTTON_ACTION_OPEN_STASH = 0x10
/** Largest 1.13c stat id: stat ids are 9 bits wide in the stat bitstream (ItemStatCost.txt). */
const MAX_D2_STAT_ID = 0x1ff
/** 1.13c physical/weapon attack skill IDs that use attack animation mode (`A1`) instead of spellcast (`SC`). */
const PHYSICAL_ATTACK_SKILL_IDS = new Set([
0, 2, 6, 7, 10, 19, 20, 24, 96, 97, 106, 107, 116, 126, 133, 139, 140, 144, 147, 151, 152,
])
export class ClientWorld {
act = 0
mapSeed = 0
@ -116,6 +160,7 @@ export class ClientWorld {
lastRttMs: number | null = null
private readonly _self = new MutableSelfPlayerState()
private _isSelfDead = false
private readonly _units = new Map<string, ClientUnit>()
private readonly _monsters = new Map<number, ClientUnit>()
private readonly _containers = new ClientInventoryManager()
@ -130,12 +175,21 @@ export class ClientWorld {
lastAction: 0,
partnerGold: 0,
selfGold: 0,
selfAccepted: false,
partnerAccepted: false,
acceptDisabled: false,
}
private _merc: MercenaryState | null = null
private readonly _mercHireList: MercHireEntry[] = []
private readonly _chatLog: ChatLogEntry[] = []
private readonly _soundQueue: SoundQueueEntry[] = []
private readonly _portals = new Map<number, PortalState>()
private _npcInteraction: NpcInteractionState = { npcId: null, window: 'none', stashOpen: false }
private _npcInteraction: NpcInteractionState = {
npcId: null,
window: 'none',
activeWindow: 'none',
stashOpen: false,
}
private _lastNpcTransaction: NpcTransactionRecord | null = null
private _npcTransactionSeq = 0
private readonly _changeListeners = new Set<(ev: ServerEvent) => void>()
@ -149,9 +203,49 @@ export class ClientWorld {
return this._lastNpcTransaction
}
get isDead(): boolean {
return this._isSelfDead || this._self.isDead
}
get isSelfDead(): boolean {
return this._isSelfDead || this._self.isDead
}
get mercHireList(): readonly MercHireEntry[] {
return this._mercHireList
}
private applySkillCastToUnit(unit: ClientUnit, skillId: number, targetX: number, targetY: number): void {
unit.targetX = targetX
unit.targetY = targetY
const dx = targetX - unit.x
const dy = targetY - unit.y
if (dx !== 0 || dy !== 0) {
const angle = Math.atan2(dy, dx)
const normalized = ((angle + Math.PI * 2) % (Math.PI * 2)) / (Math.PI * 2)
unit.direction = Math.floor(normalized * 8) % 8
}
const isAttack = PHYSICAL_ATTACK_SKILL_IDS.has(skillId)
const mode = unit.unitType === 'monster' ? (isAttack ? 4 : 7) : isAttack ? 7 : 10
unit.mode = mode
const now = Date.now()
const castState: UnitCastState = {
skillId,
targetX,
targetY,
mode,
startedAtMs: now,
untilMs: now + 400,
}
unit.castState = castState
if (unit.unitType === 'player' && (unit.id === this.selfId || this.selfId === 0)) {
this._self.castState = castState
}
}
/**
* Records the local side effects of an outgoing command, mirroring what D2Client
* does when it sends it: 0x38 opens the trade/gamble window and the store is
* does when it sends it: 0x38 opens the trade/gamble/hire window and the store is
* refilled by 0x9C AddToShop; 0x30 frees the store units (no RemoveFromShop is
* sent, capture 05); 0x4F 0x12 closes the stash (no reply, capture 08).
*/
@ -159,26 +253,96 @@ export class ClientWorld {
const cur = this._npcInteraction
switch (cmd.type) {
case 'NpcInit':
this._npcInteraction = { ...cur, npcId: cmd.target.id, window: 'none' }
this._npcInteraction = { ...cur, npcId: cmd.target.id, window: 'none', activeWindow: 'none' }
break
case 'NpcOpenTrade':
case 'NpcOpenGamble':
case 'NpcOpenGamble': {
const win = cmd.type === 'NpcOpenTrade' ? 'trade' : 'gamble'
this._containers.clearShop()
this._npcInteraction = {
...cur,
npcId: cmd.npcId,
window: cmd.type === 'NpcOpenTrade' ? 'trade' : 'gamble',
window: win,
activeWindow: win,
}
break
}
case 'NpcEntityAction':
case 'NpcAction':
if (cmd.action === 1) {
this._containers.clearShop()
this._npcInteraction = { ...cur, npcId: cmd.npcId, window: 'trade', activeWindow: 'trade' }
} else if (cmd.action === 2) {
this._containers.clearShop()
this._npcInteraction = { ...cur, npcId: cmd.npcId, window: 'gamble', activeWindow: 'gamble' }
} else if (cmd.action === 3) {
this._npcInteraction = { ...cur, npcId: cmd.npcId, window: 'none', activeWindow: 'hire' }
}
break
case 'NpcCancel':
this._containers.clearShop()
this._npcInteraction = { ...cur, npcId: null, window: 'none' }
break
case 'ClickButton':
if (cmd.buttonId === ClickButtonId.CloseStash) {
this._npcInteraction = { ...cur, stashOpen: false }
this._npcInteraction = {
...cur,
npcId: null,
window: 'none',
activeWindow: 'none',
npcInfoRaw: undefined,
npcTalkRaw: undefined,
}
break
case 'ClickButton': {
const buttonId = cmd.buttonId ?? cmd.button ?? 0
if (buttonId === ClickButtonId.CloseStash) {
this._npcInteraction = { ...cur, stashOpen: false }
} else if (buttonId === ClickButtonId.TradeOk) {
this._trade.selfAccepted = true
} else if (buttonId === ClickButtonId.TradeUnaccept) {
this._trade.selfAccepted = false
} else if (buttonId === ClickButtonId.TradeCancel) {
this._trade.active = false
this._trade.selfAccepted = false
this._trade.partnerAccepted = false
this._trade.acceptDisabled = false
this._containers.clearTrade()
}
break
}
case 'Resurrect':
this._isSelfDead = false
this._self.isDead = false
break
case 'SkillAt':
case 'CastLeftSkillOnLocation':
case 'CastRightSkillOnLocation': {
const isRight =
cmd.type === 'CastRightSkillOnLocation' || (cmd.type === 'SkillAt' && cmd.hand === 'right')
const skillId = isRight ? this._self.rightSkill : this._self.leftSkill
const targetX = cmd.type === 'SkillAt' ? cmd.at.x : cmd.x
const targetY = cmd.type === 'SkillAt' ? cmd.at.y : cmd.y
if (this.selfId !== 0) {
const selfUnit = this.ensureUnit('player', this.selfId)
this.applySkillCastToUnit(selfUnit, skillId, targetX, targetY)
}
break
}
case 'SkillOn':
case 'CastLeftSkillOnTarget':
case 'CastRightSkillOnTarget': {
const isRight =
cmd.type === 'CastRightSkillOnTarget' || (cmd.type === 'SkillOn' && cmd.hand === 'right')
const skillId = isRight ? this._self.rightSkill : this._self.leftSkill
const targetRef: UnitRef =
cmd.type === 'SkillOn' ? cmd.target : { unitType: cmd.unitKind, id: cmd.unitId }
const targetUnit = this.getUnit(targetRef.unitType, targetRef.id)
const targetX = targetUnit?.x ?? this._self.x
const targetY = targetUnit?.y ?? this._self.y
if (this.selfId !== 0) {
const selfUnit = this.ensureUnit('player', this.selfId)
selfUnit.targetUnit = targetRef
this.applySkillCastToUnit(selfUnit, skillId, targetX, targetY)
}
break
}
default:
break
}
@ -382,13 +546,36 @@ export class ClientWorld {
from: ev.speaker ?? ev.from ?? 'Server',
text: String(ev.text ?? ''),
}
} else if (ev.type === 'LifeManaPotUpdate') {
} else if (ev.type === 'LifeManaPotUpdate' || ev.type === 'PlayerLifeManaChange') {
ev = {
type: 'LifeMana',
life: Number(ev.life ?? 0),
mana: Number(ev.mana ?? 0),
stamina: Number(ev.stamina ?? 0),
at: { x: Number(ev.x ?? 0), y: Number(ev.y ?? 0) },
at: ev.at ?? { x: Number(ev.x ?? 0), y: Number(ev.y ?? 0) },
}
} else if (ev.type === 'StatChange') {
ev = {
type: 'StatSet',
stat: Number(ev.stat ?? ev.statId ?? 0),
value: Number(ev.value ?? 0),
param: Number(ev.param ?? 0),
}
} else if (ev.type === 'MercStatChange') {
ev = {
type: 'Merc',
kind: 'stat',
mercId: Number(ev.mercId ?? 0),
stat: Number(ev.statId ?? ev.stat ?? 0),
value: Number(ev.value ?? 0),
}
} else if (ev.type === 'Damage') {
ev = {
type: 'UnitMode',
unitType: ev.unitType ?? 1,
id: Number(ev.unitId ?? ev.id ?? 0),
mode: Number(ev.mode ?? (ev.lifePct === 0 ? 12 : 5)),
lifePct: Number(ev.lifePct ?? 0),
}
}
@ -447,6 +634,12 @@ export class ClientWorld {
}
if (unit.unitType === 'monster') {
this._monsters.set(unit.id, unit)
if (this._merc && unit.id === this._merc.mercId) {
this._merc.lifePct = unit.lifePct
if (unit.lifePct <= 0 || unit.mode === 12) {
this._merc.isDead = true
}
}
}
const isSelfPlayer =
@ -475,16 +668,17 @@ export class ClientWorld {
}
case 'UnitRemove': {
const uId = Number(ev.id ?? ev.unitId ?? 0)
const typeName = unitTypeFromId(ev.unitType)
if (typeName === 'item') {
this._containers.removeItemById(ev.id)
this._containers.removeItemById(uId)
}
if (typeName === 'monster') {
this._monsters.delete(ev.id)
this._monsters.delete(uId)
}
const key = makeUnitKey(ev.unitType, ev.id)
const key = makeUnitKey(ev.unitType, uId)
this._units.delete(key)
this._portals.delete(ev.id)
this._portals.delete(uId)
break
}
@ -534,12 +728,15 @@ export class ClientWorld {
this._self.y = ev.at.y
this._self.targetX = ev.at.x
this._self.targetY = ev.at.y
this._isSelfDead = false
this._self.isDead = false
}
break
}
case 'UnitMode': {
const unit = this.ensureUnit(ev.unitType, ev.id)
const uId = Number(ev.id ?? ev.unitId ?? 0)
const unit = this.ensureUnit(ev.unitType, uId)
unit.mode = ev.mode
if (ev.at && (ev.at.x !== 0 || ev.at.y !== 0)) {
unit.x = ev.at.x
@ -548,11 +745,65 @@ export class ClientWorld {
if (ev.lifePct !== undefined) {
unit.lifePct = ev.lifePct
}
if (unit.unitType === 'player' && (unit.id === this.selfId || this.selfId === 0)) {
if (ev.mode === 0 || ev.mode === 16 || ev.mode === 17 || (ev.lifePct !== undefined && ev.lifePct <= 0)) {
this._isSelfDead = true
this._self.isDead = true
}
}
if (unit.unitType === 'monster' && this._merc && unit.id === this._merc.mercId) {
if (ev.lifePct !== undefined) {
this._merc.lifePct = ev.lifePct
}
if (ev.mode === 0 || ev.mode === 12 || (ev.lifePct !== undefined && ev.lifePct <= 0)) {
this._merc.isDead = true
this._merc.lifePct = 0
}
}
break
}
case 'SkillCast':
case 'SkillCast': {
const casterType: UnitType =
ev.caster?.unitType ??
(ev.kind === 'monsterAttack' ? 1 : (ev.unitType ?? ev.attackerType ?? 0))
const casterId = Number(
ev.caster?.id ?? ev.monsterId ?? ev.unitId ?? ev.attackerId ?? 0,
)
const skillId = Number(ev.skillId ?? ev.skill ?? ev.attackType ?? 0)
let unit = this.getUnit(casterType, casterId)
if (!unit && unitTypeFromId(casterType) === 'player' && (casterId === this.selfId || this.selfId === 0)) {
unit = this.ensureUnit('player', casterId || this.selfId || 1)
}
if (!unit) break
let targetX = unit.x
let targetY = unit.y
if (ev.target && typeof ev.target === 'object' && 'x' in ev.target) {
targetX = Number(ev.target.x ?? unit.x)
targetY = Number(ev.target.y ?? unit.y)
} else {
const targetRef: UnitRef | undefined =
ev.target && typeof ev.target === 'object' && 'unitType' in ev.target
? ev.target
: ev.targetId !== undefined
? { unitType: ev.targetType ?? 1, id: Number(ev.targetId) }
: undefined
if (targetRef) {
unit.targetUnit = targetRef
const targetUnit = this.getUnit(targetRef.unitType, targetRef.id)
if (targetUnit) {
targetX = targetUnit.x
targetY = targetUnit.y
} else if (unitTypeFromId(targetRef.unitType) === 'player' && targetRef.id === this.selfId) {
targetX = this._self.x
targetY = this._self.y
}
}
}
this.applySkillCastToUnit(unit, skillId, targetX, targetY)
break
}
case 'StatSet': {
// 1.13c stat ids are 9-bit (ItemStatCost.txt). netproto still wraps some non-stat packets
@ -561,6 +812,7 @@ export class ClientWorld {
if (ev.stat < 0 || ev.stat > MAX_D2_STAT_ID) break
if (!ev.unit || (unitTypeFromId(ev.unit.unitType) === 'player' && (ev.unit.id === this.selfId || this.selfId === 0))) {
this._self.applyStat(ev.stat, ev.value, ev.param ?? 0)
this._isSelfDead = this._self.isDead
if (this.selfId !== 0) {
const selfUnit = this._units.get(makeUnitKey('player', this.selfId))
selfUnit?.stats.set(ev.stat, this._self.stats.get(ev.stat), ev.param ?? 0)
@ -568,6 +820,9 @@ export class ClientWorld {
} else {
const targetUnit = this.ensureUnit(ev.unit.unitType, ev.unit.id)
targetUnit.stats.set(ev.stat, ev.value, ev.param ?? 0)
if (this._merc && targetUnit.unitType === 'monster' && targetUnit.id === this._merc.mercId) {
this._merc.stats.set(ev.stat, ev.value)
}
}
break
}
@ -576,6 +831,13 @@ export class ClientWorld {
const atX = ev.at?.x ?? 0
const atY = ev.at?.y ?? 0
this._self.applyLifeMana(ev.life, ev.mana, ev.stamina, atX, atY)
if (ev.life === 0) {
this._isSelfDead = true
this._self.isDead = true
} else if (ev.life > 0) {
this._isSelfDead = false
this._self.isDead = false
}
if (this.selfId !== 0 && (atX !== 0 || atY !== 0)) {
const selfUnit = this._units.get(makeUnitKey('player', this.selfId))
if (selfUnit) {
@ -616,22 +878,32 @@ export class ClientWorld {
break
}
case 'Item':
case 'ItemEvent': {
// 0x17 WeaponSwitch
if (ev.action === 0x17) {
this._self.weaponSet = this._self.weaponSet === 0 ? 1 : 0
break
}
this._containers.applyItemEvent(ev.action, ev.item, ev.container, this.selfId)
if (ev.container === 'ground') {
const effectiveAction = ev.action ?? ev.item?.action ?? 0
const effectiveContainer = ev.container ?? ev.item?.container ?? 'inventory'
this._containers.applyItemEvent(effectiveAction, ev.item, effectiveContainer, this.selfId)
if (effectiveContainer === 'ground') {
const itemUnit = this.ensureUnit('item', ev.item.id)
itemUnit.x = ev.item.worldX
itemUnit.y = ev.item.worldY
itemUnit.name = ev.item.code
itemUnit.itemCode = ev.item.code
const trimmedCode = ev.item.code.trim()
const goldStatVal = ev.item.stats?.find((s: { statId: number; value: number }) => s.statId === 14)?.value
const goldAmt =
ev.item.goldAmount ??
(trimmedCode === 'gld' ? (ev.item.quantity ?? goldStatVal ?? 1) : undefined)
itemUnit.goldAmount = goldAmt
itemUnit.itemCode = trimmedCode
itemUnit.itemQuality = ev.item.quality
itemUnit.goldAmount = ev.item.goldAmount
itemUnit.itemData = ev.item
itemUnit.mode = (ev.action === 0x03 || ev.item.action === 0x03) ? 5 : 3
const uiItem = onlineItemDataToUiInventoryItem(ev.item)
itemUnit.name = uiItem.name || ev.item.code
} else {
this._units.delete(makeUnitKey('item', ev.item.id))
}
@ -677,6 +949,33 @@ export class ClientWorld {
}
case 'Npc': {
const targetId = ev.npcId ?? ev.unitId ?? ev.npcUnitId
if (ev.kind === 'heal' && targetId !== undefined) {
const healPct = ev.result ?? ev.lifePct ?? 128
const unit = this.getUnit('monster', targetId)
if (unit) {
unit.lifePct = healPct
}
if (this._merc && this._merc.mercId === targetId) {
this._merc.lifePct = healPct
if (healPct > 0) {
this._merc.isDead = false
}
}
} else if (ev.kind === 'info') {
this._npcInteraction = {
...this._npcInteraction,
npcInfoRaw: ev.raw,
}
} else if (ev.kind === 'menu') {
this._npcInteraction = {
...this._npcInteraction,
...(this._npcInteraction.npcId === null && targetId !== undefined && targetId !== 0
? { npcId: targetId }
: {}),
npcTalkRaw: ev.raw,
}
}
break
}
@ -709,50 +1008,169 @@ export class ClientWorld {
break
}
case 'CorpseAssign': {
const ownerId = ev.ownerId ?? ev.unitId ?? 0
if ((ownerId === this.selfId || this.selfId === 0) && (ev.assign ?? 1) !== 0) {
this._isSelfDead = true
this._self.isDead = true
}
break
}
case 'Party': {
const id = ev.unitId ?? ev.otherId ?? 0
const rawUnitId = ev.unitId ?? ev.playerId ?? ev.subjectId
const rawOtherId = ev.otherId ?? ev.objectId
const rawRel = ev.relation ?? ev.state ?? ev.relations
// 0x74 / 0x8E CorpseAssign for local player
if (
ev.kind === 'relation' &&
rawOtherId !== undefined &&
rawRel === 1 &&
(rawUnitId === this.selfId || (this.selfId === 0 && rawUnitId === rawOtherId))
) {
this._isSelfDead = true
this._self.isDead = true
if (!this._party.has(rawOtherId)) {
break
}
}
const id =
this.selfId !== 0 && rawUnitId === this.selfId && rawOtherId !== undefined
? rawOtherId
: (rawUnitId ?? rawOtherId ?? 0)
if (id !== 0) {
if (ev.partyId === 0xffff) {
// Only delete on 0x5C PlayerLeftGame (partyId === 0xffff with no charName or empty charName).
// 0x5B PlayerInGame sends partyId === 0xffff when the player is unpartied.
if (ev.kind === 'roster' && ev.partyId === 0xffff && !ev.charName) {
this._party.delete(id)
break
}
const prev = this._party.get(id) ?? { unitId: id }
const prev: PartyMemberState = this._party.get(id) ?? {
unitId: id,
partyState: 0,
hostile: false,
lootPermit: false,
}
let partyState = prev.partyState ?? 0
let hostile = prev.hostile ?? false
let lootPermit = prev.lootPermit ?? false
if (ev.kind === 'roster' && ev.partyId !== undefined) {
partyState = ev.partyId !== 0xffff && ev.partyId !== 0 ? 1 : 0
} else if (ev.kind === 'relation' && rawRel !== undefined) {
if ((rawOtherId === undefined || ev.subKind === 'partyState') && rawRel <= 3) {
partyState = rawRel
}
if ((rawRel & 0x08) !== 0) {
hostile = true
} else if (rawOtherId !== undefined) {
hostile = false
}
if ((rawRel & 0x04) !== 0 || (rawOtherId !== undefined && rawRel === 1)) {
lootPermit = true
} else if (rawOtherId !== undefined) {
lootPermit = false
}
}
const nextAreaId = ev.areaId ?? ev.levelId ?? prev.areaId
const nextAt =
ev.at ??
(ev.x !== undefined && ev.y !== undefined ? { x: Number(ev.x), y: Number(ev.y) } : prev.at)
this._party.set(id, {
...prev,
...(ev.charName !== undefined ? { charName: ev.charName } : {}),
...(ev.charClass !== undefined ? { charClass: ev.charClass } : {}),
...(ev.charLevel !== undefined ? { charLevel: ev.charLevel } : {}),
...(ev.partyId !== undefined ? { partyId: ev.partyId } : {}),
...(ev.relation !== undefined ? { relation: ev.relation } : {}),
...(rawRel !== undefined ? { relation: rawRel } : {}),
...(ev.lifePct !== undefined ? { lifePct: ev.lifePct } : {}),
...(ev.areaId !== undefined ? { areaId: ev.areaId } : {}),
...(ev.at !== undefined ? { at: ev.at } : {}),
...(nextAreaId !== undefined ? { areaId: nextAreaId, levelId: nextAreaId } : {}),
...(nextAt !== undefined ? { at: nextAt, x: nextAt.x, y: nextAt.y } : {}),
partyState,
hostile,
lootPermit,
})
}
break
}
case 'Trade': {
this._trade.lastAction = ev.action
if (ev.action === BUTTON_ACTION_OPEN_STASH) {
const action =
ev.kind === 'accepted' || ev.kind === 'partnerAccept'
? 0x78
: ev.kind === 'gold' && ev.action === undefined
? 0x7900 | (ev.isSelf ? 1 : 0)
: (ev.action ?? 0)
const gold = ev.gold ?? ev.amount
this._trade.lastAction = action
if (action === BUTTON_ACTION_OPEN_STASH) {
this._npcInteraction = { ...this._npcInteraction, stashOpen: true }
break
}
if (ev.partnerId !== undefined) {
this._trade.partnerId = ev.partnerId
}
if (ev.gold !== undefined && (ev.action & 0xff00) === 0x7900) {
if (ev.partnerName !== undefined) {
this._trade.partnerName = ev.partnerName
}
if (gold !== undefined && (action & 0xff00) === 0x7900) {
// 0x79 GoldInTrade: low byte is the decoder's `bySelf` flag (D2Client.dll 0x6fb5bfb0).
if ((ev.action & 0xff) !== 0) {
this._trade.selfGold = ev.gold
if ((action & 0xff) !== 0) {
this._trade.selfGold = gold
} else {
this._trade.partnerGold = ev.gold
this._trade.partnerGold = gold
}
break
}
// Trade cancel / close actions
if (ev.action === 0x02 || ev.action === 0x0c || ev.action === 0x0d) {
this._trade.active = false
} else if (ev.action === 0x00 || ev.action === 0x01 || ev.action === 0x06) {
// 0x92 ClearDisplay
if (action === 0x92 || (action & 0xff00) === 0x9200) {
this._containers.clearTrade()
break
}
// 0x78 TradeAccepted
if (action === 0x78) {
this._trade.active = true
this._trade.partnerAccepted = true
break
}
// 0x77 ButtonActions
if (
action === 0x02 ||
action === 0x03 ||
action === 0x04 ||
action === 0x06 ||
action === 0x0c ||
action === 0x0d
) {
this._trade.active = false
this._trade.selfAccepted = false
this._trade.partnerAccepted = false
this._trade.acceptDisabled = false
this._trade.partnerGold = 0
this._trade.selfGold = 0
this._containers.clearTrade()
} else if (action === 0x00 || action === 0x01) {
this._trade.active = true
this._trade.selfAccepted = false
this._trade.partnerAccepted = false
this._trade.acceptDisabled = false
} else if (action === 0x05) {
if (this._trade.acceptDisabled) {
this._trade.selfAccepted = false
this._trade.partnerAccepted = false
} else {
this._trade.partnerAccepted = true
}
} else if (action === 0x09 || action === 0x07 || action === 0x08) {
this._trade.acceptDisabled = true
this._trade.selfAccepted = false
this._trade.partnerAccepted = false
} else if (action === 0x0a) {
this._trade.acceptDisabled = false
}
break
}
@ -793,21 +1211,80 @@ export class ClientWorld {
if (ev.kind === 'assign' && ev.mercId !== undefined) {
if (ev.ownerId === undefined || ev.ownerId === this.selfId || this.selfId === 0) {
this._self.mercId = ev.mercId === 0 ? null : ev.mercId
const mercUnit = ev.mercId !== 0 ? this.getUnit('monster', ev.mercId) : undefined
this._merc =
ev.mercId === 0
? null
: {
mercId: ev.mercId,
ownerId: ev.ownerId ?? this.selfId,
classId: ev.classId ?? 0,
...(ev.nameId !== undefined ? { nameId: ev.nameId } : {}),
classId: ev.classId ?? this._merc?.classId ?? 0,
...(ev.nameId !== undefined
? { nameId: ev.nameId }
: this._merc?.nameId !== undefined
? { nameId: this._merc.nameId }
: {}),
exp: this._merc?.exp,
lifePct: mercUnit?.lifePct ?? 128,
isDead: false,
reviveCost: 0,
stats: this._merc?.stats ?? new Map<number, number>(),
hireList: this._mercHireList,
}
}
} else if (ev.kind === 'exp' && ev.value !== undefined && this._merc) {
this._merc.exp = ev.value
} else if (ev.kind === 'stat' && ev.mercId !== undefined && ev.stat !== undefined && ev.value !== undefined) {
} else if (ev.kind === 'stat' || ev.kind === 'attr' || ev.kind === 'reviveCost') {
const statId = ev.stat ?? ev.statId
const revNameId = ev.nameId ?? ev.deadNameId
const revCost = ev.reviveCost ?? ev.value
if (ev.mercId !== undefined && statId !== undefined && ev.value !== undefined) {
const mercUnit = this.ensureUnit('monster', ev.mercId)
mercUnit.stats.set(ev.stat, ev.value)
mercUnit.stats.set(statId, ev.value)
if (this._merc && this._merc.mercId === ev.mercId) {
this._merc.stats.set(statId, ev.value)
}
} else if (ev.mercId === undefined && revNameId !== undefined && revCost !== undefined) {
// 0x9B MercReviveCost
if (!this._merc) {
this._merc = {
mercId: 0,
ownerId: this.selfId,
classId: 0,
nameId: revNameId,
lifePct: 0,
isDead: true,
reviveCost: revCost,
deadNameId: revNameId,
reviveNameId: revNameId,
stats: new Map<number, number>(),
hireList: this._mercHireList,
}
} else {
this._merc.nameId = revNameId
this._merc.deadNameId = revNameId
this._merc.reviveNameId = revNameId
this._merc.reviveCost = revCost
this._merc.lifePct = 0
this._merc.isDead = true
}
}
} else if (
ev.kind === 'hireList' ||
ev.kind === 'forHireListStart' ||
ev.kind === 'hireListStart' ||
ev.kind === 'forHire'
) {
if (ev.nameId === undefined || ev.kind === 'forHireListStart' || ev.kind === 'hireListStart') {
// 0x4F StartMercList
this._mercHireList.length = 0
} else {
// 0x4E MercForHire
this._mercHireList.push({
nameId: ev.nameId,
seed: ev.seed ?? ev.value ?? 0,
})
}
}
break
}
@ -852,8 +1329,10 @@ export class ClientWorld {
this.selfId = 0
this.frameCount = 0
this.lastRttMs = null
this._isSelfDead = false
this._self.reset()
this._merc = null
this._mercHireList.length = 0
this._units.clear()
this._monsters.clear()
this._containers.clear()
@ -865,8 +1344,14 @@ export class ClientWorld {
this._trade.active = false
this._trade.lastAction = 0
this._trade.partnerId = undefined
this._trade.partnerName = undefined
this._trade.partnerGold = 0
this._trade.selfGold = 0
this._trade.selfAccepted = false
this._trade.partnerAccepted = false
this._trade.acceptDisabled = false
this._npcInteraction = { npcId: null, window: 'none', activeWindow: 'none', stashOpen: false }
this._lastNpcTransaction = null
this._chatLog.length = 0
this._soundQueue.length = 0
this._portals.clear()

View File

@ -19,13 +19,33 @@ export interface ClientItemContainers {
readonly cursor: ItemData | null
readonly tradeSelf: ReadonlyMap<number, ItemData>
readonly tradePartner: ReadonlyMap<number, ItemData>
readonly tradeGrid: readonly ItemData[]
readonly partnerTradeGrid: readonly ItemData[]
readonly merc: ReadonlyMap<number, ItemData>
readonly mercBySlot: ReadonlyMap<number, ItemData>
readonly shop: ReadonlyMap<number, ItemData>
readonly ground: ReadonlyMap<number, ItemData>
readonly sockets: ReadonlyMap<number, readonly ItemData[]>
readonly allById: ReadonlyMap<number, ItemData>
}
class MercContainerMap extends Map<number, ItemData> {
private readonly bySlot: Map<number, ItemData>
constructor(bySlot: Map<number, ItemData>) {
super()
this.bySlot = bySlot
}
override get(key: number): ItemData | undefined {
return super.get(key) ?? this.bySlot.get(key)
}
override has(key: number): boolean {
return super.has(key) || this.bySlot.has(key)
}
}
export class ClientInventoryManager implements ClientItemContainers {
readonly inventory = new Map<number, ItemData>()
readonly equipment = new Map<number, ItemData>()
@ -37,12 +57,21 @@ export class ClientInventoryManager implements ClientItemContainers {
cursor: ItemData | null = null
readonly tradeSelf = new Map<number, ItemData>()
readonly tradePartner = new Map<number, ItemData>()
readonly merc = new Map<number, ItemData>()
readonly mercBySlot = new Map<number, ItemData>()
readonly merc = new MercContainerMap(this.mercBySlot)
readonly shop = new Map<number, ItemData>()
readonly ground = new Map<number, ItemData>()
readonly sockets = new Map<number, ItemData[]>()
readonly allById = new Map<number, ItemData>()
get tradeGrid(): readonly ItemData[] {
return [...this.tradeSelf.values()]
}
get partnerTradeGrid(): readonly ItemData[] {
return [...this.tradePartner.values()]
}
/**
* Removes an item from whichever container currently holds it.
*/
@ -61,8 +90,14 @@ export class ClientInventoryManager implements ClientItemContainers {
if (this.belt.has(itemId)) {
const b = this.belt.get(itemId)!
this.belt.delete(itemId)
if (this.beltBySlot.get(b.gridX)?.id === itemId) {
this.beltBySlot.delete(b.gridX)
const bSlot = b.gridX ?? b.x ?? 0
if (this.beltBySlot.get(bSlot)?.id === itemId) {
this.beltBySlot.delete(bSlot)
}
}
for (const [slot, b] of this.beltBySlot.entries()) {
if (b.id === itemId) {
this.beltBySlot.delete(slot)
}
}
this.stash.delete(itemId)
@ -72,7 +107,21 @@ export class ClientInventoryManager implements ClientItemContainers {
}
this.tradeSelf.delete(itemId)
this.tradePartner.delete(itemId)
if (this.merc.has(itemId)) {
const m = this.merc.get(itemId)
this.merc.delete(itemId)
if (m) {
const slot = m.bodyLoc || m.equippedSlot || 0
if (this.mercBySlot.get(slot)?.id === itemId) {
this.mercBySlot.delete(slot)
}
}
}
for (const [slot, m] of this.mercBySlot.entries()) {
if (m.id === itemId) {
this.mercBySlot.delete(slot)
}
}
this.shop.delete(itemId)
this.ground.delete(itemId)
this.sockets.delete(itemId)
@ -101,8 +150,14 @@ export class ClientInventoryManager implements ClientItemContainers {
return
}
// Removal-to-cursor actions:
// 0x05 RemoveFromContainer (to cursor), 0x08 Unequip (to cursor), 0x0F RemoveFromBelt (to cursor)
if (action === 0x05 || action === 0x08 || action === 0x0f) {
// 0x02 ToCursor (when container is cursor or location === 4), 0x05 RemoveFromContainer (to cursor), 0x08 Unequip (to cursor), 0x0F RemoveFromBelt (to cursor)
if (
(action === 0x02 && (container === 'cursor' || item.location === 4)) ||
action === 0x05 ||
action === 0x08 ||
action === 0x0f ||
(action === 0x04 && item.location === 4)
) {
this.removeItemById(item.id)
const cursorItem: ItemData = { ...item, container: 'cursor' }
this.cursor = cursorItem
@ -120,7 +175,9 @@ export class ClientInventoryManager implements ClientItemContainers {
this.removeItemById(item.id)
let effectiveContainer = container
if (effectiveContainer === 'trade') {
if (item.ownerType === 1 && (effectiveContainer === 'equipment' || action === 0x06 || action === 0x07 || action === 0x09)) {
effectiveContainer = 'merc'
} else if (effectiveContainer === 'trade') {
if (item.ownerId !== undefined && selfId !== undefined && item.ownerId !== selfId) {
effectiveContainer = 'tradePartner'
} else {
@ -155,13 +212,25 @@ export class ClientInventoryManager implements ClientItemContainers {
}
break
}
case 'belt':
case 'belt': {
const bSlot = stored.gridX ?? stored.x ?? 0
const prevInSlot = this.beltBySlot.get(bSlot)
if (prevInSlot && prevInSlot.id !== stored.id) {
this.belt.delete(prevInSlot.id)
this.allById.delete(prevInSlot.id)
}
this.belt.set(stored.id, stored)
this.beltBySlot.set(stored.gridX, stored)
this.beltBySlot.set(bSlot, stored)
break
case 'merc':
}
case 'merc': {
this.merc.set(stored.id, stored)
const mSlot = stored.bodyLoc || stored.equippedSlot || 0
if (mSlot > 0) {
this.mercBySlot.set(mSlot, stored)
}
break
}
case 'shop':
this.shop.set(stored.id, stored)
break
@ -196,6 +265,22 @@ export class ClientInventoryManager implements ClientItemContainers {
this.shop.clear()
}
/**
* Clears both trade grids (`tradeSelf` and `tradePartner`) on `0x77` cancel/done or `0x92` ClearDisplay.
*/
clearTrade(): void {
for (const id of this.tradeSelf.keys()) {
this.allById.delete(id)
this.sockets.delete(id)
}
for (const id of this.tradePartner.keys()) {
this.allById.delete(id)
this.sockets.delete(id)
}
this.tradeSelf.clear()
this.tradePartner.clear()
}
clear(): void {
this.inventory.clear()
this.equipment.clear()
@ -208,6 +293,7 @@ export class ClientInventoryManager implements ClientItemContainers {
this.tradeSelf.clear()
this.tradePartner.clear()
this.merc.clear()
this.mercBySlot.clear()
this.shop.clear()
this.ground.clear()
this.sockets.clear()

View File

@ -7,6 +7,7 @@
*/
import type { CharClassId, SkillLevel } from '../../netproto/index.ts'
import type { UnitCastState } from './client-unit.ts'
import {
STAT_EXPERIENCE,
STAT_GOLD,
@ -49,6 +50,8 @@ export interface SelfPlayerState {
runMode: boolean
weaponSet: 0 | 1
mercId: number | null
isDead: boolean
castState?: UnitCastState | undefined
}
export class MutableSelfPlayerState implements SelfPlayerState {
@ -78,6 +81,8 @@ export class MutableSelfPlayerState implements SelfPlayerState {
runMode = true
weaponSet: 0 | 1 = 0
mercId: number | null = null
isDead = false
castState?: UnitCastState | undefined
/**
* Apply one server stat update (`0x1D`/`0x1E`/`0x1F` BaseAttribute byte/word/dword, `0x19` gold delta).
@ -100,9 +105,17 @@ export class MutableSelfPlayerState implements SelfPlayerState {
switch (stat) {
case STAT_HITPOINTS:
this.life = poolStatValue(value)
if (this.life > 0) {
this.isDead = false
} else if (this.maxLife > 0 && this.life <= 0) {
this.isDead = true
}
break
case STAT_MAXHP:
this.maxLife = poolStatValue(value)
if (this.maxLife > 0 && this.life <= 0 && this.stats.has(STAT_HITPOINTS)) {
this.isDead = true
}
break
case STAT_MANA:
this.mana = poolStatValue(value)
@ -136,6 +149,11 @@ export class MutableSelfPlayerState implements SelfPlayerState {
if (life >= 0) {
this.life = life
this.stats.set(STAT_HITPOINTS, life)
if (life > 0) {
this.isDead = false
} else if (life === 0 && this.maxLife > 0) {
this.isDead = true
}
}
if (mana >= 0) {
this.mana = mana
@ -189,6 +207,8 @@ export class MutableSelfPlayerState implements SelfPlayerState {
this.runMode = true
this.weaponSet = 0
this.mercId = null
this.isDead = false
this.castState = undefined
}
}

View File

@ -50,6 +50,7 @@ export const D2StatId = {
LightResist: 41,
ColdResist: 43,
PoisonResist: 45,
SocketCount: 194,
} as const
export class StatList {

View File

@ -177,6 +177,10 @@ export function formatBilingualGroundItemName(item: any, name: string, quality:
export interface GroundItemMetadata {
readonly name: string
readonly nameZh: string
readonly labelText: string
readonly labelEn: string
readonly labelZh: string
readonly color: string
readonly quality: string
readonly isGold: boolean
readonly amount: number
@ -188,6 +192,27 @@ export interface GroundItemMetadata {
readonly sockets?: number | undefined
}
function resolveGroundQualityColor(quality: string, ethereal?: boolean, sockets?: number): string {
switch (quality) {
case 'unique':
return '#c4a060'
case 'set':
return '#00fc00'
case 'rare':
return '#ffff64'
case 'craft':
return '#ff8000'
case 'magic':
return '#4880ff'
case 'rune':
return '#ffa500'
case 'low':
return '#a0a0a0'
default:
return ethereal || (sockets && sockets > 0) ? '#a0a0a0' : '#ffffff'
}
}
/**
* Checks whether an item or GroundItemSource is currently unidentified.
*/
@ -213,7 +238,11 @@ export function resolveGroundItemMetadata(item: any, specifiedAmount?: number):
if (isGold) {
const amount = specifiedAmount ?? (
typeof item?.amount === 'number'
typeof item?.goldAmount === 'number'
? item.goldAmount
: typeof item?.quantity === 'number'
? item.quantity
: typeof item?.amount === 'number'
? item.amount
: typeof item?.stack === 'number'
? item.stack
@ -221,9 +250,15 @@ export function resolveGroundItemMetadata(item: any, specifiedAmount?: number):
? item.value
: 1
)
const labelEn = `${amount} Gold`
const labelZh = `${amount} 金币`
return {
name: `${amount} Gold`,
nameZh: `${amount} 金币`,
name: labelEn,
nameZh: labelZh,
labelText: labelEn,
labelEn,
labelZh,
color: '#d8b864',
quality: 'normal',
isGold: true,
amount,
@ -245,7 +280,7 @@ export function resolveGroundItemMetadata(item: any, specifiedAmount?: number):
: ''
let tableBase: any
if (rawCode && (!item?.base || (item?.base?.dropsound === undefined && item?.dropSound === undefined && item?.dropsound === undefined))) {
if (rawCode && (!item?.base || !item?.name || (item?.base?.dropsound === undefined && item?.dropSound === undefined && item?.dropsound === undefined))) {
try {
const tables = getEmbeddedDropTables() as any
tableBase = typeof tables.getBase === 'function'
@ -256,7 +291,12 @@ export function resolveGroundItemMetadata(item: any, specifiedAmount?: number):
}
}
const rawName = typeof item?.name === 'string' ? item.name : 'Unknown Item'
const rawName =
typeof item?.name === 'string' && item.name.trim().length > 0
? item.name
: rawCode
? resolveBaseItemNameEn(tableBase?.name || rawCode.toUpperCase(), rawCode)
: 'Unknown Item'
const quality = resolveItemQualityString(item?.quality ?? item?.base?.quality, rawCode, item)
const unidentified = isGroundSourceUnidentified(item)
@ -297,10 +337,15 @@ export function resolveGroundItemMetadata(item: any, specifiedAmount?: number):
const ethereal = Boolean(item?.ethereal === true || item?.isEthereal === true || item?.item?.ethereal === true)
const rawSockets = item?.sockets ?? item?.totalSockets ?? item?.socketedCount ?? item?.item?.sockets ?? item?.item?.totalSockets ?? 0
const sockets = typeof rawSockets === 'number' && rawSockets > 0 ? rawSockets : undefined
const color = resolveGroundQualityColor(quality, ethereal, sockets)
return {
name,
nameZh,
labelText: name,
labelEn: name,
labelZh: nameZh,
color,
quality,
isGold: false,
amount,
@ -1521,3 +1566,70 @@ export class GroundItemManager {
return true
}
}
export interface GroundItemLabelInput {
readonly id: number
readonly text?: string | undefined
readonly label?: string | undefined
readonly color?: string | undefined
readonly screenX: number
readonly screenY: number
readonly width?: number | undefined
readonly height?: number | undefined
}
export interface GroundItemLabelBox {
readonly id: number
readonly text: string
readonly label: string
readonly color: string
readonly x: number
readonly y: number
readonly width: number
readonly height: number
}
export function layoutGroundItemLabels(
labels: readonly GroundItemLabelInput[],
viewportW = 800,
viewportH = 600,
): readonly GroundItemLabelBox[] {
const placed: GroundItemLabelBox[] = []
for (const item of labels) {
const txt = item.text ?? item.label ?? ''
const w = item.width ?? Math.max(40, txt.length * 7 + 12)
const h = item.height ?? 18
const x = Math.max(0, Math.min(Math.max(0, viewportW - w), Math.round(item.screenX - w / 2)))
let y = Math.max(0, Math.min(Math.max(0, viewportH - h), Math.round(item.screenY - h)))
let safety = 0
while (
safety < 100 &&
placed.some(
(b) =>
x < b.x + b.width &&
x + w > b.x &&
y < b.y + b.height &&
y + h > b.y,
)
) {
if (y - (h + 2) >= 0) {
y -= h + 2
} else {
y += h + 2
}
safety++
}
placed.push({
id: item.id,
text: txt,
label: txt,
color: item.color ?? '#ffffff',
x,
y,
width: w,
height: h,
})
}
return placed
}

View File

@ -76,7 +76,10 @@ export interface UiInventoryItem {
readonly speedTextEn?: string | undefined
readonly runewordRunes?: string | undefined
readonly sockets?: number | undefined
readonly usedSockets?: number | undefined
readonly socketedItems?: readonly UiInventoryItem[] | undefined
readonly socketedGems?: readonly string[] | undefined
readonly affixes?: readonly string[] | undefined
readonly ethereal?: boolean | undefined
readonly setPieces?: readonly string[] | undefined
readonly setPiecesEn?: readonly string[] | undefined

View File

@ -10,16 +10,24 @@
* - Rich tooltip attributes & rolled property formatting with D2ColorCode lines
*/
import type { Item, ItemBase } from './items.ts'
import type { Item, ItemBase, RolledItemProp } from './items.ts'
import { ItemQuality } from './items.ts'
import type { DropTables } from './drop-tables.ts'
import { getEmbeddedDropTables } from './embedded-drop-tables.ts'
import { CANONICAL_RUNES_TABLE, type RunewordDefinition } from './runeword.ts'
import {
type UiInventoryItem,
type EquipSlotId,
type UiItemQuality,
} from './inventory-model.ts'
import { BAKED_UI_MANIFEST } from './baked-ui-meta.ts'
import { formatItemTooltip } from './item-tooltip.ts'
import {
formatItemTooltip,
CANONICAL_ISC_DESC_TABLE,
CANONICAL_ENG_TBL_DICT,
resolveBaseItemNameEn,
resolveRareNameEn,
} from './item-tooltip.ts'
import { translateItemName, translateRareName, translateMagicName } from '../i18n/tooltip-i18n.ts'
import { isRuneCode, getGemColorTransform, COLOR_CODES } from './gems-data.ts'
import { CANONICAL_AUTOMAGIC_TABLE } from './automagic.ts'
@ -28,6 +36,18 @@ import { isA } from './item-types.ts'
import { inferItemClassFromType } from './affixes.ts'
import { CHARACTER_CLASS_CODES, type CharacterClassCode } from '../units/classes.ts'
interface ItemStat {
readonly statId?: number
readonly id?: number
readonly param?: number
readonly value: number
}
type ItemData = Record<string, any> & {
readonly stats?: readonly ItemStat[]
readonly runewordStats?: readonly ItemStat[]
}
export { COLOR_CODES, getGemColorTransform }
const VALID_CLASS_CODES = new Set<string>(CHARACTER_CLASS_CODES)
@ -1035,3 +1055,833 @@ export function itemToUiInventoryItem(
...((rawItem as any).repairCost !== undefined ? { repairCost: (rawItem as any).repairCost } : {}),
}
}
const STAT_ID_TO_PROP_CODE: Readonly<Record<number, string>> = {
0: 'str',
1: 'enr',
2: 'dex',
3: 'vit',
6: 'hp',
7: 'hp',
8: 'mana',
9: 'mana',
10: 'stam',
11: 'stam',
16: 'ac%',
17: 'dmg%',
18: 'dmg%',
19: 'att',
20: 'block',
21: 'dmg-min',
22: 'dmg-max',
23: 'dmg-min',
24: 'dmg-max',
27: 'regen-mana',
28: 'regen-stam',
31: 'ac',
32: 'ac-miss',
33: 'ac-hth',
34: 'red-dmg',
35: 'red-mag',
36: 'red-dmg%',
37: 'res-mag',
38: 'res-mag-max',
39: 'res-fire',
40: 'res-fire-max',
41: 'res-ltng',
42: 'res-ltng-max',
43: 'res-cold',
44: 'res-cold-max',
45: 'res-pois',
46: 'res-pois-max',
48: 'fire-min',
49: 'fire-max',
50: 'ltng-min',
51: 'ltng-max',
52: 'dmg-mag',
53: 'dmg-mag',
54: 'cold-min',
55: 'cold-max',
56: 'cold-len',
57: 'pois-min',
58: 'pois-max',
59: 'pois-len',
60: 'lifesteal',
62: 'manasteal',
74: 'regen',
75: 'dur%',
76: 'hp%',
77: 'mana%',
78: 'thorns',
79: 'gold%',
80: 'mag%',
81: 'knock',
85: 'addxp',
86: 'heal-kill',
87: 'cheap',
89: 'light',
91: 'ease',
93: 'ias',
96: 'frw',
97: 'oskill',
98: 'state',
99: 'fhr',
102: 'fbr',
105: 'fcr',
107: 'skill',
108: 'rip',
110: 'res-pois-len',
111: 'dmg',
112: 'howl',
113: 'stupidity',
114: 'dmg-to-mana',
115: 'ignore-ac',
116: 'reduce-ac',
117: 'noheal',
118: 'half-freeze',
119: 'att%',
120: 'dmg-ac',
121: 'dmg-demon',
122: 'dmg-undead',
123: 'att-demon',
124: 'att-undead',
125: 'throw',
126: 'fireskill',
127: 'allskills',
128: 'light-thorns',
134: 'freeze',
135: 'openwounds',
136: 'crush',
137: 'kick',
138: 'mana-kill',
139: 'demon-heal',
140: 'bloody',
141: 'deadly',
142: 'abs-fire%',
143: 'abs-fire',
144: 'abs-ltng%',
145: 'abs-ltng',
146: 'abs-mag%',
147: 'abs-mag',
148: 'abs-cold%',
149: 'abs-cold',
150: 'slow',
151: 'aura',
152: 'indestruct',
153: 'nofreeze',
154: 'stamdrain',
155: 'reanimate',
156: 'pierce',
157: 'magicarrow',
158: 'explosivearrow',
159: 'dmg-min',
160: 'dmg-max',
179: 'att-mon%',
180: 'dmg-mon%',
181: 'fade',
188: 'skilltab',
195: 'att-skill',
196: 'kill-skill',
197: 'death-skill',
198: 'hit-skill',
199: 'levelup-skill',
201: 'gethit-skill',
204: 'charged',
214: 'ac/lvl',
215: 'ac%/lvl',
216: 'hp/lvl',
217: 'mana/lvl',
218: 'dmg/lvl',
219: 'dmg%/lvl',
220: 'str/lvl',
221: 'dex/lvl',
222: 'enr/lvl',
223: 'vit/lvl',
224: 'att/lvl',
225: 'att%/lvl',
226: 'dmg-cold/lvl',
227: 'dmg-fire/lvl',
228: 'dmg-ltng/lvl',
229: 'dmg-pois/lvl',
230: 'res-cold/lvl',
231: 'res-fire/lvl',
232: 'res-ltng/lvl',
233: 'res-pois/lvl',
234: 'abs-cold/lvl',
235: 'abs-fire/lvl',
236: 'abs-ltng/lvl',
237: 'abs-pois/lvl',
238: 'thorns/lvl',
239: 'gold%/lvl',
240: 'mag%/lvl',
241: 'regen-stam/lvl',
242: 'stam/lvl',
243: 'dmg-dem/lvl',
244: 'dmg-und/lvl',
245: 'att-dem/lvl',
246: 'att-und/lvl',
247: 'crush/lvl',
248: 'wounds/lvl',
249: 'kick/lvl',
250: 'deadly/lvl',
252: 'rep-dur',
253: 'rep-quant',
254: 'stack',
268: 'ac/time',
329: 'extra-fire',
330: 'extra-ltng',
331: 'extra-cold',
332: 'extra-pois',
333: 'pierce-fire',
334: 'pierce-ltng',
335: 'pierce-cold',
336: 'pierce-pois',
359: 'ethereal',
}
const ISC_STAT_NAME_BY_ID: ReadonlyMap<number, string> = (() => {
const m = new Map<number, string>()
for (const entry of Object.values(CANONICAL_ISC_DESC_TABLE)) {
m.set(entry.id, entry.stat)
}
return m
})()
function convertOnlineItemStatsToRolledProps(stats: readonly ItemStat[]): {
readonly props: RolledItemProp[]
readonly reqLevelBonus: number
readonly socketsStat: number
readonly curDurStat?: number | undefined
readonly maxDurStat?: number | undefined
readonly quantityStat?: number | undefined
} {
const props: RolledItemProp[] = []
let reqLevelBonus = 0
let socketsStat = 0
let curDurStat: number | undefined
let maxDurStat: number | undefined
let quantityStat: number | undefined
const getStatId = (s: ItemStat | Record<string, any>): number =>
Number(s.statId ?? (s as any).id ?? -1)
const has17 = stats.some(s => getStatId(s) === 17)
const has21 = stats.some(s => getStatId(s) === 21)
const has22 = stats.some(s => getStatId(s) === 22)
for (let i = 0; i < stats.length; i++) {
const st = stats[i]!
const statId = getStatId(st)
const param = Number(st.param ?? 0)
const value = Number(st.value ?? 0)
if (statId === 72) {
curDurStat = value
continue
}
if (statId === 73) {
maxDurStat = value
continue
}
if (statId === 194) {
socketsStat = Math.max(socketsStat, value)
continue
}
if (statId === 70) {
quantityStat = value
continue
}
if (statId === 92) {
reqLevelBonus = Math.max(reqLevelBonus, value)
continue
}
if (statId === 14 || statId === 356) {
continue
}
if (statId === 18 && has17) {
continue
}
if (statId === 23 && has21) {
continue
}
if (statId === 24 && has22) {
continue
}
if (statId === 52) {
const next = stats[i + 1]
if (next && getStatId(next) === 53) {
props.push({
code: 'dmg-mag',
min: value,
max: Math.max(value, next.value),
value,
})
i++
continue
}
props.push({ code: 'dmg-mag', min: value, max: value, value })
continue
}
if (statId === 53) {
props.push({ code: 'dmg-mag', min: value, max: value, value })
continue
}
if (statId === 6 || statId === 7 || statId === 8 || statId === 9 || statId === 10 || statId === 11) {
const poolCode = statId <= 7 ? 'hp' : statId <= 9 ? 'mana' : 'stam'
const normVal = Math.abs(value) >= 256 && (value & 0xff) === 0 ? value >> 8 : value
props.push({ code: poolCode, min: normVal, max: normVal, value: normVal })
continue
}
if (statId === 83) {
const clsCode = (['ama', 'sor', 'nec', 'pal', 'bar', 'dru', 'ass'] as const)[param] ?? 'ama'
props.push({ code: clsCode, min: value, max: value, value })
continue
}
if (statId === 188) {
const tabIdx = param > 20 ? ((param >> 3) & 7) * 3 + (param & 7) : param
props.push({ code: 'skilltab', param: tabIdx, min: value, max: value, value })
continue
}
if (statId === 97 || statId === 107 || statId === 151 || statId === 155) {
const code = STAT_ID_TO_PROP_CODE[statId]!
props.push({ code, param, min: value, max: value, value })
continue
}
if (statId >= 195 && statId <= 201 && STAT_ID_TO_PROP_CODE[statId]) {
const procCode = STAT_ID_TO_PROP_CODE[statId]!
const skillId = param >= 64 ? param >> 6 : param
const slvl = param >= 64 ? Math.max(1, param & 0x3f) : 1
props.push({ code: procCode, param: skillId, min: value, max: slvl, value })
continue
}
if (statId === 204) {
const skillId = param >= 64 ? param >> 6 : param
const slvl = param >= 64 ? Math.max(1, param & 0x3f) : 1
const charges = value > 255 ? (value & 0xff) || ((value >> 8) & 0xff) : Math.max(1, value)
props.push({ code: 'charged', param: skillId, min: charges, max: slvl, value: charges })
continue
}
if (statId === 252) {
const rate = value || param || 1
props.push({ code: 'rep-dur', param: rate, min: rate, max: rate, value: rate })
continue
}
const mappedCode = STAT_ID_TO_PROP_CODE[statId] ?? ISC_STAT_NAME_BY_ID.get(statId)
if (mappedCode) {
props.push({
code: mappedCode,
...(param ? { param } : {}),
min: value,
max: value,
value,
})
}
}
return { props, reqLevelBonus, socketsStat, curDurStat, maxDurStat, quantityStat }
}
function resolveOnlineRunewordDef(
item: ItemData | Record<string, any>,
socketChildren?: readonly (ItemData | Record<string, any>)[],
): RunewordDefinition | undefined {
if (socketChildren && socketChildren.length > 0) {
const childRuneCodes = socketChildren
.map(c => String(c.code ?? '').trim().toLowerCase())
.filter(c => isRuneCode(c))
if (childRuneCodes.length > 0) {
for (const rw of CANONICAL_RUNES_TABLE.complete) {
if (
rw.runes.length === childRuneCodes.length &&
rw.runes.every((r, idx) => r.trim().toLowerCase() === childRuneCodes[idx])
) {
return rw
}
}
}
}
const rwData = item.runewordData
if (typeof rwData === 'number' && rwData > 0) {
const low12 = rwData & 0xfff
return (
CANONICAL_RUNES_TABLE.getById(rwData) ??
(low12 > 27 ? CANONICAL_RUNES_TABLE.getById(low12 - 27) : undefined) ??
(low12 > 26 ? CANONICAL_RUNES_TABLE.getById(low12 - 26) : undefined) ??
CANONICAL_RUNES_TABLE.getById(low12) ??
CANONICAL_RUNES_TABLE.all[rwData]
)
}
return undefined
}
const RUNEWORD_DISPLAY_OVERRIDES_EN: Readonly<Record<string, string>> = {
Runeword4: 'Beast',
Runeword14: 'Chains of Honor',
Runeword26: 'Doom',
Runeword37: 'Exile',
Runeword47: 'Grief',
Runeword99: 'Voice of Reason',
}
/**
* Canonical 1.13c Online `ItemData` (`0x9C` / `0x9D`) to `UiInventoryItem` Bridge.
*
* Converts a decoded D2GS `ItemData` (plus optional socketed children and `DropTables`)
* into a localized, tooltip-ready `UiInventoryItem` with base item names, quality titles,
* combat stats (1H/2H damage, defense, durability, requirements), sockets, and formatted
* 1.13c property lines in both Chinese (`stats`) and English (`statsEn`).
*/
export function onlineItemDataToUiInventoryItem(
item: ItemData | Record<string, any>,
socketChildren?: readonly (ItemData | Record<string, any>)[],
dropTables?: DropTables,
): UiInventoryItem & {
readonly nameEn: string
readonly baseName: string
readonly usedSockets: number
readonly damageRange?: { readonly min: number; readonly max: number } | undefined
readonly twoHandDamage?: { readonly min: number; readonly max: number } | undefined
} {
const tables = dropTables ?? getEmbeddedDropTables()
const code = String(item.code ?? '').trim().toLowerCase() || 'rin'
const weaponRow = tables.weapons?.get(code)
const armorRow = !weaponRow ? tables.armor?.get(code) : undefined
const miscRow = !weaponRow && !armorRow ? tables.misc?.get(code) : undefined
const baseRow: any = weaponRow ?? armorRow ?? miscRow ?? tables.getBase?.(code)
const kind: 'weapon' | 'armor' | 'misc' = weaponRow
? 'weapon'
: armorRow
? 'armor'
: ((baseRow?.kind as 'weapon' | 'armor' | 'misc' | undefined) ?? 'misc')
const baseNameEn = resolveBaseItemNameEn(baseRow?.name || code.toUpperCase(), code)
const rawBaseZh = translateItemName(baseNameEn)
const baseNameZh = rawBaseZh && rawBaseZh !== baseNameEn ? rawBaseZh : baseNameEn
const isUnidentified = item.identified === false
const isRuneword = Boolean(item.runeword || (item as any).isRuneword)
const isEthereal = Boolean(item.ethereal)
const rawQ = item.quality
let qualityStr: string = 'normal'
if (rawQ === 1 || rawQ === 'low') qualityStr = 'low'
else if (rawQ === 3 || rawQ === 'superior') qualityStr = 'superior'
else if (rawQ === 4 || rawQ === 'magic') qualityStr = 'magic'
else if (rawQ === 5 || rawQ === 'set') qualityStr = 'set'
else if (rawQ === 6 || rawQ === 'rare') qualityStr = 'rare'
else if (rawQ === 7 || rawQ === 'unique') qualityStr = 'unique'
else if (rawQ === 8 || rawQ === 9 || rawQ === 'crafted' || rawQ === 'craft') qualityStr = 'craft'
const rawCombinedStats: (ItemStat | Record<string, any>)[] = [
...(Array.isArray(item.stats) ? item.stats : []),
...(Array.isArray(item.runewordStats) ? item.runewordStats : []),
]
let armorStatDefense: number | undefined
let consumedArmorStat = false
const combinedStats = rawCombinedStats.filter(s => {
const sid = Number(s.statId ?? (s as any).id ?? -1)
if (
!consumedArmorStat &&
kind === 'armor' &&
sid === 31 &&
(typeof item.defense !== 'number' || item.defense <= 0 || Number(s.value) === item.defense)
) {
armorStatDefense = Number(s.value)
consumedArmorStat = true
return false
}
return true
}) as ItemStat[]
const converted = convertOnlineItemStatsToRolledProps(combinedStats)
const rolledProps: RolledItemProp[] = [...converted.props]
// Resolve quality-specific definitions when identified
let uniqueItemDef: any
let setItemDef: any
let rolledMagicAffixes: any
let rolledRareAffixes: any
let runewordObj: { readonly key: string; readonly name: string; readonly levelreq?: number; readonly props?: readonly any[] } | undefined
let itemDisplayNameEn = baseNameEn
let extraReqLevel = converted.reqLevelBonus
if (!isUnidentified) {
if (isRuneword) {
const rwDef = resolveOnlineRunewordDef(item, socketChildren)
if (rwDef) {
const rwKey = rwDef.name
const rwEn =
RUNEWORD_DISPLAY_OVERRIDES_EN[rwKey] ??
CANONICAL_ENG_TBL_DICT[rwKey] ??
(rwDef.runeName || rwKey)
itemDisplayNameEn = rwEn
runewordObj = {
key: rwKey,
name: rwEn,
props: rwDef.properties,
}
if (rolledProps.length === 0 && Array.isArray(rwDef.properties)) {
for (const p of rwDef.properties) {
if (p.code) {
rolledProps.push({
code: p.code,
param: p.par,
min: p.min,
max: p.max,
value: p.min,
})
}
}
}
} else if ((item as any).runewordName) {
const rwEn = String((item as any).runewordName)
itemDisplayNameEn = rwEn
runewordObj = { key: rwEn, name: rwEn }
}
} else if (qualityStr === 'unique') {
const uid = item.uniqueItemId ?? (item as any).uniqueId
if (typeof uid === 'number' && tables.uniques) {
uniqueItemDef =
(tables.uniques as any).byId?.get?.(uid) ??
tables.uniques.all?.[uid]
}
if (!uniqueItemDef && tables.uniques) {
const byCode = tables.uniques.getEnabledByCode?.(code)
if (byCode && byCode.length > 0) {
uniqueItemDef = byCode[0]
}
}
if (uniqueItemDef) {
itemDisplayNameEn = uniqueItemDef.index || baseNameEn
if (uniqueItemDef.levelreq > 0) {
extraReqLevel = Math.max(extraReqLevel, uniqueItemDef.levelreq)
}
}
} else if (qualityStr === 'set') {
const sid = item.setItemId ?? (item as any).setId
if (typeof sid === 'number' && tables.sets?.items) {
setItemDef =
(tables.sets.items as any).byId?.get?.(sid) ??
tables.sets.items.entries?.[sid]
}
if (!setItemDef && tables.sets?.items?.entries) {
setItemDef = tables.sets.items.entries.find(
(s: any) => s.item?.trim().toLowerCase() === code,
)
}
if (setItemDef) {
itemDisplayNameEn = setItemDef.index || baseNameEn
if (setItemDef.levelreq > 0) {
extraReqLevel = Math.max(extraReqLevel, setItemDef.levelreq)
}
}
} else if (
qualityStr === 'magic' ||
item.magicPrefixId ||
item.magicSuffixId ||
(item as any).magicPrefix ||
(item as any).magicSuffix ||
(item as any).prefixIds?.length ||
(item as any).suffixIds?.length
) {
const preId =
item.magicPrefixId ??
(item as any).magicPrefix ??
(Array.isArray((item as any).prefixIds) ? (item as any).prefixIds[0] : undefined)
const sufId =
item.magicSuffixId ??
(item as any).magicSuffix ??
(Array.isArray((item as any).suffixIds) ? (item as any).suffixIds[0] : undefined)
const preList = tables.magicAffixes?.prefixes?.all ?? []
const sufList = tables.magicAffixes?.suffixes?.all ?? []
const preEntry =
typeof preId === 'number' && preId > 0
? (preList[preId - 1] ?? preList.find((p: any) => p.id === preId))
: undefined
const sufEntry =
typeof sufId === 'number' && sufId > 0
? (sufList[sufId - 1] ?? sufList.find((s: any) => s.id === sufId))
: undefined
if (preEntry?.levelreq) extraReqLevel = Math.max(extraReqLevel, preEntry.levelreq)
if (sufEntry?.levelreq) extraReqLevel = Math.max(extraReqLevel, sufEntry.levelreq)
const parts = [preEntry?.name, baseNameEn, sufEntry?.name].filter(Boolean)
const magicName = parts.join(' ') || baseNameEn
itemDisplayNameEn = magicName
rolledMagicAffixes = {
name: magicName,
prefix: preEntry ? { ...preEntry, affix: preEntry, mods: [] } : undefined,
suffix: sufEntry ? { ...sufEntry, affix: sufEntry, mods: [] } : undefined,
prefixes: preEntry ? [preEntry] : [],
suffixes: sufEntry ? [sufEntry] : [],
affixes: [preEntry, sufEntry].filter(Boolean),
}
} else if (qualityStr === 'rare' || qualityStr === 'craft') {
const n1 = item.rareName1
const n2 = item.rareName2
const preTable = tables.rareNames?.prefixes
const sufTable = tables.rareNames?.suffixes
const sufCount = sufTable?.length ?? 155
let preWord: string | undefined
let sufWord: string | undefined
if (typeof n1 === 'number' && n1 > 0 && preTable) {
const entry =
n1 > sufCount
? (preTable.byIndex(n1 - sufCount - 1) ?? preTable.byIndex(n1 - 1) ?? preTable.byIndex(n1))
: (preTable.byIndex(n1 - 1) ?? preTable.byIndex(n1))
preWord = entry?.name
}
if (typeof n2 === 'number' && n2 > 0 && sufTable) {
const entry = sufTable.byIndex(n2 - 1) ?? sufTable.byIndex(n2)
sufWord = entry?.name
}
const rareAffixEntries: any[] = []
const rareSuffixes: any[] = []
const rarePrefixes: any[] = []
if (Array.isArray(item.rareAffixes)) {
const preList = tables.magicAffixes?.prefixes?.all ?? []
const sufList = tables.magicAffixes?.suffixes?.all ?? []
for (let i = 0; i < item.rareAffixes.length; i++) {
const aid = item.rareAffixes[i]
if (typeof aid !== 'number' || aid <= 0) continue
const isPrefixSlot = i % 2 === 0
const list = isPrefixSlot ? preList : sufList
const aff = list[aid - 1] ?? list.find((a: any) => a.id === aid)
if (aff) {
rareAffixEntries.push({ ...aff, affix: aff, mods: [] })
if (isPrefixSlot) rarePrefixes.push(aff)
else rareSuffixes.push(aff)
if (aff.levelreq > 0) extraReqLevel = Math.max(extraReqLevel, aff.levelreq)
}
}
}
const rawRareName = [preWord, sufWord].filter(Boolean).join(' ') || baseNameEn
const resolvedEn = preWord || sufWord ? resolveRareNameEn(rawRareName, preWord, sufWord) : baseNameEn
itemDisplayNameEn = resolvedEn
rolledRareAffixes = {
name: rawRareName,
rarePrefix: preWord ? { name: preWord } : undefined,
rareSuffix: sufWord ? { name: sufWord } : undefined,
prefixes: rarePrefixes,
suffixes: rareSuffixes,
affixes: rareAffixEntries,
}
}
}
const isIndestructible = combinedStats.some(s => s.statId === 152 && s.value > 0)
const maxDur =
item.maxDurability ??
converted.maxDurStat ??
(baseRow?.nodurability || baseRow?.noDurability ? 0 : (baseRow?.durability ?? 0))
const curDur =
item.curDurability ??
item.durability ??
converted.curDurStat ??
maxDur
const hasDur =
!isIndestructible &&
kind !== 'misc' &&
!baseRow?.nodurability &&
!baseRow?.noDurability &&
maxDur > 0
const totalSockets =
item.totalSockets ||
(item as any).sockets ||
converted.socketsStat ||
(socketChildren ? socketChildren.length : 0) ||
(item.socketed ? Math.max(1, item.usedSockets || item.socketedChildCount || 1) : 0)
const usedSockets = socketChildren
? socketChildren.length
: (item.usedSockets ?? item.socketedChildCount ?? 0)
const quantity =
item.quantity ??
converted.quantityStat ??
item.tomeData ??
(item.goldAmount !== undefined ? item.goldAmount : undefined)
// Convert socketed children into domain Items for formatItemTooltip & itemToUiInventoryItem
const childUiItems =
socketChildren && socketChildren.length > 0
? socketChildren.map(ch => onlineItemDataToUiInventoryItem(ch, undefined, tables))
: undefined
const childDomainItems =
childUiItems && childUiItems.length > 0
? childUiItems.map(u => u.rawItem!).filter(Boolean)
: undefined
// Base defense: if item.defense or armorStatDefense is explicitly provided on ItemData, use it as base defense
// (in 1.13c bitstream, item.defense already includes ethereal 1.5x if item is ethereal, whereas
// if item.defense is omitted we use baseRow.maxac || baseRow.minac).
const explicitDefense =
typeof item.defense === 'number' && item.defense > 0 ? item.defense : armorStatDefense
const hasExplicitDefense = typeof explicitDefense === 'number' && explicitDefense > 0
const rawBaseAc = hasExplicitDefense
? (isEthereal ? Math.max(1, Math.ceil(explicitDefense / 1.5)) : explicitDefense)
: (baseRow?.maxac ?? baseRow?.minac ?? baseRow?.defense ?? 0)
const baseObj: ItemBase & Record<string, any> = {
id: code,
code,
name: baseNameEn,
kind,
type: baseRow?.type,
type2: baseRow?.type2,
tags: baseRow?.tags ?? (baseRow?.type ? [baseRow.type] : []),
invWidth: Math.max(1, item.invWidth || baseRow?.invWidth || baseRow?.invwidth || 1),
invHeight: Math.max(1, item.invHeight || baseRow?.invHeight || baseRow?.invheight || 1),
maxStack: baseRow?.maxStack || baseRow?.maxstack || 1,
value: baseRow?.value || baseRow?.cost || 0,
damage: baseRow?.maxdam || baseRow?.damage || 0,
mindam: baseRow?.mindam || 0,
maxdam: baseRow?.maxdam || 0,
twoHandedMindam: baseRow?.twoHandedMindam ?? baseRow?.['2handmindam'] ?? 0,
twoHandedMaxdam: baseRow?.twoHandedMaxdam ?? baseRow?.['2handmaxdam'] ?? 0,
minMisDam: baseRow?.minMisDam ?? baseRow?.minmisdam ?? 0,
maxMisDam: baseRow?.maxMisDam ?? baseRow?.maxmisdam ?? 0,
defense: rawBaseAc,
durability: hasDur ? maxDur : 0,
nodurability: !hasDur,
reqstr: baseRow?.reqstr ?? baseRow?.reqStr ?? 0,
reqdex: baseRow?.reqdex ?? baseRow?.reqDex ?? 0,
levelreq: baseRow?.levelreq ?? baseRow?.reqLevel ?? 0,
level: baseRow?.level ?? 1,
invfile: baseRow?.invfile ?? baseRow?.invFile,
uniqueinvfile: baseRow?.uniqueinvfile,
setinvfile: baseRow?.setinvfile,
flippyfile: baseRow?.flippyfile,
dropsound: baseRow?.dropsound,
dropsfxframe: baseRow?.dropsfxframe,
twoHanded: Boolean(baseRow?.twoHanded || baseRow?.twohanded === '1' || baseRow?.['2handed'] === 1),
oneOrTwoHanded: Boolean(baseRow?.oneOrTwoHanded || baseRow?.['1or2handed'] === '1' || baseRow?.['1or2handed'] === 1),
}
const syntheticItem = {
id: String(item.id ?? item.itemId ?? `item-${code}-${++itemBridgeSeqCounter}`),
code,
name: isUnidentified ? baseNameEn : itemDisplayNameEn,
base: baseObj,
prefix: [],
suffix: [],
invWidth: baseObj.invWidth,
invHeight: baseObj.invHeight,
value: baseObj.value || 1,
quality:
qualityStr === 'low'
? ItemQuality.LOW
: qualityStr === 'superior'
? ItemQuality.SUPERIOR
: qualityStr === 'magic'
? ItemQuality.MAGIC
: qualityStr === 'set'
? ItemQuality.SET
: qualityStr === 'rare'
? ItemQuality.RARE
: qualityStr === 'unique'
? ItemQuality.UNIQUE
: qualityStr === 'craft'
? ItemQuality.CRAFTED
: ItemQuality.NORMAL,
rarity: isRuneword && !isUnidentified ? 'runeword' : qualityStr,
level: item.ilvl ?? baseObj.level ?? 1,
ilvl: item.ilvl ?? baseObj.level ?? 1,
identified: !isUnidentified,
ethereal: isEthereal,
flags: {
identified: !isUnidentified,
ethereal: isEthereal,
socketed: Boolean(item.socketed || totalSockets > 0),
runeword: isRuneword,
},
...(hasDur ? { durability: curDur, maxDurability: maxDur } : {}),
...(totalSockets > 0 ? { sockets: totalSockets, totalSockets, socketedCount: usedSockets } : {}),
...(childDomainItems ? { socketedItems: childDomainItems } : {}),
...(uniqueItemDef ? { uniqueItemDef } : {}),
...(setItemDef ? { setItemDef } : {}),
...(rolledMagicAffixes ? { rolledMagicAffixes } : {}),
...(rolledRareAffixes ? { rolledRareAffixes } : {}),
...(runewordObj ? { runeword: runewordObj, runewordName: runewordObj.name } : {}),
...(extraReqLevel > 0 ? { reqLevel: extraReqLevel } : {}),
...(typeof item.autoAffixId === 'number' ? { autoAffixId: item.autoAffixId } : {}),
...(typeof item.magicPrefixId === 'number' ? { magicPrefixId: item.magicPrefixId } : {}),
...(typeof item.magicSuffixId === 'number' ? { magicSuffixId: item.magicSuffixId } : {}),
...(quantity !== undefined ? { quantity, stack: quantity } : {}),
rolledProps: isUnidentified ? [] : rolledProps,
stats: item.goldAmount !== undefined ? { amount: item.goldAmount } : {},
} as unknown as Item & Record<string, any>
const uiItem = itemToUiInventoryItem(syntheticItem, tables)
let finalNameEn = isUnidentified ? baseNameEn : uiItem.name
let finalNameZh = isUnidentified ? baseNameZh : uiItem.nameZh
if (code === 'gld') {
const goldAmt =
item.goldAmount ??
quantity ??
combinedStats.find(s => s.statId === 14)?.value
if (typeof goldAmt === 'number' && goldAmt > 0) {
finalNameEn = `${goldAmt} Gold`
finalNameZh = `${goldAmt} 金币`
}
}
if (item.personalized && item.personalizedName) {
finalNameEn = `${item.personalizedName}'s ${finalNameEn}`
finalNameZh = `${item.personalizedName}的${finalNameZh}`
}
const dmgText = uiItem.damageEn ?? uiItem.damage
let damageRange: { min: number; max: number } | undefined
let twoHandDamage: { min: number; max: number } | undefined
if (dmgText) {
const matches = [...dmgText.matchAll(/(\d+)\s*(?:-|to)\s*(\d+)/gi)]
if (matches.length >= 2) {
damageRange = { min: Number(matches[0]![1]), max: Number(matches[0]![2]) }
twoHandDamage = { min: Number(matches[1]![1]), max: Number(matches[1]![2]) }
} else if (matches.length === 1) {
const parsed = { min: Number(matches[0]![1]), max: Number(matches[0]![2]) }
if (/two-hand|双手/i.test(dmgText)) {
twoHandDamage = parsed
}
damageRange = parsed
}
}
const hasAcBonus = rolledProps.some(p => p.code === 'ac' || p.code === 'ac%')
const finalDefense =
kind === 'armor' && hasExplicitDefense && !hasAcBonus ? explicitDefense : uiItem.defense
return {
...uiItem,
id: String(item.id ?? item.itemId ?? uiItem.id),
name: finalNameEn,
nameEn: finalNameEn,
nameZh: finalNameZh,
baseName: baseNameEn,
baseNameZh,
baseNameEn,
usedSockets,
...(finalDefense !== undefined ? { defense: finalDefense } : {}),
...(totalSockets > 0 ? { sockets: totalSockets } : {}),
...(childUiItems
? { socketedItems: childUiItems, socketedGems: childUiItems.map(c => c.code) }
: {}),
affixes: (uiItem.statsEn ?? uiItem.stats).map(s => s.text),
...(damageRange !== undefined ? { damageRange } : {}),
...(twoHandDamage !== undefined ? { twoHandDamage } : {}),
}
}
export const itemDataToUiInventoryItem = onlineItemDataToUiInventoryItem

View File

@ -202,7 +202,12 @@ export function formatPropertyCode(
locale?: TooltipLocale,
): string {
if (locale && locale !== 'en') {
return formatPropertyCodeI18n(code, valueOrMin, minOrMax, maxOrPar, maybePar, locale)
const i18nOut = formatPropertyCodeI18n(code, valueOrMin, minOrMax, maxOrPar, maybePar, locale)
const normCode = (code || '').trim().toLowerCase().replace(/^\*/, '')
if (i18nOut.startsWith(`${code}: `) && CANONICAL_ISC_DESC_TABLE[normCode]?.descStrPos) {
return formatStatByIscDescFunc(normCode, valueOrMin, minOrMax ?? valueOrMin, typeof maxOrPar === 'number' ? maxOrPar : valueOrMin, maybePar, locale)
}
return i18nOut
}
let value: number
let min: number
@ -712,10 +717,16 @@ export function formatPropertyCode(
return min > 0 && max > min ? `Adds ${min}-${max} Magic Damage` : `Adds ${effectiveVal} Magic Damage`
case 'dmg-elem':
return min > 0 && max > min ? `Adds ${min}-${max} Elemental Damage` : `Adds ${effectiveVal} Elemental Damage`
default:
default: {
const normCode = code.trim().toLowerCase().replace(/^\*/, '')
const iscEntry = CANONICAL_ISC_DESC_TABLE[normCode]
if (iscEntry && iscEntry.descStrPos && iscEntry.descFunc >= 1 && iscEntry.descFunc <= 21 && iscEntry.descFunc !== 11) {
return formatStatByIscDescFunc(normCode, effectiveVal, min, max, par, 'en')
}
return `${code}: ${plusVal}${par ? ` (${par})` : ''}`
}
}
}
/**
* Canonical 1.13c ItemStatCost.txt descpriority lookup table (0..180).

View File

@ -318,6 +318,7 @@ export function buildSkillCostMap(entries: readonly CanonicalSkillCostEntry[]):
* to their canonical VendorNpcId, Act index (0..4), and available services.
*/
export interface TownNpcServiceDescriptor {
readonly id: string;
readonly hcIdx: number;
readonly npcTxtKey: string;
readonly displayName: string;
@ -328,10 +329,21 @@ export interface TownNpcServiceDescriptor {
readonly canGamble: boolean;
readonly canHeal: boolean;
readonly canIdentify: boolean;
readonly canHire?: boolean | undefined;
readonly travelActions?: readonly ('travel_east' | 'travel_west' | 'travel_harrogath')[] | undefined;
readonly questRewardActions?: readonly ('imbue' | 'add_sockets' | 'personalize')[] | undefined;
readonly greetingKey: string;
readonly greetingLines: readonly string[];
readonly gossipLines?: readonly string[] | undefined;
readonly questLines?: Readonly<Record<string, string>> | undefined;
}
export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
type RawTownNpcServiceDescriptor = Omit<TownNpcServiceDescriptor, 'id' | 'greetingKey'> & {
readonly id?: string | undefined;
readonly greetingKey?: string | undefined;
};
const RAW_TOWN_NPC_DESCRIPTORS: readonly RawTownNpcServiceDescriptor[] = [
// Act 1
{
hcIdx: 148,
@ -348,6 +360,15 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
'I am Akara, High Priestess of the Sisterhood of the Sightless Eye.',
'Welcome back, my friend. We are still clearing the monastery, but you are welcome to rest here.',
],
gossipLines: [
'The Sisterhood of the Sightless Eye has guarded the eastern pass for generations.',
'Deckard Cain is the last of the Horadrim; his counsel is priceless if we can rescue him from Tristram.',
],
questLines: {
den_of_evil: 'There is a place of great evil in the wilderness. Clear the Den of Evil to earn the trust of the Sisters.',
search_for_cain: 'Take the Scroll of Inifuss and touch the Cairn Stones in the Stony Field to open the portal to Tristram.',
sisters_to_the_slaughter: 'Andariel, the Maiden of Anguish, occupies our Cathedral. Destroy her so the eastern road may open.',
},
},
{
hcIdx: 147,
@ -364,6 +385,14 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
'Good day to you, partner! I am Gheed, and I can already tell that I will be your best friend in this forsaken camp.',
'Step right up! Spend some gold or take a chance on a mystery item!',
],
gossipLines: [
'My wagon is loaded with curiosities from across the western kingdoms.',
'Once Warriv opens the caravan route east, I plan to make a fortune in Lut Gholein.',
],
questLines: {
den_of_evil: 'Fewer monsters outside means more customers alive to spend gold at my wagon!',
sisters_to_the_slaughter: 'Defeat Andariel so Warriv and I can finally get this caravan moving east.',
},
},
{
hcIdx: 154,
@ -376,10 +405,19 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
canGamble: false,
canHeal: false,
canIdentify: false,
questRewardActions: ['imbue'],
greetingLines: [
'Hi there! I am Charsi, the blacksmith here in camp.',
'It is good to see some strong adventurers around here. Need your gear forged or repaired?',
],
gossipLines: [
'When we fled the Monastery, I had to leave the Horadric Malus behind in the Barracks.',
'Bring me a normal item after recovering the Malus and I will imbue it with magical power.',
],
questLines: {
tools_of_the_trade: 'If you recover the Horadric Malus from the Smith in the Monastery Barracks, I will imbue an item for you.',
sisters_to_the_slaughter: 'Please help us reclaim our Monastery from Andariel.',
},
},
{
hcIdx: 146,
@ -396,6 +434,37 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
'Stay awhile and listen!',
'Bring me any unidentified items you have found, and I shall reveal their hidden properties.',
],
gossipLines: [
'Diablo, the Lord of Terror, has awakened and marches eastward toward Aranoch.',
'As the last of the Horadrim, I will identify your items without charge for rescuing me from Tristram.',
],
questLines: {
search_for_cain: 'Thank you for freeing me from Griswold and the burning ruins of Tristram.',
sisters_to_the_slaughter: 'Andariel blocks the pass to the East in Diablo\'s name. She must be vanquished.',
},
},
{
hcIdx: 265,
npcTxtKey: 'cain6',
displayName: 'Deckard Cain',
actIndex: 0,
vendorId: VendorNpcId.CAIN,
canTrade: false,
canRepair: false,
canGamble: false,
canHeal: false,
canIdentify: true,
greetingLines: [
'Stay awhile and listen!',
'Thank you for rescuing me from Tristram. Let me identify your items.',
],
gossipLines: [
'The Dark Wanderer who passed through Tristram was none other than Diablo himself.',
],
questLines: {
search_for_cain: 'You have saved my life in Tristram; my Horadric lore is yours to command.',
sisters_to_the_slaughter: 'Seek out Andariel in the Catacombs beneath the Cathedral.',
},
},
// Act 2
{
@ -413,6 +482,13 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
'Greetings, wanderer. I am Fara. I was once a paladin of the Zakarum in the eastern lands.',
'Now I ply my trade as a smith and healer here in Lut Gholein.',
],
gossipLines: [
'I trained with the Zakarum paladins before settling in Lut Gholein to forge armor and heal the wounded.',
],
questLines: {
radaments_lair: 'A foul creature named Radament lurks in the Sewers beneath our city.',
tainted_sun: 'This unnatural eclipse is the work of Claw Vipers in the desert temple.',
},
},
{
hcIdx: 202,
@ -429,6 +505,13 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
'What do you want? Speak up! Mixing potions can be hazardous to one\'s hearing.',
'Buy a potion or move along before something explodes!',
],
gossipLines: [
'Alchemical reagents from the desert are volatile—handle my potions with care!',
],
questLines: {
tainted_sun: 'Even my alchemical lamps cannot pierce this cursed darkness over the desert.',
horadric_staff: 'Only the Horadric Cube can fuse the Staff of Kings and the Viper Amulet.',
},
},
{
hcIdx: 177,
@ -445,6 +528,13 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
'Indeed, dark times have come to Lut Gholein.',
'If you seek arcane knowledge, wands, or staves, examine my wares.',
],
gossipLines: [
'Ancient Vizjerei scrolls speak of Tal Rasha\'s tomb hidden deep in the Canyon of the Magi.',
],
questLines: {
tainted_sun: 'Destroy the Claw Viper altar in the Temple of the Claw Vipers to restore the sun.',
arcane_sanctuary: 'Search beneath the Palace for Horazon\'s Arcane Sanctuary and find the true symbol of Tal Rasha\'s Tomb.',
},
},
{
hcIdx: 199,
@ -461,6 +551,13 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
'Glad to meet you, traveler. I am Elzix, innkeeper and merchant.',
'In my youth I was a bandit in the desert. Care to trade or test your luck?',
],
gossipLines: [
'Every desert caravan eventually passes through my inn. If you have gold, I have rare goods to gamble.',
],
questLines: {
radaments_lair: 'Atma grieves for her family slain by the horror in the Sewers.',
horadric_staff: 'The Halls of the Dead and the Maggot Lair hold the pieces of the Horadric Staff.',
},
},
{
hcIdx: 244,
@ -477,6 +574,13 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
'Stay awhile and listen!',
'The Horadric mysteries run deep in Lut Gholein. Let me identify your artifacts.',
],
gossipLines: [
'The Horadric Cube is one of the greatest treasures ever crafted by our order.',
],
questLines: {
horadric_staff: 'Combine the Shaft of the Horadric Staff with the headpiece inside the Horadric Cube.',
seven_tombs: 'Place the completed Horadric Staff into the orifice inside Tal Rasha\'s true tomb.',
},
},
// Act 3
{
@ -494,6 +598,12 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
'Welcome, stranger. I am Hratli, a sorcerer and smith.',
'My enchantments keep these docks safe, and my anvil keeps your blades sharp.',
],
gossipLines: [
'I wove a protective ward around the Kurast Docks, though it weakens each day.',
],
questLines: {
blade_of_the_old_religion: 'The Gidbinn is a sacred dagger that can strengthen the enchantment protecting our docks.',
},
},
{
hcIdx: 254,
@ -510,6 +620,13 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
'Oh, great. Another interrupting adventurer.',
'If you want potions or wish to gamble away your coin, make it quick.',
],
gossipLines: [
'From the ashes of Ku Y\'leh I can brew an elixir of permanent vitality—if you bring me the Jade Figurine.',
],
questLines: {
golden_bird: 'Bring me the Golden Bird of Ku Y\'leh and I shall distil a potion of life for you.',
lam_esens_tome: 'Somewhere in the ruined temples of Kurast lies Lam Esen\'s Tome.',
},
},
{
hcIdx: 255,
@ -526,6 +643,13 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
'You now speak to Ormus. He was once a great mage, but now lives like a rat in a sinking vessel.',
'Ormus can heal your wounds and trade mystic garments.',
],
gossipLines: [
'Ormus always speaks of himself in the third person, for the jungle has taken much from him.',
],
questLines: {
blade_of_the_old_religion: 'Return the Gidbinn to Ormus so the Skatsim protective ward may endure.',
the_blackened_temple: 'The High Council of Zakarum in Travincal has fallen under Mephisto\'s sway.',
},
},
{
hcIdx: 252,
@ -538,10 +662,17 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
canGamble: false,
canHeal: false,
canIdentify: false,
canHire: true,
greetingLines: [
'Greetings. I am Asheara, commander of the Iron Wolves.',
'Browse my weapons and armor if you are heading into the jungle.',
],
gossipLines: [
'My Iron Wolves are battle-mages trained in fire, ice, and lightning.',
],
questLines: {
blade_of_the_old_religion: 'Recovering the Gidbinn has inspired my Iron Wolves to fight alongside you.',
},
},
{
hcIdx: 245,
@ -558,6 +689,13 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
'Stay awhile and listen!',
'Mephisto\'s corruption has twisted Kurast. Show me any relics you have uncovered.',
],
gossipLines: [
'Only by smashing the Compelling Orb with Khalim\'s Will can you enter the Durance of Hate.',
],
questLines: {
khalims_will: 'Transmute Khalim\'s Eye, Brain, Heart, and Flail in the Horadric Cube to forge Khalim\'s Will.',
the_guardian: 'Defeat Mephisto in the Durance of Hate and step through the portal to the Pandemonium Fortress.',
},
},
// Act 4
{
@ -574,6 +712,12 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
greetingLines: [
'Keep your weapons ready and your armor mended, mortal.',
],
gossipLines: [
'The forge of the Pandemonium Fortress burns with celestial fire.',
],
questLines: {
hellforge: 'Take Mephisto\'s Soulstone to the Hellforge and shatter it with the Hellforge Hammer.',
},
},
{
hcIdx: 405,
@ -589,6 +733,13 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
greetingLines: [
'The Light blesses you, hero. Let me restore your strength and offer my sacred wares.',
],
gossipLines: [
'The High Heavens watch over your struggle against the Prime Evils.',
],
questLines: {
the_fallen_angel: 'Free Izual\'s tormented spirit on the Plains of Despair.',
terrors_end: 'Open the five seals in the Chaos Sanctuary and vanquish Diablo.',
},
},
{
hcIdx: 246,
@ -605,6 +756,13 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
'Stay awhile and listen!',
'Even at the gates of Hell, Horadric wisdom shall guide us.',
],
gossipLines: [
'We stand inside the Pandemonium Fortress on the threshold of the Burning Hells.',
],
questLines: {
hellforge: 'Destroy Mephisto\'s Soulstone upon the Hellforge so his spirit is banished forever.',
terrors_end: 'Diablo awaits within the Chaos Sanctuary.',
},
},
// Act 5
{
@ -622,6 +780,12 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
'Welcome to Harrogath, traveler. I am Malah, healer of this besieged fortress.',
'Let me tend to your wounds or supply you with potions and scrolls.',
],
gossipLines: [
'Anya vanished after confronting Nihlathak; I fear she is trapped in the Frozen River.',
],
questLines: {
prison_of_ice: 'Use my thawing potion to free Anya from her icy prison in the Frozen River.',
},
},
{
hcIdx: 511,
@ -634,10 +798,17 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
canGamble: false,
canHeal: false,
canIdentify: false,
questRewardActions: ['add_sockets'],
greetingLines: [
'What are you looking at? Never seen a Barbarian smith before?',
'Take a look at my steel—finest in all the northern highlands!',
],
gossipLines: [
'Slay Shenk the Overseer to end the siege, and I will add sockets to any piece of equipment you choose.',
],
questLines: {
siege_on_harrogath: 'Defeat Shenk the Overseer in the Bloody Foothills and I will socket an item for you.',
},
},
{
hcIdx: 512,
@ -650,10 +821,18 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
canGamble: true,
canHeal: false,
canIdentify: false,
questRewardActions: ['personalize'],
greetingLines: [
'Thank you for rescuing me from the frozen prison, hero.',
'Please, browse my wares or test your fortune with a gamble.',
],
gossipLines: [
'Nihlathak stole the Relic of the Ancients and betrayed Harrogath to Baal.',
],
questLines: {
prison_of_ice: 'Thank you for freeing me from the Frozen River.',
betrayal_of_harrogath: 'Stop Nihlathak in the Halls of Vaught, and I will inscribe your name upon an item of your choice.',
},
},
{
hcIdx: 514,
@ -670,6 +849,12 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
'Why do you bother me? Harrogath does not need outsiders.',
'If you insist on wasting your gold, I have some curiosities you can gamble on.',
],
gossipLines: [
'Harrogath\'s survival requires hard choices that outsiders could never understand.',
],
questLines: {
siege_on_harrogath: 'Killing Shenk will only anger Baal\'s legions further.',
},
},
{
hcIdx: 520,
@ -686,8 +871,15 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
'Stay awhile and listen!',
'Mount Arreat holds the Worldstone itself. Let me identify your discoveries.',
],
gossipLines: [
'If Baal corrupts the Worldstone, the very fabric of Sanctuary will be endangered.',
],
questLines: {
rite_of_passage: 'Prove your worth to the Ancients at the Arreat Summit to enter the Worldstone Keep.',
eve_of_destruction: 'Stop Baal in the Worldstone Chamber before the Worldstone is lost.',
},
// Non-vendor Town NPCs (Talk / Heal)
},
// Non-vendor Town & Quest NPCs (Talk / Heal / Travel / Hire)
{
hcIdx: 155,
npcTxtKey: 'warriv1',
@ -699,9 +891,38 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
canGamble: false,
canHeal: false,
canIdentify: false,
travelActions: ['travel_east'],
greetingLines: [
'Greetings, stranger. Once the pass to the East is safe, my caravan will take you to Lut Gholein.',
],
gossipLines: [
'Andariel\'s demons have blocked the monastery gates and the road to Lut Gholein.',
],
questLines: {
sisters_to_the_slaughter: 'With Andariel defeated, my caravan is ready to travel east to Lut Gholein whenever you wish.',
},
},
{
hcIdx: 175,
npcTxtKey: 'warriv2',
displayName: 'Warriv',
actIndex: 1,
vendorId: null,
canTrade: false,
canRepair: false,
canGamble: false,
canHeal: false,
canIdentify: false,
travelActions: ['travel_west'],
greetingLines: [
'Good to see you in Lut Gholein. Speak with Jerhyn if you seek passage further east, or travel west with my caravan.',
],
gossipLines: [
'My caravan can take you back west to the Rogue Encampment at any time.',
],
questLines: {
seven_tombs: 'Safe travels across the desert sands, my friend.',
},
},
{
hcIdx: 150,
@ -714,9 +935,16 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
canGamble: false,
canHeal: false,
canIdentify: false,
canHire: true,
greetingLines: [
'My Rogue scouts have suffered heavy losses. Prove your valor in the field, warrior.',
],
gossipLines: [
'Blood Raven was once one of our finest captains at Tristram before Andariel corrupted her.',
],
questLines: {
sisters_burial_grounds: 'Destroy Blood Raven in the Burial Grounds and my Rogue archers will gladly serve you.',
},
},
{
hcIdx: 176,
@ -732,6 +960,12 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
greetingLines: [
'Welcome to my tavern, traveler. Let me tend to your wounds while you rest from the desert sun.',
],
gossipLines: [
'A desiccated horror in the Sewers took the lives of my husband and son.',
],
questLines: {
radaments_lair: 'Avenge my family by slaying Radament in the Sewers, and the merchants of Lut Gholein will lower their prices for you.',
},
},
{
hcIdx: 198,
@ -744,9 +978,16 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
canGamble: false,
canHeal: false,
canIdentify: false,
canHire: true,
greetingLines: [
'My desert mercenaries guard the walls of Lut Gholein day and night.',
],
gossipLines: [
'If you have the coin, my spear-wielding desert guards are the toughest fighters in Aranoch.',
],
questLines: {
radaments_lair: 'Hire one of my guards if you plan to venture down into the Sewers.',
},
},
{
hcIdx: 201,
@ -762,6 +1003,34 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
greetingLines: [
'Welcome to Lut Gholein. Dark forces stir beneath the desert sands.',
],
gossipLines: [
'Demons have erupted from the Harem beneath my palace; Kaelan will grant you entry once Drognan has spoken with me.',
],
questLines: {
arcane_sanctuary: 'Enter the Palace cellar to find the portal to Horazon\'s Arcane Sanctuary.',
seven_tombs: 'You have saved Lut Gholein; I have instructed Meshif to sail you east to Kurast.',
},
},
{
hcIdx: 203,
npcTxtKey: 'kaelan',
displayName: 'Kaelan',
actIndex: 1,
vendorId: null,
canTrade: false,
canRepair: false,
canGamble: false,
canHeal: false,
canIdentify: false,
greetingLines: [
'Halt! Nobody enters the Palace without Lord Jerhyn\'s permission.',
],
gossipLines: [
'The palace guards are on high alert since strange noises began echoing from the Harem below.',
],
questLines: {
arcane_sanctuary: 'Lord Jerhyn has cleared you to enter the Palace and investigate the cellar.',
},
},
{
hcIdx: 210,
@ -774,9 +1043,38 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
canGamble: false,
canHeal: false,
canIdentify: false,
travelActions: ['travel_east'],
greetingLines: [
'Ahoy, traveler! My ship is ready to sail across the Twin Seas whenever your quest demands.',
],
gossipLines: [
'Once Lord Jerhyn gives the word, we can set sail east for the docks of Kurast.',
],
questLines: {
seven_tombs: 'All hands on deck! Speak the word and we sail east to Kurast.',
},
},
{
hcIdx: 258,
npcTxtKey: 'meshif2',
displayName: 'Meshif',
actIndex: 2,
vendorId: null,
canTrade: false,
canRepair: false,
canGamble: false,
canHeal: false,
canIdentify: false,
travelActions: ['travel_west'],
greetingLines: [
'Welcome back aboard, matey! Say the word if you wish to sail back west to Lut Gholein.',
],
gossipLines: [
'I collect green jade figurines—if you ever find a Golden Bird statue, I would trade handsomely for it.',
],
questLines: {
golden_bird: 'A Jade Figurine! Here, take this Golden Bird statue in exchange.',
},
},
{
hcIdx: 200,
@ -792,6 +1090,12 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
greetingLines: [
'What are you looking at? Pour another drink or move along!',
],
gossipLines: [
'No monster in the desert scares me half as much as running out of ale.',
],
questLines: {
radaments_lair: 'The Sewers smell even worse than usual lately.',
},
},
{
hcIdx: 256,
@ -807,6 +1111,55 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
greetingLines: [
'Keep your eyes open in the jungle. The Prime Evils are gathering in Travincal.',
],
gossipLines: [
'My order tracks corrupted mages and demons across Sanctuary.',
],
questLines: {
the_blackened_temple: 'Strike down the corrupted High Council in Travincal before Mephisto\'s plan succeeds.',
},
},
{
hcIdx: 251,
npcTxtKey: 'tyrael2',
displayName: 'Tyrael',
actIndex: 1,
vendorId: null,
canTrade: false,
canRepair: false,
canGamble: false,
canHeal: false,
canIdentify: false,
greetingLines: [
'Well done, mortal. Though Baal is already free, you have prevented Duriel from ambushing others.',
],
gossipLines: [
'Diablo and Baal have sailed east to Kurast to free their brother Mephisto.',
],
questLines: {
seven_tombs: 'Return to Lut Gholein and sail east with Meshif to Kurast at once.',
},
},
{
hcIdx: 259,
npcTxtKey: 'hadriel',
displayName: 'Hadriel',
actIndex: 3,
vendorId: null,
canTrade: false,
canRepair: false,
canGamble: false,
canHeal: false,
canIdentify: false,
greetingLines: [
'Halt, mortal! Beyond lies the Chaos Sanctuary, stronghold of Diablo himself.',
],
gossipLines: [
'Five seals guard the Lord of Terror within the Chaos Sanctuary.',
],
questLines: {
hellforge: 'Ensure Mephisto\'s Soulstone is shattered at the Hellforge.',
terrors_end: 'Proceed into the Chaos Sanctuary and break the five seals to confront Diablo.',
},
},
{
hcIdx: 367,
@ -819,9 +1172,17 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
canGamble: false,
canHeal: false,
canIdentify: false,
travelActions: ['travel_harrogath'],
greetingLines: [
'Hail to you, champion. The hour of judgment upon Diablo is at hand.',
],
gossipLines: [
'Izual was once my most trusted lieutenant before his tragic fall.',
],
questLines: {
the_fallen_angel: 'Thank you for freeing Izual\'s spirit; accept these two skill points as your reward.',
terrors_end: 'Diablo is vanquished! Step through my portal to Harrogath to stop Baal at Mount Arreat.',
},
},
{
hcIdx: 515,
@ -834,15 +1195,116 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
canGamble: false,
canHeal: false,
canIdentify: false,
canHire: true,
greetingLines: [
'Once you free our warriors on the Frigid Highlands, the finest Barbarians will fight by your side.',
],
gossipLines: [
'As senior elder of Harrogath after Nihlathak, I command our fortress defenses.',
],
questLines: {
rescue_on_mount_arreat: 'Free the fifteen Barbarian prisoners in the Frigid Highlands and take these runes: Ral, Ort, and Tal.',
},
},
];
export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] &
Readonly<Record<string, TownNpcServiceDescriptor>> = (() => {
const arr: TownNpcServiceDescriptor[] = RAW_TOWN_NPC_DESCRIPTORS.map(d => ({
...d,
id: d.id ?? d.npcTxtKey,
greetingKey: d.greetingKey ?? d.npcTxtKey,
}));
for (const d of arr) {
Object.defineProperty(arr, d.npcTxtKey, {
value: d,
enumerable: false,
});
}
return Object.freeze(arr) as unknown as readonly TownNpcServiceDescriptor[] &
Readonly<Record<string, TownNpcServiceDescriptor>>;
})();
const HCIDX_ALIASES: Readonly<Record<number, string>> = {
297: 'natalya',
521: 'tyrael1',
534: 'cain1',
};
export const NPC_HCIDX_TO_DESCRIPTOR_ID: Readonly<Record<number, string>> & {
get(hcIdx: number): string | undefined;
has(hcIdx: number): boolean;
} = (() => {
const rec: Record<number, string> = {};
for (const d of TOWN_NPC_DESCRIPTORS) {
rec[d.hcIdx] = d.npcTxtKey;
}
for (const [hcIdx, key] of Object.entries(HCIDX_ALIASES)) {
rec[Number(hcIdx)] = key;
}
Object.defineProperties(rec, {
get: {
value: (hcIdx: number) => rec[hcIdx],
enumerable: false,
},
has: {
value: (hcIdx: number) => Object.prototype.hasOwnProperty.call(rec, hcIdx),
enumerable: false,
},
});
return Object.freeze(rec) as Readonly<Record<number, string>> & {
get(hcIdx: number): string | undefined;
has(hcIdx: number): boolean;
};
})();
export const TOWN_NPC_SERVICES: Readonly<Record<string, TownNpcServiceDescriptor>> = (() => {
const rec: Record<string, TownNpcServiceDescriptor> = {};
for (const d of TOWN_NPC_DESCRIPTORS) {
rec[d.npcTxtKey] = d;
}
if (rec.warriv1) {
rec.warriv = rec.warriv1;
rec.warriv_act1 = rec.warriv1;
}
if (rec.warriv2) {
rec.warriv_act2 = rec.warriv2;
}
if (rec.meshif1) {
rec.meshif = rec.meshif1;
rec.meshif_act2 = rec.meshif1;
}
if (rec.meshif2) {
rec.meshif_act3 = rec.meshif2;
}
if (rec.tyrael1) {
rec.tyrael = rec.tyrael1;
rec.tyrael_act4 = rec.tyrael1;
}
if (rec.tyrael2) {
rec.tyrael_act2 = rec.tyrael2;
}
if (rec['qual-kehk']) {
rec.qual_kehk = rec['qual-kehk'];
}
if (rec.drehya) {
rec.anya = rec.drehya;
}
if (rec.cain1) {
rec.cain = rec.cain1;
}
return Object.freeze(rec);
})();
const DESCRIPTOR_BY_HCIDX = new Map<number, TownNpcServiceDescriptor>(
TOWN_NPC_DESCRIPTORS.map(d => [d.hcIdx, d]),
);
for (const [hcIdx, key] of Object.entries(HCIDX_ALIASES)) {
const target = TOWN_NPC_SERVICES[key];
if (target) {
DESCRIPTOR_BY_HCIDX.set(Number(hcIdx), target);
}
}
const DESCRIPTOR_BY_NAME = new Map<string, TownNpcServiceDescriptor>();
for (const d of TOWN_NPC_DESCRIPTORS) {
@ -870,6 +1332,8 @@ export function getTownNpcDescriptorByName(
return DESCRIPTOR_BY_NAME.get(lower);
}
export const getTownNpcDescriptor = getTownNpcDescriptorByName;
export function getTownNpcDescriptorByVendorId(
vendorId: VendorNpcId,
actIndex?: number,
@ -889,6 +1353,7 @@ const NPC_NAME_TBL_KEYS: Readonly<Record<string, string>> = {
cain3: 'DeckardCain',
cain4: 'DeckardCain',
cain5: 'DeckardCain',
cain6: 'DeckardCain',
'deckard cain': 'DeckardCain',
warriv: 'warriv',
warriv1: 'warriv',
@ -901,6 +1366,7 @@ const NPC_NAME_TBL_KEYS: Readonly<Record<string, string>> = {
atma: 'atma',
greiz: 'greiz',
jerhyn: 'jerhyn',
kaelan: 'kaelan',
meshif: 'meshif',
meshif1: 'meshif',
meshif2: 'meshif',
@ -912,6 +1378,7 @@ const NPC_NAME_TBL_KEYS: Readonly<Record<string, string>> = {
natalya: 'natalya',
halbu: 'halbu',
jamella: 'jamella',
hadriel: 'hadriel',
tyrael: 'tyrael',
tyrael1: 'tyrael',
tyrael2: 'tyrael',
@ -956,6 +1423,10 @@ const NPC_GREETING_ZH_BY_KEY: Readonly<Record<string, readonly string[]>> = {
'请留步,听我一言!',
'亚瑞特山脉守护着世界之石。让我为你辨识你发现的宝物。',
],
cain6: [
'请留步,听我一言!',
'感谢你从崔斯特瑞姆救出我。让我为你辨识物品吧。',
],
fara: [
'你好,流浪者。我是法拉,曾经是东方萨卡兰姆的圣骑士。',
'如今我在鲁·高因以铁匠与治疗者的身份维生。',
@ -1007,14 +1478,19 @@ const NPC_GREETING_ZH_BY_KEY: Readonly<Record<string, readonly string[]>> = {
'如果你非要浪费金币,我这里倒有一些古怪玩意儿可以让你赌一把。',
],
warriv1: ['你好,陌生人。一旦通往东方的关隘安全了,我的商队就会带你前往鲁·高因。'],
warriv2: ['很高兴在鲁·高因见到你。我的商队随时可以带你向西返回罗格营地。'],
kashya: ['我的罗格斥候伤亡惨重。在战场上证明你的勇气吧,战士。'],
atma: ['欢迎来到我的酒馆,旅行者。在躲避沙漠烈日的同时,让我为你治愈伤口吧。'],
greiz: ['我的沙漠佣兵日夜守卫着鲁·高因的城墙。'],
jerhyn: ['欢迎来到鲁·高因。黑暗力量正在沙漠深处蠢蠢欲动。'],
kaelan: ['站住!没有杰海因领主的许可,任何人不得进入王宫。'],
meshif1: ['嘿,旅行者!只要你的使命需要,我的帆船随时可以横渡双子海。'],
meshif2: ['欢迎再次登船,伙伴!如果你想向西航行返回鲁·高因,随时告诉我。'],
geglash: ['看什么看?要么再来一杯,要么赶紧走开!'],
natalya: ['在丛林里睁大眼睛。三大魔神正在崔凡克集结。'],
tyrael1: ['向你致敬,勇士。审判迪亚波罗的时刻已经到来。'],
tyrael2: ['干得好,凡人。快返回鲁·高因,乘马席夫的船前往库拉斯特。'],
hadriel: ['止步,凡人!前方就是迪亚波罗盘踞的混沌避难所。'],
'qual-kehk': ['一旦你解救了冰冻高地上的战士,最精锐的野蛮人将与你并肩作战。'],
};
@ -1028,6 +1504,7 @@ const NPC_NAME_ZH_BY_KEY: Readonly<Record<string, string>> = {
cain3: '迪卡·凯恩',
cain4: '迪卡·凯恩',
cain5: '迪卡·凯恩',
cain6: '迪卡·凯恩',
'deckard cain': '迪卡·凯恩',
warriv: '瓦瑞夫',
warriv1: '瓦瑞夫',
@ -1040,6 +1517,7 @@ const NPC_NAME_ZH_BY_KEY: Readonly<Record<string, string>> = {
atma: '亚特玛',
greiz: '格雷兹',
jerhyn: '杰海因',
kaelan: '凯兰',
meshif: '马席夫',
meshif1: '马席夫',
meshif2: '马席夫',
@ -1051,6 +1529,7 @@ const NPC_NAME_ZH_BY_KEY: Readonly<Record<string, string>> = {
natalya: '娜塔亚',
halbu: '海尔布',
jamella: '贾梅拉',
hadriel: '哈德瑞尔',
tyrael: '泰瑞尔',
tyrael1: '泰瑞尔',
tyrael2: '泰瑞尔',

View File

@ -33,6 +33,10 @@ export function encodeC2sWaypointGo(waypointId: number, destAreaId: number): Uin
* - 0x18 Transmute: Horadric Cube transmute, amount 0.
*/
export const ClickButtonId = {
TradeAccept: 0x02,
TradeCancel: 0x02,
TradeOk: 0x04,
TradeUnaccept: 0x07,
TradeGold: 0x08,
CloseStash: 0x12,
WithdrawGold: 0x13,
@ -41,13 +45,9 @@ export const ClickButtonId = {
} as const
/**
* 0x4F ClickButton (7B): u8 0x4F | u16 buttonId | u16 amountHigh | u16 amountLow.
*
* D2Game.dll 0x6fcf1470 rebuilds the 32-bit amount as `(WORD[3] << 16) | WORD[5]`; D2Client's gold
* dialog OK handler (0x6fb6fdf0..0x6fb6fe79) passes the high word in dx and the low word on the stack.
* The high word therefore precedes the low word on the wire.
* 0x4F ClickButton (7B): u8 0x4F | u16 buttonId | u16 ((amount >>> 16) & 0xffff) | u16 (amount & 0xffff).
*/
export function encodeC2sClickButton(buttonId: number, amount: number): Uint8Array {
export function encodeC2sClickButton(buttonId: number, amount = 0): Uint8Array {
if (!Number.isInteger(buttonId) || buttonId < 0 || buttonId > 0xffff) {
throw new RangeError(`ClickButton: invalid buttonId ${buttonId}`)
}
@ -71,3 +71,6 @@ export function encodeC2sResurrect(): Uint8Array {
export function encodeC2sSwapWeapons(): Uint8Array {
return validateC2sPacket(new Uint8Array([0x60]))
}
export { encodeC2sWarpTo } from './movement.ts'

View File

@ -80,6 +80,25 @@ export function encodeC2sItemSwap(itemId: number, slot: number): Uint8Array {
return validateC2sPacket(w.toUint8Array())
}
export const encodeC2sSwapBodyItem = encodeC2sItemSwap
export const encodeC2sSwapEquippedItem = encodeC2sItemSwap
/** 0x1F SwapContainerItem (17B): u8 0x1F | u32 cursorItemId | u32 targetItemId | u32 x | u32 y */
export function encodeC2sSwapContainerItem(
cursorItemId: number,
targetItemId: number,
x: number,
y: number,
): Uint8Array {
const w = new ByteWriter(17)
w.u8(0x1f)
w.u32LE(cursorItemId >>> 0)
w.u32LE(targetItemId >>> 0)
w.u32LE(x >>> 0)
w.u32LE(y >>> 0)
return validateC2sPacket(w.toUint8Array())
}
/** 0x20 UseInventoryItem (13B): u8 0x20 | u32 itemId | u32 x | u32 y */
export function encodeC2sItemUse(itemId: number, at: Pt): Uint8Array {
const w = new ByteWriter(13)
@ -107,8 +126,17 @@ export function encodeC2sBeltRemove(itemId: number): Uint8Array {
return validateC2sPacket(w.toUint8Array())
}
/** 0x25 SwapBeltItem (9B): u8 0x25 | u32 cursorItemId | u32 beltItemId */
export function encodeC2sBeltSwap(cursorItemId: number, beltItemId: number): Uint8Array {
const w = new ByteWriter(9)
w.u8(0x25)
w.u32LE(cursorItemId >>> 0)
w.u32LE(beltItemId >>> 0)
return validateC2sPacket(w.toUint8Array())
}
/** 0x26 UseBeltItem (13B): u8 0x26 | u32 itemId | u32 shift | u32 0 */
export function encodeC2sBeltUse(itemId: number, shift = false): Uint8Array {
export function encodeC2sBeltUse(itemId: number, shift: boolean | number = false): Uint8Array {
const w = new ByteWriter(13)
w.u8(0x26)
w.u32LE(itemId >>> 0)
@ -134,3 +162,21 @@ export function encodeC2sSocketItem(itemId: number, targetItemId: number): Uint8
w.u32LE(targetItemId >>> 0)
return validateC2sPacket(w.toUint8Array())
}
/** 0x29 ScrollToBook (9B): u8 0x29 | u32 scrollId | u32 bookId */
export function encodeC2sScrollToBook(scrollId: number, bookId: number): Uint8Array {
const w = new ByteWriter(9)
w.u8(0x29)
w.u32LE(scrollId >>> 0)
w.u32LE(bookId >>> 0)
return validateC2sPacket(w.toUint8Array())
}
/** 0x61 MercItem (3B): u8 0x61 | u16 slot */
export function encodeC2sMercItem(slot: number): Uint8Array {
const w = new ByteWriter(3)
w.u8(0x61)
w.u16LE(slot & 0xffff)
return validateC2sPacket(w.toUint8Array())
}

View File

@ -23,3 +23,13 @@ export function encodeC2sMoveToUnit(run: boolean, target: UnitRef): Uint8Array {
w.u32LE(target.id >>> 0)
return validateC2sPacket(w.toUint8Array())
}
/** 0x09 WarpTo (9B): u8 0x09 | u32 unitType (5 = tile) | u32 warpId */
export function encodeC2sWarpTo(warpId: number, unitType = 5): Uint8Array {
const w = new ByteWriter(9)
w.u8(0x09)
w.u32LE(unitType >>> 0)
w.u32LE(warpId >>> 0)
return validateC2sPacket(w.toUint8Array())
}

View File

@ -16,11 +16,22 @@ export function encodeC2sNpcInit(target: UnitRef): Uint8Array {
}
/** 0x30 TerminateEntityChat (9B): u8 0x30 | u32 unitType (1) | u32 npcId */
export function encodeC2sNpcCancel(target: UnitRef): Uint8Array {
export function encodeC2sNpcCancel(target: UnitRef | number): Uint8Array {
const unitType = typeof target === 'number' ? 1 : unitTypeToId(target.unitType)
const id = typeof target === 'number' ? target : target.id
const w = new ByteWriter(9)
w.u8(0x30)
w.u32LE(unitTypeToId(target.unitType) >>> 0)
w.u32LE(target.id >>> 0)
w.u32LE(unitType >>> 0)
w.u32LE(id >>> 0)
return validateC2sPacket(w.toUint8Array())
}
/** 0x31 QuestMessage (9B): u8 0x31 | u32 npcId | u32 messageId */
export function encodeC2sQuestMessage(npcId: number, messageId: number): Uint8Array {
const w = new ByteWriter(9)
w.u8(0x31)
w.u32LE(npcId >>> 0)
w.u32LE(messageId >>> 0)
return validateC2sPacket(w.toUint8Array())
}
@ -145,6 +156,8 @@ export function encodeC2sHireMerc(npcId: number, mercId: number): Uint8Array {
return validateC2sPacket(w.toUint8Array())
}
export const encodeC2sNpcMercHire = encodeC2sHireMerc
/**
* 0x38 EntityAction action values handled by D2Game.dll 0x6fce00c0 (via C2S handler 0x6fcefb30):
* - 1 Trade: vendor classes -> 0x6fcdf810(gamble = 0) fills the shop.
@ -156,10 +169,11 @@ export const NpcActionId = {
Trade: 1,
Gamble: 2,
Hire: 3,
OpenHireList: 3,
} as const
/** 0x38 EntityAction (13B): u8 0x38 | u32 action | u32 npcId | u32 complement */
export function encodeC2sNpcEntityAction(action: number, npcId: number, complement: number): Uint8Array {
export function encodeC2sNpcEntityAction(action: number, npcId: number, complement = 0): Uint8Array {
const w = new ByteWriter(13)
w.u8(0x38)
w.u32LE(requireU32('NpcEntityAction.action', action))
@ -175,3 +189,6 @@ export function encodeC2sNpcResurrectMerc(npcId: number): Uint8Array {
w.u32LE(npcId >>> 0)
return validateC2sPacket(w.toUint8Array())
}
export const encodeC2sNpcMercResurrect = encodeC2sNpcResurrectMerc

View File

@ -6,10 +6,10 @@ import { ByteWriter } from '../../core/byte-writer.ts'
import { validateC2sPacket } from '../framing-c2s.ts'
/** 0x5D SetPlayerRelationship (7B): u8 0x5D | u8 button | u8 state | u32 targetPlayerId */
export function encodeC2sHostile(targetPlayerId: number, hostile: boolean): Uint8Array {
export function encodeC2sHostile(targetPlayerId: number, hostile: boolean, button = 0x01): Uint8Array {
const w = new ByteWriter(7)
w.u8(0x5d)
w.u8(0x04) // hostility button
w.u8(button & 0xff)
w.u8(hostile ? 0x01 : 0x00)
w.u32LE(targetPlayerId >>> 0)
return validateC2sPacket(w.toUint8Array())
@ -23,3 +23,6 @@ export function encodeC2sParty(action: number, targetPlayerId: number): Uint8Arr
w.u32LE(targetPlayerId >>> 0)
return validateC2sPacket(w.toUint8Array())
}
export const encodeC2sPartyAction = encodeC2sParty

View File

@ -66,7 +66,9 @@ export function resolveC2sPacketSize(buf: Uint8Array, offset = 0): number | null
* Validates that a serialized C->S packet matches `C2S_PACKET_SIZES` (`D2Net.dll` `0x6fbfabd8`).
* Throws `ProtocolError` if the opcode is invalid or the byte length does not match.
*/
export function validateC2sPacket(bytes: Uint8Array): Uint8Array {
export function validateC2sPacket(
bytes: Uint8Array,
): Uint8Array & { readonly valid: true; readonly opcode: number; readonly expectedLength: number } {
if (bytes.length < 1) {
throw new ProtocolError('Empty D2GS C->S packet')
}
@ -80,7 +82,7 @@ export function validateC2sPacket(bytes: Uint8Array): Uint8Array {
0,
)
}
return bytes
return Object.assign(bytes, { valid: true as const, opcode, expectedLength: expected })
}
function findZeroByte(buf: Uint8Array, start: number, limit: number): number {

View File

@ -19,6 +19,7 @@ import {
import {
encodeC2sBeltPut,
encodeC2sBeltRemove,
encodeC2sBeltSwap,
encodeC2sBeltUse,
encodeC2sIdentifyItem,
encodeC2sItemDrop,
@ -30,9 +31,12 @@ import {
encodeC2sItemToCursor,
encodeC2sItemUnequip,
encodeC2sItemUse,
encodeC2sMercItem,
encodeC2sScrollToBook,
encodeC2sSocketItem,
encodeC2sSwapContainerItem,
} from './c2s/items.ts'
import { encodeC2sMove, encodeC2sMoveToUnit } from './c2s/movement.ts'
import { encodeC2sMove, encodeC2sMoveToUnit, encodeC2sWarpTo } from './c2s/movement.ts'
import {
encodeC2sHireMerc,
encodeC2sNpcBuy,
@ -45,6 +49,7 @@ import {
NpcActionId,
encodeC2sNpcResurrectMerc,
encodeC2sNpcSell,
encodeC2sQuestMessage,
} from './c2s/npc.ts'
import { encodeC2sHostile, encodeC2sParty } from './c2s/party.ts'
import { encodeC2sLeaveGame, encodeC2sPing } from './c2s/session.ts'
@ -362,11 +367,25 @@ export const D2GS_S2C_REGISTRY: Readonly<Record<number, D2gsS2cRegistryEntry>> =
0xb4: { opcode: 0xb4, name: 'TimeoutReason', d2ClientHandlerVa: '0x6fb5cbf0', decode: decode0xB4TimeoutReason },
}
const DEFAULT_EMPTY_TABLES: ItemDataTables = {
itemStatCost: new Map(),
armor: new Map(),
weapons: new Map(),
misc: new Map(),
getStatMeta: () => undefined,
getStatCost: () => undefined,
getItemBaseMeta: () => undefined,
getItemMeta: () => undefined,
}
/**
* Decode a single framed D2GS S->C packet into a strongly-typed `ServerEvent`.
* Fails fast with `ProtocolError` if opcode is unknown or length is mismatched.
*/
export function decodeD2gsS2cPacket(pkt: Uint8Array, ctx: D2gsDecodeContext): ServerEvent {
export function decodeD2gsS2cPacket(
pkt: Uint8Array,
ctx: D2gsDecodeContext = { tables: DEFAULT_EMPTY_TABLES },
): ServerEvent {
if (pkt.byteLength === 0) {
throw new ProtocolError('Empty D2GS S2C packet', { proto: 'd2gs', packetId: -1 })
}
@ -436,7 +455,9 @@ export function encodeD2gsClientCommand(
case 'OperateObject':
return [encodeC2sInteract({ unitType: 2, id: cmd.objectId })]
case 'TakeWarp':
return [encodeC2sInteract({ unitType: 5, id: cmd.warpId })]
return [encodeC2sWarpTo(cmd.warpId, 5)]
case 'WarpTo':
return [encodeC2sWarpTo(cmd.warpId, cmd.unitType ?? 5)]
case 'ItemPickup':
case 'PickUpItem':
return [encodeC2sItemPickup(cmd.itemId, cmd.toCursor)]
@ -454,26 +475,40 @@ export function encodeD2gsClientCommand(
return [encodeC2sItemUnequip(cmd.slot)]
case 'ItemSwap':
return [encodeC2sItemSwap(cmd.itemId, cmd.slot)]
case 'SwapContainerItem':
return [encodeC2sSwapContainerItem(cmd.cursorItemId, cmd.targetItemId ?? cmd.replacedItemId ?? 0, cmd.x, cmd.y)]
case 'BeltPut':
return [encodeC2sBeltPut(cmd.itemId, cmd.slot)]
case 'BeltRemove':
return [encodeC2sBeltRemove(cmd.itemId)]
case 'BeltSwap':
return [encodeC2sBeltSwap(cmd.cursorItemId, cmd.beltItemId)]
case 'BeltUse':
return [encodeC2sBeltUse(cmd.itemId, cmd.shift)]
return [encodeC2sBeltUse(cmd.itemId, Boolean(cmd.shift ?? cmd.useOnMerc))]
case 'ItemUse':
return [encodeC2sItemUse(cmd.itemId, cmd.at)]
case 'SocketItem':
return [encodeC2sSocketItem(cmd.itemId, cmd.targetItemId)]
case 'IdentifyItem':
return [encodeC2sIdentifyItem(cmd.itemId, cmd.scrollId)]
case 'ScrollToBook':
return [encodeC2sScrollToBook(cmd.scrollId, cmd.bookId)]
case 'MercItem':
return [encodeC2sMercItem(cmd.slot)]
case 'NpcInit':
return [encodeC2sNpcInit(cmd.target)]
case 'NpcCancel':
return [encodeC2sNpcCancel(cmd.target)]
return [encodeC2sNpcCancel(cmd.target ?? { unitType: 1, id: cmd.npcId ?? 0 })]
case 'QuestMessage':
return [encodeC2sQuestMessage(cmd.npcId, cmd.messageId)]
case 'NpcOpenTrade':
return [encodeC2sNpcEntityAction(NpcActionId.Trade, cmd.npcId, 0)]
case 'NpcOpenGamble':
return [encodeC2sNpcEntityAction(NpcActionId.Gamble, cmd.npcId, 0)]
case 'NpcEntityAction':
return [encodeC2sNpcEntityAction(cmd.action, cmd.npcId, cmd.complement ?? 0)]
case 'NpcAction':
return [encodeC2sNpcEntityAction(cmd.action, cmd.npcId, cmd.complement ?? cmd.param ?? 0)]
case 'NpcBuy':
return [encodeC2sNpcBuy(cmd)]
case 'NpcSell':
@ -487,8 +522,10 @@ export function encodeD2gsClientCommand(
case 'NpcIdentifyAll':
return [encodeC2sNpcIdentifyAll(cmd.npcId)]
case 'NpcResurrectMerc':
case 'MercResurrect':
return [encodeC2sNpcResurrectMerc(cmd.npcId)]
case 'HireMerc':
case 'MercHire':
return [encodeC2sHireMerc(cmd.npcId, cmd.mercId)]
case 'AddStatPoint':
return [encodeC2sAddStatPoint(cmd.statId, cmd.count)]
@ -498,12 +535,23 @@ export function encodeD2gsClientCommand(
return [encodeC2sWaypointGo(cmd.waypointId, cmd.destAreaId)]
case 'TownPortal':
return [encodeC2sSkillAt('right', { x: 0, y: 0 })]
case 'ClickButton':
return [encodeC2sClickButton(cmd.buttonId, cmd.amount)]
case 'ClickButton': {
const btn = cmd.button ?? cmd.buttonId ?? 0
if (cmd.complement !== undefined && cmd.amount === undefined) {
if (!Number.isInteger(cmd.complement) || cmd.complement < 0 || cmd.complement > 0xffffffff) {
throw new RangeError(`ClickButton: invalid amount ${cmd.complement}`)
}
const comp = cmd.complement >>> 0
const swapped = (((comp & 0xffff) << 16) | ((comp >>> 16) & 0xffff)) >>> 0
return [encodeC2sClickButton(btn, swapped)]
}
return [encodeC2sClickButton(btn, cmd.amount ?? cmd.complement ?? 0)]
}
case 'Party':
case 'PartyAction':
return [encodeC2sParty(cmd.action, cmd.targetPlayerId)]
case 'Hostile':
return [encodeC2sHostile(cmd.targetPlayerId, cmd.hostile)]
return [encodeC2sHostile(cmd.targetPlayerId, cmd.hostile, cmd.button ?? 0x01)]
case 'Trade':
return [encodeC2sTrade(cmd.action, cmd.gold)]
case 'Chat':
@ -520,3 +568,7 @@ export function encodeD2gsClientCommand(
return [encodeC2sLeaveGame()]
}
}
export const encodeClientCommand = encodeD2gsClientCommand
export const decodeD2gsServerPacket = decodeD2gsS2cPacket

View File

@ -22,12 +22,13 @@ export function decode0x77ButtonActions(pkt: Uint8Array): ServerEvent {
export function decode0x78TradeAccepted(pkt: Uint8Array): ServerEvent {
const r = new ByteReader(pkt)
r.u8() // 0x78
r.fixedCString(16) // partnerName
const partnerName = r.fixedCString(16)
const partnerId = r.u32LE()
return {
type: 'Trade',
action: 0x78,
partnerId,
partnerName,
}
}

View File

@ -29,6 +29,7 @@ export function decode0x09AssignWarp(pkt: Uint8Array): ServerEvent {
unitType,
id,
classId: warpId,
warpId,
x,
y,
},

View File

@ -26,6 +26,7 @@ export type ClientCommand =
| { readonly type: 'Interact'; readonly target: UnitRef }
| { readonly type: 'OperateObject'; readonly objectId: number }
| { readonly type: 'TakeWarp'; readonly warpId: number; readonly at?: Pt | undefined }
| { readonly type: 'WarpTo'; readonly warpId: number; readonly unitType?: number | undefined }
| { readonly type: 'ItemPickup'; readonly itemId: number; readonly toCursor?: boolean | undefined }
| { readonly type: 'PickUpItem'; readonly itemId: number; readonly toCursor?: boolean | undefined }
| { readonly type: 'ItemDrop'; readonly itemId: number }
@ -42,18 +43,52 @@ export type ClientCommand =
| { readonly type: 'ItemEquip'; readonly itemId: number; readonly slot: number }
| { readonly type: 'ItemUnequip'; readonly slot: number }
| { readonly type: 'ItemSwap'; readonly itemId: number; readonly slot: number }
| {
readonly type: 'SwapContainerItem'
readonly cursorItemId: number
readonly targetItemId?: number | undefined
readonly replacedItemId?: number | undefined
readonly x: number
readonly y: number
}
| { readonly type: 'BeltPut'; readonly itemId: number; readonly slot: number }
| { readonly type: 'BeltRemove'; readonly itemId: number }
| { readonly type: 'BeltUse'; readonly itemId: number; readonly shift?: boolean | undefined }
| { readonly type: 'BeltSwap'; readonly cursorItemId: number; readonly beltItemId: number }
| {
readonly type: 'BeltUse'
readonly itemId: number
readonly shift?: boolean | undefined
readonly useOnMerc?: number | boolean | undefined
}
| { readonly type: 'ItemUse'; readonly itemId: number; readonly at: Pt }
| { readonly type: 'SocketItem'; readonly itemId: number; readonly targetItemId: number }
| { readonly type: 'IdentifyItem'; readonly itemId: number; readonly scrollId: number }
| { readonly type: 'ScrollToBook'; readonly scrollId: number; readonly bookId: number }
| { readonly type: 'MercItem'; readonly slot: number }
| { readonly type: 'NpcInit'; readonly target: UnitRef }
| { readonly type: 'NpcCancel'; readonly target: UnitRef }
| {
readonly type: 'NpcCancel'
readonly target?: UnitRef | undefined
readonly npcId?: number | undefined
}
| { readonly type: 'QuestMessage'; readonly npcId: number; readonly messageId: number }
/** 0x38 action 1: open the vendor trade window (server fills the shop with 0x9C AddToShop). */
| { readonly type: 'NpcOpenTrade'; readonly npcId: number }
/** 0x38 action 2: open the gamble window. */
| { readonly type: 'NpcOpenGamble'; readonly npcId: number }
| {
readonly type: 'NpcEntityAction'
readonly action: number
readonly npcId: number
readonly complement?: number | undefined
}
| {
readonly type: 'NpcAction'
readonly action: number
readonly npcId: number
readonly param?: number | undefined
readonly complement?: number | undefined
}
| {
/** 0x32 BuyItem; field3 = (itemMode << 16) | (gamble ? 2 : 0) | (shiftFill ? 0x80000000 : 0). */
readonly type: 'NpcBuy'
@ -85,15 +120,29 @@ export type ClientCommand =
| { readonly type: 'NpcHeal'; readonly npcId: number }
| { readonly type: 'NpcIdentifyAll'; readonly npcId: number }
| { readonly type: 'NpcResurrectMerc'; readonly npcId: number }
| { readonly type: 'MercResurrect'; readonly npcId: number }
| { readonly type: 'HireMerc'; readonly npcId: number; readonly mercId: number }
| { readonly type: 'MercHire'; readonly npcId: number; readonly mercId: number }
| { readonly type: 'AddStatPoint'; readonly statId: number; readonly count?: number | undefined }
| { readonly type: 'AddSkillPoint'; readonly skillId: number }
| { readonly type: 'WaypointGo'; readonly waypointId: number; readonly destAreaId: number }
| { readonly type: 'TownPortal' }
/** 0x4F ClickButton: see `ClickButtonId` (0x12 close stash, 0x13 withdraw, 0x14 deposit, 0x18 transmute). */
| { readonly type: 'ClickButton'; readonly buttonId: number; readonly amount: number }
| {
readonly type: 'ClickButton'
readonly buttonId?: number | undefined
readonly button?: number | undefined
readonly amount?: number | undefined
readonly complement?: number | undefined
}
| { readonly type: 'Party'; readonly action: number; readonly targetPlayerId: number }
| { readonly type: 'Hostile'; readonly targetPlayerId: number; readonly hostile: boolean }
| { readonly type: 'PartyAction'; readonly action: number; readonly targetPlayerId: number }
| {
readonly type: 'Hostile'
readonly targetPlayerId: number
readonly hostile: boolean
readonly button?: number | undefined
}
| { readonly type: 'Trade'; readonly action: number; readonly gold: number }
| { readonly type: 'Chat'; readonly text: string; readonly whisperTo?: string | undefined }
| { readonly type: 'OverheadChat'; readonly text: string }
@ -101,3 +150,4 @@ export type ClientCommand =
| { readonly type: 'SwapWeapons' }
| { readonly type: 'Ping' }
| { readonly type: 'LeaveGame' }

View File

@ -83,6 +83,7 @@ export type ServerEvent =
readonly type: 'SkillCast'
readonly caster: UnitRef
readonly skill: number
readonly skillId?: number | undefined
readonly level: number
readonly target: UnitRef | Pt
}
@ -183,6 +184,7 @@ export type ServerEvent =
readonly type: 'Trade'
readonly action: number
readonly partnerId?: number | undefined
readonly partnerName?: string | undefined
readonly gold?: number | undefined
}
| {

View File

@ -121,12 +121,90 @@ export { BNCS_KEEPALIVE_INTERVAL_MS } from './bncs/session.ts'
export { McpFramer } from './mcp/framing.ts'
export { decodeMcpS2cPacket } from './mcp/packets.ts'
export { validateC2sPacket } from './d2gs/framing-c2s.ts'
export { ClickButtonId } from './d2gs/c2s/interact.ts'
export { NpcActionId, NPC_BUY_FLAG_GAMBLE, NPC_BUY_FLAG_SHIFT_FILL, NPC_REPAIR_ALL_FLAG } from './d2gs/c2s/npc.ts'
export { C2S_PACKET_SIZES } from './d2gs/tables/s2c-sizes.ts'
export { encodeC2sChat, encodeC2sOverheadChat } from './d2gs/c2s/chat.ts'
export {
ClickButtonId,
encodeC2sClickButton,
encodeC2sInteract,
encodeC2sResurrect,
encodeC2sSwapWeapons,
encodeC2sWaypointGo,
} from './d2gs/c2s/interact.ts'
export {
encodeC2sBeltPut,
encodeC2sBeltRemove,
encodeC2sBeltSwap,
encodeC2sBeltUse,
encodeC2sDropGold,
encodeC2sIdentifyItem,
encodeC2sItemDrop,
encodeC2sItemEquip,
encodeC2sItemPickup,
encodeC2sItemPlace,
encodeC2sItemSwap,
encodeC2sItemToCursor,
encodeC2sItemUnequip,
encodeC2sItemUse,
encodeC2sMercItem,
encodeC2sScrollToBook,
encodeC2sSocketItem,
encodeC2sSwapBodyItem,
encodeC2sSwapContainerItem,
encodeC2sSwapEquippedItem,
} from './d2gs/c2s/items.ts'
export {
encodeC2sMove,
encodeC2sMoveToUnit,
encodeC2sWarpTo,
} from './d2gs/c2s/movement.ts'
export {
NPC_BUY_FLAG_GAMBLE,
NPC_BUY_FLAG_SHIFT_FILL,
NPC_REPAIR_ALL_FLAG,
NpcActionId,
encodeC2sHireMerc,
encodeC2sNpcBuy,
encodeC2sNpcCancel,
encodeC2sNpcEntityAction,
encodeC2sNpcIdentifyAll,
encodeC2sNpcInit,
encodeC2sNpcMercHire,
encodeC2sNpcMercResurrect,
encodeC2sNpcRepairAll,
encodeC2sNpcRepairItem,
encodeC2sNpcResurrectMerc,
encodeC2sNpcSell,
encodeC2sQuestMessage,
type NpcBuyParams,
type NpcRepairItemParams,
type NpcSellParams,
} from './d2gs/c2s/npc.ts'
export {
encodeC2sHostile,
encodeC2sParty,
encodeC2sPartyAction,
} from './d2gs/c2s/party.ts'
export {
encodeC2sEnterGame,
encodeC2sGameLogon,
encodeC2sLeaveGame,
encodeC2sPing,
type D2gsGameLogonParams,
} from './d2gs/c2s/session.ts'
export {
encodeC2sAddSkillPoint,
encodeC2sAddStatPoint,
encodeC2sSelectSkill,
encodeC2sSkillAt,
encodeC2sSkillOnUnit,
} from './d2gs/c2s/skills.ts'
export { encodeC2sTrade } from './d2gs/c2s/trade.ts'
export { C2S_PACKET_SIZES, S2C_PACKET_SIZES } from './d2gs/tables/s2c-sizes.ts'
export {
D2GS_S2C_REGISTRY,
decodeD2gsS2cPacket,
decodeD2gsServerPacket,
encodeClientCommand,
encodeD2gsClientCommand,
type D2gsDecodeContext,
type D2gsS2cRegistryEntry,

View File

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { ClientWorld } from '../../src/client/world/client-world.ts'
import type { ItemData } from '../../src/netproto/index.ts'
import type { ClientCommand, ItemData } from '../../src/netproto/index.ts'
function makeItem(overrides: Partial<ItemData> & { id: number; code: string }): ItemData {
return {
@ -53,6 +53,7 @@ function makeItem(overrides: Partial<ItemData> & { id: number; code: string }):
durability: overrides.durability,
maxDurability: overrides.maxDurability,
quantity: overrides.quantity,
goldAmount: overrides.goldAmount,
stats: overrides.stats ?? [],
setBonusStats: overrides.setBonusStats ?? [],
runewordStats: overrides.runewordStats ?? [],
@ -321,4 +322,360 @@ describe('ClientWorld authoritative state mirror', () => {
world.apply({ type: 'Pong', rttMs: 42, tickCount: 1000 })
expect(world.lastRttMs).toBe(42)
})
it('encodes all 18 M1 C2S opcodes matching C2S_PACKET_SIZES and encodeClientCommand', async () => {
const netproto = await import('../../src/netproto/index.ts')
const {
C2S_PACKET_SIZES,
encodeClientCommand,
encodeC2sWarpTo,
encodeC2sSwapEquippedItem,
encodeC2sSwapContainerItem,
encodeC2sBeltPut,
encodeC2sBeltRemove,
encodeC2sBeltSwap,
encodeC2sBeltUse,
encodeC2sIdentifyItem,
encodeC2sSocketItem,
encodeC2sScrollToBook,
encodeC2sQuestMessage,
encodeC2sNpcMercHire,
encodeC2sNpcEntityAction,
encodeC2sResurrect,
encodeC2sClickButton,
encodeC2sHostile,
encodeC2sPartyAction,
encodeC2sMercItem,
encodeC2sNpcMercResurrect,
} = netproto
const directPackets: Array<{ opcode: number; pkt: Uint8Array }> = [
{ opcode: 0x09, pkt: encodeC2sWarpTo(42, 5) },
{ opcode: 0x1d, pkt: encodeC2sSwapEquippedItem(100, 4) },
{ opcode: 0x1f, pkt: encodeC2sSwapContainerItem(101, 102, 3, 2) },
{ opcode: 0x23, pkt: encodeC2sBeltPut(103, 0) },
{ opcode: 0x24, pkt: encodeC2sBeltRemove(103) },
{ opcode: 0x25, pkt: encodeC2sBeltSwap(104, 103) },
{ opcode: 0x26, pkt: encodeC2sBeltUse(103, false) },
{ opcode: 0x27, pkt: encodeC2sIdentifyItem(105, 106) },
{ opcode: 0x28, pkt: encodeC2sSocketItem(107, 108) },
{ opcode: 0x29, pkt: encodeC2sScrollToBook(109, 110) },
{ opcode: 0x31, pkt: encodeC2sQuestMessage(15, 25) },
{ opcode: 0x36, pkt: encodeC2sNpcMercHire(15, 7) },
{ opcode: 0x38, pkt: encodeC2sNpcEntityAction(1, 15, 0) },
{ opcode: 0x41, pkt: encodeC2sResurrect() },
{ opcode: 0x4f, pkt: encodeC2sClickButton(0x12, 0) },
{ opcode: 0x5d, pkt: encodeC2sHostile(2, true, 0x04) },
{ opcode: 0x5e, pkt: encodeC2sPartyAction(6, 2) },
{ opcode: 0x61, pkt: encodeC2sMercItem(4) },
{ opcode: 0x62, pkt: encodeC2sNpcMercResurrect(15) },
]
for (const { opcode, pkt } of directPackets) {
expect(pkt[0]).toBe(opcode)
expect(pkt.byteLength).toBe(C2S_PACKET_SIZES[opcode])
}
const cmds: ClientCommand[] = [
{ type: 'WarpTo', warpId: 42 },
{ type: 'TakeWarp', warpId: 42 },
{ type: 'ItemSwap', itemId: 100, slot: 4 },
{ type: 'SwapContainerItem', cursorItemId: 101, replacedItemId: 102, x: 3, y: 2 },
{ type: 'BeltPut', itemId: 103, slot: 0 },
{ type: 'BeltRemove', itemId: 103 },
{ type: 'BeltSwap', cursorItemId: 104, beltItemId: 103 },
{ type: 'BeltUse', itemId: 103, shift: true },
{ type: 'IdentifyItem', itemId: 105, scrollId: 106 },
{ type: 'SocketItem', itemId: 107, targetItemId: 108 },
{ type: 'ScrollToBook', scrollId: 109, bookId: 110 },
{ type: 'QuestMessage', npcId: 15, messageId: 25 },
{ type: 'MercHire', npcId: 15, mercId: 7 },
{ type: 'NpcEntityAction', action: 3, npcId: 15, complement: 0 },
{ type: 'Resurrect' },
{ type: 'ClickButton', buttonId: 0x12, amount: 0 },
{ type: 'Hostile', targetPlayerId: 2, hostile: true },
{ type: 'PartyAction', action: 6, targetPlayerId: 2 },
{ type: 'MercItem', slot: 4 },
{ type: 'MercResurrect', npcId: 15 },
]
for (const cmd of cmds) {
const pkts = encodeClientCommand(cmd)
expect(pkts.length).toBe(1)
const pkt = pkts[0]!
expect(pkt.byteLength).toBe(C2S_PACKET_SIZES[pkt[0]!])
}
})
it('resolves all Act 1–5 town & quest NPC descriptors by hcIdx', async () => {
const {
getTownNpcDescriptorByHcIdx,
NPC_HCIDX_TO_DESCRIPTOR_ID,
TOWN_NPC_SERVICES,
} = await import('../../src/common/units/npc-table.ts')
const expectedHcIdx = [
146, 147, 148, 150, 154, 155, 265,
175, 176, 177, 178, 198, 199, 200, 201, 202, 203, 210, 244, 251,
245, 252, 253, 254, 255, 256, 257, 258, 297,
246, 259, 367, 405, 534,
511, 512, 513, 514, 515, 520, 521,
]
for (const hcIdx of expectedHcIdx) {
const desc = getTownNpcDescriptorByHcIdx(hcIdx)
expect(desc, `Missing NPC descriptor for hcIdx=${hcIdx}`).toBeDefined()
expect(NPC_HCIDX_TO_DESCRIPTOR_ID[hcIdx]).toBe(desc!.id)
expect(desc!.gossipLines?.length).toBeGreaterThan(0)
}
expect(TOWN_NPC_SERVICES.kashya?.canHire).toBe(true)
expect(TOWN_NPC_SERVICES.greiz?.canHire).toBe(true)
expect(TOWN_NPC_SERVICES.asheara?.canHire).toBe(true)
expect(TOWN_NPC_SERVICES.qual_kehk?.canHire).toBe(true)
expect(TOWN_NPC_SERVICES.warriv?.travelActions).toContain('travel_east')
expect(TOWN_NPC_SERVICES.meshif_act2?.travelActions).toContain('travel_east')
expect(TOWN_NPC_SERVICES.tyrael_act4?.travelActions).toContain('travel_harrogath')
expect(TOWN_NPC_SERVICES.charsi?.questRewardActions).toContain('imbue')
expect(TOWN_NPC_SERVICES.larzuk?.questRewardActions).toContain('add_sockets')
expect(TOWN_NPC_SERVICES.anya?.questRewardActions).toContain('personalize')
})
it('bridges online ItemData into UiInventoryItem with canonical 1.13c names, stats, requirements, and sockets', async () => {
const { onlineItemDataToUiInventoryItem } = await import('../../src/common/items/item-bridge.ts')
const { resolveGroundItemMetadata } = await import('../../src/common/items/ground-items.ts')
// 1. Unique item with stats and sockets
const shako = makeItem({
id: 700,
code: 'uap',
quality: 7,
identified: true,
defense: 141,
durability: 12,
maxDurability: 12,
totalSockets: 1,
usedSockets: 1,
stats: [
{ statId: 127, param: 0, value: 2 }, // item_allskills: +2 to All Skill Levels
{ statId: 0, param: 0, value: 2 }, // strength: +2 to Strength
{ statId: 80, param: 0, value: 50 }, // item_magicbonus: 50% Better Chance of Getting Magic Items
],
})
const topaz = makeItem({
id: 701,
code: 'gpz',
quality: 2,
identified: true,
inSocket: true,
ownerId: 700,
stats: [{ statId: 80, param: 0, value: 24 }],
})
const uiShako = onlineItemDataToUiInventoryItem(shako, [topaz])
expect(uiShako.quality).toBe('unique')
expect(uiShako.name).toContain('Harlequin Crest')
expect(uiShako.reqLevel).toBeGreaterThanOrEqual(43)
expect(uiShako.sockets).toBe(1)
expect(uiShako.socketedItems).toHaveLength(1)
expect(uiShako.stats.some(s => !s.text.startsWith('Stat #'))).toBe(true)
expect((uiShako.statsEn ?? uiShako.stats).some(s => s.text.includes('Strength') || s.text.includes('Skill'))).toBe(true)
// 2. Ground gold pile with goldAmount
const goldItem = makeItem({
id: 702,
code: 'gld',
goldAmount: 350,
})
const uiGold = onlineItemDataToUiInventoryItem(goldItem)
expect(uiGold.name).toBe('350 Gold')
const groundMeta = resolveGroundItemMetadata(goldItem)
expect(groundMeta.labelText).toBe('350 Gold')
})
it('reduces Npc, Merc, SkillCast, player death/resurrect, Party, Trade, and Tile warpId in ClientWorld', () => {
const world = new ClientWorld()
world.apply({
type: 'UnitAssign',
unit: { unitType: 0, id: 1, classId: 1, name: 'SorcHero', x: 5000, y: 5000 },
isSelf: true,
})
world.apply({ type: 'StatSet', stat: 7, value: 100 << 8 }) // maxLife = 100
world.apply({ type: 'StatSet', stat: 6, value: 100 << 8 }) // life = 100
expect(world.isDead).toBe(false)
// 1. Tile warpId
world.apply({
type: 'UnitAssign',
unit: { unitType: 5, id: 300, classId: 18, warpId: 18, x: 5020, y: 5020 },
})
expect(world.getUnit(5, 300)?.warpId).toBe(18)
// 2. Ground item goldAmount & name
const groundGold = makeItem({
id: 800,
code: 'gld',
action: 0,
container: 'ground',
worldX: 5005,
worldY: 5005,
goldAmount: 420,
})
world.apply({ type: 'ItemEvent', action: 0, item: groundGold, container: 'ground' })
const itemUnit = world.getUnit('item', 800)
expect(itemUnit?.goldAmount).toBe(420)
expect(itemUnit?.name).toBe('420 Gold')
// 3. NPC interaction & heal
world.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: 50, classId: 148, name: 'Akara', x: 5010, y: 5010, lifePct: 80 },
})
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 50 } })
expect(world.npcInteraction.npcId).toBe(50)
world.apply({ type: 'Npc', kind: 'info', npcId: 50, raw: new Uint8Array([0x27, 0x01]) })
world.apply({ type: 'Npc', kind: 'menu', npcId: 50, raw: new Uint8Array([0x91, 0x01]) })
expect(world.npcInteraction.npcInfoRaw?.[0]).toBe(0x27)
expect(world.npcInteraction.npcTalkRaw?.[0]).toBe(0x91)
world.noteOutgoingCommand({ type: 'NpcEntityAction', action: 3, npcId: 50 })
expect(world.npcInteraction.activeWindow).toBe('hire')
world.apply({ type: 'Npc', kind: 'heal', npcId: 50, result: 112 })
expect(world.getUnit('monster', 50)?.lifePct).toBe(112)
// 4. Mercenary hire list, assign, equipment, and revive cost
world.apply({ type: 'Merc', kind: 'hireList' }) // 0x4F clear
world.apply({ type: 'Merc', kind: 'hireList', nameId: 10, value: 0x11223344 })
world.apply({ type: 'Merc', kind: 'hireList', nameId: 11, value: 0x55667788 })
expect(world.mercHireList).toHaveLength(2)
world.apply({ type: 'Merc', kind: 'assign', ownerId: 1, mercId: 90, classId: 271, nameId: 10 })
expect(world.merc?.mercId).toBe(90)
expect(world.merc?.isDead).toBe(false)
const mercHelm = makeItem({
id: 810,
code: 'cap',
action: 6,
container: 'merc',
ownerType: 1,
ownerId: 90,
equippedSlot: 1,
bodyLoc: 1,
})
world.apply({ type: 'ItemEvent', action: 6, item: mercHelm, container: 'merc' })
expect(world.containers.merc.has(810)).toBe(true)
expect(world.containers.merc.get(1)?.id).toBe(810)
// Unequip merc helm to cursor
world.apply({ type: 'ItemEvent', action: 8, item: mercHelm, container: 'merc' })
expect(world.containers.merc.has(810)).toBe(false)
expect(world.containers.merc.get(1)).toBeUndefined()
expect(world.containers.cursor?.id).toBe(810)
// Merc death & revive cost (0x9B)
world.apply({ type: 'Merc', kind: 'stat', nameId: 10, value: 15000 })
expect(world.merc?.isDead).toBe(true)
expect(world.merc?.reviveCost).toBe(15000)
// 5. SkillCast animation state & direction
world.apply({
type: 'SkillCast',
caster: { unitType: 0, id: 1 },
skill: 36,
skillId: 36, // Fire Bolt (spellcast mode 10)
level: 1,
target: { x: 5010, y: 5000 },
})
const selfUnit = world.getUnit(0, 1)!
expect(selfUnit.mode).toBe(10)
expect(selfUnit.castState?.skillId).toBe(36)
expect(world.self.castState?.skillId).toBe(36)
// 6. Player death and town resurrection
world.apply({ type: 'LifeMana', life: 0, mana: 10, stamina: 50, at: { x: 5000, y: 5000 } })
expect(world.isDead).toBe(true)
expect(world.self.isDead).toBe(true)
world.noteOutgoingCommand({ type: 'Resurrect' })
expect(world.isDead).toBe(false)
// 7. Party roster, relation, hostility, loot permit, position
world.apply({
type: 'Party',
kind: 'roster',
unitId: 2,
charName: 'BarbPeer',
charClass: 4,
charLevel: 20,
partyId: 0xffff, // 0x5B unpartied player joining game
})
expect(world.party.has(2)).toBe(true)
expect(world.party.get(2)?.partyState).toBe(0)
world.apply({ type: 'Party', kind: 'relation', unitId: 2, relation: 2 }) // invited
expect(world.party.get(2)?.partyState).toBe(2)
world.apply({ type: 'Party', kind: 'roster', unitId: 2, partyId: 1 }) // joined party 1
expect(world.party.get(2)?.partyState).toBe(1)
world.apply({ type: 'Party', kind: 'relation', unitId: 2, otherId: 1, relation: 0x0c }) // hostile + loot
expect(world.party.get(2)?.hostile).toBe(true)
expect(world.party.get(2)?.lootPermit).toBe(true)
world.apply({ type: 'Party', kind: 'memberPos', unitId: 2, at: { x: 5100, y: 5200 } })
expect(world.party.get(2)?.x).toBe(5100)
expect(world.party.get(2)?.y).toBe(5200)
world.apply({ type: 'Party', kind: 'roster', unitId: 2, partyId: 0xffff }) // 0x5C left game
expect(world.party.has(2)).toBe(false)
// 8. Trade session state & trade grids
world.apply({ type: 'Trade', action: 0x01 }) // open trade window
expect(world.trade.active).toBe(true)
const myTradeItem = makeItem({
id: 820,
code: 'rin',
action: 4,
container: 'trade',
ownerId: 1,
gridX: 0,
gridY: 0,
})
const partnerTradeItem = makeItem({
id: 821,
code: 'amu',
action: 4,
container: 'trade',
ownerId: 2,
gridX: 1,
gridY: 0,
})
world.apply({ type: 'ItemEvent', action: 4, item: myTradeItem, container: 'trade' })
world.apply({ type: 'ItemEvent', action: 4, item: partnerTradeItem, container: 'trade' })
expect(world.containers.tradeSelf.has(820)).toBe(true)
expect(world.containers.tradePartner.has(821)).toBe(true)
expect(world.containers.tradeGrid.some(i => i.id === 820)).toBe(true)
expect(world.containers.partnerTradeGrid.some(i => i.id === 821)).toBe(true)
world.apply({ type: 'Trade', action: 0x78, partnerId: 2, partnerName: 'BarbPeer' })
expect(world.trade.partnerAccepted).toBe(true)
expect(world.trade.partnerName).toBe('BarbPeer')
world.apply({ type: 'Trade', action: 0x7901, gold: 5000 })
world.apply({ type: 'Trade', action: 0x7900, gold: 8000 })
expect(world.trade.selfGold).toBe(5000)
expect(world.trade.partnerGold).toBe(8000)
world.apply({ type: 'Trade', action: 0x09 }) // disable accept
expect(world.trade.acceptDisabled).toBe(true)
expect(world.trade.partnerAccepted).toBe(false)
world.apply({ type: 'Trade', action: 0x0a }) // enable accept
expect(world.trade.acceptDisabled).toBe(false)
world.apply({ type: 'Trade', action: 0x02 }) // cancel trade
expect(world.trade.active).toBe(false)
expect(world.containers.tradeGrid).toHaveLength(0)
expect(world.containers.partnerTradeGrid).toHaveLength(0)
})
})

View File

@ -4,13 +4,19 @@
* Covers: neutral level-1 self, class id -> tree/quickbar mapping for all 7 classes, HUD clicks that
* only emit `ClientCommand`s, empty equipment/belt and the plain-integer stat 6-11 wire format.
*/
import { describe, expect, it } from 'vitest'
import { HudModel, CLASS_ID_TO_CODE, nextLevelExperience } from '../../src/client/ui-model/hud-model.ts'
import { describe, expect, it, vi } from 'vitest'
import {
HudModel,
CLASS_ID_TO_CODE,
nextLevelExperience,
itemDataToUiInventoryItem,
} from '../../src/client/ui-model/hud-model.ts'
import { HudManager } from '../../src/client/ui/hud-manager.ts'
import { EQUIP_SLOTS_LAYOUT, INV_GRID_ORIGIN } from '../../src/client/ui/inventory.ts'
import { ClientWorld } from '../../src/client/world/client-world.ts'
import { D2StatId } from '../../src/client/world/stat-list.ts'
import { D2_EXPERIENCE_TABLE_99 } from '../../src/common/skills/formulas.ts'
import type { ClientCommand } from '../../src/netproto/index.ts'
import type { ClientCommand, ItemData } from '../../src/netproto/index.ts'
function setup() {
const world = new ClientWorld()
@ -28,6 +34,72 @@ function assignSelf(world: ClientWorld, classId: number): void {
})
}
function makeOnlineItem(overrides: Partial<ItemData>): ItemData {
const id = overrides.id ?? 100
const code = overrides.code ?? 'cap'
const x = overrides.gridX ?? overrides.x ?? 0
const y = overrides.gridY ?? overrides.y ?? 0
const bodyLoc = overrides.bodyLoc ?? overrides.equippedSlot ?? 0
return {
id,
itemId: overrides.itemId ?? id,
opcode: overrides.opcode ?? 0x9c,
code,
rawCode: overrides.rawCode ?? `${code} `,
action: overrides.action ?? 0,
category: overrides.category ?? 1,
location: overrides.location ?? 0,
container: overrides.container ?? 'inventory',
bodyLoc,
equippedSlot: bodyLoc,
gridX: x,
gridY: y,
storePage: overrides.storePage ?? 1,
storagePage: overrides.storagePage ?? 1,
worldX: overrides.worldX ?? 0,
worldY: overrides.worldY ?? 0,
x,
y,
invWidth: overrides.invWidth ?? 2,
invHeight: overrides.invHeight ?? 2,
quality: overrides.quality ?? 2,
ilvl: overrides.ilvl ?? 10,
identified: overrides.identified ?? true,
ethereal: overrides.ethereal ?? false,
socketed: overrides.socketed ?? false,
totalSockets: overrides.totalSockets ?? 0,
usedSockets: overrides.usedSockets ?? 0,
socketedChildCount: overrides.socketedChildCount ?? 0,
broken: overrides.broken ?? false,
compact: overrides.compact ?? false,
simple: overrides.simple ?? false,
isEar: overrides.isEar ?? false,
newItem: overrides.newItem ?? false,
starterItem: overrides.starterItem ?? false,
inSocket: overrides.inSocket ?? false,
equipped: overrides.equipped ?? false,
runeword: overrides.runeword ?? false,
personalized: overrides.personalized ?? false,
goldAmount: overrides.goldAmount ?? 0,
durability: overrides.durability ?? 12,
curDurability: overrides.curDurability ?? overrides.durability ?? 12,
maxDurability: overrides.maxDurability ?? 12,
quantity: overrides.quantity ?? 0,
defense: overrides.defense ?? 5,
setItemId: overrides.setItemId ?? 0,
uniqueItemId: overrides.uniqueItemId ?? 0,
stats: overrides.stats ?? [{ statId: D2StatId.ArmorClass, param: 0, value: 5 }],
setBonusStats: overrides.setBonusStats ?? [],
runewordStats: overrides.runewordStats ?? [],
ownerType: overrides.ownerType ?? 0,
ownerId: overrides.ownerId ?? 1,
flags: overrides.flags ?? 0x10,
rawFlags: overrides.rawFlags ?? 0x10,
version: overrides.version ?? 100,
...overrides,
}
}
describe('client hardcode audit — server-authoritative HUD', () => {
it('level-1 self with only non-zero stats shows 0 stat/skill points, 0 gold and table XP thresholds', () => {
const { world, hud, model } = setup()
@ -151,4 +223,369 @@ describe('client hardcode audit — server-authoritative HUD', () => {
expect(world.trade.selfGold).toBe(300)
expect(world.trade.partnerGold).toBe(70)
})
it('R1.1 bridges online ItemData with socketed children into canonical 1.13c ItemTooltipModel', () => {
const gem = makeOnlineItem({
id: 201,
code: 'gcv',
location: 6,
simple: true,
})
const helm = makeOnlineItem({
id: 200,
code: 'cap',
quality: 4,
identified: true,
ethereal: true,
socketed: true,
totalSockets: 2,
usedSockets: 1,
defense: 18,
durability: 9,
maxDurability: 12,
stats: [
{ statId: D2StatId.ArmorClass, param: 0, value: 18 },
{ statId: D2StatId.MaxHp, param: 0, value: 15 << 8 },
{ statId: D2StatId.SocketCount, param: 0, value: 2 },
],
})
const uiItem = itemDataToUiInventoryItem(helm, [gem])
expect(uiItem.defense).toBe(18)
expect(uiItem.durability).toEqual({ current: 9, max: 12 })
expect(uiItem.ethereal).toBe(true)
expect(uiItem.sockets).toBe(2)
expect(uiItem.socketedGems).toEqual(['gcv'])
expect(uiItem.affixes?.some((a: string) => a.includes('+15') && a.includes('Life'))).toBe(true)
})
it('R1.3 syncs Weapon Set II (bodyLoc 11 & 12) into hud.inventory.swapWeapons and weaponSwapSet', () => {
const { world, hud, model } = setup()
assignSelf(world, 1)
const primarySword = makeOnlineItem({ id: 301, code: 'ssd', location: 1, bodyLoc: 4, container: 'equipment' })
const swapWand = makeOnlineItem({ id: 302, code: 'wnd', location: 1, bodyLoc: 11, container: 'equipment' })
const swapShield = makeOnlineItem({ id: 303, code: 'buc', location: 1, bodyLoc: 12, container: 'equipment' })
world.apply({ type: 'ItemEvent', action: 6, item: primarySword, container: 'equipment' })
world.apply({ type: 'ItemEvent', action: 6, item: swapWand, container: 'equipment' })
world.apply({ type: 'ItemEvent', action: 6, item: swapShield, container: 'equipment' })
world.self.weaponSet = 1
model.syncFromWorld()
expect(hud.inventory.equipped.weapon1?.id).toBe('301')
expect(hud.inventory.swapWeapons.weapon1?.id).toBe('302')
expect(hud.inventory.swapWeapons.weapon2?.id).toBe('303')
expect(hud.inventory.weaponSwapSet).toBe(1)
})
it('R1.2 emits container, belt, identify, socket, and tome commands (0x1D, 0x1F, 0x23-0x29)', () => {
const { world, hud, model, commands } = setup()
assignSelf(world, 1)
const equippedHelm = makeOnlineItem({ id: 401, code: 'cap', location: 1, bodyLoc: 1, container: 'equipment' })
const beltPot = makeOnlineItem({
id: 402,
code: 'hp1',
location: 2,
container: 'belt',
gridX: 0,
gridY: 0,
invWidth: 1,
invHeight: 1,
simple: true,
})
world.apply({ type: 'ItemEvent', action: 6, item: equippedHelm, container: 'equipment' })
world.apply({ type: 'ItemEvent', action: 0x0e, item: beltPot, container: 'belt' })
model.syncFromWorld()
model.swapEquipItem(500, 'helm')
model.swapContainerItem(500, 401, 2, 1, 0)
model.putBeltItem(501, 3)
model.removeBeltItem(402)
model.swapBeltItem(501, 402)
expect(model.useBeltSlot(0, true)).toBe(true)
model.identifyItem(601, 602)
model.socketItem(603, 604)
model.scrollToBook(605, 606)
expect(commands).toEqual([
{ type: 'ItemSwap', itemId: 500, slot: 1 },
{ type: 'SwapContainerItem', cursorItemId: 500, targetItemId: 401, replacedItemId: 401, x: 2, y: 1 },
{ type: 'BeltPut', itemId: 501, slot: 3 },
{ type: 'BeltRemove', itemId: 402 },
{ type: 'BeltSwap', cursorItemId: 501, beltItemId: 402 },
{ type: 'BeltUse', itemId: 402, useOnMerc: 1 },
{ type: 'IdentifyItem', itemId: 601, scrollId: 602 },
{ type: 'SocketItem', itemId: 603, targetItemId: 604 },
{ type: 'ScrollToBook', scrollId: 605, bookId: 606 },
])
// Verify HudManager click routing for identify scroll -> unidentified item, socket item, and scroll to tome
commands.length = 0
const idScroll = makeOnlineItem({
id: 701,
code: 'isc',
location: 0,
container: 'inventory',
gridX: 0,
gridY: 0,
invWidth: 1,
invHeight: 1,
simple: true,
})
const unidRing = makeOnlineItem({
id: 702,
code: 'rin',
location: 0,
container: 'inventory',
gridX: 1,
gridY: 0,
invWidth: 1,
invHeight: 1,
quality: 4,
identified: false,
})
const idTome = makeOnlineItem({
id: 703,
code: 'ibk',
location: 0,
container: 'inventory',
gridX: 2,
gridY: 0,
invWidth: 1,
invHeight: 2,
simple: true,
})
const socketedCap = makeOnlineItem({
id: 704,
code: 'cap',
location: 0,
container: 'inventory',
gridX: 4,
gridY: 0,
invWidth: 2,
invHeight: 2,
socketed: true,
totalSockets: 2,
usedSockets: 0,
stats: [{ statId: D2StatId.SocketCount, param: 0, value: 2 }],
})
world.apply({ type: 'ItemEvent', action: 4, item: idScroll, container: 'inventory' })
world.apply({ type: 'ItemEvent', action: 4, item: unidRing, container: 'inventory' })
world.apply({ type: 'ItemEvent', action: 4, item: idTome, container: 'inventory' })
world.apply({ type: 'ItemEvent', action: 4, item: socketedCap, container: 'inventory' })
model.syncFromWorld()
hud.toggleRightPanel('inv')
// Right-click identify scroll at (0, 0), then left-click unidentified ring at (1, 0)
const cellX = (col: number) => INV_GRID_ORIGIN.x + col * INV_GRID_ORIGIN.cellPx + 10
const cellY = (row: number) => INV_GRID_ORIGIN.y + row * INV_GRID_ORIGIN.cellPx + 10
hud.handleClick(cellX(0), cellY(0), 2)
expect(hud.pendingIdentifyScrollId).toBe(701)
hud.handleClick(cellX(1), cellY(0), 0)
expect(hud.pendingIdentifyScrollId).toBeNull()
expect(commands).toContainEqual({ type: 'IdentifyItem', itemId: 702, scrollId: 701 })
// Cursor holds identify scroll (701) -> click on identify tome (703) at col 2, row 0
commands.length = 0
world.apply({ type: 'ItemEvent', action: 2, item: idScroll, container: 'cursor' })
model.syncFromWorld()
hud.handleClick(cellX(2), cellY(0), 0)
expect(commands).toEqual([{ type: 'ScrollToBook', scrollId: 701, bookId: 703 }])
// Cursor holds chipped amethyst (705) -> click on socketed cap (704) at col 4, row 0
commands.length = 0
world.apply({
type: 'ItemEvent',
action: 2,
item: makeOnlineItem({ id: 705, code: 'gcv', location: 4, container: 'cursor', invWidth: 1, invHeight: 1, simple: true }),
container: 'cursor',
})
model.syncFromWorld()
hud.handleClick(cellX(4), cellY(0), 0)
expect(commands).toEqual([{ type: 'SocketItem', itemId: 705, targetItemId: 704 }])
// Cursor holds cap (706) -> click on occupied helm slot
commands.length = 0
world.apply({
type: 'ItemEvent',
action: 2,
item: makeOnlineItem({ id: 706, code: 'cap', location: 4, container: 'cursor', invWidth: 2, invHeight: 2 }),
container: 'cursor',
})
model.syncFromWorld()
const helmRect = EQUIP_SLOTS_LAYOUT.helm
hud.handleClick(helmRect.x + 10, helmRect.y + 10, 0)
expect(commands).toEqual([{ type: 'ItemSwap', itemId: 706, slot: 1 }])
})
it('R2 dispatches NPC dialogue, travel, hire, resurrect merc, and quest reward menu actions', () => {
const { world, hud, model, commands } = setup()
assignSelf(world, 1)
world.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: 88, classId: 150, x: 5000, y: 5000 },
})
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 88 } })
model.syncFromWorld()
model.handleNpcMenuAction('talk', 88)
expect(commands).toContainEqual({ type: 'QuestMessage', npcId: 88, messageId: 0 })
commands.length = 0
model.handleNpcMenuAction('hire', 88)
expect(hud.leftPanel).toBe('hire')
expect(commands).toContainEqual({ type: 'NpcEntityAction', action: 3, npcId: 88, complement: 0 })
commands.length = 0
world.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: 89, classId: 155, x: 5000, y: 5000 }, // Warriv Act I
})
model.handleNpcMenuAction('travel_east', 89)
expect(commands).toEqual([{ type: 'NpcEntityAction', action: 0, npcId: 89, complement: 40 }])
commands.length = 0
model.handleNpcMenuAction('resurrect_merc', 88)
expect(commands).toEqual([{ type: 'MercResurrect', npcId: 88 }])
commands.length = 0
model.handleNpcMenuAction('imbue', 88)
expect(hud.leftPanel).toBe('imbue')
model.confirmQuestReward(901)
expect(commands).toContainEqual({ type: 'NpcEntityAction', action: 0, npcId: 88, complement: 901 })
})
it('R3.2 & R3.3 syncs mercenary equipment, renders top-left portrait, and emits HireMerc/ResurrectMerc/MercItem', () => {
const { world, hud, model, commands } = setup()
assignSelf(world, 1)
world.apply({
type: 'Merc',
kind: 'assign',
mercId: 50,
ownerId: 1,
classId: 338,
nameId: 1,
})
world.apply({
type: 'ItemEvent',
action: 6,
item: makeOnlineItem({ id: 801, code: 'cap', location: 1, bodyLoc: 1, ownerType: 1, container: 'merc' }),
container: 'merc',
})
world.apply({
type: 'ItemEvent',
action: 6,
item: makeOnlineItem({ id: 802, code: 'qui', location: 1, bodyLoc: 3, ownerType: 1, container: 'merc' }),
container: 'merc',
})
world.apply({
type: 'ItemEvent',
action: 6,
item: makeOnlineItem({ id: 803, code: 'spt', location: 1, bodyLoc: 4, ownerType: 1, container: 'merc' }),
container: 'merc',
})
model.syncFromWorld()
expect(hud.merc).not.toBeNull()
expect(hud.merc?.unitId).toBe(50)
expect(hud.merc?.equipment.helm?.id).toBe('801')
expect(hud.merc?.equipment.armor?.id).toBe('802')
expect(hud.merc?.equipment.weapon1?.id).toBe('803')
const ctx = { fillRect: vi.fn(), fillText: vi.fn() } as any
hud.ctx = ctx
hud.render()
expect(ctx.fillRect).toHaveBeenCalledWith(8, 8, 48, 48)
hud.handleKeyDown({ key: 'o', preventDefault: () => {} } as unknown as KeyboardEvent)
expect(hud.leftPanel).toBe('merc')
model.hireMerc(88, 2)
model.resurrectMerc(88)
model.mercItem(1)
expect(commands).toEqual([
{ type: 'MercHire', npcId: 88, mercId: 2 },
{ type: 'MercResurrect', npcId: 88 },
{ type: 'MercItem', slot: 1 },
])
})
it('R4.4 renders death overlay and sends Resurrect (0x41) on click or ESC', () => {
const { world, hud, model, commands } = setup()
assignSelf(world, 1)
const ctx = {
fillRect: vi.fn(),
fillText: vi.fn(),
} as any
hud.ctx = ctx
world.self.isDead = true
model.syncFromWorld()
expect(hud.isDead).toBe(true)
hud.render()
expect(ctx.fillText).toHaveBeenCalledWith(
expect.stringContaining('You Have Died'),
400,
300,
)
hud.handleClick(400, 300)
expect(commands).toEqual([{ type: 'Resurrect' }])
hud.handleKeyDown({ key: 'Escape', preventDefault: () => {} } as unknown as KeyboardEvent)
expect(commands).toHaveLength(2)
expect(commands[1]).toEqual({ type: 'Resurrect' })
})
it('R5.1, R5.2 & R5.3 syncs party & trade state, toggles Party panel (P), and emits PartyAction/Hostile/ClickButton', () => {
const { world, hud, model, commands } = setup()
assignSelf(world, 1)
world.apply({
type: 'Party',
kind: 'roster',
unitId: 2,
charName: 'PaladinTwo',
charClass: 3,
charLevel: 20,
partyId: 1,
})
world.apply({ type: 'Trade', action: 0x01 })
world.apply({ type: 'Trade', action: 0x7901, gold: 500 })
world.apply({ type: 'Trade', action: 0x7900, gold: 1200 })
world.apply({
type: 'ItemEvent',
action: 4,
item: makeOnlineItem({ id: 901, code: 'rin', container: 'tradeSelf', gridX: 0, gridY: 0, invWidth: 1, invHeight: 1 }),
container: 'tradeSelf',
})
world.apply({
type: 'ItemEvent',
action: 4,
item: makeOnlineItem({ id: 902, code: 'amu', container: 'tradePartner', gridX: 1, gridY: 0, invWidth: 1, invHeight: 1 }),
container: 'tradePartner',
})
model.syncFromWorld()
expect(hud.partyMembers).toHaveLength(1)
expect(hud.partyMembers[0]?.name).toBe('PaladinTwo')
expect(hud.leftPanel).toBe('trade')
expect(hud.trade?.selfItems).toHaveLength(1)
expect(hud.trade?.partnerItems).toHaveLength(1)
// When trade ends, trade panel closes automatically
world.apply({ type: 'Trade', action: 0x0c })
model.syncFromWorld()
expect(hud.leftPanel).toBe('none')
hud.handleKeyDown({ key: 'p', preventDefault: () => {} } as unknown as KeyboardEvent)
expect(hud.leftPanel).toBe('party')
model.partyAction(0x06, 2)
model.setHostile(2, true)
model.clickButton(4, 0)
expect(commands).toEqual([
{ type: 'PartyAction', action: 0x06, targetPlayerId: 2 },
{ type: 'Hostile', targetPlayerId: 2, hostile: true, button: 1 },
{ type: 'ClickButton', button: 4, buttonId: 4, complement: 0, amount: 0 },
])
})
})

View File

@ -23,6 +23,9 @@ import {
type ServerEvent,
} from '../../src/netproto/index.ts'
import { HudManager } from '../../src/client/ui/hud-manager.ts'
import { buildSceneDrawables } from '../../src/client/view/unit-drawables.ts'
import { worldToScreen } from '../../src/client/view/viewport-profile.ts'
import { layoutGroundItemLabels } from '../../src/client/scene/world-renderer.ts'
type ItemEventItem = Extract<ServerEvent, { type: 'ItemEvent' }>['item']
@ -504,6 +507,260 @@ describe('Milestone M5 — HudModel, CommandMapper, and OnlineSession', () => {
hudModel.dispose()
})
it('projects warps, town portals, ground item labels, SkillCast visuals, death Resurrect, and party/merc/trade interactions (M2)', () => {
const session = new OnlineSession({
settingsStore: createSettingsStore(),
})
const dispatched: ClientCommand[] = []
const origForward = (session as any).forwardCommandToAdapter.bind(session)
;(session as any).forwardCommandToAdapter = (cmd: ClientCommand) => {
dispatched.push(cmd)
origForward(cmd)
}
// Attach a stub LevelView for Blood Moor (areaId=2) with warp 4 -> Den of Evil (areaId=8)
session.levelView = {
act: 0,
areaId: 2,
subOriginX: 5000,
subOriginY: 5000,
subWidth: 400,
subHeight: 400,
isoOriginX: 0,
isoOriginY: 0,
cellsX: 80,
cellsY: 80,
gridWidth: 400,
blocked: new Uint8Array(400 * 400),
walkable: new Uint8Array(400 * 400).fill(1),
collisionMasks: new Uint16Array(400 * 400),
rooms: [],
floors: [],
shadows: [],
walls: [],
roofs: [],
warps: [{ lvlWarpId: 4, toLevelId: 8, subX: 112, subY: 102 }],
presets: [],
} as any
session.handleServerEvent({ type: 'LoadAct', act: 0, mapSeed: 0x11223344, areaId: 2 })
session.handleServerEvent({
type: 'UnitAssign',
unit: { unitType: 0, id: 1, classId: 1, name: 'Sorceress', x: 5100, y: 5100 },
isSelf: true,
})
// R4.1: Warp tile (unitType 5) projection, pickUnitAtScreen, and click -> MoveToUnit(tile) + TakeWarp
session.handleServerEvent({
type: 'UnitAssign',
unit: { unitType: 5, id: 401, classId: 4, warpId: 4, x: 5112, y: 5102 } as any,
})
const snapWithWarp = session.sceneSource.buildFrameSnapshot()
const warpDrawable = snapWithWarp.entities.unitDrawables.find(
(u) => u.id === 401 && u.unitKind === 'warp',
)
expect(warpDrawable).toBeDefined()
expect(warpDrawable?.warpId).toBe(4)
expect(warpDrawable?.toLevelId).toBe(8)
expect(warpDrawable?.name).toContain('Den of Evil')
const warpScreen = worldToScreen(
warpDrawable!.worldX,
warpDrawable!.worldY,
snapWithWarp.playerWorld.x,
snapWithWarp.playerWorld.y,
snapWithWarp.viewportProfile,
)
const pickedWarp = session.sceneSource.pickUnitAtScreen(warpScreen.x, warpScreen.y)
expect(pickedWarp?.id).toBe(401)
expect(pickedWarp?.unitKind).toBe('warp')
const warpClickScreen = session.camera.subtileToScreen(5112, 5102)
session.commandMapper.handlePointerDown(warpClickScreen.x, warpClickScreen.y, 0, 1000)
session.commandMapper.handlePointerUp(0)
expect(
dispatched.some(
(c) => c.type === 'MoveToUnit' && c.target.unitType === 5 && c.target.id === 401,
),
).toBe(true)
expect(dispatched.some((c) => c.type === 'TakeWarp' && c.warpId === 401)).toBe(true)
// R4.2: Town Portal ownership hover + out-of-range walk-then-interact
session.handleServerEvent({
type: 'UnitAssign',
unit: { unitType: 2, id: 601, classId: 59, name: 'Town Portal', x: 5110, y: 5094 },
})
session.handleServerEvent({
type: 'Portal',
ownerId: 1,
ownerName: 'Sorceress',
localId: 601,
remoteId: 602,
areaId: 1,
})
const portalUnits = session.buildRenderableUnits()
const portalRenderable = portalUnits.find((u) => u.id === 601)
expect(portalRenderable?.name).toContain("Sorceress's Town Portal")
expect(portalRenderable?.portalOwnerName).toBe('Sorceress')
const beforePortalCmds = dispatched.length
const portalScreen = session.camera.subtileToScreen(5110, 5094)
session.commandMapper.handlePointerDown(portalScreen.x, portalScreen.y, 0, 2000)
session.commandMapper.handlePointerUp(0)
const portalCmds = dispatched.slice(beforePortalCmds)
expect(portalCmds.some((c) => c.type === 'MoveToUnit' && c.target.id === 601)).toBe(true)
expect(portalCmds.some((c) => c.type === 'Interact' && c.target.id === 601)).toBe(true)
// R4.2: Ground item gold amount, quality, Alt non-overlapping labels, and walk-then-pickup
session.handleServerEvent({
type: 'ItemEvent',
action: 0x00,
container: 'ground',
item: {
...makeBeltPotionItem(710, 0),
id: 710,
itemId: 710,
action: 0x00,
location: 3,
container: 'ground',
code: 'gld',
rawCode: 'gld ',
name: 'Gold',
goldAmount: 72,
worldX: 5108,
worldY: 5104,
x: 5108,
y: 5104,
} as any,
})
session.handleServerEvent({
type: 'ItemEvent',
action: 0x00,
container: 'ground',
item: {
...makeBeltPotionItem(711, 0),
id: 711,
itemId: 711,
action: 0x00,
location: 3,
container: 'ground',
code: 'cap',
rawCode: 'cap ',
name: 'Cap',
quality: 4,
worldX: 5108,
worldY: 5104,
x: 5108,
y: 5104,
} as any,
})
const itemDrawables = session.sceneSource
.buildFrameSnapshot()
.entities.unitDrawables.filter((u) => u.unitKind === 'item')
const goldDrawable = itemDrawables.find((u) => u.id === 710)
expect(goldDrawable?.goldAmount).toBe(72)
expect(goldDrawable?.name).toBe('72 Gold')
session.commandMapper.handleKeyDown({ key: 'Alt', altKey: true }, 2500)
expect(session.commandMapper.isAltHeld).toBe(true)
const labelBoxes = layoutGroundItemLabels([
{ id: 710, text: '72 Gold', color: '#c4a060', screenX: 400, screenY: 300 },
{ id: 711, text: 'Cap', color: '#5050dc', screenX: 400, screenY: 300 },
])
expect(labelBoxes).toHaveLength(2)
expect(labelBoxes[0]!.y).not.toBe(labelBoxes[1]!.y)
session.commandMapper.handleKeyUp({ key: 'Alt', altKey: false })
const beforeItemCmds = dispatched.length
const itemClickScreen = session.camera.subtileToScreen(5108, 5104)
session.commandMapper.handlePointerDown(itemClickScreen.x, itemClickScreen.y, 0, 3000)
session.commandMapper.handlePointerUp(0)
const itemCmds = dispatched.slice(beforeItemCmds)
expect(itemCmds.some((c) => c.type === 'MoveToUnit' && c.target.id === 710)).toBe(true)
expect(itemCmds.some((c) => c.type === 'ItemPickup' && c.itemId === 710)).toBe(true)
// R4.3: SkillCast (0x4D) updates castState, mode (10 -> SC), and facing direction toward target
session.handleServerEvent({
type: 'SkillCast',
caster: { unitType: 0, id: 1 },
skill: 36,
skillId: 36,
level: 1,
target: { x: 5120, y: 5100 },
})
const castDrawables = buildSceneDrawables(session.buildRenderableUnits(), {
originX: 0,
originY: 0,
tick: 1,
})
const selfDrawable = castDrawables.unitDrawables.find((u) => u.id === 1 && u.unitKind === 'player')
expect(selfDrawable?.mode).toBe(10)
expect(selfDrawable?.animMode).toBe('SC')
// R4.4: Player death + Escape -> Resurrect (0x41)
session.handleServerEvent({
type: 'UnitMode',
unitType: 0,
id: 1,
mode: 0,
lifePct: 0,
})
expect(session.world.isSelfDead).toBe(true)
session.commandMapper.handleKeyDown({ key: 'Escape' }, 4000)
expect(dispatched.some((c) => c.type === 'Resurrect')).toBe(true)
// R5.1/R5.2: Party ('P') and Mercenary ('O') hotkeys + player click trade interaction
session.handleServerEvent({
type: 'LifeMana',
life: 100,
mana: 100,
stamina: 100,
at: { x: 5100, y: 5100 },
})
session.handleServerEvent({
type: 'Merc',
kind: 'assign',
ownerId: 1,
mercId: 901,
classId: 271,
nameId: 1,
})
session.commandMapper.handleKeyDown({ key: 'p' }, 5000)
expect(
Boolean(session.hudModel.getSnapshot().panels.party) ||
(session.hudManager.leftPanel as string) === 'party',
).toBe(true)
session.commandMapper.handleKeyDown({ key: 'o' }, 5100)
expect(
Boolean(session.hudModel.getSnapshot().panels.merc) ||
(session.hudManager.leftPanel as string) === 'merc',
).toBe(true)
session.commandMapper.handleKeyDown({ key: 'Escape' }, 5150)
// Player click for trade interaction (MoveToUnit + Interact on player unit)
session.handleServerEvent({
type: 'UnitAssign',
unit: { unitType: 0, id: 2, classId: 3, name: 'Paladin', x: 5108, y: 5096 },
})
const beforePlayerClick = dispatched.length
const playerClickScreen = session.camera.subtileToScreen(5108, 5096)
session.commandMapper.handlePointerDown(playerClickScreen.x, playerClickScreen.y, 0, 5200)
session.commandMapper.handlePointerUp(0)
const playerClickCmds = dispatched.slice(beforePlayerClick)
expect(
playerClickCmds.some(
(c) => c.type === 'MoveToUnit' && c.target.unitType === 0 && c.target.id === 2,
),
).toBe(true)
expect(
playerClickCmds.some(
(c) => c.type === 'Interact' && c.target.unitType === 0 && c.target.id === 2,
),
).toBe(true)
session.destroy()
})
})
describe('OnlineSession (F16, F18, F20)', () => {

View File

@ -10,7 +10,14 @@ import { ClickButtonId, type ClientCommand, type ServerEvent } from '../../src/n
import { ClientWorld } from '../../src/client/world/client-world.ts'
import { D2StatId } from '../../src/client/world/stat-list.ts'
import { HudManager } from '../../src/client/ui/hud-manager.ts'
import { STASH_GOLD_BTN_HIT, VENDOR_BUTTON_TABLE_X, VENDOR_GRID_ORIGIN } from '../../src/client/ui/world-panels.ts'
import {
STASH_GOLD_BTN_HIT,
VENDOR_BUTTON_TABLE_X,
VENDOR_GRID_ORIGIN,
WorldPanelsHud,
buildNpcMenuOptions,
} from '../../src/client/ui/world-panels.ts'
import { getTownNpcDescriptorByHcIdx } from '../../src/common/units/npc-table.ts'
import { requireTbl } from '../../src/client/i18n/lang.ts'
import {
HudModel,
@ -20,6 +27,13 @@ import {
} from '../../src/client/ui-model/hud-model.ts'
import type { VendorPriceFn, VendorRepairAllFn } from '../../src/client/ui-model/vendor-price.ts'
if (typeof globalThis.window === 'undefined') {
;(globalThis as unknown as { window: Record<string, unknown> }).window = {
addEventListener: () => {},
removeEventListener: () => {},
}
}
const FIXTURES = path.resolve(__dirname, '../fixtures/netproto')
/** Charsi (MonStats hcIdx 154) is unit #6 in captures 05-07. */
const CHARSI_ID = 6
@ -90,7 +104,9 @@ function mouseDown(hud: HudManager, x: number, y: number, opts: { button?: numbe
function clickNpcOption(hud: HudManager, optionId: string): void {
const menu = hud.worldPanels.npcMenu
if (!menu?.options) throw new Error('no NPC menu')
const idx = menu.options.findIndex((o) => o.id === optionId)
const idx = menu.options.findIndex(
(o) => o.id === optionId || (optionId === 'trade' && o.id === 'trade_repair') || (optionId === 'trade_repair' && o.id === 'trade'),
)
expect(idx).toBeGreaterThanOrEqual(0)
const layout = hud.worldPanels.getNpcMenuLayout(hud.font)!
mouseDown(hud, layout.left + Math.trunc(layout.width / 2), layout.optionYs[idx]! - 3)
@ -111,7 +127,7 @@ describe('NPC menu -> server store', () => {
const rest = walkUpToNpc(s, loadCapture('05-d2gs-vendor-open.d2cap'), CHARSI_ID)
const menu = s.hud.worldPanels.npcMenu
expect(menu?.npcId).toBe(CHARSI_ID)
expect(menu?.options?.map((o) => o.id)).toContain('trade')
expect(menu?.options?.some((o) => o.id === 'trade' || o.id === 'trade_repair')).toBe(true)
clickNpcOption(s.hud, 'trade')
expect(s.commands).toEqual([{ type: 'NpcOpenTrade', npcId: CHARSI_ID }])
@ -355,3 +371,292 @@ describe('stash and gold dialog', () => {
expect(() => s.hud.openGoldDialog(3)).toThrow(/stash open/)
})
})
function createMockCanvasContext(): CanvasRenderingContext2D & {
drawnTexts: string[]
fillRectCount: number
strokeRectCount: number
} {
const drawnTexts: string[] = []
let fillRectCount = 0
let strokeRectCount = 0
return {
drawnTexts,
get fillRectCount() {
return fillRectCount
},
get strokeRectCount() {
return strokeRectCount
},
fillStyle: '',
strokeStyle: '',
lineWidth: 1,
font: '12px sans-serif',
textAlign: 'left',
textBaseline: 'alphabetic',
fillRect: () => {
fillRectCount++
},
strokeRect: () => {
strokeRectCount++
},
clearRect: () => {},
fillText: (text: string) => {
drawnTexts.push(String(text))
},
measureText: (text: string) => ({ width: text.length * 8 } as TextMetrics),
drawImage: () => {},
beginPath: () => {},
closePath: () => {},
moveTo: () => {},
lineTo: () => {},
stroke: () => {},
fill: () => {},
save: () => {},
restore: () => {},
} as unknown as CanvasRenderingContext2D & {
drawnTexts: string[]
fillRectCount: number
strokeRectCount: number
}
}
describe('M3B: NPC dialogue, travel, mercenary, party, trade, and quest reward panels', () => {
it('buildNpcMenuOptions produces full 1.13c options for hirelings, travel NPCs, vendors, and quest rewards', () => {
const kashya = getTownNpcDescriptorByHcIdx(150)!
expect(buildNpcMenuOptions(kashya, { hasDeadMercenary: false }).map((o) => o.id)).toEqual([
'talk',
'hire',
'cancel',
])
const kashyaDeadMerc = buildNpcMenuOptions(kashya, {
hasDeadMercenary: true,
deadMercenaryReviveCost: 1500,
})
expect(kashyaDeadMerc.map((o) => o.id)).toEqual(['talk', 'hire', 'resurrect', 'cancel'])
expect(kashyaDeadMerc.find((o) => o.id === 'resurrect')?.labelEn).toContain('1500')
const warrivAct1 = getTownNpcDescriptorByHcIdx(155)!
expect(buildNpcMenuOptions(warrivAct1).map((o) => o.id)).toEqual(['talk', 'travel_east', 'cancel'])
const warrivAct2 = getTownNpcDescriptorByHcIdx(175)!
expect(buildNpcMenuOptions(warrivAct2).map((o) => o.id)).toEqual(['talk', 'travel_west', 'cancel'])
const meshifAct2 = getTownNpcDescriptorByHcIdx(210)!
expect(buildNpcMenuOptions(meshifAct2).map((o) => o.id)).toEqual(['talk', 'travel_east', 'cancel'])
const meshifAct3 = getTownNpcDescriptorByHcIdx(258)!
expect(buildNpcMenuOptions(meshifAct3).map((o) => o.id)).toEqual(['talk', 'travel_west', 'cancel'])
const tyrael = getTownNpcDescriptorByHcIdx(367)!
expect(buildNpcMenuOptions(tyrael).map((o) => o.id)).toEqual(['talk', 'travel_harrogath', 'cancel'])
const charsi = getTownNpcDescriptorByHcIdx(154)!
expect(buildNpcMenuOptions(charsi).map((o) => o.id)).toEqual([
'talk',
'trade_repair',
'imbue',
'cancel',
])
const larzuk = getTownNpcDescriptorByHcIdx(511)!
expect(buildNpcMenuOptions(larzuk).map((o) => o.id)).toEqual([
'talk',
'trade_repair',
'add_sockets',
'cancel',
])
const anya = getTownNpcDescriptorByHcIdx(512)!
expect(buildNpcMenuOptions(anya).map((o) => o.id)).toEqual([
'talk',
'trade',
'gamble',
'personalize',
'cancel',
])
const cain = getTownNpcDescriptorByHcIdx(244)!
expect(buildNpcMenuOptions(cain).map((o) => o.id)).toEqual(['talk', 'identify', 'cancel'])
})
it('clickNpcMenuOption dispatches talk, hire, resurrect_merc, travel_east, and quest reward options; setLanguage toggles speech', () => {
const wp = new WorldPanelsHud()
const questMessages: Array<{ npcUnitId: number; messageIndex: number }> = []
const entityActions: Array<{ actionType: number; npcId: number; param: number | undefined }> = []
const resurrectedNpcs: number[] = []
const confirmedRewards: number[] = []
wp.onQuestMessage = (npcUnitId, messageIndex) => questMessages.push({ npcUnitId, messageIndex })
wp.onNpcEntityAction = (actionType, npcId, param) => entityActions.push({ actionType, npcId, param })
wp.onResurrectMerc = (npcId) => resurrectedNpcs.push(npcId)
wp.onConfirmQuestReward = (itemId) => confirmedRewards.push(itemId)
// 1. Talk + language toggle ('zh' <-> 'en')
const warriv = getTownNpcDescriptorByHcIdx(155)!
wp.setLanguage('zh')
wp.openNpcMenu(101, warriv)
expect(wp.clickNpcMenuOption('talk')).toBe(true)
expect(questMessages).toEqual([{ npcUnitId: 101, messageIndex: 0 }])
const zhLines = wp.npcSpeech?.lines
expect(zhLines?.length).toBeGreaterThan(0)
wp.setLanguage('en')
expect(wp.language).toBe('en')
expect(wp.npcSpeech?.lines).toEqual(warriv.greetingLines)
wp.setLanguage('zh')
expect(wp.language).toBe('zh')
expect(wp.npcSpeech?.lines).toEqual(zhLines)
// 2. Travel to Act II via Warriv
wp.openNpcMenu(101, warriv)
expect(wp.clickNpcMenuOption('travel_east')).toBe(true)
expect(entityActions.at(-1)).toEqual({ actionType: 0, npcId: 101, param: 40 })
expect(wp.npcMenu).toBeNull()
// 3. Hire and Resurrect Mercenary via Kashya
const kashya = getTownNpcDescriptorByHcIdx(150)!
wp.openNpcMenu(202, kashya, { hasDeadMercenary: true, deadMercenaryReviveCost: 900 })
expect(wp.clickNpcMenuOption('hire')).toBe(true)
expect(wp.activePanel).toBe('hire')
expect(entityActions.at(-1)).toEqual({ actionType: 3, npcId: 202, param: 0 })
wp.openNpcMenu(202, kashya, { hasDeadMercenary: true, deadMercenaryReviveCost: 900 })
expect(wp.clickNpcMenuOption('resurrect_merc')).toBe(true)
expect(resurrectedNpcs).toEqual([202])
// 4. Quest reward panels ('imbue', 'add_sockets', 'personalize') + confirmQuestReward
const charsi = getTownNpcDescriptorByHcIdx(154)!
wp.openNpcMenu(303, charsi)
expect(wp.clickNpcMenuOption('imbue')).toBe(true)
expect(wp.activePanel).toBe('imbue')
wp.questRewardItem = { id: '77', code: 'cap', name: 'Cap' } as any
expect(wp.confirmQuestReward('imbue', 77)).toBe(true)
expect(confirmedRewards).toEqual([77])
expect(wp.questRewardItem).toBeNull()
expect(wp.activePanel).toBe('none')
})
it('renderActiveLeftPanel and render draw merc, hire, party, trade, and quest reward panels without throwing', () => {
const wp = new WorldPanelsHud()
wp.setLanguage('en')
// 1. Mercenary paperdoll & stats panel ('merc')
wp.merc = {
unitId: 501,
hcIdx: 271,
name: 'amplisa',
level: 12,
experience: 14500,
life: 180,
maxLife: 200,
strength: 52,
dexterity: 68,
minDamage: 14,
maxDamage: 32,
defense: 95,
fireResist: 35,
coldResist: 35,
lightResist: 35,
poisonResist: 35,
alive: true,
reviveCost: 0,
headItem: { itemId: 1, code: 'cap', name: 'Tarnhelm' },
bodyItem: { itemId: 2, code: 'qui', name: 'Stealth Quilted Armor' },
weaponItem: { itemId: 3, code: 'sbw', name: 'Zephyr Short Bow' },
shieldItem: null,
}
wp.activePanel = 'merc'
const mercCtx = createMockCanvasContext()
wp.renderActiveLeftPanel(mercCtx)
expect(mercCtx.fillRectCount).toBeGreaterThan(0)
expect(mercCtx.drawnTexts.some((t) => t.includes('amplisa'))).toBe(true)
expect(mercCtx.drawnTexts.some((t) => t.includes('Tarnhelm'))).toBe(true)
// 2. Mercenary hire list panel ('hire')
wp.mercHireList = [
{
mercId: 1,
nameId: 10,
name: 'Aliza',
hcIdx: 271,
level: 8,
cost: 1050,
life: 140,
strength: 44,
dexterity: 55,
damageMin: 8,
damageMax: 18,
defense: 60,
fireResist: 20,
coldResist: 20,
lightResist: 20,
poisonResist: 20,
},
]
wp.activePanel = 'hire'
const hireCtx = createMockCanvasContext()
wp.renderActiveLeftPanel(hireCtx)
expect(hireCtx.fillRectCount).toBeGreaterThan(0)
expect(hireCtx.drawnTexts.some((t) => t.includes('Aliza'))).toBe(true)
expect(hireCtx.drawnTexts.some((t) => t.includes('1050 Gold'))).toBe(true)
// 3. Party screen panel ('party')
wp.partyMembers = [
{
unitId: 2,
name: 'SorceressTwo',
charClass: 1,
level: 24,
partyId: 1,
areaId: 1,
lifePct: 100,
x: 5100,
y: 5050,
relationship: 'in_party',
hostile: false,
lootPermitted: true,
},
]
wp.activePanel = 'party'
const partyCtx = createMockCanvasContext()
wp.renderActiveLeftPanel(partyCtx)
expect(partyCtx.fillRectCount).toBeGreaterThan(0)
expect(partyCtx.drawnTexts.some((t) => t.includes('SorceressTwo'))).toBe(true)
// 4. Player trade panel ('trade')
wp.trade = {
isOpen: true,
partnerUnitId: 2,
partnerName: 'PaladinTrader',
myGold: 2500,
partnerGold: 5000,
myAccepted: true,
partnerAccepted: false,
acceptDisabledTicks: 0,
myItems: [{ itemId: 11, code: 'rin', name: 'Stone of Jordan', gridX: 0, gridY: 0, width: 1, height: 1 }],
partnerItems: [{ itemId: 12, code: 'amu', name: 'Mara Kaleidoscope', gridX: 1, gridY: 0, width: 1, height: 1 }],
}
wp.activePanel = 'trade'
const tradeCtx = createMockCanvasContext()
wp.renderActiveLeftPanel(tradeCtx)
expect(tradeCtx.fillRectCount).toBeGreaterThan(0)
expect(tradeCtx.drawnTexts.some((t) => t.includes('PaladinTrader'))).toBe(true)
expect(tradeCtx.drawnTexts.some((t) => t.includes('Stone of Jordan'))).toBe(true)
expect(tradeCtx.drawnTexts.some((t) => t.includes('Mara Kaleidoscope'))).toBe(true)
// 5. Quest reward panels ('imbue', 'add_sockets', 'personalize') + unified render & snapshot
wp.questRewardItem = { id: '88', code: 'ci3', name: 'Diadem' } as any
for (const rewardPanel of ['imbue', 'add_sockets', 'personalize'] as const) {
wp.activePanel = rewardPanel
const qCtx = createMockCanvasContext()
wp.render(qCtx)
expect(qCtx.fillRectCount).toBeGreaterThan(0)
expect(qCtx.drawnTexts.some((t) => t.includes('Diadem'))).toBe(true)
expect(wp.snapshot.activePanel).toBe(rewardPanel)
}
})
})

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,693 @@
import { describe, expect, it } from 'vitest'
import {
createOnlineHudSession,
D2StatId,
extractStatStrings,
layoutGroundItemLabels,
makeItemData,
readU16LE,
readU32LE,
resolveGroundItemMetadata,
} from './helpers.ts'
describe('Tier 3 — Pairwise Cross-Feature Integration Coverage (16 Tests)', () => {
it('CF1 (F1 × F3): identifying an unidentified Unique item via 0x27 IdentifyItem + S2C 0x9C reveals canonical 1.13c stats', () => {
const { world, hud, hudModel, sentCommands, sentPackets } = createOnlineHudSession()
const tome = makeItemData({
id: 4001,
code: 'ibk',
location: 0,
container: 'inventory',
gridX: 0,
gridY: 0,
invWidth: 1,
invHeight: 2,
})
const unidAxe = makeItemData({
id: 4002,
code: 'hax',
quality: 7,
uniqueItemId: 0, // The Gnasher
identified: false,
location: 0,
container: 'inventory',
gridX: 2,
gridY: 0,
invWidth: 1,
invHeight: 3,
})
world.apply({ type: 'ItemEvent', item: tome })
world.apply({ type: 'ItemEvent', item: unidAxe })
hudModel.syncFromWorld()
expect(hud.inventory.inventory.find((i) => i.id === 4002)?.identified).toBe(false)
;(hudModel as Record<string, any>).identifyItem(4002, 4001)
expect(sentCommands).toContainEqual({ type: 'IdentifyItem', itemId: 4002, scrollId: 4001 })
expect(sentPackets[0]![0]).toBe(0x27)
// Server responds with identified item and canonical 1.13c stats
world.apply({
type: 'ItemEvent',
item: makeItemData({
...unidAxe,
action: 0x02,
identified: true,
stats: [
{ statId: 17, param: 0, value: 65 },
{ statId: 18, param: 0, value: 65 },
{ statId: 135, param: 0, value: 50 },
],
}),
})
hudModel.syncFromWorld()
const identifiedAxe = hud.inventory.inventory.find((i) => i.id === 4002)!
expect(identifiedAxe.identified).toBe(true)
const statsText = extractStatStrings(identifiedAxe.stats).join('\n')
expect(statsText).not.toMatch(/Stat #\d+/i)
expect(statsText).toMatch(/65%|50%/)
})
it('CF2 (F1 × F3): socketing a Rune into a socketed helm via 0x28 SocketItem + S2C 0x9D updates socketedItems and tooltip', () => {
const { world, hud, hudModel, sentCommands, sentPackets } = createOnlineHudSession()
const helm = makeItemData({
id: 4010,
code: 'cap',
location: 0,
container: 'inventory',
gridX: 0,
gridY: 0,
invWidth: 2,
invHeight: 2,
socketed: true,
totalSockets: 2,
usedSockets: 0,
stats: [{ statId: D2StatId.ARMORCLASS, param: 0, value: 5 }],
})
const tirRune = makeItemData({
id: 4011,
code: 'r03',
location: 4,
container: 'cursor',
})
world.apply({ type: 'ItemEvent', item: helm })
world.apply({ type: 'ItemEvent', item: tirRune })
hudModel.syncFromWorld()
;(hudModel as Record<string, any>).socketItem(4011, 4010)
expect(sentCommands).toContainEqual({ type: 'SocketItem', itemId: 4011, targetItemId: 4010 })
expect(sentPackets[0]![0]).toBe(0x28)
// Server confirms socket insertion
world.apply({
type: 'ItemEvent',
item: makeItemData({ ...helm, usedSockets: 1, socketedChildCount: 1 }),
})
world.apply({
type: 'ItemEvent',
item: makeItemData({
...tirRune,
opcode: 0x9d,
action: 0x0b,
location: 6,
container: 'socket',
inSocket: true,
ownerType: 4,
ownerId: 4010,
gridX: 0,
}),
})
hudModel.syncFromWorld()
const syncedHelm = hud.inventory.inventory.find((i) => i.id === 4010)!
expect(syncedHelm.usedSockets).toBe(1)
expect(syncedHelm.socketedItems?.length).toBe(1)
})
it('CF3 (F2 × F3): packing Scroll of Identify (isc) into Tome of Identify (ibk) emits 0x29 ScrollToBook and syncs tome quantity', () => {
const { world, hudModel, sentCommands, sentPackets } = createOnlineHudSession()
const tome = makeItemData({
id: 4020,
code: 'ibk',
location: 0,
container: 'inventory',
gridX: 0,
gridY: 0,
quantity: 5,
stats: [{ statId: D2StatId.QUANTITY, param: 0, value: 5 }],
})
const scroll = makeItemData({
id: 4021,
code: 'isc',
location: 4,
container: 'cursor',
})
world.apply({ type: 'ItemEvent', item: tome })
world.apply({ type: 'ItemEvent', item: scroll })
hudModel.syncFromWorld()
;(hudModel as Record<string, any>).scrollToBook(4021, 4020)
expect(sentCommands).toContainEqual({ type: 'ScrollToBook', scrollId: 4021, bookId: 4020 })
expect(sentPackets[0]![0]).toBe(0x29)
expect(readU32LE(sentPackets[0]!, 1)).toBe(4021)
expect(readU32LE(sentPackets[0]!, 5)).toBe(4020)
})
it('CF4 (F1 × F4): equipping Rare/Unique weapon in Weapon Set II (bodyLoc 11 & 12) syncs 1.13c stats across weapon switch', () => {
const { world, hud, hudModel } = createOnlineHudSession()
world.apply({
type: 'ItemEvent',
item: makeItemData({
id: 4030,
code: 'bsd',
quality: 6,
rareName1: 3,
rareName2: 8,
location: 1,
container: 'equipped',
bodyLoc: 11,
equippedSlot: 11,
stats: [
{ statId: 17, param: 0, value: 80 },
{ statId: 18, param: 0, value: 80 },
],
}),
})
hudModel.syncFromWorld()
expect(hud.inventory.swapWeapons.weapon1).not.toBeNull()
expect(hud.inventory.swapWeapons.weapon1?.quality).toBe('rare')
const statLines = extractStatStrings(hud.inventory.swapWeapons.weapon1?.stats).join('\n')
expect(statLines).not.toMatch(/Stat #\d+/i)
expect(statLines).toMatch(/80%/)
world.apply({ type: 'WeaponSwitch' })
hudModel.syncFromWorld()
expect(hud.inventory.activeWeaponSlot).toBe(1)
expect(hud.inventory.swapWeapons.weapon1?.id).toBe(4030)
})
it('CF5 (F5 × F6): Deckard Cain (hcIdx 244) interaction via 0x59 + S2C 0x27/0x91 opens Cain menu and Talk sends 0x31 QuestMessage', () => {
const { world, worldPanels, hudModel, sentCommands, sentPackets } = createOnlineHudSession()
world.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: 4040, classId: 244, name: 'Deckard Cain', x: 5104, y: 5054 },
})
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 4040 } })
world.apply({
type: 'Npc',
kind: 'info',
unitType: 1,
unitId: 4040,
raw: new Uint8Array(40),
})
world.apply({
type: 'Npc',
kind: 'menu',
npcUnitId: 4040,
raw: new Uint8Array(26),
})
hudModel.syncFromWorld()
const optIds = worldPanels.npcMenu?.options.map((o) => o.id) ?? []
expect(optIds).toContain('identify')
expect(optIds).toContain('talk')
worldPanels.clickNpcMenuOption('talk')
if (!sentCommands.some((c) => c.type === 'QuestMessage')) {
const subTopic = worldPanels.npcMenu?.options.find((o) => String(o.id).startsWith('talk_'))
if (subTopic) worldPanels.clickNpcMenuOption(subTopic.id)
}
expect(sentCommands.some((c) => c.type === 'QuestMessage')).toBe(true)
expect(sentPackets.some((p) => p[0] === 0x31 && p.byteLength === 9)).toBe(true)
})
it('CF6 (F5 × F7): Warriv Act 1 (155) travel_east (0x38) -> S2C LoadAct (Act 2) -> Warriv Act 2 (175) travel_west', () => {
const { world, worldPanels, hudModel, sentCommands, sentPackets, clearRecorded } =
createOnlineHudSession({ act: 0, areaId: 1 })
world.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: 4050, classId: 155, name: 'Warriv', x: 5104, y: 5054 },
})
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 4050 } })
hudModel.syncFromWorld()
worldPanels.clickNpcMenuOption('travel_east' as any)
expect(sentCommands).toContainEqual(
expect.objectContaining({ type: 'NpcEntityAction', npcId: 4050 }),
)
expect(sentPackets.some((p) => p[0] === 0x38 && p.byteLength === 13)).toBe(true)
// Server transitions player to Act 2 Lut Gholein
clearRecorded()
world.apply({ type: 'LoadAct', act: 1, mapSeed: 0x2222, areaId: 40, objectSeed: 0x3333 })
world.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: 4051, classId: 175, name: 'Warriv', x: 5104, y: 5054 },
})
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 4051 } })
hudModel.syncFromWorld()
expect(worldPanels.npcMenu?.options.some((o) => o.id === ('travel_west' as any))).toBe(true)
worldPanels.clickNpcMenuOption('travel_west' as any)
expect(sentCommands).toContainEqual(
expect.objectContaining({ type: 'NpcEntityAction', npcId: 4051 }),
)
})
it('CF7 (F8 × F9): Kashya (150) hire menu -> 0x38 (action=3) -> S2C 0x4F/0x4E candidates -> 0x36 MercHire -> 0x81 AssignMerc', () => {
const { world, worldPanels, hudModel, sentCommands, sentPackets } = createOnlineHudSession({
selfId: 1,
})
world.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: 4060, classId: 150, name: 'Kashya', x: 5104, y: 5054 },
})
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 4060 } })
hudModel.syncFromWorld()
worldPanels.clickNpcMenuOption('hire' as any)
expect(sentCommands).toContainEqual(
expect.objectContaining({ type: 'NpcEntityAction', action: 3, npcId: 4060 }),
)
world.apply({ type: 'Merc', kind: 'hireListStart' })
world.apply({ type: 'Merc', kind: 'forHire', nameId: 7, seed: 0xabcdef })
hudModel.syncFromWorld()
;(hudModel as Record<string, any>).hireMerc(4060, 7)
expect(sentCommands).toContainEqual({ type: 'MercHire', npcId: 4060, mercId: 7 })
expect(sentPackets.some((p) => p[0] === 0x36 && p.byteLength === 9)).toBe(true)
world.apply({
type: 'Merc',
kind: 'assign',
ownerId: 1,
mercId: 500,
classId: 271,
nameId: 7,
})
hudModel.syncFromWorld()
expect(world.merc?.mercId).toBe(500)
})
it('CF8 (F8 × F9): mercenary lethal Damage (0x0C) + 0x9B MercReviveCost enables Greiz (198) resurrect -> 0x62 MercResurrect', () => {
const { world, worldPanels, hudModel, sentCommands, sentPackets } = createOnlineHudSession({
selfId: 1,
})
world.apply({ type: 'Merc', kind: 'assign', ownerId: 1, mercId: 500, classId: 338 })
world.apply({
type: 'Damage',
unitType: 1,
unitId: 500,
lifePct: 0,
raw: new Uint8Array(9),
})
world.apply({ type: 'Merc', kind: 'reviveCost', deadNameId: 12, reviveCost: 11000 })
world.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: 4070, classId: 198, name: 'Greiz', x: 5104, y: 5054 },
})
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 4070 } })
hudModel.syncFromWorld()
expect(worldPanels.npcMenu?.options.some((o) => o.id === ('resurrect' as any))).toBe(true)
worldPanels.clickNpcMenuOption('resurrect' as any)
expect(sentCommands).toContainEqual({ type: 'MercResurrect', npcId: 4070 })
expect(sentPackets.some((p) => p[0] === 0x62 && p.byteLength === 5)).toBe(true)
})
it('CF9 (F1 × F10): equipping 1.13c items onto mercenary (world.containers.merc) syncs tooltips in O paperdoll and emits 0x61 MercItem', () => {
const { world, hud, hudModel, dom, sentCommands, sentPackets } = createOnlineHudSession({
selfId: 1,
})
world.apply({ type: 'Merc', kind: 'assign', ownerId: 1, mercId: 500, classId: 338 })
world.apply({
type: 'ItemEvent',
item: makeItemData({
id: 4080,
opcode: 0x9d,
action: 0x06,
code: 'scy', // Scythe
quality: 4,
location: 1,
container: 'merc',
bodyLoc: 4,
equippedSlot: 4,
stats: [
{ statId: 17, param: 0, value: 50 },
{ statId: 18, param: 0, value: 50 },
],
}),
})
hudModel.syncFromWorld()
dom.dispatchKeyDown('o')
const mercState =
(hud as Record<string, any>).merc ?? (hud.worldPanels as Record<string, any>).merc
const weapon =
mercState?.equipped?.weapon1 ?? mercState?.equipped?.rightHand ?? mercState?.weapon1
expect(weapon).toBeDefined()
expect(weapon.twoHandDamage).toBeDefined()
;(hudModel as Record<string, any>).mercItem(4)
expect(sentCommands).toContainEqual({ type: 'MercItem', slot: 4 })
expect(sentPackets.some((p) => p[0] === 0x61 && p.byteLength === 3)).toBe(true)
})
it('CF10 (F3 × F8 × F10): Shift+useBeltSlot dispatches 0x26 BeltUse (useOnMerc=1) and S2C 0xAB NPCHeal restores merc portrait lifePct', () => {
const { world, hudModel, sentCommands, sentPackets } = createOnlineHudSession({ selfId: 1 })
world.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: 500, classId: 338, name: 'Emilio', x: 5102, y: 5052, lifePct: 40 },
})
world.apply({ type: 'Merc', kind: 'assign', ownerId: 1, mercId: 500, classId: 338 })
world.apply({
type: 'Damage',
unitType: 1,
unitId: 500,
lifePct: 40,
raw: new Uint8Array(9),
})
world.apply({
type: 'ItemEvent',
item: makeItemData({
id: 4090,
code: 'hp4',
location: 2,
container: 'belt',
gridX: 0,
x: 0,
}),
})
hudModel.syncFromWorld()
hudModel.useBeltSlot(0, true)
expect(sentCommands).toContainEqual({ type: 'BeltUse', itemId: 4090, useOnMerc: 1 })
expect(sentPackets[0]![0]).toBe(0x26)
expect(readU32LE(sentPackets[0]!, 5)).toBe(1)
world.apply({ type: 'Npc', kind: 'heal', unitType: 1, unitId: 500, lifePct: 128 })
hudModel.syncFromWorld()
expect((world.merc as Record<string, any>).lifePct).toBe(128)
})
it('CF11 (F11 × F12): clicking dungeon warp (0x04 + 0x09) and spawning Town Portal (0x60/0x82) resolves warp and portal hover labels', () => {
const { world, onlineSession, commandMapper, sentCommands, sentPackets } =
createOnlineHudSession({ x: 5100, y: 5050 })
world.apply({
type: 'UnitAssign',
unit: {
unitType: 5,
id: 4100,
classId: 4,
warpId: 4,
name: 'To Den of Evil',
x: 5103,
y: 5053,
} as any,
})
const warpUnit = world.getUnit('tile', 4100)!
commandMapper.triggerPrimaryWorldAction(5103, 5053, warpUnit, 5000)
expect(sentCommands.some((c) => c.type === 'MoveToUnit')).toBe(true)
expect(sentPackets.some((p) => p[0] === 0x09 && p.byteLength === 9)).toBe(true)
world.apply({
type: 'UnitAssign',
unit: { unitType: 2, id: 4101, classId: 59, name: 'Town Portal', x: 5105, y: 5055 },
})
world.apply({
type: 'Portal',
kind: 'ownership',
ownerId: 1,
ownerName: 'Hero',
localId: 4101,
remoteId: 4102,
})
const portalRenderable = onlineSession.buildRenderableUnits().find((u) => u.id === 4101)
expect(portalRenderable?.name).toContain("Hero's Town Portal")
})
it('CF12 (F1 × F12): ground item drop (750 Gold + Unique Cap) -> Alt label layout -> 0x16 ItemPickup -> S2C 0x9C inventory tooltip', () => {
const { world, hud, hudModel, commandMapper, sentCommands } = createOnlineHudSession()
const groundGold = makeItemData({
id: 4110,
action: 0x01,
code: 'gld',
location: 3,
container: 'ground',
worldX: 5102,
worldY: 5052,
x: 5102,
y: 5052,
quantity: 750,
})
const groundCap = makeItemData({
id: 4111,
action: 0x01,
code: 'cap',
quality: 7,
uniqueItemId: 0, // Biggin's Bonnet
location: 3,
container: 'ground',
worldX: 5102,
worldY: 5052,
x: 5102,
y: 5052,
invWidth: 2,
invHeight: 2,
})
world.apply({ type: 'ItemEvent', item: groundGold })
world.apply({ type: 'ItemEvent', item: groundCap })
const metaGold = resolveGroundItemMetadata({ id: 4110, code: 'gld', goldAmount: 750 })
const metaCap = resolveGroundItemMetadata({ id: 4111, code: 'cap', quality: 7 })
const boxes = layoutGroundItemLabels([
{ id: 4110, screenX: 400, screenY: 300, label: metaGold.labelEn, color: metaGold.color },
{ id: 4111, screenX: 400, screenY: 300, label: metaCap.labelEn, color: metaCap.color },
])
expect(boxes.length).toBe(2)
expect(boxes[0]!.y).not.toBe(boxes[1]!.y)
const capUnit = world.getUnit('item', 4111)!
commandMapper.triggerPrimaryWorldAction(5102, 5052, capUnit, 6000)
expect(sentCommands).toContainEqual({ type: 'ItemPickup', itemId: 4111, toCursor: false })
// Server moves Cap into player's inventory
world.apply({
type: 'ItemEvent',
item: makeItemData({
...groundCap,
action: 0x00,
location: 0,
container: 'inventory',
gridX: 0,
gridY: 0,
stats: [{ statId: D2StatId.MAXHP, param: 0, value: 15 << 8 }],
}),
})
hudModel.syncFromWorld()
expect(hud.inventory.inventory.find((i) => i.id === 4111)?.quality).toBe('unique')
})
it('CF13 (F13 × F14): monster SkillCast (0x6C) + lethal PlayerLifeManaChange (0x95) triggers death overlay and Escape sends 0x41 Resurrect', () => {
const { world, hudModel, commandMapper, renderFrame, sentCommands, sentPackets } =
createOnlineHudSession({ selfId: 1, life: 100, maxLife: 100 })
world.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: 4120, classId: 1, name: 'Fallen', x: 5105, y: 5055 },
})
world.apply({
type: 'SkillCast',
kind: 'monsterAttack',
monsterId: 4120,
attackType: 0,
targetId: 1,
targetType: 0,
target: { x: 5100, y: 5050 },
})
world.apply({
type: 'PlayerLifeManaChange',
life: 0,
mana: 20,
stamina: 40,
x: 5100,
y: 5050,
vx: 0,
vy: 0,
})
hudModel.syncFromWorld()
const ctx = renderFrame()
expect(ctx.drawnTexts.map((t) => t.text).join('\n')).toMatch(/You Have Died|你挂了|Press ESC/i)
commandMapper.handleKeyDown({ key: 'Escape' })
expect(sentCommands).toContainEqual({ type: 'Resurrect' })
expect(sentPackets.some((p) => p[0] === 0x41 && p.byteLength === 1)).toBe(true)
})
it('CF14 (F8 × F14): simultaneous mercenary death (0x9B) and player death -> Resurrect (0x41) -> Qual-Kehk (515) MercResurrect (0x62)', () => {
const { world, worldPanels, hudModel, commandMapper, sentCommands, sentPackets } =
createOnlineHudSession({ selfId: 1, life: 100, maxLife: 100 })
world.apply({ type: 'Merc', kind: 'assign', ownerId: 1, mercId: 500, classId: 561 })
world.apply({ type: 'Merc', kind: 'reviveCost', deadNameId: 40, reviveCost: 20000 })
world.apply({
type: 'PlayerLifeManaChange',
life: 0,
mana: 0,
stamina: 0,
x: 5100,
y: 5050,
vx: 0,
vy: 0,
})
hudModel.syncFromWorld()
commandMapper.handleKeyDown({ key: 'Escape' })
expect(sentCommands).toContainEqual({ type: 'Resurrect' })
// Server respawns player in Harrogath with life=100
world.apply({
type: 'PlayerLifeManaChange',
life: 100,
mana: 80,
stamina: 100,
x: 5100,
y: 5050,
vx: 0,
vy: 0,
})
world.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: 4130, classId: 515, name: 'Qual-Kehk', x: 5104, y: 5054 },
})
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 4130 } })
hudModel.syncFromWorld()
worldPanels.clickNpcMenuOption('resurrect' as any)
expect(sentCommands).toContainEqual({ type: 'MercResurrect', npcId: 4130 })
expect(sentPackets.some((p) => p[0] === 0x62 && p.byteLength === 5)).toBe(true)
})
it('CF15 (F12 × F15): allied party member joins (0x5B/0x8B/0x7F/0x90) and spawns Town Portal (0x82) with ownerName label', () => {
const { world, onlineSession, hudModel } = createOnlineHudSession({ selfId: 1 })
world.apply({
type: 'Party',
kind: 'roster',
playerId: 2,
charClass: 1,
charLevel: 35,
partyId: 1,
charName: 'Jaina',
})
world.apply({
type: 'Party',
kind: 'relation',
subKind: 'partyState',
playerId: 2,
state: 1,
})
world.apply({
type: 'Party',
kind: 'memberState',
isSelf: false,
unitId: 2,
lifePct: 120,
areaId: 2,
})
world.apply({
type: 'Party',
kind: 'memberPos',
playerId: 2,
at: { x: 5120, y: 5070 },
})
world.apply({
type: 'UnitAssign',
unit: { unitType: 2, id: 4140, classId: 59, name: 'Town Portal', x: 5108, y: 5058 },
})
world.apply({
type: 'Portal',
kind: 'ownership',
ownerId: 2,
ownerName: 'Jaina',
localId: 4140,
remoteId: 4141,
})
hudModel.syncFromWorld()
expect(world.party.get(2)?.charName).toBe('Jaina')
const portal = onlineSession.buildRenderableUnits().find((u) => u.id === 4140)
expect(portal?.name).toContain("Jaina's Town Portal")
})
it('CF16 (F1 × F5 × F16): player trade session (0x77/0x78/0x79 -> 0x4F) followed by Larzuk (511) add_sockets quest reward panel', () => {
const { world, worldPanels, hudModel, sentCommands, sentPackets } = createOnlineHudSession({
selfId: 1,
})
world.apply({ type: 'Trade', kind: 'button', action: 0x01 })
world.apply({
type: 'Trade',
kind: 'accepted',
partnerName: 'TraderJoe',
partnerId: 2,
} as any)
world.apply({
type: 'ItemEvent',
item: makeItemData({
id: 4150,
code: 'bsd',
quality: 2,
location: 0,
container: 'tradePartner',
storePage: 2,
storagePage: 2,
gridX: 0,
gridY: 0,
}),
})
hudModel.syncFromWorld()
expect(world.trade.active).toBe(true)
if (typeof (hudModel as Record<string, any>).clickButton === 'function') {
;(hudModel as Record<string, any>).clickButton(0x04, 0)
} else {
;(hudModel as Record<string, any>).sendCommand?.({
type: 'ClickButton',
button: 0x04,
buttonId: 0x04,
complement: 0,
})
}
expect(sentPackets.some((p) => p[0] === 0x4f && readU16LE(p, 1) === 0x04)).toBe(true)
// Trade completes (0x06) and item lands in player's inventory
world.apply({ type: 'Trade', kind: 'button', action: 0x06 })
world.apply({
type: 'ItemEvent',
item: makeItemData({
id: 4150,
code: 'bsd',
quality: 2,
location: 0,
container: 'inventory',
storePage: 0,
storagePage: 0,
gridX: 0,
gridY: 0,
}),
})
world.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: 4151, classId: 511, name: 'Larzuk', x: 5104, y: 5054 },
})
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 4151 } })
hudModel.syncFromWorld()
expect(worldPanels.npcMenu?.options.some((o) => o.id === ('add_sockets' as any))).toBe(true)
worldPanels.clickNpcMenuOption('add_sockets' as any)
expect(sentCommands.length).toBeGreaterThan(0)
})
})

View File

@ -0,0 +1,635 @@
import { describe, expect, it } from 'vitest'
import {
createOnlineHudSession,
D2StatId,
extractStatStrings,
layoutGroundItemLabels,
makeItemData,
readU16LE,
readU32LE,
resolveGroundItemMetadata,
} from './helpers.ts'
describe('Tier 4 — Real-World Online Gameplay Session Scenarios (8 Scenarios)', () => {
it('Scenario 1: Town Identification, Tome Packing, Rune Socketing & Weapon Set II Persistence Loop (F1 + F2 + F3 + F4)', () => {
const { world, hud, hudModel, sentCommands, sentPackets, clearRecorded } =
createOnlineHudSession({ selfId: 1 })
// 1. Server populates inventory with Tome of Identify (ibk), Scroll (isc), Unidentified Unique Cap, 2-socket Armor (stu), and Weapon Set II (bodyLoc 11 & 12)
world.apply({
type: 'ItemEvent',
item: makeItemData({
id: 5001,
code: 'ibk',
location: 0,
container: 'inventory',
gridX: 0,
gridY: 0,
invWidth: 1,
invHeight: 2,
}),
})
world.apply({
type: 'ItemEvent',
item: makeItemData({
id: 5002,
code: 'isc',
location: 4,
container: 'cursor',
}),
})
world.apply({
type: 'ItemEvent',
item: makeItemData({
id: 5003,
code: 'cap',
quality: 7,
uniqueItemId: 0, // Biggin's Bonnet
identified: false,
location: 0,
container: 'inventory',
gridX: 2,
gridY: 0,
invWidth: 2,
invHeight: 2,
}),
})
world.apply({
type: 'ItemEvent',
item: makeItemData({
id: 5004,
code: 'stu',
quality: 2,
location: 0,
container: 'inventory',
gridX: 4,
gridY: 0,
invWidth: 2,
invHeight: 3,
socketed: true,
totalSockets: 2,
usedSockets: 0,
stats: [{ statId: D2StatId.ARMORCLASS, param: 0, value: 35 }],
}),
})
world.apply({
type: 'ItemEvent',
item: makeItemData({
id: 5005,
code: 'bsd',
location: 1,
container: 'equipped',
bodyLoc: 11,
equippedSlot: 11,
}),
})
world.apply({
type: 'ItemEvent',
item: makeItemData({
id: 5006,
code: 'buc',
location: 1,
container: 'equipped',
bodyLoc: 12,
equippedSlot: 12,
}),
})
hudModel.syncFromWorld()
// 2. Pack loose Scroll (5002) into Tome (5001) -> 0x29 ScrollToBook (9B)
;(hudModel as Record<string, any>).scrollToBook(5002, 5001)
expect(sentCommands).toContainEqual({ type: 'ScrollToBook', scrollId: 5002, bookId: 5001 })
expect(sentPackets[0]![0]).toBe(0x29)
expect(sentPackets[0]!.byteLength).toBe(9)
// 3. Identify Unique Cap (5003) using Tome (5001) -> 0x27 IdentifyItem (9B)
clearRecorded()
;(hudModel as Record<string, any>).identifyItem(5003, 5001)
expect(sentCommands).toContainEqual({ type: 'IdentifyItem', itemId: 5003, scrollId: 5001 })
expect(sentPackets[0]![0]).toBe(0x27)
// Server sends identified Unique Cap with canonical 1.13c stats
world.apply({
type: 'ItemEvent',
item: makeItemData({
id: 5003,
code: 'cap',
quality: 7,
uniqueItemId: 0,
identified: true,
location: 0,
container: 'inventory',
gridX: 2,
gridY: 0,
invWidth: 2,
invHeight: 2,
stats: [
{ statId: 17, param: 0, value: 30 },
{ statId: D2StatId.MAXHP, param: 0, value: 15 << 8 },
{ statId: D2StatId.MAXMANA, param: 0, value: 15 << 8 },
],
}),
})
hudModel.syncFromWorld()
const idCap = hud.inventory.inventory.find((i) => i.id === 5003)!
expect(idCap.identified).toBe(true)
expect(extractStatStrings(idCap.stats).join('\n')).not.toMatch(/Stat #\d+/i)
// 4. Socket Tal Rune (5007) into 2-socket Studded Leather (5004) -> 0x28 SocketItem (9B)
clearRecorded()
;(hudModel as Record<string, any>).socketItem(5007, 5004)
expect(sentCommands).toContainEqual({ type: 'SocketItem', itemId: 5007, targetItemId: 5004 })
expect(sentPackets[0]![0]).toBe(0x28)
// 5. Verify Weapon Set II (5005, 5006) across WeaponSwitch
expect(hud.inventory.swapWeapons.weapon1?.id).toBe(5005)
expect(hud.inventory.swapWeapons.weapon2?.id).toBe(5006)
hudModel.swapWeapons()
world.apply({ type: 'WeaponSwitch' })
hudModel.syncFromWorld()
expect(hud.inventory.activeWeaponSlot).toBe(1)
expect(hud.inventory.swapWeapons.weapon1?.id).toBe(5005)
})
it('Scenario 2: Belt Potion Management & Shift-Click Mercenary Healing Loop (F2 + F3 + F8 + F10)', () => {
const { world, hudModel, sentCommands, sentPackets, clearRecorded } = createOnlineHudSession({
selfId: 1,
})
// 1. Place potion (5101) into belt slot 0 -> 0x23 BeltPut (9B)
;(hudModel as Record<string, any>).putBeltItem(5101, 0)
expect(sentCommands).toContainEqual({ type: 'BeltPut', itemId: 5101, slot: 0 })
expect(sentPackets[0]![0]).toBe(0x23)
// Server confirms potion in belt slot 0
world.apply({
type: 'ItemEvent',
item: makeItemData({
id: 5101,
code: 'hp3',
location: 2,
container: 'belt',
gridX: 0,
x: 0,
}),
})
hudModel.syncFromWorld()
// 2. Swap belt potion (5101) with Super Healing Potion (5102) on cursor -> 0x25 BeltSwap (9B)
clearRecorded()
;(hudModel as Record<string, any>).swapBeltItem(5102, 5101)
expect(sentCommands).toContainEqual({
type: 'BeltSwap',
cursorItemId: 5102,
beltItemId: 5101,
})
expect(sentPackets[0]![0]).toBe(0x25)
world.apply({
type: 'ItemEvent',
item: makeItemData({
id: 5102,
code: 'hp5',
location: 2,
container: 'belt',
gridX: 0,
x: 0,
}),
})
hudModel.syncFromWorld()
// 3. Assign damaged mercenary (lifePct = 32) and feed belt slot 0 with Shift -> 0x26 BeltUse (useOnMerc=1)
world.apply({ type: 'Merc', kind: 'assign', ownerId: 1, mercId: 88, classId: 338 })
world.apply({
type: 'Damage',
unitType: 1,
unitId: 88,
lifePct: 32,
raw: new Uint8Array(9),
})
hudModel.syncFromWorld()
clearRecorded()
hudModel.useBeltSlot(0, true)
expect(sentCommands).toContainEqual({
type: 'BeltUse',
itemId: 5102,
useOnMerc: 1,
})
expect(sentPackets[0]![0]).toBe(0x26)
expect(readU32LE(sentPackets[0]!, 5)).toBe(1)
// 4. Remove potion from belt -> 0x24 BeltRemove (5B)
clearRecorded()
;(hudModel as Record<string, any>).removeBeltItem(5102)
expect(sentCommands).toContainEqual({ type: 'BeltRemove', itemId: 5102 })
expect(sentPackets[0]![0]).toBe(0x24)
})
it('Scenario 3: Multi-Act Town NPC Dialogue, Cain Identification & Act Travel Loop (F2 + F5 + F6 + F7)', () => {
const { world, worldPanels, hudModel, sentCommands, sentPackets, clearRecorded } =
createOnlineHudSession({ act: 0, areaId: 1 })
// 1. Interact with Deckard Cain in Act 1 (hcIdx 244)
world.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: 5201, classId: 244, name: 'Deckard Cain', x: 5104, y: 5054 },
})
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 5201 } })
hudModel.syncFromWorld()
// Click Identify Items -> 0x38 (action=4)
worldPanels.clickNpcMenuOption('identify')
expect(sentCommands).toContainEqual({
type: 'NpcIdentifyItems',
npcId: 5201,
})
// Re-open Cain menu and trigger Talk -> 0x31 QuestMessage
clearRecorded()
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 5201 } })
hudModel.syncFromWorld()
worldPanels.clickNpcMenuOption('talk')
if (!sentCommands.some((c) => c.type === 'QuestMessage')) {
const topic = worldPanels.npcMenu?.options.find((o) => String(o.id).startsWith('talk_'))
if (topic) worldPanels.clickNpcMenuOption(topic.id)
}
expect(sentPackets.some((p) => p[0] === 0x31 && p.byteLength === 9)).toBe(true)
// 2. Travel East with Warriv (155) -> Act 2 -> Travel East with Meshif (210) -> Act 3 -> Travel to Harrogath with Tyrael (367)
for (const step of [
{ id: 5202, hcIdx: 155, name: 'Warriv', action: 'travel_east', nextAct: 1 as const, nextArea: 40 },
{ id: 5203, hcIdx: 210, name: 'Meshif', action: 'travel_east', nextAct: 2 as const, nextArea: 75 },
{ id: 5204, hcIdx: 367, name: 'Tyrael', action: 'travel_harrogath', nextAct: 4 as const, nextArea: 109 },
]) {
clearRecorded()
world.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: step.id, classId: step.hcIdx, name: step.name, x: 5104, y: 5054 },
})
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: step.id } })
hudModel.syncFromWorld()
worldPanels.clickNpcMenuOption(step.action as any)
expect(sentCommands).toContainEqual(
expect.objectContaining({ type: 'NpcEntityAction', npcId: step.id }),
)
expect(sentPackets.some((p) => p[0] === 0x38 && p.byteLength === 13)).toBe(true)
world.apply({
type: 'LoadAct',
act: step.nextAct,
mapSeed: 0x9999,
areaId: step.nextArea,
objectSeed: 0x8888,
})
hudModel.syncFromWorld()
expect(world.act).toBe(step.nextAct)
expect(world.areaId).toBe(step.nextArea)
}
})
it('Scenario 4: Mercenary Captain Hiring, Equipment Paperdoll (O) & Post-Combat Resurrection Loop (F1 + F2 + F8 + F9 + F10)', () => {
const { world, hud, worldPanels, hudModel, dom, sentCommands, sentPackets, clearRecorded } =
createOnlineHudSession({ selfId: 1 })
// 1. Open Greiz (198) menu -> click Hire -> 0x38 (action=3)
world.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: 5301, classId: 198, name: 'Greiz', x: 5104, y: 5054 },
})
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 5301 } })
hudModel.syncFromWorld()
worldPanels.clickNpcMenuOption('hire' as any)
expect(sentCommands).toContainEqual(
expect.objectContaining({ type: 'NpcEntityAction', action: 3, npcId: 5301 }),
)
// 2. Receive 0x4F + 0x4E candidates and hire mercId=9 -> 0x36 MercHire (9B)
world.apply({ type: 'Merc', kind: 'hireListStart' })
world.apply({ type: 'Merc', kind: 'forHire', nameId: 9, seed: 0x445566 })
hudModel.syncFromWorld()
clearRecorded()
;(hudModel as Record<string, any>).hireMerc(5301, 9)
expect(sentCommands).toContainEqual({ type: 'MercHire', npcId: 5301, mercId: 9 })
expect(sentPackets[0]![0]).toBe(0x36)
// 3. Server assigns merc (unitId=600) and equips polearm (bodyLoc=4)
world.apply({ type: 'Merc', kind: 'assign', ownerId: 1, mercId: 600, classId: 338, nameId: 9 })
world.apply({
type: 'ItemEvent',
item: makeItemData({
id: 5310,
opcode: 0x9d,
action: 0x06,
code: 'vou', // Voulge
location: 1,
container: 'merc',
bodyLoc: 4,
equippedSlot: 4,
}),
})
hudModel.syncFromWorld()
// Open 'O' Mercenary Paperdoll and click weapon slot -> 0x61 MercItem (3B)
dom.dispatchKeyDown('o')
clearRecorded()
;(hudModel as Record<string, any>).mercItem(4)
expect(sentCommands).toContainEqual({ type: 'MercItem', slot: 4 })
expect(sentPackets[0]![0]).toBe(0x61)
// 4. Mercenary dies in combat (0x9B MercReviveCost) -> Resurrect at Greiz -> 0x62 MercResurrect (5B)
world.apply({ type: 'Merc', kind: 'reviveCost', deadNameId: 9, reviveCost: 14000 })
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 5301 } })
hudModel.syncFromWorld()
clearRecorded()
worldPanels.clickNpcMenuOption('resurrect' as any)
expect(sentCommands).toContainEqual({ type: 'MercResurrect', npcId: 5301 })
expect(sentPackets[0]![0]).toBe(0x62)
})
it('Scenario 5: Dungeon Warp Traversal, Town Portal Ownership Hover & Ground Loot Pickup Loop (F2 + F11 + F12)', () => {
const {
world,
onlineSession,
sceneSource,
commandMapper,
sentCommands,
sentPackets,
clearRecorded,
} = createOnlineHudSession({ x: 5100, y: 5050 })
// 1. Server assigns Den of Evil warp tile (unitId=5401, warpId=4)
world.apply({
type: 'UnitAssign',
unit: {
unitType: 5,
id: 5401,
classId: 4,
warpId: 4,
name: 'To Den of Evil',
x: 5100,
y: 5050,
} as any,
})
// Pick warp at screen center and click -> MoveToUnit (0x04) + TakeWarp (0x09)
const pickedWarp = sceneSource.pickUnitAtScreen(400, 300 - 24, 48)
expect(pickedWarp?.id).toBe(5401)
const warpUnit = world.getUnit('tile', 5401)!
commandMapper.triggerPrimaryWorldAction(5100, 5050, warpUnit, 7000)
expect(sentCommands).toContainEqual(
expect.objectContaining({ type: 'MoveToUnit', target: { unitType: 5, id: 5401 } }),
)
expect(sentPackets.some((p) => p[0] === 0x09 && p.byteLength === 9)).toBe(true)
// 2. Allied Town Portal (5402) and Ground Gold (5403: 1250 Gold) spawn
clearRecorded()
world.apply({
type: 'UnitAssign',
unit: { unitType: 2, id: 5402, classId: 59, name: 'Town Portal', x: 5104, y: 5054 },
})
world.apply({
type: 'Portal',
kind: 'ownership',
ownerId: 2,
ownerName: 'Sorceress',
localId: 5402,
remoteId: 5403,
})
world.apply({
type: 'ItemEvent',
item: makeItemData({
id: 5404,
action: 0x01,
code: 'gld',
location: 3,
container: 'ground',
worldX: 5102,
worldY: 5052,
x: 5102,
y: 5052,
quantity: 1250,
}),
})
const renderables = onlineSession.buildRenderableUnits()
expect(renderables.find((u) => u.id === 5402)?.name).toContain("Sorceress's Town Portal")
const goldMeta = resolveGroundItemMetadata({ id: 5404, code: 'gld', goldAmount: 1250 })
expect(goldMeta.labelEn).toBe('1250 Gold')
const boxes = layoutGroundItemLabels([
{ id: 5404, screenX: 400, screenY: 300, label: goldMeta.labelEn, color: goldMeta.color },
])
expect(boxes.length).toBe(1)
// 3. Click ground gold -> 0x16 ItemPickup (13B)
const goldUnit = world.getUnit('item', 5404)!
commandMapper.triggerPrimaryWorldAction(5102, 5052, goldUnit, 8000)
expect(sentCommands).toContainEqual({ type: 'ItemPickup', itemId: 5404, toCursor: false })
expect(sentPackets.some((p) => p[0] === 0x16 && p.byteLength === 13)).toBe(true)
})
it('Scenario 6: Combat SkillCast Animation, Player Death Overlay & Town Corpse Resurrection Loop (F2 + F13 + F14)', () => {
const {
world,
onlineSession,
hudModel,
commandMapper,
renderFrame,
sentCommands,
sentPackets,
} = createOnlineHudSession({ selfId: 1, life: 120, maxLife: 120, x: 5100, y: 5050 })
// 1. Spawn monster (5501) and exchange SkillCast packets (0x4D player cast + 0x6C monster attack)
world.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: 5501, classId: 1, name: 'Fallen', x: 5110, y: 5060 },
})
world.apply({
type: 'SkillCast',
kind: 'atLocation',
unitType: 0,
unitId: 1,
skillId: 47,
skillLevel: 5,
target: { x: 5110, y: 5060 },
})
world.apply({
type: 'SkillCast',
kind: 'monsterAttack',
monsterId: 5501,
attackType: 0,
targetId: 1,
targetType: 0,
target: { x: 5100, y: 5050 },
})
const monsterRenderable = onlineSession.buildRenderableUnits().find((u) => u.id === 5501)
expect([4, 5, 7]).toContain(monsterRenderable?.mode)
// 2. Player takes lethal damage (life -> 0)
world.apply({
type: 'PlayerLifeManaChange',
life: 0,
mana: 10,
stamina: 20,
x: 5100,
y: 5050,
vx: 0,
vy: 0,
})
hudModel.syncFromWorld()
const ctx = renderFrame()
expect(ctx.drawnTexts.map((t) => t.text).join('\n')).toMatch(/You Have Died|你挂了|Press ESC/i)
// 3. Player presses Escape -> 0x41 Resurrect (1B)
commandMapper.handleKeyDown({ key: 'Escape' })
expect(sentCommands).toContainEqual({ type: 'Resurrect' })
expect(sentPackets.some((p) => p[0] === 0x41 && p.byteLength === 1)).toBe(true)
// 4. Server respawns player in town with full life
world.apply({
type: 'PlayerLifeManaChange',
life: 120,
mana: 80,
stamina: 100,
x: 5100,
y: 5050,
vx: 0,
vy: 0,
})
hudModel.syncFromWorld()
expect(
Boolean((world as Record<string, any>).isDead || (world as Record<string, any>).isSelfDead),
).toBe(false)
})
it('Scenario 7: Multiplayer Party Roster, Invite/Accept, Hostile & Corpse Loot Permissions Loop (F2 + F15)', () => {
const { world, hud, worldPanels, hudModel, commandMapper, sentCommands, sentPackets, clearRecorded } =
createOnlineHudSession({ selfId: 1 })
// 1. Peer player (id=2, Amazon) joins game unpartied (partyId=0xffff, charName='Valkyrie')
world.apply({
type: 'Party',
kind: 'roster',
playerId: 2,
charClass: 0,
charLevel: 42,
partyId: 0xffff,
charName: 'Valkyrie',
})
hudModel.syncFromWorld()
expect(world.party.get(2)?.charName).toBe('Valkyrie')
// 2. Open 'P' Party panel and invite peer -> 0x5E (action=0x06)
commandMapper.handleKeyDown({ key: 'p' })
expect(
(hud.leftPanel as string) === 'party' || (worldPanels.activePanel as string) === 'party',
).toBe(true)
;(hudModel as Record<string, any>).partyAction(0x06, 2)
expect(sentCommands).toContainEqual({ type: 'PartyAction', action: 0x06, targetPlayerId: 2 })
expect(sentPackets[0]![0]).toBe(0x5e)
// 3. Server updates party relation to allied (state=1), lifePct=100, areaId=2, pos=(5150, 5090)
world.apply({ type: 'Party', kind: 'relation', subKind: 'partyState', playerId: 2, state: 1 })
world.apply({
type: 'Party',
kind: 'memberState',
isSelf: false,
unitId: 2,
lifePct: 100,
areaId: 2,
})
world.apply({ type: 'Party', kind: 'memberPos', playerId: 2, at: { x: 5150, y: 5090 } })
hudModel.syncFromWorld()
// 4. Grant Corpse Loot permission (button=2) and toggle Hostile (button=1) -> 0x5D (7B)
clearRecorded()
;(hudModel as Record<string, any>).setHostile(2, true, 2)
expect(sentPackets[0]![0]).toBe(0x5d)
expect(sentPackets[0]![1]).toBe(2)
clearRecorded()
;(hudModel as Record<string, any>).setHostile(2, true, 1)
expect(sentPackets[0]![0]).toBe(0x5d)
expect(sentPackets[0]![1]).toBe(1)
// 5. Peer leaves game (0x5C: partyId=0xffff, charName='')
world.apply({ type: 'Party', kind: 'roster', playerId: 2, partyId: 0xffff, charName: '' })
hudModel.syncFromWorld()
expect(world.party.has(2)).toBe(false)
})
it('Scenario 8: Player-to-Player Trade Session & Act 1/5 Quest Reward Item Upgrading Loop (F1 + F2 + F5 + F16)', () => {
const { world, worldPanels, hudModel, sentPackets, clearRecorded } = createOnlineHudSession({
selfId: 1,
})
// 1. Server opens trade session (0x77 action 0x01) with partner 'Deckard' (0x78) and gold offers (0x79)
world.apply({ type: 'Trade', kind: 'button', action: 0x01 })
world.apply({
type: 'Trade',
kind: 'accepted',
partnerName: 'Deckard',
partnerId: 3,
} as any)
world.apply({ type: 'Trade', kind: 'gold', isSelf: false, amount: 50000 })
world.apply({ type: 'Trade', kind: 'gold', isSelf: true, amount: 20000 })
world.apply({
type: 'ItemEvent',
item: makeItemData({
id: 5701,
code: 'cap',
quality: 7,
uniqueItemId: 0,
location: 0,
container: 'tradePartner',
storePage: 2,
storagePage: 2,
gridX: 0,
gridY: 0,
}),
})
hudModel.syncFromWorld()
expect(world.trade.active).toBe(true)
expect(world.containers.partnerTradeGrid.length).toBe(1)
// 2. Click Trade Accept -> 0x4F (buttonId=0x04)
if (typeof (hudModel as Record<string, any>).clickButton === 'function') {
;(hudModel as Record<string, any>).clickButton(0x04, 0)
} else {
;(hudModel as Record<string, any>).sendCommand?.({
type: 'ClickButton',
button: 0x04,
buttonId: 0x04,
complement: 0,
})
}
expect(sentPackets.some((p) => p[0] === 0x4f && readU16LE(p, 1) === 0x04)).toBe(true)
// 3. Complete trade (0x77 action 0x06) and open Charsi (154 imbue), Larzuk (511 add_sockets), and Anya (512 personalize)
world.apply({ type: 'Trade', kind: 'button', action: 0x06 })
hudModel.syncFromWorld()
expect(world.trade.active).toBe(false)
for (const reward of [
{ id: 5710, hcIdx: 154, name: 'Charsi', action: 'imbue' },
{ id: 5711, hcIdx: 511, name: 'Larzuk', action: 'add_sockets' },
{ id: 5712, hcIdx: 512, name: 'Anya', action: 'personalize' },
]) {
clearRecorded()
world.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: reward.id, classId: reward.hcIdx, name: reward.name, x: 5104, y: 5054 },
})
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: reward.id } })
hudModel.syncFromWorld()
expect(worldPanels.npcMenu?.options.some((o) => o.id === (reward.action as any))).toBe(true)
worldPanels.clickNpcMenuOption(reward.action as any)
hudModel.closeActiveNpcPanel()
}
})
})

View File

@ -0,0 +1,774 @@
/**
* Tier 5 — White-Box Adversarial & Post-Implementation Hardening Suite (20 Tests)
*
* Stress-tests the real implementation across R1–R5 for:
* - Out-of-order / burst S2C packet & WorldEvent streams (socketed child/parent ordering,
* rapid 0x21/0x22 skill bonus updates, rapid 0x0C LifeManaChange bursts, belt slot collisions)
* - Re-entrant panel state transitions (P2P trade cancel mid-cursor-drag, NPC menu -> Identify -> Talk,
* quest reward panel interrupts)
* - Stale unit ID handling (removed warp/NPC/ground item via 0x0A UnitRemove)
* - Rapid belt shift-clicks + mercenary life/death transitions & paperdoll ('O') locking
* - Full-roundtrip binary C2S packet byte & C2S_PACKET_SIZES verification across all newly wired commands
*/
import { describe, expect, it } from 'vitest'
import {
C2S_PACKET_SIZES,
decodeD2gsS2cPacket,
validateC2sPacket,
} from '../../src/netproto/index.ts'
import {
assertNoUndefinedText,
bridgeOnlineItemToUi,
createOnlineHudSession,
encodeClientCommandBytes,
extractStatStrings,
getLeftPanelOrigin,
makeItemData,
readU16LE,
readU32LE,
} from './helpers.ts'
const EMPTY_TABLES = {
itemStatCost: new Map(),
armor: new Map(),
weapons: new Map(),
misc: new Map(),
getStatMeta: () => undefined,
getStatCost: () => undefined,
getItemBaseMeta: () => undefined,
getItemMeta: () => undefined,
} as const
describe('Tier 5 — White-Box Adversarial & Post-Implementation Hardening (20 Tests)', () => {
// =========================================================================
// Section 1: R1 — Out-of-Order & Burst Container / Tooltip / Belt Streams
// =========================================================================
describe('R1 Adversarial — Out-of-Order Socket Streams, Belt Collisions & Stat Bursts', () => {
it('T5.1: socketed child (location=6) arriving after parent (0x9C) dynamically updates parent usedSockets and tooltip stats on re-sync', () => {
const { world, hud, hudModel } = createOnlineHudSession()
// 1. Parent 2-socket armor arrives first with 0 children
const armor = makeItemData({
id: 9001,
code: 'qui',
quality: 2,
location: 0,
container: 'inventory',
gridX: 0,
gridY: 0,
invWidth: 2,
invHeight: 3,
socketed: true,
totalSockets: 2,
usedSockets: 0,
stats: [{ statId: 31, param: 0, value: 11 }],
})
world.apply({ type: 'ItemEvent', action: 0x00, item: armor })
hudModel.syncFromWorld()
let uiArmor = hud.inventory.inventory.find((i) => i.id === 9001)!
expect(uiArmor).toBeDefined()
expect(uiArmor.sockets).toBe(2)
expect(uiArmor.usedSockets).toBe(0)
// 2. Two socketed children (location=6) arrive subsequently via 0x9D OwnedItemAction (action=0x00 PutInContainer)
const chippedRuby = makeItemData({
id: 9002,
code: 'gcr',
quality: 2,
location: 6,
container: 'socket',
ownerType: 4,
ownerId: 9001,
gridX: 0,
gridY: 0,
stats: [{ statId: 39, param: 0, value: 12 }], // Fire Resist +12%
})
const tirRune = makeItemData({
id: 9003,
code: 'r03',
quality: 2,
location: 6,
container: 'socket',
ownerType: 4,
ownerId: 9001,
gridX: 1,
gridY: 0,
stats: [{ statId: 138, param: 0, value: 2 }], // +2 to Mana after each Kill
})
world.apply({ type: 'ItemEvent', action: 0x00, item: chippedRuby })
world.apply({ type: 'ItemEvent', action: 0x00, item: tirRune })
hudModel.syncFromWorld()
uiArmor = hud.inventory.inventory.find((i) => i.id === 9001)!
expect(uiArmor.usedSockets).toBe(2)
expect(uiArmor.socketedGems?.length).toBe(2)
const statLines = extractStatStrings(uiArmor.statsEn ?? uiArmor.stats).join(' | ')
expect(statLines).toMatch(/10 to Life/i)
expect(statLines).toMatch(/2 to Mana/i)
})
it('T5.2: replacing a belt slot via action=0x0E evicts the old potion from world.containers.allById and belt map without orphan leaks', () => {
const { world, hud, hudModel, sentCommands, clearRecorded } = createOnlineHudSession()
const oldPotion = makeItemData({
id: 9010,
code: 'hp1',
location: 2,
container: 'belt',
gridX: 2,
gridY: 0,
})
const newPotion = makeItemData({
id: 9011,
code: 'rvl',
location: 2,
container: 'belt',
gridX: 2,
gridY: 0,
})
world.apply({ type: 'ItemEvent', action: 0x0e, item: oldPotion })
hudModel.syncFromWorld()
expect(world.containers.beltBySlot.get(2)?.id).toBe(9010)
expect(world.containers.allById.has(9010)).toBe(true)
// Overwrite belt slot 2 with newPotion
world.apply({ type: 'ItemEvent', action: 0x0e, item: newPotion })
hudModel.syncFromWorld()
expect(world.containers.beltBySlot.get(2)?.id).toBe(9011)
expect(world.containers.belt.has(9010)).toBe(false)
expect(world.containers.allById.has(9010)).toBe(false)
expect(hud.inventory.belt[2]?.id).toBe(9011)
clearRecorded()
hudModel.useBeltSlot(2, false)
expect(sentCommands).toContainEqual({ type: 'BeltUse', itemId: 9011, shift: false })
})
it('T5.3: action=0x02 (ToCursor) with container=inventory does not hijack the cursor slot unless location===4 or container===cursor', () => {
const { world, hud, hudModel } = createOnlineHudSession()
const bagItem = makeItemData({
id: 9020,
code: 'rin',
location: 0,
container: 'inventory',
gridX: 4,
gridY: 2,
})
// Adversarial packet where action is 0x02 but item is still in inventory (location=0)
world.apply({ type: 'ItemEvent', action: 0x02, item: bagItem })
hudModel.syncFromWorld()
expect(world.containers.cursor).toBeNull()
expect(hud.inventory.cursorItem).toBeNull()
expect(hud.inventory.inventory.some((i) => i.id === 9020)).toBe(true)
})
it('T5.4: rapid burst of SkillList (0x94/0x21/0x22) and PlayerLifeManaChange (0x0C) events converges to exact final state without duplicate hotkeys', () => {
const { world, hud, hudModel } = createOnlineHudSession({ selfId: 1, charClass: 1 })
// Rapid burst of skill updates on Fire Bolt (36) and Teleport (54)
for (let lvl = 1; lvl <= 10; lvl++) {
world.apply({
type: 'SkillList',
unitId: 1,
skills: [
{ skillId: 36, skill: 36, level: lvl, baseLevel: lvl, bonus: lvl % 3 },
{ skillId: 54, skill: 54, level: 1, baseLevel: 1, bonus: lvl },
],
})
}
// Rapid burst of PlayerLifeManaChange events
for (let i = 0; i < 15; i++) {
world.apply({
type: 'PlayerLifeManaChange',
life: 100 + i * 5,
mana: 80 + i * 3,
stamina: 50 + i,
x: 5100 + i,
y: 5050 + i,
vx: 0,
vy: 0,
})
}
hudModel.syncFromWorld()
expect(hud.skillTree.hardPoints.get(36)).toBe(10)
expect(hud.skillTree.bonusLevels.get(36)).toBe(1)
expect(hud.skillTree.hardPoints.get(54)).toBe(1)
expect(hud.skillTree.bonusLevels.get(54)).toBe(10)
// Hotkeys list must not duplicate skills across burst syncs
const skills36 = hud.hotkeys.availableSkills.filter((s) => s.skillId === 36)
expect(skills36.length).toBe(1)
expect(skills36[0]!.level).toBe(11)
})
it('T5.5: bridgeOnlineItemToUi handles armor with explicitDefense and statId=31 without double-adding base ac or crashing on unknown affixes', () => {
const item = makeItemData({
id: 9030,
code: 'qui',
quality: 4, // Magic
identified: true,
defense: 15,
magicPrefixId: 99999, // Out-of-range prefix ID
magicSuffixId: 88888, // Out-of-range suffix ID
stats: [
{ statId: 31, param: 0, value: 15 }, // ArmorClass
{ statId: 194, param: 0, value: 2 }, // NumSockets
],
})
const ui = bridgeOnlineItemToUi(item)
expect(ui.defense).toBe(15)
expect(ui.sockets).toBe(2)
expect(ui.nameEn.length).toBeGreaterThan(0)
})
})
// =========================================================================
// Section 2: R1.3 & R2 — Weapon Swap & Multi-Act NPC Adversarial Transitions
// =========================================================================
describe('R1.3 & R2 Adversarial — Weapon Swap Cycles, Unsolicited S2C 0x27 & NPC Menu Re-entry', () => {
it('T5.6: rapid WeaponSwap (0x60) command emissions do not mutate hud.inventory.weaponSwapSet until S2C 0x97 arrives', () => {
const { world, hud, hudModel, sentCommands, sentPackets } = createOnlineHudSession()
expect(hud.inventory.weaponSwapSet).toBe(0)
hudModel.swapWeapons()
hudModel.swapWeapons()
hudModel.swapWeapons()
expect(sentCommands.filter((c) => c.type === 'SwapWeapons').length).toBe(3)
expect(sentPackets.filter((p) => p[0] === 0x60 && p.byteLength === 1).length).toBe(3)
// Must remain 0 until server confirms via 0x97 (ItemEvent action 0x17)
expect(hud.inventory.weaponSwapSet).toBe(0)
world.apply({ type: 'WeaponSwitch' })
hudModel.syncFromWorld()
expect(hud.inventory.weaponSwapSet).toBe(1)
world.apply({ type: 'WeaponSwitch' })
hudModel.syncFromWorld()
expect(hud.inventory.weaponSwapSet).toBe(0)
})
it('T5.7: unsolicited S2C 0x27 NPC Info packet when no NpcInit was noted stores npcInfoRaw without hijacking npcInteraction.npcId', () => {
const { world, worldPanels, hudModel } = createOnlineHudSession()
const pkt27 = new Uint8Array(40)
pkt27[0] = 0x27
pkt27[1] = 0x01 // unitType = monster
pkt27[2] = 0x8c
pkt27[3] = 0x23 // unitId = 9100
const ev = decodeD2gsS2cPacket(pkt27, { tables: EMPTY_TABLES, encoding: 'gbk', rttMs: 0 })
world.apply(ev)
hudModel.syncFromWorld()
expect(world.npcInteraction.npcId).toBeNull()
expect(worldPanels.npcMenu).toBeNull()
expect(world.npcInteraction.npcInfoRaw).toBeDefined()
})
it('T5.8: clicking Cain identify closes menu, and subsequent NpcInit + syncFromWorld cleanly re-opens Cain menu for Talk/Trade', () => {
const { world, worldPanels, hudModel, sentCommands, clearRecorded } = createOnlineHudSession()
world.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: 9101, classId: 244, name: 'Deckard Cain', x: 5104, y: 5054 },
})
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 9101 } })
hudModel.syncFromWorld()
expect(worldPanels.npcMenu).not.toBeNull()
worldPanels.clickNpcMenuOption('identify')
expect(sentCommands).toContainEqual({ type: 'NpcIdentifyItems', npcId: 9101 })
expect(worldPanels.npcMenu).toBeNull()
// Re-initiate interaction with same NPC 9101
clearRecorded()
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 9101 } })
hudModel.syncFromWorld()
expect(worldPanels.npcMenu).not.toBeNull()
expect(worldPanels.npcMenu?.npcId).toBe(9101)
worldPanels.clickNpcMenuOption('talk')
expect(sentCommands).toContainEqual({ type: 'QuestMessage', npcId: 9101, messageId: 0 })
})
it('T5.9: Escape key / closeActiveNpcPanel while NPC menu is open emits 0x30 NpcCancel and clears world.npcInteraction.npcId', () => {
const { world, worldPanels, hudModel, sentCommands, sentPackets } = createOnlineHudSession()
world.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: 9102, classId: 148, name: 'Akara', x: 5104, y: 5054 },
})
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 9102 } })
hudModel.syncFromWorld()
expect(worldPanels.npcMenu).not.toBeNull()
hudModel.closeActiveNpcPanel()
hudModel.syncFromWorld()
expect(world.npcInteraction.npcId).toBeNull()
expect(worldPanels.npcMenu).toBeNull()
expect(sentCommands).toContainEqual({ type: 'NpcCancel', npcId: 9102 })
expect(sentPackets.some((p) => p[0] === 0x30 && p.byteLength === 9 && readU32LE(p, 5) === 9102)).toBe(true)
})
})
// =========================================================================
// Section 3: R3 — Mercenary Life/Death Transitions & Paperdoll ('O') Locking
// =========================================================================
describe('R3 Adversarial — Mercenary Death Mid-Session, Equipment Sync & Revive Guards', () => {
it('T5.10: mercenary dying (UnitMode mode=12, lifePct=0) locks O hotkey from opening merc paperdoll and enables captain resurrect option', () => {
const { world, hud, worldPanels, hudModel, dom } = createOnlineHudSession({ selfId: 1 })
// Assign active mercenary (lifePct=100)
world.apply({
type: 'Merc',
kind: 'assign',
ownerId: 1,
mercId: 9201,
classId: 271,
nameId: 10,
})
hudModel.syncFromWorld()
expect(hud.merc?.isDead).toBe(false)
// Mercenary dies (mode=12, lifePct=0) and server reports reviveCost via 0x9B
world.apply({
type: 'UnitMode',
unitType: 1,
id: 9201,
mode: 12,
lifePct: 0,
})
world.apply({
type: 'Merc',
kind: 'reviveCost',
deadNameId: 10,
reviveCost: 12500,
})
hudModel.syncFromWorld()
expect(hud.merc?.isDead).toBe(true)
expect(hud.merc?.reviveCost).toBe(12500)
expect((worldPanels as any).hasDeadMercenary).toBe(true)
// Pressing 'O' when merc is dead must NOT open the merc paperdoll
hud.leftPanel = 'none'
dom.dispatchKeyDown('o')
expect(hud.leftPanel).toBe('none')
})
it('T5.11: equipping and swapping mercenary items across slots 1, 3, 4 keeps both merc.equipped (numeric id) and merc.equipment (string id) synchronized and renders without undefined', () => {
const { world, hud, hudModel, dom, sentCommands, sentPackets, clearRecorded, renderFrame } =
createOnlineHudSession({ selfId: 1 })
world.apply({
type: 'Merc',
kind: 'assign',
ownerId: 1,
mercId: 9202,
classId: 338,
nameId: 12,
})
const helm = makeItemData({
id: 9211,
opcode: 0x9d,
action: 0x06,
code: 'cap',
location: 1,
container: 'merc',
bodyLoc: 1,
equippedSlot: 1,
})
const armor = makeItemData({
id: 9213,
opcode: 0x9d,
action: 0x06,
code: 'qui',
location: 1,
container: 'merc',
bodyLoc: 3,
equippedSlot: 3,
})
const weapon = makeItemData({
id: 9214,
opcode: 0x9d,
action: 0x06,
code: 'spc',
location: 1,
container: 'merc',
bodyLoc: 4,
equippedSlot: 4,
})
world.apply({ type: 'ItemEvent', action: 0x06, item: helm })
world.apply({ type: 'ItemEvent', action: 0x06, item: armor })
world.apply({ type: 'ItemEvent', action: 0x06, item: weapon })
hudModel.syncFromWorld()
expect(hud.merc?.equipped.head?.id).toBe(9211)
expect(hud.merc?.equipped.torso?.id).toBe(9213)
expect(hud.merc?.equipped.rightHand?.id).toBe(9214)
expect(hud.merc?.equipment.head?.id).toBe('9211')
expect(hud.merc?.equipment.torso?.id).toBe('9213')
expect(hud.merc?.equipment.rightHand?.id).toBe('9214')
dom.dispatchKeyDown('o')
const ctx = renderFrame()
assertNoUndefinedText(ctx)
const texts = ctx.drawnTexts.map((t) => t.text).join(' ')
expect(texts.includes('cap') || texts.includes('Cap') || texts.includes('帽子')).toBe(true)
expect(
texts.includes('qui') || texts.includes('Quilted Armor') || texts.includes('布甲'),
).toBe(true)
expect(texts.includes('spc') || texts.includes('Scepter') || texts.includes('權杖')).toBe(
true,
)
// Click merc slot 4 (Right Hand / Weapon: ox + 36..92, oy + 98..210) -> C2S 0x61 MercItem (3B)
clearRecorded()
const { ox, oy } = getLeftPanelOrigin()
hud.handleClick(ox + 64, oy + 154)
expect(sentCommands).toContainEqual({ type: 'MercItem', slot: 4 })
expect(
sentPackets.some((p) => p[0] === 0x61 && p.byteLength === 3 && readU16LE(p, 1) === 4),
).toBe(true)
})
})
// =========================================================================
// Section 4: R4 — Stale Unit IDs, Warp Removal, SkillCast & Death Guards
// =========================================================================
describe('R4 Adversarial — Stale Unit Removal (0x0A), SkillCast Decoding & Death Lockout', () => {
it('T5.12: removing a warp or ground item via S2C 0x0A UnitRemove purges it from world units and containers immediately', () => {
const { world, hudModel } = createOnlineHudSession()
world.apply({
type: 'UnitAssign',
unit: {
unitType: 5,
id: 9301,
classId: 2,
warpId: 2,
name: 'To Cave',
x: 5110,
y: 5060,
} as any,
})
const groundSword = makeItemData({
id: 9302,
action: 0x03,
code: 'ssd',
location: 3,
container: 'ground',
worldX: 5112,
worldY: 5062,
})
world.apply({ type: 'ItemEvent', action: 0x03, item: groundSword })
hudModel.syncFromWorld()
expect(world.getUnit('tile', 9301)).toBeDefined()
expect(world.getUnit('item', 9302)).toBeDefined()
// Server despawns both via UnitRemove (0x0A)
world.apply({ type: 'UnitRemove', unitType: 5, id: 9301 })
world.apply({ type: 'UnitRemove', unitType: 4, id: 9302 })
hudModel.syncFromWorld()
expect(world.getUnit('tile', 9301)).toBeUndefined()
expect(world.getUnit('item', 9302)).toBeUndefined()
})
it('T5.13: decoding S2C 0x4C (unit-target SkillCast) and 0x4D (ground-target SkillCast) updates caster castState and facing direction', () => {
const { world, hudModel } = createOnlineHudSession({ selfId: 1, x: 5100, y: 5050 })
world.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: 9350, classId: 1, name: 'Fallen', x: 5120, y: 5050 },
})
// S2C 0x4C (16B): [0x4c, unitType=0, unitId(4)=1, skillId(2)=47, lvl=1, unk=1, targetId(4)=9350, 0, 0]
const pkt4c = new Uint8Array(16)
const v4c = new DataView(pkt4c.buffer)
pkt4c[0] = 0x4c
pkt4c[1] = 0
v4c.setUint32(2, 1, true)
v4c.setUint16(6, 47, true)
pkt4c[8] = 1
pkt4c[9] = 1
v4c.setUint32(10, 9350, true)
const ev4c = decodeD2gsS2cPacket(pkt4c, { tables: EMPTY_TABLES, encoding: 'gbk', rttMs: 0 })
world.apply(ev4c)
hudModel.syncFromWorld()
const selfUnit = world.getUnit('player', 1) as Record<string, any> | undefined
const castState = selfUnit?.castState ?? (world.self as Record<string, any>).castState
expect(castState).toBeDefined()
expect(castState.skillId).toBe(47)
})
it('T5.14: player death (life=0) sets world.isDead=true, and S2C PlayerLifeManaChange at town spawn clears isDead after 0x41 Resurrect', () => {
const { world, hud, hudModel, sentCommands, sentPackets } = createOnlineHudSession({
selfId: 1,
life: 100,
maxLife: 100,
})
world.apply({
type: 'PlayerLifeManaChange',
life: 0,
mana: 40,
stamina: 50,
x: 5100,
y: 5050,
vx: 0,
vy: 0,
})
hudModel.syncFromWorld()
expect(world.isDead).toBe(true)
expect((hud as any).isDead).toBe(true)
hudModel.resurrect()
expect(sentCommands).toContainEqual({ type: 'Resurrect' })
expect(sentPackets.some((p) => p[0] === 0x41 && p.byteLength === 1)).toBe(true)
// Server restores life in town
world.apply({
type: 'PlayerLifeManaChange',
life: 100,
mana: 50,
stamina: 80,
x: 5104,
y: 5054,
vx: 0,
vy: 0,
})
hudModel.syncFromWorld()
expect(world.isDead).toBe(false)
expect((hud as any).isDead).toBe(false)
})
})
// =========================================================================
// Section 5: R5 — Re-Entrant Trade Cancels, Party Departures & Full C2S Audit
// =========================================================================
describe('R5 Adversarial — Re-Entrant Trade Aborts, Party Leaves & C2S Binary Audit', () => {
it('T5.15: receiving S2C 0x77 action=0x0C (Trade Cancel) while trade window is open with partner items and gold resets trade state and closes left panel', () => {
const { world, hud, worldPanels, hudModel, renderFrame } = createOnlineHudSession({ selfId: 1 })
// Open trade window (0x77 action 0x01)
world.apply({ type: 'Trade', kind: 'button', action: 0x01 })
const partnerRing = makeItemData({
id: 9401,
code: 'rin',
location: 0,
container: 'tradePartner',
storePage: 2,
storagePage: 2,
gridX: 0,
gridY: 0,
})
world.apply({ type: 'ItemEvent', action: 0x00, item: partnerRing })
world.apply({ type: 'Trade', kind: 'gold', isSelf: false, amount: 25000 })
world.apply({ type: 'Trade', kind: 'button', action: 0x05 }) // Partner clicked accept
hudModel.syncFromWorld()
expect(world.trade.active).toBe(true)
expect(world.trade.partnerAccepted).toBe(true)
expect(world.trade.partnerGold).toBe(25000)
expect(hud.leftPanel).toBe('trade')
const ctx = renderFrame()
assertNoUndefinedText(ctx)
expect(ctx.drawnTexts.some((t) => t.text.includes('Partner Gold: 25000'))).toBe(true)
// Server cancels trade mid-session (0x77 action 0x0C)
world.apply({ type: 'Trade', kind: 'button', action: 0x0c })
hudModel.syncFromWorld()
expect(world.trade.active).toBe(false)
expect(world.trade.partnerAccepted).toBe(false)
expect(world.trade.selfAccepted).toBe(false)
expect(hud.leftPanel).toBe('none')
expect((worldPanels as any).activePanel).toBe('none')
})
it('T5.16: S2C 0x5C PlayerLeaveGame (partyId=0xffff, empty charName) removes the departed player from world.party and hud.partyMembers', () => {
const { world, hud, hudModel } = createOnlineHudSession({ selfId: 1 })
world.apply({
type: 'Party',
kind: 'roster',
playerId: 9450,
charClass: 3,
charLevel: 42,
partyId: 1,
charName: 'PaladinBro',
})
hudModel.syncFromWorld()
expect(world.party.has(9450)).toBe(true)
expect((hud as any).partyMembers.some((m: any) => m.playerId === 9450)).toBe(true)
// 0x5C leaves game -> partyId=0xffff, empty charName
world.apply({
type: 'Party',
kind: 'roster',
playerId: 9450,
partyId: 0xffff,
charName: '',
})
hudModel.syncFromWorld()
expect(world.party.has(9450)).toBe(false)
expect((hud as any).partyMembers.some((m: any) => m.playerId === 9450)).toBe(false)
})
it('T5.17: Charsi imbue, Larzuk socket, and Anya personalize panels open inventory alongside leftPanel and emit 0x38 NpcEntityAction(action=0x58, npcId, itemId) on canvas Confirm click', () => {
const { world, hud, worldPanels, hudModel, sentCommands, sentPackets, clearRecorded, renderFrame } =
createOnlineHudSession()
const { ox, oy } = getLeftPanelOrigin()
for (const reward of [
{ npcId: 9501, hcIdx: 154, name: 'Charsi', option: 'imbue' as const, itemId: 9601 },
{ npcId: 9502, hcIdx: 511, name: 'Larzuk', option: 'add_sockets' as const, itemId: 9602 },
{ npcId: 9503, hcIdx: 512, name: 'Anya', option: 'personalize' as const, itemId: 9603 },
]) {
clearRecorded()
world.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: reward.npcId, classId: reward.hcIdx, name: reward.name, x: 5100, y: 5050 },
})
world.apply({
type: 'ItemEvent',
item: makeItemData({
id: reward.itemId,
code: 'cap',
location: 4,
container: 'cursor',
}),
})
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: reward.npcId } })
hudModel.syncFromWorld()
worldPanels.clickNpcMenuOption(reward.option)
expect(hud.leftPanel).toBe(reward.option)
expect(hud.rightPanel).toBe('inv')
const ctx = renderFrame()
assertNoUndefinedText(ctx)
// Place cursor item into 1-item slot box (ox + 116..204, oy + 120..236)
hud.handleClick(ox + 160, oy + 178)
const ctx2 = renderFrame()
assertNoUndefinedText(ctx2)
clearRecorded()
// Click Confirm button box (ox + 112..208, oy + 280..312)
hud.handleClick(ox + 160, oy + 296)
expect(sentCommands).toContainEqual({
type: 'NpcEntityAction',
action: 0,
npcId: reward.npcId,
complement: reward.itemId,
})
expect(
sentPackets.some(
(p) =>
p[0] === 0x38 &&
p.byteLength === 13 &&
readU32LE(p, 1) === 0 &&
readU32LE(p, 5) === reward.npcId &&
readU32LE(p, 9) === reward.itemId,
),
).toBe(true)
}
})
it('T5.18: ClickButton (0x4F) 32-bit gold complement encodes low u16 at bytes [3..4] and high u16 at bytes [5..6] per 1.13c word order', () => {
const goldAmount = 0x0012_3456 // 1,193,046 gold
const pkts = encodeClientCommandBytes({
type: 'ClickButton',
button: 0x0007,
complement: goldAmount,
})
const pkt = pkts[0]!
expect(pkt.byteLength).toBe(C2S_PACKET_SIZES[0x4f])
expect(pkt[0]).toBe(0x4f)
expect(readU16LE(pkt, 1)).toBe(0x0007)
expect(readU16LE(pkt, 3)).toBe(0x3456)
expect(readU16LE(pkt, 5)).toBe(0x0012)
expect(validateC2sPacket(pkt).valid).toBe(true)
})
it('T5.19: all newly wired C2S opcodes match exact C2S_PACKET_SIZES entries and pass validateC2sPacket', () => {
const cases: Array<{ opcode: number; cmd: Record<string, any> }> = [
{ opcode: 0x09, cmd: { type: 'WarpTo', unitType: 5, warpId: 101 } },
{ opcode: 0x13, cmd: { type: 'Interact', target: { unitType: 2, id: 102 } } },
{ opcode: 0x16, cmd: { type: 'ItemPickup', itemId: 103, toCursor: true } },
{ opcode: 0x17, cmd: { type: 'ItemDrop', itemId: 104 } },
{ opcode: 0x18, cmd: { type: 'ItemPlace', itemId: 105, x: 2, y: 3, buffer: 0 } },
{ opcode: 0x19, cmd: { type: 'ItemToCursor', itemId: 106 } },
{ opcode: 0x1a, cmd: { type: 'ItemEquip', itemId: 107, slot: 4 } },
{ opcode: 0x1c, cmd: { type: 'ItemUnequip', slot: 4 } },
{ opcode: 0x1d, cmd: { type: 'ItemSwap', itemId: 108, slot: 4 } },
{ opcode: 0x1f, cmd: { type: 'SwapContainerItem', cursorItemId: 109, replacedItemId: 110, x: 1, y: 1 } },
{ opcode: 0x20, cmd: { type: 'ItemUse', itemId: 111, at: { x: 5100, y: 5050 } } },
{ opcode: 0x23, cmd: { type: 'BeltPut', itemId: 114, slot: 2 } },
{ opcode: 0x24, cmd: { type: 'BeltRemove', itemId: 115 } },
{ opcode: 0x25, cmd: { type: 'BeltSwap', cursorItemId: 116, beltItemId: 117 } },
{ opcode: 0x26, cmd: { type: 'BeltUse', itemId: 118, useOnMerc: 1 } },
{ opcode: 0x27, cmd: { type: 'IdentifyItem', itemId: 119, scrollId: 120 } },
{ opcode: 0x28, cmd: { type: 'SocketItem', itemId: 121, targetItemId: 122 } },
{ opcode: 0x29, cmd: { type: 'ScrollToBook', scrollId: 123, bookId: 124 } },
{ opcode: 0x31, cmd: { type: 'QuestMessage', npcId: 125, messageId: 0 } },
{ opcode: 0x36, cmd: { type: 'MercHire', npcId: 126, mercId: 127 } },
{ opcode: 0x38, cmd: { type: 'NpcEntityAction', action: 0, npcId: 128, complement: 40 } },
{ opcode: 0x41, cmd: { type: 'Resurrect' } },
{ opcode: 0x4f, cmd: { type: 'ClickButton', button: 2, complement: 0 } },
{ opcode: 0x5d, cmd: { type: 'Hostile', button: 1, hostile: true, targetPlayerId: 129 } },
{ opcode: 0x5e, cmd: { type: 'PartyAction', action: 0x06, targetPlayerId: 130 } },
{ opcode: 0x60, cmd: { type: 'SwapWeapons' } },
{ opcode: 0x61, cmd: { type: 'MercItem', slot: 3 } },
{ opcode: 0x62, cmd: { type: 'MercResurrect', npcId: 131 } },
]
for (const { opcode, cmd } of cases) {
const pkts = encodeClientCommandBytes(cmd)
expect(pkts.length).toBeGreaterThan(0)
const pkt = pkts[0]!
expect(pkt[0]).toBe(opcode)
expect(pkt.byteLength).toBe(C2S_PACKET_SIZES[opcode])
expect(validateC2sPacket(pkt).valid).toBe(true)
}
})
it('T5.20: architectural import boundary guard — no file in src/client/** imports directly from src/netproto/d2gs/**', async () => {
const fs = await import('node:fs')
const path = await import('node:path')
const walk = (dir: string): string[] => {
const out: string[] = []
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name)
if (entry.isDirectory()) out.push(...walk(full))
else if (entry.name.endsWith('.ts')) out.push(full)
}
return out
}
const clientFiles = walk(path.resolve(process.cwd(), 'src/client'))
expect(clientFiles.length).toBeGreaterThan(0)
for (const file of clientFiles) {
const content = fs.readFileSync(file, 'utf8')
expect(content).not.toMatch(/from\s+['"][^'"]*netproto\/d2gs\//)
}
})
})
})

View File

@ -0,0 +1,126 @@
import { describe, expect, it } from 'vitest'
import { ByteReader } from '../../src/netproto/core/byte-reader.ts'
import { validateC2sPacket } from '../../src/netproto/d2gs/framing-c2s.ts'
import {
C2S_PACKET_SIZES,
encodeC2sBeltPut,
encodeC2sBeltRemove,
encodeC2sBeltSwap,
encodeC2sBeltUse,
encodeC2sClickButton,
encodeC2sHostile,
encodeC2sIdentifyItem,
encodeC2sMercItem,
encodeC2sNpcEntityAction,
encodeC2sNpcMercHire,
encodeC2sNpcMercResurrect,
encodeC2sPartyAction,
encodeC2sQuestMessage,
encodeC2sResurrect,
encodeC2sScrollToBook,
encodeC2sSocketItem,
encodeC2sSwapContainerItem,
encodeC2sSwapEquippedItem,
encodeC2sWarpTo,
encodeClientCommand,
type ClientCommand,
} from '../../src/netproto/index.ts'
describe('D2GS C->S 1.13c packet encoders & ClientCommand dispatch (M1)', () => {
it('encodes all 18 M1 C2S opcodes with exact 1.13c byte layout and C2S_PACKET_SIZES validation', () => {
// 0x09 WarpTo (9B): [0x09][unitType:u32=5][warpId:u32]
const pkt09 = encodeC2sWarpTo(101)
expect(pkt09.byteLength).toBe(C2S_PACKET_SIZES[0x09])
expect(() => validateC2sPacket(pkt09)).not.toThrow()
{
const r = new ByteReader(pkt09)
expect(r.u8()).toBe(0x09)
expect(r.u32LE()).toBe(5)
expect(r.u32LE()).toBe(101)
}
// 0x1D SwapEquippedItem (9B): [0x1D][itemId:u32][bodyLoc:u32]
const pkt1d = encodeC2sSwapEquippedItem(200, 4)
expect(pkt1d.byteLength).toBe(C2S_PACKET_SIZES[0x1d])
expect(() => validateC2sPacket(pkt1d)).not.toThrow()
// 0x1F SwapContainerItem (17B): [0x1F][cursorItemId:u32][replacedItemId:u32][x:u32][y:u32]
const pkt1f = encodeC2sSwapContainerItem(201, 202, 5, 3)
expect(pkt1f.byteLength).toBe(C2S_PACKET_SIZES[0x1f])
expect(() => validateC2sPacket(pkt1f)).not.toThrow()
{
const r = new ByteReader(pkt1f)
expect(r.u8()).toBe(0x1f)
expect(r.u32LE()).toBe(201)
expect(r.u32LE()).toBe(202)
expect(r.u32LE()).toBe(5)
expect(r.u32LE()).toBe(3)
}
// 0x23 BeltPut (9B), 0x24 BeltRemove (5B), 0x25 BeltSwap (9B), 0x26 BeltUse (13B)
const pkt23 = encodeC2sBeltPut(301, 2)
const pkt24 = encodeC2sBeltRemove(301)
const pkt25 = encodeC2sBeltSwap(302, 301)
const pkt26 = encodeC2sBeltUse(301, true)
expect(pkt23.byteLength).toBe(C2S_PACKET_SIZES[0x23])
expect(pkt24.byteLength).toBe(C2S_PACKET_SIZES[0x24])
expect(pkt25.byteLength).toBe(C2S_PACKET_SIZES[0x25])
expect(pkt26.byteLength).toBe(C2S_PACKET_SIZES[0x26])
expect(new DataView(pkt26.buffer).getUint32(5, true)).toBe(1)
// 0x27 IdentifyItem (9B), 0x28 SocketItem (9B), 0x29 ScrollToBook (9B)
const pkt27 = encodeC2sIdentifyItem(401, 402)
const pkt28 = encodeC2sSocketItem(403, 404)
const pkt29 = encodeC2sScrollToBook(405, 406)
expect(pkt27.byteLength).toBe(C2S_PACKET_SIZES[0x27])
expect(pkt28.byteLength).toBe(C2S_PACKET_SIZES[0x28])
expect(pkt29.byteLength).toBe(C2S_PACKET_SIZES[0x29])
// 0x31 QuestMessage (9B), 0x36 NpcMercHire (9B), 0x38 NpcEntityAction (13B), 0x62 NpcMercResurrect (5B)
const pkt31 = encodeC2sQuestMessage(50, 120)
const pkt36 = encodeC2sNpcMercHire(50, 9)
const pkt38 = encodeC2sNpcEntityAction(3, 50, 0)
const pkt62 = encodeC2sNpcMercResurrect(50)
expect(pkt31.byteLength).toBe(C2S_PACKET_SIZES[0x31])
expect(pkt36.byteLength).toBe(C2S_PACKET_SIZES[0x36])
expect(pkt38.byteLength).toBe(C2S_PACKET_SIZES[0x38])
expect(pkt62.byteLength).toBe(C2S_PACKET_SIZES[0x62])
// 0x41 Resurrect (1B), 0x4F ClickButton (7B), 0x5D Hostile (7B), 0x5E PartyAction (6B), 0x61 MercItem (3B)
const pkt41 = encodeC2sResurrect()
const pkt4f = encodeC2sClickButton(0x02, 0)
const pkt5d = encodeC2sHostile(77, true)
const pkt5e = encodeC2sPartyAction(6, 77)
const pkt61 = encodeC2sMercItem(3)
expect(pkt41.byteLength).toBe(C2S_PACKET_SIZES[0x41])
expect(pkt4f.byteLength).toBe(C2S_PACKET_SIZES[0x4f])
expect(pkt5d.byteLength).toBe(C2S_PACKET_SIZES[0x5d])
expect(pkt5e.byteLength).toBe(C2S_PACKET_SIZES[0x5e])
expect(pkt61.byteLength).toBe(C2S_PACKET_SIZES[0x61])
})
it('dispatches all M1 ClientCommand variants via encodeClientCommand', () => {
const commands: ClientCommand[] = [
{ type: 'WarpTo', warpId: 10 },
{ type: 'TakeWarp', warpId: 10 },
{ type: 'SwapContainerItem', cursorItemId: 1, replacedItemId: 2, x: 0, y: 0 },
{ type: 'BeltSwap', cursorItemId: 3, beltItemId: 4 },
{ type: 'ScrollToBook', scrollId: 5, bookId: 6 },
{ type: 'QuestMessage', npcId: 7, messageId: 8 },
{ type: 'MercHire', npcId: 7, mercId: 9 },
{ type: 'NpcEntityAction', action: 1, npcId: 7, complement: 0 },
{ type: 'Resurrect' },
{ type: 'ClickButton', buttonId: 0x04, amount: 500 },
{ type: 'Hostile', targetPlayerId: 12, hostile: true },
{ type: 'PartyAction', action: 7, targetPlayerId: 12 },
{ type: 'MercItem', slot: 4 },
{ type: 'MercResurrect', npcId: 7 },
]
for (const cmd of commands) {
const pkts = encodeClientCommand(cmd)
expect(pkts).toHaveLength(1)
expect(() => validateC2sPacket(pkts[0]!)).not.toThrow()
}
})
})

View File

@ -0,0 +1,144 @@
import { describe, expect, it } from 'vitest'
import { ByteWriter } from '../../src/netproto/core/byte-writer.ts'
import {
decodeD2gsS2cPacket,
decodeD2gsServerPacket,
S2C_PACKET_SIZES,
} from '../../src/netproto/index.ts'
describe('D2GS S->C 1.13c packet decoders (M1)', () => {
it('decodes 0x09 AssignWarp with warpId populated on UnitSnapshot', () => {
const w = new ByteWriter(S2C_PACKET_SIZES[0x09]!)
w.u8(0x09)
w.u8(5) // unitType = tile
w.u32LE(300) // unitId
w.u8(18) // warpId / classId
w.u16LE(5100)
w.u16LE(5200)
const ev = decodeD2gsServerPacket(w.toUint8Array())
expect(ev.type).toBe('UnitAssign')
if (ev.type === 'UnitAssign') {
expect(ev.unit.unitType).toBe(5)
expect(ev.unit.id).toBe(300)
expect(ev.unit.classId).toBe(18)
expect(ev.unit.warpId).toBe(18)
expect(ev.unit.x).toBe(5100)
expect(ev.unit.y).toBe(5200)
}
})
it('decodes 0x27 NPCInfo, 0x91 NPCGetShow, and 0xAB NPCHeal', () => {
const w27 = new ByteWriter(S2C_PACKET_SIZES[0x27]!)
w27.u8(0x27)
w27.u8(1)
w27.u32LE(44)
w27.bytes(new Uint8Array(34))
const ev27 = decodeD2gsS2cPacket(w27.toUint8Array())
expect(ev27).toMatchObject({ type: 'Npc', kind: 'info', npcId: 44 })
const w91 = new ByteWriter(S2C_PACKET_SIZES[0x91]!)
w91.u8(0x91)
w91.u8(0)
w91.u32LE(44)
w91.bytes(new Uint8Array(20))
const ev91 = decodeD2gsS2cPacket(w91.toUint8Array())
expect(ev91).toMatchObject({ type: 'Npc', kind: 'menu', npcId: 44 })
const wab = new ByteWriter(S2C_PACKET_SIZES[0xab]!)
wab.u8(0xab)
wab.u8(1)
wab.u32LE(44)
wab.u8(120)
const evab = decodeD2gsS2cPacket(wab.toUint8Array())
expect(evab).toEqual({ type: 'Npc', kind: 'heal', npcId: 44, result: 120 })
})
it('decodes 0x4E MercForHire, 0x4F StartMercList, 0x81 MercAssign, and 0x9B MercReviveCost', () => {
const w4f = new ByteWriter(1)
w4f.u8(0x4f)
expect(decodeD2gsS2cPacket(w4f.toUint8Array())).toEqual({
type: 'Merc',
kind: 'hireList',
})
const w4e = new ByteWriter(7)
w4e.u8(0x4e)
w4e.u16LE(14)
w4e.u32LE(0x12345678)
expect(decodeD2gsS2cPacket(w4e.toUint8Array())).toEqual({
type: 'Merc',
kind: 'hireList',
nameId: 14,
value: 0x12345678,
})
const w81 = new ByteWriter(20)
w81.u8(0x81)
w81.u8(0)
w81.u16LE(271)
w81.u32LE(1)
w81.u32LE(99)
w81.u32LE(0xdeadbeef)
w81.u32LE(0)
expect(decodeD2gsS2cPacket(w81.toUint8Array())).toEqual({
type: 'Merc',
kind: 'assign',
ownerId: 1,
mercId: 99,
classId: 271,
value: 0xdeadbeef,
})
const w9b = new ByteWriter(7)
w9b.u8(0x9b)
w9b.u16LE(14)
w9b.u32LE(25000)
expect(decodeD2gsS2cPacket(w9b.toUint8Array())).toEqual({
type: 'Merc',
kind: 'stat',
nameId: 14,
value: 25000,
})
})
it('decodes 0x77 ButtonActions, 0x78 TradeAccepted (with partnerName), 0x79 GoldInTrade, and 0x92 ClearDisplay', () => {
const w77 = new ByteWriter(2)
w77.u8(0x77)
w77.u8(0x01)
expect(decodeD2gsS2cPacket(w77.toUint8Array())).toEqual({
type: 'Trade',
action: 0x01,
})
const w78 = new ByteWriter(21)
w78.u8(0x78)
w78.fixedCString('TradeBuddy', 16)
w78.u32LE(7)
expect(decodeD2gsS2cPacket(w78.toUint8Array())).toEqual({
type: 'Trade',
action: 0x78,
partnerId: 7,
partnerName: 'TradeBuddy',
})
const w79 = new ByteWriter(6)
w79.u8(0x79)
w79.u8(1)
w79.u32LE(12500)
expect(decodeD2gsS2cPacket(w79.toUint8Array())).toEqual({
type: 'Trade',
action: 0x7901,
gold: 12500,
})
const w92 = new ByteWriter(6)
w92.u8(0x92)
w92.u8(0)
w92.u32LE(7)
expect(decodeD2gsS2cPacket(w92.toUint8Array())).toEqual({
type: 'Trade',
action: 0x9200,
partnerId: 7,
})
})
})