From 5de27f8177d43524444de7aa301cc470c4f0844e Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Tue, 29 Sep 2026 21:01:51 +0000 Subject: [PATCH] test(e2e): add umbrella P0 #516 end-to-end and 136-level verification suite (#516) Add full-spectrum E2E verification covering all 10 P0 issues and 136-level DRLG parity invariants. TAG=agy CONV=4e31689c-063a-4965-968b-59c0f5795f97 --- .../e2e-p0-516/tier1-feature-coverage.test.ts | 1118 +++++++++++++++++ .../e2e-p0-516/tier2-boundary-corner.test.ts | 825 ++++++++++++ tests/e2e-p0-516/tier3-cross-feature.test.ts | 598 +++++++++ .../tier4-real-world-scenarios.test.ts | 646 ++++++++++ 4 files changed, 3187 insertions(+) create mode 100644 tests/e2e-p0-516/tier1-feature-coverage.test.ts create mode 100644 tests/e2e-p0-516/tier2-boundary-corner.test.ts create mode 100644 tests/e2e-p0-516/tier3-cross-feature.test.ts create mode 100644 tests/e2e-p0-516/tier4-real-world-scenarios.test.ts diff --git a/tests/e2e-p0-516/tier1-feature-coverage.test.ts b/tests/e2e-p0-516/tier1-feature-coverage.test.ts new file mode 100644 index 0000000..0437af0 --- /dev/null +++ b/tests/e2e-p0-516/tier1-feature-coverage.test.ts @@ -0,0 +1,1118 @@ +/** + * Tier 1: Requirement-Driven Feature Coverage (`tests/e2e-p0-516/tier1-feature-coverage.test.ts`). + * + * Systematic Category-Partition functional tests across all 10 P0 issues (`#517`, `#518`, `#520`, `#521`, `#522`, `#523`, `#524`, `#528`, `#529`, `#530`) under umbrella `#516`. + * 60 test cases (6 per P0 issue). + */ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { startRelay, decodeFrames, encodeFrame as encodeRelayFrame } from '../../scripts/net-relay.ts' +import { + LockstepSession, + NetplaySession, + encodeMessage, + decodeMessage, + encodeFrame, + decodeFrame, + computeLockstepHash, + memoryTransportPair, + DEFAULT_MAX_MESSAGE, + MAX_FUTURE_TICKS, + NO_ACK, + type InputFrame, +} from '../../src/net/lockstep-manager.ts' +import { GameEngine, type EngineInput, type WorldMapProvider } from '../../src/game/engine.ts' +import { GroundItemManager, calculateBounceHeight } from '../../src/game/ground-items.ts' +import { Inventory, goldItem, type Item, type ItemBase } from '../../src/game/items.ts' +import { InventoryPanel, getInventoryGoldLimit } from '../../src/ui/inventory.ts' +import { WorldPanelsHud, STASH_GOLD_CAP } from '../../src/ui/world-panels.ts' +import { CharacterSheetPanel } from '../../src/ui/character-sheet.ts' +import { HudManager } from '../../src/ui/hud-manager.ts' +import { AudioManager } from '../../src/audio/manager.ts' +import { playInventoryFullFeedback } from '../../src/scene/frontend/overhead-labels.ts' +import { LoadGeneration, SpriteRenderer } from '../../src/render/renderer.ts' +import { + disposeRuntimeAtlases, + createLevelTransitionController, +} from '../../src/scene/frontend/pack-loader.ts' +import { + loadItemsAtlas, + resolveGroundItemFlippyFrame, + drawGroundItem, +} from '../../src/scene/frontend/ground-item-renderer.ts' +import { + loadMissileArtMap, + loadOverlayArtMap, + drawMissileProjectile, + drawExplosion, + drawCastOverlay, +} from '../../src/scene/frontend/missile-overlay-renderer.ts' +import { D2Rng } from '../../src/game/d2-rng.ts' +import { CANONICAL_AUTOMAGIC_TABLE, parseAutoMagicTable } from '../../src/game/automagic.ts' +import { getSharedDataRegistry } from '../../src/game/engine/data-registry.ts' +import { UnitStatList } from '../../src/game/engine/stat-list.ts' +import { StateBus } from '../../src/game/engine/state-bus.ts' +import { MissileEngine } from '../../src/game/engine/missile-engine.ts' +import { AuraScanner } from '../../src/game/engine/aura-scanner.ts' +import { SummonManager } from '../../src/game/engine/summon-manager.ts' +import { evaluateSkill113c, executeSkill113c } from '../../src/game/skills/registry.ts' +import { + castSkill, + CANONICAL_113C_MISSILES, + CANONICAL_113C_OVERLAYS, + createActiveOverlay, + getSkillCastOverlay, +} from '../../src/game/skills.ts' + +// --------------------------------------------------------------------------- +// Test Helpers +// --------------------------------------------------------------------------- + +function makeItem(id: string, name: string, invWidth = 1, invHeight = 1, value = 50): Item { + const base: ItemBase = { + id, + name, + kind: 'misc', + invWidth, + invHeight, + maxStack: 1, + value, + damage: 0, + defense: 0, + tags: ['misc'], + level: 1, + } + return { + base, + prefix: null, + suffix: null, + level: 1, + name, + stats: {}, + invWidth, + invHeight, + stack: 1, + value, + code: id, + } +} + +function makeEngine(seed = 12345): GameEngine { + const terrain: WorldMapProvider = { widthPx: 2000, heightPx: 2000, overlap: () => 0 } + return new GameEngine(terrain, { + spawn: { x: 200, y: 200 }, + stats: [{ id: 'zombie', name: 'Zombie', hp: 30, speed: 60, reach: 32, damage: 5, cooldownTicks: 20, xp: 50, aggroRadius: 200 }], + xpTable: [0, 500, 1500, 3750], + skills: [ + { + id: 'firebolt', + name: 'Fire Bolt', + projectile: true, + manaCost: 3, + cooldownTicks: 10, + baseMinDamage: 10, + baseMaxDamage: 12, + damagePerLevel: 2, + speed: 320, + range: 600, + radius: 10, + }, + ], + npcDefs: [], + questDefs: [], + combatOptions: { + playerSpeed: 170, + playerReach: 48, + playerCooldownTicks: 5, + playerDamage: 20, + playerManaPerAttack: 0, + respawnTicks: 50, + }, + lootSeed: seed, + talkRadius: 64, + pickupRadius: 64, + inventoryCols: 10, + inventoryRows: 4, + monsterCount: 2, + }) +} + +const IDLE_INPUT: EngineInput = { + movement: { x: 0, y: 0 }, + attacking: false, + pickingUp: false, + talking: false, + digits: [], + saving: false, + loading: false, +} + +function createMockWebGLCanvas() { + let nextTexId = 1 + const liveTextures = new Set() + const gl = { + NO_ERROR: 0, + TRIANGLES: 4, + TEXTURE_2D: 0x0de1, + TEXTURE0: 0x84c0, + TEXTURE1: 0x84c1, + RGBA: 0x1908, + R8: 0x8229, + RED: 0x1903, + UNSIGNED_BYTE: 0x1401, + NEAREST: 0x2600, + CLAMP_TO_EDGE: 0x812f, + TEXTURE_MIN_FILTER: 0x2801, + TEXTURE_MAG_FILTER: 0x2800, + TEXTURE_WRAP_S: 0x2802, + TEXTURE_WRAP_T: 0x2803, + UNPACK_ALIGNMENT: 0x0cf5, + ARRAY_BUFFER: 0x8892, + DYNAMIC_DRAW: 0x88e8, + FLOAT: 0x1406, + BLEND: 0x0be2, + SRC_ALPHA: 0x0302, + ONE_MINUS_SRC_ALPHA: 0x0303, + ONE: 1, + COLOR_BUFFER_BIT: 0x4000, + VERTEX_SHADER: 0x8b31, + FRAGMENT_SHADER: 0x8b30, + COMPILE_STATUS: 0x8b81, + LINK_STATUS: 0x8b82, + getError: vi.fn(() => 0), + createShader: vi.fn(() => ({})), + shaderSource: vi.fn(), + compileShader: vi.fn(), + getShaderParameter: vi.fn(() => true), + getShaderInfoLog: vi.fn(() => ''), + deleteShader: vi.fn(), + createProgram: vi.fn(() => ({})), + attachShader: vi.fn(), + linkProgram: vi.fn(), + getProgramParameter: vi.fn(() => true), + getProgramInfoLog: vi.fn(() => ''), + useProgram: vi.fn(), + deleteProgram: vi.fn(), + getUniformLocation: vi.fn(() => ({})), + uniform1i: vi.fn(), + uniform2f: vi.fn(), + createVertexArray: vi.fn(() => ({})), + bindVertexArray: vi.fn(), + deleteVertexArray: vi.fn(), + createBuffer: vi.fn(() => ({})), + bindBuffer: vi.fn(), + bufferData: vi.fn(), + bufferSubData: vi.fn(), + deleteBuffer: vi.fn(), + enableVertexAttribArray: vi.fn(), + vertexAttribPointer: vi.fn(), + createTexture: vi.fn(() => { + const id = nextTexId++ + liveTextures.add(id) + return { id } + }), + bindTexture: vi.fn(), + activeTexture: vi.fn(), + pixelStorei: vi.fn(), + texParameteri: vi.fn(), + texImage2D: vi.fn(), + deleteTexture: vi.fn((tex: { id?: number } | null) => { + if (tex?.id !== undefined) liveTextures.delete(tex.id) + }), + enable: vi.fn(), + blendFunc: vi.fn(), + viewport: vi.fn(), + clearColor: vi.fn(), + clear: vi.fn(), + drawArrays: vi.fn(), + } + const canvas = { + width: 800, + height: 600, + getContext: vi.fn((type: string) => (type === 'webgl2' ? gl : null)), + } as unknown as HTMLCanvasElement + return { canvas, gl, liveTextures } +} + +function createMock2DCanvas(): HTMLCanvasElement { + const ctx2d = { + save: vi.fn(), + restore: vi.fn(), + clearRect: vi.fn(), + fillRect: vi.fn(), + strokeRect: vi.fn(), + beginPath: vi.fn(), + moveTo: vi.fn(), + lineTo: vi.fn(), + arc: vi.fn(), + closePath: vi.fn(), + fill: vi.fn(), + stroke: vi.fn(), + fillText: vi.fn(), + strokeText: vi.fn(), + measureText: vi.fn((text: string) => ({ width: text.length * 7 })), + drawImage: vi.fn(), + putImageData: vi.fn(), + createImageData: vi.fn((w: number, h: number) => ({ + width: w, + height: h, + data: new Uint8ClampedArray(w * h * 4), + })), + createLinearGradient: vi.fn(() => ({ addColorStop: vi.fn() })), + createRadialGradient: vi.fn(() => ({ addColorStop: vi.fn() })), + setTransform: vi.fn(), + translate: vi.fn(), + scale: vi.fn(), + clip: vi.fn(), + } + return { + width: 800, + height: 600, + getContext: vi.fn(() => ctx2d), + } as unknown as HTMLCanvasElement +} + +afterEach(() => { + vi.restoreAllMocks() +}) + +// =========================================================================== +// 1. Issue #517 — Net Relay Frame Security & Connection Caps (6 tests) +// =========================================================================== +function encodeMaskedClientFrame(opcode: number, payload: Uint8Array): Uint8Array { + const unmasked = encodeRelayFrame(opcode, payload) + const maskKey = new Uint8Array([0x12, 0x34, 0x56, 0x78]) + const headerLen = unmasked.byteLength - payload.byteLength + const out = new Uint8Array(headerLen + 4 + payload.byteLength) + out.set(unmasked.subarray(0, headerLen), 0) + out[1] = (out[1]! | 0x80) + out.set(maskKey, headerLen) + for (let i = 0; i < payload.byteLength; i++) { + out[headerLen + 4 + i] = payload[i]! ^ maskKey[i & 3]! + } + return out +} + +describe('Tier 1 — #517: Net Relay Frame Security & Connection Caps', () => { + it('T1-517-01: decodeFrames parses valid length-prefixed frame with opcode and payload', () => { + const payload = new Uint8Array([10, 20, 30, 40]) + const encoded = encodeMaskedClientFrame(0x02, payload) + const res = decodeFrames(encoded) + expect(res.error).toBeUndefined() + expect(res.frames).toHaveLength(1) + expect(res.frames[0]!.opcode).toBe(0x02) + expect(Array.from(res.frames[0]!.payload)).toEqual([10, 20, 30, 40]) + expect(res.consumed).toBe(encoded.byteLength) + }) + + it('T1-517-02: decodeFrames parses multiple concatenated frames in a single buffer', () => { + const f1 = encodeMaskedClientFrame(0x02, new Uint8Array([1, 2])) + const f2 = encodeMaskedClientFrame(0x02, new Uint8Array([3, 4, 5])) + const combined = new Uint8Array(f1.byteLength + f2.byteLength) + combined.set(f1, 0) + combined.set(f2, f1.byteLength) + const res = decodeFrames(combined) + expect(res.error).toBeUndefined() + expect(res.frames).toHaveLength(2) + expect(res.frames[0]!.opcode).toBe(0x02) + expect(res.frames[1]!.opcode).toBe(0x02) + expect(res.consumed).toBe(combined.byteLength) + }) + + it('T1-517-03: decodeFrames preserves partial trailing frame bytes without error', () => { + const full = encodeMaskedClientFrame(0x02, new Uint8Array([9, 8, 7])) + const partial = encodeMaskedClientFrame(0x02, new Uint8Array([1, 2, 3, 4, 5])).subarray(0, 4) + const buf = new Uint8Array(full.byteLength + partial.byteLength) + buf.set(full, 0) + buf.set(partial, full.byteLength) + const res = decodeFrames(buf) + expect(res.error).toBeUndefined() + expect(res.frames).toHaveLength(1) + expect(res.consumed).toBe(full.byteLength) + }) + + it('T1-517-04: decodeFrames returns code 1009 when payload length exceeds maxPayloadBytes', () => { + const header = new Uint8Array(4) + header[0] = 0x82 + header[1] = 0x80 | 126 + new DataView(header.buffer).setUint16(2, 5000, false) + const res = decodeFrames(header, 4096) + expect(res.error).toBeDefined() + expect(res.error?.code).toBe(1009) + }) + + it('T1-517-05: startRelay boots an ephemeral HTTP/WS server and cleanly closes', async () => { + const relay = await startRelay(0, { + maxConnectionsPerIp: 4, + maxRooms: 8, + maxMessageBytes: 4096, + }) + try { + expect(relay.url).toMatch(/^ws:\/\/127\.0\.0\.1:\d+$/) + } finally { + await relay.close() + } + }) + + it('T1-517-06: startRelay accepts custom rate-limiting and heartbeat options', async () => { + const relay = await startRelay(0, { + maxRooms: 4, + maxPeersPerRoom: 2, + maxMessagesPerSec: 60, + heartbeatIntervalMs: 5000, + }) + try { + expect(relay.url).toContain('ws://127.0.0.1:') + } finally { + await relay.close() + } + }) +}) + +// =========================================================================== +// 2. Issue #518 — Lockstep Session & Netplay Protocol (6 tests) +// =========================================================================== +describe('Tier 1 — #518: Lockstep Session & Netplay Protocol', () => { + it('T1-518-01: encodeMessage and decodeMessage round-trip all 5 NetMessage kinds', () => { + const messages = [ + { kind: 'hello' as const, peer: 0, peers: 2, seed: 0x12345678, ackTo: NO_ACK }, + { + kind: 'input' as const, + peer: 1, + frame: { tick: 12, movement: { x: 1, y: -1 }, attack: true, pickup: false, talk: false, skill: 2 }, + }, + { kind: 'hash' as const, peer: 0, tick: 12, hash: 0xdeadbeef }, + { kind: 'heartbeat' as const, peer: 1, tick: 15 }, + { kind: 'bye' as const, peer: 0 }, + ] + for (const msg of messages) { + const bytes = encodeMessage(msg) + const decoded = decodeMessage(bytes) + expect(decoded).toEqual(msg) + } + }) + + it('T1-518-02: encodeFrame and decodeFrame round-trip framed NetMessage packets', () => { + const msg = { + kind: 'input' as const, + peer: 0, + frame: { tick: 5, movement: { x: 0, y: 1 }, attack: false, pickup: true, talk: false, skill: 0 }, + } + const framed = encodeFrame(msg) + const decoded = decodeFrame(framed) + expect(decoded).toEqual(msg) + }) + + it('T1-518-03: LockstepSession steps deterministically when all peers submit inputs for current tick', () => { + let simState = 100 + const session = new LockstepSession( + { peers: 2, inputDelayTicks: 1 }, + { + advance: (inputs) => { + for (const inp of inputs) { + simState += inp.movement.x * 10 + (inp.attack ? 5 : 0) + } + }, + hash: () => simState >>> 0, + }, + ) + const idle0: InputFrame = { tick: 0, movement: { x: 0, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 } + expect(session.submit(0, idle0)).toBe(true) + expect(session.submit(1, idle0)).toBe(true) + const step0 = session.step() + expect(step0.kind).toBe('stepped') + expect(session.tick).toBe(1) + + const f0: InputFrame = { tick: 1, movement: { x: 1, y: 0 }, attack: true, pickup: false, talk: false, skill: 0 } + const f1: InputFrame = { tick: 1, movement: { x: 1, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 } + expect(session.submit(0, f0)).toBe(true) + expect(session.submit(1, f1)).toBe(true) + + const step1 = session.step() + expect(step1).toEqual({ kind: 'stepped', tick: 1, hash: 125 }) + }) + + it('T1-518-04: LockstepSession records remote hash and agrees when hashes match', () => { + let simHash = 0xabcdef01 + const session = new LockstepSession( + { peers: 2, inputDelayTicks: 1 }, + { advance: () => {}, hash: () => simHash }, + ) + const idle0: InputFrame = { tick: 0, movement: { x: 0, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 } + session.submit(0, idle0) + session.submit(1, idle0) + expect(session.recordRemoteHash(1, 0, 0xabcdef01)).toBe(true) + const out = session.step() + expect(out.kind).toBe('stepped') + expect(session.desyncReport).toBeNull() + }) + + it('T1-518-05: NetplaySession 2-peer handshake over memoryTransportPair reaches ready state and steps', () => { + const [t0, t1] = memoryTransportPair() + let hash0 = 42 + let hash1 = 42 + const p0 = new NetplaySession( + { peer: 0, peers: 2, seed: 777, inputDelayTicks: 2, hashInterval: 2 }, + { advance: () => { hash0 += 1 }, hash: () => hash0 }, + t0, + ) + const p1 = new NetplaySession( + { peer: 1, peers: 2, seed: 777, inputDelayTicks: 2, hashInterval: 2 }, + { advance: () => { hash1 += 1 }, hash: () => hash1 }, + t1, + ) + p0.start() + p1.start() + for (let i = 0; i < 6; i++) { + t0.flush() + t1.flush() + p0.pump() + p1.pump() + } + expect(p0.stats.ready).toBe(true) + expect(p1.stats.ready).toBe(true) + expect(p0.stats.stepped).toBeGreaterThan(0) + expect(p1.stats.stepped).toBeGreaterThan(0) + }) + + it('T1-518-06: computeLockstepHash produces deterministic FNV-1a 32-bit hash over world, inventories, ground, and quests', () => { + const world = { + tick: 10, + rngState: 999, + kills: 2, + xp: 100, + level: 2, + player: { x: 150.5, y: 220.25, hp: 50, maxHp: 60, mana: 20, maxMana: 25, cooldown: 0 }, + monsters: [{ id: 1, kind: 'zombie', x: 300, y: 400, hp: 20, maxHp: 30, cooldown: 5, deadTicks: 0 }], + } + const h1 = computeLockstepHash(world, [], [], []) + const h2 = computeLockstepHash(world, [], [], []) + expect(h1).toBe(h2) + expect(typeof h1).toBe('number') + }) +}) + +// =========================================================================== +// 3. Issue #520 — Unified Player Inventory Authority & Gold Cap (6 tests) +// =========================================================================== +describe('Tier 1 — #520: Unified Player Inventory Authority & Gold Cap', () => { + it('T1-520-01: getInventoryGoldLimit scales at 10,000 gold per character level', () => { + expect(getInventoryGoldLimit(1)).toBe(10_000) + expect(getInventoryGoldLimit(15)).toBe(150_000) + expect(getInventoryGoldLimit(99)).toBe(990_000) + }) + + it('T1-520-02: GameEngine pickupGold adds to engine.gold up to maxGoldCapacity and removes ground entity', () => { + const engine = makeEngine() + const gItem = goldItem(2500) + const groundEnt = engine.groundItems.add(gItem, 10, 10, 200, 200) + const res = engine.pickupGold(groundEnt.id) + expect(res.picked).toBe(true) + expect(res.amount).toBe(2500) + expect(engine.gold).toBe(2500) + expect(engine.groundItems.get(groundEnt.id)).toBeUndefined() + }) + + it('T1-520-03: GameEngine dropGold deducts from engine.gold and spawns ground gold item', () => { + const engine = makeEngine() + engine.gold = 5000 + const dropped = engine.dropGold(1500, 210, 210, 11, 11) + expect(dropped).not.toBeNull() + expect(engine.gold).toBe(3500) + expect(engine.groundItems.count).toBe(1) + expect(dropped?.item.value).toBe(1500) + }) + + it('T1-520-04: WorldPanelsHud depositGold and withdrawGold transfer gold between inventory and stash', () => { + const inv = new InventoryPanel() + inv.playerLevel = 10 // cap 100,000 + inv.gold = 40_000 + const worldPanels = new WorldPanelsHud() + const deposited = worldPanels.depositGold(25_000, inv) + expect(deposited).toBe(25_000) + expect(inv.gold).toBe(15_000) + expect(worldPanels.stashGold).toBe(25_000) + + const withdrawn = worldPanels.withdrawGold(10_000, inv) + expect(withdrawn).toBe(10_000) + expect(inv.gold).toBe(25_000) + expect(worldPanels.stashGold).toBe(15_000) + }) + + it('T1-520-05: WorldPanelsHud quickTransferToStash and quickTransferFromStash move items cleanly', () => { + const inv = new InventoryPanel() + const worldPanels = new WorldPanelsHud() + const item = makeItem('swd', 'Short Sword', 1, 3, 100) + expect(inv.autoPlaceInGrid(item)).toBe(true) + expect(inv.gridItems).toHaveLength(1) + + expect(worldPanels.quickTransferToStash(item, inv)).toBe(true) + expect(inv.gridItems).toHaveLength(0) + expect(worldPanels.stashItems).toHaveLength(1) + + expect(worldPanels.quickTransferFromStash(item, inv)).toBe(true) + expect(worldPanels.stashItems).toHaveLength(0) + expect(inv.gridItems).toHaveLength(1) + }) + + it('T1-520-06: HudManager syncPublishedState synchronizes inventory, stash, and character sheet state', () => { + const hud = new HudManager(createMock2DCanvas(), { + onToggleAutomap: () => {}, + onWaypointTeleport: () => {}, + }) + hud.inventory.playerLevel = 5 + hud.inventory.gold = 12_000 + hud.worldPanels.stashGold = 50_000 + expect(() => hud.syncPublishedState()).not.toThrow() + expect(hud.inventory.maxGold).toBe(50_000) + }) +}) + +// =========================================================================== +// 4. Issue #521 — Web Audio API Synthesis & Drop/Feedback SFX (6 tests) +// =========================================================================== +describe('Tier 1 — #521: Web Audio API Synthesis & Drop/Feedback SFX', () => { + it('T1-521-01: AudioManager playSfx triggers procedural synthesis without throwing in headless env', () => { + const audio = new AudioManager() + expect(() => audio.playSfx('item_gold')).not.toThrow() + expect(() => audio.playSfx('item_weapon')).not.toThrow() + expect(() => audio.playSfx('item_armor')).not.toThrow() + audio.dispose() + }) + + it('T1-521-02: AudioManager volume setters clamp master, sfx, and music volumes to [0, 1]', () => { + const audio = new AudioManager() + expect(() => { + audio.setMasterVolume(0.8) + audio.setSfxVolume(0.6) + audio.setMusicVolume(0.4) + }).not.toThrow() + audio.dispose() + }) + + it('T1-521-03: GroundItemManager updateBounces plays drop SFX when item bounce lands', () => { + const gm = new GroundItemManager() + const audio = new AudioManager() + const spy = vi.spyOn(audio, 'playSfx') + const item = goldItem(100) + gm.add(item, 5, 5, 100, 100, { now: 1000, bounceDurationMs: 400, peakHeightPx: 32 }) + gm.updateBounces(1500, audio) + expect(spy).toHaveBeenCalled() + audio.dispose() + }) + + it('T1-521-04: playInventoryFullFeedback invokes AudioManager error feedback sound', () => { + const audio = new AudioManager() + const spy = vi.spyOn(audio, 'playSfx') + playInventoryFullFeedback(audio) + expect(spy).toHaveBeenCalled() + audio.dispose() + }) + + it('T1-521-05: calculateBounceHeight returns positive arc height mid-flight and 0 after duration', () => { + const bounce = { startTime: 1000, durationMs: 400, peakHeightPx: 40, phase: 'primary' as const } + const mid = calculateBounceHeight(bounce, 1200) + const done = calculateBounceHeight(bounce, 1500) + expect(mid).toBeGreaterThan(0) + expect(mid).toBeLessThanOrEqual(40) + expect(done).toBe(0) + }) + + it('T1-521-06: AudioManager playTone and playMusic execute safely and dispose cleans up', () => { + const audio = new AudioManager() + expect(() => + audio.playTone({ type: 'sine', from: 440, to: 220, gain: 0.2, durationS: 0.1 }), + ).not.toThrow() + expect(() => audio.playMusic('tristram')).not.toThrow() + expect(() => audio.dispose()).not.toThrow() + }) +}) + +// =========================================================================== +// 5. Issue #522 — Ground Flippy & Missile/Overlay Sprites (6 tests) +// =========================================================================== +describe('Tier 1 — #522: Ground Flippy & Missile/Overlay Sprites', () => { + it('T1-522-01: resolveGroundItemFlippyFrame resolves authentic flippy frame keys for gold piles and gear', () => { + const smallGold = goldItem(50) + const largeGold = goldItem(15000) + const sword = makeItem('swd', 'Short Sword', 1, 3, 100) + expect(typeof resolveGroundItemFlippyFrame(smallGold)).toBe('string') + expect(typeof resolveGroundItemFlippyFrame(largeGold)).toBe('string') + expect(typeof resolveGroundItemFlippyFrame(sword)).toBe('string') + }) + + it('T1-522-02: drawGroundItem renders ground item with bounce offset via SpriteRenderer', () => { + const { canvas } = createMockWebGLCanvas() + const renderer = new SpriteRenderer(canvas) + const gm = new GroundItemManager() + const ent = gm.add(goldItem(500), 10, 10, 250, 250, { now: 1000 }) + renderer.begin(250, 250) + expect(() => drawGroundItem(renderer, ent, 1100, null)).not.toThrow() + renderer.end() + renderer.dispose() + }) + + it('T1-522-03: CANONICAL_113C_MISSILES and CANONICAL_113C_OVERLAYS expose authentic missile and overlay records', () => { + expect(Object.keys(CANONICAL_113C_MISSILES).length).toBeGreaterThan(0) + expect(Object.keys(CANONICAL_113C_OVERLAYS).length).toBeGreaterThan(0) + }) + + it('T1-522-04: castSkill spawns projectiles and cast overlays on GameEngine during tick', () => { + const engine = makeEngine() + engine.world.player.mana = 50 + engine.tick({ ...IDLE_INPUT, digits: [1] }) + expect(engine.projectiles.length).toBeGreaterThanOrEqual(1) + }) + + it('T1-522-05: drawMissileProjectile, drawExplosion, and drawCastOverlay render without throwing', () => { + const { canvas } = createMockWebGLCanvas() + const renderer = new SpriteRenderer(canvas) + renderer.begin(200, 200) + expect(() => + drawMissileProjectile( + renderer, + { x: 200, y: 200, vx: 100, vy: 0, damage: 10, ttl: 10, fromPlayer: true, skillId: 'firebolt' }, + null, + null, + ), + ).not.toThrow() + expect(() => + drawExplosion(renderer, { missileType: 'fireexplode', x: 220, y: 220, radius: 32, frame: 5, maxFrames: 10 }, null), + ).not.toThrow() + const overlay = createActiveOverlay('sorceress_cast_fire', { x: 200, y: 200 }, 'player') + if (overlay) { + expect(() => drawCastOverlay(renderer, overlay, null)).not.toThrow() + } + renderer.end() + renderer.dispose() + }) + + it('T1-522-06: getSkillCastOverlay returns canonical overlay ID for skill casting', () => { + const overlayId = getSkillCastOverlay('firebolt') + expect(overlayId === undefined || typeof overlayId === 'string').toBe(true) + }) +}) + +// =========================================================================== +// 6. Issue #523 — WebGL Texture Disposal & LoadGeneration (6 tests) +// =========================================================================== +describe('Tier 1 — #523: WebGL Texture Disposal & LoadGeneration', () => { + it('T1-523-01: LoadGeneration increments generation counter and aborts prior AbortSignal on next()', () => { + const lg = new LoadGeneration() + expect(lg.current).toBe(0) + const first = lg.next() + expect(first.gen).toBe(1) + expect(lg.isCurrent(1)).toBe(true) + expect(first.signal.aborted).toBe(false) + + const second = lg.next() + expect(second.gen).toBe(2) + expect(lg.isCurrent(1)).toBe(false) + expect(lg.isCurrent(2)).toBe(true) + expect(first.signal.aborted).toBe(true) + expect(second.signal.aborted).toBe(false) + }) + + it('T1-523-02: LoadGeneration invalidate() increments generation and aborts active signal', () => { + const lg = new LoadGeneration() + const { gen, signal } = lg.next() + expect(lg.isCurrent(gen)).toBe(true) + lg.invalidate() + expect(lg.isCurrent(gen)).toBe(false) + expect(signal.aborted).toBe(true) + }) + + it('T1-523-03: SpriteRenderer tracks liveTextureCount across addAtlas and deleteAtlas', () => { + const { canvas } = createMockWebGLCanvas() + const renderer = new SpriteRenderer(canvas) + const baseline = renderer.liveTextureCount + const h1 = renderer.addAtlas(new Uint8Array(16 * 16 * 4), 16, 16) + const h2 = renderer.addIndexedAtlas(new Uint8Array(16 * 16), 16, 16) + expect(renderer.liveTextureCount).toBe(baseline + 2) + + expect(renderer.deleteAtlas(h1)).toBe(true) + expect(renderer.liveTextureCount).toBe(baseline + 1) + expect(renderer.deleteAtlas(h2)).toBe(true) + expect(renderer.liveTextureCount).toBe(baseline) + renderer.dispose() + }) + + it('T1-523-04: SpriteRenderer.dispose() releases all WebGL textures and sets isDisposed = true', () => { + const { canvas, liveTextures } = createMockWebGLCanvas() + const renderer = new SpriteRenderer(canvas) + renderer.addAtlas(new Uint8Array(8 * 8 * 4), 8, 8) + renderer.addAtlas(new Uint8Array(8 * 8 * 4), 8, 8) + expect(renderer.liveTextureCount).toBeGreaterThan(0) + + renderer.dispose() + expect(renderer.isDisposed).toBe(true) + expect(renderer.liveTextureCount).toBe(0) + expect(liveTextures.size).toBe(0) + }) + + it('T1-523-05: disposeRuntimeAtlases deletes all atlas handles registered on a level runtime', () => { + const { canvas } = createMockWebGLCanvas() + const renderer = new SpriteRenderer(canvas) + const baseline = renderer.liveTextureCount + const hWall = renderer.addAtlas(new Uint8Array(8 * 8 * 4), 8, 8) + const hFloor = renderer.addAtlas(new Uint8Array(8 * 8 * 4), 8, 8) + expect(renderer.liveTextureCount).toBe(baseline + 2) + + const mockRuntime = { + wallAtlas: hWall, + floorAtlas: hFloor, + monsterAtlases: new Map(), + } + disposeRuntimeAtlases(mockRuntime as never, renderer) + expect(renderer.liveTextureCount).toBe(baseline) + renderer.dispose() + }) + + it('T1-523-06: createLevelTransitionController coordinates generation tokens and disposes previous runtime', async () => { + const { canvas } = createMockWebGLCanvas() + const renderer = new SpriteRenderer(canvas) + const hOld = renderer.addAtlas(new Uint8Array(8 * 8 * 4), 8, 8) + const oldRuntime = { wallAtlas: hOld, monsterAtlases: new Map() } + + const controller = createLevelTransitionController({ + renderer, + initialRuntime: oldRuntime as never, + loadLevelRuntime: async (levelId: number) => ({ + levelId, + wallAtlas: renderer.addAtlas(new Uint8Array(8 * 8 * 4), 8, 8), + monsterAtlases: new Map(), + }), + }) + expect(controller).toBeDefined() + renderer.dispose() + }) +}) + +// =========================================================================== +// 7. Issue #524 — Real MPQ Data & 36-Row AutoMagic.txt (6 tests) +// =========================================================================== +describe('Tier 1 — #524: Real MPQ Data & 36-Row AutoMagic.txt', () => { + it('T1-524-01: CANONICAL_AUTOMAGIC_TABLE contains all 36 authentic 1.13c AutoMagic.txt rows', () => { + expect(CANONICAL_AUTOMAGIC_TABLE.entries.length).toBe(36) + }) + + it('T1-524-02: CANONICAL_AUTOMAGIC_TABLE lookup by ID returns expected affix groups and properties', () => { + const first = CANONICAL_AUTOMAGIC_TABLE.get(0) + const last = CANONICAL_AUTOMAGIC_TABLE.get(35) + expect(first).toBeDefined() + expect(last).toBeDefined() + expect(first?.name).toBeTruthy() + expect(last?.name).toBeTruthy() + }) + + it('T1-524-03: CANONICAL_AUTOMAGIC_TABLE.getEligible filters by item type and alvl', () => { + const palShields = CANONICAL_AUTOMAGIC_TABLE.getEligible('ashd', 50, true) + expect(palShields.length).toBeGreaterThan(0) + for (const row of palShields) { + expect(row.level).toBeLessThanOrEqual(50) + } + }) + + it('T1-524-04: D2DataRegistry loads canonical 1.13c skills, missiles, states, and overlays', async () => { + const reg = await getSharedDataRegistry() + expect(reg.getAllPlayerSkills().length).toBeGreaterThanOrEqual(221) + expect(reg.getPetSubSkills().length).toBeGreaterThanOrEqual(17) + expect(reg.getSkillById(36)?.skill).toBe('Fire Bolt') + expect(reg.getMissileByName('firebolt')).toBeDefined() + }) + + it('T1-524-05: evaluateSkill113c evaluates skill damage and mana cost from real MPQ skill records', async () => { + const reg = await getSharedDataRegistry() + const skill = reg.getSkillById(36)! // Fire Bolt + const statList = new UnitStatList(reg) + const evalRes = evaluateSkill113c({ + registry: reg, + skill, + slvl: 10, + blvl: 10, + statList, + }) + expect(evalRes.skillId).toBe(36) + expect(evalRes.minElemDmg).toBeGreaterThan(0) + expect(evalRes.maxElemDmg).toBeGreaterThanOrEqual(evalRes.minElemDmg) + expect(evalRes.manaCost256).toBeGreaterThan(0) + }) + + it('T1-524-06: executeSkill113c executes Fire Bolt and spawns a firebolt missile in MissileEngine', async () => { + const reg = await getSharedDataRegistry() + const skill = reg.getSkillById(36)! + const statList = new UnitStatList(reg) + const stateBus = new StateBus(reg, statList) + const evalResult = evaluateSkill113c({ registry: reg, skill, slvl: 5, blvl: 5, statList, stateBus }) + const caster = { id: 'player-1', name: 'Sorceress', statList, stateBus, x: 100, y: 100 } + const missileEngine = new MissileEngine(reg) + const auraScanner = new AuraScanner(reg) + const summonManager = new SummonManager(reg) + + const outcome = executeSkill113c({ + registry: reg, + skill, + evalResult, + caster, + targets: [], + targetPositions: new Map(), + corpses: [], + missileEngine, + auraScanner, + summonManager, + currentTick: 1, + targetX: 200, + targetY: 100, + }) + expect(outcome.executed).toBe(true) + expect(outcome.missilesSpawned).toContain('firebolt') + }) +}) + +// =========================================================================== +// 8. Issue #528 — Authentic 64-Bit D2Rng LCG & Zero Math.random() (6 tests) +// =========================================================================== +describe('Tier 1 — #528: Authentic 64-Bit D2Rng LCG & Zero Math.random()', () => { + it('T1-528-01: D2Rng step() implements exact 0x6AC690C5 * lo + hi 64-bit carry recurrence', () => { + const rng = new D2Rng(1, 666) + const val = rng.step() + // 1 * 0x6AC690C5 + 666 = 1791398085 + 666 = 1791398751 (0x6AC6935F) + expect(val).toBe(0x6ac6935f) + expect(rng.lo).toBe(0x6ac6935f) + expect(rng.hi).toBe(0) + }) + + it('T1-528-02: D2Rng clone() and getSeed()/setSeed() preserve and restore 64-bit state identically', () => { + const rng1 = new D2Rng(0x12345678, 0x9abcdef0) + rng1.step() + rng1.step() + const clone = rng1.clone() + const snapshot = rng1.getSeed() + + const rng2 = new D2Rng(1, 1) + rng2.setSeed(snapshot.lo, snapshot.hi) + + for (let i = 0; i < 10; i++) { + const a = rng1.step() + const b = clone.step() + const c = rng2.step() + expect(a).toBe(b) + expect(a).toBe(c) + } + }) + + it('T1-528-03: D2Rng rand(max), randRange(min, max), and int(min, max) stay within bounds', () => { + const rng = new D2Rng(42) + for (let i = 0; i < 50; i++) { + const r = rng.rand(10) + expect(r).toBeGreaterThanOrEqual(0) + expect(r).toBeLessThan(10) + + const rr = rng.randRange(5, 15) + expect(rr).toBeGreaterThanOrEqual(5) + expect(rr).toBeLessThanOrEqual(15) + + const ri = rng.int(100, 105) + expect(ri).toBeGreaterThanOrEqual(100) + expect(ri).toBeLessThanOrEqual(105) + } + }) + + it('T1-528-04: Two GameEngine instances with identical lootSeed produce identical RNG trajectories', () => { + const e1 = makeEngine(98765) + const e2 = makeEngine(98765) + for (let t = 0; t < 20; t++) { + e1.tick(IDLE_INPUT) + e2.tick(IDLE_INPUT) + } + expect(e1.loot.getSeed()).toEqual(e2.loot.getSeed()) + expect(e1.castRng.getSeed()).toEqual(e2.castRng.getSeed()) + }) + + it('T1-528-05: GameEngine tick() never invokes Math.random()', () => { + const spy = vi.spyOn(Math, 'random') + const engine = makeEngine(55555) + for (let i = 0; i < 15; i++) { + engine.tick({ ...IDLE_INPUT, attacking: i % 3 === 0 }) + } + expect(spy).not.toHaveBeenCalled() + }) + + it('T1-528-06: GameEngine saveSnapshot and loadSnapshot preserve 64-bit RNG states', () => { + const engine = makeEngine(24680) + for (let i = 0; i < 10; i++) engine.tick(IDLE_INPUT) + const snap = engine.saveSnapshot() + const expectedLootNext = engine.loot.clone().step() + + const restored = makeEngine(11111) + expect(restored.loadSnapshot(snap)).toBe(true) + expect(restored.loot.step()).toBe(expectedLootNext) + }) +}) + +// =========================================================================== +// 9. Issue #529 — Interactive HUD Panels & Click Routing (6 tests) +// =========================================================================== +describe('Tier 1 — #529: Interactive HUD Panels & Click Routing', () => { + it('T1-529-01: InventoryPanel clickGridCell picks up item to cursor and places it into empty cell', () => { + const inv = new InventoryPanel() + const item = makeItem('hp1', 'Minor Healing Potion', 1, 1, 30) + expect(inv.autoPlaceInGrid(item)).toBe(true) + const placed = inv.gridItems[0]! + + const pickRes = inv.clickGridCell(placed.col, placed.row) + expect(pickRes.cursorItem).toBe(item) + expect(inv.gridItems).toHaveLength(0) + + const placeRes = inv.clickGridCell(5, 2) + expect(placeRes.cursorItem).toBeNull() + expect(inv.gridItems).toHaveLength(1) + expect(inv.gridItems[0]!.col).toBe(5) + expect(inv.gridItems[0]!.row).toBe(2) + }) + + it('T1-529-02: InventoryPanel clickEquipSlot equips compatible item from cursor and unequips on second click', () => { + const inv = new InventoryPanel() + const helm = makeItem('cap', 'Cap', 2, 2, 40) + inv.cursorItem = helm + const eqRes = inv.clickEquipSlot('head') + expect(eqRes.action).toBe('equip') + expect(inv.equipped.get('head')).toBe(helm) + expect(inv.cursorItem).toBeNull() + + const unEqRes = inv.clickEquipSlot('head') + expect(unEqRes.action).toBe('unequip') + expect(inv.cursorItem).toBe(helm) + expect(inv.equipped.get('head')).toBeUndefined() + }) + + it('T1-529-03: CharacterSheetPanel allocateStat spends statPoints and increments attribute + derived stats', () => { + const charSheet = new CharacterSheetPanel() + charSheet.attrs.statPoints = 5 + const oldStr = charSheet.attrs.str + const oldVit = charSheet.attrs.vit + const oldHp = charSheet.attrs.baseMaxHp + + expect(charSheet.allocateStat('str')).toBe(true) + expect(charSheet.attrs.str).toBe(oldStr + 1) + expect(charSheet.attrs.statPoints).toBe(4) + + expect(charSheet.allocateStat('vit')).toBe(true) + expect(charSheet.attrs.vit).toBe(oldVit + 1) + expect(charSheet.attrs.baseMaxHp).toBeGreaterThanOrEqual(oldHp) + expect(charSheet.attrs.statPoints).toBe(3) + }) + + it('T1-529-04: WorldPanelsHud vendor buyFromActiveVendor and sellToActiveVendor update gold and inventory', () => { + const inv = new InventoryPanel() + inv.playerLevel = 20 + inv.gold = 50_000 + const wp = new WorldPanelsHud() + wp.openVendorForNpc('Akara', 'trade', 10, 'normal') + + const sellItem = makeItem('swd', 'Short Sword', 1, 3, 200) + expect(inv.autoPlaceInGrid(sellItem)).toBe(true) + const sold = wp.sellToActiveVendor(sellItem, inv) + expect(sold.ok).toBe(true) + expect(inv.gold).toBeGreaterThan(50_000) + }) + + it('T1-529-05: HudManager toggleLeftPanel and toggleRightPanel manage mutually exclusive panel states', () => { + const hud = new HudManager(createMock2DCanvas(), { + onToggleAutomap: () => {}, + onWaypointTeleport: () => {}, + }) + hud.toggleLeftPanel('char') + expect(hud.leftPanel).toBe('char') + expect(hud.charSheet.visible).toBe(true) + + hud.toggleRightPanel('inv') + expect(hud.rightPanel).toBe('inv') + expect(hud.inventory.visible).toBe(true) + + hud.closeAllPanels() + expect(hud.leftPanel).toBe('none') + expect(hud.rightPanel).toBe('none') + }) + + it('T1-529-06: HudManager switchClass updates active character class across HUD subpanels', () => { + const hud = new HudManager(createMock2DCanvas(), { + onToggleAutomap: () => {}, + onWaypointTeleport: () => {}, + }) + expect(() => hud.switchClass('sor')).not.toThrow() + expect(() => hud.switchClass('pal')).not.toThrow() + }) +}) + +// =========================================================================== +// 10. Issue #530 — Single Unified Engine Authority & Rendering (6 tests) +// =========================================================================== +describe('Tier 1 — #530: Single Unified Engine Authority & Rendering', () => { + it('T1-530-01: GameEngine exposes unified world, bag, groundItems, projectiles, explosions, and overlays', () => { + const engine = makeEngine() + expect(engine.world).toBeDefined() + expect(engine.bag).toBeInstanceOf(Inventory) + expect(engine.groundItems).toBeInstanceOf(GroundItemManager) + expect(Array.isArray(engine.projectiles)).toBe(true) + expect(Array.isArray(engine.explosions)).toBe(true) + expect(Array.isArray(engine.overlays)).toBe(true) + }) + + it('T1-530-02: GameEngine dropItem and pickupItem round-trip item between engine.bag and engine.groundItems', () => { + const engine = makeEngine() + const item = makeItem('rng', 'Ring', 1, 1, 150) + const dropped = engine.dropItem(item, 200, 200, 10, 10) + expect(engine.groundItems.count).toBe(1) + + const picked = engine.pickupItem(dropped.id) + expect(picked.picked).toBe(true) + expect(engine.groundItems.count).toBe(0) + expect(engine.bag.list().some((p) => p.item.name === 'Ring')).toBe(true) + }) + + it('T1-530-03: GameEngine damageMonster kills monster, awards XP, and spawns drop in groundItems', () => { + const engine = makeEngine() + const m = engine.world.monsters[0]! + const prevKills = engine.world.kills + const res = engine.damageMonster(0, m.hp + 100, 'player') + expect(res.killed).toBe(true) + expect(engine.world.kills).toBe(prevKills + 1) + }) + + it('T1-530-04: loadItemsAtlas, loadMissileArtMap, and loadOverlayArtMap handle missing fetch gracefully', async () => { + const { canvas } = createMockWebGLCanvas() + const renderer = new SpriteRenderer(canvas) + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 404 }))) + + const itemsAtlas = await loadItemsAtlas(renderer, '/nonexistent') + const missileMap = await loadMissileArtMap(renderer, '/nonexistent') + const overlayMap = await loadOverlayArtMap(renderer, '/nonexistent') + + expect(itemsAtlas).toBeUndefined() + expect(missileMap.size).toBe(0) + expect(overlayMap.size).toBe(0) + renderer.dispose() + }) + + it('T1-530-05: GameEngine tick advances player movement and updates world tick counter', () => { + const engine = makeEngine() + const startX = engine.world.player.x + engine.tick({ ...IDLE_INPUT, movement: { x: 1, y: 0 } }) + expect(engine.world.tick).toBe(1) + expect(engine.world.player.x).toBeGreaterThan(startX) + }) + + it('T1-530-06: GameEngine saveSnapshot and loadSnapshot restore player position, inventory, and gold', () => { + const engine = makeEngine() + engine.gold = 4321 + engine.bag.add(makeItem('amu', 'Amulet', 1, 1, 200)) + engine.tick({ ...IDLE_INPUT, movement: { x: 1, y: 1 } }) + const snap = engine.saveSnapshot() + + const engine2 = makeEngine() + expect(engine2.loadSnapshot(snap)).toBe(true) + expect(engine2.gold).toBe(4321) + expect(engine2.bag.list().some((p) => p.item.name === 'Amulet')).toBe(true) + expect(engine2.world.player.x).toBeCloseTo(engine.world.player.x, 4) + }) +}) diff --git a/tests/e2e-p0-516/tier2-boundary-corner.test.ts b/tests/e2e-p0-516/tier2-boundary-corner.test.ts new file mode 100644 index 0000000..61a2d5f --- /dev/null +++ b/tests/e2e-p0-516/tier2-boundary-corner.test.ts @@ -0,0 +1,825 @@ +/** + * Tier 2: Boundary Value Analysis & Negative/Adversarial Tests (`tests/e2e-p0-516/tier2-boundary-corner.test.ts`). + * + * Covers boundary conditions, limits, malformed inputs, overflow guards, and negative paths + * across all 10 P0 issues (`#517`, `#518`, `#520`, `#521`, `#522`, `#523`, `#524`, `#528`, `#529`, `#530`). + * 60 test cases (6 per P0 issue). + */ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { decodeFrames, decodeFrame as decodeRelayFrame, encodeFrame as encodeRelayFrame } from '../../scripts/net-relay.ts' +import { + LockstepSession, + NetplaySession, + ProtocolError, + encodeMessage, + decodeMessage, + decodeFrame, + memoryTransportPair, + DEFAULT_MAX_MESSAGE, + MAX_FUTURE_TICKS, + NO_ACK, + type InputFrame, +} from '../../src/net/lockstep-manager.ts' +import { GameEngine, type EngineInput, type WorldMapProvider } from '../../src/game/engine.ts' +import { GroundItemManager, calculateBounceHeight } from '../../src/game/ground-items.ts' +import { Inventory, goldItem, type Item, type ItemBase } from '../../src/game/items.ts' +import { InventoryPanel, getInventoryGoldLimit } from '../../src/ui/inventory.ts' +import { WorldPanelsHud, STASH_GOLD_CAP } from '../../src/ui/world-panels.ts' +import { CharacterSheetPanel } from '../../src/ui/character-sheet.ts' +import { AudioManager } from '../../src/audio/manager.ts' +import { playInventoryFullFeedback } from '../../src/scene/frontend/overhead-labels.ts' +import { LoadGeneration, SpriteRenderer } from '../../src/render/renderer.ts' +import { disposeRuntimeAtlases } from '../../src/scene/frontend/pack-loader.ts' +import { resolveGroundItemFlippyFrame, drawGroundItem } from '../../src/scene/frontend/ground-item-renderer.ts' +import { drawMissileProjectile, drawExplosion } from '../../src/scene/frontend/missile-overlay-renderer.ts' +import { D2Rng } from '../../src/game/d2-rng.ts' +import { CANONICAL_AUTOMAGIC_TABLE, parseAutoMagicTable } from '../../src/game/automagic.ts' +import { getSharedDataRegistry } from '../../src/game/engine/data-registry.ts' +import { UnitStatList } from '../../src/game/engine/stat-list.ts' +import { evaluateSkill113c } from '../../src/game/skills/registry.ts' + +function makeItem(id: string, name: string, invWidth = 1, invHeight = 1, value = 50): Item { + const base: ItemBase = { + id, + name, + kind: 'misc', + invWidth, + invHeight, + maxStack: 1, + value, + damage: 0, + defense: 0, + tags: ['misc'], + level: 1, + } + return { + base, + prefix: null, + suffix: null, + level: 1, + name, + stats: {}, + invWidth, + invHeight, + stack: 1, + value, + code: id, + } +} + +function makeEngine(seed = 12345): GameEngine { + const terrain: WorldMapProvider = { widthPx: 2000, heightPx: 2000, overlap: () => 0 } + return new GameEngine(terrain, { + spawn: { x: 200, y: 200 }, + stats: [{ id: 'zombie', name: 'Zombie', hp: 30, speed: 60, reach: 32, damage: 5, cooldownTicks: 20, xp: 50, aggroRadius: 200 }], + xpTable: [0, 500, 1500, 3750], + skills: [], + npcDefs: [], + questDefs: [], + combatOptions: { + playerSpeed: 170, + playerReach: 48, + playerCooldownTicks: 5, + playerDamage: 20, + playerManaPerAttack: 0, + respawnTicks: 50, + }, + lootSeed: seed, + talkRadius: 64, + pickupRadius: 64, + inventoryCols: 10, + inventoryRows: 4, + monsterCount: 2, + }) +} + +function createMockWebGLCanvas() { + let nextTexId = 1 + const liveTextures = new Set() + const gl = { + NO_ERROR: 0, + TRIANGLES: 4, + TEXTURE_2D: 0x0de1, + TEXTURE0: 0x84c0, + TEXTURE1: 0x84c1, + RGBA: 0x1908, + R8: 0x8229, + RED: 0x1903, + UNSIGNED_BYTE: 0x1401, + NEAREST: 0x2600, + CLAMP_TO_EDGE: 0x812f, + TEXTURE_MIN_FILTER: 0x2801, + TEXTURE_MAG_FILTER: 0x2800, + TEXTURE_WRAP_S: 0x2802, + TEXTURE_WRAP_T: 0x2803, + UNPACK_ALIGNMENT: 0x0cf5, + ARRAY_BUFFER: 0x8892, + DYNAMIC_DRAW: 0x88e8, + FLOAT: 0x1406, + BLEND: 0x0be2, + SRC_ALPHA: 0x0302, + ONE_MINUS_SRC_ALPHA: 0x0303, + ONE: 1, + COLOR_BUFFER_BIT: 0x4000, + VERTEX_SHADER: 0x8b31, + FRAGMENT_SHADER: 0x8b30, + COMPILE_STATUS: 0x8b81, + LINK_STATUS: 0x8b82, + getError: vi.fn(() => 0), + createShader: vi.fn(() => ({})), + shaderSource: vi.fn(), + compileShader: vi.fn(), + getShaderParameter: vi.fn(() => true), + getShaderInfoLog: vi.fn(() => ''), + deleteShader: vi.fn(), + createProgram: vi.fn(() => ({})), + attachShader: vi.fn(), + linkProgram: vi.fn(), + getProgramParameter: vi.fn(() => true), + getProgramInfoLog: vi.fn(() => ''), + useProgram: vi.fn(), + deleteProgram: vi.fn(), + getUniformLocation: vi.fn(() => ({})), + uniform1i: vi.fn(), + uniform2f: vi.fn(), + createVertexArray: vi.fn(() => ({})), + bindVertexArray: vi.fn(), + deleteVertexArray: vi.fn(), + createBuffer: vi.fn(() => ({})), + bindBuffer: vi.fn(), + bufferData: vi.fn(), + bufferSubData: vi.fn(), + deleteBuffer: vi.fn(), + enableVertexAttribArray: vi.fn(), + vertexAttribPointer: vi.fn(), + createTexture: vi.fn(() => { + const id = nextTexId++ + liveTextures.add(id) + return { id } + }), + bindTexture: vi.fn(), + activeTexture: vi.fn(), + pixelStorei: vi.fn(), + texParameteri: vi.fn(), + texImage2D: vi.fn(), + deleteTexture: vi.fn((tex: { id?: number } | null) => { + if (tex?.id !== undefined) liveTextures.delete(tex.id) + }), + enable: vi.fn(), + blendFunc: vi.fn(), + viewport: vi.fn(), + clearColor: vi.fn(), + clear: vi.fn(), + drawArrays: vi.fn(), + } + const canvas = { + width: 800, + height: 600, + getContext: vi.fn((type: string) => (type === 'webgl2' ? gl : null)), + } as unknown as HTMLCanvasElement + return { canvas, gl, liveTextures } +} + +afterEach(() => { + vi.restoreAllMocks() +}) + +// =========================================================================== +// 1. Issue #517 — Net Relay Boundaries & Malformed Frames (6 tests) +// =========================================================================== +function encodeMaskedClientFrame(opcode: number, payload: Uint8Array): Uint8Array { + const unmasked = encodeRelayFrame(opcode, payload) + const maskKey = new Uint8Array([0x12, 0x34, 0x56, 0x78]) + const headerLen = unmasked.byteLength - payload.byteLength + const out = new Uint8Array(headerLen + 4 + payload.byteLength) + out.set(unmasked.subarray(0, headerLen), 0) + out[1] = (out[1]! | 0x80) + out.set(maskKey, headerLen) + for (let i = 0; i < payload.byteLength; i++) { + out[headerLen + 4 + i] = payload[i]! ^ maskKey[i & 3]! + } + return out +} + +describe('Tier 2 — #517: Net Relay Boundaries & Malformed Frames', () => { + it('T2-517-01: decodeFrames accepts exact maxPayloadBytes (4096) without error', () => { + const payload = new Uint8Array(4096) + payload[0] = 0xaa + payload[4095] = 0xbb + const frame = encodeMaskedClientFrame(0x02, payload) + const res = decodeFrames(frame, 4096) + expect(res.error).toBeUndefined() + expect(res.frames).toHaveLength(1) + expect(res.frames[0]!.payload.byteLength).toBe(4096) + }) + + it('T2-517-02: decodeFrames rejects maxPayloadBytes + 1 (4097) with code 1009', () => { + const payload = new Uint8Array(4097) + const frame = encodeMaskedClientFrame(0x02, payload) + const res = decodeFrames(frame, 4096) + expect(res.error).toBeDefined() + expect(res.error?.code).toBe(1009) + expect(res.frames).toHaveLength(0) + }) + + it('T2-517-03: decodeFrames handles 0-byte empty buffer cleanly', () => { + const res = decodeFrames(new Uint8Array(0)) + expect(res.error).toBeUndefined() + expect(res.frames).toHaveLength(0) + expect(res.consumed).toBe(0) + }) + + it('T2-517-04: decodeFrames handles 4-byte incomplete header (< 5 bytes) without consuming', () => { + const buf = new Uint8Array([0x82, 0x80 | 10, 0x12, 0x34]) + const res = decodeFrames(buf) + expect(res.error).toBeUndefined() + expect(res.frames).toHaveLength(0) + expect(res.consumed).toBe(0) + }) + + it('T2-517-05: decodeFrames handles 0-length payload frame (header-only 6 bytes masked)', () => { + const frame = encodeMaskedClientFrame(0x02, new Uint8Array(0)) + expect(frame.byteLength).toBe(6) + const res = decodeFrames(frame) + expect(res.error).toBeUndefined() + expect(res.frames).toHaveLength(1) + expect(res.frames[0]!.opcode).toBe(0x02) + expect(res.frames[0]!.payload.byteLength).toBe(0) + }) + + it('T2-517-06: decodeFrames stops at oversized second frame after decoding valid first frame', () => { + const valid = encodeMaskedClientFrame(0x02, new Uint8Array([1, 2])) + const badHeader = new Uint8Array(4) + badHeader[0] = 0x82 + badHeader[1] = 0x80 | 126 + new DataView(badHeader.buffer).setUint16(2, 5000, false) + const combined = new Uint8Array(valid.byteLength + badHeader.byteLength) + combined.set(valid, 0) + combined.set(badHeader, valid.byteLength) + const res = decodeFrames(combined, 4096) + expect(res.frames).toHaveLength(1) + expect(res.error?.code).toBe(1009) + }) +}) + +// =========================================================================== +// 2. Issue #518 — Lockstep Protocol & Session Boundaries (6 tests) +// =========================================================================== +describe('Tier 2 — #518: Lockstep Protocol & Session Boundaries', () => { + it('T2-518-01: decodeMessage throws ProtocolError on empty or unknown message type', () => { + expect(() => decodeMessage(new Uint8Array(0))).toThrow(ProtocolError) + expect(() => decodeMessage(new Uint8Array([0xff, 0, 0, 0]))).toThrow(ProtocolError) + }) + + it('T2-518-02: decodeFrame returns null on truncated frame and decodeRelayFrame throws on oversized frame', () => { + expect(decodeFrame(new Uint8Array([0, 10]))).toBeNull() + const huge = new Uint8Array(4) + new DataView(huge.buffer).setUint16(0, DEFAULT_MAX_MESSAGE + 100, false) + expect(decodeFrame(huge)).toBeNull() + expect(() => decodeRelayFrame(huge, DEFAULT_MAX_MESSAGE)).toThrow() + }) + + it('T2-518-03: LockstepSession rejects input frames beyond maxFutureTicks horizon', () => { + const session = new LockstepSession( + { peers: 2, inputDelayTicks: 1, maxFutureTicks: MAX_FUTURE_TICKS }, + { advance: () => {}, hash: () => 0 }, + ) + const farFuture: InputFrame = { + tick: session.tick + MAX_FUTURE_TICKS + 1, + movement: { x: 0, y: 0 }, + attack: false, + pickup: false, + talk: false, + skill: 0, + } + expect(session.submit(0, farFuture)).toBe(false) + }) + + it('T2-518-04: LockstepSession rejects out-of-range peer IDs and past ticks', () => { + const session = new LockstepSession( + { peers: 2, inputDelayTicks: 1 }, + { advance: () => {}, hash: () => 0 }, + ) + const idle0: InputFrame = { tick: 0, movement: { x: 0, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 } + session.submit(0, idle0) + session.submit(1, idle0) + session.step() // advances session.tick to 1 + const frame: InputFrame = { tick: 0, movement: { x: 0, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 } + expect(session.submit(0, frame)).toBe(false) // past tick 0 < current tick 1 + expect(session.submit(-1, { ...frame, tick: 1 })).toBe(false) + expect(session.submit(2, { ...frame, tick: 1 })).toBe(false) + }) + + it('T2-518-05: LockstepSession detects desync when remote hash mismatches local step hash', () => { + const session = new LockstepSession( + { peers: 2, inputDelayTicks: 1 }, + { advance: () => {}, hash: () => 0x11111111 }, + ) + const idle0: InputFrame = { tick: 0, movement: { x: 0, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 } + session.submit(0, idle0) + session.submit(1, idle0) + session.recordRemoteHash(1, 0, 0x22222222) + const out = session.step() + expect(out.kind).toBe('desync') + expect(session.desyncReport).not.toBeNull() + expect(session.desyncReport?.localHash).toBe(0x11111111) + expect(session.desyncReport?.remoteHash).toBe(0x22222222) + }) + + it('T2-518-06: NetplaySession detects seedMismatch when peers advertise different seeds in hello', () => { + const [t0, t1] = memoryTransportPair() + const p0 = new NetplaySession( + { peer: 0, peers: 2, seed: 100, inputDelayTicks: 1 }, + { advance: () => {}, hash: () => 1 }, + t0, + ) + const p1 = new NetplaySession( + { peer: 1, peers: 2, seed: 999, inputDelayTicks: 1 }, + { advance: () => {}, hash: () => 1 }, + t1, + ) + p0.start() + p1.start() + p0.pump() + p1.pump() + t0.flush() + t1.flush() + p0.pump() + p1.pump() + expect(p0.seedMismatch).toBe(999) + expect(p1.seedMismatch).toBe(100) + }) +}) + +// =========================================================================== +// 3. Issue #520 — Gold Cap Overflow & Full Grid Rejection (6 tests) +// =========================================================================== +describe('Tier 2 — #520: Gold Cap Overflow & Full Grid Rejection', () => { + it('T2-520-01: getInventoryGoldLimit clamps level <= 0 to level 1 (10,000) and level > 99 to 99 (990,000)', () => { + expect(getInventoryGoldLimit(0)).toBe(10_000) + expect(getInventoryGoldLimit(-5)).toBe(10_000) + expect(getInventoryGoldLimit(150)).toBe(990_000) + }) + + it('T2-520-02: GameEngine pickupGold at partial capacity leaves remainder on the ground', () => { + const engine = makeEngine() + engine.gold = engine.maxGoldCapacity - 300 + const groundEnt = engine.groundItems.add(goldItem(1000), 10, 10, 200, 200) + const res = engine.pickupGold(groundEnt.id) + expect(res.picked).toBe(true) + expect(res.amount).toBe(300) + expect(engine.gold).toBe(engine.maxGoldCapacity) + expect(engine.groundItems.get(groundEnt.id)?.item.value).toBe(700) + }) + + it('T2-520-03: GameEngine pickupGold when already at maxGoldCapacity picks up 0 and keeps ground gold', () => { + const engine = makeEngine() + engine.gold = engine.maxGoldCapacity + const groundEnt = engine.groundItems.add(goldItem(500), 10, 10, 200, 200) + const res = engine.pickupGold(groundEnt.id) + expect(res.picked).toBe(false) + expect(res.amount).toBe(0) + expect(engine.groundItems.get(groundEnt.id)?.item.value).toBe(500) + }) + + it('T2-520-04: WorldPanelsHud depositGold clamps at STASH_GOLD_CAP (2,500,000)', () => { + const inv = new InventoryPanel() + inv.playerLevel = 99 + inv.gold = 500_000 + const wp = new WorldPanelsHud() + wp.stashGold = STASH_GOLD_CAP - 100_000 + const moved = wp.depositGold(500_000, inv) + expect(moved).toBe(100_000) + expect(wp.stashGold).toBe(STASH_GOLD_CAP) + expect(inv.gold).toBe(400_000) + }) + + it('T2-520-05: WorldPanelsHud withdrawGold clamps at player level gold capacity', () => { + const inv = new InventoryPanel() + inv.playerLevel = 1 // max 10,000 + inv.gold = 8_000 + const wp = new WorldPanelsHud() + wp.stashGold = 50_000 + const moved = wp.withdrawGold(10_000, inv) + expect(moved).toBe(2_000) + expect(inv.gold).toBe(10_000) + expect(wp.stashGold).toBe(48_000) + }) + + it('T2-520-06: GameEngine pickupItem rejects 2x3 item when 10x4 bag has no contiguous 2x3 space', () => { + const engine = makeEngine() + // Fill every even row so no 2x3 item can fit + for (let x = 0; x < 10; x++) { + for (let y = 0; y < 4; y += 2) { + engine.bag.place(makeItem(`p_${x}_${y}`, 'Potion', 1, 1), x, y) + } + } + const bigArmor = makeItem('aar', 'Ancient Armor', 2, 3, 500) + const ent = engine.groundItems.add(bigArmor, 10, 10, 200, 200) + const res = engine.pickupItem(ent.id) + expect(res.picked).toBe(false) + expect(engine.groundItems.get(ent.id)).toBeDefined() + }) +}) + +// =========================================================================== +// 4. Issue #521 — Audio Boundaries & Missing Context Resilience (6 tests) +// =========================================================================== +describe('Tier 2 — #521: Audio Boundaries & Missing Context Resilience', () => { + it('T2-521-01: AudioManager clamps negative and >1 volume inputs without throwing', () => { + const audio = new AudioManager() + expect(() => { + audio.setMasterVolume(-5) + audio.setMasterVolume(99) + audio.setSfxVolume(-1) + audio.setSfxVolume(2) + audio.setMusicVolume(-0.5) + audio.setMusicVolume(10) + }).not.toThrow() + audio.dispose() + }) + + it('T2-521-02: AudioManager playSfx ignores unknown sound names safely', () => { + const audio = new AudioManager() + expect(() => audio.playSfx('non_existent_sfx_id_12345')).not.toThrow() + audio.dispose() + }) + + it('T2-521-03: playInventoryFullFeedback with undefined AudioManager does not throw', () => { + expect(() => playInventoryFullFeedback(undefined)).not.toThrow() + }) + + it('T2-521-04: calculateBounceHeight handles durationMs <= 0 and now < startTime safely', () => { + expect(calculateBounceHeight({ startTime: 1000, durationMs: 0, peakHeightPx: 30 }, 1000)).toBe(0) + expect(calculateBounceHeight({ startTime: 1000, durationMs: 400, peakHeightPx: 30 }, 900)).toBe(0) + }) + + it('T2-521-05: GroundItemManager updateBounces does not re-trigger drop sound on subsequent frames', () => { + const gm = new GroundItemManager() + const audio = new AudioManager() + const spy = vi.spyOn(audio, 'playSfx') + gm.add(goldItem(50), 5, 5, 100, 100, { now: 1000, bounceDurationMs: 300, peakHeightPx: 24 }) + gm.updateBounces(1400, audio) + const countAfterFirstLand = spy.mock.calls.length + gm.updateBounces(2000, audio) + const countAfterSecondLand = spy.mock.calls.length + gm.updateBounces(2500, audio) + expect(spy.mock.calls.length).toBe(countAfterSecondLand) + expect(countAfterFirstLand).toBeGreaterThanOrEqual(1) + audio.dispose() + }) + + it('T2-521-06: Calling AudioManager methods after dispose() is a safe no-op', () => { + const audio = new AudioManager() + audio.dispose() + expect(() => audio.playSfx('item_gold')).not.toThrow() + expect(() => audio.dispose()).not.toThrow() + }) +}) + +// =========================================================================== +// 5. Issue #522 — Ground Flippy & Missile Fallback Boundaries (6 tests) +// =========================================================================== +describe('Tier 2 — #522: Ground Flippy & Missile Fallback Boundaries', () => { + it('T2-522-01: resolveGroundItemFlippyFrame handles gold boundary thresholds (1, 99, 100, 499, 500, 4999, 5000)', () => { + const thresholds = [1, 99, 100, 499, 500, 4999, 5000] + const frames = thresholds.map((amt) => resolveGroundItemFlippyFrame(goldItem(amt))) + for (const f of frames) { + expect(typeof f).toBe('string') + expect(f.length).toBeGreaterThan(0) + } + expect(frames[0]).not.toBe(frames[frames.length - 1]) + }) + + it('T2-522-02: resolveGroundItemFlippyFrame returns valid fallback frame for unknown item code', () => { + const unknown = makeItem('zzz', 'Mystery Relic', 1, 1, 10) + const frameKey = resolveGroundItemFlippyFrame(unknown) + expect(typeof frameKey).toBe('string') + expect(frameKey.length).toBeGreaterThan(0) + }) + + it('T2-522-03: drawGroundItem falls back cleanly when itemsAtlas lacks requested frame key', () => { + const { canvas } = createMockWebGLCanvas() + const renderer = new SpriteRenderer(canvas) + const gm = new GroundItemManager() + const ent = gm.add(makeItem('swd', 'Short Sword', 1, 3), 1, 1, 50, 50) + renderer.begin(50, 50) + expect(() => + drawGroundItem(renderer, ent, 1000, { handle: 1, frames: new Map() }), + ).not.toThrow() + renderer.end() + renderer.dispose() + }) + + it('T2-522-04: drawMissileProjectile falls back to solid draw when missileArtMap has no matching skill entry', () => { + const { canvas } = createMockWebGLCanvas() + const renderer = new SpriteRenderer(canvas) + renderer.begin(100, 100) + expect(() => + drawMissileProjectile( + renderer, + { x: 100, y: 100, vx: 0, vy: 0, damage: 5, ttl: 5, fromPlayer: true, skillId: 'unknown_skill' }, + null, + new Map(), + ), + ).not.toThrow() + renderer.end() + renderer.dispose() + }) + + it('T2-522-05: drawExplosion handles zero or negative maxTicks without division-by-zero', () => { + const { canvas } = createMockWebGLCanvas() + const renderer = new SpriteRenderer(canvas) + renderer.begin(100, 100) + expect(() => + drawExplosion(renderer, { missileType: 'fireexplode', x: 100, y: 100, radius: 20, frame: 0, maxFrames: 0 }, null), + ).not.toThrow() + renderer.end() + renderer.dispose() + }) + + it('T2-522-06: GroundItemManager findAtPoint returns null when click is outside hitRadiusPx', () => { + const gm = new GroundItemManager() + gm.add(goldItem(100), 10, 10, 200, 200) + expect(gm.findAtPoint(200, 200, 28)).not.toBeNull() + expect(gm.findAtPoint(500, 500, 28)).toBeNull() + }) +}) + +// =========================================================================== +// 6. Issue #523 — WebGL Disposal Idempotency & Invalid Handles (6 tests) +// =========================================================================== +describe('Tier 2 — #523: WebGL Disposal Idempotency & Invalid Handles', () => { + it('T2-523-01: SpriteRenderer.deleteAtlas returns false for invalid/negative/already-deleted handles', () => { + const { canvas } = createMockWebGLCanvas() + const renderer = new SpriteRenderer(canvas) + expect(renderer.deleteAtlas(-1)).toBe(false) + expect(renderer.deleteAtlas(99999)).toBe(false) + + const h = renderer.addAtlas(new Uint8Array(4 * 4 * 4), 4, 4) + expect(renderer.deleteAtlas(h)).toBe(true) + expect(renderer.deleteAtlas(h)).toBe(false) + renderer.dispose() + }) + + it('T2-523-02: SpriteRenderer.dispose() is idempotent when called multiple times', () => { + const { canvas } = createMockWebGLCanvas() + const renderer = new SpriteRenderer(canvas) + renderer.addAtlas(new Uint8Array(4 * 4 * 4), 4, 4) + renderer.dispose() + expect(renderer.isDisposed).toBe(true) + expect(() => renderer.dispose()).not.toThrow() + expect(renderer.liveTextureCount).toBe(0) + }) + + it('T2-523-03: disposeRuntimeAtlases handles null/undefined runtime and missing optional atlases safely', () => { + const { canvas } = createMockWebGLCanvas() + const renderer = new SpriteRenderer(canvas) + expect(() => disposeRuntimeAtlases(null as never, renderer)).not.toThrow() + expect(() => disposeRuntimeAtlases({} as never, renderer)).not.toThrow() + renderer.dispose() + }) + + it('T2-523-04: LoadGeneration rapid sequential next() calls abort all intermediate signals', () => { + const lg = new LoadGeneration() + const signals = Array.from({ length: 20 }, () => lg.next().signal) + for (let i = 0; i < 19; i++) { + expect(signals[i]!.aborted).toBe(true) + } + expect(signals[19]!.aborted).toBe(false) + expect(lg.current).toBe(20) + }) + + it('T2-523-05: Repeatedly adding and deleting 50 atlases leaves zero leaked textures', () => { + const { canvas } = createMockWebGLCanvas() + const renderer = new SpriteRenderer(canvas) + const base = renderer.liveTextureCount + for (let i = 0; i < 50; i++) { + const h = renderer.addAtlas(new Uint8Array(4 * 4 * 4), 4, 4) + expect(renderer.deleteAtlas(h)).toBe(true) + } + expect(renderer.liveTextureCount).toBe(base) + renderer.dispose() + }) + + it('T2-523-06: SpriteRenderer throws descriptive error if canvas does not support WebGL2', () => { + const badCanvas = { + width: 800, + height: 600, + getContext: () => null, + } as unknown as HTMLCanvasElement + expect(() => new SpriteRenderer(badCanvas)).toThrow() + }) +}) + +// =========================================================================== +// 7. Issue #524 — MPQ Data & AutoMagic Boundary Queries (6 tests) +// =========================================================================== +describe('Tier 2 — #524: MPQ Data & AutoMagic Boundary Queries', () => { + it('T2-524-01: CANONICAL_AUTOMAGIC_TABLE.get returns undefined for negative or out-of-range IDs', () => { + expect(CANONICAL_AUTOMAGIC_TABLE.get(-1)).toBeUndefined() + expect(CANONICAL_AUTOMAGIC_TABLE.get(36)).toBeUndefined() + expect(CANONICAL_AUTOMAGIC_TABLE.get(999)).toBeUndefined() + }) + + it('T2-524-02: CANONICAL_AUTOMAGIC_TABLE.getEligible returns empty array for non-matching item type', () => { + const res = CANONICAL_AUTOMAGIC_TABLE.getEligible('nonexistent_type', 99, true) + expect(res).toEqual([]) + }) + + it('T2-524-03: CANONICAL_AUTOMAGIC_TABLE.getEligible respects maxlevel ceiling when alvl is high', () => { + const lowAlvl = CANONICAL_AUTOMAGIC_TABLE.getEligible('ashd', 5, true) + const highAlvl = CANONICAL_AUTOMAGIC_TABLE.getEligible('ashd', 99, true) + expect(lowAlvl.length).toBeGreaterThan(0) + expect(highAlvl.length).toBeGreaterThan(0) + }) + + it('T2-524-04: parseAutoMagicTable skips Expansion marker rows and blank rows cleanly', () => { + const tsv = [ + 'Name\tversion\tspawnable\trare\tlevel\tmaxlevel\tlevelreq\tgroup\tmod1code\tmod1param\tmod1min\tmod1max\titype1', + 'Expansion\t\t\t\t\t\t\t\t\t\t\t\t', + 'Sharp\t100\t1\t1\t1\t20\t1\t300\tdmg%\t\t10\t20\tashd', + ].join('\n') + const table = parseAutoMagicTable(tsv) + expect(table.entries).toHaveLength(1) + expect(table.entries[0]!.name).toBe('Sharp') + }) + + it('T2-524-05: D2DataRegistry returns undefined for non-existent skill/missile/state lookups', async () => { + const reg = await getSharedDataRegistry() + expect(reg.getSkillById(-999)).toBeUndefined() + expect(reg.getSkillByName('DefinitelyNotASkill')).toBeUndefined() + expect(reg.getMissileByName('definitely_not_a_missile')).toBeUndefined() + expect(reg.getStateById(99999)).toBeUndefined() + }) + + it('T2-524-06: evaluateSkill113c handles high slvl (slvl = 60, Band 5) without NaN or overflow', async () => { + const reg = await getSharedDataRegistry() + const skill = reg.getSkillById(36)! + const statList = new UnitStatList(reg) + const res = evaluateSkill113c({ registry: reg, skill, slvl: 60, blvl: 20, statList }) + expect(Number.isFinite(res.minElemDmg)).toBe(true) + expect(Number.isFinite(res.maxElemDmg)).toBe(true) + expect(res.maxElemDmg).toBeGreaterThan(0) + }) +}) + +// =========================================================================== +// 8. Issue #528 — 64-Bit D2Rng Corner Cases & Overflow (6 tests) +// =========================================================================== +describe('Tier 2 — #528: 64-Bit D2Rng Corner Cases & Overflow', () => { + it('T2-528-01: D2Rng handles seed = 0 without degenerating into all-zero output', () => { + const rng = new D2Rng(0) + const v1 = rng.step() + const v2 = rng.step() + expect(v1 !== 0 || v2 !== 0).toBe(true) + }) + + it('T2-528-02: D2Rng handles 0xFFFFFFFF 32-bit unsigned boundary without precision loss', () => { + const rng = new D2Rng(0xffffffff, 0xffffffff) + const out = rng.step() + expect(out).toBeGreaterThanOrEqual(0) + expect(out).toBeLessThanOrEqual(0xffffffff) + expect(Number.isInteger(out)).toBe(true) + }) + + it('T2-528-03: D2Rng rand(0) and rand(-10) return 0 per D2Common RAND_Max guard', () => { + const rng = new D2Rng(123) + expect(rng.rand(0)).toBe(0) + expect(rng.rand(-10)).toBe(0) + expect(rng.rand(1)).toBe(0) + }) + + it('T2-528-04: D2Rng randRange(min, max) when min >= max returns min', () => { + const rng = new D2Rng(123) + expect(rng.randRange(10, 10)).toBe(10) + expect(rng.randRange(20, 5)).toBe(20) + }) + + it('T2-528-05: D2Rng next() always returns float in [0, 1)', () => { + const rng = new D2Rng(777) + for (let i = 0; i < 100; i++) { + const f = rng.next() + expect(f).toBeGreaterThanOrEqual(0) + expect(f).toBeLessThan(1) + } + }) + + it('T2-528-06: GameEngine loadSnapshot rejects corrupted or wrong-version JSON safely', () => { + const engine = makeEngine() + expect(engine.loadSnapshot('not-valid-json{{{')).toBe(false) + expect(engine.loadSnapshot(JSON.stringify({ version: 999 }))).toBe(false) + }) +}) + +// =========================================================================== +// 9. Issue #529 — HUD Click Out-of-Bounds & Invalid Allocations (6 tests) +// =========================================================================== +describe('Tier 2 — #529: HUD Click Out-of-Bounds & Invalid Allocations', () => { + it('T2-529-01: InventoryPanel clickGridCell rejects negative and out-of-bounds grid coordinates', () => { + const inv = new InventoryPanel() + inv.cursorItem = makeItem('hp1', 'Potion', 1, 1) + const res1 = inv.clickGridCell(-1, 0) + const res2 = inv.clickGridCell(10, 0) + const res3 = inv.clickGridCell(0, 4) + expect(res1.cursorItem).toBe(inv.cursorItem) + expect(res2.cursorItem).toBe(inv.cursorItem) + expect(res3.cursorItem).toBe(inv.cursorItem) + expect(inv.gridItems).toHaveLength(0) + }) + + it('T2-529-02: InventoryPanel clickGridCell rejects placing 2x2 item at bottom-right cell (9, 3)', () => { + const inv = new InventoryPanel() + const shield = makeItem('buc', 'Buckler', 2, 2) + inv.cursorItem = shield + const res = inv.clickGridCell(9, 3) + expect(res.cursorItem).toBe(shield) + expect(inv.gridItems).toHaveLength(0) + }) + + it('T2-529-03: CharacterSheetPanel allocateStat returns false when statPoints === 0', () => { + const cs = new CharacterSheetPanel() + cs.attrs.statPoints = 0 + const prevStr = cs.attrs.str + expect(cs.allocateStat('str')).toBe(false) + expect(cs.attrs.str).toBe(prevStr) + }) + + it('T2-529-04: InventoryPanel dropGold clamps negative, NaN, and excessive amounts safely', () => { + const inv = new InventoryPanel() + inv.gold = 500 + expect(inv.dropGold(0)).toBe(0) + expect(inv.dropGold(-100)).toBe(0) + expect(inv.dropGold(NaN)).toBe(0) + expect(inv.dropGold(1000)).toBe(500) + expect(inv.gold).toBe(0) + }) + + it('T2-529-05: WorldPanelsHud clickStashCell rejects out-of-bounds coordinates', () => { + const wp = new WorldPanelsHud() + const item = makeItem('rng', 'Ring', 1, 1) + const res = wp.clickStashCell(-1, 99, item) + expect(res.cursorItem).toBe(item) + expect(wp.stashItems).toHaveLength(0) + }) + + it('T2-529-06: WorldPanelsHud buyFromActiveVendor fails when player has 0 gold', () => { + const inv = new InventoryPanel() + inv.gold = 0 + const wp = new WorldPanelsHud() + wp.openVendorForNpc('Akara', 'trade', 10, 'normal') + const bought = wp.buyFromActiveVendor(0, 0, inv) + expect(bought.ok).toBe(false) + }) +}) + +// =========================================================================== +// 10. Issue #530 — Engine Authority & Edge Case Guards (6 tests) +// =========================================================================== +describe('Tier 2 — #530: Engine Authority & Edge Case Guards', () => { + it('T2-530-01: GameEngine dropGold returns null for amount <= 0 or when engine.gold === 0', () => { + const engine = makeEngine() + engine.gold = 0 + expect(engine.dropGold(100)).toBeNull() + engine.gold = 500 + expect(engine.dropGold(0)).toBeNull() + expect(engine.dropGold(-50)).toBeNull() + }) + + it('T2-530-02: GameEngine pickupGold and pickupItem return picked: false for non-existent entity ID', () => { + const engine = makeEngine() + expect(engine.pickupGold(999999)).toEqual({ picked: false, amount: 0, remainder: 0 }) + expect(engine.pickupItem(999999)).toEqual({ picked: false }) + }) + + it('T2-530-03: GameEngine pickupGold rejects non-gold ground items', () => { + const engine = makeEngine() + const ent = engine.groundItems.add(makeItem('swd', 'Short Sword', 1, 3), 10, 10, 200, 200) + expect(engine.pickupGold(ent.id)).toEqual({ picked: false, amount: 0, remainder: 0 }) + }) + + it('T2-530-04: GameEngine damageMonster returns hit: false for out-of-range monster index or dead monster', () => { + const engine = makeEngine() + expect(engine.damageMonster(-1, 50)).toEqual({ hit: false, killed: false, xpAwarded: 0 }) + expect(engine.damageMonster(999, 50)).toEqual({ hit: false, killed: false, xpAwarded: 0 }) + + engine.damageMonster(0, 10_000) + expect(engine.damageMonster(0, 50)).toEqual({ hit: false, killed: false, xpAwarded: 0 }) + }) + + it('T2-530-05: GroundItemManager clear() removes all ground items and resets count to 0', () => { + const gm = new GroundItemManager() + gm.add(goldItem(10), 1, 1, 10, 10) + gm.add(goldItem(20), 2, 2, 20, 20) + expect(gm.count).toBe(2) + gm.clear() + expect(gm.count).toBe(0) + expect(gm.all).toHaveLength(0) + }) + + it('T2-530-06: Inventory place() throws when placing overlapping items in the same grid cell', () => { + const bag = new Inventory(10, 4) + bag.place(makeItem('r1', 'Ring 1', 1, 1), 0, 0) + expect(() => bag.place(makeItem('r2', 'Ring 2', 1, 1), 0, 0)).toThrow() + }) +}) diff --git a/tests/e2e-p0-516/tier3-cross-feature.test.ts b/tests/e2e-p0-516/tier3-cross-feature.test.ts new file mode 100644 index 0000000..6457959 --- /dev/null +++ b/tests/e2e-p0-516/tier3-cross-feature.test.ts @@ -0,0 +1,598 @@ +/** + * Tier 3: Pairwise Cross-Feature Interaction Tests (`tests/e2e-p0-516/tier3-cross-feature.test.ts`). + * + * Exercises 16 pairwise interactions between the 10 P0 issues (`#517`, `#518`, `#520`, `#521`, `#522`, `#523`, `#524`, `#528`, `#529`, `#530`) under umbrella `#516`. + */ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { decodeFrames, encodeFrame as encodeRelayFrame } from '../../scripts/net-relay.ts' +import { + LockstepSession, + NetplaySession, + encodeMessage, + decodeMessage, + computeLockstepHash, + memoryTransportPair, + type InputFrame, +} from '../../src/net/lockstep-manager.ts' +import { GameEngine, type EngineInput, type WorldMapProvider } from '../../src/game/engine.ts' +import { GroundItemManager } from '../../src/game/ground-items.ts' +import { goldItem, type Item, type ItemBase } from '../../src/game/items.ts' +import { InventoryPanel, getInventoryGoldLimit } from '../../src/ui/inventory.ts' +import { WorldPanelsHud } from '../../src/ui/world-panels.ts' +import { CharacterSheetPanel } from '../../src/ui/character-sheet.ts' +import { HudManager } from '../../src/ui/hud-manager.ts' +import { AudioManager } from '../../src/audio/manager.ts' +import { playInventoryFullFeedback } from '../../src/scene/frontend/overhead-labels.ts' +import { LoadGeneration, SpriteRenderer } from '../../src/render/renderer.ts' +import { disposeRuntimeAtlases } from '../../src/scene/frontend/pack-loader.ts' +import { resolveGroundItemFlippyFrame, drawGroundItem } from '../../src/scene/frontend/ground-item-renderer.ts' +import { drawMissileProjectile, drawExplosion } from '../../src/scene/frontend/missile-overlay-renderer.ts' +import { D2Rng } from '../../src/game/d2-rng.ts' +import { CANONICAL_AUTOMAGIC_TABLE } from '../../src/game/automagic.ts' +import { getSharedDataRegistry } from '../../src/game/engine/data-registry.ts' +import { UnitStatList } from '../../src/game/engine/stat-list.ts' +import { StateBus } from '../../src/game/engine/state-bus.ts' +import { MissileEngine } from '../../src/game/engine/missile-engine.ts' +import { AuraScanner } from '../../src/game/engine/aura-scanner.ts' +import { SummonManager } from '../../src/game/engine/summon-manager.ts' +import { evaluateSkill113c, executeSkill113c } from '../../src/game/skills/registry.ts' + +function makeItem(id: string, name: string, invWidth = 1, invHeight = 1, value = 50): Item { + const base: ItemBase = { + id, + name, + kind: 'misc', + invWidth, + invHeight, + maxStack: 1, + value, + damage: 0, + defense: 0, + tags: ['misc'], + level: 1, + } + return { + base, + prefix: null, + suffix: null, + level: 1, + name, + stats: {}, + invWidth, + invHeight, + stack: 1, + value, + code: id, + } +} + +function makeEngine(seed = 12345): GameEngine { + const terrain: WorldMapProvider = { widthPx: 2000, heightPx: 2000, overlap: () => 0 } + return new GameEngine(terrain, { + spawn: { x: 200, y: 200 }, + stats: [{ id: 'zombie', name: 'Zombie', hp: 30, speed: 60, reach: 32, damage: 5, cooldownTicks: 20, xp: 50, aggroRadius: 200 }], + xpTable: [0, 500, 1500, 3750], + skills: [ + { + id: 'firebolt', + name: 'Fire Bolt', + projectile: true, + manaCost: 2, + cooldownTicks: 5, + baseMinDamage: 10, + baseMaxDamage: 15, + damagePerLevel: 2, + speed: 300, + range: 600, + radius: 8, + }, + ], + npcDefs: [], + questDefs: [], + combatOptions: { + playerSpeed: 170, + playerReach: 48, + playerCooldownTicks: 5, + playerDamage: 20, + playerManaPerAttack: 0, + respawnTicks: 50, + }, + lootSeed: seed, + talkRadius: 64, + pickupRadius: 64, + inventoryCols: 10, + inventoryRows: 4, + monsterCount: 2, + }) +} + +const IDLE_INPUT: EngineInput = { + movement: { x: 0, y: 0 }, + attacking: false, + pickingUp: false, + talking: false, + digits: [], + saving: false, + loading: false, +} + +function createMockWebGLCanvas() { + let nextTexId = 1 + const liveTextures = new Set() + const gl = { + NO_ERROR: 0, + TRIANGLES: 4, + TEXTURE_2D: 0x0de1, + TEXTURE0: 0x84c0, + TEXTURE1: 0x84c1, + RGBA: 0x1908, + R8: 0x8229, + RED: 0x1903, + UNSIGNED_BYTE: 0x1401, + NEAREST: 0x2600, + CLAMP_TO_EDGE: 0x812f, + TEXTURE_MIN_FILTER: 0x2801, + TEXTURE_MAG_FILTER: 0x2800, + TEXTURE_WRAP_S: 0x2802, + TEXTURE_WRAP_T: 0x2803, + UNPACK_ALIGNMENT: 0x0cf5, + ARRAY_BUFFER: 0x8892, + DYNAMIC_DRAW: 0x88e8, + FLOAT: 0x1406, + BLEND: 0x0be2, + SRC_ALPHA: 0x0302, + ONE_MINUS_SRC_ALPHA: 0x0303, + ONE: 1, + COLOR_BUFFER_BIT: 0x4000, + VERTEX_SHADER: 0x8b31, + FRAGMENT_SHADER: 0x8b30, + COMPILE_STATUS: 0x8b81, + LINK_STATUS: 0x8b82, + getError: vi.fn(() => 0), + createShader: vi.fn(() => ({})), + shaderSource: vi.fn(), + compileShader: vi.fn(), + getShaderParameter: vi.fn(() => true), + getShaderInfoLog: vi.fn(() => ''), + deleteShader: vi.fn(), + createProgram: vi.fn(() => ({})), + attachShader: vi.fn(), + linkProgram: vi.fn(), + getProgramParameter: vi.fn(() => true), + getProgramInfoLog: vi.fn(() => ''), + useProgram: vi.fn(), + deleteProgram: vi.fn(), + getUniformLocation: vi.fn(() => ({})), + uniform1i: vi.fn(), + uniform2f: vi.fn(), + createVertexArray: vi.fn(() => ({})), + bindVertexArray: vi.fn(), + deleteVertexArray: vi.fn(), + createBuffer: vi.fn(() => ({})), + bindBuffer: vi.fn(), + bufferData: vi.fn(), + bufferSubData: vi.fn(), + deleteBuffer: vi.fn(), + enableVertexAttribArray: vi.fn(), + vertexAttribPointer: vi.fn(), + createTexture: vi.fn(() => { + const id = nextTexId++ + liveTextures.add(id) + return { id } + }), + bindTexture: vi.fn(), + activeTexture: vi.fn(), + pixelStorei: vi.fn(), + texParameteri: vi.fn(), + texImage2D: vi.fn(), + deleteTexture: vi.fn((tex: { id?: number } | null) => { + if (tex?.id !== undefined) liveTextures.delete(tex.id) + }), + enable: vi.fn(), + blendFunc: vi.fn(), + viewport: vi.fn(), + clearColor: vi.fn(), + clear: vi.fn(), + drawArrays: vi.fn(), + } + const canvas = { + width: 800, + height: 600, + getContext: vi.fn((type: string) => (type === 'webgl2' ? gl : null)), + } as unknown as HTMLCanvasElement + return { canvas, gl, liveTextures } +} + +function createMock2DCanvas(): HTMLCanvasElement { + const ctx2d = { + save: vi.fn(), + restore: vi.fn(), + clearRect: vi.fn(), + fillRect: vi.fn(), + strokeRect: vi.fn(), + beginPath: vi.fn(), + moveTo: vi.fn(), + lineTo: vi.fn(), + arc: vi.fn(), + closePath: vi.fn(), + fill: vi.fn(), + stroke: vi.fn(), + fillText: vi.fn(), + strokeText: vi.fn(), + measureText: vi.fn((text: string) => ({ width: text.length * 7 })), + drawImage: vi.fn(), + putImageData: vi.fn(), + createImageData: vi.fn((w: number, h: number) => ({ + width: w, + height: h, + data: new Uint8ClampedArray(w * h * 4), + })), + createLinearGradient: vi.fn(() => ({ addColorStop: vi.fn() })), + createRadialGradient: vi.fn(() => ({ addColorStop: vi.fn() })), + setTransform: vi.fn(), + translate: vi.fn(), + scale: vi.fn(), + clip: vi.fn(), + } + return { + width: 800, + height: 600, + getContext: vi.fn(() => ctx2d), + } as unknown as HTMLCanvasElement +} + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('Tier 3 — Pairwise Cross-Feature Interactions (16 tests)', () => { + // 1. #517 x #518: Relay framing + Lockstep NetMessage codec + it('T3-01 (#517 x #518): NetMessage encoded into relay frame decodes identically across relay wire boundary', () => { + const netMsg = { + kind: 'input' as const, + peer: 1, + frame: { tick: 7, movement: { x: -1, y: 1 }, attack: true, pickup: false, talk: false, skill: 3 }, + } + const payload = encodeMessage(netMsg) + const mask = Buffer.from([0x12, 0x34, 0x56, 0x78]) + const maskedPayload = Buffer.from(payload) + for (let i = 0; i < maskedPayload.length; i++) maskedPayload[i] ^= mask[i & 3]! + const relayWire = + payload.length < 126 + ? Buffer.concat([Buffer.from([0x82, 0x80 | payload.length]), mask, maskedPayload]) + : Buffer.concat([ + Buffer.from([0x82, 0x80 | 126, (payload.length >> 8) & 0xff, payload.length & 0xff]), + mask, + maskedPayload, + ]) + const relayOut = decodeFrames(relayWire, 4096) + expect(relayOut.error).toBeUndefined() + expect(relayOut.frames).toHaveLength(1) + const decodedMsg = decodeMessage(relayOut.frames[0]!.payload) + expect(decodedMsg).toEqual(netMsg) + }) + + // 2. #518 x #528: LockstepSession + 64-bit D2Rng determinism + it('T3-02 (#518 x #528): Two LockstepSessions driving D2Rng simulations stay in bit-exact hash agreement', () => { + const rngA = new D2Rng(0xdeadbeef, 0x01020304) + const rngB = new D2Rng(0xdeadbeef, 0x01020304) + + const sA = new LockstepSession( + { peers: 2, inputDelayTicks: 1 }, + { + advance: (inputs) => { + for (const inp of inputs) { + if (inp.attack) rngA.step() + } + }, + hash: () => (rngA.lo ^ rngA.hi) >>> 0, + }, + ) + const sB = new LockstepSession( + { peers: 2, inputDelayTicks: 1 }, + { + advance: (inputs) => { + for (const inp of inputs) { + if (inp.attack) rngB.step() + } + }, + hash: () => (rngB.lo ^ rngB.hi) >>> 0, + }, + ) + + const idle0: InputFrame = { tick: 0, movement: { x: 0, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 } + sA.submit(0, idle0) + sA.submit(1, idle0) + sB.submit(0, idle0) + sB.submit(1, idle0) + sA.step() + sB.step() + + const f0: InputFrame = { tick: 1, movement: { x: 1, y: 0 }, attack: true, pickup: false, talk: false, skill: 0 } + const f1: InputFrame = { tick: 1, movement: { x: 0, y: 1 }, attack: true, pickup: false, talk: false, skill: 0 } + sA.submit(0, f0) + sA.submit(1, f1) + sB.submit(0, f0) + sB.submit(1, f1) + + const outA = sA.step() + const outB = sB.step() + expect(outA.kind).toBe('stepped') + expect(outA).toEqual(outB) + expect(sA.compare({ tick: 1, hash: (outB as { hash: number }).hash })).toBeNull() + }) + + // 3. #518 x #530: computeLockstepHash + GameEngine state + it('T3-03 (#518 x #530): Two identically seeded GameEngines produce identical computeLockstepHash after 15 ticks', () => { + const e1 = makeEngine(424242) + const e2 = makeEngine(424242) + for (let t = 0; t < 15; t++) { + const inp: EngineInput = { + ...IDLE_INPUT, + movement: { x: t % 2 === 0 ? 1 : 0, y: 0 }, + attacking: t % 5 === 0, + } + e1.tick(inp) + e2.tick(inp) + } + const h1 = computeLockstepHash(e1.world, [e1.bag], e1.ground, []) + const h2 = computeLockstepHash(e2.world, [e2.bag], e2.ground, []) + expect(h1).toBe(h2) + }) + + // 4. #520 x #521: Full inventory rejection + audio feedback + it('T3-04 (#520 x #521): Failed item pickup due to full inventory triggers playInventoryFullFeedback SFX', () => { + const engine = makeEngine() + const audio = new AudioManager() + const spy = vi.spyOn(audio, 'playSfx') + + // Fill all 40 cells of 10x4 bag + for (let x = 0; x < 10; x++) { + for (let y = 0; y < 4; y++) { + engine.bag.place(makeItem(`c_${x}_${y}`, 'Charm', 1, 1), x, y) + } + } + const dropped = engine.dropItem(makeItem('ring', 'Ring', 1, 1), 200, 200, 10, 10) + const res = engine.pickupItem(dropped.id) + expect(res.picked).toBe(false) + if (!res.picked) { + playInventoryFullFeedback(audio) + } + expect(spy).toHaveBeenCalled() + audio.dispose() + }) + + // 5. #520 x #522: Gold drop + flippy frame resolution + bounce rendering + it('T3-05 (#520 x #522): Dropping gold from GameEngine creates ground item with valid flippy frame and renders', () => { + const engine = makeEngine() + engine.gold = 20_000 + const ent = engine.dropGold(7_500, 220, 220, 11, 11) + expect(ent).not.toBeNull() + const frameKey = resolveGroundItemFlippyFrame(ent!.item) + expect(frameKey).toBeTruthy() + + const { canvas } = createMockWebGLCanvas() + const renderer = new SpriteRenderer(canvas) + renderer.begin(220, 220) + expect(() => drawGroundItem(renderer, ent!, 1050, null)).not.toThrow() + renderer.end() + renderer.dispose() + }) + + // 6. #520 x #529: CharacterSheet level up + Inventory gold limit + Stash withdrawal + it('T3-06 (#520 x #529): Increasing character level expands InventoryPanel gold cap and allows larger stash withdrawal', () => { + const hud = new HudManager(createMock2DCanvas(), { + onToggleAutomap: () => {}, + onWaypointTeleport: () => {}, + }) + hud.inventory.playerLevel = 1 // cap 10,000 + hud.inventory.gold = 10_000 + hud.worldPanels.stashGold = 50_000 + + // At level 1, already at cap -> withdraw 0 + expect(hud.worldPanels.withdrawGold(5_000, hud.inventory)).toBe(0) + + // Level up to level 2 -> cap 20,000 + hud.charSheet.attrs.level = 2 + hud.inventory.playerLevel = hud.charSheet.attrs.level + expect(getInventoryGoldLimit(hud.inventory.playerLevel)).toBe(20_000) + + const withdrawn = hud.worldPanels.withdrawGold(5_000, hud.inventory) + expect(withdrawn).toBe(5_000) + expect(hud.inventory.gold).toBe(15_000) + }) + + // 7. #520 x #530: Engine gold pickup remainder + GroundItemManager persistence + it('T3-07 (#520 x #530): Partial gold pickup updates engine.gold to cap and mutates ground item stack in-place', () => { + const engine = makeEngine() + engine.gold = engine.maxGoldCapacity - 1200 + const gEnt = engine.groundItems.add(goldItem(3000), 10, 10, 200, 200) + + const first = engine.pickupGold(gEnt.id) + expect(first).toEqual({ picked: true, amount: 1200, remainder: 1800 }) + expect(engine.gold).toBe(engine.maxGoldCapacity) + expect(engine.groundItems.get(gEnt.id)?.item.value).toBe(1800) + }) + + // 8. #521 x #522: Ground item bounce trajectory + landing SFX trigger + it('T3-08 (#521 x #522): Ground item bounce height is positive before landing and triggers SFX on landing', () => { + const gm = new GroundItemManager() + const audio = new AudioManager() + const spy = vi.spyOn(audio, 'playSfx') + const ent = gm.add(makeItem('swd', 'Short Sword', 1, 3), 5, 5, 100, 100, { + now: 1000, + bounceDurationMs: 400, + peakHeightPx: 32, + }) + expect(ent.bounceState).toBeDefined() + gm.updateBounces(1200, audio) + expect(spy).not.toHaveBeenCalled() + gm.updateBounces(1500, audio) + expect(spy).toHaveBeenCalled() + audio.dispose() + }) + + // 9. #522 x #523: SpriteRenderer atlas allocation for ground items + clean disposal + it('T3-09 (#522 x #523): Ground item and missile rendering with custom atlas cleans up all textures on dispose()', () => { + const { canvas, liveTextures } = createMockWebGLCanvas() + const renderer = new SpriteRenderer(canvas) + const hItems = renderer.addAtlas(new Uint8Array(16 * 16 * 4), 16, 16) + const gm = new GroundItemManager() + const ent = gm.add(goldItem(250), 2, 2, 100, 100) + + renderer.begin(100, 100) + drawGroundItem(renderer, ent, 1200, { + handle: hItems, + frames: new Map([[resolveGroundItemFlippyFrame(ent.item), { u0: 0, v0: 0, u1: 1, v1: 1, width: 16, height: 16 }]]), + }) + renderer.end() + + expect(renderer.liveTextureCount).toBeGreaterThan(0) + renderer.dispose() + expect(renderer.liveTextureCount).toBe(0) + expect(liveTextures.size).toBe(0) + }) + + // 10. #522 x #524: MPQ D2DataRegistry skill execution + missile rendering + it('T3-10 (#522 x #524): Executing MPQ Fire Bolt skill spawns missile that renders via drawMissileProjectile', async () => { + const reg = await getSharedDataRegistry() + const skill = reg.getSkillById(36)! // Fire Bolt + const statList = new UnitStatList(reg) + const stateBus = new StateBus(reg, statList) + const evalResult = evaluateSkill113c({ registry: reg, skill, slvl: 4, blvl: 4, statList, stateBus }) + const caster = { id: 'p1', name: 'Sorceress', statList, stateBus, x: 100, y: 100 } + const missileEngine = new MissileEngine(reg) + const auraScanner = new AuraScanner(reg) + const summonManager = new SummonManager(reg) + + const out = executeSkill113c({ + registry: reg, + skill, + evalResult, + caster, + targets: [], + targetPositions: new Map(), + corpses: [], + missileEngine, + auraScanner, + summonManager, + currentTick: 1, + targetX: 250, + targetY: 100, + }) + expect(out.executed).toBe(true) + + const { canvas } = createMockWebGLCanvas() + const renderer = new SpriteRenderer(canvas) + renderer.begin(100, 100) + expect(() => + drawMissileProjectile( + renderer, + { x: 120, y: 100, vx: 200, vy: 0, damage: evalResult.minElemDmg, ttl: 20, fromPlayer: true, skillId: 'firebolt' }, + null, + null, + ), + ).not.toThrow() + renderer.end() + renderer.dispose() + }) + + // 11. #524 x #528: AutoMagic table + D2Rng deterministic selection + it('T3-11 (#524 x #528): Selecting eligible AutoMagic rows using D2Rng is 100% deterministic across runs', () => { + const eligible = CANONICAL_AUTOMAGIC_TABLE.getEligible('ashd', 45, true) + expect(eligible.length).toBeGreaterThan(0) + + const rng1 = new D2Rng(0xCAFEBABE) + const rng2 = new D2Rng(0xCAFEBABE) + + const picks1 = Array.from({ length: 10 }, () => eligible[rng1.rand(eligible.length)]!.name) + const picks2 = Array.from({ length: 10 }, () => eligible[rng2.rand(eligible.length)]!.name) + expect(picks1).toEqual(picks2) + }) + + // 12. #523 x #530: LoadGeneration + level runtime atlas disposal during engine session + it('T3-12 (#523 x #530): Stale level generation is rejected by LoadGeneration and its atlases are disposed', () => { + const { canvas } = createMockWebGLCanvas() + const renderer = new SpriteRenderer(canvas) + const base = renderer.liveTextureCount + const lg = new LoadGeneration() + + const gen1 = lg.next() + const hGen1 = renderer.addAtlas(new Uint8Array(8 * 8 * 4), 8, 8) + const gen2 = lg.next() + const hGen2 = renderer.addAtlas(new Uint8Array(8 * 8 * 4), 8, 8) + + expect(lg.isCurrent(gen1.gen)).toBe(false) + expect(gen1.signal.aborted).toBe(true) + if (!lg.isCurrent(gen1.gen)) { + disposeRuntimeAtlases({ wallAtlas: hGen1 } as never, renderer) + } + expect(lg.isCurrent(gen2.gen)).toBe(true) + expect(renderer.liveTextureCount).toBe(base + 1) + + disposeRuntimeAtlases({ wallAtlas: hGen2 } as never, renderer) + expect(renderer.liveTextureCount).toBe(base) + renderer.dispose() + }) + + // 13. #528 x #530: GameEngine combat kill + deterministic loot drop + it('T3-13 (#528 x #530): Two GameEngines with same seed produce identical monster death state and loot RNG state', () => { + const e1 = makeEngine(77777) + const e2 = makeEngine(77777) + + const r1 = e1.damageMonster(0, 500, 'player') + const r2 = e2.damageMonster(0, 500, 'player') + expect(r1).toEqual(r2) + expect(e1.loot.getSeed()).toEqual(e2.loot.getSeed()) + expect(e1.groundItems.count).toBe(e2.groundItems.count) + }) + + // 14. #529 x #530: InventoryPanel equip/unequip + GameEngine bag synchronization + it('T3-14 (#529 x #530): Equipping an item in InventoryPanel and syncing with GameEngine bag preserves item state', () => { + const engine = makeEngine() + const inv = new InventoryPanel() + const sword = makeItem('swd', 'Short Sword', 1, 3, 120) + engine.bag.add(sword) + expect(inv.autoPlaceInGrid(sword)).toBe(true) + + const picked = inv.clickGridCell(0, 0) + expect(picked.cursorItem).toBe(sword) + const equipped = inv.clickEquipSlot('mainHand') + expect(equipped.action).toBe('equip') + expect(inv.equipped.get('mainHand')).toBe(sword) + }) + + // 15. #518 x #520: Lockstep hash sensitivity to inventory and gold changes + it('T3-15 (#518 x #520): computeLockstepHash changes immediately when inventory item or ground gold changes', () => { + const engine = makeEngine(10101) + const hashBefore = computeLockstepHash(engine.world, [engine.bag], engine.ground, []) + engine.bag.add(makeItem('rng', 'Ring', 1, 1, 100)) + const hashAfterBag = computeLockstepHash(engine.world, [engine.bag], engine.ground, []) + expect(hashAfterBag).not.toBe(hashBefore) + + engine.dropGold(100, 200, 200, 10, 10, { fromWorld: true }) + const hashAfterGround = computeLockstepHash(engine.world, [engine.bag], engine.ground, []) + expect(hashAfterGround).not.toBe(hashAfterBag) + }) + + // 16. #522 x #530: Projectile lifecycle -> explosion spawn -> explosion rendering + it('T3-16 (#522 x #530): GameEngine projectile cast advances across ticks and renders projectile/explosion cleanly', () => { + const engine = makeEngine() + engine.world.player.mana = 50 + engine.tick({ ...IDLE_INPUT, digits: [1] }) + expect(engine.projectiles.length).toBeGreaterThan(0) + + const { canvas } = createMockWebGLCanvas() + const renderer = new SpriteRenderer(canvas) + renderer.begin(engine.world.player.x, engine.world.player.y) + for (const p of engine.projectiles) { + drawMissileProjectile(renderer, p, null, null) + } + for (let i = 0; i < 10; i++) engine.tick(IDLE_INPUT) + for (const exp of engine.explosions) { + drawExplosion(renderer, exp, null) + } + renderer.end() + renderer.dispose() + }) +}) diff --git a/tests/e2e-p0-516/tier4-real-world-scenarios.test.ts b/tests/e2e-p0-516/tier4-real-world-scenarios.test.ts new file mode 100644 index 0000000..1128db6 --- /dev/null +++ b/tests/e2e-p0-516/tier4-real-world-scenarios.test.ts @@ -0,0 +1,646 @@ +/** + * Tier 4: Real-World End-to-End Gameplay Scenarios (`tests/e2e-p0-516/tier4-real-world-scenarios.test.ts`). + * + * 10 full user journey scenarios exercising all 10 P0 issues (`#517`, `#518`, `#520`, `#521`, `#522`, `#523`, `#524`, `#528`, `#529`, `#530`) under umbrella `#516`. + */ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { startRelay, decodeFrames, encodeFrame as encodeRelayFrame } from '../../scripts/net-relay.ts' +import { + LockstepSession, + NetplaySession, + encodeMessage, + decodeMessage, + computeLockstepHash, + memoryTransportPair, + type InputFrame, +} from '../../src/net/lockstep-manager.ts' +import { GameEngine, type EngineInput, type WorldMapProvider } from '../../src/game/engine.ts' +import { GroundItemManager, calculateBounceHeight } from '../../src/game/ground-items.ts' +import { goldItem, type Item, type ItemBase } from '../../src/game/items.ts' +import { InventoryPanel, getInventoryGoldLimit } from '../../src/ui/inventory.ts' +import { WorldPanelsHud, STASH_GOLD_CAP } from '../../src/ui/world-panels.ts' +import { HudManager } from '../../src/ui/hud-manager.ts' +import { AudioManager } from '../../src/audio/manager.ts' +import { playInventoryFullFeedback } from '../../src/scene/frontend/overhead-labels.ts' +import { LoadGeneration, SpriteRenderer, type AtlasHandle } from '../../src/render/renderer.ts' +import { disposeRuntimeAtlases } from '../../src/scene/frontend/pack-loader.ts' +import { resolveGroundItemFlippyFrame, drawGroundItem } from '../../src/scene/frontend/ground-item-renderer.ts' +import { drawMissileProjectile, drawExplosion } from '../../src/scene/frontend/missile-overlay-renderer.ts' +import { D2Rng } from '../../src/game/d2-rng.ts' +import { CANONICAL_AUTOMAGIC_TABLE } from '../../src/game/automagic.ts' +import { getSharedDataRegistry } from '../../src/game/engine/data-registry.ts' +import { UnitStatList } from '../../src/game/engine/stat-list.ts' +import { StateBus } from '../../src/game/engine/state-bus.ts' +import { MissileEngine } from '../../src/game/engine/missile-engine.ts' +import { AuraScanner } from '../../src/game/engine/aura-scanner.ts' +import { SummonManager } from '../../src/game/engine/summon-manager.ts' +import { evaluateSkill113c, executeSkill113c } from '../../src/game/skills/registry.ts' + +function makeItem(id: string, name: string, invWidth = 1, invHeight = 1, value = 50): Item { + const base: ItemBase = { + id, + name, + kind: 'misc', + invWidth, + invHeight, + maxStack: 1, + value, + damage: 0, + defense: 0, + tags: ['misc'], + level: 1, + } + return { + base, + prefix: null, + suffix: null, + level: 1, + name, + stats: {}, + invWidth, + invHeight, + stack: 1, + value, + code: id, + } +} + +function makeEngine(seed = 12345): GameEngine { + const terrain: WorldMapProvider = { widthPx: 2000, heightPx: 2000, overlap: () => 0 } + return new GameEngine(terrain, { + spawn: { x: 200, y: 200 }, + stats: [{ id: 'zombie', name: 'Zombie', hp: 30, speed: 60, reach: 32, damage: 5, cooldownTicks: 20, xp: 600, aggroRadius: 200 }], + xpTable: [0, 500, 1500, 3750], + skills: [ + { + id: 'firebolt', + name: 'Fire Bolt', + projectile: true, + manaCost: 2, + cooldownTicks: 4, + baseMinDamage: 20, + baseMaxDamage: 25, + damagePerLevel: 2, + speed: 320, + range: 600, + radius: 10, + }, + ], + npcDefs: [], + questDefs: [], + combatOptions: { + playerSpeed: 170, + playerReach: 48, + playerCooldownTicks: 5, + playerDamage: 20, + playerManaPerAttack: 0, + respawnTicks: 50, + }, + lootSeed: seed, + talkRadius: 64, + pickupRadius: 64, + inventoryCols: 10, + inventoryRows: 4, + monsterCount: 2, + }) +} + +const IDLE_INPUT: EngineInput = { + movement: { x: 0, y: 0 }, + attacking: false, + pickingUp: false, + talking: false, + digits: [], + saving: false, + loading: false, +} + +function createMockWebGLCanvas() { + let nextTexId = 1 + const liveTextures = new Set() + const gl = { + NO_ERROR: 0, + TRIANGLES: 4, + TEXTURE_2D: 0x0de1, + TEXTURE0: 0x84c0, + TEXTURE1: 0x84c1, + RGBA: 0x1908, + R8: 0x8229, + RED: 0x1903, + UNSIGNED_BYTE: 0x1401, + NEAREST: 0x2600, + CLAMP_TO_EDGE: 0x812f, + TEXTURE_MIN_FILTER: 0x2801, + TEXTURE_MAG_FILTER: 0x2800, + TEXTURE_WRAP_S: 0x2802, + TEXTURE_WRAP_T: 0x2803, + UNPACK_ALIGNMENT: 0x0cf5, + ARRAY_BUFFER: 0x8892, + DYNAMIC_DRAW: 0x88e8, + FLOAT: 0x1406, + BLEND: 0x0be2, + SRC_ALPHA: 0x0302, + ONE_MINUS_SRC_ALPHA: 0x0303, + ONE: 1, + COLOR_BUFFER_BIT: 0x4000, + VERTEX_SHADER: 0x8b31, + FRAGMENT_SHADER: 0x8b30, + COMPILE_STATUS: 0x8b81, + LINK_STATUS: 0x8b82, + getError: vi.fn(() => 0), + createShader: vi.fn(() => ({})), + shaderSource: vi.fn(), + compileShader: vi.fn(), + getShaderParameter: vi.fn(() => true), + getShaderInfoLog: vi.fn(() => ''), + deleteShader: vi.fn(), + createProgram: vi.fn(() => ({})), + attachShader: vi.fn(), + linkProgram: vi.fn(), + getProgramParameter: vi.fn(() => true), + getProgramInfoLog: vi.fn(() => ''), + useProgram: vi.fn(), + deleteProgram: vi.fn(), + getUniformLocation: vi.fn(() => ({})), + uniform1i: vi.fn(), + uniform2f: vi.fn(), + createVertexArray: vi.fn(() => ({})), + bindVertexArray: vi.fn(), + deleteVertexArray: vi.fn(), + createBuffer: vi.fn(() => ({})), + bindBuffer: vi.fn(), + bufferData: vi.fn(), + bufferSubData: vi.fn(), + deleteBuffer: vi.fn(), + enableVertexAttribArray: vi.fn(), + vertexAttribPointer: vi.fn(), + createTexture: vi.fn(() => { + const id = nextTexId++ + liveTextures.add(id) + return { id } + }), + bindTexture: vi.fn(), + activeTexture: vi.fn(), + pixelStorei: vi.fn(), + texParameteri: vi.fn(), + texImage2D: vi.fn(), + deleteTexture: vi.fn((tex: { id?: number } | null) => { + if (tex?.id !== undefined) liveTextures.delete(tex.id) + }), + enable: vi.fn(), + blendFunc: vi.fn(), + viewport: vi.fn(), + clearColor: vi.fn(), + clear: vi.fn(), + drawArrays: vi.fn(), + } + const canvas = { + width: 800, + height: 600, + getContext: vi.fn((type: string) => (type === 'webgl2' ? gl : null)), + } as unknown as HTMLCanvasElement + return { canvas, gl, liveTextures } +} + +function createMock2DCanvas(): HTMLCanvasElement { + const ctx2d = { + save: vi.fn(), + restore: vi.fn(), + clearRect: vi.fn(), + fillRect: vi.fn(), + strokeRect: vi.fn(), + beginPath: vi.fn(), + moveTo: vi.fn(), + lineTo: vi.fn(), + arc: vi.fn(), + closePath: vi.fn(), + fill: vi.fn(), + stroke: vi.fn(), + fillText: vi.fn(), + strokeText: vi.fn(), + measureText: vi.fn((text: string) => ({ width: text.length * 7 })), + drawImage: vi.fn(), + putImageData: vi.fn(), + createImageData: vi.fn((w: number, h: number) => ({ + width: w, + height: h, + data: new Uint8ClampedArray(w * h * 4), + })), + createLinearGradient: vi.fn(() => ({ addColorStop: vi.fn() })), + createRadialGradient: vi.fn(() => ({ addColorStop: vi.fn() })), + setTransform: vi.fn(), + translate: vi.fn(), + scale: vi.fn(), + clip: vi.fn(), + } + return { + width: 800, + height: 600, + getContext: vi.fn(() => ctx2d), + } as unknown as HTMLCanvasElement +} + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('Tier 4 — Real-World End-to-End Gameplay Scenarios (10 scenarios)', () => { + // Scenario 1: Blood Moor Combat, Monster Kill, Flippy Bounce, Landing SFX, and Loot Pickup + it('T4-01: Blood Moor combat loop — cast Fire Bolt, kill monster, bounce loot, play SFX, and pick up', () => { + const engine = makeEngine(20260929) + const audio = new AudioManager() + const sfxSpy = vi.spyOn(audio, 'playSfx') + const { canvas } = createMockWebGLCanvas() + const renderer = new SpriteRenderer(canvas) + + // Cast Fire Bolt and kill monster 0 + engine.tick({ ...IDLE_INPUT, digits: [1] }) + expect(engine.projectiles.length).toBeGreaterThan(0) + + const killRes = engine.damageMonster(0, 500, 'player') + expect(killRes.killed).toBe(true) + expect(engine.world.level).toBeGreaterThanOrEqual(2) + + // Spawn a gold pile and sword with bounce + const droppedSword = engine.dropItem(makeItem('swd', 'Short Sword', 1, 3, 120), 205, 205, 10, 10) + const droppedGold = engine.groundItems.add(goldItem(450), 10, 10, 202, 202, { + now: 1000, + bounceDurationMs: 350, + peakHeightPx: 30, + }) + + // Render mid-bounce frame + renderer.begin(engine.world.player.x, engine.world.player.y) + for (const p of engine.projectiles) drawMissileProjectile(renderer, p, null, null) + for (const g of engine.groundItems.all) drawGroundItem(renderer, g, 1150, null) + renderer.end() + + // Complete bounce -> triggers drop SFX + engine.groundItems.updateBounces(1400, audio) + expect(sfxSpy).toHaveBeenCalled() + + // Pick up both gold and sword + const goldPick = engine.pickupGold(droppedGold.id) + const itemPick = engine.pickupItem(droppedSword.id) + expect(goldPick.picked).toBe(true) + expect(itemPick.picked).toBe(true) + expect(engine.gold).toBeGreaterThanOrEqual(450) + + renderer.dispose() + audio.dispose() + }) + + // Scenario 2: Town Economy Loop — Vendor Trade, Stash Deposit/Withdraw, and Level Gold Cap + it('T4-02: Rogue Encampment town economy — sell loot to Akara, deposit to stash cap, level up, withdraw', () => { + const hud = new HudManager(createMock2DCanvas(), { + onToggleAutomap: () => {}, + onWaypointTeleport: () => {}, + }) + hud.inventory.playerLevel = 1 // 10,000 cap + hud.inventory.gold = 9_500 + + const armor = makeItem('qui', 'Quilted Armor', 2, 3, 400) + expect(hud.inventory.autoPlaceInGrid(armor)).toBe(true) + + hud.worldPanels.openVendorForNpc('Akara', 'trade', 1, 'normal') + expect(hud.worldPanels.sellToActiveVendor(armor, hud.inventory).ok).toBe(true) + expect(hud.inventory.gridItems).toHaveLength(0) + + // Deposit 8,000 into stash + const dep = hud.worldPanels.depositGold(8_000, hud.inventory) + expect(dep).toBe(8_000) + expect(hud.worldPanels.stashGold).toBe(8_000) + + // Allocate stat points on CharacterSheet after level up + hud.charSheet.attrs.level = 5 + hud.charSheet.attrs.statPoints = 5 + expect(hud.charSheet.allocateStat('str')).toBe(true) + expect(hud.charSheet.allocateStat('vit')).toBe(true) + hud.inventory.playerLevel = hud.charSheet.attrs.level + hud.syncPublishedState() + expect(hud.inventory.maxGold).toBe(50_000) + + // Withdraw all 8,000 back from stash + const wit = hud.worldPanels.withdrawGold(8_000, hud.inventory) + expect(wit).toBe(8_000) + expect(hud.worldPanels.stashGold).toBe(0) + }) + + // Scenario 3: 2-Peer Multiplayer Co-Op Lockstep Session with Relay Framing & State Hash Verification + it('T4-03: 2-peer multiplayer co-op session — handshake, synchronized combat ticks, and FNV-1a hash agreement', () => { + const [t0, t1] = memoryTransportPair() + const eng0 = makeEngine(88888) + const eng1 = makeEngine(88888) + + const stepSim = (eng: GameEngine, inputs: readonly InputFrame[]) => { + const combined = inputs[0] ?? { tick: 0, movement: { x: 0, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 } + eng.tick({ + movement: combined.movement, + attacking: combined.attack, + pickingUp: combined.pickup, + talking: combined.talk, + digits: combined.skill > 0 ? [combined.skill] : [], + saving: false, + loading: false, + }) + } + + const p0 = new NetplaySession( + { peer: 0, peers: 2, seed: 88888, inputDelayTicks: 2, hashInterval: 2 }, + { + advance: (inputs) => stepSim(eng0, inputs), + hash: () => computeLockstepHash(eng0.world, [eng0.bag], eng0.ground, []), + }, + t0, + ) + const p1 = new NetplaySession( + { peer: 1, peers: 2, seed: 88888, inputDelayTicks: 2, hashInterval: 2 }, + { + advance: (inputs) => stepSim(eng1, inputs), + hash: () => computeLockstepHash(eng1.world, [eng1.bag], eng1.ground, []), + }, + t1, + ) + + p0.start() + p1.start() + + for (let tick = 0; tick < 12; tick++) { + p0.setIntent({ movement: { x: 1, y: 0 }, attack: tick % 4 === 0, pickup: false, talk: false, skill: 0 }) + p1.setIntent({ movement: { x: 0, y: 1 }, attack: false, pickup: false, talk: false, skill: 0 }) + t0.flush() + t1.flush() + p0.pump() + p1.pump() + } + + expect(p0.stats.ready).toBe(true) + expect(p1.stats.ready).toBe(true) + expect(p0.desyncReport).toBeNull() + expect(p1.desyncReport).toBeNull() + expect(p0.stats.hashesAgreed).toBeGreaterThan(0) + expect(eng0.world.player.x).toBe(eng1.world.player.x) + }) + + // Scenario 4: Rapid Multi-Act Waypoint Teleportation without WebGL Texture Leaks + it('T4-04: Rapid 15-waypoint level transitions — LoadGeneration cancels stale loads and zero WebGL textures leak', () => { + const { canvas, liveTextures } = createMockWebGLCanvas() + const renderer = new SpriteRenderer(canvas) + const baseline = renderer.liveTextureCount + const lg = new LoadGeneration() + + let activeRuntime: { wallAtlas: AtlasHandle; floorAtlas: AtlasHandle } | null = null + + for (let wp = 1; wp <= 15; wp++) { + const { gen, signal } = lg.next() + const wall = renderer.addAtlas(new Uint8Array(16 * 16 * 4), 16, 16) + const floor = renderer.addIndexedAtlas(new Uint8Array(16 * 16), 16, 16) + + if (wp < 15) { + // Simulate next waypoint clicked before previous finished committing + if (activeRuntime) { + disposeRuntimeAtlases(activeRuntime as never, renderer) + } + activeRuntime = { wallAtlas: wall, floorAtlas: floor } + } else { + expect(lg.isCurrent(gen)).toBe(true) + expect(signal.aborted).toBe(false) + if (activeRuntime) { + disposeRuntimeAtlases(activeRuntime as never, renderer) + } + activeRuntime = { wallAtlas: wall, floorAtlas: floor } + } + } + + // Only the final level's 2 atlases (+ baseline) should remain live + expect(renderer.liveTextureCount).toBe(baseline + 2) + + disposeRuntimeAtlases(activeRuntime as never, renderer) + expect(renderer.liveTextureCount).toBe(baseline) + renderer.dispose() + expect(liveTextures.size).toBe(0) + }) + + // Scenario 5: Authentic 1.13c Skill Progression, Summons, Auras, and Missiles via D2DataRegistry + it('T4-05: Full 1.13c skill pipeline — Paladin Might aura, Necromancer Raise Skeleton, Sorceress Frozen Orb', async () => { + const reg = await getSharedDataRegistry() + const statList = new UnitStatList(reg) + const stateBus = new StateBus(reg, statList) + const caster = { id: 'hero', name: 'Hero', statList, stateBus, x: 200, y: 200 } + const missileEngine = new MissileEngine(reg) + const auraScanner = new AuraScanner(reg) + const summonManager = new SummonManager(reg) + + // 1. Paladin Might Aura (skillId 98) + const might = reg.getSkillById(98)! + const mightEval = evaluateSkill113c({ registry: reg, skill: might, slvl: 10, blvl: 10, statList, stateBus }) + const mightOut = executeSkill113c({ + registry: reg, + skill: might, + evalResult: mightEval, + caster, + targets: [], + targetPositions: new Map(), + corpses: [], + missileEngine, + auraScanner, + summonManager, + currentTick: 1, + targetX: 200, + targetY: 200, + }) + expect(mightOut.executed).toBe(true) + + // 2. Necromancer Raise Skeleton (skillId 70) with corpse + const raiseSkel = reg.getSkillById(70)! + const skelEval = evaluateSkill113c({ registry: reg, skill: raiseSkel, slvl: 5, blvl: 5, statList, stateBus }) + const corpses = [{ id: 'corpse-1', consumed: false, baseMaxHp: 100, x: 220, y: 220 }] + const skelOut = executeSkill113c({ + registry: reg, + skill: raiseSkel, + evalResult: skelEval, + caster, + targets: [], + targetPositions: new Map(), + corpses, + missileEngine, + auraScanner, + summonManager, + currentTick: 2, + targetX: 220, + targetY: 220, + }) + expect(skelOut.executed).toBe(true) + expect(skelOut.corpsesConsumed).toBe(1) + + // 3. Sorceress Frozen Orb (skillId 64) + const frozenOrb = reg.getSkillById(64)! + const orbEval = evaluateSkill113c({ registry: reg, skill: frozenOrb, slvl: 15, blvl: 15, statList, stateBus }) + const orbOut = executeSkill113c({ + registry: reg, + skill: frozenOrb, + evalResult: orbEval, + caster, + targets: [], + targetPositions: new Map(), + corpses: [], + missileEngine, + auraScanner, + summonManager, + currentTick: 3, + targetX: 350, + targetY: 200, + }) + expect(orbOut.executed).toBe(true) + expect(orbOut.missilesSpawned.length).toBeGreaterThan(0) + }) + + // Scenario 6: Save/Load Snapshot Mid-Session Continuity with 64-Bit RNG & Inventory + it('T4-06: Save/Load snapshot mid-combat preserves exact 64-bit RNG trajectory, gold, and inventory', () => { + const engine1 = makeEngine(31415926) + engine1.gold = 7_777 + engine1.bag.add(makeItem('amu', 'Amulet of the Viper', 1, 1, 500)) + for (let i = 0; i < 10; i++) { + engine1.tick({ ...IDLE_INPUT, movement: { x: 1, y: 0 }, attacking: i % 2 === 0 }) + } + + const snap = engine1.saveSnapshot() + + // Continue engine1 for 10 more ticks + for (let i = 0; i < 10; i++) { + engine1.tick({ ...IDLE_INPUT, movement: { x: 0, y: 1 }, attacking: i % 3 === 0 }) + } + + // Restore into engine2 and run the exact same 10 ticks + const engine2 = makeEngine(1) + expect(engine2.loadSnapshot(snap)).toBe(true) + for (let i = 0; i < 10; i++) { + engine2.tick({ ...IDLE_INPUT, movement: { x: 0, y: 1 }, attacking: i % 3 === 0 }) + } + + expect(engine2.gold).toBe(7_777) + expect(engine2.world.player.x).toBeCloseTo(engine1.world.player.x, 5) + expect(engine2.world.player.y).toBeCloseTo(engine1.world.player.y, 5) + expect(engine2.loot.getSeed()).toEqual(engine1.loot.getSeed()) + }) + + // Scenario 7: Adversarial Network Relay & Lockstep Resilience under Malformed/Oversized Packets + it('T4-07: Relay and NetplaySession reject oversized/malformed packets while keeping valid session alive', async () => { + const relay = await startRelay(0, { maxPayloadBytes: 4096 }) + try { + // Oversized relay frame rejected with 1009 + const badRelayFrame = Buffer.from([0x82, 0x80 | 126, (5000 >> 8) & 0xff, 5000 & 0xff, 1, 2, 3, 4]) + const relayRes = decodeFrames(badRelayFrame, 4096) + expect(relayRes.error?.code).toBe(1009) + + // NetplaySession records malformed packet stat without crashing + const [t0, t1] = memoryTransportPair() + const p0 = new NetplaySession( + { peer: 0, peers: 2, seed: 555, inputDelayTicks: 1 }, + { advance: () => {}, hash: () => 10 }, + t0, + ) + const p1 = new NetplaySession( + { peer: 1, peers: 2, seed: 555, inputDelayTicks: 1 }, + { advance: () => {}, hash: () => 10 }, + t1, + ) + p0.start() + p1.start() + // Inject garbage bytes on t1 -> t0 + t1.send(new Uint8Array([0xde, 0xad, 0xbe, 0xef])) + t0.flush() + t1.flush() + p0.pump() + p1.pump() + t0.flush() + t1.flush() + p0.pump() + p1.pump() + expect(p0.stats.malformed).toBeGreaterThanOrEqual(1) + expect(p0.stats.ready).toBe(true) + } finally { + await relay.close() + } + }) + + // Scenario 8: Full Inventory Tetris, Paperdoll Equipment Swap, and Stash Quick-Transfer + it('T4-08: Inventory tetris — equip weapons/armor, swap equipped gear, quick-transfer to stash, and reject overflow', () => { + const inv = new InventoryPanel() + const wp = new WorldPanelsHud() + const audio = new AudioManager() + const sfxSpy = vi.spyOn(audio, 'playSfx') + + const sword1 = makeItem('swd', 'Short Sword', 1, 3, 100) + const sword2 = makeItem('lsd', 'Long Sword', 2, 3, 250) + const helm = makeItem('cap', 'Cap', 2, 2, 50) + + // Equip sword1 and helm + inv.cursorItem = sword1 + expect(inv.clickEquipSlot('mainHand').action).toBe('equip') + inv.cursorItem = helm + expect(inv.clickEquipSlot('head').action).toBe('equip') + + // Swap sword2 with sword1 in mainHand + inv.cursorItem = sword2 + const swapRes = inv.clickEquipSlot('mainHand') + expect(swapRes.action).toBe('swap') + expect(inv.equipped.get('mainHand')).toBe(sword2) + expect(inv.cursorItem).toBe(sword1) + + // Place sword1 into grid and quick-transfer to stash + expect(inv.clickGridCell(0, 0).cursorItem).toBeNull() + expect(wp.quickTransferToStash(sword1, inv)).toBe(true) + expect(wp.stashItems).toHaveLength(1) + + // Trigger inventory full feedback + playInventoryFullFeedback(audio) + expect(sfxSpy).toHaveBeenCalled() + audio.dispose() + }) + + // Scenario 9: Class-Specific AutoMagic Affix Generation with 64-Bit D2Rng + it('T4-09: Class-specific AutoMagic affix roll for Paladin shield and Sorceress orb using 64-bit D2Rng', () => { + const rng = new D2Rng(0x1337c0de, 0x0000029a) + const palEligible = CANONICAL_AUTOMAGIC_TABLE.getEligible('ashd', 60, true) + const sorEligible = CANONICAL_AUTOMAGIC_TABLE.getEligible('orb', 60, true) + + expect(palEligible.length).toBeGreaterThan(0) + expect(sorEligible.length).toBeGreaterThan(0) + + const chosenPal = palEligible[rng.rand(palEligible.length)]! + const chosenSor = sorEligible[rng.rand(sorEligible.length)]! + expect(chosenPal.mods.length).toBeGreaterThan(0) + expect(chosenSor.mods.length).toBeGreaterThan(0) + }) + + // Scenario 10: Desync Detection & Recovery Inspection in Multiplayer Lockstep + it('T4-10: Multiplayer lockstep detects state divergence and populates structured DesyncReport', () => { + const [t0, t1] = memoryTransportPair() + let state0 = 1000 + let state1 = 1000 + + const p0 = new NetplaySession( + { peer: 0, peers: 2, seed: 999, inputDelayTicks: 1, hashInterval: 1 }, + { advance: () => { state0 += 10 }, hash: () => state0 }, + t0, + ) + const p1 = new NetplaySession( + { peer: 1, peers: 2, seed: 999, inputDelayTicks: 1, hashInterval: 1 }, + { advance: () => { state1 += 999 }, hash: () => state1 }, // intentional divergence + t1, + ) + + p0.start() + p1.start() + + for (let i = 0; i < 5; i++) { + t0.flush() + t1.flush() + p0.pump() + p1.pump() + } + + expect(p0.desyncReport ?? p1.desyncReport).not.toBeNull() + }) +})