fix(client): fix Multiple Shot missile key, UnitStateSet iteration, local player state overlays, and arrow classId (#550)

This commit is contained in:
troytt 2026-10-02 13:12:50 +00:00
parent 4460f356e6
commit 3feb58fcfc
8 changed files with 199 additions and 124 deletions

View File

@ -1,4 +1,4 @@
# Project: D2GS v1.13c Online Gameplay Loops & Server Authority (`diablo2-web`)
# Project: D2GS v1.13c Online Gameplay Loops, Server Authority & Spell Visual Effects (`diablo2-web`)
## Architecture
@ -9,7 +9,8 @@ diablo2-web/
├── src/
│ ├── common/
│ │ ├── items/ # 1.13c item tables (embedded-drop-tables.ts), stat formatter (item-bridge.ts), tooltip builder (item-tooltip.ts), ground-items.ts
│ │ └── units/ # All-Act Town & Quest NPC descriptors (npc-table.ts: TOWN_NPC_DESCRIPTORS, NPC_HCIDX_TO_DESCRIPTOR_ID)
│ │ ├── skills/ # 1.13c Skills.txt & Missiles.txt data (skills-data.ts, skills-meta.ts, missiles-data.ts)
│ │ └── units/ # All-Act Town & Quest NPC descriptors (npc-table.ts), 1.13c direction mapping (direction.ts: DIR64_TO_DCC)
│ ├── netproto/
│ │ ├── index.ts # Sole public barrel export for netproto (strict boundary for src/client/**)
│ │ ├── domain/ # Typed ClientCommand (client-command.ts), ServerEvent (server-event.ts), ItemData & UnitSnapshot (ids.ts)
@ -17,50 +18,40 @@ diablo2-web/
│ │ ├── tables/ # Authoritative 1.13c packet size tables (s2c-sizes.ts: C2S_PACKET_SIZES, S2C_PACKET_SIZES)
│ │ ├── items/ # 0x9C/0x9D item bitstream decoder (item-bitstream.ts)
│ │ ├── c2s/ # C2S binary packet encoders (items.ts, npc.ts, party.ts, movement.ts, interact.ts, ui.ts)
│ │ ├── s2c/ # S2C binary packet decoders (units.ts, npc.ts, merc.ts, party.ts, trade.ts, waypoint.ts, combat.ts)
│ │ ├── s2c/ # S2C binary packet decoders (units.ts, npc.ts, merc.ts, party.ts, trade.ts, waypoint.ts, skills.ts, misc.ts)
│ │ └── registry.ts # encodeClientCommand & decodeD2gsServerPacket dispatchers
│ └── client/
│ ├── world/ # Authoritative ClientWorld state reducer (client-world.ts, client-unit.ts, inventory.ts, self.ts)
│ ├── world/ # Authoritative ClientWorld state reducer & 25Hz simulation (client-world.ts, client-unit.ts, inventory.ts, self.ts)
│ ├── view/ # Scene drawables & picking (unit-drawables.ts, scene-source.ts)
│ ├── scene/ # WorldRenderer (world-renderer.ts)
│ ├── render/ # Pre-baked 1.13c missile & overlay metadata (missiles-meta.ts, overlays-meta.ts)
│ ├── scene/ # WorldRenderer (world-renderer.ts, missile-overlay-renderer.ts)
│ ├── session/ # OnlineSession (online-session.ts)
│ ├── input/ # CommandMapper (command-mapper.ts)
│ ├── ui-model/ # HudModel (hud-model.ts): syncs ClientWorld -> HudManager/WorldPanelsHud & emits ClientCommand
│ └── ui/ # HudManager (hud-manager.ts), WorldPanelsHud (world-panels.ts), InventoryPanel (inventory.ts), Minimap (minimap.ts)
└── tests/
├── client/ # Unit & integration tests (hud-server-authority, vendor-stash-ui, vendor-stash-replay, client-world, hud-session-play)
├── arch/ # Architectural boundary enforcement (boundaries.test.ts)
├── client/ # Unit & integration tests (client-world, hud-session-play, missile-id-map, unit-facing-motion, hud-server-authority, vendor-stash-ui)
└── e2e-d2gs-online/ # 4-tier opaque-box E2E test suite + Tier 5 adversarial coverage hardening
```
### 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.
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/skills/`, `src/common/units/npc-table.ts`) must strictly conform to Diablo II v1.13c.
2. **Server Authority**: When `HudManager` has a `_commandSink` attached (`hasCommandSink === true`), user actions MUST emit typed `ClientCommand` events (`src/netproto/domain/client-command.ts`) encoded via `encodeClientCommand` (`src/netproto/d2gs/registry.ts`) without mutating local container/stat/skill state ahead of the server. `ServerEvent` updates applied to `ClientWorld` (`src/client/world/client-world.ts`) and synced via `HudModel.syncFromWorld()` (`src/client/ui-model/hud-model.ts`) drive all UI state.
3. **Strict Import Boundary**: Files in `src/client/**` MUST ONLY import protocol types/functions from `src/netproto/index.ts` (never deep-importing `src/netproto/d2gs/**`).
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/**` or `src/server/**`). Files in `src/common/**` MUST ONLY import from `src/common/**` and remain 100% deterministic.
---
## Feature Inventory
## Feature Inventory (Issue #550 — Spell Visual Effects)
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.
Every feature from the Phase 0 Survey (derived from `ORIGINAL_REQUEST.md` `2026-10-02T07:31:06Z` and Explorer Reports `r1_1`, `r1_2`, `r1_3`) is listed below with its assigned milestone. No feature is left unassigned.
| # | Feature | Description | Milestone | Source |
|---|---------|-------------|-----------|--------|
| F1 | Canonical 1.13c Online Item Tooltip Bridge (R1.1) | Bridge online `ItemData` (`item-bitstream.ts`) and socketed child items (`world.containers.sockets.get(item.id)`) through canonical 1.13c item tables (`getEmbeddedDropTables()`) and `ItemStatCost` descriptor formatter (`item-bridge.ts`, `item-tooltip.ts`, `hud-model.ts`). Resolve localized CHI/ENG base item names, Magic/Rare/Set/Unique/Runeword names, 1H/2H weapon damage, defense, durability, level/str/dex/class requirements, sorted `ItemStatCost` property lines (`stats` and `statsEn` instead of raw `Stat #N`), and socketed child item previews (`socketedItems`, `usedSockets`). | M1, M3 | R1.1, Survey 1 |
| F2 | C2S Container, Belt, NPC, Merc, Warp, Party & Trade Packet Encoders (R1.2, R2–R5) | Implement/complete C2S binary encoders and `ClientCommand` + `encodeClientCommand` (`registry.ts`) mappings matching `C2S_PACKET_SIZES`: `0x09 WarpTo` (9B), `0x1D SwapCursorBodyItem` (`ItemSwap`, 9B), `0x1F SwapContainerItem` (17B), `0x23 BeltPut` (9B), `0x24 BeltRemove` (5B), `0x25 BeltSwap` (9B), `0x27 IdentifyItem` (9B), `0x28 SocketItem` (9B), `0x29 ScrollToBook` (9B), `0x31 QuestMessage` (9B), `0x36 HireMerc` (`MercHire`, 9B), `0x38 EntityAction` (`NpcEntityAction`, 13B), `0x41 Resurrect` (1B), `0x4F Trade` (`ClickButton`, 7B), `0x5D Hostile` (7B with `button` parameter `1`=hostile, `2`=loot), `0x5E Party` (`PartyAction`, 6B), `0x61 MercItem` (3B), `0x62 NpcResurrectMerc` (`MercResurrect`, 5B). | M1 | R1.2, Acceptance Criteria, Survey 1–3 |
| F3 | Server-Authoritative Container, Belt, Identify, Socket & Tome UI Wiring (R1.2) | Wire `HudCommandSink`, `HudModel`, and `HudManager` so: (a) left-clicking an occupied equipment slot with a cursor item emits `0x28 SocketItem` (if socketable) or `0x1D ItemSwap` (9B); (b) left-clicking an occupied inventory/stash/cube cell with a cursor item emits `0x29 ScrollToBook` (matching scroll into tome), `0x28 SocketItem` (compatible filler into socketed item), or `0x1F SwapContainerItem` (17B); (c) left-clicking belt slots emits `0x24 BeltRemove` (empty cursor on occupied slot), `0x23 BeltPut` (cursor potion on empty slot), or `0x25 BeltSwap` (cursor potion on occupied slot); (d) right-clicking `isc`/`ibk` in online mode arms identify targeting mode and subsequent left-click on an unidentified item dispatches `0x27 IdentifyItem`. | M3 | R1.2, Survey 1 |
| F4 | Weapon Set II (`bodyLoc` 11 & 12) Sync (R1.3) | Map `bodyLoc` 11 (`rarm2`) and 12 (`larm2`) in `HudModel.syncFromWorld` / `syncInventory` to `hud.inventory.swapWeapons` (`weapon1` and `weapon2`) so inactive weapon slots remain synced across `0x60` / `0x97 WeaponSwitch`. | M3 | R1.3, Survey 1 |
| F5 | Act 1–5 Town & Quest NPC Descriptors (R2.1) | Complete `TOWN_NPC_DESCRIPTORS` and export `NPC_HCIDX_TO_DESCRIPTOR_ID` in `src/common/units/npc-table.ts` for all Deckard Cain `hcIdx` variants (`146`, `244`, `245`, `246`, `520`, `265`), Warriv Act 1 & Act 2 (`155`, `175`), Meshif Act 2 & Act 3 (`210`, `258`), Kaelan (`203`), Tyrael (`251`, `367`), Hadriel (`259`), and Qual-Kehk (`515`), including `canHire`, `travelActions`, `questRewardActions`, `gossipLines`, and `questLines`. | M1 | R2.1, Survey 2 |
| F6 | NPC Info (`0x27`), SetNPCTalk (`0x91`), Talk Submenu & `0x31 QuestMessage` (R2.2) | Process S2C `0x27 NPCInfo`, `0x91 SetNPCTalk`, and `0xAB NPCHeal` in `ClientWorld.apply` (`case 'Npc'`, preserving backward-compatible immediate menu opening on outgoing `NpcInit` for synthetic tests) [M1]. Upgrade the `Talk` (`交談`) menu in `WorldPanelsHud` and `HudModel` to support `Introduction` / `Gossip` / active Quest Speech topics with a scrollable `.tbl` speech dialog box and C2S `0x31 QuestMessage` dispatch [M3]. | M1, M3 | R2.2, Survey 2 |
| F7 | Act Travel Menu Options & C2S `0x38 EntityAction` (R2.3) | Add Act transition menu options (`travel_east`, `travel_west`, `travel_harrogath`) to Warriv (`155`, `175`), Meshif (`210`, `258`), and Tyrael (`367`) and wire them in `WorldPanelsHud` and `HudModel` to C2S `0x38 EntityAction` travel commands. | M3 | R2.3, Survey 2 |
| F8 | Mercenary World State (`0x4E`, `0x4F`, `0x9B` & `lifePct`) (R3.1) | In `ClientWorld.apply` (`case 'Merc'`), store `0x4E MercForHire` candidate entries (`nameId`, `seed`), clear the list on `0x4F MercForHireListStart`, store `0x9B MercReviveCost` (`reviveCost`, `deadNameId`), track active mercenary life percentage (`lifePct`), and fix `world.containers.merc` unequip-to-cursor handling. | M1 | R3.1, Survey 2 |
| F9 | Mercenary Hire (`0x36`) & Resurrect (`0x62`) UI (R3.2) | Wire the `Hire` NPC menu option (`Kashya` `150`, `Greiz` `198`, `Asheara` `252`, `Qual-Kehk` `515`) to send `0x38 EntityAction (action=3)`, open the Mercenary Hire panel populated from `world.merc.hireList`, and send `0x36 HireMerc` on selection. Show the `Resurrect` option when a dead mercenary is awaiting revival and dispatch `0x62 NpcResurrectMerc`. | M3 | R3.2, Survey 2 |
| F10 | Mercenary HUD Portrait & Paperdoll (`O` Hotkey, `0x61 MercItem`) (R3.3) | Render the active mercenary top-left HUD portrait and life bar in `HudManager`. Sync `world.containers.merc` into the Mercenary Paperdoll panel (`O` hotkey) in `HudModel` / `WorldPanelsHud` / `HudManager`, and dispatch `0x61 MercItem` (3B: `u8 0x61 \| u16 slot`) when clicking mercenary equipment slots. | M3 | R3.3, Survey 2 |
| F11 | Dungeon/Stair Warp Clicking (`0x09 AssignWarp` → C2S `0x09 WarpTo`) (R4.1) | Preserve `warpId` on `0x09 AssignWarp` in `s2c/units.ts` & `ClientUnit` [M1]; project `kind === 'warp'` units in `buildSceneDrawables` (`src/client/view/unit-drawables.ts`) and `ClientWorldSceneSource.pickUnitAtScreen` (`src/client/view/scene-source.ts`) with localized destination labels and emit `MoveToUnit (0x04)` + `WarpTo (0x09)` when clicking a warp entrance [M2]. | M1, M2 | R4.1, Survey 3 |
| F12 | Portal Ownership Hover (`0x60`/`0x82`) & Ground Item Labels (`0x16`) (R4.2) | Store ground item `goldAmount` (`item.quantity`) and resolved item names on `UnitState` / `ClientUnit` and `resolveGroundItemMetadata` [M1]. Enrich Town Portal objects (`classId` 59/60) with `world.portals.get(id)` (`ownerName`, destination `areaId`), replace hardcoded `'1 Gold'` in `world-renderer.ts`, and support `Alt` key ground item label display and click-to-pickup (`0x16`) [M2]. | M1, M2 | R4.2, Survey 3 |
| F13 | `SkillCast` (`0x4C`/`0x4D`/`0x6C`/`0x99`/`0x9A`) Animation (R4.3) | Handle `case 'SkillCast'` in `ClientWorld.apply` by updating the caster unit's active cast state, facing direction toward the target, and attack/cast animation mode [M1] so `unit-drawables.ts` and `world-renderer.ts` animate melee swings and spell casts [M2]. | M1, M2 | R4.3, Survey 3 |
| F14 | Player Death Overlay & C2S `0x41 Resurrect` (R4.4) | Detect player death (`world.self.life <= 0` or `selfUnit.mode === 0` after spawn) in `ClientWorld` [M1], render the classic D2 death overlay (`"You Have Died. Press ESC to continue."` / `"你挂了,请按 ESC 键继续"`) in `HudManager`, and emit `{ type: 'Resurrect' }` (C2S `0x41`) when pressing `Escape` or clicking the overlay [M2, M3]. | M1, M2, M3 | R4.4, Survey 3 |
| F15 | Multiplayer Party State & Party Panel (`P` Hotkey, `0x5D`/`0x5E`) (R5.1) | Handle `memberState` (`0x66`/`0x7F`), `relation` (`0x8B`/`0x8C`/`0x74`/`0x8E`), `memberPos` (`0x90`), and player leave (`0x5C`) in `ClientWorld.apply` [M1]. Implement automap party player markers in `world-renderer.ts` [M2] and the Party Screen (`P` hotkey) in `WorldPanelsHud` + `HudModel` + `HudManager` with Invite/Accept/Cancel/Leave (`0x5E Party`), Hostile toggle (`0x5D Hostile` button=1), Corpse Loot toggle (`0x5D Hostile` button=2), and top-left party portraits [M3]. | M1, M2, M3 | R5.1, Survey 3 |
| F16 | Player-to-Player Trade UI (`0x77`/`0x78`/`0x79` & C2S `0x4F`) & Quest Reward Item Panels (R5.2, R5.3) | Track `0x77 ButtonActions`, `0x78 TradeAccepted` (`partnerName`), and `0x79 GoldInTrade` in `ClientWorld.apply` [M1]. Render the 2-player Trade left-dock panel (`tradePartner` top 10×4 grid + partner gold, `tradeSelf` bottom 10×4 grid + self gold + Trade Accept check button sending `0x4F Trade`) and wire `imbue` (Charsi), `add_sockets` (Larzuk), and `personalize` (Anya) NPC menu actions to open the single-item quest reward box and dispatch the corresponding C2S action in `WorldPanelsHud` + `HudModel` + `HudManager` [M3]. | M1, M3 | R5.2, R5.3, Survey 2 & 3 |
| F17 | Canonical 1.13c Cast Overlays & Caster State Lifecycle (R1) | Display canonical 1.13c `Skills.txt` `castoverlay` (`Overlay.txt`, e.g. `fire_cast_1`, `fire_cast_2`, `ice_cast_1`, `ice_cast_2`, `ice_cast_3`, `light_cast_1`, `light_cast_2`, `teleport`, `bonearmor_cast`, `bonecast`, `cursecast`) on any casting unit (from server `SkillCast` `0x4C`/`0x4D`/`0x6C`/`0x99`/`0x9A` or local `useSkillAt`/`useSkillOn`/`attackUnit`) as a one-shot non-looping animation with dynamic point lighting; deduplicate local player casts against server `SkillCast` echoes within 250ms; expire `unit.castState` back to neutral mode (`1`) when cast duration finishes. | M5 | R1, Survey `r1_1`–`r1_3` |
| F18 | Client-Side Flying Missiles, `0x73 CreateMissile`, & 1.13c Trajectory Simulation (R2) | Preserve `at: { x, y }`, `level`, `owner` in `decode0x73CreateMissile` and spawn missile units (`unitType = 3`, `kind = 'missile'`) on `0x73` and spell casts; step at 25Hz according to 1.13c `Skills.txt` & `Missiles.txt` (`Vel`, `Range`, `LevRange`) for single-bolt projectiles (`Fire Bolt` 36, `Ice Bolt` 39, `Ice Blast` 45, `Fire Ball` 47, `Glacial Spike` 55, `Lightning` 49, `Chain Lightning` 53, `Bone Spear` 84, `Bone Spirit` 93, `Holy Bolt` 101, bow/crossbow skills), multi-projectile fans (`Charged Bolt` 38 with orthogonal jitter, `Teeth` 67, `Multiple Shot` 12), radial rings (`Frost Nova` 44, `Nova` 48, `Poison Nova` 92), spiral trajectories (`Blessed Hammer` 112), sub-missile emitters (`Frozen Orb` 64 emitting radial `icebolt`s + final nova), and ground/skyfall spells (`Fire Wall` 51, `Blaze` 46, `Meteor` 56, `Blizzard` 59); map velocity vectors to authentic 1.13c DCC directions (`DIR64_TO_DCC` / `velocityToDccDirection`). | M5 | R2, Survey `r1_1`–`r1_3` |
| F19 | Collision Detection & Spell Hit / Impact Explosion Visuals (R3) | Detect missile collisions against blocked map wall/obstacle sub-tiles and alive hostile units (respecting `pierce`); terminate non-piercing missiles on impact (or range expiry for `alwaysExplode` / `ExplosionMissile` missiles) and spawn canonical 1.13c `ExplosionMissile` / hit effect (`fireexplode` for `Fire Bolt`, `explodingarrowexp` for `Fire Ball`, `iceexplode` for `Ice Bolt`, `freezeexplode` for `Ice Blast`/`Glacial Spike`, `frozenorbexplode` for `Frozen Orb`, `meteorexplode` for `Meteor`, `teethexplode` for `Teeth`/`Bone Spear`, `bonespiritexplode` for `Bone Spirit`, `lightninghit` for `Lightning`/`Chain Lightning`/`Static Field`/`Telekinesis`) as a one-shot non-looping visual (`loop: false`, `loopAnim: false`) with dynamic point lighting and frame clamping (`totalFrames - 1`) that is removed upon completion. | M5 | R3, Survey `r1_1`–`r1_3` |
| F20 | Scene Drawables, `WorldRenderer` One-Shot Clamping, Automated Tests & Gitea PR Merge (R4) | Output `SceneOverlayDrawable` and `SceneMissileDrawable` with `loop: false` / `loopAnim: false` one-shot frame clamping and dynamic point lights in `unit-drawables.ts` and `world-renderer.ts`; verify with automated tests (`npx vitest run`) and production build (`npm run build`); commit, push `fix/issue-550-spell-visual-effects`, open Gitea PR (`Closes #550`), and merge into `main`. | M5 | R4, Survey `r1_1`–`r1_3` |
---
@ -68,106 +59,29 @@ Every feature from the Phase 0 Survey (derived from `ORIGINAL_REQUEST.md` `2026-
| # | Name | Scope | Dependencies | Status |
|---|------|-------|-------------|--------|
| M1 | Protocol Layer, Common Item/NPC Tables & `ClientWorld` Authoritative State (`src/netproto/`, `src/common/`, `src/client/world/`) | F1 (bridge helper), F2, F5, and state layer of F6, F8, F11, F12, F13, F14, F15, F16: Complete all C2S encoders (`0x09`, `0x1D`, `0x1F`, `0x23`–`0x29`, `0x31`, `0x36`, `0x38`, `0x41`, `0x4F`, `0x5D` with `button`, `0x5E`, `0x61`, `0x62`), `ClientCommand` & `encodeClientCommand` (`registry.ts`), S2C `0x09` `warpId` & `0x78` `partnerName`, `TOWN_NPC_DESCRIPTORS` + `NPC_HCIDX_TO_DESCRIPTOR_ID` (`npc-table.ts`), canonical 1.13c `itemDataToUiInventoryItem` converter (`item-bridge.ts`), `goldAmount` resolution (`ground-items.ts`), and all `ClientWorld.apply` reducers (`Npc`, `Merc`, `SkillCast`, `Party`, `Trade`, `isDead`, ground `goldAmount`, warp `warpId`). Verified in `tests/client/client-world.test.ts` & `tests/netproto/d2gs-{c2s,s2c}.test.ts` (14/14 PASS, 0 TS errors). | none | DONE |
| M2 | Scene View, Warp Picking, Portals, Ground Item Labels & `SkillCast` Visuals (`src/client/view/`, `src/client/scene/`, `src/client/session/`, `src/client/input/`) | F11, F12, F13, F14 (Escape/input), F15 (automap markers): Project `kind === 'warp'` in `unit-drawables.ts` and `ClientWorldSceneSource.pickUnitAtScreen` (`scene-source.ts`) with localized destination labels emitting `MoveToUnit (0x04)` + `WarpTo (0x09)`; enrich Town Portal (`classId` 59/60) with `world.portals.get(id)` (`ownerName`, `areaId`); propagate ground `goldAmount` and resolved item names to `UnitState` & `world-renderer.ts` (replacing `'1 Gold'`) with `Alt` label display & click-to-pickup (`0x16`); animate `SkillCast` melee swings & spell casts; add automap party/portal markers. Verified in `tests/client/hud-session-play.test.ts` (5/5 PASS, 0 TS errors). | M1 | DONE |
| M3 | HUD Model, `HudManager` & `WorldPanelsHud` — Tooltips, Containers, NPC Dialogue/Travel, Mercs, Death Overlay, Party, Trade & Quest Rewards (`src/client/ui-model/`, `src/client/ui/`) | F1, F3, F4, F6, F7, F9, F10, F14 (HUD overlay), F15 (Party UI & portraits), F16: Wire canonical 1.13c online item tooltips (`stats`, `statsEn`, `socketedItems`, `usedSockets`) & `bodyLoc` 11/12 `swapWeapons`; wire `0x1D`, `0x1F`, `0x23`–`0x29` container/belt/identify/socket/tome clicks; upgrade `Talk` submenu (`Introduction`/`Gossip`/Quest speech + `.tbl` dialog + `0x31 QuestMessage`) & Act Travel (`travel_east`/`travel_west`/`travel_harrogath` -> `0x38`); wire Mercenary `Hire` (`0x38` action=3 -> `0x36`), `Resurrect` (`0x62`), top-left Merc portrait, and `O` Paperdoll (`0x61 MercItem`); render Player Death overlay & `0x41 Resurrect`; implement `P` Party Screen (`0x5E`, `0x5D` button=1/2) & party portraits; implement 2-player Trade panel (`0x4F`) & Quest Reward panels (`imbue`, `add_sockets`, `personalize`). Verified in `tests/client/hud-server-authority.test.ts` and `tests/client/vendor-stash-ui.test.ts` (45/45 PASS). | M1 | DONE |
| M4 | Final Milestone: Pass 100% E2E Test Suite (Tiers 1–4) & Adversarial Coverage Hardening (Tier 5) | Phase 1: Wait for `TEST_READY.md`, run and pass 100% of E2E tests (`tests/e2e-d2gs-online/**` Tiers 1–4) and all unit/integration/boundary suites + `npx tsc --noEmit`. Phase 2: White-box Tier 5 adversarial coverage hardening (`tests/e2e-d2gs-online/tier5-adversarial-hardening.test.ts`, 204/204 E2E tests PASS, 269/269 14-file verification suite PASS, 0 TS errors; Unanimous 5/5 Verification Gate PASS across 2 Reviewers, 2 Challengers, and 1 Forensic Auditor). | M2, M3, E2E | DONE |
| E2E | E2E Testing Track (`TEST_INFRA.md`, Tiers 1–4 Test Suite, `TEST_READY.md`) | Design and create requirement-driven, opaque-box 4-tier E2E test suite in `tests/e2e-d2gs-online/` covering all 16 features (`F1`–`F16`) with `80` Tier 1 tests, `80` Tier 2 tests, `16` Tier 3 tests, and `8` Tier 4 application scenarios (`184` total, plus `20` Tier 5 tests = `204` total); publish `TEST_INFRA.md` and `TEST_READY.md`. | none | DONE |
| M1–M4, E2E | Prior D2GS v1.13c Online Gameplay Milestones | F1–F16 (Completed in prior cycle) | none | DONE |
| M5 | Issue #550: Restore 1.13c Spell Visual Effects (Cast Overlays, Flying Missiles, Hit Explosions, Verification & PR Merge) | F17, F18, F19, F20 across `src/netproto/`, `src/common/skills/`, `src/client/world/`, `src/client/session/`, `src/client/view/`, `src/client/scene/`, and `tests/client/` | M1–M4 | IN_PROGRESS |
---
## Interface Contracts
## Interface Contracts (Issue #550)
### 1. `src/netproto/` ↔ `src/client/` (`ClientCommand` & C2S Encoders)
All newly added and updated C2S encoders MUST produce a `Uint8Array` whose `byteLength` equals `C2S_PACKET_SIZES[opcode]` in `src/netproto/d2gs/tables/s2c-sizes.ts`:
- `0x09 WarpTo` (9B: `u8 0x09 | u32LE unitType(5) | u32LE warpId`):
- `encodeC2sWarpTo(warpId: number, unitType?: number): Uint8Array`
- `ClientCommand`: `{ readonly type: 'TakeWarp'; readonly warpId: number }` and `{ readonly type: 'WarpTo'; readonly warpId: number; readonly unitType?: number }` (both encoded as opcode `0x09`, 9B).
- `0x1D SwapCursorBodyItem` (9B: `u8 0x1D | u32LE itemId | u16LE slot | u16LE 0`):
- `encodeC2sItemSwap(itemId: number, slot: number): Uint8Array`
- `ClientCommand`: `{ readonly type: 'ItemSwap'; readonly itemId: number; readonly slot: number }`
- `0x1F SwapContainerItem` (17B: `u8 0x1F | u32LE cursorItemId | u32LE targetItemId | u32LE x | u32LE y`):
- `encodeC2sSwapContainerItem(cursorItemId: number, targetItemId: number, x: number, y: number): Uint8Array`
- `ClientCommand`: `{ readonly type: 'SwapContainerItem'; readonly cursorItemId: number; readonly targetItemId: number; readonly x: number; readonly y: number }`
- `0x23 BeltPut` (9B: `u8 0x23 | u32LE itemId | u32LE slot`):
- `encodeC2sBeltPut(itemId: number, slot: number): Uint8Array`
- `ClientCommand`: `{ readonly type: 'BeltPut'; readonly itemId: number; readonly slot: number }`
- `0x24 BeltRemove` (5B: `u8 0x24 | u32LE itemId`):
- `encodeC2sBeltRemove(itemId: number): Uint8Array`
- `ClientCommand`: `{ readonly type: 'BeltRemove'; readonly itemId: number }`
- `0x25 BeltSwap` (9B: `u8 0x25 | u32LE cursorItemId | u32LE beltItemId`):
- `encodeC2sBeltSwap(cursorItemId: number, beltItemId: number): Uint8Array`
- `ClientCommand`: `{ readonly type: 'BeltSwap'; readonly cursorItemId: number; readonly beltItemId: number }`
- `0x27 IdentifyItem` (9B: `u8 0x27 | u32LE itemId | u32LE scrollId`):
- `encodeC2sIdentifyItem(itemId: number, scrollId: number): Uint8Array`
- `ClientCommand`: `{ readonly type: 'IdentifyItem'; readonly itemId: number; readonly scrollId: number }`
- `0x28 SocketItem` (9B: `u8 0x28 | u32LE itemId | u32LE targetItemId`):
- `encodeC2sSocketItem(itemId: number, targetItemId: number): Uint8Array`
- `ClientCommand`: `{ readonly type: 'SocketItem'; readonly itemId: number; readonly targetItemId: number }`
- `0x29 ScrollToBook` (9B: `u8 0x29 | u32LE scrollId | u32LE bookId`):
- `encodeC2sScrollToBook(scrollId: number, bookId: number): Uint8Array`
- `ClientCommand`: `{ readonly type: 'ScrollToBook'; readonly scrollId: number; readonly bookId: number }`
- `0x31 QuestMessage` (9B: `u8 0x31 | u32LE npcId | u32LE messageId`):
- `encodeC2sQuestMessage(npcId: number, messageId: number): Uint8Array`
- `ClientCommand`: `{ readonly type: 'QuestMessage'; readonly npcId: number; readonly messageId: number }`
- `0x36 HireMerc` (9B: `u8 0x36 | u32LE npcId | u32LE mercId`):
- `encodeC2sNpcMercHire(npcId: number, mercId: number): Uint8Array`
- `ClientCommand`: `{ readonly type: 'MercHire'; readonly npcId: number; readonly mercId: number }`
- `0x38 EntityAction` (13B: `u8 0x38 | u32LE action | u32LE npcId | u32LE complement`):
- `encodeC2sNpcEntityAction(action: number, npcId: number, complement?: number): Uint8Array`
- `ClientCommand`: `{ readonly type: 'NpcEntityAction'; readonly action: number; readonly npcId: number; readonly complement?: number }` (in addition to `NpcOpenTrade` and `NpcOpenGamble`)
- `0x41 Resurrect` (1B: `u8 0x41`):
- `encodeC2sResurrect(): Uint8Array`
- `ClientCommand`: `{ readonly type: 'Resurrect' }`
- `0x4F Trade` / `ClickButton` (7B: `u8 0x4F | u16LE buttonId | u16LE amountLow | u16LE amountHigh`):
- `encodeC2sClickButton(buttonId: number, complement?: number): Uint8Array`
- `ClientCommand`: `{ readonly type: 'ClickButton'; readonly buttonId: number; readonly complement?: number }`
- `0x5D Hostile` (7B: `u8 0x5D | u8 button | u8 state | u32LE targetPlayerId`):
- `encodeC2sHostile(targetPlayerId: number, hostile: boolean, button?: number): Uint8Array`
- `ClientCommand`: `{ readonly type: 'Hostile'; readonly targetPlayerId: number; readonly hostile: boolean; readonly button?: number }` (`button = 1` for Hostile toggle, `button = 2` for Corpse Loot toggle)
- `0x5E Party` (6B: `u8 0x5E | u8 action | u32LE targetPlayerId`):
- `encodeC2sPartyAction(action: number, targetPlayerId: number): Uint8Array`
- `ClientCommand`: `{ readonly type: 'PartyAction'; readonly action: number; readonly targetPlayerId: number }`
- `0x61 MercItem` (3B: `u8 0x61 | u16LE slot`):
- `encodeC2sMercItem(slot: number): Uint8Array`
- `ClientCommand`: `{ readonly type: 'MercItem'; readonly slot: number }`
- `0x62 NpcResurrectMerc` (5B: `u8 0x62 | u32LE npcId`):
- `encodeC2sNpcMercResurrect(npcId: number): Uint8Array`
- `ClientCommand`: `{ readonly type: 'MercResurrect'; readonly npcId: number }`
### 1. `src/netproto/domain/server-event.ts` & `src/netproto/d2gs/s2c/misc.ts`
- `SkillCast` variant on `ServerEvent` includes optional `at?: Pt | undefined` and `owner?: UnitRef | undefined`.
- `decode0x73CreateMissile` preserves `at: { x, y }`, `level: level || 1`, `owner: { unitType: ownerType, id: ownerId }`, and `target: { x: targetX || x, y: targetY || y }`.
### 2. `src/common/units/npc-table.ts` ↔ `src/client/`
- `TownNpcServiceDescriptor` includes:
- `canHire?: boolean` (`150` Kashya, `198` Greiz, `252` Asheara, `515` Qual-Kehk)
- `travelActions?: readonly ('travel_east' | 'travel_west' | 'travel_harrogath')[]` (`155` Warriv Act 1 -> `['travel_east']`, `175` Warriv Act 2 -> `['travel_west']`, `210` Meshif Act 2 -> `['travel_east']`, `258` Meshif Act 3 -> `['travel_west']`, `367` Tyrael Act 4 -> `['travel_harrogath']`)
- `questRewardActions?: readonly ('imbue' | 'add_sockets' | 'personalize')[]` (`154` Charsi -> `['imbue']`, `511` Larzuk -> `['add_sockets']`, `512` Anya -> `['personalize']`)
- `gossipLines?: readonly string[]`
- `questLines?: Readonly<Record<string, string>>`
- `TOWN_NPC_DESCRIPTORS` and `NPC_HCIDX_TO_DESCRIPTOR_ID` cover all Act 1–5 Town & Quest NPC `hcIdx` values, including Cain (`146`, `244`, `245`, `246`, `520`, `265`), Warriv (`155`, `175`), Meshif (`210`, `258`), Kaelan (`203`), Tyrael (`251`, `367`), Hadriel (`259`), and Qual-Kehk (`515`).
### 2. `ClientWorld` (`src/client/world/client-world.ts`) & `OnlineSession` (`src/client/session/online-session.ts`)
- `OnlineSession` exposes `useSkillAt(x, y, skillId?, hand?, shift?)`, `useSkillOn(unitId, unitType?, skillId?, hand?, shift?)`, and `attackUnit(unitId, unitType?, shift?)`.
- `OnlineSession.tick(dtMs, nowMs)` invokes `this.world.tick(dtMs, ...)` with map walkability/wall collision check when `levelView` is loaded (returning `false` / unblocked when `levelView === null`).
- `OnlineSession.buildRenderableUnits(nowMs)` includes `this.world.getUnitsByType(3)` (missiles) and active caster overlays.
- `ClientWorld` deduplicates local player skill casts against server `SkillCast` echoes within `250ms`, expires `unit.castState` back to `mode = 1`, steps missiles at 25Hz, checks wall and hostile unit collisions, respects `pierce`, spawns one-shot `ExplosionMissile` units, and removes completed one-shot explosions/overlays.
### 3. `ClientWorld` (`src/client/world/client-world.ts`) ↔ `HudModel` & `SceneSource`
- **Mercenary state (`world.merc`)**:
- `hireList: readonly { nameId: number; seed: number }[]` (cleared on `0x4F`, appended on `0x4E`)
- `reviveCost: number`, `deadNameId: number` (populated on `0x9B MercReviveCost`)
- `lifePct: number` (updated on `0x0C`, `0xAB`, `0x69`, `0x81`, `0x9E..0xA0`)
- **NPC interaction state (`world.npcInteraction`)**:
- Stores `0x27 NPCInfo` (`npcInfoRaw`) and `0x91 SetNPCTalk` (`npcTalkRaw`), while keeping immediate `npcId` assignment on outgoing `NpcInit` for synthetic tests.
- Supports `window: 'none' | 'trade' | 'gamble' | 'hire' | 'imbue' | 'add_sockets' | 'personalize'`.
- **Warp, Portal, Ground Item, `SkillCast` & Death state**:
- `kind === 'warp'` / `unitType === 'tile'` units carry `warpId` and localized destination label; `buildSceneDrawables` (`unit-drawables.ts`) and `ClientWorldSceneSource.pickUnitAtScreen` (`scene-source.ts`) expose them as `'warp'` so clicking emits `MoveToUnit (0x04)` + `TakeWarp`/`WarpTo (0x09)`.
- Town Portal objects (`classId` `59`/`60`) join `world.portals.get(id)` (`ownerName`, `areaId`).
- Ground item units carry `goldAmount` (`item.quantity`) and resolved item names.
- `case 'SkillCast'` updates caster unit `castState`, facing `dir`, and attack/cast animation mode (`'A1'`/`'SC'`).
- Player death (`world.self.life <= 0` or `selfUnit.mode === 0` after spawn) sets `isDead`, renders the death overlay (`"You Have Died. Press ESC to continue."` / `"你挂了,请按 ESC 键继续"`), and emits `{ type: 'Resurrect' }` (`0x41`) on `Escape` or overlay click.
- **Party & Trade state**:
- `world.party` tracks `memberState` (`0x66`/`0x7F`), `relation` (`0x8B`/`0x8C`/`0x74`/`0x8E`), `memberPos` (`0x90`), and removes leaving players (`0x5C`).
- `world.trade` tracks `0x77 ButtonActions`, `0x78 TradeAccepted` (`partnerName`), and `0x79 GoldInTrade` (`selfGold`, `partnerGold`), with `tradePartner` and `tradeSelf` 10×4 item grids synced into `WorldPanelsHud`.
### 3. `unit-drawables.ts` & `world-renderer.ts`
- `SceneOverlayDrawable` and `SceneMissileDrawable` carry `loop: boolean`, `loopAnim: boolean`, `totalFrames: number`, and clamp `frame` to `[0, totalFrames - 1]` when `!loop` (`loop === false` / `loopAnim === false`).
- `MISSILE_ID_TO_KEY` preserves exact `Missiles.txt` row names (`tests/client/missile-id-map.test.ts`); `unit.token` (`missileKey`) is passed to `resolveMissileMetadata(unit.classId, unit.token)` for client aliases such as `freezeexplode`.
---
## Code Layout & Write Ownership
- **M1 (`sub_orch_m1_d2gs`)**: Owns `src/netproto/**`, `src/common/**`, `src/client/world/**`, and `tests/client/client-world.test.ts` (plus any `tests/netproto/**` packet tests).
- **M2 (`sub_orch_m2_d2gs`)** *(after M1 completes; runs in parallel with M3)*: Owns `src/client/view/**`, `src/client/scene/**`, `src/client/session/**`, `src/client/input/**`, and `tests/client/hud-session-play.test.ts`.
- **M3 (`sub_orch_m3_d2gs`)** *(after M1 completes; runs in parallel with M2)*: Owns `src/client/ui-model/**`, `src/client/ui/**`, `tests/client/hud-server-authority.test.ts`, and `tests/client/vendor-stash-ui.test.ts`.
- **E2E (`sub_orch_e2e_d2gs`)** *(runs in parallel with M1–M3)*: Owns EXCLUSIVELY `/usr/local/google/home/taodao/diablo2-web/TEST_INFRA.md`, `/usr/local/google/home/taodao/diablo2-web/TEST_READY.md`, and `/usr/local/google/home/taodao/diablo2-web/tests/e2e-d2gs-online/**`.
- **M4 (`sub_orch_m4_d2gs`)** *(after M2, M3, and E2E complete)*: Owns full-repo E2E verification and Tier 5 adversarial coverage hardening.
- **M5 Worker (`teamwork_preview_worker_r1_1`)**: Owns `src/netproto/domain/server-event.ts`, `src/netproto/d2gs/s2c/misc.ts`, `src/common/skills/**`, `src/client/world/**`, `src/client/session/online-session.ts`, `src/client/view/unit-drawables.ts`, `src/client/scene/world-renderer.ts`, `src/client/scene/missile-overlay-renderer.ts`, `tests/client/client-world.test.ts`, and `tests/client/hud-session-play.test.ts`.

View File

@ -1700,6 +1700,11 @@ export class OnlineSession {
: 1,
isLocalPlayer: true,
isCorpse: isSelfDead,
states: selfUnit
? Array.from(selfUnit.states.list())
: this.world.self.states.size > 0
? Array.from(this.world.self.states)
: undefined,
...(selfCastState !== undefined ? { castState: selfCastState } : {}),
...(selfUnit && selfUnit.overlays.length > 0 ? { overlays: selfUnit.overlays } : {}),
...(selfAnimStartedAtMs !== undefined ? { animStartedAtMs: selfAnimStartedAtMs } : {}),

View File

@ -68,7 +68,7 @@ export interface RenderableUnitInput {
readonly token?: string | undefined
readonly compositeArmor?: Readonly<Record<string, string>> | undefined
readonly overlays?: readonly UnitOverlayState[] | undefined
readonly states?: ReadonlySet<number> | readonly number[] | undefined
readonly states?: ReadonlySet<number> | readonly number[] | Iterable<number> | undefined
readonly isHovered?: boolean | undefined
readonly isCorpse?: boolean | undefined
readonly quality?: number | undefined
@ -339,7 +339,16 @@ export interface BuildSceneDrawablesResult {
* dynamic 1.13c `PointLightInput` entries for `applyLightingInputs`.
*/
export function buildSceneDrawables(
units: Iterable<RenderableUnitInput | UnitSnapshot>,
unitsOrInput:
| Iterable<RenderableUnitInput | UnitSnapshot>
| {
readonly units: Iterable<RenderableUnitInput | UnitSnapshot>
readonly originX?: number | undefined
readonly originY?: number | undefined
readonly tick?: number | undefined
readonly elapsedMs?: number | undefined
readonly selfUnitId?: number | undefined
},
optionsOrWidth?:
| {
readonly originX?: number | undefined
@ -349,10 +358,27 @@ export function buildSceneDrawables(
| number,
_height?: number,
): BuildSceneDrawablesResult {
const options = typeof optionsOrWidth === 'object' ? optionsOrWidth : undefined
const isWrapper =
unitsOrInput !== null &&
typeof unitsOrInput === 'object' &&
!(Symbol.iterator in unitsOrInput) &&
'units' in unitsOrInput
const units: Iterable<RenderableUnitInput | UnitSnapshot> = isWrapper
? unitsOrInput.units
: (unitsOrInput as Iterable<RenderableUnitInput | UnitSnapshot>)
const options =
typeof optionsOrWidth === 'object'
? optionsOrWidth
: isWrapper
? unitsOrInput
: undefined
const originX = options?.originX ?? 0
const originY = options?.originY ?? 0
const tick = options?.tick ?? 0
const tick =
options?.tick ??
(isWrapper && typeof unitsOrInput.elapsedMs === 'number'
? Math.floor(unitsOrInput.elapsedMs / 40)
: 0)
const unitDrawables: SceneUnitDrawable[] = []
const missileDrawables: SceneMissileDrawable[] = []

View File

@ -261,6 +261,16 @@ const CURSE_TARGET_OVERLAYS: Readonly<Record<number, string>> = Object.freeze({
91: 'curselowerresist',
})
/** Canonical 1.13c self-buff skills that set an active state ID on the caster (States.txt). */
const SELF_BUFF_SKILL_STATES: Readonly<Record<number, number>> = Object.freeze({
40: 10, // Frozen Armor -> state 10 (FrozenArmor)
50: 88, // Shiver Armor -> state 88 (ShiverArmor)
52: 16, // Enchant -> state 16 (Enchant)
58: 30, // Energy Shield -> state 30 (EnergyShield)
60: 20, // Chilling Armor -> state 20 (ChillingArmor)
117: 101, // Holy Shield -> state 101 (HolyShield)
})
export interface SpawnMissileOptions {
readonly id?: number | undefined
readonly classId?: number | undefined
@ -488,7 +498,7 @@ export class ClientWorld {
const cleanKey = missileKey.trim().toLowerCase()
const data = getMissileTxtData(cleanKey)
const canonical = CANONICAL_113C_MISSILES[cleanKey]
const classId = opts?.classId ?? (canonical?.id && canonical.id > 0 ? canonical.id : data.id || 1)
const classId = opts?.classId ?? canonical?.id ?? data.id ?? 0
const id = opts?.id ?? this._nextMissileId++
const level = Math.max(1, opts?.skillLevel ?? 1)
@ -600,7 +610,7 @@ export class ClientWorld {
// 2. Multi-projectile fan spells: Charged Bolt (38), Teeth (67), Multiple Shot (12)
if (skillId === 38 || skillId === 67 || skillId === 12) {
const missileKey =
skillId === 38 ? 'chargedbolt' : skillId === 67 ? 'teeth' : 'multiple shot'
skillId === 38 ? 'chargedbolt' : skillId === 67 ? 'teeth' : 'multipleshotarrow'
const count =
skillId === 38
? Math.min(24, 2 + Math.max(1, skillLevel))
@ -889,6 +899,13 @@ export class ClientWorld {
if (castOverlayToken) {
this.attachUnitOverlay(unit, castOverlayToken, false)
}
const buffStateId = SELF_BUFF_SKILL_STATES[skillId]
if (buffStateId !== undefined) {
unit.states.add(buffStateId)
if (isSelf) {
this._self.states.add(buffStateId)
}
}
const effectiveLevel =
unit.unitType === 'player' && (unit.id === this.selfId || this.selfId === 0)
? Math.max(1, this._self.skills.get(skillId) ?? skillLevel)
@ -1611,6 +1628,10 @@ export class ClientWorld {
}
}
listUnits(): ClientUnit[] {
return Array.from(this._units.values())
}
getUnit(unitType: UnitType, id: number): ClientUnit | undefined {
return this._units.get(makeUnitKey(unitType, id))
}

View File

@ -46,6 +46,10 @@ export class UnitStateSet {
return Array.from(this.active).sort((a, b) => a - b)
}
[Symbol.iterator](): IterableIterator<number> {
return this.active.values()
}
get size(): number {
return this.active.size
}

View File

@ -1744,6 +1744,28 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
distancePx: 960,
clientAlias: true,
},
'multiple shot': {
name: 'multipleshotarrow',
id: 213,
vel: 24,
maxVel: 24,
range: 50,
levRange: 0,
celFile: 'Arrow',
animLen: 1,
animSpeed: 16,
loopAnim: 0,
numDirections: 32,
explosionMissile: '',
light: 0,
red: 255,
green: 255,
blue: 255,
speedPxPerSec: 600,
trans: 0,
distancePx: 1200,
clientAlias: true,
},
})
/**

View File

@ -878,6 +878,57 @@ describe('ClientWorld authoritative state mirror', () => {
u => u.unitType === 'missile' && u.missileKey === 'frozenorbbolt',
)
expect(subBolts.length).toBeGreaterThanOrEqual(4)
// 6. Multiple Shot (skill 12, level 5 -> 6 multipleshotarrow missiles in a fan, classId=213)
const msWorld = new ClientWorld()
msWorld.apply({
type: 'UnitAssign',
unit: { unitType: 0, id: 1, classId: 0, name: 'AmaHero', x: 5000, y: 5000 },
isSelf: true,
})
msWorld.apply({
type: 'SkillCast',
caster: { unitType: 0, id: 1 },
skill: 12,
skillId: 12,
level: 5,
target: { x: 5020, y: 5000 },
})
const multiShotArrows = Array.from(msWorld.units.values()).filter(
u => u.unitType === 'missile' && u.missileKey === 'multipleshotarrow',
)
expect(multiShotArrows).toHaveLength(6)
expect(multiShotArrows.every(m => m.classId === 213)).toBe(true)
// 7. Challenge 3: spawnMissileByKey('arrow') without opts.classId preserves classId === 0
const arrowUnit = msWorld.spawnMissileByKey('arrow', 5000, 5000, 5020, 5000)
expect(arrowUnit.classId).toBe(0)
expect(arrowUnit.missileKey).toBe('arrow')
// 8. Full R2 skill sweep: all 36 R2 skill IDs cast and step at 25Hz without throwing
const allR2Skills = [
6, 7, 11, 12, 16, 21, 22, 26, 27, 31, 36, 38, 39, 42, 43, 44, 45, 46,
47, 48, 49, 51, 53, 54, 55, 56, 59, 64, 66, 67, 84, 92, 93, 101, 112, 121,
]
for (const skillId of allR2Skills) {
const sweepWorld = new ClientWorld()
sweepWorld.apply({
type: 'UnitAssign',
unit: { unitType: 0, id: 1, classId: 1, name: 'Caster', x: 5000, y: 5000 },
isSelf: true,
})
expect(() => {
sweepWorld.apply({
type: 'SkillCast',
caster: { unitType: 0, id: 1 },
skill: skillId,
skillId,
level: 3,
target: { x: 5020, y: 5005 },
})
sweepWorld.tick(40, 1000)
}).not.toThrow()
}
})
it('R3: detects wall and hostile monster collisions, respects pierce, and spawns one-shot 1.13c ExplosionMissile visuals', () => {

View File

@ -1109,6 +1109,38 @@ describe('Milestone M5 — HudModel, CommandMapper, and OnlineSession', () => {
expect(snapAfter.entities.missileDrawables).toHaveLength(0)
expect(snapAfter.entities.overlayDrawables).toHaveLength(0)
// Challenge 2a & 2b: Local player state overlays (FrozenArmor=10, EnergyShield=30) propagate
// through OnlineSession.buildRenderableUnits() AND when passing ClientUnit (UnitStateSet)
// directly into buildSceneDrawables.
session.handleServerEvent({
type: 'StateSet',
unit: { unitType: 0, id: 1 },
state: 10, // FrozenArmor
})
session.handleServerEvent({
type: 'StateSet',
unit: { unitType: 0, id: 1 },
state: 30, // EnergyShield
})
const selfRenderUnit = session
.buildRenderableUnits()
.find((u) => u.id === 1 && u.kind === 'player')
expect(selfRenderUnit?.states).toEqual([10, 30])
const snapBuffs = session.sceneSource.buildFrameSnapshot()
expect(
snapBuffs.entities.overlayDrawables.some((o) => o.token === 'FrozenArmor' && o.loop === true),
).toBe(true)
expect(
snapBuffs.entities.overlayDrawables.some((o) => o.token === 'EnergyShield' && o.loop === true),
).toBe(true)
// Direct ClientUnit (UnitStateSet iterable) input to buildSceneDrawables:
const directDrawables = buildSceneDrawables(session.world.units.values(), { tick: 3 })
expect(
directDrawables.overlayDrawables.some((o) => o.token === 'FrozenArmor' && o.loop === true),
).toBe(true)
session.destroy()
})
})