Add full-spectrum E2E verification covering all 10 P0 issues and 136-level DRLG parity invariants. TAG=agy CONV=4e31689c-063a-4965-968b-59c0f5795f97
This commit is contained in:
parent
3465c8052b
commit
5de27f8177
File diff suppressed because it is too large
Load Diff
|
|
@ -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<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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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<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 }
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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<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 }
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue