1119 lines
41 KiB
TypeScript
1119 lines
41 KiB
TypeScript
/**
|
|
* Tier 1: Requirement-Driven Feature Coverage (`tests/e2e-p0-516/tier1-feature-coverage.test.ts`).
|
|
*
|
|
* Systematic Category-Partition functional tests across all 10 P0 issues (`#517`, `#518`, `#520`, `#521`, `#522`, `#523`, `#524`, `#528`, `#529`, `#530`) under umbrella `#516`.
|
|
* 60 test cases (6 per P0 issue).
|
|
*/
|
|
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
import { startRelay, decodeFrames, encodeFrame as encodeRelayFrame } from '../../scripts/net-relay.ts'
|
|
import {
|
|
LockstepSession,
|
|
NetplaySession,
|
|
encodeMessage,
|
|
decodeMessage,
|
|
encodeFrame,
|
|
decodeFrame,
|
|
computeLockstepHash,
|
|
memoryTransportPair,
|
|
DEFAULT_MAX_MESSAGE,
|
|
MAX_FUTURE_TICKS,
|
|
NO_ACK,
|
|
type InputFrame,
|
|
} from '../../src/net/lockstep-manager.ts'
|
|
import { GameEngine, type EngineInput, type WorldMapProvider } from '../../src/game/engine.ts'
|
|
import { GroundItemManager, calculateBounceHeight } from '../../src/game/ground-items.ts'
|
|
import { Inventory, goldItem, type Item, type ItemBase } from '../../src/game/items.ts'
|
|
import { InventoryPanel, getInventoryGoldLimit } from '../../src/ui/inventory.ts'
|
|
import { WorldPanelsHud, STASH_GOLD_CAP } from '../../src/ui/world-panels.ts'
|
|
import { CharacterSheetPanel } from '../../src/ui/character-sheet.ts'
|
|
import { HudManager } from '../../src/ui/hud-manager.ts'
|
|
import { AudioManager } from '../../src/audio/manager.ts'
|
|
import { playInventoryFullFeedback } from '../../src/scene/frontend/overhead-labels.ts'
|
|
import { LoadGeneration, SpriteRenderer } from '../../src/render/renderer.ts'
|
|
import {
|
|
disposeRuntimeAtlases,
|
|
createLevelTransitionController,
|
|
} from '../../src/scene/frontend/pack-loader.ts'
|
|
import {
|
|
loadItemsAtlas,
|
|
resolveGroundItemFlippyFrame,
|
|
drawGroundItem,
|
|
} from '../../src/scene/frontend/ground-item-renderer.ts'
|
|
import {
|
|
loadMissileArtMap,
|
|
loadOverlayArtMap,
|
|
drawMissileProjectile,
|
|
drawExplosion,
|
|
drawCastOverlay,
|
|
} from '../../src/scene/frontend/missile-overlay-renderer.ts'
|
|
import { D2Rng } from '../../src/game/d2-rng.ts'
|
|
import { CANONICAL_AUTOMAGIC_TABLE, parseAutoMagicTable } from '../../src/game/automagic.ts'
|
|
import { getSharedDataRegistry } from '../../src/game/engine/data-registry.ts'
|
|
import { UnitStatList } from '../../src/game/engine/stat-list.ts'
|
|
import { StateBus } from '../../src/game/engine/state-bus.ts'
|
|
import { MissileEngine } from '../../src/game/engine/missile-engine.ts'
|
|
import { AuraScanner } from '../../src/game/engine/aura-scanner.ts'
|
|
import { SummonManager } from '../../src/game/engine/summon-manager.ts'
|
|
import { evaluateSkill113c, executeSkill113c } from '../../src/game/skills/registry.ts'
|
|
import {
|
|
castSkill,
|
|
CANONICAL_113C_MISSILES,
|
|
CANONICAL_113C_OVERLAYS,
|
|
createActiveOverlay,
|
|
getSkillCastOverlay,
|
|
} from '../../src/game/skills.ts'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function makeItem(id: string, name: string, invWidth = 1, invHeight = 1, value = 50): Item {
|
|
const base: ItemBase = {
|
|
id,
|
|
name,
|
|
kind: 'misc',
|
|
invWidth,
|
|
invHeight,
|
|
maxStack: 1,
|
|
value,
|
|
damage: 0,
|
|
defense: 0,
|
|
tags: ['misc'],
|
|
level: 1,
|
|
}
|
|
return {
|
|
base,
|
|
prefix: null,
|
|
suffix: null,
|
|
level: 1,
|
|
name,
|
|
stats: {},
|
|
invWidth,
|
|
invHeight,
|
|
stack: 1,
|
|
value,
|
|
code: id,
|
|
}
|
|
}
|
|
|
|
function makeEngine(seed = 12345): GameEngine {
|
|
const terrain: WorldMapProvider = { widthPx: 2000, heightPx: 2000, overlap: () => 0 }
|
|
return new GameEngine(terrain, {
|
|
spawn: { x: 200, y: 200 },
|
|
stats: [{ id: 'zombie', name: 'Zombie', hp: 30, speed: 60, reach: 32, damage: 5, cooldownTicks: 20, xp: 50, aggroRadius: 200 }],
|
|
xpTable: [0, 500, 1500, 3750],
|
|
skills: [
|
|
{
|
|
id: 'firebolt',
|
|
name: 'Fire Bolt',
|
|
projectile: true,
|
|
manaCost: 3,
|
|
cooldownTicks: 10,
|
|
baseMinDamage: 10,
|
|
baseMaxDamage: 12,
|
|
damagePerLevel: 2,
|
|
speed: 320,
|
|
range: 600,
|
|
radius: 10,
|
|
},
|
|
],
|
|
npcDefs: [],
|
|
questDefs: [],
|
|
combatOptions: {
|
|
playerSpeed: 170,
|
|
playerReach: 48,
|
|
playerCooldownTicks: 5,
|
|
playerDamage: 20,
|
|
playerManaPerAttack: 0,
|
|
respawnTicks: 50,
|
|
},
|
|
lootSeed: seed,
|
|
talkRadius: 64,
|
|
pickupRadius: 64,
|
|
inventoryCols: 10,
|
|
inventoryRows: 4,
|
|
monsterCount: 2,
|
|
})
|
|
}
|
|
|
|
const IDLE_INPUT: EngineInput = {
|
|
movement: { x: 0, y: 0 },
|
|
attacking: false,
|
|
pickingUp: false,
|
|
talking: false,
|
|
digits: [],
|
|
saving: false,
|
|
loading: false,
|
|
}
|
|
|
|
function createMockWebGLCanvas() {
|
|
let nextTexId = 1
|
|
const liveTextures = new Set<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()
|
|
})
|
|
|
|
// ===========================================================================
|
|
// 1. Issue #517 — Net Relay Frame Security & Connection Caps (6 tests)
|
|
// ===========================================================================
|
|
function encodeMaskedClientFrame(opcode: number, payload: Uint8Array): Uint8Array {
|
|
const unmasked = encodeRelayFrame(opcode, payload)
|
|
const maskKey = new Uint8Array([0x12, 0x34, 0x56, 0x78])
|
|
const headerLen = unmasked.byteLength - payload.byteLength
|
|
const out = new Uint8Array(headerLen + 4 + payload.byteLength)
|
|
out.set(unmasked.subarray(0, headerLen), 0)
|
|
out[1] = (out[1]! | 0x80)
|
|
out.set(maskKey, headerLen)
|
|
for (let i = 0; i < payload.byteLength; i++) {
|
|
out[headerLen + 4 + i] = payload[i]! ^ maskKey[i & 3]!
|
|
}
|
|
return out
|
|
}
|
|
|
|
describe('Tier 1 — #517: Net Relay Frame Security & Connection Caps', () => {
|
|
it('T1-517-01: decodeFrames parses valid length-prefixed frame with opcode and payload', () => {
|
|
const payload = new Uint8Array([10, 20, 30, 40])
|
|
const encoded = encodeMaskedClientFrame(0x02, payload)
|
|
const res = decodeFrames(encoded)
|
|
expect(res.error).toBeUndefined()
|
|
expect(res.frames).toHaveLength(1)
|
|
expect(res.frames[0]!.opcode).toBe(0x02)
|
|
expect(Array.from(res.frames[0]!.payload)).toEqual([10, 20, 30, 40])
|
|
expect(res.consumed).toBe(encoded.byteLength)
|
|
})
|
|
|
|
it('T1-517-02: decodeFrames parses multiple concatenated frames in a single buffer', () => {
|
|
const f1 = encodeMaskedClientFrame(0x02, new Uint8Array([1, 2]))
|
|
const f2 = encodeMaskedClientFrame(0x02, new Uint8Array([3, 4, 5]))
|
|
const combined = new Uint8Array(f1.byteLength + f2.byteLength)
|
|
combined.set(f1, 0)
|
|
combined.set(f2, f1.byteLength)
|
|
const res = decodeFrames(combined)
|
|
expect(res.error).toBeUndefined()
|
|
expect(res.frames).toHaveLength(2)
|
|
expect(res.frames[0]!.opcode).toBe(0x02)
|
|
expect(res.frames[1]!.opcode).toBe(0x02)
|
|
expect(res.consumed).toBe(combined.byteLength)
|
|
})
|
|
|
|
it('T1-517-03: decodeFrames preserves partial trailing frame bytes without error', () => {
|
|
const full = encodeMaskedClientFrame(0x02, new Uint8Array([9, 8, 7]))
|
|
const partial = encodeMaskedClientFrame(0x02, new Uint8Array([1, 2, 3, 4, 5])).subarray(0, 4)
|
|
const buf = new Uint8Array(full.byteLength + partial.byteLength)
|
|
buf.set(full, 0)
|
|
buf.set(partial, full.byteLength)
|
|
const res = decodeFrames(buf)
|
|
expect(res.error).toBeUndefined()
|
|
expect(res.frames).toHaveLength(1)
|
|
expect(res.consumed).toBe(full.byteLength)
|
|
})
|
|
|
|
it('T1-517-04: decodeFrames returns code 1009 when payload length exceeds maxPayloadBytes', () => {
|
|
const header = new Uint8Array(4)
|
|
header[0] = 0x82
|
|
header[1] = 0x80 | 126
|
|
new DataView(header.buffer).setUint16(2, 5000, false)
|
|
const res = decodeFrames(header, 4096)
|
|
expect(res.error).toBeDefined()
|
|
expect(res.error?.code).toBe(1009)
|
|
})
|
|
|
|
it('T1-517-05: startRelay boots an ephemeral HTTP/WS server and cleanly closes', async () => {
|
|
const relay = await startRelay(0, {
|
|
maxConnectionsPerIp: 4,
|
|
maxRooms: 8,
|
|
maxMessageBytes: 4096,
|
|
})
|
|
try {
|
|
expect(relay.url).toMatch(/^ws:\/\/127\.0\.0\.1:\d+$/)
|
|
} finally {
|
|
await relay.close()
|
|
}
|
|
})
|
|
|
|
it('T1-517-06: startRelay accepts custom rate-limiting and heartbeat options', async () => {
|
|
const relay = await startRelay(0, {
|
|
maxRooms: 4,
|
|
maxPeersPerRoom: 2,
|
|
maxMessagesPerSec: 60,
|
|
heartbeatIntervalMs: 5000,
|
|
})
|
|
try {
|
|
expect(relay.url).toContain('ws://127.0.0.1:')
|
|
} finally {
|
|
await relay.close()
|
|
}
|
|
})
|
|
})
|
|
|
|
// ===========================================================================
|
|
// 2. Issue #518 — Lockstep Session & Netplay Protocol (6 tests)
|
|
// ===========================================================================
|
|
describe('Tier 1 — #518: Lockstep Session & Netplay Protocol', () => {
|
|
it('T1-518-01: encodeMessage and decodeMessage round-trip all 5 NetMessage kinds', () => {
|
|
const messages = [
|
|
{ kind: 'hello' as const, peer: 0, peers: 2, seed: 0x12345678, ackTo: NO_ACK },
|
|
{
|
|
kind: 'input' as const,
|
|
peer: 1,
|
|
frame: { tick: 12, movement: { x: 1, y: -1 }, attack: true, pickup: false, talk: false, skill: 2 },
|
|
},
|
|
{ kind: 'hash' as const, peer: 0, tick: 12, hash: 0xdeadbeef },
|
|
{ kind: 'heartbeat' as const, peer: 1, tick: 15 },
|
|
{ kind: 'bye' as const, peer: 0 },
|
|
]
|
|
for (const msg of messages) {
|
|
const bytes = encodeMessage(msg)
|
|
const decoded = decodeMessage(bytes)
|
|
expect(decoded).toEqual(msg)
|
|
}
|
|
})
|
|
|
|
it('T1-518-02: encodeFrame and decodeFrame round-trip framed NetMessage packets', () => {
|
|
const msg = {
|
|
kind: 'input' as const,
|
|
peer: 0,
|
|
frame: { tick: 5, movement: { x: 0, y: 1 }, attack: false, pickup: true, talk: false, skill: 0 },
|
|
}
|
|
const framed = encodeFrame(msg)
|
|
const decoded = decodeFrame(framed)
|
|
expect(decoded).toEqual(msg)
|
|
})
|
|
|
|
it('T1-518-03: LockstepSession steps deterministically when all peers submit inputs for current tick', () => {
|
|
let simState = 100
|
|
const session = new LockstepSession(
|
|
{ peers: 2, inputDelayTicks: 1 },
|
|
{
|
|
advance: (inputs) => {
|
|
for (const inp of inputs) {
|
|
simState += inp.movement.x * 10 + (inp.attack ? 5 : 0)
|
|
}
|
|
},
|
|
hash: () => simState >>> 0,
|
|
},
|
|
)
|
|
const idle0: InputFrame = { tick: 0, movement: { x: 0, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 }
|
|
expect(session.submit(0, idle0)).toBe(true)
|
|
expect(session.submit(1, idle0)).toBe(true)
|
|
const step0 = session.step()
|
|
expect(step0.kind).toBe('stepped')
|
|
expect(session.tick).toBe(1)
|
|
|
|
const f0: InputFrame = { tick: 1, movement: { x: 1, y: 0 }, attack: true, pickup: false, talk: false, skill: 0 }
|
|
const f1: InputFrame = { tick: 1, movement: { x: 1, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 }
|
|
expect(session.submit(0, f0)).toBe(true)
|
|
expect(session.submit(1, f1)).toBe(true)
|
|
|
|
const step1 = session.step()
|
|
expect(step1).toEqual({ kind: 'stepped', tick: 1, hash: 125 })
|
|
})
|
|
|
|
it('T1-518-04: LockstepSession records remote hash and agrees when hashes match', () => {
|
|
let simHash = 0xabcdef01
|
|
const session = new LockstepSession(
|
|
{ peers: 2, inputDelayTicks: 1 },
|
|
{ advance: () => {}, hash: () => simHash },
|
|
)
|
|
const idle0: InputFrame = { tick: 0, movement: { x: 0, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 }
|
|
session.submit(0, idle0)
|
|
session.submit(1, idle0)
|
|
expect(session.recordRemoteHash(1, 0, 0xabcdef01)).toBe(true)
|
|
const out = session.step()
|
|
expect(out.kind).toBe('stepped')
|
|
expect(session.desyncReport).toBeNull()
|
|
})
|
|
|
|
it('T1-518-05: NetplaySession 2-peer handshake over memoryTransportPair reaches ready state and steps', () => {
|
|
const [t0, t1] = memoryTransportPair()
|
|
let hash0 = 42
|
|
let hash1 = 42
|
|
const p0 = new NetplaySession(
|
|
{ peer: 0, peers: 2, seed: 777, inputDelayTicks: 2, hashInterval: 2 },
|
|
{ advance: () => { hash0 += 1 }, hash: () => hash0 },
|
|
t0,
|
|
)
|
|
const p1 = new NetplaySession(
|
|
{ peer: 1, peers: 2, seed: 777, inputDelayTicks: 2, hashInterval: 2 },
|
|
{ advance: () => { hash1 += 1 }, hash: () => hash1 },
|
|
t1,
|
|
)
|
|
p0.start()
|
|
p1.start()
|
|
for (let i = 0; i < 6; i++) {
|
|
t0.flush()
|
|
t1.flush()
|
|
p0.pump()
|
|
p1.pump()
|
|
}
|
|
expect(p0.stats.ready).toBe(true)
|
|
expect(p1.stats.ready).toBe(true)
|
|
expect(p0.stats.stepped).toBeGreaterThan(0)
|
|
expect(p1.stats.stepped).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('T1-518-06: computeLockstepHash produces deterministic FNV-1a 32-bit hash over world, inventories, ground, and quests', () => {
|
|
const world = {
|
|
tick: 10,
|
|
rngState: 999,
|
|
kills: 2,
|
|
xp: 100,
|
|
level: 2,
|
|
player: { x: 150.5, y: 220.25, hp: 50, maxHp: 60, mana: 20, maxMana: 25, cooldown: 0 },
|
|
monsters: [{ id: 1, kind: 'zombie', x: 300, y: 400, hp: 20, maxHp: 30, cooldown: 5, deadTicks: 0 }],
|
|
}
|
|
const h1 = computeLockstepHash(world, [], [], [])
|
|
const h2 = computeLockstepHash(world, [], [], [])
|
|
expect(h1).toBe(h2)
|
|
expect(typeof h1).toBe('number')
|
|
})
|
|
})
|
|
|
|
// ===========================================================================
|
|
// 3. Issue #520 — Unified Player Inventory Authority & Gold Cap (6 tests)
|
|
// ===========================================================================
|
|
describe('Tier 1 — #520: Unified Player Inventory Authority & Gold Cap', () => {
|
|
it('T1-520-01: getInventoryGoldLimit scales at 10,000 gold per character level', () => {
|
|
expect(getInventoryGoldLimit(1)).toBe(10_000)
|
|
expect(getInventoryGoldLimit(15)).toBe(150_000)
|
|
expect(getInventoryGoldLimit(99)).toBe(990_000)
|
|
})
|
|
|
|
it('T1-520-02: GameEngine pickupGold adds to engine.gold up to maxGoldCapacity and removes ground entity', () => {
|
|
const engine = makeEngine()
|
|
const gItem = goldItem(2500)
|
|
const groundEnt = engine.groundItems.add(gItem, 10, 10, 200, 200)
|
|
const res = engine.pickupGold(groundEnt.id)
|
|
expect(res.picked).toBe(true)
|
|
expect(res.amount).toBe(2500)
|
|
expect(engine.gold).toBe(2500)
|
|
expect(engine.groundItems.get(groundEnt.id)).toBeUndefined()
|
|
})
|
|
|
|
it('T1-520-03: GameEngine dropGold deducts from engine.gold and spawns ground gold item', () => {
|
|
const engine = makeEngine()
|
|
engine.gold = 5000
|
|
const dropped = engine.dropGold(1500, 210, 210, 11, 11)
|
|
expect(dropped).not.toBeNull()
|
|
expect(engine.gold).toBe(3500)
|
|
expect(engine.groundItems.count).toBe(1)
|
|
expect(dropped?.item.value).toBe(1500)
|
|
})
|
|
|
|
it('T1-520-04: WorldPanelsHud depositGold and withdrawGold transfer gold between inventory and stash', () => {
|
|
const inv = new InventoryPanel()
|
|
inv.playerLevel = 10 // cap 100,000
|
|
inv.gold = 40_000
|
|
const worldPanels = new WorldPanelsHud()
|
|
const deposited = worldPanels.depositGold(25_000, inv)
|
|
expect(deposited).toBe(25_000)
|
|
expect(inv.gold).toBe(15_000)
|
|
expect(worldPanels.stashGold).toBe(25_000)
|
|
|
|
const withdrawn = worldPanels.withdrawGold(10_000, inv)
|
|
expect(withdrawn).toBe(10_000)
|
|
expect(inv.gold).toBe(25_000)
|
|
expect(worldPanels.stashGold).toBe(15_000)
|
|
})
|
|
|
|
it('T1-520-05: WorldPanelsHud quickTransferToStash and quickTransferFromStash move items cleanly', () => {
|
|
const inv = new InventoryPanel()
|
|
const worldPanels = new WorldPanelsHud()
|
|
const item = makeItem('swd', 'Short Sword', 1, 3, 100)
|
|
expect(inv.autoPlaceInGrid(item)).toBe(true)
|
|
expect(inv.gridItems).toHaveLength(1)
|
|
|
|
expect(worldPanels.quickTransferToStash(item, inv)).toBe(true)
|
|
expect(inv.gridItems).toHaveLength(0)
|
|
expect(worldPanels.stashItems).toHaveLength(1)
|
|
|
|
expect(worldPanels.quickTransferFromStash(item, inv)).toBe(true)
|
|
expect(worldPanels.stashItems).toHaveLength(0)
|
|
expect(inv.gridItems).toHaveLength(1)
|
|
})
|
|
|
|
it('T1-520-06: HudManager syncPublishedState synchronizes inventory, stash, and character sheet state', () => {
|
|
const hud = new HudManager(createMock2DCanvas(), {
|
|
onToggleAutomap: () => {},
|
|
onWaypointTeleport: () => {},
|
|
})
|
|
hud.inventory.playerLevel = 5
|
|
hud.inventory.gold = 12_000
|
|
hud.worldPanels.stashGold = 50_000
|
|
expect(() => hud.syncPublishedState()).not.toThrow()
|
|
expect(hud.inventory.maxGold).toBe(50_000)
|
|
})
|
|
})
|
|
|
|
// ===========================================================================
|
|
// 4. Issue #521 — Web Audio API Synthesis & Drop/Feedback SFX (6 tests)
|
|
// ===========================================================================
|
|
describe('Tier 1 — #521: Web Audio API Synthesis & Drop/Feedback SFX', () => {
|
|
it('T1-521-01: AudioManager playSfx triggers procedural synthesis without throwing in headless env', () => {
|
|
const audio = new AudioManager()
|
|
expect(() => audio.playSfx('item_gold')).not.toThrow()
|
|
expect(() => audio.playSfx('item_weapon')).not.toThrow()
|
|
expect(() => audio.playSfx('item_armor')).not.toThrow()
|
|
audio.dispose()
|
|
})
|
|
|
|
it('T1-521-02: AudioManager volume setters clamp master, sfx, and music volumes to [0, 1]', () => {
|
|
const audio = new AudioManager()
|
|
expect(() => {
|
|
audio.setMasterVolume(0.8)
|
|
audio.setSfxVolume(0.6)
|
|
audio.setMusicVolume(0.4)
|
|
}).not.toThrow()
|
|
audio.dispose()
|
|
})
|
|
|
|
it('T1-521-03: GroundItemManager updateBounces plays drop SFX when item bounce lands', () => {
|
|
const gm = new GroundItemManager()
|
|
const audio = new AudioManager()
|
|
const spy = vi.spyOn(audio, 'playSfx')
|
|
const item = goldItem(100)
|
|
gm.add(item, 5, 5, 100, 100, { now: 1000, bounceDurationMs: 400, peakHeightPx: 32 })
|
|
gm.updateBounces(1500, audio)
|
|
expect(spy).toHaveBeenCalled()
|
|
audio.dispose()
|
|
})
|
|
|
|
it('T1-521-04: playInventoryFullFeedback invokes AudioManager error feedback sound', () => {
|
|
const audio = new AudioManager()
|
|
const spy = vi.spyOn(audio, 'playSfx')
|
|
playInventoryFullFeedback(audio)
|
|
expect(spy).toHaveBeenCalled()
|
|
audio.dispose()
|
|
})
|
|
|
|
it('T1-521-05: calculateBounceHeight returns positive arc height mid-flight and 0 after duration', () => {
|
|
const bounce = { startTime: 1000, durationMs: 400, peakHeightPx: 40, phase: 'primary' as const }
|
|
const mid = calculateBounceHeight(bounce, 1200)
|
|
const done = calculateBounceHeight(bounce, 1500)
|
|
expect(mid).toBeGreaterThan(0)
|
|
expect(mid).toBeLessThanOrEqual(40)
|
|
expect(done).toBe(0)
|
|
})
|
|
|
|
it('T1-521-06: AudioManager playTone and playMusic execute safely and dispose cleans up', () => {
|
|
const audio = new AudioManager()
|
|
expect(() =>
|
|
audio.playTone({ type: 'sine', from: 440, to: 220, gain: 0.2, durationS: 0.1 }),
|
|
).not.toThrow()
|
|
expect(() => audio.playMusic('tristram')).not.toThrow()
|
|
expect(() => audio.dispose()).not.toThrow()
|
|
})
|
|
})
|
|
|
|
// ===========================================================================
|
|
// 5. Issue #522 — Ground Flippy & Missile/Overlay Sprites (6 tests)
|
|
// ===========================================================================
|
|
describe('Tier 1 — #522: Ground Flippy & Missile/Overlay Sprites', () => {
|
|
it('T1-522-01: resolveGroundItemFlippyFrame resolves authentic flippy frame keys for gold piles and gear', () => {
|
|
const smallGold = goldItem(50)
|
|
const largeGold = goldItem(15000)
|
|
const sword = makeItem('swd', 'Short Sword', 1, 3, 100)
|
|
expect(typeof resolveGroundItemFlippyFrame(smallGold)).toBe('string')
|
|
expect(typeof resolveGroundItemFlippyFrame(largeGold)).toBe('string')
|
|
expect(typeof resolveGroundItemFlippyFrame(sword)).toBe('string')
|
|
})
|
|
|
|
it('T1-522-02: drawGroundItem renders ground item with bounce offset via SpriteRenderer', () => {
|
|
const { canvas } = createMockWebGLCanvas()
|
|
const renderer = new SpriteRenderer(canvas)
|
|
const gm = new GroundItemManager()
|
|
const ent = gm.add(goldItem(500), 10, 10, 250, 250, { now: 1000 })
|
|
renderer.begin(250, 250)
|
|
expect(() => drawGroundItem(renderer, ent, 1100, null)).not.toThrow()
|
|
renderer.end()
|
|
renderer.dispose()
|
|
})
|
|
|
|
it('T1-522-03: CANONICAL_113C_MISSILES and CANONICAL_113C_OVERLAYS expose authentic missile and overlay records', () => {
|
|
expect(Object.keys(CANONICAL_113C_MISSILES).length).toBeGreaterThan(0)
|
|
expect(Object.keys(CANONICAL_113C_OVERLAYS).length).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('T1-522-04: castSkill spawns projectiles and cast overlays on GameEngine during tick', () => {
|
|
const engine = makeEngine()
|
|
engine.world.player.mana = 50
|
|
engine.tick({ ...IDLE_INPUT, digits: [1] })
|
|
expect(engine.projectiles.length).toBeGreaterThanOrEqual(1)
|
|
})
|
|
|
|
it('T1-522-05: drawMissileProjectile, drawExplosion, and drawCastOverlay render without throwing', () => {
|
|
const { canvas } = createMockWebGLCanvas()
|
|
const renderer = new SpriteRenderer(canvas)
|
|
renderer.begin(200, 200)
|
|
expect(() =>
|
|
drawMissileProjectile(
|
|
renderer,
|
|
{ x: 200, y: 200, vx: 100, vy: 0, damage: 10, ttl: 10, fromPlayer: true, skillId: 'firebolt' },
|
|
null,
|
|
null,
|
|
),
|
|
).not.toThrow()
|
|
expect(() =>
|
|
drawExplosion(renderer, { missileType: 'fireexplode', x: 220, y: 220, radius: 32, frame: 5, maxFrames: 10 }, null),
|
|
).not.toThrow()
|
|
const overlay = createActiveOverlay('sorceress_cast_fire', { x: 200, y: 200 }, 'player')
|
|
if (overlay) {
|
|
expect(() => drawCastOverlay(renderer, overlay, null)).not.toThrow()
|
|
}
|
|
renderer.end()
|
|
renderer.dispose()
|
|
})
|
|
|
|
it('T1-522-06: getSkillCastOverlay returns canonical overlay ID for skill casting', () => {
|
|
const overlayId = getSkillCastOverlay('firebolt')
|
|
expect(overlayId === undefined || typeof overlayId === 'string').toBe(true)
|
|
})
|
|
})
|
|
|
|
// ===========================================================================
|
|
// 6. Issue #523 — WebGL Texture Disposal & LoadGeneration (6 tests)
|
|
// ===========================================================================
|
|
describe('Tier 1 — #523: WebGL Texture Disposal & LoadGeneration', () => {
|
|
it('T1-523-01: LoadGeneration increments generation counter and aborts prior AbortSignal on next()', () => {
|
|
const lg = new LoadGeneration()
|
|
expect(lg.current).toBe(0)
|
|
const first = lg.next()
|
|
expect(first.gen).toBe(1)
|
|
expect(lg.isCurrent(1)).toBe(true)
|
|
expect(first.signal.aborted).toBe(false)
|
|
|
|
const second = lg.next()
|
|
expect(second.gen).toBe(2)
|
|
expect(lg.isCurrent(1)).toBe(false)
|
|
expect(lg.isCurrent(2)).toBe(true)
|
|
expect(first.signal.aborted).toBe(true)
|
|
expect(second.signal.aborted).toBe(false)
|
|
})
|
|
|
|
it('T1-523-02: LoadGeneration invalidate() increments generation and aborts active signal', () => {
|
|
const lg = new LoadGeneration()
|
|
const { gen, signal } = lg.next()
|
|
expect(lg.isCurrent(gen)).toBe(true)
|
|
lg.invalidate()
|
|
expect(lg.isCurrent(gen)).toBe(false)
|
|
expect(signal.aborted).toBe(true)
|
|
})
|
|
|
|
it('T1-523-03: SpriteRenderer tracks liveTextureCount across addAtlas and deleteAtlas', () => {
|
|
const { canvas } = createMockWebGLCanvas()
|
|
const renderer = new SpriteRenderer(canvas)
|
|
const baseline = renderer.liveTextureCount
|
|
const h1 = renderer.addAtlas(new Uint8Array(16 * 16 * 4), 16, 16)
|
|
const h2 = renderer.addIndexedAtlas(new Uint8Array(16 * 16), 16, 16)
|
|
expect(renderer.liveTextureCount).toBe(baseline + 2)
|
|
|
|
expect(renderer.deleteAtlas(h1)).toBe(true)
|
|
expect(renderer.liveTextureCount).toBe(baseline + 1)
|
|
expect(renderer.deleteAtlas(h2)).toBe(true)
|
|
expect(renderer.liveTextureCount).toBe(baseline)
|
|
renderer.dispose()
|
|
})
|
|
|
|
it('T1-523-04: SpriteRenderer.dispose() releases all WebGL textures and sets isDisposed = true', () => {
|
|
const { canvas, liveTextures } = createMockWebGLCanvas()
|
|
const renderer = new SpriteRenderer(canvas)
|
|
renderer.addAtlas(new Uint8Array(8 * 8 * 4), 8, 8)
|
|
renderer.addAtlas(new Uint8Array(8 * 8 * 4), 8, 8)
|
|
expect(renderer.liveTextureCount).toBeGreaterThan(0)
|
|
|
|
renderer.dispose()
|
|
expect(renderer.isDisposed).toBe(true)
|
|
expect(renderer.liveTextureCount).toBe(0)
|
|
expect(liveTextures.size).toBe(0)
|
|
})
|
|
|
|
it('T1-523-05: disposeRuntimeAtlases deletes all atlas handles registered on a level runtime', () => {
|
|
const { canvas } = createMockWebGLCanvas()
|
|
const renderer = new SpriteRenderer(canvas)
|
|
const baseline = renderer.liveTextureCount
|
|
const hWall = renderer.addAtlas(new Uint8Array(8 * 8 * 4), 8, 8)
|
|
const hFloor = renderer.addAtlas(new Uint8Array(8 * 8 * 4), 8, 8)
|
|
expect(renderer.liveTextureCount).toBe(baseline + 2)
|
|
|
|
const mockRuntime = {
|
|
wallAtlas: hWall,
|
|
floorAtlas: hFloor,
|
|
monsterAtlases: new Map(),
|
|
}
|
|
disposeRuntimeAtlases(mockRuntime as never, renderer)
|
|
expect(renderer.liveTextureCount).toBe(baseline)
|
|
renderer.dispose()
|
|
})
|
|
|
|
it('T1-523-06: createLevelTransitionController coordinates generation tokens and disposes previous runtime', async () => {
|
|
const { canvas } = createMockWebGLCanvas()
|
|
const renderer = new SpriteRenderer(canvas)
|
|
const hOld = renderer.addAtlas(new Uint8Array(8 * 8 * 4), 8, 8)
|
|
const oldRuntime = { wallAtlas: hOld, monsterAtlases: new Map() }
|
|
|
|
const controller = createLevelTransitionController({
|
|
renderer,
|
|
initialRuntime: oldRuntime as never,
|
|
loadLevelRuntime: async (levelId: number) => ({
|
|
levelId,
|
|
wallAtlas: renderer.addAtlas(new Uint8Array(8 * 8 * 4), 8, 8),
|
|
monsterAtlases: new Map(),
|
|
}),
|
|
})
|
|
expect(controller).toBeDefined()
|
|
renderer.dispose()
|
|
})
|
|
})
|
|
|
|
// ===========================================================================
|
|
// 7. Issue #524 — Real MPQ Data & 36-Row AutoMagic.txt (6 tests)
|
|
// ===========================================================================
|
|
describe('Tier 1 — #524: Real MPQ Data & 36-Row AutoMagic.txt', () => {
|
|
it('T1-524-01: CANONICAL_AUTOMAGIC_TABLE contains all 36 authentic 1.13c AutoMagic.txt rows', () => {
|
|
expect(CANONICAL_AUTOMAGIC_TABLE.entries.length).toBe(36)
|
|
})
|
|
|
|
it('T1-524-02: CANONICAL_AUTOMAGIC_TABLE lookup by ID returns expected affix groups and properties', () => {
|
|
const first = CANONICAL_AUTOMAGIC_TABLE.get(0)
|
|
const last = CANONICAL_AUTOMAGIC_TABLE.get(35)
|
|
expect(first).toBeDefined()
|
|
expect(last).toBeDefined()
|
|
expect(first?.name).toBeTruthy()
|
|
expect(last?.name).toBeTruthy()
|
|
})
|
|
|
|
it('T1-524-03: CANONICAL_AUTOMAGIC_TABLE.getEligible filters by item type and alvl', () => {
|
|
const palShields = CANONICAL_AUTOMAGIC_TABLE.getEligible('ashd', 50, true)
|
|
expect(palShields.length).toBeGreaterThan(0)
|
|
for (const row of palShields) {
|
|
expect(row.level).toBeLessThanOrEqual(50)
|
|
}
|
|
})
|
|
|
|
it('T1-524-04: D2DataRegistry loads canonical 1.13c skills, missiles, states, and overlays', async () => {
|
|
const reg = await getSharedDataRegistry()
|
|
expect(reg.getAllPlayerSkills().length).toBeGreaterThanOrEqual(221)
|
|
expect(reg.getPetSubSkills().length).toBeGreaterThanOrEqual(17)
|
|
expect(reg.getSkillById(36)?.skill).toBe('Fire Bolt')
|
|
expect(reg.getMissileByName('firebolt')).toBeDefined()
|
|
})
|
|
|
|
it('T1-524-05: evaluateSkill113c evaluates skill damage and mana cost from real MPQ skill records', async () => {
|
|
const reg = await getSharedDataRegistry()
|
|
const skill = reg.getSkillById(36)! // Fire Bolt
|
|
const statList = new UnitStatList(reg)
|
|
const evalRes = evaluateSkill113c({
|
|
registry: reg,
|
|
skill,
|
|
slvl: 10,
|
|
blvl: 10,
|
|
statList,
|
|
})
|
|
expect(evalRes.skillId).toBe(36)
|
|
expect(evalRes.minElemDmg).toBeGreaterThan(0)
|
|
expect(evalRes.maxElemDmg).toBeGreaterThanOrEqual(evalRes.minElemDmg)
|
|
expect(evalRes.manaCost256).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('T1-524-06: executeSkill113c executes Fire Bolt and spawns a firebolt missile in MissileEngine', async () => {
|
|
const reg = await getSharedDataRegistry()
|
|
const skill = reg.getSkillById(36)!
|
|
const statList = new UnitStatList(reg)
|
|
const stateBus = new StateBus(reg, statList)
|
|
const evalResult = evaluateSkill113c({ registry: reg, skill, slvl: 5, blvl: 5, statList, stateBus })
|
|
const caster = { id: 'player-1', name: 'Sorceress', statList, stateBus, x: 100, y: 100 }
|
|
const missileEngine = new MissileEngine(reg)
|
|
const auraScanner = new AuraScanner(reg)
|
|
const summonManager = new SummonManager(reg)
|
|
|
|
const outcome = executeSkill113c({
|
|
registry: reg,
|
|
skill,
|
|
evalResult,
|
|
caster,
|
|
targets: [],
|
|
targetPositions: new Map(),
|
|
corpses: [],
|
|
missileEngine,
|
|
auraScanner,
|
|
summonManager,
|
|
currentTick: 1,
|
|
targetX: 200,
|
|
targetY: 100,
|
|
})
|
|
expect(outcome.executed).toBe(true)
|
|
expect(outcome.missilesSpawned).toContain('firebolt')
|
|
})
|
|
})
|
|
|
|
// ===========================================================================
|
|
// 8. Issue #528 — Authentic 64-Bit D2Rng LCG & Zero Math.random() (6 tests)
|
|
// ===========================================================================
|
|
describe('Tier 1 — #528: Authentic 64-Bit D2Rng LCG & Zero Math.random()', () => {
|
|
it('T1-528-01: D2Rng step() implements exact 0x6AC690C5 * lo + hi 64-bit carry recurrence', () => {
|
|
const rng = new D2Rng(1, 666)
|
|
const val = rng.step()
|
|
// 1 * 0x6AC690C5 + 666 = 1791398085 + 666 = 1791398751 (0x6AC6935F)
|
|
expect(val).toBe(0x6ac6935f)
|
|
expect(rng.lo).toBe(0x6ac6935f)
|
|
expect(rng.hi).toBe(0)
|
|
})
|
|
|
|
it('T1-528-02: D2Rng clone() and getSeed()/setSeed() preserve and restore 64-bit state identically', () => {
|
|
const rng1 = new D2Rng(0x12345678, 0x9abcdef0)
|
|
rng1.step()
|
|
rng1.step()
|
|
const clone = rng1.clone()
|
|
const snapshot = rng1.getSeed()
|
|
|
|
const rng2 = new D2Rng(1, 1)
|
|
rng2.setSeed(snapshot.lo, snapshot.hi)
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
const a = rng1.step()
|
|
const b = clone.step()
|
|
const c = rng2.step()
|
|
expect(a).toBe(b)
|
|
expect(a).toBe(c)
|
|
}
|
|
})
|
|
|
|
it('T1-528-03: D2Rng rand(max), randRange(min, max), and int(min, max) stay within bounds', () => {
|
|
const rng = new D2Rng(42)
|
|
for (let i = 0; i < 50; i++) {
|
|
const r = rng.rand(10)
|
|
expect(r).toBeGreaterThanOrEqual(0)
|
|
expect(r).toBeLessThan(10)
|
|
|
|
const rr = rng.randRange(5, 15)
|
|
expect(rr).toBeGreaterThanOrEqual(5)
|
|
expect(rr).toBeLessThanOrEqual(15)
|
|
|
|
const ri = rng.int(100, 105)
|
|
expect(ri).toBeGreaterThanOrEqual(100)
|
|
expect(ri).toBeLessThanOrEqual(105)
|
|
}
|
|
})
|
|
|
|
it('T1-528-04: Two GameEngine instances with identical lootSeed produce identical RNG trajectories', () => {
|
|
const e1 = makeEngine(98765)
|
|
const e2 = makeEngine(98765)
|
|
for (let t = 0; t < 20; t++) {
|
|
e1.tick(IDLE_INPUT)
|
|
e2.tick(IDLE_INPUT)
|
|
}
|
|
expect(e1.loot.getSeed()).toEqual(e2.loot.getSeed())
|
|
expect(e1.castRng.getSeed()).toEqual(e2.castRng.getSeed())
|
|
})
|
|
|
|
it('T1-528-05: GameEngine tick() never invokes Math.random()', () => {
|
|
const spy = vi.spyOn(Math, 'random')
|
|
const engine = makeEngine(55555)
|
|
for (let i = 0; i < 15; i++) {
|
|
engine.tick({ ...IDLE_INPUT, attacking: i % 3 === 0 })
|
|
}
|
|
expect(spy).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('T1-528-06: GameEngine saveSnapshot and loadSnapshot preserve 64-bit RNG states', () => {
|
|
const engine = makeEngine(24680)
|
|
for (let i = 0; i < 10; i++) engine.tick(IDLE_INPUT)
|
|
const snap = engine.saveSnapshot()
|
|
const expectedLootNext = engine.loot.clone().step()
|
|
|
|
const restored = makeEngine(11111)
|
|
expect(restored.loadSnapshot(snap)).toBe(true)
|
|
expect(restored.loot.step()).toBe(expectedLootNext)
|
|
})
|
|
})
|
|
|
|
// ===========================================================================
|
|
// 9. Issue #529 — Interactive HUD Panels & Click Routing (6 tests)
|
|
// ===========================================================================
|
|
describe('Tier 1 — #529: Interactive HUD Panels & Click Routing', () => {
|
|
it('T1-529-01: InventoryPanel clickGridCell picks up item to cursor and places it into empty cell', () => {
|
|
const inv = new InventoryPanel()
|
|
const item = makeItem('hp1', 'Minor Healing Potion', 1, 1, 30)
|
|
expect(inv.autoPlaceInGrid(item)).toBe(true)
|
|
const placed = inv.gridItems[0]!
|
|
|
|
const pickRes = inv.clickGridCell(placed.col, placed.row)
|
|
expect(pickRes.cursorItem).toBe(item)
|
|
expect(inv.gridItems).toHaveLength(0)
|
|
|
|
const placeRes = inv.clickGridCell(5, 2)
|
|
expect(placeRes.cursorItem).toBeNull()
|
|
expect(inv.gridItems).toHaveLength(1)
|
|
expect(inv.gridItems[0]!.col).toBe(5)
|
|
expect(inv.gridItems[0]!.row).toBe(2)
|
|
})
|
|
|
|
it('T1-529-02: InventoryPanel clickEquipSlot equips compatible item from cursor and unequips on second click', () => {
|
|
const inv = new InventoryPanel()
|
|
const helm = makeItem('cap', 'Cap', 2, 2, 40)
|
|
inv.cursorItem = helm
|
|
const eqRes = inv.clickEquipSlot('head')
|
|
expect(eqRes.action).toBe('equip')
|
|
expect(inv.equipped.get('head')).toBe(helm)
|
|
expect(inv.cursorItem).toBeNull()
|
|
|
|
const unEqRes = inv.clickEquipSlot('head')
|
|
expect(unEqRes.action).toBe('unequip')
|
|
expect(inv.cursorItem).toBe(helm)
|
|
expect(inv.equipped.get('head')).toBeUndefined()
|
|
})
|
|
|
|
it('T1-529-03: CharacterSheetPanel allocateStat spends statPoints and increments attribute + derived stats', () => {
|
|
const charSheet = new CharacterSheetPanel()
|
|
charSheet.attrs.statPoints = 5
|
|
const oldStr = charSheet.attrs.str
|
|
const oldVit = charSheet.attrs.vit
|
|
const oldHp = charSheet.attrs.baseMaxHp
|
|
|
|
expect(charSheet.allocateStat('str')).toBe(true)
|
|
expect(charSheet.attrs.str).toBe(oldStr + 1)
|
|
expect(charSheet.attrs.statPoints).toBe(4)
|
|
|
|
expect(charSheet.allocateStat('vit')).toBe(true)
|
|
expect(charSheet.attrs.vit).toBe(oldVit + 1)
|
|
expect(charSheet.attrs.baseMaxHp).toBeGreaterThanOrEqual(oldHp)
|
|
expect(charSheet.attrs.statPoints).toBe(3)
|
|
})
|
|
|
|
it('T1-529-04: WorldPanelsHud vendor buyFromActiveVendor and sellToActiveVendor update gold and inventory', () => {
|
|
const inv = new InventoryPanel()
|
|
inv.playerLevel = 20
|
|
inv.gold = 50_000
|
|
const wp = new WorldPanelsHud()
|
|
wp.openVendorForNpc('Akara', 'trade', 10, 'normal')
|
|
|
|
const sellItem = makeItem('swd', 'Short Sword', 1, 3, 200)
|
|
expect(inv.autoPlaceInGrid(sellItem)).toBe(true)
|
|
const sold = wp.sellToActiveVendor(sellItem, inv)
|
|
expect(sold.ok).toBe(true)
|
|
expect(inv.gold).toBeGreaterThan(50_000)
|
|
})
|
|
|
|
it('T1-529-05: HudManager toggleLeftPanel and toggleRightPanel manage mutually exclusive panel states', () => {
|
|
const hud = new HudManager(createMock2DCanvas(), {
|
|
onToggleAutomap: () => {},
|
|
onWaypointTeleport: () => {},
|
|
})
|
|
hud.toggleLeftPanel('char')
|
|
expect(hud.leftPanel).toBe('char')
|
|
expect(hud.charSheet.visible).toBe(true)
|
|
|
|
hud.toggleRightPanel('inv')
|
|
expect(hud.rightPanel).toBe('inv')
|
|
expect(hud.inventory.visible).toBe(true)
|
|
|
|
hud.closeAllPanels()
|
|
expect(hud.leftPanel).toBe('none')
|
|
expect(hud.rightPanel).toBe('none')
|
|
})
|
|
|
|
it('T1-529-06: HudManager switchClass updates active character class across HUD subpanels', () => {
|
|
const hud = new HudManager(createMock2DCanvas(), {
|
|
onToggleAutomap: () => {},
|
|
onWaypointTeleport: () => {},
|
|
})
|
|
expect(() => hud.switchClass('sor')).not.toThrow()
|
|
expect(() => hud.switchClass('pal')).not.toThrow()
|
|
})
|
|
})
|
|
|
|
// ===========================================================================
|
|
// 10. Issue #530 — Single Unified Engine Authority & Rendering (6 tests)
|
|
// ===========================================================================
|
|
describe('Tier 1 — #530: Single Unified Engine Authority & Rendering', () => {
|
|
it('T1-530-01: GameEngine exposes unified world, bag, groundItems, projectiles, explosions, and overlays', () => {
|
|
const engine = makeEngine()
|
|
expect(engine.world).toBeDefined()
|
|
expect(engine.bag).toBeInstanceOf(Inventory)
|
|
expect(engine.groundItems).toBeInstanceOf(GroundItemManager)
|
|
expect(Array.isArray(engine.projectiles)).toBe(true)
|
|
expect(Array.isArray(engine.explosions)).toBe(true)
|
|
expect(Array.isArray(engine.overlays)).toBe(true)
|
|
})
|
|
|
|
it('T1-530-02: GameEngine dropItem and pickupItem round-trip item between engine.bag and engine.groundItems', () => {
|
|
const engine = makeEngine()
|
|
const item = makeItem('rng', 'Ring', 1, 1, 150)
|
|
const dropped = engine.dropItem(item, 200, 200, 10, 10)
|
|
expect(engine.groundItems.count).toBe(1)
|
|
|
|
const picked = engine.pickupItem(dropped.id)
|
|
expect(picked.picked).toBe(true)
|
|
expect(engine.groundItems.count).toBe(0)
|
|
expect(engine.bag.list().some((p) => p.item.name === 'Ring')).toBe(true)
|
|
})
|
|
|
|
it('T1-530-03: GameEngine damageMonster kills monster, awards XP, and spawns drop in groundItems', () => {
|
|
const engine = makeEngine()
|
|
const m = engine.world.monsters[0]!
|
|
const prevKills = engine.world.kills
|
|
const res = engine.damageMonster(0, m.hp + 100, 'player')
|
|
expect(res.killed).toBe(true)
|
|
expect(engine.world.kills).toBe(prevKills + 1)
|
|
})
|
|
|
|
it('T1-530-04: loadItemsAtlas, loadMissileArtMap, and loadOverlayArtMap handle missing fetch gracefully', async () => {
|
|
const { canvas } = createMockWebGLCanvas()
|
|
const renderer = new SpriteRenderer(canvas)
|
|
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 404 })))
|
|
|
|
const itemsAtlas = await loadItemsAtlas(renderer, '/nonexistent')
|
|
const missileMap = await loadMissileArtMap(renderer, '/nonexistent')
|
|
const overlayMap = await loadOverlayArtMap(renderer, '/nonexistent')
|
|
|
|
expect(itemsAtlas).toBeUndefined()
|
|
expect(missileMap.size).toBe(0)
|
|
expect(overlayMap.size).toBe(0)
|
|
renderer.dispose()
|
|
})
|
|
|
|
it('T1-530-05: GameEngine tick advances player movement and updates world tick counter', () => {
|
|
const engine = makeEngine()
|
|
const startX = engine.world.player.x
|
|
engine.tick({ ...IDLE_INPUT, movement: { x: 1, y: 0 } })
|
|
expect(engine.world.tick).toBe(1)
|
|
expect(engine.world.player.x).toBeGreaterThan(startX)
|
|
})
|
|
|
|
it('T1-530-06: GameEngine saveSnapshot and loadSnapshot restore player position, inventory, and gold', () => {
|
|
const engine = makeEngine()
|
|
engine.gold = 4321
|
|
engine.bag.add(makeItem('amu', 'Amulet', 1, 1, 200))
|
|
engine.tick({ ...IDLE_INPUT, movement: { x: 1, y: 1 } })
|
|
const snap = engine.saveSnapshot()
|
|
|
|
const engine2 = makeEngine()
|
|
expect(engine2.loadSnapshot(snap)).toBe(true)
|
|
expect(engine2.gold).toBe(4321)
|
|
expect(engine2.bag.list().some((p) => p.item.name === 'Amulet')).toBe(true)
|
|
expect(engine2.world.player.x).toBeCloseTo(engine.world.player.x, 4)
|
|
})
|
|
})
|