diff --git a/TEST_INFRA.md b/TEST_INFRA.md index 85c31c1..1e6c0fc 100644 --- a/TEST_INFRA.md +++ b/TEST_INFRA.md @@ -1,6 +1,6 @@ # Diablo II v1.13c Parity Test Infrastructure (`TEST_INFRA.md`) -This document defines the comprehensive test infrastructure, architectural contracts, test runner configuration, and execution guidelines for the Diablo II v1.13c parity test initiative (Issues #562–#712). +This document defines the comprehensive test infrastructure, architectural contracts, test runner configuration, and execution guidelines for the Diablo II v1.13c parity test initiative (151 Issues, `#562–#712` across Milestones `M1`–`M8`). --- @@ -9,14 +9,17 @@ This document defines the comprehensive test infrastructure, architectural contr ### Test Framework - **Test Runner**: [Vitest v2.1.9](https://vitest.dev/) - **Runtime Environment**: Node.js v20+ with native TypeScript compilation and ESM modules. -- **Assertion Library**: Vitest BDD assertions (`describe`, `it`, `expect`) with strict value and deep object matching. -- **Execution Mode**: Hermetic, isolated, parallelized test runner with sub-millisecond execution times for model and state-tree tests, and isolated headless Chromium browser automation for frontend flows. +- **Assertion Library**: Vitest BDD assertions (`describe`, `it`, `expect`, `vi`) with strict value and deep object matching against real production exports. +- **Execution Mode**: Hermetic, isolated, parallelized test runner with sub-millisecond execution times for model, renderer, and state-tree tests, and isolated headless Chromium browser automation for E2E browser flows. ### Standard Test Commands ```bash -# Run all tests in the repository -npx vitest run +# Run TypeScript typechecking across all 7 project references +npm run typecheck + +# Run the M9 Gate verification suite (4-tier parity + arch boundaries + automap + netproto + frontend) +npx vitest run tests/e2e-parity/ tests/arch/boundaries.test.ts tests/frontend-flow.test.ts tests/netproto-bncs-packets.test.ts tests/netproto-online-flow.test.ts tests/automap-renderer.test.ts # Run only the 4-tier Diablo II v1.13c parity test suite npx vitest run tests/e2e-parity/ @@ -27,65 +30,71 @@ npx vitest run tests/e2e-parity/tier2-boundary-corner.test.ts npx vitest run tests/e2e-parity/tier3-cross-feature.test.ts npx vitest run tests/e2e-parity/tier4-real-world-scenarios.test.ts -# Run migrated legacy test suites (zero regressions) -npx vitest run tests/frontend-flow.test.ts tests/netproto-bncs-packets.test.ts tests/netproto-online-flow.test.ts tests/e2e-bnet-create-account.test.ts +# Run Batch 1–8 subsystem verification suites +npx vitest run tests/client/ ``` --- -## 2. Four-Tier Parity Test Suite Structure +## 2. Four-Tier Parity Test Suite Structure & M1–M8 Batch Alignment The parity test suite is organized into four complementary verification tiers inside `tests/e2e-parity/`: ``` tests/e2e-parity/ -├── helpers.ts # Shared test fixtures, mock collision oracles, item builders, and math helpers -├── tier1-feature-coverage.test.ts # Tier 1: 48 Feature Coverage Tests (6 per batch across 8 batches) -├── tier2-boundary-corner.test.ts # Tier 2: 48 Boundary & Edge Case Tests (6 per batch across 8 batches) +├── helpers.ts # Production bindings (ClientWorld, HudModel, Viewport, InventoryPanel, FrontendRenderer, BeltModel, i18n) +├── tier1-feature-coverage.test.ts # Tier 1: 48 Feature Coverage Tests (6 per batch across Batches 1–8) +├── tier2-boundary-corner.test.ts # Tier 2: 48 Boundary & Edge Case Tests (6 per batch across Batches 1–8) ├── tier3-cross-feature.test.ts # Tier 3: 8 Pairwise Cross-Feature Integration Tests └── tier4-real-world-scenarios.test.ts # Tier 4: 5 Full-Lifecycle Gameplay Scenarios ``` -### Tier 1: Feature Coverage (48 Tests) -Guarantees primary positive-path behavior across all 8 subsystem batches: -- **Batch 1 (Issues #562–#579)**: 25Hz simulation tick accumulator (40ms ticks), isometric 2:1 projection math, sub-tile conversions, and diminishing return curves. -- **Batch 2 (Issues #580–#597)**: Universal DT1 tile loading (`Blank.dt1`, `InvisWal.dt1`, `Warp.dt1`), multi-flag collision masks, void collision blocking, and dynamic entity footprint reservation. -- **Batch 3 (Issues #598–#615)**: Player posture modes (walk, run, town neutral, town walk), pathing waypoint queues, stamina consumption, and collision obstruction stoppage. -- **Batch 4 (Issues #616–#633)**: DCC direction alignment (8/16/32 directions), animation clip pacing, 1.13c FCR/IAS speed formulas, and cast overlay alignment. -- **Batch 5 (Issues #634–#651)**: DT1 block layout decoding (sub-blocks 0..24), SplitMix64 spatial hashing (no periodic stripe artifacts), Bresenham LOS raycasting, and missile projectile physics. -- **Batch 6 (Issues #652–#669)**: Grid container placement, item bridging (`onlineItemDataToUiInventoryItem`), bilingual tooltips, belt drink commands, and weapon swap toggling. -- **Batch 7 (Issues #670–#703)**: Authoritative C2S/S2C packet size validation, S2C movement packet parsing, Battle.net character name filtering, and corpse assignment tracking. -- **Batch 8 (Issues #704–#712)**: Asset manifest integrity, fail-fast DT1 library audits, and headless level audit verification. +### Subsystem Batch Taxonomy (`PROJECT.md § Feature Inventory`) +All 8 subsystem batches match `PROJECT.md` verbatim across `TEST_INFRA.md`, `TEST_READY.md`, `tests/e2e-parity/`, and `tests/client/`: +- **Batch 1 (M1: `#562–#576`, 15 issues)**: **Core World, 25Hz Timing, Movement, Missiles & Combat Formulas** + - 25Hz simulation tick accumulator (`40ms` ticks), isometric 2:1 projection (`subTileToWorldOrtho`, `worldOrthoToSubTile`), `S2C 0x59` `AssignPlayer` self authority, 1.13c AR/DR hit chance & running block formulas, 8.8 fixed-point step velocity, and `Missiles.txt` range scaling. +- **Batch 2 (M2: `#624–#641`, 18 issues)**: **Automap, Minimap, Control Bar, Globes, Belt & Speedbar** + - Universal DT1 tile loading (`Blank.dt1`, `InvisWal.dt1`, `Warp.dt1`), multi-flag collision masks (`COLLIDE_BLANK | COLLIDE_WALL` synchronized with `ClientWorld.setSubTileBlocked`), SplitMix64 spatial hashing (`pickVariant`), 8-sheet automap sprite loading (`Minimap.loadAtlases`), and belt/globe HUD models. +- **Batch 3 (M3: `#642–#663`, 22 issues)**: **Fonts, PL2 Text Colors, Cursor, Tooltips, NPC Dialogue & World Panels** + - Player walk/run velocity ratios (`UnitMove`), action state machine transitions (`Neutral -> Cast -> Neutral`), one-shot cast duration from `AnimData.d2`, town walk-only posture enforcement (`isTownLevel`), and bitmap font / tooltip / NPC menu layout. +- **Batch 4 (M4: `#604–#623`, 20 issues)**: **World Renderer, Animation, DCC Paperdoll, Lighting & Shaders** + - Diminishing returns FCR/IAS/FHR formulas (`computeDiminishingRate113c`, `computeEffectiveFcr`, `computeEffectiveIas`), one-shot animation clamping (`computeAnimFrameIndex`), `Overlay.txt` / `States.txt` point lights, ground item flippy animations, and strict missing-art reporting. +- **Batch 5 (M5: `#577–#603`, 27 issues)**: **Combat, Motion Kinematics, Targeting, Overlays, Shadows & Weather** + - Single-clock 25Hz missile kinematics, `ClientUnit.lifePercent` (`0..128` -> `0..100%`) scaling, `VIEWPORT_800x600` coordinate culling (`worldToScreen`, `computeViewportAnchors`), entity/corpse/overlay depth sorting (`buildSceneDrawables`), mouse hover hitboxes (`CommandMapper.findUnitAtScreenPoint`), and `UnitReassign` server authority. +- **Batch 6 (M6: `#664–#689`, 26 issues)**: **World Panels, Inventory `inventory.txt`, Item Variants, Usables, Sockets, Skills & Character Sheet** + - Canonical `inventory.txt` 10x4 grid (`getInventoryLayout('Sorceress2')`), Horadric Cube `(198, 199)` origin and `3x4 = 12` internal grid (`getInventoryLayout('Transmogrify Box Page 1')`) vs `2x2` exterior item footprint, `BeltModel` (`getBeltRowsForCode`), unformatted integer gold/XP display (`InventoryPanel.draw`, `STASH_GOLD_CAP`), and `Misc.txt` `useable=1` right-click item usage. +- **Batch 7 (M7: `#690–#703`, 14 issues)**: **In-Game UI Overlays, Missing-Art Fail-Fast, Offline Paperdoll/Skill Tree Rules & Character Select** + - Authoritative `C2S_PACKET_SIZES` / `S2C_PACKET_SIZES` tables, `S2C 0x0D` movement packet handling, Battle.net character/account name validation (`isValidCharacterName`, `canAcceptCharNameChar`, `isValidAccountChar`), `CAMPFIRE_FLAME_CONTROL_SPEC` `(345, 470)` alignment, MCP `0x19` avatar statstring parsing (`parseMcpAvatarStatstring`), and `S2C 0x8E` corpse assignment. +- **Batch 8 (M8: `#704–#712`, 9 issues)**: **Campfire Character Creation, `D2EditBox` / `TRANS50` Controls, TBL Strings & i18n** + - `FrontendRenderer.render` `D2WinEditBox` `_` cursor 250ms blink period, `DRAWMODE_TRANS50` (`globalAlpha = 0.5`, `filter = 'none'`) disabled button rendering (`transToBlendMode(3)`), canonical `.tbl` string resolution (`resolveStringByTblId`, `lookupTbl`, `requireTbl`), fail-fast resource validation (`charSummaryToRosterCharacter`), and URL/pathname i18n language routing (`resolveLangFromPathname`). ### Tier 2: Boundary & Corner Cases (48 Tests) -Stress tests extreme numerical bounds, edge conditions, invalid inputs, and corrupt state: -- Zero, negative, and fractional delta times in the simulation tick loop. -- Extreme coordinate out-of-bounds queries clamped to `COLLIDE_BLANK | COLLIDE_WALL`. -- Multi-layer collision bitwise flag isolation (missile barriers vs walking walls). -- Extreme combat math limits: 0 Attack Rating, 100,000 Defense, 100% target defense reduction, 0-HP clamp, and 5% min / 95% max hit chance clamps. -- Diminishing return caps for Faster Cast Rate (+1000% FCR clamped to 75%). -- SplitMix64 spatial distribution across negative coordinates and coordinate mirroring. -- Inventory boundary checks: 10x4 grid limits, overlapping 2x2 item placement rejection, and out-of-bounds slot rejection. -- Netproto bounds: C2S packet size boundaries, corrupt 0x8E packet handling, minimum/maximum account name constraints (2 to 15 characters), and forbidden control character filtering. +Stress tests extreme numerical bounds, edge conditions, invalid inputs, and corrupt state against real production functions: +- Zero, non-finite (`NaN`/`Infinity`), and capped (`200ms`) delta times in `ClientWorld.tick`. +- Extreme negative and out-of-bounds sub-tile coordinates returning `COLLIDE_BLANK | COLLIDE_WALL` and `ClientWorld.isSubTileBlocked === true`. +- Extreme combat math limits: 0 Attack Rating, 100,000 Defense, `lifePct = 0` (`ClientUnit.dead === true`), and `[5%, 95%]` hit chance clamps. +- Diminishing return caps for Faster Cast Rate (`500%` FCR clamped to `75%`, `-500%` clamped to `-85%`). +- Inventory boundary checks via `findFreeGridSlot` and `HudModel`: `2x2` item placement at `(8, 2)` accepted vs column `9` overflow rejected, `STASH_GOLD_CAP = 2,500,000` unformatted integer rendering in `InventoryPanel.draw`, and Horadric Cube `3x4 = 12` internal slots vs `2x2` exterior item footprint. +- Frontend & i18n bounds: 250ms `_` cursor toggle across `100ms / 300ms / 600ms / 800ms` in `FrontendRenderer.render`, `TRANS50` disabled button rendering on empty `char_select` roster, and missing TBL key fallback in `lookupTbl` vs fail-fast in `requireTbl` / `resolveStringByTblId`. ### Tier 3: Pairwise Cross-Feature Interactions (8 Tests) Verifies multi-subsystem contracts across overlapping modules: -- **X1 (Movement + Weapon Swap + Skill Cast)**: Swapping weapon sets while running updates effective cast rate and interrupts run motion upon skill cast. -- **X2 (Collision + Teleport + Town Portal)**: Teleporting bypasses physical wall barriers and stepping into a Town Portal transitions area to town posture. -- **X3 (Missile Trajectory + Obstacle Collision + Impact Overlay)**: Projectiles traverse open cells, impact hostile entities, and trigger visual impact overlays. -- **X4 (S2C Server Correction + Reassign)**: Local client prediction is authoritatively corrected by S2C UnitReassign packets. -- **X5 (Item Drop + Inventory Placement + UI Item Bridge)**: Ground drop entities enter container slots and produce properly formatted UI inventory items. -- **X6 (Character Selection + Account Validation + Flame Anchor)**: Validates account credentials, filters character names, and aligns campfire flame anchors. -- **X7 (Line-of-Sight Raycast + Viewport Coordinate Culling)**: Bresenham raycasting determines target visibility and culls out-of-screen render entities. -- **X8 (Stamina / Posture Toggle + Block Chance Degradation)**: Running posture reduces player block chance by two-thirds, capped at 25%. +- **X1 (Movement + Weapon Swap + Skill Cast)**: Clicking weapon swap tab (`resolveWeaponSwapTabClick`) and calling `hudModel.swapWeapons()` emits `WeaponSwitch`, updates `STAT_FASTERCASTRATE`, and interrupts run motion upon skill cast. +- **X2 (Collision + Teleport + Town Portal)**: Teleporting bypasses wall barriers (`ClientWorld.isSubTileBlocked`) and stepping into a Town Portal transitions area to town posture (`isTownLevel`). +- **X3 (Missile Trajectory + Obstacle Collision + Impact Overlay)**: Projectiles traverse open cells, impact hostile entities, and resolve canonical `Overlay.txt` impact overlays. +- **X4 (S2C Server Correction + Reassign)**: Local client prediction is authoritatively corrected by `S2C UnitReassign` packets. +- **X5 (Item Drop + Inventory Placement + UI Item Bridge)**: Ground drop entities enter container slots and produce properly formatted UI inventory items (`onlineItemDataToUiInventoryItem`). +- **X6 (Character Selection + Account Validation + Flame Anchor)**: Validates account credentials, transitions `enterCharCreate` / `selectCampfireClass`, parses MCP `0x19` statstrings (`parseMcpAvatarStatstring`), and verifies `CAMPFIRE_FLAME_CONTROL_SPEC` `(345, 470)`. +- **X7 (Line-of-Sight Raycast + Viewport Coordinate Culling)**: Bresenham raycasting determines target visibility and projects screen coordinates via `worldToScreen` and `VIEWPORT_800x600`. +- **X8 (Stamina / Posture Toggle + Block Chance Degradation)**: Running posture reduces player block chance to one-third, capped at 25% (`calculatePlayerBlockChance`). ### Tier 4: Real-World Gameplay Scenarios (5 Scenarios) Verifies authentic full-lifecycle gameplay journeys under 1.13c ground truth: -1. **Scenario 1 — Blood Moor Clearing**: Player departs Rogue Encampment (area 1 -> 2), transitions posture from town neutral to combat, navigates terrain obstacles, defeats Fallen, takes damage, and recovers via a belt health potion. -2. **Scenario 2 — Den of Evil Completion**: Enters cave (area 8), reveals cavern rooms, tracks remaining monster count reaching 0, and receives Quest 0 completion bitmask from the server. +1. **Scenario 1 — Blood Moor Clearing**: Player departs Rogue Encampment (area 1 -> 2), transitions posture from town neutral to combat, navigates terrain obstacles, defeats Fallen, takes damage, and recovers via a belt health potion (`BeltUse`). +2. **Scenario 2 — Den of Evil Completion**: Enters cave (area 8), reveals cavern rooms, clears all 5 live monsters in `ClientWorld.units` down to `0`, and receives `a1q1` (`getActQuests(1)[0]`) completion bitmask from the server. 3. **Scenario 3 — Countess Tower Run**: Descends Forgotten Tower cellars (areas 21 -> 25), battles Superunique Countess with FCR-boosted spells, slays her, and loots a guaranteed Ral Rune (`r08`) into inventory. -4. **Scenario 4 — Tristram Rescue**: Steps through Cairn Stones Red Portal into Tristram (area 38), kites Griswold over multiple 25Hz ticks, interacts with the gibbet cage, frees Deckard Cain, and advances Quest 2. -5. **Scenario 5 — Act Boss Kill (Andariel)**: Infiltrates Catacombs Level 4 (area 39), survives Andariel's Poison Spray, exploits her -50% Fire Resistance in Normal difficulty, slays her, triggers death overlays, completes Act 1 Quest 5, and travels with Warriv's caravan to Act 2 Lut Gholein (area 40). +4. **Scenario 4 — Tristram Rescue**: Steps through Cairn Stones Red Portal into Tristram (area 38), kites Griswold over multiple 25Hz ticks, interacts with the gibbet cage (`QuestMessage`), frees Deckard Cain, and advances Quest 2. +5. **Scenario 5 — Act Boss Kill (Andariel)**: Infiltrates Catacombs Level 4 (area 39), survives Andariel's Poison Spray, fires `fireball` projectiles (`spawnMissileByKey`) reducing `andariel.lifePercent` from `100% -> 50% -> 0%`, triggers death overlays, completes `a1q6` (`getActQuests(1)[5]`), and travels with Warriv's caravan to Act 2 Lut Gholein (`getActWaypoints(2)[0].levelId === 40`). --- @@ -99,5 +108,5 @@ Verifies authentic full-lifecycle gameplay journeys under 1.13c ground truth: Universal DT1s (`Blank.dt1`, `InvisWal.dt1`, `Warp.dt1`) are loaded unconditionally with `COLLIDE_BLANK | COLLIDE_WALL` for void safety. 4. **SplitMix64 Spatial Hash**: Tile variant selection uses 64-bit SplitMix64 spatial hashing to prevent 45-degree mechanical tiling artifacts. -5. **No Facade Tests**: - All tests assert on real models (`ClientWorld`, `HudModel`, `CollisionGridOracle`) and verify state mutations, container contents, and packet serialization without mocking away critical domain logic. +5. **Zero Tautological / Facade Tests**: + All tests import and execute real production modules (`ClientWorld`, `ClientUnit`, `CommandMapper`, `HudModel`, `InventoryPanel`, `WorldPanelsHud`, `BeltModel`, `FrontendRenderer`, `Minimap`, `viewport-profile`, `unit-drawables`, `flow-state`, `lang`) and verify real state mutations, draw calls, container contents, and packet serialization. diff --git a/TEST_READY.md b/TEST_READY.md index 25e88da..58e5bd3 100644 --- a/TEST_READY.md +++ b/TEST_READY.md @@ -1,91 +1,102 @@ # Diablo II v1.13c Parity Test Suite — Readiness Certification (`TEST_READY.md`) -**Date**: 2026-10-02 +**Date**: 2026-10-03 **Status**: READY (100% PASS RATE) -**Target Initiative**: Diablo II v1.13c Parity (151 Issues, #562–#712) -**Author**: Test Writer Specialist Agent (`teamwork_preview_test_writer_e2e_1`) +**Target Initiative**: Diablo II v1.13c Parity (151 Issues, #562–#712 across M1–M8) +**Authors**: Test Writer Specialist (`teamwork_preview_test_writer_e2e_1`) & M9 Gate Remediation Implementer (`teamwork_preview_worker_m9_rem_1`) --- ## 1. Executive Summary -The complete 4-tier requirement-driven opaque-box E2E parity test suite for the Diablo II v1.13c initiative is implemented, verified, and certified ready for continuous integration and regression testing. +The complete 4-tier requirement-driven opaque-box E2E parity test suite (`tests/e2e-parity/`), architectural boundary gates (`tests/arch/boundaries.test.ts`), and Batch 1–8 subsystem verification suites (`tests/client/`) for the Diablo II v1.13c initiative are implemented, verified against real M1–M8 production modules (`src/client/**`, `src/common/**`, `src/netproto/**`), and certified ready for continuous integration and regression testing. -- **Total Parity Tests**: 109 tests across 4 tiers in `tests/e2e-parity/`. -- **Legacy Migrated Tests**: 13 tests across 4 suites migrated from `node:test` to Vitest. -- **Combined Test Count**: 122 tests executed via `npx vitest run`. -- **Pass Rate**: 100% (122 / 122 tests passing cleanly). -- **Production Code Guard**: 0 changes to production files (`src/**`). All modifications strictly confined to `tests/**` and documentation. +- **Total 4-Tier Parity Tests (`tests/e2e-parity/`)**: 109 tests across 4 tiers (`tier1`–`tier4`) bound directly to real production modules with zero local-constant or local-arrow-function tautologies. +- **Architectural & Core Gate Suites**: `tests/arch/boundaries.test.ts` (2 tests), `tests/automap-renderer.test.ts` (18 tests), `tests/frontend-flow.test.ts` (6 tests), `tests/netproto-bncs-packets.test.ts` (4 tests), `tests/netproto-online-flow.test.ts` (2 tests), `tests/e2e-bnet-create-account.test.ts` (1 test). +- **Batch 1–8 Subsystem Verification Suites (`tests/client/`)**: Dedicated adversarial and parity suites covering all 151 issues (`#562–#712`). +- **Pass Rate**: 100% across all parity, architectural, batch, and legacy suites. --- -## 2. Test Coverage & Tier Breakdown +## 2. Test Coverage & Suite Breakdown -| Tier | File Path | Focus Area | Test Count | Pass Rate | +| Category / Tier | File Path | Focus Area | Test Count | Pass Rate | |---|---|---|---|---| -| **Tier 1** | `tests/e2e-parity/tier1-feature-coverage.test.ts` | Core Feature Coverage across 8 Batches (#562–#712) | 48 tests | 100% (48/48) | -| **Tier 2** | `tests/e2e-parity/tier2-boundary-corner.test.ts` | Boundary, Corner & Edge Cases across 8 Batches | 48 tests | 100% (48/48) | +| **Tier 1** | `tests/e2e-parity/tier1-feature-coverage.test.ts` | Core Feature Coverage across 8 Batches (`M1`–`M8`, `#562–#712`) | 48 tests | 100% (48/48) | +| **Tier 2** | `tests/e2e-parity/tier2-boundary-corner.test.ts` | Boundary, Corner & Edge Cases across 8 Batches (`M1`–`M8`) | 48 tests | 100% (48/48) | | **Tier 3** | `tests/e2e-parity/tier3-cross-feature.test.ts` | Pairwise & Multi-Subsystem Cross-Feature Integrations | 8 tests | 100% (8/8) | | **Tier 4** | `tests/e2e-parity/tier4-real-world-scenarios.test.ts` | Authentic Full-Lifecycle Gameplay Scenarios | 5 tests | 100% (5/5) | +| **Arch Gate** | `tests/arch/boundaries.test.ts` | Browser `node:*` Import Boundary & Layer Dependency Gate | 2 tests | 100% (2/2) | +| **Automap** | `tests/automap-renderer.test.ts` | 8-Sheet Automap / Minimap Sprite & Crosshair Parity | 18 tests | 100% (18/18) | +| **Batch 1 (M1)** | `tests/client/batch1-adversarial-stress.test.ts` | Core World, 25Hz Timing, Movement, Missiles & Combat Formulas (`#562–#576`) | Verified | 100% | +| **Batch 2 (M2)** | `tests/client/challenger-hud-belts-exp-stress.test.ts` | Automap, Minimap, Control Bar, Globes, Belt & Speedbar (`#624–#641`) | Verified | 100% | +| **Batch 3 (M3)** | `tests/client/core-hud-parity.test.ts` | Fonts, PL2 Text Colors, Cursor, Tooltips, NPC Dialogue & World Panels (`#642–#663`) | Verified | 100% | +| **Batch 4 (M4)** | `tests/client/renderer-animation-parity-m4-p1.test.ts` | World Renderer, Animation, DCC Paperdoll, Lighting & Shaders (`#604–#623`) | Verified | 100% | +| **Batch 5 (M5)** | `tests/client/combat-targeting-batch5.test.ts`, `tests/client/motion-kinematics-batch5.test.ts`, `tests/client/renderer-overlays-batch5.test.ts` | Combat, Motion Kinematics, Targeting, Overlays, Shadows & Weather (`#577–#603`) | Verified | 100% | +| **Batch 6 (M6)** | `tests/client/world-panels-batch6.test.ts`, `tests/client/inventory-batch6.test.ts`, `tests/client/skills-charsheet-batch6.test.ts` | World Panels, Inventory `inventory.txt`, Item Variants, Usables, Sockets, Skills & Character Sheet (`#664–#689`) | Verified | 100% | +| **Batch 7 (M7)** | `tests/client/ui-offline-batch7.test.ts`, `tests/client/frontend-batch7-batch8.test.ts` | In-Game UI Overlays, Missing-Art Fail-Fast, Offline Paperdoll/Skill Tree Rules & Character Select (`#690–#703`) | Verified | 100% | +| **Batch 8 (M8)** | `tests/client/frontend-batch7-batch8.test.ts`, `tests/client/frontend-i18n-manifest-batch8.test.ts` | Campfire Character Creation, `D2EditBox` / `TRANS50` Controls, TBL Strings & i18n (`#704–#712`) | Verified | 100% | | **Legacy** | `tests/frontend-flow.test.ts` | Battle.net Frontend Flow & Asset Verification | 6 tests | 100% (6/6) | | **Legacy** | `tests/netproto-bncs-packets.test.ts` | BNCS Packet Encoders/Decoders | 4 tests | 100% (4/4) | | **Legacy** | `tests/netproto-online-flow.test.ts` | Online Flow Integration | 2 tests | 100% (2/2) | | **Legacy** | `tests/e2e-bnet-create-account.test.ts` | Headless Chromium Browser E2E Account Flow | 1 test | 100% (1/1) | -| **Total** | — | **Full Test Suite** | **122 tests** | **100% (122/122)** | --- -## 3. Subsystem Batch Coverage Matrix (Tiers 1 & 2) +## 3. Subsystem Batch Coverage Matrix (`PROJECT.md § Feature Inventory`) -Each of the 8 subsystem batches has dedicated 6 primary feature tests and 6 edge/boundary tests: +Each of the 8 subsystem batches (`M1` through `M8`, covering all 151 issues `#562–#712`) has dedicated feature and boundary coverage in `tests/e2e-parity/` as well as exhaustive subsystem test suites in `tests/client/`: -1. **Batch 1 (Issues #562–#579: Engine Timing, Coordinates & Math)**: - - 25Hz tick accumulator (40ms ticks), sub-frame delta accumulation, isometric 2:1 projection math, sub-tile conversions, and diminishing return curves. -2. **Batch 2 (Issues #580–#597: Collision Layer, Floor Grid & Walkability)**: - - Universal DT1 loading (`Blank.dt1`, `InvisWal.dt1`, `Warp.dt1`), multi-flag collision masks, void collision blocking, and dynamic entity footprint reservation. -3. **Batch 3 (Issues #598–#615: Player Movement, Pathing & Action States)**: - - Posture modes (walk, run, town neutral, town walk), pathing waypoint queues, stamina consumption, and collision obstruction stoppage. -4. **Batch 4 (Issues #616–#633: Character & Monster Animation / DCC Pipeline)**: - - DCC direction alignment (8/16/32 directions), animation clip pacing, 1.13c FCR/IAS speed formulas, and cast overlay alignment. -5. **Batch 5 (Issues #634–#651: Renderer & DT1 Tile Pipeline)**: - - DT1 block layout decoding (sub-blocks 0..24), SplitMix64 spatial hashing (no periodic stripe artifacts), Bresenham LOS raycasting, and missile projectile physics. -6. **Batch 6 (Issues #652–#669: UI, HUD, Inventory & Tooltip Systems)**: - - Grid container placement, item bridging (`onlineItemDataToUiInventoryItem`), bilingual tooltips, belt drink commands, and weapon swap toggling. -7. **Batch 7 (Issues #670–#703: Network Packet Sync & Message Serialization)**: - - Authoritative C2S/S2C packet size validation, S2C movement packet parsing, Battle.net character name filtering, and corpse assignment tracking. -8. **Batch 8 (Issues #704–#712: Tools, Asset Manifest & Headless Auditing)**: - - Asset manifest integrity, fail-fast DT1 library audits, and headless level audit verification. +1. **Batch 1 (M1: `#562–#576`, 15 issues)**: **Core World, 25Hz Timing, Movement, Missiles & Combat Formulas** + - 25Hz simulation tick accumulator (`D2_TICK_MS = 40`), sub-tile to 2:1 isometric projection (`subTileToWorldOrtho` / `worldOrthoToSubTile`), `S2C 0x59` player assignment authority, 1.13c AR/DR hit chance & running block formulas, 8.8 fixed-point step velocity, and `Missiles.txt` range/velocity scaling. +2. **Batch 2 (M2: `#624–#641`, 18 issues)**: **Automap, Minimap, Control Bar, Globes, Belt & Speedbar** + - Universal DT1 loading (`Blank.dt1`, `InvisWal.dt1`, `Warp.dt1`), multi-layer collision masks (`COLLIDE_BLANK | COLLIDE_WALL`), SplitMix64 variant hashing (`pickVariant`), 8-sheet automap sprite routing (`Minimap`), belt row capacity (`Belts.txt`), and control bar / globe HUD state. +3. **Batch 3 (M3: `#642–#663`, 22 issues)**: **Fonts, PL2 Text Colors, Cursor, Tooltips, NPC Dialogue & World Panels** + - Walk vs run velocity and posture transitions, one-shot cast state machine (`Neutral -> Cast -> Neutral`), town walk-only posture enforcement (`isTownLevel`), `D2FontRenderer` glyph rendering, PL2 color palettes, and NPC dialogue / world panel anchoring. +4. **Batch 4 (M4: `#604–#623`, 20 issues)**: **World Renderer, Animation, DCC Paperdoll, Lighting & Shaders** + - Diminishing returns FCR/IAS/FHR curves (`computeDiminishingRate113c`, `computeEffectiveFcr`, `computeEffectiveIas`), one-shot animation frame clamping (`computeAnimFrameIndex`), `Overlay.txt` / `States.txt` point lights, ground item flippy animation, and strict missing-art reporting. +5. **Batch 5 (M5: `#577–#603`, 27 issues)**: **Combat, Motion Kinematics, Targeting, Overlays, Shadows & Weather** + - Single-clock 25Hz projectile kinematics, `ClientUnit.lifePercent` (`0..128` -> `0..100%`) scaling, `VIEWPORT_800x600` coordinate projection (`worldToScreen`), entity/corpse/overlay depth sorting (`buildSceneDrawables`), mouse hover hitboxes (`CommandMapper.findUnitAtScreenPoint`), and server `UnitReassign` authority. +6. **Batch 6 (M6: `#664–#689`, 26 issues)**: **World Panels, Inventory `inventory.txt`, Item Variants, Usables, Sockets, Skills & Character Sheet** + - Canonical `inventory.txt` 10x4 grid (`getInventoryLayout('Sorceress2')`), Horadric Cube `(198, 199)` origin and `3x4 = 12` internal grid (`getInventoryLayout('Transmogrify Box Page 1')`) vs `2x2` exterior item footprint, `BeltModel` 4x4 potion matrix, unformatted integer gold/XP rendering (`InventoryPanel.draw`, `STASH_GOLD_CAP = 2,500,000`), and `Misc.txt` `useable=1` right-click consumption. +7. **Batch 7 (M7: `#690–#703`, 14 issues)**: **In-Game UI Overlays, Missing-Art Fail-Fast, Offline Paperdoll/Skill Tree Rules & Character Select** + - Authoritative `C2S_PACKET_SIZES` / `S2C_PACKET_SIZES` tables, `S2C 0x0D` movement mode & life preservation, Battle.net character/account name validation (`isValidCharacterName`, `canAcceptCharNameChar`, `isValidAccountChar`), `CAMPFIRE_FLAME_CONTROL_SPEC` `(345, 470)` alignment, MCP `0x19` 11-component avatar statstring parsing (`parseMcpAvatarStatstring`), and `S2C 0x8E` corpse assignment. +8. **Batch 8 (M8: `#704–#712`, 9 issues)**: **Campfire Character Creation, `D2EditBox` / `TRANS50` Controls, TBL Strings & i18n** + - `FrontendRenderer` `D2WinEditBox` `_` cursor 250ms blink period, `DRAWMODE_TRANS50` (`globalAlpha = 0.5`, `filter = 'none'`) disabled button rendering (`transToBlendMode(3)`), canonical `.tbl` string resolution (`resolveStringByTblId`, `lookupTbl`, `requireTbl`), fail-fast resource validation (`charSummaryToRosterCharacter`), and URL/pathname i18n language detection (`resolveLangFromPathname`). --- ## 4. Cross-Feature & Gameplay Scenarios (Tiers 3 & 4) -- **Tier 3 (8 Interactions)**: - - X1: Movement + Weapon Swap + Skill Cast - - X2: Collision Grid + Teleport + Town Portal - - X3: Missile Trajectory + Wall Collision + Impact Overlay - - X4: S2C Server Correction + Unit Motion Synchronization - - X5: Item Drop + Inventory Placement + Tooltip Stat Generation - - X6: Character Selection + Account Validation + Flame Anchor - - X7: Line-of-Sight Raycast + Viewport Coordinate Culling - - X8: Stamina / Posture Toggle + Block Chance Degradation -- **Tier 4 (5 Full Scenarios)**: - - Scenario 1: Blood Moor Clearing (Town departure, terrain navigation, combat against Fallen, and potion recovery) - - Scenario 2: Den of Evil Completion (Cave exploration, room visibility, monster clearing count 5 -> 0, and quest completion flag) - - Scenario 3: Countess Tower Run (Multi-floor cellar descent 21 -> 25, superunique fight, rune drop to ground, pickup into inventory) - - Scenario 4: Tristram Rescue (Red portal transition 4 -> 38, Griswold combat & movement kiting, Deckard Cain cage interaction & quest update) - - Scenario 5: Act Boss Kill: Andariel (Catacombs L4 chamber entry, poison spray mechanics & vulnerability, Andariel defeat, Act 1 quest complete, Act 2 Lut Gholein transition) +- **Tier 3 (8 Pairwise Cross-Feature Interactions)**: + - `X1`: Movement + Weapon Swap (`resolveWeaponSwapTabClick`, `hudModel.swapWeapons`) + `STAT_FASTERCASTRATE` Skill Cast + - `X2`: Collision Grid (`ClientWorld.isSubTileBlocked`) + Teleport + Town Portal + - `X3`: Missile Trajectory + Wall Collision + Impact Overlay (`getSkillCastOverlay`, `getOverlayTxtData`) + - `X4`: `S2C` Server Correction (`UnitReassign`) + Unit Motion Synchronization + - `X5`: Item Drop + Inventory Placement + Tooltip Stat Generation (`onlineItemDataToUiInventoryItem`) + - `X6`: Character Selection (`enterCharCreate`, `selectCampfireClass`, `parseMcpAvatarStatstring`) + Account Validation + `CAMPFIRE_FLAME_CONTROL_SPEC` `(345, 470)` + - `X7`: Line-of-Sight Raycast + Viewport Coordinate Projection (`worldToScreen`, `VIEWPORT_800x600`) + - `X8`: Stamina / Posture Toggle + Running Block Chance Degradation (`calculatePlayerBlockChance`) +- **Tier 4 (5 Full-Lifecycle Gameplay Scenarios)**: + - `Scenario 1`: Blood Moor Clearing (Town departure, terrain navigation, combat against Fallen, and belt potion recovery) + - `Scenario 2`: Den of Evil Completion (Cave exploration, room visibility, `ClientWorld` live monster clearing 5 -> 0, and `a1q1` quest completion) + - `Scenario 3`: Countess Tower Run (Multi-floor cellar descent 21 -> 25, superunique fight, Ral rune drop to ground, pickup into inventory) + - `Scenario 4`: Tristram Rescue (Red portal transition 4 -> 38, Griswold combat & movement kiting, Deckard Cain cage interaction & quest update) + - `Scenario 5`: Act Boss Kill: Andariel (Catacombs L4 chamber entry, Poison Spray & Fire Ball projectile exchange, `andariel.lifePercent` depletion, `a1q6` quest completion, and Act 2 Lut Gholein transition) --- ## 5. Verification Commands ```bash -# Verify the entire 4-tier parity suite (109 tests) -npx vitest run tests/e2e-parity/ +# 1. Typecheck all 7 TypeScript projects +npm run typecheck -# Verify legacy test suites (13 tests) -npx vitest run tests/frontend-flow.test.ts tests/netproto-bncs-packets.test.ts tests/netproto-online-flow.test.ts tests/e2e-bnet-create-account.test.ts +# 2. Verify M9 gate test suites (4-tier parity, architecture boundaries, automap, frontend, and netproto) +npx vitest run tests/e2e-parity/ tests/arch/boundaries.test.ts tests/frontend-flow.test.ts tests/netproto-bncs-packets.test.ts tests/netproto-online-flow.test.ts tests/automap-renderer.test.ts + +# 3. Verify all Batch 1–8 client subsystem suites +npx vitest run tests/client/ ``` All tests pass cleanly with zero warnings and zero flakes. diff --git a/src/client/ui/minimap.ts b/src/client/ui/minimap.ts index d6e9362..b1dbeeb 100644 --- a/src/client/ui/minimap.ts +++ b/src/client/ui/minimap.ts @@ -795,9 +795,10 @@ export class Minimap { await Promise.all( entries.map(async ([sheetName, meta]) => { - const url = baseUrl - ? `${baseUrl.replace(/\/+$/, '')}/${meta.image.replace(/^\/+/, '')}` - : meta.image + const url = + baseUrl && (!meta.image.startsWith('/') || baseUrl.startsWith('http')) + ? `${baseUrl.replace(/\/+$/, '')}/${meta.image.replace(/^\/+/, '')}` + : meta.image try { const res = await fetch(url) if (!res.ok) { diff --git a/tests/e2e-parity/helpers.ts b/tests/e2e-parity/helpers.ts index 6b92ef2..299305e 100644 --- a/tests/e2e-parity/helpers.ts +++ b/tests/e2e-parity/helpers.ts @@ -73,15 +73,105 @@ import { type ServerEvent, } from '../../src/netproto/index.ts' import { ClientWorld } from '../../src/client/world/client-world.ts' +import { ClientUnit } from '../../src/client/world/client-unit.ts' +import { CommandMapper, ViewportCamera } from '../../src/client/input/command-mapper.ts' +import { + VIEWPORT_800x600, + getViewportProfile, + subTileToWorldOrtho, + worldOrthoToSubTile, + worldToScreen, + screenToWorld, + subTileToScreen, + screenToSubTile, + computeViewportAnchors, + computePanelCameraShift, +} from '../../src/client/view/viewport-profile.ts' +import { buildCollisionGridFromLevelView } from '../../src/client/view/scene-source.ts' +import { + buildSceneDrawables, + transToBlendMode, + type RenderableUnitInput, +} from '../../src/client/view/unit-drawables.ts' import { HudModel } from '../../src/client/ui-model/hud-model.ts' +import { + INV_COLS, + INV_ROWS, + INV_GRID_ORIGIN, + INVENTORY_TXT_LAYOUTS, + getInventoryLayout, + InventoryPanel, + WEAPON_SWAP_TABS_BOUNDS, + resolveWeaponSwapTabClick, + STASH_GOLD_CAP, + getInventoryGoldLimit, + findFreeGridSlot, + type UiInventoryItem, + type GridPlacement, +} from '../../src/client/ui/inventory.ts' +import { + CUBE_GRID_ORIGIN, + WorldPanelsHud, + getActQuests, + getActWaypoints, + computeNpcMenuAnchorY, +} from '../../src/client/ui/world-panels.ts' +import { + CharacterSheetPanel, + type CharacterAttributes, +} from '../../src/client/ui/character-sheet.ts' +import { D2FontRenderer } from '../../src/client/ui/font.ts' +import { HudManager } from '../../src/client/ui/hud-manager.ts' +import { Minimap } from '../../src/client/ui/minimap.ts' import { onlineItemDataToUiInventoryItem } from '../../src/common/items/item-bridge.ts' import { + BeltModel, + getBeltRowsForCode, + getBeltSlotBox, + itemToBeltPotion, + BELT_COLS, + BELT_ROWS, +} from '../../src/common/items/belt-model.ts' +import { computeMonsterMoveSpeedSubTilesPerSec } from '../../src/common/units/monsters-data.ts' +import { + AVATAR_COMPONENT_NAMES, + CAMPFIRE_FLAME_CONTROL_SPEC, + CAMPFIRE_CLASS_SPECS, + CANONICAL_D2LAUNCH_TBL_BY_ID, + D2_COLORS_TXT_PALETTE, + charSummaryToRosterCharacter, createInitialFlowState, + enterCharCreate, filterAccountName, + filterCharNameInput, + formatCharacterLevelAndClass, isValidAccountChar, + isValidCharacterName, + canAcceptCharNameChar, mapCreateAccountServerStatus, + parseMcpAvatarStatstring, + resolveClassNameByTbl, + resolveStringByTblId, + selectCampfireClass, + toggleChatGem, validateNewAccountInputs, } from '../../src/client/frontend/flow-state.ts' +import { + FrontendRenderer, + CAMPFIRE_OVERLAY_SPECS, + type FrontendManifest, +} from '../../src/client/frontend/frontend-renderer.ts' +import { + lookupTbl, + requireTbl, + getLang, + setLang, + getGameLang, + setGameLang, + getTblLang, + resolveLangFromPathname, + detectLangFromUrl, +} from '../../src/client/i18n/lang.ts' import { isTownLevel } from '../../src/common/world/scene-population.ts' import { decode0x8ECorpseAssign } from '../../src/netproto/d2gs/s2c/party.ts' @@ -114,6 +204,19 @@ export { pickVariant, DRLG_UNIVERSAL_DT1, + // Viewport & Projection + VIEWPORT_800x600, + getViewportProfile, + subTileToWorldOrtho, + worldOrthoToSubTile, + worldToScreen, + screenToWorld, + subTileToScreen, + screenToSubTile, + computeViewportAnchors, + computePanelCameraShift, + buildCollisionGridFromLevelView, + // Animation & Speed Constants / Math CHAR_ANIM_CLIPS_113C, computeAnimFrameIndex, @@ -135,11 +238,14 @@ export { calculateHitChance, calculatePlayerBlockChance, - // Missiles & Overlays + // Missiles, Scene Drawables & Overlays CANONICAL_113C_MISSILES, getMissileTxtData, getOverlayTxtData, getSkillCastOverlay, + buildSceneDrawables, + transToBlendMode, + type RenderableUnitInput, // Protocol & Tables C2S_PACKET_SIZES, @@ -149,17 +255,77 @@ export { BncsOpcode, encodeBncsCreateAccount2, - // Client Classes + // Client Classes, Combat, UI, & Items ClientWorld, + ClientUnit, + CommandMapper, + ViewportCamera, HudModel, + HudManager, + INV_COLS, + INV_ROWS, + INV_GRID_ORIGIN, + INVENTORY_TXT_LAYOUTS, + getInventoryLayout, + InventoryPanel, + WEAPON_SWAP_TABS_BOUNDS, + resolveWeaponSwapTabClick, + STASH_GOLD_CAP, + getInventoryGoldLimit, + findFreeGridSlot, + type UiInventoryItem, + type GridPlacement, + CUBE_GRID_ORIGIN, + WorldPanelsHud, + getActQuests, + getActWaypoints, + computeNpcMenuAnchorY, + CharacterSheetPanel, + type CharacterAttributes, + D2FontRenderer, + Minimap, onlineItemDataToUiInventoryItem, + BeltModel, + getBeltRowsForCode, + getBeltSlotBox, + itemToBeltPotion, + BELT_COLS, + BELT_ROWS, - // Frontend flow + // Frontend flow, Renderer, & i18n + AVATAR_COMPONENT_NAMES, + CAMPFIRE_FLAME_CONTROL_SPEC, + CAMPFIRE_CLASS_SPECS, + CAMPFIRE_OVERLAY_SPECS, + CANONICAL_D2LAUNCH_TBL_BY_ID, + D2_COLORS_TXT_PALETTE, + FrontendRenderer, + charSummaryToRosterCharacter, + computeMonsterMoveSpeedSubTilesPerSec, createInitialFlowState, + enterCharCreate, filterAccountName, + filterCharNameInput, + formatCharacterLevelAndClass, isValidAccountChar, + isValidCharacterName, + canAcceptCharNameChar, mapCreateAccountServerStatus, + parseMcpAvatarStatstring, + resolveClassNameByTbl, + resolveStringByTblId, + selectCampfireClass, + toggleChatGem, validateNewAccountInputs, + lookupTbl, + requireTbl, + getLang, + setLang, + getGameLang, + setGameLang, + getTblLang, + resolveLangFromPathname, + detectLangFromUrl, } // ───────────────────────────────────────────────────────────────────────────── @@ -167,26 +333,19 @@ export { // ───────────────────────────────────────────────────────────────────────────── /** - * Projects continuous sub-tile coordinates into 2:1 isometric screen coordinates. - * Each sub-tile diamond is 16x8 pixels. + * Projects continuous sub-tile coordinates into 2:1 isometric screen coordinates + * by delegating directly to production `subTileToWorldOrtho` (`src/client/view/viewport-profile.ts`). */ export function subTileToOrthoScreen(subX: number, subY: number): { x: number; y: number } { - return { - x: (subX - subY) * ORTHO_SUB_TILE_WIDTH, - y: (subX + subY) * ORTHO_SUB_TILE_HEIGHT, - } + return subTileToWorldOrtho(subX, subY, 0, 0) } /** - * Inverse projects 2:1 isometric screen coordinates back to continuous sub-tile coordinates. + * Inverse projects 2:1 isometric screen coordinates back to continuous sub-tile coordinates + * by delegating directly to production `worldOrthoToSubTile` (`src/client/view/viewport-profile.ts`). */ export function orthoScreenToSubTile(screenX: number, screenY: number): { x: number; y: number } { - const normX = screenX / ORTHO_SUB_TILE_WIDTH - const normY = screenY / ORTHO_SUB_TILE_HEIGHT - return { - x: (normY + normX) / 2, - y: (normY - normX) / 2, - } + return worldOrthoToSubTile(screenX, screenY, 0, 0) } /** @@ -200,12 +359,13 @@ export function tileToSubTile(tileX: number, tileY: number, subOffset = { x: 0, } // ───────────────────────────────────────────────────────────────────────────── -// Mock Collision Grid Oracle +// Production-Backed Collision Grid Oracle // ───────────────────────────────────────────────────────────────────────────── export interface CollisionGridOracle { readonly width: number readonly height: number + readonly world: ClientWorld getCollision(subX: number, subY: number): number setCollision(subX: number, subY: number, mask: number): void isWalkable(subX: number, subY: number): boolean @@ -213,14 +373,26 @@ export interface CollisionGridOracle { } /** - * Creates an in-memory collision grid conforming to 1.13c multi-layer collision flags. + * Creates a collision grid backed by a real `ClientWorld` instance (`setSubTileBlocked` / `isSubTileBlocked`) + * alongside 1.13c multi-layer collision bitmasks (`COLLIDE_*` from `src/common/world/d2map.ts`). */ export function createCollisionGrid(width: number, height: number, defaultMask = COLLIDE_NONE): CollisionGridOracle { const data = new Uint16Array(width * height).fill(defaultMask) + const world = new ClientWorld() + const groundBlockingMask = COLLIDE_WALL | COLLIDE_BLANK | COLLIDE_OBJECT | COLLIDE_DOOR + + if ((defaultMask & groundBlockingMask) !== 0) { + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + world.setSubTileBlocked(x, y, true) + } + } + } const oracle: CollisionGridOracle = { width, height, + world, getCollision(subX: number, subY: number): number { if (subX < 0 || subX >= width || subY < 0 || subY >= height) { return COLLIDE_BLANK | COLLIDE_WALL // Void out-of-bounds protection @@ -230,13 +402,17 @@ export function createCollisionGrid(width: number, height: number, defaultMask = setCollision(subX: number, subY: number, mask: number): void { if (subX >= 0 && subX < width && subY >= 0 && subY < height) { data[subY * width + subX] = mask + world.setSubTileBlocked(subX, subY, (mask & groundBlockingMask) !== 0) } }, isWalkable(subX: number, subY: number): boolean { + if (subX < 0 || subX >= width || subY < 0 || subY >= height) { + return false + } const flags = oracle.getCollision(subX, subY) - // Any wall, blank, closed door, or obstacle blocks ground walking - const blocking = COLLIDE_WALL | COLLIDE_BLANK | COLLIDE_OBJECT | COLLIDE_DOOR - return (flags & blocking) === 0 + const blockedByMask = (flags & groundBlockingMask) !== 0 + const blockedInWorld = world.isSubTileBlocked(subX, subY) + return !blockedByMask && !blockedInWorld }, hasLineOfSight(x0: number, y0: number, x1: number, y1: number): boolean { // Bresenham raycast checking COLLIDE_VISIBLE and COLLIDE_MISSILE_BARRIER @@ -389,3 +565,240 @@ export function createParitySessionFixture(options: { selfId?: number; charClass }, } } + +// ───────────────────────────────────────────────────────────────────────────── +// HUD & Frontend Canvas Recording Helpers +// ───────────────────────────────────────────────────────────────────────────── + +export function createMockHudCanvasContext() { + const drawTextCalls: { text: string; x: number; y: number; color?: string; font?: string }[] = [] + const font = new D2FontRenderer() + vi.spyOn(font, 'drawText').mockImplementation((_ctx, text, x, y, opts) => { + drawTextCalls.push({ + text: String(text), + x, + y, + ...(opts?.color !== undefined ? { color: opts.color } : {}), + ...(opts?.font !== undefined ? { font: opts.font } : {}), + }) + return String(text).length * 8 + }) + const ctx = { + save: vi.fn(), + restore: vi.fn(), + translate: vi.fn(), + drawImage: vi.fn(), + fillRect: vi.fn(), + strokeRect: vi.fn(), + beginPath: vi.fn(), + moveTo: vi.fn(), + lineTo: vi.fn(), + arc: vi.fn(), + fill: vi.fn(), + stroke: vi.fn(), + fillText: vi.fn(), + measureText: vi.fn((t: string) => ({ width: t.length * 7 })), + fillStyle: '#000', + strokeStyle: '#000', + lineWidth: 1, + globalAlpha: 1, + } as unknown as CanvasRenderingContext2D + return { ctx, font, drawTextCalls } +} + +export interface FrontendDrawImageCall { + readonly img: unknown + readonly args: readonly number[] + readonly compositeOp: string + readonly alpha: number + readonly filter: string +} + +export function createRecordingFrontendRenderer() { + const drawCalls: FrontendDrawImageCall[] = [] + const stateStack: Array<{ compositeOp: string; alpha: number; filter: string; fillStyle: string }> = [] + + const ctx = { + imageSmoothingEnabled: false, + globalCompositeOperation: 'source-over', + globalAlpha: 1, + filter: 'none', + fillStyle: '#000000', + strokeStyle: '#000000', + lineWidth: 1, + save() { + stateStack.push({ + compositeOp: this.globalCompositeOperation, + alpha: this.globalAlpha, + filter: this.filter, + fillStyle: this.fillStyle, + }) + }, + restore() { + const prev = stateStack.pop() + if (prev) { + this.globalCompositeOperation = prev.compositeOp + this.globalAlpha = prev.alpha + this.filter = prev.filter + this.fillStyle = prev.fillStyle + } + }, + clearRect() {}, + fillRect() {}, + strokeRect() {}, + beginPath() {}, + moveTo() {}, + lineTo() {}, + stroke() {}, + rect() {}, + clip() {}, + drawImage(img: unknown, ...args: number[]) { + drawCalls.push({ + img, + args, + compositeOp: this.globalCompositeOperation, + alpha: this.globalAlpha, + filter: this.filter, + }) + }, + } + + const fakeCanvas = { + width: 800, + height: 600, + style: { cursor: 'none' }, + getContext: () => ctx, + getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }), + } as unknown as HTMLCanvasElement + + const state = createInitialFlowState() + const renderer = new FrontendRenderer(fakeCanvas, state) + + const stubImg = (id: string) => ({ __id: id, width: 800, height: 600 }) as unknown as HTMLImageElement + for (const key of [ + 'bg-main-menu.png', + 'bg-char-create.png', + 'bg-char-select.png', + 'bg-waiting-room.png', + 'popup-340x224.png', + 'popup-okcancel.png', + 'terms-of-use-bg.png', + 'scrollbar.png', + 'btn-popup-small.png', + 'create-game-bg.png', + 'charselectbox.png', + 'realmselect.png', + 'btn-realm-thin.png', + 'btn-wide.png', + 'btn-3wide.png', + 'btn-wide02.png', + 'btn-medium.png', + 'btn-short.png', + 'btn-tall.png', + 'textbox.png', + 'clickbox.png', + 'joingameclickbox.png', + 'radiobutton.png', + 'gembutton.png', + 'btn-chatleft.png', + 'btn-chatrighttop.png', + 'btn-chatright.png', + 'btn-game.png', + 'btn-create-cancel.png', + 'btn-create-game.png', + 'grey-level-box.png', + 'cursor-hand.png', + ]) { + renderer.images.set(key, stubImg(key)) + } + + const glyphs = Array.from({ length: 256 }, (_, code) => ({ + code, + width: 8, + height: 14, + x: (code % 16) * 16, + y: Math.floor(code / 16) * 16, + frameW: 8, + frameH: 14, + })) + for (const fontName of [ + 'font6', + 'font8', + 'font16', + 'font24', + 'font30', + 'font42', + 'fontformal10', + 'fontformal11', + 'fontformal12', + 'fontexocet8', + 'fontexocet10', + 'fontridiculous', + ]) { + renderer.fontMetas[fontName] = { + name: fontName, + lineHeight: 14, + capHeight: 12, + atlasWidth: 256, + atlasHeight: 256, + glyphs, + } + for (const colorIdx of [0, 1, 2, 3, 4, 5, 7, 8, 9]) { + renderer.images.set(`fonts/${fontName}-c${colorIdx}.png`, stubImg(`fonts/${fontName}-c${colorIdx}.png`)) + } + } + + const canonicalStringsById = Object.fromEntries( + Object.entries(CANONICAL_D2LAUNCH_TBL_BY_ID).map(([id, val]) => [String(id), val]), + ) + + const minimalManifest: FrontendManifest = { + version: '1.13c', + decodedDc6Count: 0, + fontColors: [0, 1, 2, 3, 4, 5, 7, 8, 9], + animStrips: {}, + campfireClasses: {} as FrontendManifest['campfireClasses'], + charAvatars: {}, + strings: { + ...canonicalStringsById, + '5127': 'Select Hero Class', + '5272': 'Select Your Character', + singlePlayer: 'SINGLE PLAYER', + battleNet: 'BATTLE.NET', + otherMultiplayer: 'OTHER MULTIPLAYER', + exitDiablo2: 'EXIT DIABLO II', + credits: 'CREDITS', + cinematics: 'CINEMATICS', + loginTitle: 'Battle.net Log In', + loginInstruction: + "To use an existing Battle.net account, enter the information below and click 'Log In'. Otherwise, click 'Create New Account'.", + accountName: 'Account Name', + accountPassword: 'Password', + password: 'Password', + logIn: 'LOG IN', + changePassword: 'CHANGE PASSWORD', + createNewAccount: 'CREATE NEW ACCOUNT', + accountSettings: 'ACCOUNT SETTINGS', + selectCharacterTitle: 'Select Your Character', + createNewCharacter: 'CREATE NEW CHARACTER', + convertToExpansion: 'CONVERT TO EXPANSION', + deleteCharacter: 'DELETE CHARACTER', + '5273': 'CREATE NEW CHARACTER', + '5274': 'DELETE CHARACTER', + '22729': 'CONVERT TO EXPANSION', + cancel: 'CANCEL', + ok: 'OK', + tblById: { + ...canonicalStringsById, + '5127': 'Select Hero Class', + '5272': 'Select Your Character', + }, + } as unknown as FrontendManifest['strings'], + } + + renderer.manifest = minimalManifest + renderer.ready = true + + return { renderer, state, ctx, drawCalls } +} + diff --git a/tests/e2e-parity/tier1-feature-coverage.test.ts b/tests/e2e-parity/tier1-feature-coverage.test.ts index b4e9391..ca4831d 100644 --- a/tests/e2e-parity/tier1-feature-coverage.test.ts +++ b/tests/e2e-parity/tier1-feature-coverage.test.ts @@ -6,19 +6,29 @@ * ensuring strict conformance with Diablo II v1.13c decompiled ground truth. */ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { + BeltModel, BncsCreateAccountStatus, C2S_PACKET_SIZES, + CAMPFIRE_CLASS_SPECS, + CAMPFIRE_FLAME_CONTROL_SPEC, + CAMPFIRE_OVERLAY_SPECS, CANONICAL_113C_MISSILES, CHAR_ANIM_CLIPS_113C, COLLIDE_BLANK, COLLIDE_DOOR, COLLIDE_NONE, COLLIDE_WALL, + CUBE_GRID_ORIGIN, + CommandMapper, D2_TICK_MS, DRLG_UNIVERSAL_DT1, - mapCreateAccountServerStatus, + INV_COLS, + INV_GRID_ORIGIN, + INV_ROWS, + InventoryPanel, + Minimap, ORTHO_CELL_HEIGHT, ORTHO_CELL_WIDTH, ORTHO_SUB_TILE_HEIGHT, @@ -26,37 +36,63 @@ import { S2C_PACKET_SIZES, STAT_FASTERATTACKRATE, STAT_FASTERCASTRATE, + VIEWPORT_800x600, + buildSceneDrawables, calculateHitChance, calculatePlayerBlockChance, + canAcceptCharNameChar, + charSummaryToRosterCharacter, computeAnimFrameIndex, computeDiminishingRate113c, computeEffectiveFcr, computeEffectiveIas, + computeMonsterMoveSpeedSubTilesPerSec, computeOneShotDurationMs, computeOneShotTicks, computeStepSpeed256, + computeViewportAnchors, createCollisionGrid, createInitialFlowState, + createMockHudCanvasContext, createParitySessionFixture, + createRecordingFrontendRenderer, + decode0x8ECorpseAssign, filterAccountName, + filterCharNameInput, + getBeltRowsForCode, + getBeltSlotBox, + getInventoryLayout, + getLang, getMissileTxtData, getOverlayTxtData, getSkillCastOverlay, + getViewportProfile, isTownLevel, - decode0x8ECorpseAssign, isValidAccountChar, + isValidCharacterName, + lookupTbl, makeParityItem, + mapCreateAccountServerStatus, onlineItemDataToUiInventoryItem, orthoScreenToSubTile, pickVariant, + resolveClassNameByTbl, + resolveLangFromPathname, + resolveStringByTblId, + setLang, subTileToOrthoScreen, + subTileToWorldOrtho, tileToSubTile, + transToBlendMode, validateNewAccountInputs, + worldOrthoToSubTile, + worldToScreen, + ViewportCamera, } from './helpers.ts' describe('Tier 1 — Core Feature Coverage across 8 Subsystem Batches (48 Tests)', () => { // ═══════════════════════════════════════════════════════════════════════════ - // Batch 1: Engine Timing, Coordinate Math & Tick Loop (Issues #562–#576) + // Batch 1 (M1): Adversarial & Stress Verification (Issues #562–#576) // ═══════════════════════════════════════════════════════════════════════════ describe('Batch 1 — Engine Timing, Coordinate Math & Tick Loop', () => { it('T1.1.1: 25Hz simulation tick advances exactly 40ms and processes accumulated time', () => { @@ -258,9 +294,48 @@ describe('Tier 1 — Core Feature Coverage across 8 Subsystem Batches (48 Tests) }) it('T1.3.2: Walk vs Run mode velocity and stamina parameters follow 1.13c ratios', () => { - const walkSpeed = 6 // subtiles per second - const runSpeed = 9 // subtiles per second (50% faster than walk) - expect(runSpeed / walkSpeed).toBe(1.5) + const walkSpeed = computeMonsterMoveSpeedSubTilesPerSec(211, false) + const runSpeed = computeMonsterMoveSpeedSubTilesPerSec(211, true) + expect(runSpeed).toBeGreaterThan(walkSpeed) + + const fixture = createParitySessionFixture({ selfId: 1 }) + fixture.applyServerEvents( + { + type: 'UnitAssign', + unit: { unitType: 'monster', id: 211, classId: 211, x: 100, y: 100, mode: 1 }, + }, + { + type: 'UnitMove', + unitType: 'monster', + id: 211, + from: { x: 100, y: 100 }, + to: { x: 120, y: 100 }, + mode: 'walk', + }, + ) + expect(fixture.world.getUnit('monster', 211)?.mode).toBe(2) + fixture.tick(1) + const walkDelta = fixture.world.getUnit('monster', 211)!.x - 100 + + const runFixture = createParitySessionFixture({ selfId: 1 }) + runFixture.applyServerEvents( + { + type: 'UnitAssign', + unit: { unitType: 'monster', id: 211, classId: 211, x: 100, y: 100, mode: 1 }, + }, + { + type: 'UnitMove', + unitType: 'monster', + id: 211, + from: { x: 100, y: 100 }, + to: { x: 120, y: 100 }, + mode: 'run', + }, + ) + expect(runFixture.world.getUnit('monster', 211)?.mode).toBe(15) + runFixture.tick(1) + const runDelta = runFixture.world.getUnit('monster', 211)!.x - 100 + expect(runDelta).toBeGreaterThan(walkDelta) // Player block chance is reduced to 1/3 and capped at 25% while running const standingBlock = calculatePlayerBlockChance(75, 200, 50, false) @@ -430,43 +505,114 @@ describe('Tier 1 — Core Feature Coverage across 8 Subsystem Batches (48 Tests) expect(missileAfter!.x).toBeGreaterThan(100) // advanced along path }) - it('T1.5.2: Server entity life percentage scaling (0..128 to 0..100%)', () => { - const scaleLife128ToPercent = (l: number) => Math.round((l * 100) / 128) - expect(scaleLife128ToPercent(128)).toBe(100) - expect(scaleLife128ToPercent(64)).toBe(50) - expect(scaleLife128ToPercent(0)).toBe(0) - expect(scaleLife128ToPercent(1)).toBe(1) + it('T1.5.2: Server entity life percentage scaling (0..128 to 0..100%) via ClientUnit.lifePercent', () => { + const fixture = createParitySessionFixture({ selfId: 1 }) + fixture.applyServerEvents({ + type: 'UnitAssign', + isSelf: false, + unit: { + unitType: 'monster', + id: 301, + classId: 1, + x: 110, + y: 110, + mode: 1, + lifePct: 128, + }, + }) + const monster = fixture.world.getUnit('monster', 301)! + expect(monster).toBeDefined() + expect(monster.lifePercent).toBe(100) + expect(monster.dead).toBe(false) + + monster.lifePct = 64 + expect(monster.lifePercent).toBe(50) + + monster.lifePct = 1 + expect(monster.lifePercent).toBe(1) + + monster.lifePct = 0 + expect(monster.lifePercent).toBe(0) + expect(monster.dead).toBe(true) }) - it('T1.5.3: Viewport coordinate culling defines boundaries for 800x600 resolution', () => { - const VIEWPORT_WIDTH = 800 - const VIEWPORT_HEIGHT = 600 - const isVisible = (screenX: number, screenY: number) => { - return ( - screenX >= -ORTHO_CELL_WIDTH && - screenX <= VIEWPORT_WIDTH + ORTHO_CELL_WIDTH && - screenY >= -ORTHO_CELL_HEIGHT && - screenY <= VIEWPORT_HEIGHT + ORTHO_CELL_HEIGHT - ) - } + it('T1.5.3: Viewport coordinate culling defines boundaries for 800x600 resolution via VIEWPORT_800x600 and worldToScreen', () => { + const profile = getViewportProfile('800x600') + expect(profile.width).toBe(VIEWPORT_800x600.width) + expect(profile.height).toBe(VIEWPORT_800x600.height) - expect(isVisible(400, 300)).toBe(true) // Center screen - expect(isVisible(-200, 300)).toBe(false) // Culled far left - expect(isVisible(1200, 300)).toBe(false) // Culled far right + const anchors = computeViewportAnchors(profile) + expect(anchors.bottomBar.y).toBe(552) + expect(anchors.leftPanel.x).toBe(0) + expect(anchors.rightPanel.x).toBe(400) + expect(anchors.playAreaCenter).toEqual({ x: 400, y: 276 }) + + const centerScreen = worldToScreen(0, 0, 0, 0, profile) + expect(centerScreen.x).toBe(400) + expect(centerScreen.y).toBe(300) + + const farLeftScreen = worldToScreen(-600, 0, 0, 0, profile) + expect(farLeftScreen.x).toBeLessThan(-ORTHO_CELL_WIDTH) + + const farRightScreen = worldToScreen(600, 0, 0, 0, profile) + expect(farRightScreen.x).toBeGreaterThan(profile.width + ORTHO_CELL_WIDTH) }) - it('T1.5.4: Tile layer z-ordering (floor -> shadow -> wall -> roof) maintains strict depth order', () => { - const LAYER_FLOOR = 0 - const LAYER_SHADOW = 1 - const LAYER_WALL = 2 - const LAYER_ROOF = 3 + it('T1.5.4: Entity & overlay layer z-ordering (corpse/item -> unit/missile -> overlay) maintains strict depth order via buildSceneDrawables', () => { + const result = buildSceneDrawables([ + { + kind: 'player', // layer 1 + id: 1, + classId: 1, + x: 100, + y: 100, + mode: 1, + direction: 0, + overlays: [ + { + overlayId: 10, + token: 'frozenarmor', // overlay (layer 2) + loop: false, + }, + ], + }, + { + kind: 'monster', // monster corpse (mode 12 -> layer 0) + id: 201, + classId: 1, + x: 100, + y: 100, + mode: 12, + direction: 0, + isCorpse: true, + lifePct: 0, + }, + { + kind: 'item', // ground item (layer 0) + id: 301, + classId: 0, + x: 100, + y: 100, + mode: 3, + direction: 0, + code: 'cap', + }, + ]) - expect(LAYER_FLOOR).toBeLessThan(LAYER_SHADOW) - expect(LAYER_SHADOW).toBeLessThan(LAYER_WALL) - expect(LAYER_WALL).toBeLessThan(LAYER_ROOF) + expect(result.sortedDrawables.length).toBeGreaterThanOrEqual(4) + const indexed = result.sortedDrawables.map((d, idx) => ({ d, idx })) + const itemEntry = indexed.find((x) => x.d.drawableKind === 'unit' && x.d.unitKind === 'item')! + const corpseEntry = indexed.find((x) => x.d.drawableKind === 'unit' && x.d.id === 201)! + const playerEntry = indexed.find((x) => x.d.drawableKind === 'unit' && x.d.id === 1)! + const overlayEntry = indexed.find((x) => x.d.drawableKind === 'overlay')! + + // Corpse and ground item (layer 0) sort strictly before live player (layer 1), which sorts before overlay (layer 2) + expect(corpseEntry.idx).toBeLessThan(playerEntry.idx) + expect(itemEntry.idx).toBeLessThan(playerEntry.idx) + expect(playerEntry.idx).toBeLessThan(overlayEntry.idx) }) - it('T1.5.5: Mouse hover hitbox against DCC screen bounds accurately resolves entity hit', () => { + it('T1.5.5: Mouse hover hitbox against DCC screen bounds accurately resolves entity hit via CommandMapper.findUnitAtScreenPoint', () => { const fixture = createParitySessionFixture({ selfId: 1 }) // Spawn a monster at (110, 110) fixture.applyServerEvents({ @@ -483,11 +629,38 @@ describe('Tier 1 — Core Feature Coverage across 8 Subsystem Batches (48 Tests) }, }) - const monster = fixture.world.getUnit('monster', 201) + const monster = fixture.world.getUnit('monster', 201)! expect(monster).toBeDefined() - expect(monster?.unitType).toBe('monster') - expect(monster?.x).toBe(110) - expect(monster?.y).toBe(110) + const hitBox = monster.getHitBox() + expect(hitBox.width).toBeGreaterThan(0) + expect(hitBox.height).toBeGreaterThan(0) + + const camera = new ViewportCamera(VIEWPORT_800x600) + camera.snapTo(100, 100) + const fakeHudManager = { + screenWidth: 800, + screenHeight: 600, + hotkeys: { leftSkillId: 0, rightSkillId: 0 }, + groundItemLabelBoxes: [], + } as unknown as import('../../src/client/ui/hud-manager.ts').HudManager + + const mapper = new CommandMapper({ + world: fixture.world, + camera, + hudManager: fakeHudManager, + hudModel: fixture.hudModel, + sendCommand: (cmd) => fixture.sentCommands.push(cmd), + }) + const monsterScreen = camera.subtileToScreen(monster.x, monster.y) + + // Hover inside monster's torso hitbox resolves monster 201 + const hitUnit = mapper.findUnitAtScreenPoint(monsterScreen.x, monsterScreen.y - 24) + expect(hitUnit).toBeDefined() + expect(hitUnit?.id).toBe(201) + expect(hitUnit?.unitType).toBe('monster') + + // Hover far outside hitbox returns null + expect(mapper.findUnitAtScreenPoint(10, 10)).toBeNull() }) it('T1.5.6: Net-world authority — server events drive client world state, overriding client prediction', () => { @@ -513,41 +686,84 @@ describe('Tier 1 — Core Feature Coverage across 8 Subsystem Batches (48 Tests) // Batch 6: UI, HUD, Inventory & Tooltip Systems (Issues #664–#689) // ═══════════════════════════════════════════════════════════════════════════ describe('Batch 6 — UI, HUD, Inventory & Tooltip Systems', () => { - it('T1.6.1: Inventory grid origin and slot dimensions derived from canonical inventory.txt', () => { - // 1.13c inventory: 10 columns by 4 rows of 29x29 pixel cells - const INV_COLS = 10 - const INV_ROWS = 4 - const CELL_SIZE = 29 - expect(INV_COLS * CELL_SIZE).toBe(290) - expect(INV_ROWS * CELL_SIZE).toBe(116) + it('T1.6.1: Inventory grid origin and slot dimensions derived from canonical inventory.txt via getInventoryLayout', () => { + expect(INV_COLS).toBe(10) + expect(INV_ROWS).toBe(4) + expect(INV_GRID_ORIGIN.x).toBe(419) + expect(INV_GRID_ORIGIN.y).toBe(315) + + const sorcLayout = getInventoryLayout('Sorceress2') + expect(sorcLayout.gridX).toBe(10) + expect(sorcLayout.gridY).toBe(4) + expect(sorcLayout.gridBoxWidth).toBe(29) + expect(sorcLayout.gridBoxHeight).toBe(29) + expect(sorcLayout.gridLeft).toBe(419) + expect(sorcLayout.gridTop).toBe(315) + expect(sorcLayout.gridX * sorcLayout.gridBoxWidth).toBe(290) + expect(sorcLayout.gridY * sorcLayout.gridBoxHeight).toBe(116) }) - it('T1.6.2: Horadric Cube grid origin set to (198, y) matching 1.13c Transmogrify Box2', () => { - const CUBE_ORIGIN_X = 198 - expect(CUBE_ORIGIN_X).toBe(198) // Corrected from legacy 197 bug + it('T1.6.2: Horadric Cube grid origin set to (198, 199) matching 1.13c Transmogrify Box Page 1 via CUBE_GRID_ORIGIN and getInventoryLayout', () => { + expect(CUBE_GRID_ORIGIN.x).toBe(198) + expect(CUBE_GRID_ORIGIN.y).toBe(199) + + const cubeLayout = getInventoryLayout('Transmogrify Box Page 1') + expect(cubeLayout.gridX).toBe(3) + expect(cubeLayout.gridY).toBe(4) + expect(cubeLayout.gridBoxWidth).toBe(29) + expect(cubeLayout.gridBoxHeight).toBe(29) }) - it('T1.6.3: Belt quick-slot row counts derived from equipped belt item (Belts.txt) with (col, row) indexing', () => { - // Sash/Light Belt = 2 rows (8 slots), Heavy/Plated Belt = 4 rows (16 slots) - const beltTypeRows: Record = { - lbl: 2, // Light Belt - vbl: 3, // Heavy Belt - tbl: 4, // Plated Belt - } - expect(beltTypeRows['lbl']).toBe(2) - expect(beltTypeRows['tbl']).toBe(4) + it('T1.6.3: Belt quick-slot row counts derived from equipped belt item (Belts.txt) via getBeltRowsForCode and BeltModel', () => { + expect(getBeltRowsForCode(null)).toBe(1) // No belt = 1 row (4 slots) + expect(getBeltRowsForCode('lbl')).toBe(2) // Sash = 2 rows (8 slots) + expect(getBeltRowsForCode('vbl')).toBe(2) // Light Belt = 2 rows (8 slots) + expect(getBeltRowsForCode('mbl')).toBe(3) // Belt = 3 rows (12 slots) + expect(getBeltRowsForCode('tbl')).toBe(3) // Heavy Belt = 3 rows (12 slots) + expect(getBeltRowsForCode('hbl')).toBe(4) // Plated Belt = 4 rows (16 slots) - // getSlot(col, row) where col in 0..3 and row in 0..3 - const getSlotIndex = (col: number, row: number) => col + row * 4 - expect(getSlotIndex(0, 0)).toBe(0) - expect(getSlotIndex(3, 3)).toBe(15) + const belt = new BeltModel() + belt.setEquippedBelt('hbl') + expect(belt.maxRows).toBe(4) + belt.togglePopup() + expect(belt.activeRows).toBe(4) + + belt.setSlot(3, 3, { + id: 'pot-15', + name: 'Super Healing Potion', + nameZh: '超级治疗药剂', + code: 'hp5', + invFile: 'invhp5', + kind: 'hp', + healHp: 320, + healMana: 0, + }) + expect(belt.getSlot(3, 3)?.id).toBe('pot-15') + expect(getBeltSlotBox(0, 0).x).toBe(423) }) - it('T1.6.4: Gold amount formatting without thousands commas per 1.13c specification', () => { - const formatGold113c = (amount: number) => String(Math.floor(amount)) - expect(formatGold113c(1500)).toBe('1500') - expect(formatGold113c(250000)).toBe('250000') - expect(formatGold113c(1500)).not.toMatch(/,/) + it('T1.6.4: Gold amount formatting without thousands commas per 1.13c specification via InventoryPanel.draw', () => { + const { ctx, font, drawTextCalls } = createMockHudCanvasContext() + const panel = new InventoryPanel() + panel.visible = true + panel.gold = 250000 + panel.draw( + ctx, + { + borderRightImg: null, + invSheetImg: null, + invTab0Img: null, + invTab1Img: null, + buySellBtnImg: null, + itemsAtlasImg: null, + itemRects: {}, + }, + font, + ) + + const goldTexts = drawTextCalls.map((c) => c.text) + expect(goldTexts).toContain('250000') + expect(goldTexts.some((t) => t.includes('250,000'))).toBe(false) }) it('T1.6.5: Online item tooltip resolves localized base names, defense, durability, and stats', () => { @@ -634,7 +850,7 @@ describe('Tier 1 — Core Feature Coverage across 8 Subsystem Batches (48 Tests) expect(fixture.selfUnit?.mode).toBe(3) }) - it('T1.7.4: Battle.net character name validation rules (2-15 chars, alphanumeric with allowed separators)', () => { + it('T1.7.4: Battle.net character and account name validation rules via isValidCharacterName, canAcceptCharNameChar, and isValidAccountChar', () => { expect(isValidAccountChar('A')).toBe(true) expect(isValidAccountChar('z')).toBe(true) expect(isValidAccountChar('9')).toBe(true) @@ -643,6 +859,15 @@ describe('Tier 1 — Core Feature Coverage across 8 Subsystem Batches (48 Tests) expect(isValidAccountChar('"')).toBe(false) expect(isValidAccountChar(' ')).toBe(false) + // Character name rules (2-15 letters, at most 1 hyphen/underscore, no leading/trailing separator, no digits) + expect(isValidCharacterName('Sorceress_I')).toBe(true) + expect(isValidCharacterName('Hero99')).toBe(false) + expect(isValidCharacterName('_Leading')).toBe(false) + expect(isValidCharacterName('Trailing-')).toBe(false) + expect(isValidCharacterName('Two_Sep-Test')).toBe(false) + expect(canAcceptCharNameChar('Valid_Name', '_')).toBe(false) + expect(filterCharNameInput('Sorc_123-X!')).toBe('Sorc_X') + const valOk = validateNewAccountInputs({ accountName: 'ValidHero_1', accountPassword: 'pass1234', accountVerifyPassword: 'pass1234' }) expect(valOk.valid).toBe(true) @@ -653,11 +878,22 @@ describe('Tier 1 — Core Feature Coverage across 8 Subsystem Batches (48 Tests) expect(valMismatch.valid).toBe(false) }) - it('T1.7.5: Campfire expansion flame anchor alignment at (345, 470) with character overlay compositing', () => { - const CAMPFIRE_ANCHOR_X = 345 - const CAMPFIRE_ANCHOR_Y = 470 - expect(CAMPFIRE_ANCHOR_X).toBe(345) - expect(CAMPFIRE_ANCHOR_Y).toBe(470) + it('T1.7.5: Campfire expansion flame anchor alignment at (345, 470) with character overlay compositing via CAMPFIRE_FLAME_CONTROL_SPEC and CAMPFIRE_OVERLAY_SPECS', () => { + expect(CAMPFIRE_FLAME_CONTROL_SPEC.x).toBe(345) + expect(CAMPFIRE_FLAME_CONTROL_SPEC.y).toBe(470) + expect(CAMPFIRE_FLAME_CONTROL_SPEC.w).toBe(110) + expect(CAMPFIRE_FLAME_CONTROL_SPEC.h).toBe(127) + expect(CAMPFIRE_FLAME_CONTROL_SPEC.drawMode).toBe(3) + + expect(CAMPFIRE_OVERLAY_SPECS.so?.nu3).toEqual({ + stripKey: 'nu3_ov', + drawMode: 3, + }) + expect(CAMPFIRE_OVERLAY_SPECS.so?.nu1).toBeUndefined() + expect(CAMPFIRE_OVERLAY_SPECS.so?.fw).toEqual({ + stripKey: 'fw_ov', + drawMode: 3, + }) }) it('T1.7.6: S2C 0x8E / 0x74 corpse assignment records corpse entity without killing living player', () => { @@ -680,48 +916,132 @@ describe('Tier 1 — Core Feature Coverage across 8 Subsystem Batches (48 Tests) // Batch 8: Tools, Asset Manifest & Headless Auditing (Issues #704–#712) // ═══════════════════════════════════════════════════════════════════════════ describe('Batch 8 — Tools, Asset Manifest & Headless Auditing', () => { - it('T1.8.1: Frontend text edit box cursor underscore "_" with 250ms blink rate', () => { - const CURSOR_CHAR = '_' - const BLINK_RATE_MS = 250 - expect(CURSOR_CHAR).toBe('_') - expect(BLINK_RATE_MS).toBe(250) + it('T1.8.1: Frontend text edit box cursor underscore "_" with 250ms blink rate via FrontendRenderer.render', () => { + const { renderer, state, drawCalls } = createRecordingFrontendRenderer() + state.stage = 'bnet_login' + state.activeInput = 'accountName' + state.accountName = 'Tyrael' - const isCursorVisible = (timeMs: number) => Math.floor(timeMs / BLINK_RATE_MS) % 2 === 0 - expect(isCursorVisible(0)).toBe(true) - expect(isCursorVisible(249)).toBe(true) - expect(isCursorVisible(250)).toBe(false) - expect(isCursorVisible(499)).toBe(false) - expect(isCursorVisible(500)).toBe(true) + // At nowMs = 0 -> Math.floor(0 / 250) & 1 === 0 (cursor off) + drawCalls.length = 0 + renderer.render(0) + const callsOff = drawCalls.length + + // At nowMs = 250 -> Math.floor(250 / 250) & 1 === 1 (cursor on -> draws "_" glyph at charCode 95) + drawCalls.length = 0 + renderer.render(250) + const callsOn = drawCalls.length + expect(callsOn).toBe(callsOff + 1) + + const underscoreGlyph = renderer.fontMetas.fontformal12!.glyphs[95]! + expect( + drawCalls.some((c) => c.args[0] === underscoreGlyph.x && c.args[1] === underscoreGlyph.y), + ).toBe(true) }) - it('T1.8.2: Disabled UI button rendering via TRANS50 blending instead of CSS brightness', () => { - const BLEND_MODE_DISABLED = 'TRANS50' - expect(BLEND_MODE_DISABLED).toBe('TRANS50') + it('T1.8.2: Disabled UI button rendering via TRANS50 blending (alpha=0.5, filter=none) in FrontendRenderer and transToBlendMode', () => { + expect(transToBlendMode(3)).toBe('trans50') + + const { renderer, state, drawCalls } = createRecordingFrontendRenderer() + state.stage = 'main_menu' + renderer.render(100) + + const trans50Draws = drawCalls.filter((c) => c.alpha === 0.5) + expect(trans50Draws.length).toBeGreaterThanOrEqual(5) + expect(drawCalls.every((c) => !c.filter.includes('brightness'))).toBe(true) }) - it('T1.8.3: Bypassing .tbl strings in frontend dialogs is disallowed; resolves canonical strings', () => { + it('T1.8.3: Bypassing .tbl strings in frontend dialogs is disallowed; resolves canonical strings via mapCreateAccountServerStatus and resolveStringByTblId', () => { const errorMap = mapCreateAccountServerStatus(BncsCreateAccountStatus.ALREADY_EXISTS) expect(errorMap.tblId).toBe(5239) // Canonical 1.13c TBL string ID for already exists expect(errorMap.titleTblId).toBe(5249) + + const { renderer } = createRecordingFrontendRenderer() + const stringsRecord = renderer.manifest!.strings as unknown as Record + expect(resolveStringByTblId(stringsRecord, 5239)).toContain( + 'An account already exists with the name you have chosen.', + ) + expect(() => resolveStringByTblId({}, 5223, 'createAccountInstruction')).toThrow( + /Missing required \.tbl string #5223/, + ) }) - it('T1.8.4: Asset manifest keys match canonical table and sprite names without missing index', () => { - // Canonically verified missiles in 1.13c + it('T1.8.4: Asset manifest keys match canonical table and sprite names and Minimap.loadAtlases preserves root-relative /assets/ paths', async () => { expect(Object.keys(CANONICAL_113C_MISSILES).length).toBeGreaterThan(50) const fireBolt = CANONICAL_113C_MISSILES['firebolt'] expect(fireBolt?.id).toBe(58) + + // Verify Minimap.loadAtlases does not prefix root-relative /assets/ui/automap/*.png with relative baseUrl 'samples/d2-packs' + const minimap = new Minimap() + const requestedUrls: string[] = [] + const origFetch = globalThis.fetch + const origCreateImageBitmap = globalThis.createImageBitmap + try { + globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => { + requestedUrls.push(String(input)) + return { + ok: true, + status: 200, + blob: async () => new Blob([new Uint8Array([0])]), + } as Response + }) + globalThis.createImageBitmap = vi.fn(async () => ({ + width: 128, + height: 128, + close: vi.fn(), + })) as unknown as typeof createImageBitmap + + const mockRenderer = { + addAtlas: vi.fn(() => ({ texture: {} as WebGLTexture, width: 128, height: 128 })), + } as unknown as Parameters[0] + + const loaded = await minimap.loadAtlases(mockRenderer, 'samples/d2-packs') + expect(loaded).toBe(8) + expect(requestedUrls.length).toBe(8) + for (const url of requestedUrls) { + expect(url.startsWith('/assets/ui/automap/')).toBe(true) + expect(url.includes('samples/d2-packs')).toBe(false) + } + } finally { + globalThis.fetch = origFetch + globalThis.createImageBitmap = origCreateImageBitmap + } }) - it('T1.8.5: Frontend fail-fast hardening — missing resources trigger explicit rejection', () => { + it('T1.8.5: Frontend fail-fast hardening — missing resources trigger explicit rejection via charSummaryToRosterCharacter and measureBitmapText', () => { const invalidStatus = mapCreateAccountServerStatus(999 as any) - expect(invalidStatus.tblId).toBe(5231) // safe default fallback without throwing unhandled error + expect(invalidStatus.tblId).toBe(5231) + + expect(() => + charSummaryToRosterCharacter({ + name: 'MissingProg', + classId: 0, + level: 10, + }), + ).toThrow(/missing required numeric progression/) + + const { renderer } = createRecordingFrontendRenderer() + delete renderer.fontMetas.font16 + expect(() => renderer.measureBitmapText('Test', 'font16')).toThrow( + /Missing bitmap font metrics for "font16"/, + ) }) - it('T1.8.6: i18n language fallback gracefully resolves english fallback when translation is missing', () => { - const resolveI18n = (zhText: string | undefined, enText: string) => zhText || enText - expect(resolveI18n('帽', 'Cap')).toBe('帽') - expect(resolveI18n(undefined, 'Cap')).toBe('Cap') - expect(resolveI18n('', 'Cap')).toBe('Cap') + it('T1.8.6: i18n language fallback and TBL lookup resolve localized and English strings via lookupTbl and resolveLangFromPathname', () => { + const prevLang = getLang() + try { + setLang('en') + expect(lookupTbl('strchrstr', 'ENG')).toBe('Strength') + setLang('zh') + expect(lookupTbl('strchrstr', 'CHI')).toBe('力量') + expect(lookupTbl('__non_existent_key__', 'ENG')).toBeUndefined() + + expect(resolveLangFromPathname('/acts-en.html')).toBe('en') + expect(resolveLangFromPathname('/acts-zh.html')).toBe('zh') + expect(resolveLangFromPathname('/unknown')).toBeNull() + } finally { + setLang(prevLang) + } }) }) }) diff --git a/tests/e2e-parity/tier2-boundary-corner.test.ts b/tests/e2e-parity/tier2-boundary-corner.test.ts index c4e480e..2dac5fa 100644 --- a/tests/e2e-parity/tier2-boundary-corner.test.ts +++ b/tests/e2e-parity/tier2-boundary-corner.test.ts @@ -18,9 +18,13 @@ import { COLLIDE_NONE, COLLIDE_WALL, D2_TICK_MS, + INV_COLS, + INV_ROWS, + InventoryPanel, ORTHO_SUB_TILE_HEIGHT, ORTHO_SUB_TILE_WIDTH, S2C_PACKET_SIZES, + STASH_GOLD_CAP, calculateHitChance, calculatePlayerBlockChance, computeAnimFrameIndex, @@ -30,19 +34,29 @@ import { computeOneShotDurationMs, computeStepSpeed256, createCollisionGrid, + createMockHudCanvasContext, createParitySessionFixture, + createRecordingFrontendRenderer, decode0x8ECorpseAssign, + filterAccountName, + findFreeGridSlot, + getInventoryGoldLimit, + getInventoryLayout, getMissileTxtData, getOverlayTxtData, isTownLevel, isValidAccountChar, + lookupTbl, makeParityItem, onlineItemDataToUiInventoryItem, orthoScreenToSubTile, pickVariant, + requireTbl, + resolveStringByTblId, subTileToOrthoScreen, + transToBlendMode, validateNewAccountInputs, - filterAccountName, + type UiInventoryItem, } from './helpers.ts' describe('Tier 2 — Boundary & Corner Cases across 8 Subsystem Batches (48 Tests)', () => { @@ -364,9 +378,26 @@ describe('Tier 2 — Boundary & Corner Cases across 8 Subsystem Batches (48 Test expect(fixture.world.getUnit('monster', 301)).toBeUndefined() }) - it('T2.5.4: Zero life byte (0 / 128) results in 0% life percentage and death posture', () => { - const scaleLife = (l: number) => Math.round((l * 100) / 128) - expect(scaleLife(0)).toBe(0) + it('T2.5.4: Zero life byte (0 / 128) results in 0% life percentage and death posture via ClientUnit', () => { + const fixture = createParitySessionFixture({ selfId: 1 }) + fixture.applyServerEvents({ + type: 'UnitAssign', + isSelf: false, + unit: { + unitType: 'monster', + id: 404, + classId: 1, + x: 105, + y: 105, + mode: 12, + lifePct: 0, + }, + }) + const corpse = fixture.world.getUnit('monster', 404)! + expect(corpse).toBeDefined() + expect(corpse.lifePct).toBe(0) + expect(corpse.lifePercent).toBe(0) + expect(corpse.dead).toBe(true) }) it('T2.5.5: Simultaneous multiple missiles maintain independent deterministic trajectories', () => { @@ -399,29 +430,51 @@ describe('Tier 2 — Boundary & Corner Cases across 8 Subsystem Batches (48 Test // Batch 6: UI, HUD & Inventory Boundaries // ═══════════════════════════════════════════════════════════════════════════ describe('Batch 6 — UI, HUD & Inventory Boundaries', () => { - it('T2.6.1: Placing 2x2 item at boundary slot (8, 2) fits within 10x4 inventory grid', () => { - const item = makeParityItem({ - id: 901, - gridX: 8, - gridY: 2, - invWidth: 2, - invHeight: 2, + it('T2.6.1: Placing 2x2 item at boundary slot (8, 2) fits within 10x4 inventory grid via findFreeGridSlot and HudModel', () => { + const dummyItem = onlineItemDataToUiInventoryItem(makeParityItem({ id: 900, code: 'cap' })) + const grid: (UiInventoryItem | null)[][] = Array.from({ length: INV_ROWS }, (_, row) => + Array.from({ length: INV_COLS }, (_, col) => (col >= 8 && row >= 2 ? null : dummyItem)), + ) + + const freeSlot = findFreeGridSlot(grid, 2, 2) + expect(freeSlot).toMatchObject({ x: 8, y: 2, col: 8, row: 2 }) + + const fixture = createParitySessionFixture({ selfId: 1 }) + fixture.applyServerEvents({ + type: 'ItemEvent', + action: 0, + container: 'inventory', + item: makeParityItem({ + id: 901, + code: 'cap', + container: 'inventory', + gridX: 8, + gridY: 2, + invWidth: 2, + invHeight: 2, + }), }) - // Item spans x: [8, 9], y: [2, 3] which fits exactly inside [0..9, 0..3] - expect(item.gridX + item.invWidth).toBeLessThanOrEqual(10) - expect(item.gridY + item.invHeight).toBeLessThanOrEqual(4) + const panel = new InventoryPanel() + const stored = fixture.world.containers.inventory.get(901)! + panel.gridItems.push({ + item: fixture.hudModel.toUiItem(stored), + col: stored.gridX ?? 8, + row: stored.gridY ?? 2, + }) + expect(panel.getGridOverlaps(8, 2, 1, 1)[0]?.item.id).toBe('901') + expect(panel.getGridOverlaps(9, 3, 1, 1)[0]?.item.id).toBe('901') }) - it('T2.6.2: Placing 2x2 item at boundary slot (9, 2) exceeds 10-column boundary and overflows', () => { - const item = makeParityItem({ - id: 902, - gridX: 9, - gridY: 2, - invWidth: 2, - invHeight: 2, - }) - // Item spans x: [9, 10] -> exceeds 10 - expect(item.gridX + item.invWidth).toBeGreaterThan(10) + it('T2.6.2: Placing 2x2 item when only column 9 is free exceeds 10-column boundary and is rejected by findFreeGridSlot', () => { + const dummyItem = onlineItemDataToUiInventoryItem(makeParityItem({ id: 900, code: 'cap' })) + const grid: (UiInventoryItem | null)[][] = Array.from({ length: INV_ROWS }, () => + Array.from({ length: INV_COLS }, (_, col) => (col === 9 ? null : dummyItem)), + ) + + // A 2x2 item cannot fit at column 9 because col 9 + width 2 > INV_COLS (10) + expect(findFreeGridSlot(grid, 2, 2)).toBeNull() + // A 1x2 item fits at (9, 0) + expect(findFreeGridSlot(grid, 1, 2)).toMatchObject({ x: 9, y: 0, col: 9, row: 0 }) }) it('T2.6.3: Empty inventory container reports item count 0 and accepts incoming items', () => { @@ -437,18 +490,67 @@ describe('Tier 2 — Boundary & Corner Cases across 8 Subsystem Batches (48 Test expect(fixture.world.containers.inventory.size).toBe(1) }) - it('T2.6.4: Gold values at maximum boundary (2,500,000) format correctly without decimal points', () => { - const MAX_GOLD = 2500000 - expect(String(MAX_GOLD)).toBe('2500000') - expect(String(MAX_GOLD)).not.toContain(',') - expect(String(MAX_GOLD)).not.toContain('.') + it('T2.6.4: Gold values at maximum boundary (STASH_GOLD_CAP = 2,500,000) format without commas or decimals in InventoryPanel.draw', () => { + expect(STASH_GOLD_CAP).toBe(2500000) + expect(getInventoryGoldLimit(99)).toBe(990000) + + const { ctx, font, drawTextCalls } = createMockHudCanvasContext() + const panel = new InventoryPanel() + panel.visible = true + panel.gold = STASH_GOLD_CAP + panel.draw( + ctx, + { + borderRightImg: null, + invSheetImg: null, + invTab0Img: null, + invTab1Img: null, + buySellBtnImg: null, + itemsAtlasImg: null, + itemRects: {}, + }, + font, + ) + + const goldTexts = drawTextCalls.map((c) => c.text) + expect(goldTexts).toContain('2500000') + expect(goldTexts.some((t) => t.includes(','))).toBe(false) + expect(goldTexts.some((t) => t.includes('.'))).toBe(false) }) - it('T2.6.5: Horadric Cube 2x2 grid fits four 1x1 items exactly', () => { - const CUBE_WIDTH = 2 - const CUBE_HEIGHT = 2 - const slots = CUBE_WIDTH * CUBE_HEIGHT - expect(slots).toBe(4) + it('T2.6.5: Horadric Cube 3x4 internal grid (12 slots) vs 2x2 exterior item footprint', () => { + const cubeLayout = getInventoryLayout('Transmogrify Box Page 1') + expect(cubeLayout.gridX).toBe(3) + expect(cubeLayout.gridY).toBe(4) + expect(cubeLayout.gridX * cubeLayout.gridY).toBe(12) + + // Exterior item footprint of Horadric Cube ('box') in player inventory is 2x2 (4 cells) + const uiCubeItem = onlineItemDataToUiInventoryItem(makeParityItem({ id: 950, code: 'box' })) + expect(uiCubeItem.invWidth).toBe(2) + expect(uiCubeItem.invHeight).toBe(2) + + // Populate all 12 internal slots (3 cols x 4 rows) of the Cube container in ClientWorld + const fixture = createParitySessionFixture({ selfId: 1 }) + let nextId = 960 + for (let row = 0; row < cubeLayout.gridY; row++) { + for (let col = 0; col < cubeLayout.gridX; col++) { + fixture.applyServerEvents({ + type: 'ItemEvent', + action: 0, + container: 'cube', + item: makeParityItem({ + id: nextId++, + code: 'rin', + container: 'cube', + gridX: col, + gridY: row, + invWidth: 1, + invHeight: 1, + }), + }) + } + } + expect(fixture.world.containers.cube.size).toBe(12) }) it('T2.6.6: Right-click usage on nonexistent item id dispatches ItemUse for server validation', () => { @@ -538,25 +640,46 @@ describe('Tier 2 — Boundary & Corner Cases across 8 Subsystem Batches (48 Test // Batch 8: Tools, Asset Manifest & Fail-Fast Boundaries // ═══════════════════════════════════════════════════════════════════════════ describe('Batch 8 — Tools, Asset Manifest & Fail-Fast Boundaries', () => { - it('T2.8.1: Text edit box cursor toggle rate is exactly 250ms periodic', () => { - const isCursorVisible = (ms: number) => Math.floor(ms / 250) % 2 === 0 - // 0..249: on, 250..499: off, 500..749: on, 750..999: off - expect(isCursorVisible(100)).toBe(true) - expect(isCursorVisible(300)).toBe(false) - expect(isCursorVisible(600)).toBe(true) - expect(isCursorVisible(800)).toBe(false) + it('T2.8.1: Text edit box cursor toggle rate is exactly 250ms periodic via FrontendRenderer.render', () => { + const { renderer, state, drawCalls } = createRecordingFrontendRenderer() + state.stage = 'bnet_login' + state.activeInput = 'accountName' + state.accountName = 'Hero' + + // 0..249ms: bit 0 is 0 (cursor off); 250..499ms: bit 0 is 1 (cursor on); 500..749ms: off; 750..999ms: on + const countCallsAt = (ms: number) => { + drawCalls.length = 0 + renderer.render(ms) + return drawCalls.length + } + + const calls100 = countCallsAt(100) + const calls300 = countCallsAt(300) + const calls600 = countCallsAt(600) + const calls800 = countCallsAt(800) + + expect(calls300).toBe(calls100 + 1) + expect(calls600).toBe(calls100) + expect(calls800).toBe(calls100 + 1) }) - it('T2.8.2: TRANS50 blending factor is 0.50 opacity for disabled UI widgets', () => { - const trans50Alpha = 0.5 - expect(trans50Alpha).toBe(0.5) + it('T2.8.2: TRANS50 blending factor is 0.50 opacity for disabled UI widgets via FrontendRenderer and transToBlendMode', () => { + expect(transToBlendMode(3)).toBe('trans50') + + const { renderer, state, drawCalls } = createRecordingFrontendRenderer() + state.stage = 'char_select' + state.roster = [] // Empty roster disables OK and DELETE CHARACTER buttons + renderer.render(0) + + const disabledDraws = drawCalls.filter((c) => c.alpha === 0.5 && c.filter === 'none') + expect(disabledDraws.length).toBeGreaterThanOrEqual(2) }) - it('T2.8.3: Missing TBL string query falls back to English string cleanly', () => { - const fallbackStr = (customMap: Record, id: number, def: string) => customMap[id] ?? def - const dict: Record = { 5239: 'Account already exists' } - expect(fallbackStr(dict, 5239, 'Default')).toBe('Account already exists') - expect(fallbackStr(dict, 99999, 'Default')).toBe('Default') + it('T2.8.3: Missing TBL string query falls back cleanly via lookupTbl and throws in requireTbl / resolveStringByTblId', () => { + expect(lookupTbl('strchrstr', 'ENG')).toBe('Strength') + expect(lookupTbl('__missing_tbl_id_99999__', 'ENG')).toBeUndefined() + expect(() => requireTbl('__missing_tbl_id_99999__', 'ENG')).toThrow(/not packed/) + expect(() => resolveStringByTblId({}, 99999)).toThrow(/Missing required \.tbl string #99999/) }) it('T2.8.4: Requesting invalid missile name from manifest throws Error explicitly', () => { diff --git a/tests/e2e-parity/tier3-cross-feature.test.ts b/tests/e2e-parity/tier3-cross-feature.test.ts index 0f5fa84..d54c8f7 100644 --- a/tests/e2e-parity/tier3-cross-feature.test.ts +++ b/tests/e2e-parity/tier3-cross-feature.test.ts @@ -9,6 +9,8 @@ import { describe, expect, it } from 'vitest' import { BncsCreateAccountStatus, C2S_PACKET_SIZES, + CAMPFIRE_CLASS_SPECS, + CAMPFIRE_FLAME_CONTROL_SPEC, CANONICAL_113C_MISSILES, CHAR_ANIM_CLIPS_113C, COLLIDE_BLANK, @@ -23,6 +25,9 @@ import { ORTHO_SUB_TILE_HEIGHT, ORTHO_SUB_TILE_WIDTH, S2C_PACKET_SIZES, + STAT_FASTERCASTRATE, + VIEWPORT_800x600, + WEAPON_SWAP_TABS_BOUNDS, calculateHitChance, calculatePlayerBlockChance, computeAnimFrameIndex, @@ -31,19 +36,26 @@ import { computeOneShotDurationMs, computeStepSpeed256, createCollisionGrid, + createInitialFlowState, createParitySessionFixture, + enterCharCreate, filterAccountName, getMissileTxtData, getOverlayTxtData, getSkillCastOverlay, + getViewportProfile, isTownLevel, isValidAccountChar, makeParityItem, onlineItemDataToUiInventoryItem, orthoScreenToSubTile, + parseMcpAvatarStatstring, pickVariant, + resolveWeaponSwapTabClick, + selectCampfireClass, subTileToOrthoScreen, validateNewAccountInputs, + worldToScreen, } from './helpers.ts' describe('Tier 3 — Pairwise Cross-Feature Integration Tests', () => { @@ -51,7 +63,7 @@ describe('Tier 3 — Pairwise Cross-Feature Integration Tests', () => { // Interaction 1: Movement + Weapon Swap + Skill Cast // ───────────────────────────────────────────────────────────────────────── describe('X1: Movement + Weapon Swap + Skill Cast', () => { - it('swapping weapon sets while running updates effective cast rate and interrupts run on cast', () => { + it('swapping weapon sets via resolveWeaponSwapTabClick and hudModel.swapWeapons updates FCR and interrupts run on cast', () => { const fixture = createParitySessionFixture({ selfId: 1, charClass: 1 }) // Sorceress // 1. Player is running toward (150, 100) @@ -64,11 +76,26 @@ describe('Tier 3 — Pairwise Cross-Feature Integration Tests', () => { }) expect(fixture.selfUnit?.mode).toBe(3) // Run mode - // 2. Weapon swap event (switching from weapon set 0 to set 1) + // 2. Click Weapon Swap Tab II on Inventory Panel and dispatch WeaponSwitch (C2S 0x60) + const rightSlotTabClick = resolveWeaponSwapTabClick( + WEAPON_SWAP_TABS_BOUNDS.right.x + 60, + WEAPON_SWAP_TABS_BOUNDS.right.y + 10, + ) + expect(rightSlotTabClick).toBe(1) + + fixture.clearCommands() + fixture.hudModel.swapWeapons() + expect(fixture.sentCommands).toContainEqual({ type: 'SwapWeapons' }) + fixture.world.self.weaponSet = 1 + expect(fixture.hudModel.getSnapshot().weaponSet).toBe(1) + + // Server updates player STAT_FASTERCASTRATE (105) after weapon switch fixture.applyServerEvents({ - type: 'WeaponSetChange', - weaponSet: 1, - } as any) + type: 'StatSet', + stat: STAT_FASTERCASTRATE, + value: 105, + }) + expect(fixture.world.self.stats.get(STAT_FASTERCASTRATE)).toBe(105) // Set 0 has 0% FCR, Set 1 has +105% FCR const durSet0 = computeOneShotDurationMs(14, computeStepSpeed256(256, computeEffectiveFcr(0))) @@ -259,7 +286,7 @@ describe('Tier 3 — Pairwise Cross-Feature Integration Tests', () => { // Interaction 6: Character Selection + Account Validation + Flame Anchor // ───────────────────────────────────────────────────────────────────────── describe('X6: Character Selection + Account Validation + Flame Anchor', () => { - it('validates account credentials, filters valid characters, and aligns campfire flame anchor', () => { + it('validates account credentials, selects campfire class state, and verifies CAMPFIRE_FLAME_CONTROL_SPEC', () => { // 1. Account name filtering const rawInput = ' Warrior_123 ' const cleanName = filterAccountName(rawInput) @@ -273,11 +300,28 @@ describe('Tier 3 — Pairwise Cross-Feature Integration Tests', () => { }) expect(validation.valid).toBe(true) - // 3. Campfire anchor point (345, 470) - const flameAnchorX = 345 - const flameAnchorY = 470 - expect(flameAnchorX).toBe(345) - expect(flameAnchorY).toBe(470) + // 3. Campfire flame anchor point (345, 470) and class step transitions via flow-state + expect(CAMPFIRE_FLAME_CONTROL_SPEC.x).toBe(345) + expect(CAMPFIRE_FLAME_CONTROL_SPEC.y).toBe(470) + expect(CAMPFIRE_FLAME_CONTROL_SPEC.drawMode).toBe(3) + + const state = createInitialFlowState() + enterCharCreate(state) + expect(state.stage).toBe('char_create') + selectCampfireClass(state, 'so') + expect(state.selectedClass).toBe('so') + expect(state.campfireClasses.so.phase).toBe('fw') + expect(CAMPFIRE_CLASS_SPECS.so.frameCount.fw).toBe(52) + + // 4. Parse MCP 0x19 statstring for character roster avatar + const statBytes = new Uint8Array(33).fill(0xff) + statBytes[0] = 0x84 + statBytes[1] = 0x80 + statBytes[13] = 0x02 + statBytes[25] = 10 + const parsed = parseMcpAvatarStatstring(statBytes, 'so') + expect(parsed.hasEquippedComponents).toBe(false) + expect(parsed.weaponClass).toBe('hth') }) }) @@ -285,7 +329,7 @@ describe('Tier 3 — Pairwise Cross-Feature Integration Tests', () => { // Interaction 7: Line-of-Sight Raycast + Viewport Coordinate Culling // ───────────────────────────────────────────────────────────────────────── describe('X7: Line-of-Sight Raycast + Viewport Coordinate Culling', () => { - it('determines visibility via Bresenham raycast and culls out-of-screen entities', () => { + it('determines visibility via Bresenham raycast and projects screen coordinates via worldToScreen and VIEWPORT_800x600', () => { const grid = createCollisionGrid(30, 30, COLLIDE_NONE) // Unit A at (5, 5), Unit B at (15, 5). Clear line of sight @@ -295,14 +339,27 @@ describe('Tier 3 — Pairwise Cross-Feature Integration Tests', () => { for (let y = 0; y < 30; y++) { grid.setCollision(10, y, COLLIDE_WALL | COLLIDE_MISSILE_BARRIER) } - // Line of sight blocked + // Line of sight blocked across both mask and underlying ClientWorld sub-tile collision expect(grid.hasLineOfSight(5, 5, 15, 5)).toBe(false) + expect(grid.world.isSubTileBlocked(10, 5)).toBe(true) - // Viewport culling at 800x600 resolution - const isWithinScreen = (x: number, y: number) => x >= 0 && x <= 800 && y >= 0 && y <= 600 - expect(isWithinScreen(400, 300)).toBe(true) // Center screen - expect(isWithinScreen(900, 300)).toBe(false) // Outside screen - expect(isWithinScreen(-50, 200)).toBe(false) // Outside screen + // Viewport projection at 800x600 resolution via production worldToScreen + const profile = getViewportProfile('800x600') + const centerProj = worldToScreen(0, 0, 0, 0, profile) + expect(centerProj.x).toBe(400) + expect(centerProj.y).toBe(300) + expect( + centerProj.x >= 0 && + centerProj.x <= VIEWPORT_800x600.width && + centerProj.y >= 0 && + centerProj.y <= VIEWPORT_800x600.height, + ).toBe(true) + + const offscreenRight = worldToScreen(550, 0, 0, 0, profile) + expect(offscreenRight.x).toBeGreaterThan(VIEWPORT_800x600.width) + + const offscreenLeft = worldToScreen(-550, 0, 0, 0, profile) + expect(offscreenLeft.x).toBeLessThan(0) }) }) diff --git a/tests/e2e-parity/tier4-real-world-scenarios.test.ts b/tests/e2e-parity/tier4-real-world-scenarios.test.ts index e24dfbe..850a3ee 100644 --- a/tests/e2e-parity/tier4-real-world-scenarios.test.ts +++ b/tests/e2e-parity/tier4-real-world-scenarios.test.ts @@ -25,6 +25,8 @@ import { computeStepSpeed256, createCollisionGrid, createParitySessionFixture, + getActQuests, + getActWaypoints, getMissileTxtData, getOverlayTxtData, isTownLevel, @@ -157,7 +159,7 @@ describe('Tier 4 — Real-World Gameplay Scenarios (1.13c Ground Truth)', () => // Scenario 2: Den of Evil Completion // ───────────────────────────────────────────────────────────────────────── describe('Scenario 2: Den of Evil Completion', () => { - it('tracks cave entrance, room reveal, monster counter decrease, and quest completion flag', () => { + it('tracks cave entrance, room reveal, ClientWorld live monster clearing, and Act 1 Quest 1 completion', () => { const fixture = createParitySessionFixture({ selfId: 102, charClass: 1 }) // Sorceress // 1. Transition into Cave: Area 8 (Den of Evil) @@ -188,8 +190,7 @@ describe('Tier 4 — Real-World Gameplay Scenarios (1.13c Ground Truth)', () => // Line of sight open along same corridor expect(caveGrid.hasLineOfSight(8, 10, 8, 20)).toBe(true) - // 4. Den of Evil Monster Counter Tracking (starts with 5 remaining monsters) - let denMonstersRemaining = 5 + // 4. Den of Evil Monster Clearing in ClientWorld (5 live monsters -> 0 live monsters) const monsterIds = [301, 302, 303, 304, 305] for (const mId of monsterIds) { @@ -199,16 +200,22 @@ describe('Tier 4 — Real-World Gameplay Scenarios (1.13c Ground Truth)', () => unit: { unitType: 'monster', id: mId, - classId: mId === 305 ? 683 : 1, // 305 is Corpsefire (Superunique Gargantuan Beast), others Fallens + classId: mId === 305 ? 683 : 1, // 305 is Corpsefire, others Fallens x: 20 + (mId % 5), y: 15 + (mId % 3), mode: 1, + lifePct: 128, }, }) } - expect(fixture.world.units.size).toBe(6) // 1 player + 5 monsters + const getLiveMonsterCount = () => + Array.from(fixture.world.units.values()).filter( + (u) => u.unitType === 'monster' && !u.dead && u.mode !== 12, + ).length - // Player casts Fire Bolt and clears all monsters one by one + expect(getLiveMonsterCount()).toBe(5) + + // Player casts Fire Bolt and clears all monsters one by one in ClientWorld for (const mId of monsterIds) { fixture.applyServerEvents({ type: 'UnitMode', @@ -216,12 +223,13 @@ describe('Tier 4 — Real-World Gameplay Scenarios (1.13c Ground Truth)', () => id: mId, mode: 12, }) - denMonstersRemaining-- + fixture.world.getUnit('monster', mId)!.lifePct = 0 } - expect(denMonstersRemaining).toBe(0) + expect(getLiveMonsterCount()).toBe(0) // 5. Quest Completion: Server sends QuestState marking Den of Evil (Quest 0) complete - // Bit 0 = quest completed (0x1001) + const act1Quests = getActQuests(1) + expect(act1Quests[0]?.id).toBe('a1q1') fixture.applyServerEvents({ type: 'QuestState', quests: new Uint16Array([0x1001]), @@ -470,12 +478,19 @@ describe('Tier 4 — Real-World Gameplay Scenarios (1.13c Ground Truth)', () => }) expect(fixture.world.self.life).toBe(80) - // 4. Andariel Weakness: In 1.13c Normal difficulty, Andariel has -50% Fire Resistance - // Sorceress casts Fire Ball: effective damage is 150% of base damage - const baseFireDamage = 100 - const andarielFireRes = -50 - const effectiveDamage = Math.round(baseFireDamage * (1 - andarielFireRes / 100)) - expect(effectiveDamage).toBe(150) + // 4. Sorceress casts Fire Ball (skill 47, missile 'fireball') against Andariel + fixture.world.spawnMissileByKey('fireball', 90, 100, 100, 100, { + id: 602, + sourceSkillId: 47, + }) + const fireball = fixture.world.getUnit('missile', 602) + expect(fireball).toBeDefined() + fixture.tick(2) + expect(fireball!.x).toBeGreaterThan(90) + + // Fire Ball impact reduces Andariel lifePct from 128 -> 64 (50%) -> 0 (dead) + andariel!.lifePct = 64 + expect(andariel!.lifePercent).toBe(50) // 5. Killing Blow & Quest Completion: Andariel Dies fixture.applyServerEvents({ @@ -484,6 +499,8 @@ describe('Tier 4 — Real-World Gameplay Scenarios (1.13c Ground Truth)', () => id: 601, mode: 12, }) + andariel!.lifePct = 0 + expect(andariel!.dead).toBe(true) expect(fixture.world.getUnit('monster', 601)?.mode).toBe(12) // Flame death overlay triggers @@ -491,7 +508,8 @@ describe('Tier 4 — Real-World Gameplay Scenarios (1.13c Ground Truth)', () => expect(deathOverlay).toBeDefined() expect(deathOverlay?.frames).toBeGreaterThan(0) - // Sisters to the Slaughter (Quest 5) completes + // Sisters to the Slaughter (Act 1 Quest 6, index 5) completes + expect(getActQuests(1)[5]?.id).toBe('a1q6') fixture.applyServerEvents({ type: 'QuestState', quests: new Uint16Array([0x1001, 0, 0x0001, 0, 0, 0x0001]), @@ -499,6 +517,7 @@ describe('Tier 4 — Real-World Gameplay Scenarios (1.13c Ground Truth)', () => expect((fixture.world.quests[5]! & 0x0001) !== 0).toBe(true) // 6. Act 2 Transition: Warriv caravan takes hero East to Lut Gholein (Act 1 in 0-index, Area 40) + expect(getActWaypoints(2)[0]?.levelId).toBe(40) fixture.applyServerEvents({ type: 'LoadAct', act: 1, // Act 2