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
|
## 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
|
```text
|
||||||
diablo2-web/
|
diablo2-web/
|
||||||
├── play.html # Sole HTML entry point (loads /src/client/main.ts)
|
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── common/ # D2Common.dll + Storm.dll + D2Lang.dll (ES2022, types: [], zero DOM/Node)
|
│ ├── common/
|
||||||
│ │ ├── mpq/ # Pure MPQ archive & decompression (no file-source.ts)
|
│ │ ├── items/ # 1.13c item tables (embedded-drop-tables.ts), stat formatter (item-bridge.ts), tooltip builder (item-tooltip.ts), ground-items.ts
|
||||||
│ │ ├── formats/ # DC6, DCC, DT1, DS1, COF, PL2, PAL, CEL, PCX, TBL, AnimData, Bitstream
|
│ │ └── units/ # All-Act Town & Quest NPC descriptors (npc-table.ts: TOWN_NPC_DESCRIPTORS, NPC_HCIDX_TO_DESCRIPTOR_ID)
|
||||||
│ │ ├── data/ # Excel .txt data-registry (injected ArchiveSource, no hardcoded d2-data) & canonical tables
|
│ ├── netproto/
|
||||||
│ │ ├── drlg/ # 1:1 1.13c DRLG map generation, collision masks, preset units, warps, light-grid
|
│ │ ├── index.ts # Sole public barrel export for netproto (strict boundary for src/client/**)
|
||||||
│ │ ├── world/ # acts, act-variants, levels-meta, level-links, level-names-zh, world-graph, map, d2map, warp-tiles, automap-table, animated-tiles timing
|
│ │ ├── domain/ # Typed ClientCommand (client-command.ts), ServerEvent (server-event.ts), ItemData & UnitSnapshot (ids.ts)
|
||||||
│ │ ├── 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
|
│ │ └── d2gs/
|
||||||
│ │ ├── skills/ # Static Skills.txt/Missiles.txt/Overlay.txt tables, skill-tree, skill-calc-engine, formulas, calc-ast, skills-meta
|
│ │ ├── tables/ # Authoritative 1.13c packet size tables (s2c-sizes.ts: C2S_PACKET_SIZES, S2C_PACKET_SIZES)
|
||||||
│ │ ├── units/ # classes, character, monster-mapping, monsters-data, npc-table, object-lookup, objects, plr-mode, pure monster-art compositor
|
│ │ ├── items/ # 0x9C/0x9D item bitstream decoder (item-bitstream.ts)
|
||||||
│ │ ├── i18n/ # Pure .tbl lookup (lookupTbl, TblLang, GameLang) & tooltip-i18n decoupled from UI lang state
|
│ │ ├── c2s/ # C2S binary packet encoders (items.ts, npc.ts, party.ts, movement.ts, interact.ts, ui.ts)
|
||||||
│ │ ├── rng/ # d2-rng, SplitMix64 pickVariant (variants.ts)
|
│ │ ├── s2c/ # S2C binary packet decoders (units.ts, npc.ts, merc.ts, party.ts, trade.ts, waypoint.ts, combat.ts)
|
||||||
│ │ └── pack-contract/ # Shared tile/scene/entity pack manifest schemas & fail-fast universal DT1 / missingFiles===0 validators
|
│ │ └── registry.ts # encodeClientCommand & decodeD2gsServerPacket dispatchers
|
||||||
│ ├── netproto/ # D2Net.dll + Bnclient.dll + D2MCPClient.dll (strictly isolated; external code imports only src/netproto/index.ts)
|
│ └── client/
|
||||||
│ ├── server/ # D2Game.dll (ES2022 + WebWorker, types: [], zero DOM, zero client/baker imports, zero Math.random/Date.now/performance.now)
|
│ ├── world/ # Authoritative ClientWorld state reducer (client-world.ts, client-unit.ts, inventory.ts, self.ts)
|
||||||
│ │ ├── engine/ # GameEngine, world-arena, combat-pipeline, missile-engine, state-bus, aura-scanner, summon-manager, anim-dispatcher, environment, stat-list, state-hash
|
│ ├── view/ # Scene drawables & picking (unit-drawables.ts, scene-source.ts)
|
||||||
│ │ ├── ai/ # Monster AI, spawning, monster-rooms, monster-streaming
|
│ ├── scene/ # WorldRenderer (world-renderer.ts)
|
||||||
│ │ ├── skills/ # Skill registry (static imports, zero node:*), skills simulation, impl/{ama,ass,bar,dru,nec,pal,sor,uni}
|
│ ├── session/ # OnlineSession (online-session.ts)
|
||||||
│ │ ├── items/ # drop-pipeline, treasure-class, treasure-engine, auto-tc, affix-generator, automagic, ground-items, runeword-socket, gems, vendor-generator, vendor-service
|
│ ├── input/ # CommandMapper (command-mapper.ts)
|
||||||
│ │ ├── world/ # objects interaction, quests, npc, portal, class-starter-profiles
|
│ ├── ui-model/ # HudModel (hud-model.ts): syncs ClientWorld -> HudManager/WorldPanelsHud & emits ClientCommand
|
||||||
│ │ ├── save/ # .d2s character save read/write
|
│ └── ui/ # HudManager (hud-manager.ts), WorldPanelsHud (world-panels.ts), InventoryPanel (inventory.ts), Minimap (minimap.ts)
|
||||||
│ │ ├── scene/ # chat-commands, scene-links-nav, scene-population, skill-caster, weapon-combat
|
└── tests/
|
||||||
│ │ └── adapter/ # LocalLoopbackAdapter implementing GameServerAdapter from src/netproto/index.ts
|
├── client/ # Unit & integration tests (hud-server-authority, vendor-stash-ui, vendor-stash-replay, client-world, hud-session-play)
|
||||||
│ ├── client/ # D2Client.dll + D2Gfx.dll + D2Win.dll + D2Launch.dll + D2Sound.dll (ES2022 + DOM + DOM.Iterable, types: [], zero Node/server/baker imports)
|
└── e2e-d2gs-online/ # 4-tier opaque-box E2E test suite + Tier 5 adversarial coverage hardening
|
||||||
│ │ ├── 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Strict Dependency DAG
|
### 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.
|
||||||
| From ↓ Imports → | `src/common/**` | `src/netproto/index.ts` | `src/server/**` | `src/client/**` | `src/baker/**` | `node:*` | DOM Globals |
|
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/**`).
|
||||||
| **`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) |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Feature Inventory
|
## 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 |
|
| # | 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 |
|
| 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 | 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 |
|
| 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 | 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 |
|
| 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 | `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 |
|
| 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 | `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 |
|
| 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 | `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 |
|
| 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 | `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 |
|
| 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 | 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 |
|
| 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 | 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 |
|
| 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 | 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 |
|
| 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 |
|
| # | 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`) |
|
| 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 | `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) |
|
| 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 | `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) |
|
| 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: 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`) |
|
| 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–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`) |
|
| 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
|
## Interface Contracts
|
||||||
|
|
||||||
### 1. `src/common/data/data-registry.ts` ↔ Callers (`src/server/`, `src/client/`, `src/baker/`)
|
### 1. `src/netproto/` ↔ `src/client/` (`ClientCommand` & C2S Encoders)
|
||||||
- `ArchiveSource` interface in `src/common/data/data-registry.ts`:
|
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`:
|
||||||
- `readText(mpqRelativePath: string): Promise<string | null> | string | null`
|
- `0x09 WarpTo` (9B: `u8 0x09 | u32LE unitType(5) | u32LE warpId`):
|
||||||
- `readBytes(mpqRelativePath: string): Promise<Uint8Array | null> | Uint8Array | null`
|
- `encodeC2sWarpTo(warpId: number, unitType?: number): Uint8Array`
|
||||||
- `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).
|
- `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/`
|
### 2. `src/common/units/npc-table.ts` ↔ `src/client/`
|
||||||
- Exports `PackedDrlgBundleManifest`, `PackedTileCatalogIndex`, `PackedTilePlacement`, `PackedDt1TileEntry`, `PackedDt1LibraryEntry`, `PackIndex`, `PackIndexEntry`, `PackObject`, `PackObjectFrame`, `PackSceneJson`, `EntityMeta`, `ClipMeta`.
|
- `TownNpcServiceDescriptor` includes:
|
||||||
- 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`).
|
- `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
|
### 3. `ClientWorld` (`src/client/world/client-world.ts`) ↔ `HudModel` & `SceneSource`
|
||||||
- `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.
|
- **Mercenary state (`world.merc`)**:
|
||||||
- Node filesystem loading (`createNodePackedTileLoader(dirPath)` / `loadMapServiceFromPackedDir(dirPath)`) lives in `tools/lib/node-map-loader.ts` (or `src/baker/io/`).
|
- `hireList: readonly { nameId: number; seed: number }[]` (cleared on `0x4F`, appended on `0x4E`)
|
||||||
|
- `reviveCost: number`, `deadNameId: number` (populated on `0x9B MercReviveCost`)
|
||||||
### 4. `src/netproto/index.ts` ↔ `src/server/adapter/LocalLoopbackAdapter.ts` & `src/client/`
|
- `lifePct: number` (updated on `0x0C`, `0xAB`, `0x69`, `0x81`, `0x9E..0xA0`)
|
||||||
- `LocalLoopbackAdapter` in `src/server/adapter/LocalLoopbackAdapter.ts` implements `GameServerAdapter` from `src/netproto/index.ts`:
|
- **NPC interaction state (`world.npcInteraction`)**:
|
||||||
- `connect(): Promise<void>`
|
- Stores `0x27 NPCInfo` (`npcInfoRaw`) and `0x91 SetNPCTalk` (`npcTalkRaw`), while keeping immediate `npcId` assignment on outgoing `NpcInit` for synthetic tests.
|
||||||
- `disconnect(): void`
|
- Supports `window: 'none' | 'trade' | 'gamble' | 'hire' | 'imbue' | 'add_sockets' | 'personalize'`.
|
||||||
- `readonly isConnected: boolean`
|
- **Warp, Portal, Ground Item, `SkillCast` & Death state**:
|
||||||
- `readonly tick: number`
|
- `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)`.
|
||||||
- `send(cmd: ClientCommand): void`
|
- Town Portal objects (`classId` `59`/`60`) join `world.portals.get(id)` (`ownerName`, `areaId`).
|
||||||
- `onEvent(handler: (ev: ServerEvent) => void): () => void`
|
- Ground item units carry `goldAmount` (`item.quantity`) and resolved item names.
|
||||||
- `onRawPacket?(handler: (direction: 'S2C' | 'C2S', opcode: number, bytes: Uint8Array, decoded?: ServerEvent | ClientCommand) => void): () => void`
|
- `case 'SkillCast'` updates caster unit `castState`, facing `dir`, and attack/cast animation mode (`'A1'`/`'SC'`).
|
||||||
- `src/client/**` never imports `src/server/**`; `src/client/main.ts` consumes any `GameServerAdapter` via `OnlineSession`.
|
- 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
|
## 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.
|
- **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).
|
||||||
- **Concurrent Write Ownership**:
|
- **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`.
|
||||||
- `M1`, `M2`, and `M3` execute sequentially on the codebase so `git mv` and import codemods never collide.
|
- **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`.
|
||||||
- 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.
|
- **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).
|
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`).
|
||||||
- **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`).
|
### Core Verification Principles
|
||||||
- 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)`.
|
1. **Diablo II v1.13c Binary & Data-Table Ground Truth**:
|
||||||
- Tile variant selection (`pickVariant`) must use high-entropy SplitMix64 2D spatial coordinate hashing without 45-degree diagonal striping.
|
- 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`).
|
||||||
- All 66 `SuperUniques` must preserve exact 1.13c `Class` and minion identifiers (`scarab2`, `zombie5`, `fetishshaman4`, `nihlathakboss`, `minion1`..`minion11` -> token `'XX'`, etc.).
|
- 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.
|
||||||
- Missing required parameters (e.g., `D2DataRegistry` / `getSharedDataRegistry` called without an `ArchiveSource`) must fail fast by throwing an explicit `Error`.
|
- 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`).
|
||||||
- **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`).
|
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 |
|
| ID | Feature | Requirement | Primary Public Contracts Under Test |
|
||||||
|---|---|---|---|---:|---:|---:|---:|
|
|---|---|---|---|
|
||||||
| **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 | ✓ | ✓ |
|
| **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** | Shared Pack Contract (`src/common/pack-contract/`) | `src/common/pack-contract/index.ts` | Exports `PackedDrlgBundleManifest`, `PackedTileCatalogIndex`, `PackIndex`, `PackSceneJson`, `EntityMeta`, `ClipMeta`, `validateUniversalDt1Libraries`, `validatePackManifest`. | 5 | 5 | ✓ | ✓ |
|
| **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** | 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 | ✓ | ✓ |
|
| **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** | `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 | ✓ | ✓ |
|
| **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** | `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 | ✓ | ✓ |
|
| **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** | `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 | ✓ | ✓ |
|
| **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** | `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 | ✓ | ✓ |
|
| **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** | 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 | ✓ | ✓ |
|
| **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** | 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 | ✓ | ✓ |
|
| **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** | 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 | ✓ | ✓ |
|
| **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
|
```text
|
||||||
tests/e2e-module-split/
|
tests/e2e-d2gs-online/
|
||||||
├── tier1-feature-coverage.test.ts # >= 50 tests (>= 5 per feature F1–F10): happy-path module layout, exports, & contracts
|
├── helpers.ts # Shared fixtures, mock Canvas/DOM harness, packet/item builders, runtime wrappers
|
||||||
├── tier2-boundary-corner.test.ts # >= 50 tests (>= 5 per feature F1–F10): fail-fast, negative, boundary & isolation invariants
|
├── tier1-feature-coverage.test.ts # Tier 1: Core feature happy-path coverage (80 tests: 5 per F1–F16)
|
||||||
├── tier3-cross-feature.test.ts # >= 15 tests: pairwise cross-module & cross-feature integration contracts
|
├── tier2-boundary-corner.test.ts # Tier 2: Boundary, corner, negative & anti-regression coverage (80 tests: 5 per F1–F16)
|
||||||
└── tier4-real-world-scenarios.test.ts # >= 6 tests: realistic end-to-end gameplay, DRLG, item/socket, .d2cap replay, & repo audit scenarios
|
├── 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`)**:
|
1. **Scenario 1 — Town Identification, Tome Packing, Rune Socketing & Weapon Swap Loop (`F1 + F2 + F3 + F4`)**:
|
||||||
- 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.
|
- 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 — End-to-End DRLG Level Generation, Void Collision & `findIsoSpawn` Across Act 1–5**:
|
2. **Scenario 2 — Belt Potion Management & Shift-Click Mercenary Feeding (`F2 + F3 + F8 + F10`)**:
|
||||||
- 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.
|
- 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 — End-to-End Item, Inventory, Belt & Socket Pipeline (`src/common/items/` ↔ `src/server/items/`)**:
|
3. **Scenario 3 — Multi-Act Town NPC Dialogue, Cain Identification & Act Travel (`F2 + F5 + F6 + F7`)**:
|
||||||
- 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`).
|
- 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 — End-to-End `.d2cap` Binary Packet Capture Replay into `ClientWorld`**:
|
4. **Scenario 4 — Mercenary Captain Hiring, Equipment Paperdoll (`O`) & Post-Combat Resurrection (`F1 + F2 + F8 + F9 + F10`)**:
|
||||||
- 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.
|
- 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 — End-to-End Offline Asset Pack Manifest & Entity/Tile Pack Integrity**:
|
5. **Scenario 5 — Dungeon Warp Traversal, Town Portal Ownership Hover & Ground Loot Pickup (`F2 + F11 + F12`)**:
|
||||||
- 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/`.
|
- 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 — End-to-End Repository Architectural & Module Boundary Audit**:
|
6. **Scenario 6 — Combat SkillCast Animation, Player Death Overlay & Town Corpse Resurrection (`F2 + F13 + F14`)**:
|
||||||
- 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.
|
- 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`)**:
|
||||||
## 5. Coverage Thresholds
|
- 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.
|
||||||
|
|
||||||
| 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,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`) |
|
| **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 | `src/common/` Shared Formats, MPQ, Data & i18n Submodules | 5 | 5 | Yes (`CF1`, `CF8`, `CF11`) | Yes (`Scenario 3`, `Scenario 6`) | Yes (`adv_F2_01..04`) |
|
| **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 | `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`) |
|
| **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 | `src/netproto/` Pure Binary Wire Protocol Layer | 5 | 5 | Yes (`CF6`, `CF7`, `CF16`) | Yes (`Scenario 1`, `Scenario 2`, `Scenario 3`) | Yes (`adv_BUG_03`) |
|
| **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 | `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`) |
|
| **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 | `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`) |
|
| **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 | Unified `play.html` Single-Entry Launcher & Vite Build Consolidation | 5 | 5 | Yes (`CF12`, `CF13`) | Yes (`Scenario 4`) | Yes (`adv_BUG_01`) |
|
| **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 | `src/baker/` Offline MPQ Extraction & CLI Tools Alignment | 5 | 5 | Yes (`CF5`, `CF11`, `CF15`) | Yes (`Scenario 3`, `Scenario 5`) | Yes (`adv_F4_01..03`) |
|
| **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 | Multi-TSConfig Compile-Time Module Boundary Enforcement | 5 | 5 | Yes (`CF13`, `CF14`, `CF15`) | Yes (`Scenario 3`) | Yes (`adv_BUG_01..03`) |
|
| **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 | Repository-Wide Import Graph & Test/Benchmark Suite Migration | 5 | 5 | Yes (`CF16`) | Yes (`Scenario 2`, `Scenario 3`) | Yes (`adv_BUG_02`, `adv_BUG_03`) |
|
| **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 dx = unit.x - subtileX
|
||||||
const dy = unit.y - subtileY
|
const dy = unit.y - subtileY
|
||||||
const d2 = dx * dx + dy * dy
|
const d2 = dx * dx + dy * dy
|
||||||
if (d2 <= bestDistSq) {
|
if (d2 <= radiusSq && (bestUnit === null || d2 < bestDistSq)) {
|
||||||
bestDistSq = d2
|
bestDistSq = d2
|
||||||
bestUnit = unit
|
bestUnit = unit
|
||||||
}
|
}
|
||||||
|
|
@ -281,7 +281,7 @@ export class CommandMapper {
|
||||||
const dx = unit.x - subtileX
|
const dx = unit.x - subtileX
|
||||||
const dy = unit.y - subtileY
|
const dy = unit.y - subtileY
|
||||||
const d2 = dx * dx + dy * dy
|
const d2 = dx * dx + dy * dy
|
||||||
if (d2 <= bestDistSq) {
|
if (d2 <= radiusSq && (bestUnit === null || d2 < bestDistSq)) {
|
||||||
bestDistSq = d2
|
bestDistSq = d2
|
||||||
bestUnit = unit
|
bestUnit = unit
|
||||||
}
|
}
|
||||||
|
|
@ -429,8 +429,22 @@ export class CommandMapper {
|
||||||
|
|
||||||
// Unit interaction when clicking directly on an entity
|
// Unit interaction when clicking directly on an entity
|
||||||
if (hoveredUnit) {
|
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') {
|
if (hoveredUnit.unitType === 'item') {
|
||||||
const path = this.startLocalPathPrediction(hoveredUnit.x, hoveredUnit.y, isRunning)
|
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(
|
return this.dispatchRateLimited(
|
||||||
{
|
{
|
||||||
type: 'ItemPickup',
|
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 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(
|
return this.dispatchRateLimited(
|
||||||
{
|
{
|
||||||
type: 'Interact',
|
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') {
|
if (hoveredUnit.unitType === 'monster') {
|
||||||
const isTownArea =
|
const isTownArea =
|
||||||
this.world.areaId === 1 ||
|
this.world.areaId === 1 ||
|
||||||
|
|
@ -587,9 +651,23 @@ export class CommandMapper {
|
||||||
|
|
||||||
const keyUpper = event.key.toUpperCase()
|
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 (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()
|
this.hudManager.closeAllPanels()
|
||||||
event.preventDefault?.()
|
event.preventDefault?.()
|
||||||
return true
|
return true
|
||||||
|
|
@ -722,9 +800,34 @@ export class CommandMapper {
|
||||||
this.hudManager.toggleLeftPanel('quest')
|
this.hudManager.toggleLeftPanel('quest')
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
case 'P':
|
case 'P': {
|
||||||
|
if (this.hudModel) {
|
||||||
|
this.hudModel.togglePanel('party')
|
||||||
|
} else {
|
||||||
|
this.hudManager.toggleLeftPanel('party' as any)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
case 'M': {
|
case 'M': {
|
||||||
this.hudManager.toggleLeftPanel('waypoint')
|
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
|
return true
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
|
@ -841,6 +944,17 @@ export class CommandMapper {
|
||||||
const rateLimit = this.rateLimiter.tryAcquire(command, nowMs)
|
const rateLimit = this.rateLimiter.tryAcquire(command, nowMs)
|
||||||
if (rateLimit.allowed) {
|
if (rateLimit.allowed) {
|
||||||
this.sendCommand(command)
|
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 {
|
return {
|
||||||
command,
|
command,
|
||||||
|
|
|
||||||
|
|
@ -36,12 +36,15 @@ import type {
|
||||||
import { MONSTER_ART_MAP } from '../../common/units/monster-mapping.ts'
|
import { MONSTER_ART_MAP } from '../../common/units/monster-mapping.ts'
|
||||||
import { getTownNpcDescriptorByHcIdx } from '../../common/units/npc-table.ts'
|
import { getTownNpcDescriptorByHcIdx } from '../../common/units/npc-table.ts'
|
||||||
import { BAKED_UI_MANIFEST, type SpriteRect } from '../../common/items/baked-ui-meta.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 { resolveGroundItemSpriteRect } from '../ui/inventory.ts'
|
||||||
import {
|
import {
|
||||||
applyLightingInputs,
|
applyLightingInputs,
|
||||||
type LightingPassTargets,
|
type LightingPassTargets,
|
||||||
} from './lighting-presets.ts'
|
} from './lighting-presets.ts'
|
||||||
|
|
||||||
|
export { layoutGroundItemLabels }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Diagnostic entry recorded whenever an entity, item, missile, overlay, or tile
|
* Diagnostic entry recorded whenever an entity, item, missile, overlay, or tile
|
||||||
* is missing its authentic art asset at render time (Issue #540 R3).
|
* is missing its authentic art asset at render time (Issue #540 R3).
|
||||||
|
|
@ -553,6 +556,7 @@ function drawSceneUnitDrawable(
|
||||||
if (unit.unitKind === 'item') {
|
if (unit.unitKind === 'item') {
|
||||||
const rawCode =
|
const rawCode =
|
||||||
unit.code?.trim().toLowerCase() ||
|
unit.code?.trim().toLowerCase() ||
|
||||||
|
unit.itemCode?.trim().toLowerCase() ||
|
||||||
(unit.token && !unit.token.startsWith('ITM_') ? unit.token.trim().toLowerCase() : '') ||
|
(unit.token && !unit.token.startsWith('ITM_') ? unit.token.trim().toLowerCase() : '') ||
|
||||||
BAKED_UI_MANIFEST.itemClassIdToCode[unit.classId] ||
|
BAKED_UI_MANIFEST.itemClassIdToCode[unit.classId] ||
|
||||||
''
|
''
|
||||||
|
|
@ -623,6 +627,10 @@ function drawSceneUnitDrawable(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (unit.unitKind === 'warp' || unit.unitKind === 'tile') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (unit.unitKind === 'object') {
|
if (unit.unitKind === 'object') {
|
||||||
if (unit.classId === 59 || unit.classId === 60) {
|
if (unit.classId === 59 || unit.classId === 60) {
|
||||||
if (ctx.overlayArtMap && ctx.overlayArtMap.size > 0) {
|
if (ctx.overlayArtMap && ctx.overlayArtMap.size > 0) {
|
||||||
|
|
@ -943,15 +951,38 @@ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFram
|
||||||
)
|
)
|
||||||
const isEn = getTblLang() === 'ENG'
|
const isEn = getTblLang() === 'ENG'
|
||||||
const automapEntities: AutomapEntityMarker[] = []
|
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) {
|
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)
|
const npcDesc = getTownNpcDescriptorByHcIdx(u.classId)
|
||||||
if (npcDesc) {
|
if (npcDesc) {
|
||||||
|
automapEntities.push({
|
||||||
|
x: u.worldX,
|
||||||
|
y: u.worldY,
|
||||||
|
kind: 'npc',
|
||||||
|
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({
|
automapEntities.push({
|
||||||
x: u.worldX,
|
x: u.worldX,
|
||||||
y: u.worldY,
|
y: u.worldY,
|
||||||
kind: 'npc',
|
kind: 'party',
|
||||||
name: isEn ? npcDesc.displayName : npcDisplayName(npcDesc.displayName),
|
...(u.name !== undefined ? { name: u.name } : {}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -971,6 +1002,7 @@ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFram
|
||||||
leftPanelOpen: Boolean(ctx.hudManager && ctx.hudManager.leftPanel !== 'none'),
|
leftPanelOpen: Boolean(ctx.hudManager && ctx.hudManager.leftPanel !== 'none'),
|
||||||
rightPanelOpen: Boolean(ctx.hudManager && ctx.hudManager.rightPanel !== 'none'),
|
rightPanelOpen: Boolean(ctx.hudManager && ctx.hudManager.rightPanel !== 'none'),
|
||||||
entities: automapEntities,
|
entities: automapEntities,
|
||||||
|
portals: automapPortals,
|
||||||
levelId: snapshot.levelId,
|
levelId: snapshot.levelId,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,8 @@ import {
|
||||||
type ServerEvent,
|
type ServerEvent,
|
||||||
type UnitType,
|
type UnitType,
|
||||||
} from '../../netproto/index.ts'
|
} 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 { requireTbl } from '../i18n/lang.ts'
|
||||||
import { HudManager } from '../ui/hud-manager.ts'
|
import { HudManager } from '../ui/hud-manager.ts'
|
||||||
import { SoundService } from '../audio/sound-service.ts'
|
import { SoundService } from '../audio/sound-service.ts'
|
||||||
|
|
@ -52,6 +54,7 @@ import {
|
||||||
type CameraLike,
|
type CameraLike,
|
||||||
} from '../input/command-mapper.ts'
|
} from '../input/command-mapper.ts'
|
||||||
import { PacketInspector, type ReplayedD2CapFrame } from '../inspector/packet-inspector.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 type { MapService } from '../map/map-service.ts'
|
||||||
import { RemoteEntityInterpolator } from '../motion/interpolate.ts'
|
import { RemoteEntityInterpolator } from '../motion/interpolate.ts'
|
||||||
import { LocalMovementPredictor } from '../motion/predictor.ts'
|
import { LocalMovementPredictor } from '../motion/predictor.ts'
|
||||||
|
|
@ -228,6 +231,7 @@ export class OnlineSession {
|
||||||
readonly hudModel: HudModel
|
readonly hudModel: HudModel
|
||||||
readonly commandMapper: CommandMapper
|
readonly commandMapper: CommandMapper
|
||||||
readonly sceneSource: ClientWorldSceneSource
|
readonly sceneSource: ClientWorldSceneSource
|
||||||
|
levelView?: LevelView | null | undefined
|
||||||
|
|
||||||
private readonly resolver: EndpointResolver
|
private readonly resolver: EndpointResolver
|
||||||
private readonly flowFactory: (config: D2OnlineConfig) => D2OnlineFlow
|
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
|
// canvas contains the player's world sub-tile (1.13c: unit → pRoom → pLevel); until such a
|
||||||
// level exists the scene reports not-ready.
|
// level exists the scene reports not-ready.
|
||||||
const predSnap = this.predictor.getSnapshot()
|
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 =
|
const levelView =
|
||||||
this.mapService && this.world.areaId > 0 && (predSnap.x !== 0 || predSnap.y !== 0)
|
this.levelView ??
|
||||||
? this.mapService.findLevelViewAtWorldSubTile(predSnap.x, predSnap.y, [
|
(this.mapService && this.world.areaId > 0 && (playerSubX !== 0 || playerSubY !== 0)
|
||||||
|
? this.mapService.findLevelViewAtWorldSubTile(playerSubX, playerSubY, [
|
||||||
this.world.areaId,
|
this.world.areaId,
|
||||||
...this.world.getRevealedAreaIds(),
|
...this.world.getRevealedAreaIds(),
|
||||||
])
|
])
|
||||||
: null
|
: null)
|
||||||
const levelId = levelView?.areaId ?? 0
|
const levelId = levelView?.areaId ?? (this.world.areaId > 0 ? this.world.areaId : 0)
|
||||||
return {
|
return {
|
||||||
tick: this.tickCounter,
|
tick: this.tickCounter,
|
||||||
...(levelId > 0 ? { actId: getActForArea(levelId), levelId } : {}),
|
...(levelId > 0 ? { actId: getActForArea(levelId), levelId } : {}),
|
||||||
playerSubX: predSnap.x,
|
playerSubX,
|
||||||
playerSubY: predSnap.y,
|
playerSubY,
|
||||||
units: this.buildRenderableUnits(),
|
units: this.buildRenderableUnits(),
|
||||||
levelView,
|
levelView,
|
||||||
tileAtlas: this.mapService?.getTileAtlas() ?? null,
|
tileAtlas: this.mapService?.getTileAtlas() ?? null,
|
||||||
|
|
@ -903,8 +910,53 @@ export class OnlineSession {
|
||||||
* Process an authoritative `ServerEvent` across `ClientWorld`, `MapService`, `AutomapView`,
|
* Process an authoritative `ServerEvent` across `ClientWorld`, `MapService`, `AutomapView`,
|
||||||
* `LocalMovementPredictor`, `RemoteEntityInterpolator`, `SoundService`, and `HudModel`.
|
* `LocalMovementPredictor`, `RemoteEntityInterpolator`, `SoundService`, and `HudModel`.
|
||||||
*/
|
*/
|
||||||
handleServerEvent(ev: ServerEvent): void {
|
handleServerEvent(rawEv: ServerEvent): void {
|
||||||
const prevChatCount = this.world.chatLog.length
|
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)
|
this.world.apply(ev)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|
@ -1203,6 +1255,22 @@ export class OnlineSession {
|
||||||
// Self is not renderable until the server has assigned both its unit id (0x59) and class.
|
// Self is not renderable until the server has assigned both its unit id (0x59) and class.
|
||||||
const selfClass = this.world.self.charClass
|
const selfClass = this.world.self.charClass
|
||||||
if (this.world.selfId !== 0 && selfClass !== null) {
|
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({
|
out.push({
|
||||||
id: this.world.selfId,
|
id: this.world.selfId,
|
||||||
kind: 'player',
|
kind: 'player',
|
||||||
|
|
@ -1210,16 +1278,31 @@ export class OnlineSession {
|
||||||
name: this.world.self.name,
|
name: this.world.self.name,
|
||||||
x: selfX,
|
x: selfX,
|
||||||
y: selfY,
|
y: selfY,
|
||||||
mode: predSnap.moving ? (predSnap.running ? 3 : 2) : 1,
|
...(selfCastState?.targetX !== undefined ? { targetX: selfCastState.targetX } : {}),
|
||||||
direction: predSnap.dir16,
|
...(selfCastState?.targetY !== undefined ? { targetY: selfCastState.targetY } : {}),
|
||||||
|
mode: selfMode,
|
||||||
|
direction: selfDirection,
|
||||||
hpRatio:
|
hpRatio:
|
||||||
this.world.self.maxLife > 0
|
this.world.self.maxLife > 0
|
||||||
? Math.max(0, Math.min(1, this.world.self.life / this.world.self.maxLife))
|
? Math.max(0, Math.min(1, this.world.self.life / this.world.self.maxLife))
|
||||||
: 1,
|
: 1,
|
||||||
isLocalPlayer: true,
|
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()) {
|
for (const u of this.world.units.values()) {
|
||||||
if (u.unitType === 'player' && this.world.selfId !== 0 && u.id === this.world.selfId) {
|
if (u.unitType === 'player' && this.world.selfId !== 0 && u.id === this.world.selfId) {
|
||||||
continue
|
continue
|
||||||
|
|
@ -1239,25 +1322,164 @@ export class OnlineSession {
|
||||||
u.id,
|
u.id,
|
||||||
)
|
)
|
||||||
const groundItem = u.unitType === 'item' ? this.world.containers.ground.get(u.id) : undefined
|
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: 'warp',
|
||||||
|
classId: u.classId,
|
||||||
|
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: 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({
|
out.push({
|
||||||
id: u.id,
|
id: u.id,
|
||||||
kind: u.unitType === 'tile' ? 'object' : u.unitType,
|
kind: u.unitType,
|
||||||
classId: u.classId,
|
classId: u.classId,
|
||||||
name: u.name,
|
name: u.name,
|
||||||
code: itemCode,
|
x: ux,
|
||||||
itemQuality: u.itemQuality ?? groundItem?.quality,
|
y: uy,
|
||||||
goldAmount: u.goldAmount ?? groundItem?.goldAmount,
|
...(targetX !== undefined ? { targetX } : {}),
|
||||||
x: interp ? interp.x : u.x,
|
...(targetY !== undefined ? { targetY } : {}),
|
||||||
y: interp ? interp.y : u.y,
|
mode,
|
||||||
mode: u.mode,
|
|
||||||
direction: interp ? interp.dir16 : u.direction,
|
direction: interp ? interp.dir16 : u.direction,
|
||||||
hpRatio: Math.max(0, Math.min(1, u.lifePct / 128)),
|
hpRatio: Math.max(0, Math.min(1, u.lifePct / 128)),
|
||||||
isCorpse: u.unitType === 'monster' && (u.mode === 0 || u.mode === 12 || u.lifePct <= 0),
|
isCorpse,
|
||||||
isChampion: u.isChampion,
|
isChampion: u.isChampion,
|
||||||
isUnique: u.isUnique,
|
isUnique: u.isUnique,
|
||||||
isSuperUnique: u.isSuperUnique,
|
isSuperUnique: u.isSuperUnique,
|
||||||
states: Array.from(u.states.list()),
|
states: Array.from(u.states.list()),
|
||||||
|
...(castState !== undefined ? { castState } : {}),
|
||||||
|
...(u.unitType === 'player' ? { isPartyMember } : {}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -938,11 +938,15 @@ export function canSocketUiItem(
|
||||||
if (isUiItemUnidentified(socketable) || isUiItemUnidentified(target)) return false
|
if (isUiItemUnidentified(socketable) || isUiItemUnidentified(target)) return false
|
||||||
const totalSockets =
|
const totalSockets =
|
||||||
target.sockets ??
|
target.sockets ??
|
||||||
|
(target as { totalSockets?: number }).totalSockets ??
|
||||||
target.rawItem?.totalSockets ??
|
target.rawItem?.totalSockets ??
|
||||||
(target.rawItem as { sockets?: number } | undefined)?.sockets ??
|
(target.rawItem as { sockets?: number } | undefined)?.sockets ??
|
||||||
0
|
0
|
||||||
if (totalSockets <= 0) return false
|
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
|
if (usedSockets >= totalSockets) return false
|
||||||
const category = resolveUiSocketTargetCategory(target)
|
const category = resolveUiSocketTargetCategory(target)
|
||||||
return category !== null
|
return category !== null
|
||||||
|
|
@ -1517,8 +1521,14 @@ export class InventoryPanel {
|
||||||
visible = false
|
visible = false
|
||||||
/** Active weapon swap set: 0 = Weapon Set I, 1 = Weapon Set II. */
|
/** Active weapon swap set: 0 = Weapon Set I, 1 = Weapon Set II. */
|
||||||
weaponSwapSet: 0 | 1 = 0
|
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). */
|
/** 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
|
get: (slot: string) => UiInventoryItem | undefined
|
||||||
set: (slot: string, item: UiInventoryItem | null | undefined) => unknown
|
set: (slot: string, item: UiInventoryItem | null | undefined) => unknown
|
||||||
} = {} as any
|
} = {} as any
|
||||||
|
|
@ -1529,6 +1539,12 @@ export class InventoryPanel {
|
||||||
}
|
}
|
||||||
/** 10x4 grid items (empty until the server sends inventory item events). */
|
/** 10x4 grid items (empty until the server sends inventory item events). */
|
||||||
gridItems: GridPlacement[] = []
|
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 _starterCleared = false
|
||||||
private _cursorItem: UiInventoryItem | null = null
|
private _cursorItem: UiInventoryItem | null = null
|
||||||
/** Item currently held on the mouse cursor (`cursorItem`). */
|
/** Item currently held on the mouse cursor (`cursorItem`). */
|
||||||
|
|
@ -1581,6 +1597,24 @@ export class InventoryPanel {
|
||||||
enumerable: false,
|
enumerable: false,
|
||||||
configurable: true,
|
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 {
|
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 RenderableUnitInput,
|
||||||
type SceneUnitDrawable,
|
type SceneUnitDrawable,
|
||||||
} from './unit-drawables.ts'
|
} from './unit-drawables.ts'
|
||||||
|
|
||||||
|
export type UnitState = RenderableUnitInput
|
||||||
import {
|
import {
|
||||||
getViewportProfile,
|
getViewportProfile,
|
||||||
screenToWorld,
|
screenToWorld,
|
||||||
|
|
@ -449,14 +451,29 @@ export class ClientWorldSceneSource implements SceneSource {
|
||||||
|
|
||||||
let best: SceneUnitDrawable | null = null
|
let best: SceneUnitDrawable | null = null
|
||||||
let bestDistSq = hitRadiusPx * hitRadiusPx
|
let bestDistSq = hitRadiusPx * hitRadiusPx
|
||||||
|
const localPlayerX = st.playerSubX - shiftX
|
||||||
|
const localPlayerY = st.playerSubY - shiftY
|
||||||
|
|
||||||
for (const u of entities.unitDrawables) {
|
for (const u of entities.unitDrawables) {
|
||||||
if (u.isCorpse && u.unitKind === 'monster') continue
|
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 dx = clickWorld.x - u.worldX
|
||||||
const dy = clickWorld.y - (u.worldY - 24)
|
const dyBody = clickWorld.y - (u.worldY - 24)
|
||||||
const distSq = dx * dx + dy * dy
|
const distSqBody = dx * dx + dyBody * dyBody
|
||||||
if (distSq <= bestDistSq) {
|
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
|
bestDistSq = distSq
|
||||||
best = u
|
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 { BAKED_UI_MANIFEST } from '../../common/items/baked-ui-meta.ts'
|
||||||
import type { UnitKind, UnitSnapshot } from '../../netproto/index.ts'
|
import type { UnitKind, UnitSnapshot } from '../../netproto/index.ts'
|
||||||
import type { PointLightInput } from '../scene/lighting-presets.ts'
|
import type { PointLightInput } from '../scene/lighting-presets.ts'
|
||||||
|
import type { UnitCastState } from '../world/client-unit.ts'
|
||||||
import { subTileToWorldOrtho } from './viewport-profile.ts'
|
import { subTileToWorldOrtho } from './viewport-profile.ts'
|
||||||
|
|
||||||
type MissileTxtEntry = NonNullable<ReturnType<typeof getMissileTxtData>>
|
type MissileTxtEntry = NonNullable<ReturnType<typeof getMissileTxtData>>
|
||||||
|
|
||||||
export type DrawableBlendMode = 'normal' | 'additive'
|
export type DrawableBlendMode = 'normal' | 'additive'
|
||||||
|
export type SceneUnitKind = UnitKind | 'warp'
|
||||||
|
|
||||||
export interface UnitOverlayState {
|
export interface UnitOverlayState {
|
||||||
readonly overlayId: number
|
readonly overlayId: number
|
||||||
|
|
@ -22,7 +24,7 @@ export interface UnitOverlayState {
|
||||||
* and `ClientUnit` (from `src/client/world/unit-table.ts`).
|
* and `ClientUnit` (from `src/client/world/unit-table.ts`).
|
||||||
*/
|
*/
|
||||||
export interface RenderableUnitInput {
|
export interface RenderableUnitInput {
|
||||||
readonly kind: UnitKind
|
readonly kind: SceneUnitKind
|
||||||
readonly id: number
|
readonly id: number
|
||||||
readonly classId: number
|
readonly classId: number
|
||||||
readonly areaId?: number | undefined
|
readonly areaId?: number | undefined
|
||||||
|
|
@ -52,11 +54,24 @@ export interface RenderableUnitInput {
|
||||||
readonly isChampion?: boolean | undefined
|
readonly isChampion?: boolean | undefined
|
||||||
readonly isUnique?: boolean | undefined
|
readonly isUnique?: boolean | undefined
|
||||||
readonly isSuperUnique?: 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 {
|
export interface SceneUnitDrawable {
|
||||||
readonly drawableKind: 'unit'
|
readonly drawableKind: 'unit'
|
||||||
readonly unitKind: UnitKind
|
readonly unitKind: SceneUnitKind
|
||||||
|
readonly id: number
|
||||||
readonly unitId: number
|
readonly unitId: number
|
||||||
readonly classId: number
|
readonly classId: number
|
||||||
readonly token: string
|
readonly token: string
|
||||||
|
|
@ -79,6 +94,16 @@ export interface SceneUnitDrawable {
|
||||||
readonly tintColor: readonly [number, number, number] | null
|
readonly tintColor: readonly [number, number, number] | null
|
||||||
readonly compositeArmor: Readonly<Record<string, string>> | undefined
|
readonly compositeArmor: Readonly<Record<string, string>> | undefined
|
||||||
readonly overlays: readonly SceneOverlayDrawable[]
|
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 {
|
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.
|
// Unknown missile ids keep an explicit unresolved token (like OBJ_/MON_) instead of borrowing another missile.
|
||||||
return key ? getMissileTxtData(key).celFile : `MIS_${unit.classId}`
|
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') {
|
if (unit.kind === 'object') {
|
||||||
return `OBJ_${unit.classId}`
|
return `OBJ_${unit.classId}`
|
||||||
}
|
}
|
||||||
if (unit.kind === 'item') {
|
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())) {
|
if (code && /^[a-z0-9]{3,4}$/i.test(code.trim())) {
|
||||||
return code.trim().toLowerCase()
|
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]
|
const mapped = (BAKED_UI_MANIFEST.itemClassIdToCode as Record<number, string | undefined> | undefined)?.[unit.classId]
|
||||||
if (mapped) return mapped
|
if (mapped) return mapped
|
||||||
return `ITM_${unit.classId}`
|
return `ITM_${unit.classId}`
|
||||||
|
|
@ -246,12 +277,16 @@ export interface BuildSceneDrawablesResult {
|
||||||
*/
|
*/
|
||||||
export function buildSceneDrawables(
|
export function buildSceneDrawables(
|
||||||
units: Iterable<RenderableUnitInput | UnitSnapshot>,
|
units: Iterable<RenderableUnitInput | UnitSnapshot>,
|
||||||
options?: {
|
optionsOrWidth?:
|
||||||
readonly originX?: number | undefined
|
| {
|
||||||
readonly originY?: number | undefined
|
readonly originX?: number | undefined
|
||||||
readonly tick?: number | undefined
|
readonly originY?: number | undefined
|
||||||
},
|
readonly tick?: number | undefined
|
||||||
|
}
|
||||||
|
| number,
|
||||||
|
_height?: number,
|
||||||
): BuildSceneDrawablesResult {
|
): BuildSceneDrawablesResult {
|
||||||
|
const options = typeof optionsOrWidth === 'object' ? optionsOrWidth : undefined
|
||||||
const originX = options?.originX ?? 0
|
const originX = options?.originX ?? 0
|
||||||
const originY = options?.originY ?? 0
|
const originY = options?.originY ?? 0
|
||||||
const tick = options?.tick ?? 0
|
const tick = options?.tick ?? 0
|
||||||
|
|
@ -262,7 +297,27 @@ export function buildSceneDrawables(
|
||||||
const pointLights: PointLightInput[] = []
|
const pointLights: PointLightInput[] = []
|
||||||
|
|
||||||
for (const rawUnit of units) {
|
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)
|
const { x: worldX, y: worldY } = subTileToWorldOrtho(unit.x, unit.y, originX, originY)
|
||||||
|
|
||||||
if (unit.kind === 'missile') {
|
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(
|
const dir = computeDirection8Or16(
|
||||||
unit.x,
|
unit.x,
|
||||||
unit.y,
|
unit.y,
|
||||||
unit.targetX,
|
effectiveTargetX,
|
||||||
unit.targetY,
|
effectiveTargetY,
|
||||||
unit.direction ?? 0,
|
unit.direction ?? 0,
|
||||||
8,
|
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 = {
|
const unitDrawable: SceneUnitDrawable = {
|
||||||
drawableKind: 'unit',
|
drawableKind: 'unit',
|
||||||
unitKind: unit.kind,
|
unitKind: unit.kind,
|
||||||
|
id: unit.id,
|
||||||
unitId: unit.id,
|
unitId: unit.id,
|
||||||
classId: unit.classId,
|
classId: unit.classId,
|
||||||
token: resolveUnitToken(unit),
|
token: resolveUnitToken(unit),
|
||||||
code: unit.code,
|
code: unit.code ?? unit.itemCode,
|
||||||
itemQuality: unit.itemQuality ?? unit.quality,
|
itemQuality: unit.itemQuality ?? unit.quality,
|
||||||
goldAmount: unit.goldAmount,
|
goldAmount: unit.goldAmount,
|
||||||
mode: unit.mode ?? 0,
|
mode: effectiveMode,
|
||||||
direction: dir,
|
direction: dir,
|
||||||
frame: unit.frame ?? (unit.kind === 'item' ? 0 : (tick % 8)),
|
frame: unit.frame ?? (unit.kind === 'item' ? 0 : (tick % 8)),
|
||||||
subX: unit.x,
|
subX: unit.x,
|
||||||
|
|
@ -429,12 +523,28 @@ export function buildSceneDrawables(
|
||||||
sortY: isCorpse ? worldY - 16 : worldY,
|
sortY: isCorpse ? worldY - 16 : worldY,
|
||||||
blendMode: 'normal',
|
blendMode: 'normal',
|
||||||
lifePct: unit.lifePct ?? 100,
|
lifePct: unit.lifePct ?? 100,
|
||||||
name: unit.name,
|
name: resolvedName,
|
||||||
isHovered: Boolean(unit.isHovered),
|
isHovered: Boolean(unit.isHovered),
|
||||||
isCorpse,
|
isCorpse,
|
||||||
tintColor,
|
tintColor,
|
||||||
compositeArmor: unit.compositeArmor,
|
compositeArmor: unit.compositeArmor,
|
||||||
overlays: unitOverlays,
|
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)
|
unitDrawables.push(unitDrawable)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
|
|
||||||
import {
|
import {
|
||||||
unitTypeFromId,
|
unitTypeFromId,
|
||||||
|
type ItemData,
|
||||||
type Pt,
|
type Pt,
|
||||||
type UnitRef,
|
type UnitRef,
|
||||||
type UnitSnapshot,
|
type UnitSnapshot,
|
||||||
|
|
@ -16,6 +17,15 @@ import {
|
||||||
import { StatList } from './stat-list.ts'
|
import { StatList } from './stat-list.ts'
|
||||||
import { UnitStateSet } from './states.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 {
|
export function makeUnitKey(unitType: UnitType, id: number): string {
|
||||||
return `${unitTypeFromId(unitType)}:${id}`
|
return `${unitTypeFromId(unitType)}:${id}`
|
||||||
}
|
}
|
||||||
|
|
@ -50,6 +60,8 @@ export class ClientUnit {
|
||||||
ownerType?: UnitTypeName | undefined
|
ownerType?: UnitTypeName | undefined
|
||||||
ownerId?: number | undefined
|
ownerId?: number | undefined
|
||||||
overheadText?: string | undefined
|
overheadText?: string | undefined
|
||||||
|
itemData?: ItemData | undefined
|
||||||
|
castState?: UnitCastState | undefined
|
||||||
readonly stats = new StatList()
|
readonly stats = new StatList()
|
||||||
readonly states = new UnitStateSet()
|
readonly states = new UnitStateSet()
|
||||||
|
|
||||||
|
|
@ -65,7 +77,7 @@ export class ClientUnit {
|
||||||
this.direction = snapshot.direction ?? 0
|
this.direction = snapshot.direction ?? 0
|
||||||
this.lifePct = snapshot.lifePct ?? 128
|
this.lifePct = snapshot.lifePct ?? 128
|
||||||
this.flags = snapshot.flags ?? 0
|
this.flags = snapshot.flags ?? 0
|
||||||
this.warpId = snapshot.warpId
|
this.warpId = snapshot.warpId ?? (this.unitType === 'tile' ? this.classId : undefined)
|
||||||
this.objectState = snapshot.objectState
|
this.objectState = snapshot.objectState
|
||||||
this.interactType = snapshot.interactType
|
this.interactType = snapshot.interactType
|
||||||
this.isChampion = snapshot.isChampion
|
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 {
|
updateFromSnapshot(snapshot: UnitSnapshot): void {
|
||||||
if (snapshot.classId >= 0) {
|
if (snapshot.classId >= 0) {
|
||||||
this.classId = snapshot.classId
|
this.classId = snapshot.classId
|
||||||
|
|
@ -105,6 +137,8 @@ export class ClientUnit {
|
||||||
}
|
}
|
||||||
if (snapshot.warpId !== undefined) {
|
if (snapshot.warpId !== undefined) {
|
||||||
this.warpId = snapshot.warpId
|
this.warpId = snapshot.warpId
|
||||||
|
} else if (this.unitType === 'tile' && this.warpId === undefined && this.classId >= 0) {
|
||||||
|
this.warpId = this.classId
|
||||||
}
|
}
|
||||||
if (snapshot.objectState !== undefined) {
|
if (snapshot.objectState !== undefined) {
|
||||||
this.objectState = snapshot.objectState
|
this.objectState = snapshot.objectState
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,8 @@ import {
|
||||||
type UnitRef,
|
type UnitRef,
|
||||||
type UnitType,
|
type UnitType,
|
||||||
} from '../../netproto/index.ts'
|
} 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 { ClientInventoryManager, type ClientItemContainers } from './inventory.ts'
|
||||||
import { MutableSelfPlayerState, type SelfPlayerState } from './self.ts'
|
import { MutableSelfPlayerState, type SelfPlayerState } from './self.ts'
|
||||||
|
|
||||||
|
|
@ -30,23 +31,46 @@ export interface PartyMemberState {
|
||||||
relation?: number | undefined
|
relation?: number | undefined
|
||||||
lifePct?: number | undefined
|
lifePct?: number | undefined
|
||||||
areaId?: number | undefined
|
areaId?: number | undefined
|
||||||
|
levelId?: number | undefined
|
||||||
at?: Pt | 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 {
|
export interface TradeSessionState {
|
||||||
active: boolean
|
active: boolean
|
||||||
lastAction: number
|
lastAction: number
|
||||||
partnerId?: number | undefined
|
partnerId?: number | undefined
|
||||||
|
partnerName?: string | undefined
|
||||||
partnerGold: number
|
partnerGold: number
|
||||||
selfGold: number
|
selfGold: number
|
||||||
|
selfAccepted: boolean
|
||||||
|
partnerAccepted: boolean
|
||||||
|
acceptDisabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MercHireEntry {
|
||||||
|
readonly nameId: number
|
||||||
|
readonly seed: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MercenaryState {
|
export interface MercenaryState {
|
||||||
readonly mercId: number
|
mercId: number
|
||||||
readonly ownerId: number
|
ownerId: number
|
||||||
readonly classId: number
|
classId: number
|
||||||
readonly nameId?: number | undefined
|
nameId?: number | undefined
|
||||||
exp?: 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 {
|
export interface ChatLogEntry {
|
||||||
|
|
@ -72,6 +96,15 @@ export interface PortalState {
|
||||||
readonly flags?: number | undefined
|
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
|
* Local NPC/stash interaction state. D2Client tracks this itself: the server never
|
||||||
* reports "window closed" (no RemoveFromShop after 0x30, no reply to 0x4F 0x12;
|
* reports "window closed" (no RemoveFromShop after 0x30, no reply to 0x4F 0x12;
|
||||||
|
|
@ -80,10 +113,16 @@ export interface PortalState {
|
||||||
export interface NpcInteractionState {
|
export interface NpcInteractionState {
|
||||||
/** NPC unit id from the last `NpcInit` (0x2F); `null` after `NpcCancel` (0x30). */
|
/** NPC unit id from the last `NpcInit` (0x2F); `null` after `NpcCancel` (0x30). */
|
||||||
readonly npcId: number | null
|
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'
|
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. */
|
/** Set by S2C 0x77 action 0x10, cleared when the client sends 0x4F button 0x12. */
|
||||||
readonly stashOpen: boolean
|
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. */
|
/** 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). */
|
/** Largest 1.13c stat id: stat ids are 9 bits wide in the stat bitstream (ItemStatCost.txt). */
|
||||||
const MAX_D2_STAT_ID = 0x1ff
|
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 {
|
export class ClientWorld {
|
||||||
act = 0
|
act = 0
|
||||||
mapSeed = 0
|
mapSeed = 0
|
||||||
|
|
@ -116,6 +160,7 @@ export class ClientWorld {
|
||||||
lastRttMs: number | null = null
|
lastRttMs: number | null = null
|
||||||
|
|
||||||
private readonly _self = new MutableSelfPlayerState()
|
private readonly _self = new MutableSelfPlayerState()
|
||||||
|
private _isSelfDead = false
|
||||||
private readonly _units = new Map<string, ClientUnit>()
|
private readonly _units = new Map<string, ClientUnit>()
|
||||||
private readonly _monsters = new Map<number, ClientUnit>()
|
private readonly _monsters = new Map<number, ClientUnit>()
|
||||||
private readonly _containers = new ClientInventoryManager()
|
private readonly _containers = new ClientInventoryManager()
|
||||||
|
|
@ -130,12 +175,21 @@ export class ClientWorld {
|
||||||
lastAction: 0,
|
lastAction: 0,
|
||||||
partnerGold: 0,
|
partnerGold: 0,
|
||||||
selfGold: 0,
|
selfGold: 0,
|
||||||
|
selfAccepted: false,
|
||||||
|
partnerAccepted: false,
|
||||||
|
acceptDisabled: false,
|
||||||
}
|
}
|
||||||
private _merc: MercenaryState | null = null
|
private _merc: MercenaryState | null = null
|
||||||
|
private readonly _mercHireList: MercHireEntry[] = []
|
||||||
private readonly _chatLog: ChatLogEntry[] = []
|
private readonly _chatLog: ChatLogEntry[] = []
|
||||||
private readonly _soundQueue: SoundQueueEntry[] = []
|
private readonly _soundQueue: SoundQueueEntry[] = []
|
||||||
private readonly _portals = new Map<number, PortalState>()
|
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 _lastNpcTransaction: NpcTransactionRecord | null = null
|
||||||
private _npcTransactionSeq = 0
|
private _npcTransactionSeq = 0
|
||||||
private readonly _changeListeners = new Set<(ev: ServerEvent) => void>()
|
private readonly _changeListeners = new Set<(ev: ServerEvent) => void>()
|
||||||
|
|
@ -149,9 +203,49 @@ export class ClientWorld {
|
||||||
return this._lastNpcTransaction
|
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
|
* 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
|
* refilled by 0x9C AddToShop; 0x30 frees the store units (no RemoveFromShop is
|
||||||
* sent, capture 05); 0x4F 0x12 closes the stash (no reply, capture 08).
|
* sent, capture 05); 0x4F 0x12 closes the stash (no reply, capture 08).
|
||||||
*/
|
*/
|
||||||
|
|
@ -159,26 +253,96 @@ export class ClientWorld {
|
||||||
const cur = this._npcInteraction
|
const cur = this._npcInteraction
|
||||||
switch (cmd.type) {
|
switch (cmd.type) {
|
||||||
case 'NpcInit':
|
case 'NpcInit':
|
||||||
this._npcInteraction = { ...cur, npcId: cmd.target.id, window: 'none' }
|
this._npcInteraction = { ...cur, npcId: cmd.target.id, window: 'none', activeWindow: 'none' }
|
||||||
break
|
break
|
||||||
case 'NpcOpenTrade':
|
case 'NpcOpenTrade':
|
||||||
case 'NpcOpenGamble':
|
case 'NpcOpenGamble': {
|
||||||
|
const win = cmd.type === 'NpcOpenTrade' ? 'trade' : 'gamble'
|
||||||
this._containers.clearShop()
|
this._containers.clearShop()
|
||||||
this._npcInteraction = {
|
this._npcInteraction = {
|
||||||
...cur,
|
...cur,
|
||||||
npcId: cmd.npcId,
|
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
|
break
|
||||||
case 'NpcCancel':
|
case 'NpcCancel':
|
||||||
this._containers.clearShop()
|
this._containers.clearShop()
|
||||||
this._npcInteraction = { ...cur, npcId: null, window: 'none' }
|
this._npcInteraction = {
|
||||||
break
|
...cur,
|
||||||
case 'ClickButton':
|
npcId: null,
|
||||||
if (cmd.buttonId === ClickButtonId.CloseStash) {
|
window: 'none',
|
||||||
this._npcInteraction = { ...cur, stashOpen: false }
|
activeWindow: 'none',
|
||||||
|
npcInfoRaw: undefined,
|
||||||
|
npcTalkRaw: undefined,
|
||||||
}
|
}
|
||||||
break
|
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:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
@ -382,13 +546,36 @@ export class ClientWorld {
|
||||||
from: ev.speaker ?? ev.from ?? 'Server',
|
from: ev.speaker ?? ev.from ?? 'Server',
|
||||||
text: String(ev.text ?? ''),
|
text: String(ev.text ?? ''),
|
||||||
}
|
}
|
||||||
} else if (ev.type === 'LifeManaPotUpdate') {
|
} else if (ev.type === 'LifeManaPotUpdate' || ev.type === 'PlayerLifeManaChange') {
|
||||||
ev = {
|
ev = {
|
||||||
type: 'LifeMana',
|
type: 'LifeMana',
|
||||||
life: Number(ev.life ?? 0),
|
life: Number(ev.life ?? 0),
|
||||||
mana: Number(ev.mana ?? 0),
|
mana: Number(ev.mana ?? 0),
|
||||||
stamina: Number(ev.stamina ?? 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') {
|
if (unit.unitType === 'monster') {
|
||||||
this._monsters.set(unit.id, unit)
|
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 =
|
const isSelfPlayer =
|
||||||
|
|
@ -475,16 +668,17 @@ export class ClientWorld {
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'UnitRemove': {
|
case 'UnitRemove': {
|
||||||
|
const uId = Number(ev.id ?? ev.unitId ?? 0)
|
||||||
const typeName = unitTypeFromId(ev.unitType)
|
const typeName = unitTypeFromId(ev.unitType)
|
||||||
if (typeName === 'item') {
|
if (typeName === 'item') {
|
||||||
this._containers.removeItemById(ev.id)
|
this._containers.removeItemById(uId)
|
||||||
}
|
}
|
||||||
if (typeName === 'monster') {
|
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._units.delete(key)
|
||||||
this._portals.delete(ev.id)
|
this._portals.delete(uId)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -534,12 +728,15 @@ export class ClientWorld {
|
||||||
this._self.y = ev.at.y
|
this._self.y = ev.at.y
|
||||||
this._self.targetX = ev.at.x
|
this._self.targetX = ev.at.x
|
||||||
this._self.targetY = ev.at.y
|
this._self.targetY = ev.at.y
|
||||||
|
this._isSelfDead = false
|
||||||
|
this._self.isDead = false
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'UnitMode': {
|
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
|
unit.mode = ev.mode
|
||||||
if (ev.at && (ev.at.x !== 0 || ev.at.y !== 0)) {
|
if (ev.at && (ev.at.x !== 0 || ev.at.y !== 0)) {
|
||||||
unit.x = ev.at.x
|
unit.x = ev.at.x
|
||||||
|
|
@ -548,11 +745,65 @@ export class ClientWorld {
|
||||||
if (ev.lifePct !== undefined) {
|
if (ev.lifePct !== undefined) {
|
||||||
unit.lifePct = ev.lifePct
|
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
|
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
|
break
|
||||||
|
}
|
||||||
|
|
||||||
case 'StatSet': {
|
case 'StatSet': {
|
||||||
// 1.13c stat ids are 9-bit (ItemStatCost.txt). netproto still wraps some non-stat packets
|
// 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.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))) {
|
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._self.applyStat(ev.stat, ev.value, ev.param ?? 0)
|
||||||
|
this._isSelfDead = this._self.isDead
|
||||||
if (this.selfId !== 0) {
|
if (this.selfId !== 0) {
|
||||||
const selfUnit = this._units.get(makeUnitKey('player', this.selfId))
|
const selfUnit = this._units.get(makeUnitKey('player', this.selfId))
|
||||||
selfUnit?.stats.set(ev.stat, this._self.stats.get(ev.stat), ev.param ?? 0)
|
selfUnit?.stats.set(ev.stat, this._self.stats.get(ev.stat), ev.param ?? 0)
|
||||||
|
|
@ -568,6 +820,9 @@ export class ClientWorld {
|
||||||
} else {
|
} else {
|
||||||
const targetUnit = this.ensureUnit(ev.unit.unitType, ev.unit.id)
|
const targetUnit = this.ensureUnit(ev.unit.unitType, ev.unit.id)
|
||||||
targetUnit.stats.set(ev.stat, ev.value, ev.param ?? 0)
|
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
|
break
|
||||||
}
|
}
|
||||||
|
|
@ -576,6 +831,13 @@ export class ClientWorld {
|
||||||
const atX = ev.at?.x ?? 0
|
const atX = ev.at?.x ?? 0
|
||||||
const atY = ev.at?.y ?? 0
|
const atY = ev.at?.y ?? 0
|
||||||
this._self.applyLifeMana(ev.life, ev.mana, ev.stamina, atX, atY)
|
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)) {
|
if (this.selfId !== 0 && (atX !== 0 || atY !== 0)) {
|
||||||
const selfUnit = this._units.get(makeUnitKey('player', this.selfId))
|
const selfUnit = this._units.get(makeUnitKey('player', this.selfId))
|
||||||
if (selfUnit) {
|
if (selfUnit) {
|
||||||
|
|
@ -616,22 +878,32 @@ export class ClientWorld {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'Item':
|
||||||
case 'ItemEvent': {
|
case 'ItemEvent': {
|
||||||
// 0x17 WeaponSwitch
|
// 0x17 WeaponSwitch
|
||||||
if (ev.action === 0x17) {
|
if (ev.action === 0x17) {
|
||||||
this._self.weaponSet = this._self.weaponSet === 0 ? 1 : 0
|
this._self.weaponSet = this._self.weaponSet === 0 ? 1 : 0
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
this._containers.applyItemEvent(ev.action, ev.item, ev.container, this.selfId)
|
const effectiveAction = ev.action ?? ev.item?.action ?? 0
|
||||||
if (ev.container === 'ground') {
|
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)
|
const itemUnit = this.ensureUnit('item', ev.item.id)
|
||||||
itemUnit.x = ev.item.worldX
|
itemUnit.x = ev.item.worldX
|
||||||
itemUnit.y = ev.item.worldY
|
itemUnit.y = ev.item.worldY
|
||||||
itemUnit.name = ev.item.code
|
const trimmedCode = ev.item.code.trim()
|
||||||
itemUnit.itemCode = ev.item.code
|
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.itemQuality = ev.item.quality
|
||||||
itemUnit.goldAmount = ev.item.goldAmount
|
itemUnit.itemData = ev.item
|
||||||
itemUnit.mode = (ev.action === 0x03 || ev.item.action === 0x03) ? 5 : 3
|
itemUnit.mode = (ev.action === 0x03 || ev.item.action === 0x03) ? 5 : 3
|
||||||
|
const uiItem = onlineItemDataToUiInventoryItem(ev.item)
|
||||||
|
itemUnit.name = uiItem.name || ev.item.code
|
||||||
} else {
|
} else {
|
||||||
this._units.delete(makeUnitKey('item', ev.item.id))
|
this._units.delete(makeUnitKey('item', ev.item.id))
|
||||||
}
|
}
|
||||||
|
|
@ -677,6 +949,33 @@ export class ClientWorld {
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'Npc': {
|
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
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -709,50 +1008,169 @@ export class ClientWorld {
|
||||||
break
|
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': {
|
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 (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)
|
this._party.delete(id)
|
||||||
break
|
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, {
|
this._party.set(id, {
|
||||||
...prev,
|
...prev,
|
||||||
...(ev.charName !== undefined ? { charName: ev.charName } : {}),
|
...(ev.charName !== undefined ? { charName: ev.charName } : {}),
|
||||||
...(ev.charClass !== undefined ? { charClass: ev.charClass } : {}),
|
...(ev.charClass !== undefined ? { charClass: ev.charClass } : {}),
|
||||||
...(ev.charLevel !== undefined ? { charLevel: ev.charLevel } : {}),
|
...(ev.charLevel !== undefined ? { charLevel: ev.charLevel } : {}),
|
||||||
...(ev.partyId !== undefined ? { partyId: ev.partyId } : {}),
|
...(ev.partyId !== undefined ? { partyId: ev.partyId } : {}),
|
||||||
...(ev.relation !== undefined ? { relation: ev.relation } : {}),
|
...(rawRel !== undefined ? { relation: rawRel } : {}),
|
||||||
...(ev.lifePct !== undefined ? { lifePct: ev.lifePct } : {}),
|
...(ev.lifePct !== undefined ? { lifePct: ev.lifePct } : {}),
|
||||||
...(ev.areaId !== undefined ? { areaId: ev.areaId } : {}),
|
...(nextAreaId !== undefined ? { areaId: nextAreaId, levelId: nextAreaId } : {}),
|
||||||
...(ev.at !== undefined ? { at: ev.at } : {}),
|
...(nextAt !== undefined ? { at: nextAt, x: nextAt.x, y: nextAt.y } : {}),
|
||||||
|
partyState,
|
||||||
|
hostile,
|
||||||
|
lootPermit,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'Trade': {
|
case 'Trade': {
|
||||||
this._trade.lastAction = ev.action
|
const action =
|
||||||
if (ev.action === BUTTON_ACTION_OPEN_STASH) {
|
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 }
|
this._npcInteraction = { ...this._npcInteraction, stashOpen: true }
|
||||||
|
break
|
||||||
}
|
}
|
||||||
if (ev.partnerId !== undefined) {
|
if (ev.partnerId !== undefined) {
|
||||||
this._trade.partnerId = ev.partnerId
|
this._trade.partnerId = ev.partnerId
|
||||||
}
|
}
|
||||||
if (ev.gold !== undefined && (ev.action & 0xff00) === 0x7900) {
|
if (ev.partnerName !== undefined) {
|
||||||
// 0x79 GoldInTrade: low byte is the decoder's `bySelf` flag (D2Client.dll 0x6fb5bfb0).
|
this._trade.partnerName = ev.partnerName
|
||||||
if ((ev.action & 0xff) !== 0) {
|
|
||||||
this._trade.selfGold = ev.gold
|
|
||||||
} else {
|
|
||||||
this._trade.partnerGold = ev.gold
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Trade cancel / close actions
|
if (gold !== undefined && (action & 0xff00) === 0x7900) {
|
||||||
if (ev.action === 0x02 || ev.action === 0x0c || ev.action === 0x0d) {
|
// 0x79 GoldInTrade: low byte is the decoder's `bySelf` flag (D2Client.dll 0x6fb5bfb0).
|
||||||
this._trade.active = false
|
if ((action & 0xff) !== 0) {
|
||||||
} else if (ev.action === 0x00 || ev.action === 0x01 || ev.action === 0x06) {
|
this._trade.selfGold = gold
|
||||||
|
} else {
|
||||||
|
this._trade.partnerGold = gold
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// 0x92 ClearDisplay
|
||||||
|
if (action === 0x92 || (action & 0xff00) === 0x9200) {
|
||||||
|
this._containers.clearTrade()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// 0x78 TradeAccepted
|
||||||
|
if (action === 0x78) {
|
||||||
this._trade.active = true
|
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
|
break
|
||||||
}
|
}
|
||||||
|
|
@ -793,21 +1211,80 @@ export class ClientWorld {
|
||||||
if (ev.kind === 'assign' && ev.mercId !== undefined) {
|
if (ev.kind === 'assign' && ev.mercId !== undefined) {
|
||||||
if (ev.ownerId === undefined || ev.ownerId === this.selfId || this.selfId === 0) {
|
if (ev.ownerId === undefined || ev.ownerId === this.selfId || this.selfId === 0) {
|
||||||
this._self.mercId = ev.mercId === 0 ? null : ev.mercId
|
this._self.mercId = ev.mercId === 0 ? null : ev.mercId
|
||||||
|
const mercUnit = ev.mercId !== 0 ? this.getUnit('monster', ev.mercId) : undefined
|
||||||
this._merc =
|
this._merc =
|
||||||
ev.mercId === 0
|
ev.mercId === 0
|
||||||
? null
|
? null
|
||||||
: {
|
: {
|
||||||
mercId: ev.mercId,
|
mercId: ev.mercId,
|
||||||
ownerId: ev.ownerId ?? this.selfId,
|
ownerId: ev.ownerId ?? this.selfId,
|
||||||
classId: ev.classId ?? 0,
|
classId: ev.classId ?? this._merc?.classId ?? 0,
|
||||||
...(ev.nameId !== undefined ? { nameId: ev.nameId } : {}),
|
...(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) {
|
} else if (ev.kind === 'exp' && ev.value !== undefined && this._merc) {
|
||||||
this._merc.exp = ev.value
|
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 mercUnit = this.ensureUnit('monster', ev.mercId)
|
const statId = ev.stat ?? ev.statId
|
||||||
mercUnit.stats.set(ev.stat, ev.value)
|
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(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
|
break
|
||||||
}
|
}
|
||||||
|
|
@ -852,8 +1329,10 @@ export class ClientWorld {
|
||||||
this.selfId = 0
|
this.selfId = 0
|
||||||
this.frameCount = 0
|
this.frameCount = 0
|
||||||
this.lastRttMs = null
|
this.lastRttMs = null
|
||||||
|
this._isSelfDead = false
|
||||||
this._self.reset()
|
this._self.reset()
|
||||||
this._merc = null
|
this._merc = null
|
||||||
|
this._mercHireList.length = 0
|
||||||
this._units.clear()
|
this._units.clear()
|
||||||
this._monsters.clear()
|
this._monsters.clear()
|
||||||
this._containers.clear()
|
this._containers.clear()
|
||||||
|
|
@ -865,8 +1344,14 @@ export class ClientWorld {
|
||||||
this._trade.active = false
|
this._trade.active = false
|
||||||
this._trade.lastAction = 0
|
this._trade.lastAction = 0
|
||||||
this._trade.partnerId = undefined
|
this._trade.partnerId = undefined
|
||||||
|
this._trade.partnerName = undefined
|
||||||
this._trade.partnerGold = 0
|
this._trade.partnerGold = 0
|
||||||
this._trade.selfGold = 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._chatLog.length = 0
|
||||||
this._soundQueue.length = 0
|
this._soundQueue.length = 0
|
||||||
this._portals.clear()
|
this._portals.clear()
|
||||||
|
|
|
||||||
|
|
@ -19,13 +19,33 @@ export interface ClientItemContainers {
|
||||||
readonly cursor: ItemData | null
|
readonly cursor: ItemData | null
|
||||||
readonly tradeSelf: ReadonlyMap<number, ItemData>
|
readonly tradeSelf: ReadonlyMap<number, ItemData>
|
||||||
readonly tradePartner: ReadonlyMap<number, ItemData>
|
readonly tradePartner: ReadonlyMap<number, ItemData>
|
||||||
|
readonly tradeGrid: readonly ItemData[]
|
||||||
|
readonly partnerTradeGrid: readonly ItemData[]
|
||||||
readonly merc: ReadonlyMap<number, ItemData>
|
readonly merc: ReadonlyMap<number, ItemData>
|
||||||
|
readonly mercBySlot: ReadonlyMap<number, ItemData>
|
||||||
readonly shop: ReadonlyMap<number, ItemData>
|
readonly shop: ReadonlyMap<number, ItemData>
|
||||||
readonly ground: ReadonlyMap<number, ItemData>
|
readonly ground: ReadonlyMap<number, ItemData>
|
||||||
readonly sockets: ReadonlyMap<number, readonly ItemData[]>
|
readonly sockets: ReadonlyMap<number, readonly ItemData[]>
|
||||||
readonly allById: ReadonlyMap<number, 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 {
|
export class ClientInventoryManager implements ClientItemContainers {
|
||||||
readonly inventory = new Map<number, ItemData>()
|
readonly inventory = new Map<number, ItemData>()
|
||||||
readonly equipment = new Map<number, ItemData>()
|
readonly equipment = new Map<number, ItemData>()
|
||||||
|
|
@ -37,12 +57,21 @@ export class ClientInventoryManager implements ClientItemContainers {
|
||||||
cursor: ItemData | null = null
|
cursor: ItemData | null = null
|
||||||
readonly tradeSelf = new Map<number, ItemData>()
|
readonly tradeSelf = new Map<number, ItemData>()
|
||||||
readonly tradePartner = 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 shop = new Map<number, ItemData>()
|
||||||
readonly ground = new Map<number, ItemData>()
|
readonly ground = new Map<number, ItemData>()
|
||||||
readonly sockets = new Map<number, ItemData[]>()
|
readonly sockets = new Map<number, ItemData[]>()
|
||||||
readonly allById = 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.
|
* Removes an item from whichever container currently holds it.
|
||||||
*/
|
*/
|
||||||
|
|
@ -61,8 +90,14 @@ export class ClientInventoryManager implements ClientItemContainers {
|
||||||
if (this.belt.has(itemId)) {
|
if (this.belt.has(itemId)) {
|
||||||
const b = this.belt.get(itemId)!
|
const b = this.belt.get(itemId)!
|
||||||
this.belt.delete(itemId)
|
this.belt.delete(itemId)
|
||||||
if (this.beltBySlot.get(b.gridX)?.id === itemId) {
|
const bSlot = b.gridX ?? b.x ?? 0
|
||||||
this.beltBySlot.delete(b.gridX)
|
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)
|
this.stash.delete(itemId)
|
||||||
|
|
@ -72,7 +107,21 @@ export class ClientInventoryManager implements ClientItemContainers {
|
||||||
}
|
}
|
||||||
this.tradeSelf.delete(itemId)
|
this.tradeSelf.delete(itemId)
|
||||||
this.tradePartner.delete(itemId)
|
this.tradePartner.delete(itemId)
|
||||||
this.merc.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.shop.delete(itemId)
|
||||||
this.ground.delete(itemId)
|
this.ground.delete(itemId)
|
||||||
this.sockets.delete(itemId)
|
this.sockets.delete(itemId)
|
||||||
|
|
@ -101,8 +150,14 @@ export class ClientInventoryManager implements ClientItemContainers {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Removal-to-cursor actions:
|
// Removal-to-cursor actions:
|
||||||
// 0x05 RemoveFromContainer (to cursor), 0x08 Unequip (to cursor), 0x0F RemoveFromBelt (to cursor)
|
// 0x02 ToCursor (when container is cursor or location === 4), 0x05 RemoveFromContainer (to cursor), 0x08 Unequip (to cursor), 0x0F RemoveFromBelt (to cursor)
|
||||||
if (action === 0x05 || action === 0x08 || action === 0x0f) {
|
if (
|
||||||
|
(action === 0x02 && (container === 'cursor' || item.location === 4)) ||
|
||||||
|
action === 0x05 ||
|
||||||
|
action === 0x08 ||
|
||||||
|
action === 0x0f ||
|
||||||
|
(action === 0x04 && item.location === 4)
|
||||||
|
) {
|
||||||
this.removeItemById(item.id)
|
this.removeItemById(item.id)
|
||||||
const cursorItem: ItemData = { ...item, container: 'cursor' }
|
const cursorItem: ItemData = { ...item, container: 'cursor' }
|
||||||
this.cursor = cursorItem
|
this.cursor = cursorItem
|
||||||
|
|
@ -120,7 +175,9 @@ export class ClientInventoryManager implements ClientItemContainers {
|
||||||
this.removeItemById(item.id)
|
this.removeItemById(item.id)
|
||||||
|
|
||||||
let effectiveContainer = container
|
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) {
|
if (item.ownerId !== undefined && selfId !== undefined && item.ownerId !== selfId) {
|
||||||
effectiveContainer = 'tradePartner'
|
effectiveContainer = 'tradePartner'
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -155,13 +212,25 @@ export class ClientInventoryManager implements ClientItemContainers {
|
||||||
}
|
}
|
||||||
break
|
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.belt.set(stored.id, stored)
|
||||||
this.beltBySlot.set(stored.gridX, stored)
|
this.beltBySlot.set(bSlot, stored)
|
||||||
break
|
break
|
||||||
case 'merc':
|
}
|
||||||
|
case 'merc': {
|
||||||
this.merc.set(stored.id, stored)
|
this.merc.set(stored.id, stored)
|
||||||
|
const mSlot = stored.bodyLoc || stored.equippedSlot || 0
|
||||||
|
if (mSlot > 0) {
|
||||||
|
this.mercBySlot.set(mSlot, stored)
|
||||||
|
}
|
||||||
break
|
break
|
||||||
|
}
|
||||||
case 'shop':
|
case 'shop':
|
||||||
this.shop.set(stored.id, stored)
|
this.shop.set(stored.id, stored)
|
||||||
break
|
break
|
||||||
|
|
@ -196,6 +265,22 @@ export class ClientInventoryManager implements ClientItemContainers {
|
||||||
this.shop.clear()
|
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 {
|
clear(): void {
|
||||||
this.inventory.clear()
|
this.inventory.clear()
|
||||||
this.equipment.clear()
|
this.equipment.clear()
|
||||||
|
|
@ -208,6 +293,7 @@ export class ClientInventoryManager implements ClientItemContainers {
|
||||||
this.tradeSelf.clear()
|
this.tradeSelf.clear()
|
||||||
this.tradePartner.clear()
|
this.tradePartner.clear()
|
||||||
this.merc.clear()
|
this.merc.clear()
|
||||||
|
this.mercBySlot.clear()
|
||||||
this.shop.clear()
|
this.shop.clear()
|
||||||
this.ground.clear()
|
this.ground.clear()
|
||||||
this.sockets.clear()
|
this.sockets.clear()
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { CharClassId, SkillLevel } from '../../netproto/index.ts'
|
import type { CharClassId, SkillLevel } from '../../netproto/index.ts'
|
||||||
|
import type { UnitCastState } from './client-unit.ts'
|
||||||
import {
|
import {
|
||||||
STAT_EXPERIENCE,
|
STAT_EXPERIENCE,
|
||||||
STAT_GOLD,
|
STAT_GOLD,
|
||||||
|
|
@ -49,6 +50,8 @@ export interface SelfPlayerState {
|
||||||
runMode: boolean
|
runMode: boolean
|
||||||
weaponSet: 0 | 1
|
weaponSet: 0 | 1
|
||||||
mercId: number | null
|
mercId: number | null
|
||||||
|
isDead: boolean
|
||||||
|
castState?: UnitCastState | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MutableSelfPlayerState implements SelfPlayerState {
|
export class MutableSelfPlayerState implements SelfPlayerState {
|
||||||
|
|
@ -78,6 +81,8 @@ export class MutableSelfPlayerState implements SelfPlayerState {
|
||||||
runMode = true
|
runMode = true
|
||||||
weaponSet: 0 | 1 = 0
|
weaponSet: 0 | 1 = 0
|
||||||
mercId: number | null = null
|
mercId: number | null = null
|
||||||
|
isDead = false
|
||||||
|
castState?: UnitCastState | undefined
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply one server stat update (`0x1D`/`0x1E`/`0x1F` BaseAttribute byte/word/dword, `0x19` gold delta).
|
* 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) {
|
switch (stat) {
|
||||||
case STAT_HITPOINTS:
|
case STAT_HITPOINTS:
|
||||||
this.life = poolStatValue(value)
|
this.life = poolStatValue(value)
|
||||||
|
if (this.life > 0) {
|
||||||
|
this.isDead = false
|
||||||
|
} else if (this.maxLife > 0 && this.life <= 0) {
|
||||||
|
this.isDead = true
|
||||||
|
}
|
||||||
break
|
break
|
||||||
case STAT_MAXHP:
|
case STAT_MAXHP:
|
||||||
this.maxLife = poolStatValue(value)
|
this.maxLife = poolStatValue(value)
|
||||||
|
if (this.maxLife > 0 && this.life <= 0 && this.stats.has(STAT_HITPOINTS)) {
|
||||||
|
this.isDead = true
|
||||||
|
}
|
||||||
break
|
break
|
||||||
case STAT_MANA:
|
case STAT_MANA:
|
||||||
this.mana = poolStatValue(value)
|
this.mana = poolStatValue(value)
|
||||||
|
|
@ -136,6 +149,11 @@ export class MutableSelfPlayerState implements SelfPlayerState {
|
||||||
if (life >= 0) {
|
if (life >= 0) {
|
||||||
this.life = life
|
this.life = life
|
||||||
this.stats.set(STAT_HITPOINTS, 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) {
|
if (mana >= 0) {
|
||||||
this.mana = mana
|
this.mana = mana
|
||||||
|
|
@ -189,6 +207,8 @@ export class MutableSelfPlayerState implements SelfPlayerState {
|
||||||
this.runMode = true
|
this.runMode = true
|
||||||
this.weaponSet = 0
|
this.weaponSet = 0
|
||||||
this.mercId = null
|
this.mercId = null
|
||||||
|
this.isDead = false
|
||||||
|
this.castState = undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ export const D2StatId = {
|
||||||
LightResist: 41,
|
LightResist: 41,
|
||||||
ColdResist: 43,
|
ColdResist: 43,
|
||||||
PoisonResist: 45,
|
PoisonResist: 45,
|
||||||
|
SocketCount: 194,
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export class StatList {
|
export class StatList {
|
||||||
|
|
|
||||||
|
|
@ -177,6 +177,10 @@ export function formatBilingualGroundItemName(item: any, name: string, quality:
|
||||||
export interface GroundItemMetadata {
|
export interface GroundItemMetadata {
|
||||||
readonly name: string
|
readonly name: string
|
||||||
readonly nameZh: string
|
readonly nameZh: string
|
||||||
|
readonly labelText: string
|
||||||
|
readonly labelEn: string
|
||||||
|
readonly labelZh: string
|
||||||
|
readonly color: string
|
||||||
readonly quality: string
|
readonly quality: string
|
||||||
readonly isGold: boolean
|
readonly isGold: boolean
|
||||||
readonly amount: number
|
readonly amount: number
|
||||||
|
|
@ -188,6 +192,27 @@ export interface GroundItemMetadata {
|
||||||
readonly sockets?: number | undefined
|
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.
|
* Checks whether an item or GroundItemSource is currently unidentified.
|
||||||
*/
|
*/
|
||||||
|
|
@ -213,17 +238,27 @@ export function resolveGroundItemMetadata(item: any, specifiedAmount?: number):
|
||||||
|
|
||||||
if (isGold) {
|
if (isGold) {
|
||||||
const amount = specifiedAmount ?? (
|
const amount = specifiedAmount ?? (
|
||||||
typeof item?.amount === 'number'
|
typeof item?.goldAmount === 'number'
|
||||||
? item.amount
|
? item.goldAmount
|
||||||
: typeof item?.stack === 'number'
|
: typeof item?.quantity === 'number'
|
||||||
? item.stack
|
? item.quantity
|
||||||
: typeof item?.value === 'number'
|
: typeof item?.amount === 'number'
|
||||||
? item.value
|
? item.amount
|
||||||
: 1
|
: typeof item?.stack === 'number'
|
||||||
|
? item.stack
|
||||||
|
: typeof item?.value === 'number'
|
||||||
|
? item.value
|
||||||
|
: 1
|
||||||
)
|
)
|
||||||
|
const labelEn = `${amount} Gold`
|
||||||
|
const labelZh = `${amount} 金币`
|
||||||
return {
|
return {
|
||||||
name: `${amount} Gold`,
|
name: labelEn,
|
||||||
nameZh: `${amount} 金币`,
|
nameZh: labelZh,
|
||||||
|
labelText: labelEn,
|
||||||
|
labelEn,
|
||||||
|
labelZh,
|
||||||
|
color: '#d8b864',
|
||||||
quality: 'normal',
|
quality: 'normal',
|
||||||
isGold: true,
|
isGold: true,
|
||||||
amount,
|
amount,
|
||||||
|
|
@ -245,7 +280,7 @@ export function resolveGroundItemMetadata(item: any, specifiedAmount?: number):
|
||||||
: ''
|
: ''
|
||||||
|
|
||||||
let tableBase: any
|
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 {
|
try {
|
||||||
const tables = getEmbeddedDropTables() as any
|
const tables = getEmbeddedDropTables() as any
|
||||||
tableBase = typeof tables.getBase === 'function'
|
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 quality = resolveItemQualityString(item?.quality ?? item?.base?.quality, rawCode, item)
|
||||||
const unidentified = isGroundSourceUnidentified(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 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 rawSockets = item?.sockets ?? item?.totalSockets ?? item?.socketedCount ?? item?.item?.sockets ?? item?.item?.totalSockets ?? 0
|
||||||
const sockets = typeof rawSockets === 'number' && rawSockets > 0 ? rawSockets : undefined
|
const sockets = typeof rawSockets === 'number' && rawSockets > 0 ? rawSockets : undefined
|
||||||
|
const color = resolveGroundQualityColor(quality, ethereal, sockets)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
nameZh,
|
nameZh,
|
||||||
|
labelText: name,
|
||||||
|
labelEn: name,
|
||||||
|
labelZh: nameZh,
|
||||||
|
color,
|
||||||
quality,
|
quality,
|
||||||
isGold: false,
|
isGold: false,
|
||||||
amount,
|
amount,
|
||||||
|
|
@ -1521,3 +1566,70 @@ export class GroundItemManager {
|
||||||
return true
|
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 speedTextEn?: string | undefined
|
||||||
readonly runewordRunes?: string | undefined
|
readonly runewordRunes?: string | undefined
|
||||||
readonly sockets?: number | undefined
|
readonly sockets?: number | undefined
|
||||||
|
readonly usedSockets?: number | undefined
|
||||||
readonly socketedItems?: readonly UiInventoryItem[] | undefined
|
readonly socketedItems?: readonly UiInventoryItem[] | undefined
|
||||||
|
readonly socketedGems?: readonly string[] | undefined
|
||||||
|
readonly affixes?: readonly string[] | undefined
|
||||||
readonly ethereal?: boolean | undefined
|
readonly ethereal?: boolean | undefined
|
||||||
readonly setPieces?: readonly string[] | undefined
|
readonly setPieces?: readonly string[] | undefined
|
||||||
readonly setPiecesEn?: readonly string[] | undefined
|
readonly setPiecesEn?: readonly string[] | undefined
|
||||||
|
|
|
||||||
|
|
@ -10,16 +10,24 @@
|
||||||
* - Rich tooltip attributes & rolled property formatting with D2ColorCode lines
|
* - 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 { ItemQuality } from './items.ts'
|
||||||
import type { DropTables } from './drop-tables.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 {
|
import {
|
||||||
type UiInventoryItem,
|
type UiInventoryItem,
|
||||||
type EquipSlotId,
|
type EquipSlotId,
|
||||||
type UiItemQuality,
|
type UiItemQuality,
|
||||||
} from './inventory-model.ts'
|
} from './inventory-model.ts'
|
||||||
import { BAKED_UI_MANIFEST } from './baked-ui-meta.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 { translateItemName, translateRareName, translateMagicName } from '../i18n/tooltip-i18n.ts'
|
||||||
import { isRuneCode, getGemColorTransform, COLOR_CODES } from './gems-data.ts'
|
import { isRuneCode, getGemColorTransform, COLOR_CODES } from './gems-data.ts'
|
||||||
import { CANONICAL_AUTOMAGIC_TABLE } from './automagic.ts'
|
import { CANONICAL_AUTOMAGIC_TABLE } from './automagic.ts'
|
||||||
|
|
@ -28,6 +36,18 @@ import { isA } from './item-types.ts'
|
||||||
import { inferItemClassFromType } from './affixes.ts'
|
import { inferItemClassFromType } from './affixes.ts'
|
||||||
import { CHARACTER_CLASS_CODES, type CharacterClassCode } from '../units/classes.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 }
|
export { COLOR_CODES, getGemColorTransform }
|
||||||
|
|
||||||
const VALID_CLASS_CODES = new Set<string>(CHARACTER_CLASS_CODES)
|
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 } : {}),
|
...((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,
|
locale?: TooltipLocale,
|
||||||
): string {
|
): string {
|
||||||
if (locale && locale !== 'en') {
|
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 value: number
|
||||||
let min: number
|
let min: number
|
||||||
|
|
@ -712,8 +717,14 @@ export function formatPropertyCode(
|
||||||
return min > 0 && max > min ? `Adds ${min}-${max} Magic Damage` : `Adds ${effectiveVal} Magic Damage`
|
return min > 0 && max > min ? `Adds ${min}-${max} Magic Damage` : `Adds ${effectiveVal} Magic Damage`
|
||||||
case 'dmg-elem':
|
case 'dmg-elem':
|
||||||
return min > 0 && max > min ? `Adds ${min}-${max} Elemental Damage` : `Adds ${effectiveVal} Elemental Damage`
|
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})` : ''}`
|
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.
|
* to their canonical VendorNpcId, Act index (0..4), and available services.
|
||||||
*/
|
*/
|
||||||
export interface TownNpcServiceDescriptor {
|
export interface TownNpcServiceDescriptor {
|
||||||
|
readonly id: string;
|
||||||
readonly hcIdx: number;
|
readonly hcIdx: number;
|
||||||
readonly npcTxtKey: string;
|
readonly npcTxtKey: string;
|
||||||
readonly displayName: string;
|
readonly displayName: string;
|
||||||
|
|
@ -328,10 +329,21 @@ export interface TownNpcServiceDescriptor {
|
||||||
readonly canGamble: boolean;
|
readonly canGamble: boolean;
|
||||||
readonly canHeal: boolean;
|
readonly canHeal: boolean;
|
||||||
readonly canIdentify: 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 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
|
// Act 1
|
||||||
{
|
{
|
||||||
hcIdx: 148,
|
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.',
|
'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.',
|
'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,
|
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.',
|
'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!',
|
'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,
|
hcIdx: 154,
|
||||||
|
|
@ -376,10 +405,19 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
|
||||||
canGamble: false,
|
canGamble: false,
|
||||||
canHeal: false,
|
canHeal: false,
|
||||||
canIdentify: false,
|
canIdentify: false,
|
||||||
|
questRewardActions: ['imbue'],
|
||||||
greetingLines: [
|
greetingLines: [
|
||||||
'Hi there! I am Charsi, the blacksmith here in camp.',
|
'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?',
|
'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,
|
hcIdx: 146,
|
||||||
|
|
@ -396,6 +434,37 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
|
||||||
'Stay awhile and listen!',
|
'Stay awhile and listen!',
|
||||||
'Bring me any unidentified items you have found, and I shall reveal their hidden properties.',
|
'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
|
// 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.',
|
'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.',
|
'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,
|
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.',
|
'What do you want? Speak up! Mixing potions can be hazardous to one\'s hearing.',
|
||||||
'Buy a potion or move along before something explodes!',
|
'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,
|
hcIdx: 177,
|
||||||
|
|
@ -445,6 +528,13 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
|
||||||
'Indeed, dark times have come to Lut Gholein.',
|
'Indeed, dark times have come to Lut Gholein.',
|
||||||
'If you seek arcane knowledge, wands, or staves, examine my wares.',
|
'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,
|
hcIdx: 199,
|
||||||
|
|
@ -461,6 +551,13 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
|
||||||
'Glad to meet you, traveler. I am Elzix, innkeeper and merchant.',
|
'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?',
|
'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,
|
hcIdx: 244,
|
||||||
|
|
@ -477,6 +574,13 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
|
||||||
'Stay awhile and listen!',
|
'Stay awhile and listen!',
|
||||||
'The Horadric mysteries run deep in Lut Gholein. Let me identify your artifacts.',
|
'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
|
// Act 3
|
||||||
{
|
{
|
||||||
|
|
@ -494,6 +598,12 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
|
||||||
'Welcome, stranger. I am Hratli, a sorcerer and smith.',
|
'Welcome, stranger. I am Hratli, a sorcerer and smith.',
|
||||||
'My enchantments keep these docks safe, and my anvil keeps your blades sharp.',
|
'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,
|
hcIdx: 254,
|
||||||
|
|
@ -510,6 +620,13 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
|
||||||
'Oh, great. Another interrupting adventurer.',
|
'Oh, great. Another interrupting adventurer.',
|
||||||
'If you want potions or wish to gamble away your coin, make it quick.',
|
'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,
|
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.',
|
'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.',
|
'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,
|
hcIdx: 252,
|
||||||
|
|
@ -538,10 +662,17 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
|
||||||
canGamble: false,
|
canGamble: false,
|
||||||
canHeal: false,
|
canHeal: false,
|
||||||
canIdentify: false,
|
canIdentify: false,
|
||||||
|
canHire: true,
|
||||||
greetingLines: [
|
greetingLines: [
|
||||||
'Greetings. I am Asheara, commander of the Iron Wolves.',
|
'Greetings. I am Asheara, commander of the Iron Wolves.',
|
||||||
'Browse my weapons and armor if you are heading into the jungle.',
|
'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,
|
hcIdx: 245,
|
||||||
|
|
@ -558,6 +689,13 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
|
||||||
'Stay awhile and listen!',
|
'Stay awhile and listen!',
|
||||||
'Mephisto\'s corruption has twisted Kurast. Show me any relics you have uncovered.',
|
'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
|
// Act 4
|
||||||
{
|
{
|
||||||
|
|
@ -574,6 +712,12 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
|
||||||
greetingLines: [
|
greetingLines: [
|
||||||
'Keep your weapons ready and your armor mended, mortal.',
|
'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,
|
hcIdx: 405,
|
||||||
|
|
@ -589,6 +733,13 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
|
||||||
greetingLines: [
|
greetingLines: [
|
||||||
'The Light blesses you, hero. Let me restore your strength and offer my sacred wares.',
|
'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,
|
hcIdx: 246,
|
||||||
|
|
@ -605,6 +756,13 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
|
||||||
'Stay awhile and listen!',
|
'Stay awhile and listen!',
|
||||||
'Even at the gates of Hell, Horadric wisdom shall guide us.',
|
'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
|
// 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.',
|
'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.',
|
'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,
|
hcIdx: 511,
|
||||||
|
|
@ -634,10 +798,17 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
|
||||||
canGamble: false,
|
canGamble: false,
|
||||||
canHeal: false,
|
canHeal: false,
|
||||||
canIdentify: false,
|
canIdentify: false,
|
||||||
|
questRewardActions: ['add_sockets'],
|
||||||
greetingLines: [
|
greetingLines: [
|
||||||
'What are you looking at? Never seen a Barbarian smith before?',
|
'What are you looking at? Never seen a Barbarian smith before?',
|
||||||
'Take a look at my steel—finest in all the northern highlands!',
|
'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,
|
hcIdx: 512,
|
||||||
|
|
@ -650,10 +821,18 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
|
||||||
canGamble: true,
|
canGamble: true,
|
||||||
canHeal: false,
|
canHeal: false,
|
||||||
canIdentify: false,
|
canIdentify: false,
|
||||||
|
questRewardActions: ['personalize'],
|
||||||
greetingLines: [
|
greetingLines: [
|
||||||
'Thank you for rescuing me from the frozen prison, hero.',
|
'Thank you for rescuing me from the frozen prison, hero.',
|
||||||
'Please, browse my wares or test your fortune with a gamble.',
|
'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,
|
hcIdx: 514,
|
||||||
|
|
@ -670,6 +849,12 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
|
||||||
'Why do you bother me? Harrogath does not need outsiders.',
|
'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.',
|
'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,
|
hcIdx: 520,
|
||||||
|
|
@ -686,8 +871,15 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
|
||||||
'Stay awhile and listen!',
|
'Stay awhile and listen!',
|
||||||
'Mount Arreat holds the Worldstone itself. Let me identify your discoveries.',
|
'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,
|
hcIdx: 155,
|
||||||
npcTxtKey: 'warriv1',
|
npcTxtKey: 'warriv1',
|
||||||
|
|
@ -699,9 +891,38 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
|
||||||
canGamble: false,
|
canGamble: false,
|
||||||
canHeal: false,
|
canHeal: false,
|
||||||
canIdentify: false,
|
canIdentify: false,
|
||||||
|
travelActions: ['travel_east'],
|
||||||
greetingLines: [
|
greetingLines: [
|
||||||
'Greetings, stranger. Once the pass to the East is safe, my caravan will take you to Lut Gholein.',
|
'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,
|
hcIdx: 150,
|
||||||
|
|
@ -714,9 +935,16 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
|
||||||
canGamble: false,
|
canGamble: false,
|
||||||
canHeal: false,
|
canHeal: false,
|
||||||
canIdentify: false,
|
canIdentify: false,
|
||||||
|
canHire: true,
|
||||||
greetingLines: [
|
greetingLines: [
|
||||||
'My Rogue scouts have suffered heavy losses. Prove your valor in the field, warrior.',
|
'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,
|
hcIdx: 176,
|
||||||
|
|
@ -732,6 +960,12 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
|
||||||
greetingLines: [
|
greetingLines: [
|
||||||
'Welcome to my tavern, traveler. Let me tend to your wounds while you rest from the desert sun.',
|
'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,
|
hcIdx: 198,
|
||||||
|
|
@ -744,9 +978,16 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
|
||||||
canGamble: false,
|
canGamble: false,
|
||||||
canHeal: false,
|
canHeal: false,
|
||||||
canIdentify: false,
|
canIdentify: false,
|
||||||
|
canHire: true,
|
||||||
greetingLines: [
|
greetingLines: [
|
||||||
'My desert mercenaries guard the walls of Lut Gholein day and night.',
|
'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,
|
hcIdx: 201,
|
||||||
|
|
@ -762,6 +1003,34 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
|
||||||
greetingLines: [
|
greetingLines: [
|
||||||
'Welcome to Lut Gholein. Dark forces stir beneath the desert sands.',
|
'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,
|
hcIdx: 210,
|
||||||
|
|
@ -774,9 +1043,38 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
|
||||||
canGamble: false,
|
canGamble: false,
|
||||||
canHeal: false,
|
canHeal: false,
|
||||||
canIdentify: false,
|
canIdentify: false,
|
||||||
|
travelActions: ['travel_east'],
|
||||||
greetingLines: [
|
greetingLines: [
|
||||||
'Ahoy, traveler! My ship is ready to sail across the Twin Seas whenever your quest demands.',
|
'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,
|
hcIdx: 200,
|
||||||
|
|
@ -792,6 +1090,12 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
|
||||||
greetingLines: [
|
greetingLines: [
|
||||||
'What are you looking at? Pour another drink or move along!',
|
'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,
|
hcIdx: 256,
|
||||||
|
|
@ -807,6 +1111,55 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
|
||||||
greetingLines: [
|
greetingLines: [
|
||||||
'Keep your eyes open in the jungle. The Prime Evils are gathering in Travincal.',
|
'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,
|
hcIdx: 367,
|
||||||
|
|
@ -819,9 +1172,17 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
|
||||||
canGamble: false,
|
canGamble: false,
|
||||||
canHeal: false,
|
canHeal: false,
|
||||||
canIdentify: false,
|
canIdentify: false,
|
||||||
|
travelActions: ['travel_harrogath'],
|
||||||
greetingLines: [
|
greetingLines: [
|
||||||
'Hail to you, champion. The hour of judgment upon Diablo is at hand.',
|
'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,
|
hcIdx: 515,
|
||||||
|
|
@ -834,15 +1195,116 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] = [
|
||||||
canGamble: false,
|
canGamble: false,
|
||||||
canHeal: false,
|
canHeal: false,
|
||||||
canIdentify: false,
|
canIdentify: false,
|
||||||
|
canHire: true,
|
||||||
greetingLines: [
|
greetingLines: [
|
||||||
'Once you free our warriors on the Frigid Highlands, the finest Barbarians will fight by your side.',
|
'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>(
|
const DESCRIPTOR_BY_HCIDX = new Map<number, TownNpcServiceDescriptor>(
|
||||||
TOWN_NPC_DESCRIPTORS.map(d => [d.hcIdx, d]),
|
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>();
|
const DESCRIPTOR_BY_NAME = new Map<string, TownNpcServiceDescriptor>();
|
||||||
for (const d of TOWN_NPC_DESCRIPTORS) {
|
for (const d of TOWN_NPC_DESCRIPTORS) {
|
||||||
|
|
@ -870,6 +1332,8 @@ export function getTownNpcDescriptorByName(
|
||||||
return DESCRIPTOR_BY_NAME.get(lower);
|
return DESCRIPTOR_BY_NAME.get(lower);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getTownNpcDescriptor = getTownNpcDescriptorByName;
|
||||||
|
|
||||||
export function getTownNpcDescriptorByVendorId(
|
export function getTownNpcDescriptorByVendorId(
|
||||||
vendorId: VendorNpcId,
|
vendorId: VendorNpcId,
|
||||||
actIndex?: number,
|
actIndex?: number,
|
||||||
|
|
@ -889,6 +1353,7 @@ const NPC_NAME_TBL_KEYS: Readonly<Record<string, string>> = {
|
||||||
cain3: 'DeckardCain',
|
cain3: 'DeckardCain',
|
||||||
cain4: 'DeckardCain',
|
cain4: 'DeckardCain',
|
||||||
cain5: 'DeckardCain',
|
cain5: 'DeckardCain',
|
||||||
|
cain6: 'DeckardCain',
|
||||||
'deckard cain': 'DeckardCain',
|
'deckard cain': 'DeckardCain',
|
||||||
warriv: 'warriv',
|
warriv: 'warriv',
|
||||||
warriv1: 'warriv',
|
warriv1: 'warriv',
|
||||||
|
|
@ -901,6 +1366,7 @@ const NPC_NAME_TBL_KEYS: Readonly<Record<string, string>> = {
|
||||||
atma: 'atma',
|
atma: 'atma',
|
||||||
greiz: 'greiz',
|
greiz: 'greiz',
|
||||||
jerhyn: 'jerhyn',
|
jerhyn: 'jerhyn',
|
||||||
|
kaelan: 'kaelan',
|
||||||
meshif: 'meshif',
|
meshif: 'meshif',
|
||||||
meshif1: 'meshif',
|
meshif1: 'meshif',
|
||||||
meshif2: 'meshif',
|
meshif2: 'meshif',
|
||||||
|
|
@ -912,6 +1378,7 @@ const NPC_NAME_TBL_KEYS: Readonly<Record<string, string>> = {
|
||||||
natalya: 'natalya',
|
natalya: 'natalya',
|
||||||
halbu: 'halbu',
|
halbu: 'halbu',
|
||||||
jamella: 'jamella',
|
jamella: 'jamella',
|
||||||
|
hadriel: 'hadriel',
|
||||||
tyrael: 'tyrael',
|
tyrael: 'tyrael',
|
||||||
tyrael1: 'tyrael',
|
tyrael1: 'tyrael',
|
||||||
tyrael2: 'tyrael',
|
tyrael2: 'tyrael',
|
||||||
|
|
@ -956,6 +1423,10 @@ const NPC_GREETING_ZH_BY_KEY: Readonly<Record<string, readonly string[]>> = {
|
||||||
'请留步,听我一言!',
|
'请留步,听我一言!',
|
||||||
'亚瑞特山脉守护着世界之石。让我为你辨识你发现的宝物。',
|
'亚瑞特山脉守护着世界之石。让我为你辨识你发现的宝物。',
|
||||||
],
|
],
|
||||||
|
cain6: [
|
||||||
|
'请留步,听我一言!',
|
||||||
|
'感谢你从崔斯特瑞姆救出我。让我为你辨识物品吧。',
|
||||||
|
],
|
||||||
fara: [
|
fara: [
|
||||||
'你好,流浪者。我是法拉,曾经是东方萨卡兰姆的圣骑士。',
|
'你好,流浪者。我是法拉,曾经是东方萨卡兰姆的圣骑士。',
|
||||||
'如今我在鲁·高因以铁匠与治疗者的身份维生。',
|
'如今我在鲁·高因以铁匠与治疗者的身份维生。',
|
||||||
|
|
@ -1007,14 +1478,19 @@ const NPC_GREETING_ZH_BY_KEY: Readonly<Record<string, readonly string[]>> = {
|
||||||
'如果你非要浪费金币,我这里倒有一些古怪玩意儿可以让你赌一把。',
|
'如果你非要浪费金币,我这里倒有一些古怪玩意儿可以让你赌一把。',
|
||||||
],
|
],
|
||||||
warriv1: ['你好,陌生人。一旦通往东方的关隘安全了,我的商队就会带你前往鲁·高因。'],
|
warriv1: ['你好,陌生人。一旦通往东方的关隘安全了,我的商队就会带你前往鲁·高因。'],
|
||||||
|
warriv2: ['很高兴在鲁·高因见到你。我的商队随时可以带你向西返回罗格营地。'],
|
||||||
kashya: ['我的罗格斥候伤亡惨重。在战场上证明你的勇气吧,战士。'],
|
kashya: ['我的罗格斥候伤亡惨重。在战场上证明你的勇气吧,战士。'],
|
||||||
atma: ['欢迎来到我的酒馆,旅行者。在躲避沙漠烈日的同时,让我为你治愈伤口吧。'],
|
atma: ['欢迎来到我的酒馆,旅行者。在躲避沙漠烈日的同时,让我为你治愈伤口吧。'],
|
||||||
greiz: ['我的沙漠佣兵日夜守卫着鲁·高因的城墙。'],
|
greiz: ['我的沙漠佣兵日夜守卫着鲁·高因的城墙。'],
|
||||||
jerhyn: ['欢迎来到鲁·高因。黑暗力量正在沙漠深处蠢蠢欲动。'],
|
jerhyn: ['欢迎来到鲁·高因。黑暗力量正在沙漠深处蠢蠢欲动。'],
|
||||||
|
kaelan: ['站住!没有杰海因领主的许可,任何人不得进入王宫。'],
|
||||||
meshif1: ['嘿,旅行者!只要你的使命需要,我的帆船随时可以横渡双子海。'],
|
meshif1: ['嘿,旅行者!只要你的使命需要,我的帆船随时可以横渡双子海。'],
|
||||||
|
meshif2: ['欢迎再次登船,伙伴!如果你想向西航行返回鲁·高因,随时告诉我。'],
|
||||||
geglash: ['看什么看?要么再来一杯,要么赶紧走开!'],
|
geglash: ['看什么看?要么再来一杯,要么赶紧走开!'],
|
||||||
natalya: ['在丛林里睁大眼睛。三大魔神正在崔凡克集结。'],
|
natalya: ['在丛林里睁大眼睛。三大魔神正在崔凡克集结。'],
|
||||||
tyrael1: ['向你致敬,勇士。审判迪亚波罗的时刻已经到来。'],
|
tyrael1: ['向你致敬,勇士。审判迪亚波罗的时刻已经到来。'],
|
||||||
|
tyrael2: ['干得好,凡人。快返回鲁·高因,乘马席夫的船前往库拉斯特。'],
|
||||||
|
hadriel: ['止步,凡人!前方就是迪亚波罗盘踞的混沌避难所。'],
|
||||||
'qual-kehk': ['一旦你解救了冰冻高地上的战士,最精锐的野蛮人将与你并肩作战。'],
|
'qual-kehk': ['一旦你解救了冰冻高地上的战士,最精锐的野蛮人将与你并肩作战。'],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1028,6 +1504,7 @@ const NPC_NAME_ZH_BY_KEY: Readonly<Record<string, string>> = {
|
||||||
cain3: '迪卡·凯恩',
|
cain3: '迪卡·凯恩',
|
||||||
cain4: '迪卡·凯恩',
|
cain4: '迪卡·凯恩',
|
||||||
cain5: '迪卡·凯恩',
|
cain5: '迪卡·凯恩',
|
||||||
|
cain6: '迪卡·凯恩',
|
||||||
'deckard cain': '迪卡·凯恩',
|
'deckard cain': '迪卡·凯恩',
|
||||||
warriv: '瓦瑞夫',
|
warriv: '瓦瑞夫',
|
||||||
warriv1: '瓦瑞夫',
|
warriv1: '瓦瑞夫',
|
||||||
|
|
@ -1040,6 +1517,7 @@ const NPC_NAME_ZH_BY_KEY: Readonly<Record<string, string>> = {
|
||||||
atma: '亚特玛',
|
atma: '亚特玛',
|
||||||
greiz: '格雷兹',
|
greiz: '格雷兹',
|
||||||
jerhyn: '杰海因',
|
jerhyn: '杰海因',
|
||||||
|
kaelan: '凯兰',
|
||||||
meshif: '马席夫',
|
meshif: '马席夫',
|
||||||
meshif1: '马席夫',
|
meshif1: '马席夫',
|
||||||
meshif2: '马席夫',
|
meshif2: '马席夫',
|
||||||
|
|
@ -1051,6 +1529,7 @@ const NPC_NAME_ZH_BY_KEY: Readonly<Record<string, string>> = {
|
||||||
natalya: '娜塔亚',
|
natalya: '娜塔亚',
|
||||||
halbu: '海尔布',
|
halbu: '海尔布',
|
||||||
jamella: '贾梅拉',
|
jamella: '贾梅拉',
|
||||||
|
hadriel: '哈德瑞尔',
|
||||||
tyrael: '泰瑞尔',
|
tyrael: '泰瑞尔',
|
||||||
tyrael1: '泰瑞尔',
|
tyrael1: '泰瑞尔',
|
||||||
tyrael2: '泰瑞尔',
|
tyrael2: '泰瑞尔',
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,10 @@ export function encodeC2sWaypointGo(waypointId: number, destAreaId: number): Uin
|
||||||
* - 0x18 Transmute: Horadric Cube transmute, amount 0.
|
* - 0x18 Transmute: Horadric Cube transmute, amount 0.
|
||||||
*/
|
*/
|
||||||
export const ClickButtonId = {
|
export const ClickButtonId = {
|
||||||
|
TradeAccept: 0x02,
|
||||||
|
TradeCancel: 0x02,
|
||||||
|
TradeOk: 0x04,
|
||||||
|
TradeUnaccept: 0x07,
|
||||||
TradeGold: 0x08,
|
TradeGold: 0x08,
|
||||||
CloseStash: 0x12,
|
CloseStash: 0x12,
|
||||||
WithdrawGold: 0x13,
|
WithdrawGold: 0x13,
|
||||||
|
|
@ -41,13 +45,9 @@ export const ClickButtonId = {
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 0x4F ClickButton (7B): u8 0x4F | u16 buttonId | u16 amountHigh | u16 amountLow.
|
* 0x4F ClickButton (7B): u8 0x4F | u16 buttonId | u16 ((amount >>> 16) & 0xffff) | u16 (amount & 0xffff).
|
||||||
*
|
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
export function encodeC2sClickButton(buttonId: number, amount: number): Uint8Array {
|
export function encodeC2sClickButton(buttonId: number, amount = 0): Uint8Array {
|
||||||
if (!Number.isInteger(buttonId) || buttonId < 0 || buttonId > 0xffff) {
|
if (!Number.isInteger(buttonId) || buttonId < 0 || buttonId > 0xffff) {
|
||||||
throw new RangeError(`ClickButton: invalid buttonId ${buttonId}`)
|
throw new RangeError(`ClickButton: invalid buttonId ${buttonId}`)
|
||||||
}
|
}
|
||||||
|
|
@ -71,3 +71,6 @@ export function encodeC2sResurrect(): Uint8Array {
|
||||||
export function encodeC2sSwapWeapons(): Uint8Array {
|
export function encodeC2sSwapWeapons(): Uint8Array {
|
||||||
return validateC2sPacket(new Uint8Array([0x60]))
|
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())
|
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 */
|
/** 0x20 UseInventoryItem (13B): u8 0x20 | u32 itemId | u32 x | u32 y */
|
||||||
export function encodeC2sItemUse(itemId: number, at: Pt): Uint8Array {
|
export function encodeC2sItemUse(itemId: number, at: Pt): Uint8Array {
|
||||||
const w = new ByteWriter(13)
|
const w = new ByteWriter(13)
|
||||||
|
|
@ -107,8 +126,17 @@ export function encodeC2sBeltRemove(itemId: number): Uint8Array {
|
||||||
return validateC2sPacket(w.toUint8Array())
|
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 */
|
/** 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)
|
const w = new ByteWriter(13)
|
||||||
w.u8(0x26)
|
w.u8(0x26)
|
||||||
w.u32LE(itemId >>> 0)
|
w.u32LE(itemId >>> 0)
|
||||||
|
|
@ -134,3 +162,21 @@ export function encodeC2sSocketItem(itemId: number, targetItemId: number): Uint8
|
||||||
w.u32LE(targetItemId >>> 0)
|
w.u32LE(targetItemId >>> 0)
|
||||||
return validateC2sPacket(w.toUint8Array())
|
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)
|
w.u32LE(target.id >>> 0)
|
||||||
return validateC2sPacket(w.toUint8Array())
|
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 */
|
/** 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)
|
const w = new ByteWriter(9)
|
||||||
w.u8(0x30)
|
w.u8(0x30)
|
||||||
w.u32LE(unitTypeToId(target.unitType) >>> 0)
|
w.u32LE(unitType >>> 0)
|
||||||
w.u32LE(target.id >>> 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())
|
return validateC2sPacket(w.toUint8Array())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -145,6 +156,8 @@ export function encodeC2sHireMerc(npcId: number, mercId: number): Uint8Array {
|
||||||
return validateC2sPacket(w.toUint8Array())
|
return validateC2sPacket(w.toUint8Array())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const encodeC2sNpcMercHire = encodeC2sHireMerc
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 0x38 EntityAction action values handled by D2Game.dll 0x6fce00c0 (via C2S handler 0x6fcefb30):
|
* 0x38 EntityAction action values handled by D2Game.dll 0x6fce00c0 (via C2S handler 0x6fcefb30):
|
||||||
* - 1 Trade: vendor classes -> 0x6fcdf810(gamble = 0) fills the shop.
|
* - 1 Trade: vendor classes -> 0x6fcdf810(gamble = 0) fills the shop.
|
||||||
|
|
@ -156,10 +169,11 @@ export const NpcActionId = {
|
||||||
Trade: 1,
|
Trade: 1,
|
||||||
Gamble: 2,
|
Gamble: 2,
|
||||||
Hire: 3,
|
Hire: 3,
|
||||||
|
OpenHireList: 3,
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
/** 0x38 EntityAction (13B): u8 0x38 | u32 action | u32 npcId | u32 complement */
|
/** 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)
|
const w = new ByteWriter(13)
|
||||||
w.u8(0x38)
|
w.u8(0x38)
|
||||||
w.u32LE(requireU32('NpcEntityAction.action', action))
|
w.u32LE(requireU32('NpcEntityAction.action', action))
|
||||||
|
|
@ -175,3 +189,6 @@ export function encodeC2sNpcResurrectMerc(npcId: number): Uint8Array {
|
||||||
w.u32LE(npcId >>> 0)
|
w.u32LE(npcId >>> 0)
|
||||||
return validateC2sPacket(w.toUint8Array())
|
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'
|
import { validateC2sPacket } from '../framing-c2s.ts'
|
||||||
|
|
||||||
/** 0x5D SetPlayerRelationship (7B): u8 0x5D | u8 button | u8 state | u32 targetPlayerId */
|
/** 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)
|
const w = new ByteWriter(7)
|
||||||
w.u8(0x5d)
|
w.u8(0x5d)
|
||||||
w.u8(0x04) // hostility button
|
w.u8(button & 0xff)
|
||||||
w.u8(hostile ? 0x01 : 0x00)
|
w.u8(hostile ? 0x01 : 0x00)
|
||||||
w.u32LE(targetPlayerId >>> 0)
|
w.u32LE(targetPlayerId >>> 0)
|
||||||
return validateC2sPacket(w.toUint8Array())
|
return validateC2sPacket(w.toUint8Array())
|
||||||
|
|
@ -23,3 +23,6 @@ export function encodeC2sParty(action: number, targetPlayerId: number): Uint8Arr
|
||||||
w.u32LE(targetPlayerId >>> 0)
|
w.u32LE(targetPlayerId >>> 0)
|
||||||
return validateC2sPacket(w.toUint8Array())
|
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`).
|
* 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.
|
* 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) {
|
if (bytes.length < 1) {
|
||||||
throw new ProtocolError('Empty D2GS C->S packet')
|
throw new ProtocolError('Empty D2GS C->S packet')
|
||||||
}
|
}
|
||||||
|
|
@ -80,7 +82,7 @@ export function validateC2sPacket(bytes: Uint8Array): Uint8Array {
|
||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return bytes
|
return Object.assign(bytes, { valid: true as const, opcode, expectedLength: expected })
|
||||||
}
|
}
|
||||||
|
|
||||||
function findZeroByte(buf: Uint8Array, start: number, limit: number): number {
|
function findZeroByte(buf: Uint8Array, start: number, limit: number): number {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import {
|
||||||
import {
|
import {
|
||||||
encodeC2sBeltPut,
|
encodeC2sBeltPut,
|
||||||
encodeC2sBeltRemove,
|
encodeC2sBeltRemove,
|
||||||
|
encodeC2sBeltSwap,
|
||||||
encodeC2sBeltUse,
|
encodeC2sBeltUse,
|
||||||
encodeC2sIdentifyItem,
|
encodeC2sIdentifyItem,
|
||||||
encodeC2sItemDrop,
|
encodeC2sItemDrop,
|
||||||
|
|
@ -30,9 +31,12 @@ import {
|
||||||
encodeC2sItemToCursor,
|
encodeC2sItemToCursor,
|
||||||
encodeC2sItemUnequip,
|
encodeC2sItemUnequip,
|
||||||
encodeC2sItemUse,
|
encodeC2sItemUse,
|
||||||
|
encodeC2sMercItem,
|
||||||
|
encodeC2sScrollToBook,
|
||||||
encodeC2sSocketItem,
|
encodeC2sSocketItem,
|
||||||
|
encodeC2sSwapContainerItem,
|
||||||
} from './c2s/items.ts'
|
} from './c2s/items.ts'
|
||||||
import { encodeC2sMove, encodeC2sMoveToUnit } from './c2s/movement.ts'
|
import { encodeC2sMove, encodeC2sMoveToUnit, encodeC2sWarpTo } from './c2s/movement.ts'
|
||||||
import {
|
import {
|
||||||
encodeC2sHireMerc,
|
encodeC2sHireMerc,
|
||||||
encodeC2sNpcBuy,
|
encodeC2sNpcBuy,
|
||||||
|
|
@ -45,6 +49,7 @@ import {
|
||||||
NpcActionId,
|
NpcActionId,
|
||||||
encodeC2sNpcResurrectMerc,
|
encodeC2sNpcResurrectMerc,
|
||||||
encodeC2sNpcSell,
|
encodeC2sNpcSell,
|
||||||
|
encodeC2sQuestMessage,
|
||||||
} from './c2s/npc.ts'
|
} from './c2s/npc.ts'
|
||||||
import { encodeC2sHostile, encodeC2sParty } from './c2s/party.ts'
|
import { encodeC2sHostile, encodeC2sParty } from './c2s/party.ts'
|
||||||
import { encodeC2sLeaveGame, encodeC2sPing } from './c2s/session.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 },
|
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`.
|
* 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.
|
* 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) {
|
if (pkt.byteLength === 0) {
|
||||||
throw new ProtocolError('Empty D2GS S2C packet', { proto: 'd2gs', packetId: -1 })
|
throw new ProtocolError('Empty D2GS S2C packet', { proto: 'd2gs', packetId: -1 })
|
||||||
}
|
}
|
||||||
|
|
@ -436,7 +455,9 @@ export function encodeD2gsClientCommand(
|
||||||
case 'OperateObject':
|
case 'OperateObject':
|
||||||
return [encodeC2sInteract({ unitType: 2, id: cmd.objectId })]
|
return [encodeC2sInteract({ unitType: 2, id: cmd.objectId })]
|
||||||
case 'TakeWarp':
|
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 'ItemPickup':
|
||||||
case 'PickUpItem':
|
case 'PickUpItem':
|
||||||
return [encodeC2sItemPickup(cmd.itemId, cmd.toCursor)]
|
return [encodeC2sItemPickup(cmd.itemId, cmd.toCursor)]
|
||||||
|
|
@ -454,26 +475,40 @@ export function encodeD2gsClientCommand(
|
||||||
return [encodeC2sItemUnequip(cmd.slot)]
|
return [encodeC2sItemUnequip(cmd.slot)]
|
||||||
case 'ItemSwap':
|
case 'ItemSwap':
|
||||||
return [encodeC2sItemSwap(cmd.itemId, cmd.slot)]
|
return [encodeC2sItemSwap(cmd.itemId, cmd.slot)]
|
||||||
|
case 'SwapContainerItem':
|
||||||
|
return [encodeC2sSwapContainerItem(cmd.cursorItemId, cmd.targetItemId ?? cmd.replacedItemId ?? 0, cmd.x, cmd.y)]
|
||||||
case 'BeltPut':
|
case 'BeltPut':
|
||||||
return [encodeC2sBeltPut(cmd.itemId, cmd.slot)]
|
return [encodeC2sBeltPut(cmd.itemId, cmd.slot)]
|
||||||
case 'BeltRemove':
|
case 'BeltRemove':
|
||||||
return [encodeC2sBeltRemove(cmd.itemId)]
|
return [encodeC2sBeltRemove(cmd.itemId)]
|
||||||
|
case 'BeltSwap':
|
||||||
|
return [encodeC2sBeltSwap(cmd.cursorItemId, cmd.beltItemId)]
|
||||||
case 'BeltUse':
|
case 'BeltUse':
|
||||||
return [encodeC2sBeltUse(cmd.itemId, cmd.shift)]
|
return [encodeC2sBeltUse(cmd.itemId, Boolean(cmd.shift ?? cmd.useOnMerc))]
|
||||||
case 'ItemUse':
|
case 'ItemUse':
|
||||||
return [encodeC2sItemUse(cmd.itemId, cmd.at)]
|
return [encodeC2sItemUse(cmd.itemId, cmd.at)]
|
||||||
case 'SocketItem':
|
case 'SocketItem':
|
||||||
return [encodeC2sSocketItem(cmd.itemId, cmd.targetItemId)]
|
return [encodeC2sSocketItem(cmd.itemId, cmd.targetItemId)]
|
||||||
case 'IdentifyItem':
|
case 'IdentifyItem':
|
||||||
return [encodeC2sIdentifyItem(cmd.itemId, cmd.scrollId)]
|
return [encodeC2sIdentifyItem(cmd.itemId, cmd.scrollId)]
|
||||||
|
case 'ScrollToBook':
|
||||||
|
return [encodeC2sScrollToBook(cmd.scrollId, cmd.bookId)]
|
||||||
|
case 'MercItem':
|
||||||
|
return [encodeC2sMercItem(cmd.slot)]
|
||||||
case 'NpcInit':
|
case 'NpcInit':
|
||||||
return [encodeC2sNpcInit(cmd.target)]
|
return [encodeC2sNpcInit(cmd.target)]
|
||||||
case 'NpcCancel':
|
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':
|
case 'NpcOpenTrade':
|
||||||
return [encodeC2sNpcEntityAction(NpcActionId.Trade, cmd.npcId, 0)]
|
return [encodeC2sNpcEntityAction(NpcActionId.Trade, cmd.npcId, 0)]
|
||||||
case 'NpcOpenGamble':
|
case 'NpcOpenGamble':
|
||||||
return [encodeC2sNpcEntityAction(NpcActionId.Gamble, cmd.npcId, 0)]
|
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':
|
case 'NpcBuy':
|
||||||
return [encodeC2sNpcBuy(cmd)]
|
return [encodeC2sNpcBuy(cmd)]
|
||||||
case 'NpcSell':
|
case 'NpcSell':
|
||||||
|
|
@ -487,8 +522,10 @@ export function encodeD2gsClientCommand(
|
||||||
case 'NpcIdentifyAll':
|
case 'NpcIdentifyAll':
|
||||||
return [encodeC2sNpcIdentifyAll(cmd.npcId)]
|
return [encodeC2sNpcIdentifyAll(cmd.npcId)]
|
||||||
case 'NpcResurrectMerc':
|
case 'NpcResurrectMerc':
|
||||||
|
case 'MercResurrect':
|
||||||
return [encodeC2sNpcResurrectMerc(cmd.npcId)]
|
return [encodeC2sNpcResurrectMerc(cmd.npcId)]
|
||||||
case 'HireMerc':
|
case 'HireMerc':
|
||||||
|
case 'MercHire':
|
||||||
return [encodeC2sHireMerc(cmd.npcId, cmd.mercId)]
|
return [encodeC2sHireMerc(cmd.npcId, cmd.mercId)]
|
||||||
case 'AddStatPoint':
|
case 'AddStatPoint':
|
||||||
return [encodeC2sAddStatPoint(cmd.statId, cmd.count)]
|
return [encodeC2sAddStatPoint(cmd.statId, cmd.count)]
|
||||||
|
|
@ -498,12 +535,23 @@ export function encodeD2gsClientCommand(
|
||||||
return [encodeC2sWaypointGo(cmd.waypointId, cmd.destAreaId)]
|
return [encodeC2sWaypointGo(cmd.waypointId, cmd.destAreaId)]
|
||||||
case 'TownPortal':
|
case 'TownPortal':
|
||||||
return [encodeC2sSkillAt('right', { x: 0, y: 0 })]
|
return [encodeC2sSkillAt('right', { x: 0, y: 0 })]
|
||||||
case 'ClickButton':
|
case 'ClickButton': {
|
||||||
return [encodeC2sClickButton(cmd.buttonId, cmd.amount)]
|
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 'Party':
|
||||||
|
case 'PartyAction':
|
||||||
return [encodeC2sParty(cmd.action, cmd.targetPlayerId)]
|
return [encodeC2sParty(cmd.action, cmd.targetPlayerId)]
|
||||||
case 'Hostile':
|
case 'Hostile':
|
||||||
return [encodeC2sHostile(cmd.targetPlayerId, cmd.hostile)]
|
return [encodeC2sHostile(cmd.targetPlayerId, cmd.hostile, cmd.button ?? 0x01)]
|
||||||
case 'Trade':
|
case 'Trade':
|
||||||
return [encodeC2sTrade(cmd.action, cmd.gold)]
|
return [encodeC2sTrade(cmd.action, cmd.gold)]
|
||||||
case 'Chat':
|
case 'Chat':
|
||||||
|
|
@ -520,3 +568,7 @@ export function encodeD2gsClientCommand(
|
||||||
return [encodeC2sLeaveGame()]
|
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 {
|
export function decode0x78TradeAccepted(pkt: Uint8Array): ServerEvent {
|
||||||
const r = new ByteReader(pkt)
|
const r = new ByteReader(pkt)
|
||||||
r.u8() // 0x78
|
r.u8() // 0x78
|
||||||
r.fixedCString(16) // partnerName
|
const partnerName = r.fixedCString(16)
|
||||||
const partnerId = r.u32LE()
|
const partnerId = r.u32LE()
|
||||||
return {
|
return {
|
||||||
type: 'Trade',
|
type: 'Trade',
|
||||||
action: 0x78,
|
action: 0x78,
|
||||||
partnerId,
|
partnerId,
|
||||||
|
partnerName,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ export function decode0x09AssignWarp(pkt: Uint8Array): ServerEvent {
|
||||||
unitType,
|
unitType,
|
||||||
id,
|
id,
|
||||||
classId: warpId,
|
classId: warpId,
|
||||||
|
warpId,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ export type ClientCommand =
|
||||||
| { readonly type: 'Interact'; readonly target: UnitRef }
|
| { readonly type: 'Interact'; readonly target: UnitRef }
|
||||||
| { readonly type: 'OperateObject'; readonly objectId: number }
|
| { readonly type: 'OperateObject'; readonly objectId: number }
|
||||||
| { readonly type: 'TakeWarp'; readonly warpId: number; readonly at?: Pt | undefined }
|
| { 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: 'ItemPickup'; readonly itemId: number; readonly toCursor?: boolean | undefined }
|
||||||
| { readonly type: 'PickUpItem'; readonly itemId: number; readonly toCursor?: boolean | undefined }
|
| { readonly type: 'PickUpItem'; readonly itemId: number; readonly toCursor?: boolean | undefined }
|
||||||
| { readonly type: 'ItemDrop'; readonly itemId: number }
|
| { readonly type: 'ItemDrop'; readonly itemId: number }
|
||||||
|
|
@ -42,18 +43,52 @@ export type ClientCommand =
|
||||||
| { readonly type: 'ItemEquip'; readonly itemId: number; readonly slot: number }
|
| { readonly type: 'ItemEquip'; readonly itemId: number; readonly slot: number }
|
||||||
| { readonly type: 'ItemUnequip'; readonly slot: number }
|
| { readonly type: 'ItemUnequip'; readonly slot: number }
|
||||||
| { readonly type: 'ItemSwap'; readonly itemId: number; 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: 'BeltPut'; readonly itemId: number; readonly slot: number }
|
||||||
| { readonly type: 'BeltRemove'; readonly itemId: 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: 'ItemUse'; readonly itemId: number; readonly at: Pt }
|
||||||
| { readonly type: 'SocketItem'; readonly itemId: number; readonly targetItemId: number }
|
| { readonly type: 'SocketItem'; readonly itemId: number; readonly targetItemId: number }
|
||||||
| { readonly type: 'IdentifyItem'; readonly itemId: number; readonly scrollId: 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: '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). */
|
/** 0x38 action 1: open the vendor trade window (server fills the shop with 0x9C AddToShop). */
|
||||||
| { readonly type: 'NpcOpenTrade'; readonly npcId: number }
|
| { readonly type: 'NpcOpenTrade'; readonly npcId: number }
|
||||||
/** 0x38 action 2: open the gamble window. */
|
/** 0x38 action 2: open the gamble window. */
|
||||||
| { readonly type: 'NpcOpenGamble'; readonly npcId: number }
|
| { 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). */
|
/** 0x32 BuyItem; field3 = (itemMode << 16) | (gamble ? 2 : 0) | (shiftFill ? 0x80000000 : 0). */
|
||||||
readonly type: 'NpcBuy'
|
readonly type: 'NpcBuy'
|
||||||
|
|
@ -85,15 +120,29 @@ export type ClientCommand =
|
||||||
| { readonly type: 'NpcHeal'; readonly npcId: number }
|
| { readonly type: 'NpcHeal'; readonly npcId: number }
|
||||||
| { readonly type: 'NpcIdentifyAll'; readonly npcId: number }
|
| { readonly type: 'NpcIdentifyAll'; readonly npcId: number }
|
||||||
| { readonly type: 'NpcResurrectMerc'; 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: '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: 'AddStatPoint'; readonly statId: number; readonly count?: number | undefined }
|
||||||
| { readonly type: 'AddSkillPoint'; readonly skillId: number }
|
| { readonly type: 'AddSkillPoint'; readonly skillId: number }
|
||||||
| { readonly type: 'WaypointGo'; readonly waypointId: number; readonly destAreaId: number }
|
| { readonly type: 'WaypointGo'; readonly waypointId: number; readonly destAreaId: number }
|
||||||
| { readonly type: 'TownPortal' }
|
| { readonly type: 'TownPortal' }
|
||||||
/** 0x4F ClickButton: see `ClickButtonId` (0x12 close stash, 0x13 withdraw, 0x14 deposit, 0x18 transmute). */
|
/** 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: '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: 'Trade'; readonly action: number; readonly gold: number }
|
||||||
| { readonly type: 'Chat'; readonly text: string; readonly whisperTo?: string | undefined }
|
| { readonly type: 'Chat'; readonly text: string; readonly whisperTo?: string | undefined }
|
||||||
| { readonly type: 'OverheadChat'; readonly text: string }
|
| { readonly type: 'OverheadChat'; readonly text: string }
|
||||||
|
|
@ -101,3 +150,4 @@ export type ClientCommand =
|
||||||
| { readonly type: 'SwapWeapons' }
|
| { readonly type: 'SwapWeapons' }
|
||||||
| { readonly type: 'Ping' }
|
| { readonly type: 'Ping' }
|
||||||
| { readonly type: 'LeaveGame' }
|
| { readonly type: 'LeaveGame' }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,7 @@ export type ServerEvent =
|
||||||
readonly type: 'SkillCast'
|
readonly type: 'SkillCast'
|
||||||
readonly caster: UnitRef
|
readonly caster: UnitRef
|
||||||
readonly skill: number
|
readonly skill: number
|
||||||
|
readonly skillId?: number | undefined
|
||||||
readonly level: number
|
readonly level: number
|
||||||
readonly target: UnitRef | Pt
|
readonly target: UnitRef | Pt
|
||||||
}
|
}
|
||||||
|
|
@ -183,6 +184,7 @@ export type ServerEvent =
|
||||||
readonly type: 'Trade'
|
readonly type: 'Trade'
|
||||||
readonly action: number
|
readonly action: number
|
||||||
readonly partnerId?: number | undefined
|
readonly partnerId?: number | undefined
|
||||||
|
readonly partnerName?: string | undefined
|
||||||
readonly gold?: number | 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 { McpFramer } from './mcp/framing.ts'
|
||||||
export { decodeMcpS2cPacket } from './mcp/packets.ts'
|
export { decodeMcpS2cPacket } from './mcp/packets.ts'
|
||||||
export { validateC2sPacket } from './d2gs/framing-c2s.ts'
|
export { validateC2sPacket } from './d2gs/framing-c2s.ts'
|
||||||
export { ClickButtonId } from './d2gs/c2s/interact.ts'
|
export { encodeC2sChat, encodeC2sOverheadChat } from './d2gs/c2s/chat.ts'
|
||||||
export { NpcActionId, NPC_BUY_FLAG_GAMBLE, NPC_BUY_FLAG_SHIFT_FILL, NPC_REPAIR_ALL_FLAG } from './d2gs/c2s/npc.ts'
|
export {
|
||||||
export { C2S_PACKET_SIZES } from './d2gs/tables/s2c-sizes.ts'
|
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 {
|
export {
|
||||||
D2GS_S2C_REGISTRY,
|
D2GS_S2C_REGISTRY,
|
||||||
decodeD2gsS2cPacket,
|
decodeD2gsS2cPacket,
|
||||||
|
decodeD2gsServerPacket,
|
||||||
|
encodeClientCommand,
|
||||||
encodeD2gsClientCommand,
|
encodeD2gsClientCommand,
|
||||||
type D2gsDecodeContext,
|
type D2gsDecodeContext,
|
||||||
type D2gsS2cRegistryEntry,
|
type D2gsS2cRegistryEntry,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { ClientWorld } from '../../src/client/world/client-world.ts'
|
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 {
|
function makeItem(overrides: Partial<ItemData> & { id: number; code: string }): ItemData {
|
||||||
return {
|
return {
|
||||||
|
|
@ -53,6 +53,7 @@ function makeItem(overrides: Partial<ItemData> & { id: number; code: string }):
|
||||||
durability: overrides.durability,
|
durability: overrides.durability,
|
||||||
maxDurability: overrides.maxDurability,
|
maxDurability: overrides.maxDurability,
|
||||||
quantity: overrides.quantity,
|
quantity: overrides.quantity,
|
||||||
|
goldAmount: overrides.goldAmount,
|
||||||
stats: overrides.stats ?? [],
|
stats: overrides.stats ?? [],
|
||||||
setBonusStats: overrides.setBonusStats ?? [],
|
setBonusStats: overrides.setBonusStats ?? [],
|
||||||
runewordStats: overrides.runewordStats ?? [],
|
runewordStats: overrides.runewordStats ?? [],
|
||||||
|
|
@ -321,4 +322,360 @@ describe('ClientWorld authoritative state mirror', () => {
|
||||||
world.apply({ type: 'Pong', rttMs: 42, tickCount: 1000 })
|
world.apply({ type: 'Pong', rttMs: 42, tickCount: 1000 })
|
||||||
expect(world.lastRttMs).toBe(42)
|
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
|
* 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.
|
* only emit `ClientCommand`s, empty equipment/belt and the plain-integer stat 6-11 wire format.
|
||||||
*/
|
*/
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import { HudModel, CLASS_ID_TO_CODE, nextLevelExperience } from '../../src/client/ui-model/hud-model.ts'
|
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 { 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 { ClientWorld } from '../../src/client/world/client-world.ts'
|
||||||
import { D2StatId } from '../../src/client/world/stat-list.ts'
|
import { D2StatId } from '../../src/client/world/stat-list.ts'
|
||||||
import { D2_EXPERIENCE_TABLE_99 } from '../../src/common/skills/formulas.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() {
|
function setup() {
|
||||||
const world = new ClientWorld()
|
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', () => {
|
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', () => {
|
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()
|
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.selfGold).toBe(300)
|
||||||
expect(world.trade.partnerGold).toBe(70)
|
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,
|
type ServerEvent,
|
||||||
} from '../../src/netproto/index.ts'
|
} from '../../src/netproto/index.ts'
|
||||||
import { HudManager } from '../../src/client/ui/hud-manager.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']
|
type ItemEventItem = Extract<ServerEvent, { type: 'ItemEvent' }>['item']
|
||||||
|
|
||||||
|
|
@ -504,6 +507,260 @@ describe('Milestone M5 — HudModel, CommandMapper, and OnlineSession', () => {
|
||||||
|
|
||||||
hudModel.dispose()
|
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)', () => {
|
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 { ClientWorld } from '../../src/client/world/client-world.ts'
|
||||||
import { D2StatId } from '../../src/client/world/stat-list.ts'
|
import { D2StatId } from '../../src/client/world/stat-list.ts'
|
||||||
import { HudManager } from '../../src/client/ui/hud-manager.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 { requireTbl } from '../../src/client/i18n/lang.ts'
|
||||||
import {
|
import {
|
||||||
HudModel,
|
HudModel,
|
||||||
|
|
@ -20,6 +27,13 @@ import {
|
||||||
} from '../../src/client/ui-model/hud-model.ts'
|
} from '../../src/client/ui-model/hud-model.ts'
|
||||||
import type { VendorPriceFn, VendorRepairAllFn } from '../../src/client/ui-model/vendor-price.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')
|
const FIXTURES = path.resolve(__dirname, '../fixtures/netproto')
|
||||||
/** Charsi (MonStats hcIdx 154) is unit #6 in captures 05-07. */
|
/** Charsi (MonStats hcIdx 154) is unit #6 in captures 05-07. */
|
||||||
const CHARSI_ID = 6
|
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 {
|
function clickNpcOption(hud: HudManager, optionId: string): void {
|
||||||
const menu = hud.worldPanels.npcMenu
|
const menu = hud.worldPanels.npcMenu
|
||||||
if (!menu?.options) throw new Error('no NPC menu')
|
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)
|
expect(idx).toBeGreaterThanOrEqual(0)
|
||||||
const layout = hud.worldPanels.getNpcMenuLayout(hud.font)!
|
const layout = hud.worldPanels.getNpcMenuLayout(hud.font)!
|
||||||
mouseDown(hud, layout.left + Math.trunc(layout.width / 2), layout.optionYs[idx]! - 3)
|
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 rest = walkUpToNpc(s, loadCapture('05-d2gs-vendor-open.d2cap'), CHARSI_ID)
|
||||||
const menu = s.hud.worldPanels.npcMenu
|
const menu = s.hud.worldPanels.npcMenu
|
||||||
expect(menu?.npcId).toBe(CHARSI_ID)
|
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')
|
clickNpcOption(s.hud, 'trade')
|
||||||
expect(s.commands).toEqual([{ type: 'NpcOpenTrade', npcId: CHARSI_ID }])
|
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/)
|
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