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:
parent
ff1f8e4cbe
commit
8dac79e4e4
232
PROJECT.md
232
PROJECT.md
|
|
@ -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.
|
||||
|
|
|
|||
119
TEST_INFRA.md
119
TEST_INFRA.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ export const D2StatId = {
|
|||
LightResist: 41,
|
||||
ColdResist: 43,
|
||||
PoisonResist: 45,
|
||||
SocketCount: 194,
|
||||
} as const
|
||||
|
||||
export class StatList {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,9 +717,15 @@ 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})` : ''}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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: '泰瑞尔',
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ export function decode0x09AssignWarp(pkt: Uint8Array): ServerEvent {
|
|||
unitType,
|
||||
id,
|
||||
classId: warpId,
|
||||
warpId,
|
||||
x,
|
||||
y,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
| {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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)', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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\//)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue