599 lines
21 KiB
TypeScript
599 lines
21 KiB
TypeScript
/**
|
|
* 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()
|
|
})
|
|
})
|