From 3feb58fcfca65f9acaa346486ed21b867314e812 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 2 Oct 2026 13:12:50 +0000 Subject: [PATCH] fix(client): fix Multiple Shot missile key, UnitStateSet iteration, local player state overlays, and arrow classId (#550) --- PROJECT.md | 150 ++++++-------------------- src/client/session/online-session.ts | 5 + src/client/view/unit-drawables.ts | 34 +++++- src/client/world/client-world.ts | 25 ++++- src/client/world/states.ts | 4 + src/common/skills/missiles-data.ts | 22 ++++ tests/client/client-world.test.ts | 51 +++++++++ tests/client/hud-session-play.test.ts | 32 ++++++ 8 files changed, 199 insertions(+), 124 deletions(-) diff --git a/PROJECT.md b/PROJECT.md index d90c9ff..52d674e 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -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>` -- `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`. diff --git a/src/client/session/online-session.ts b/src/client/session/online-session.ts index c4fa7d7..db46261 100644 --- a/src/client/session/online-session.ts +++ b/src/client/session/online-session.ts @@ -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 } : {}), diff --git a/src/client/view/unit-drawables.ts b/src/client/view/unit-drawables.ts index 93cce5c..72e42a7 100644 --- a/src/client/view/unit-drawables.ts +++ b/src/client/view/unit-drawables.ts @@ -68,7 +68,7 @@ export interface RenderableUnitInput { readonly token?: string | undefined readonly compositeArmor?: Readonly> | undefined readonly overlays?: readonly UnitOverlayState[] | undefined - readonly states?: ReadonlySet | readonly number[] | undefined + readonly states?: ReadonlySet | readonly number[] | Iterable | 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, + unitsOrInput: + | Iterable + | { + readonly units: Iterable + 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 = isWrapper + ? unitsOrInput.units + : (unitsOrInput as Iterable) + 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[] = [] diff --git a/src/client/world/client-world.ts b/src/client/world/client-world.ts index ef8d173..c20f1f4 100644 --- a/src/client/world/client-world.ts +++ b/src/client/world/client-world.ts @@ -261,6 +261,16 @@ const CURSE_TARGET_OVERLAYS: Readonly> = 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> = 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)) } diff --git a/src/client/world/states.ts b/src/client/world/states.ts index 2499650..cceba37 100644 --- a/src/client/world/states.ts +++ b/src/client/world/states.ts @@ -46,6 +46,10 @@ export class UnitStateSet { return Array.from(this.active).sort((a, b) => a - b) } + [Symbol.iterator](): IterableIterator { + return this.active.values() + } + get size(): number { return this.active.size } diff --git a/src/common/skills/missiles-data.ts b/src/common/skills/missiles-data.ts index 4dd0eed..d085d8f 100644 --- a/src/common/skills/missiles-data.ts +++ b/src/common/skills/missiles-data.ts @@ -1744,6 +1744,28 @@ export const CANONICAL_113C_MISSILES: Readonly> = 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, + }, }) /** diff --git a/tests/client/client-world.test.ts b/tests/client/client-world.test.ts index 5e86cf2..5768f0c 100644 --- a/tests/client/client-world.test.ts +++ b/tests/client/client-world.test.ts @@ -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', () => { diff --git a/tests/client/hud-session-play.test.ts b/tests/client/hud-session-play.test.ts index 3d4666a..0c4e74a 100644 --- a/tests/client/hud-session-play.test.ts +++ b/tests/client/hud-session-play.test.ts @@ -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() }) })