diablo2-web/tests/e2e-p0-516/tier2-boundary-corner.test.ts

826 lines
32 KiB
TypeScript

/**
* 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<number>()
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()
})
})