└── 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.
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.
| 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` |
| 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 |
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`:
- `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.
- `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.
- **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.
- `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.