383 lines
12 KiB
TypeScript
383 lines
12 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest'
|
|
import { ActorAnimator } from '../src/game/actor-animator.ts'
|
|
import type { LoadedMonsterArt } from '../src/game/monster-art.ts'
|
|
import { LoadGeneration, type AtlasHandle, type SpriteRenderer } from '../src/render/renderer.ts'
|
|
import * as entityArtLoader from '../src/scene/frontend/entity-art-loader.ts'
|
|
import {
|
|
createLevelTransitionController,
|
|
disposeRuntimeAtlases,
|
|
type MapRuntime,
|
|
} from '../src/scene/frontend/pack-loader.ts'
|
|
import { createHeroLightingAndMonsterControls } from '../src/scene/frontend/toolbar-chat-ui.ts'
|
|
|
|
function createTrackingRenderer() {
|
|
let nextHandle = 1
|
|
const liveHandles = new Set<AtlasHandle>()
|
|
const deletedHandles: AtlasHandle[] = []
|
|
|
|
const renderer = {
|
|
addAtlas: vi.fn((_source: unknown, _w: number, _h: number): AtlasHandle => {
|
|
const h = nextHandle++ as unknown as AtlasHandle
|
|
liveHandles.add(h)
|
|
return h
|
|
}),
|
|
deleteAtlas: vi.fn((h: AtlasHandle) => {
|
|
if (liveHandles.delete(h)) {
|
|
deletedHandles.push(h)
|
|
}
|
|
}),
|
|
setPalette: vi.fn(),
|
|
get liveTextureCount() {
|
|
return liveHandles.size
|
|
},
|
|
} as unknown as SpriteRenderer & { liveTextureCount: number }
|
|
|
|
return { renderer, liveHandles, deletedHandles }
|
|
}
|
|
|
|
function makeDummyRuntime(levelId: number, pages: Array<AtlasHandle | null> = []): MapRuntime {
|
|
return {
|
|
source: 'pack',
|
|
base: '/packs',
|
|
act: 1,
|
|
level: String(levelId),
|
|
levelId,
|
|
variant: 'v0',
|
|
variants: ['v0'],
|
|
title: `Level ${levelId}`,
|
|
widthPx: 800,
|
|
heightPx: 600,
|
|
cellsX: 10,
|
|
cellsY: 10,
|
|
animSpeed: 16,
|
|
frameDurationMs: 62.5,
|
|
grid: {
|
|
gridWidth: 10,
|
|
gridHeight: 10,
|
|
tileWidth: 10,
|
|
tileHeight: 10,
|
|
blocked: new Uint8Array(100),
|
|
originX: 0,
|
|
originY: 0,
|
|
} as any,
|
|
floors: [],
|
|
shadows: [],
|
|
walls: [],
|
|
roofs: [],
|
|
objectDrawables: [],
|
|
npcs: [],
|
|
monsterTypes: ['fallen1'],
|
|
monsterPacks: [],
|
|
baseMonsterPacks: [],
|
|
entrances: [],
|
|
warps: [],
|
|
waypoints: [],
|
|
stashes: [],
|
|
objects: [],
|
|
spawn: { x: 100, y: 100 },
|
|
palette: { rgb: new Uint8Array(768), rgba: new Uint32Array(256) } as any,
|
|
pages: [...pages],
|
|
objectPages: [],
|
|
priorityPages: [],
|
|
totalPages: pages.length,
|
|
charBases: ['/mpq'],
|
|
loadPages: vi.fn(async () => {}),
|
|
disposed: false,
|
|
loadGen: new LoadGeneration(),
|
|
} as unknown as MapRuntime
|
|
}
|
|
|
|
describe('P0 #529: Async Scene LoadGeneration Cancellation & GPU Texture Disposal', () => {
|
|
it('LoadGeneration increments generation IDs and aborts prior AbortSignal instances', () => {
|
|
const loadGen = new LoadGeneration()
|
|
expect(loadGen.current).toBe(0)
|
|
|
|
const { gen: gen1, signal: sig1 } = loadGen.next()
|
|
expect(gen1).toBe(1)
|
|
expect(loadGen.isCurrent(gen1)).toBe(true)
|
|
expect(sig1.aborted).toBe(false)
|
|
|
|
const { gen: gen2 } = loadGen.next()
|
|
expect(gen2).toBe(2)
|
|
expect(loadGen.isCurrent(gen1)).toBe(false)
|
|
expect(loadGen.isCurrent(gen2)).toBe(true)
|
|
expect(sig1.aborted).toBe(true)
|
|
|
|
loadGen.invalidate()
|
|
expect(loadGen.isCurrent(gen2)).toBe(false)
|
|
expect(loadGen.signal.aborted).toBe(true)
|
|
})
|
|
|
|
it('discards out-of-order switchHero completions and deletes stale character GPU textures', async () => {
|
|
const { renderer, liveHandles, deletedHandles } = createTrackingRenderer()
|
|
|
|
let resolveFirst!: (val: entityArtLoader.LoadedCharacter) => void
|
|
let resolveSecond!: (val: entityArtLoader.LoadedCharacter) => void
|
|
|
|
const loadCharSpy = vi
|
|
.spyOn(entityArtLoader, 'loadCharacterArt')
|
|
.mockImplementationOnce(
|
|
() =>
|
|
new Promise(resolve => {
|
|
resolveFirst = resolve
|
|
}),
|
|
)
|
|
.mockImplementationOnce(
|
|
() =>
|
|
new Promise(resolve => {
|
|
resolveSecond = resolve
|
|
}),
|
|
)
|
|
|
|
let currentHeroToken = 'so'
|
|
const initialHandle = renderer.addAtlas(new Uint8Array(4), 64, 64)
|
|
let activeCharacter: entityArtLoader.LoadedCharacter | null = {
|
|
handle: initialHandle,
|
|
clips: {},
|
|
members: 1,
|
|
} as unknown as entityArtLoader.LoadedCharacter
|
|
|
|
const runtime = makeDummyRuntime(1)
|
|
const status = { textContent: '' } as HTMLElement
|
|
|
|
const controls = createHeroLightingAndMonsterControls({
|
|
renderer,
|
|
dropTables: {} as any,
|
|
playerAnimator: new ActorAnimator(),
|
|
status,
|
|
heroSelect: null,
|
|
lightingSelect: null,
|
|
densitySelect: null,
|
|
eliteSelect: null,
|
|
getRuntime: () => runtime,
|
|
getEngine: () => ({ world: { player: {} } }) as any,
|
|
getHudManager: () => null,
|
|
getCurrentHeroToken: () => currentHeroToken,
|
|
setCurrentHeroToken: t => {
|
|
currentHeroToken = t
|
|
},
|
|
getCharacter: () => activeCharacter,
|
|
setCharacter: c => {
|
|
activeCharacter = c
|
|
},
|
|
getActivePalette: () => runtime.palette,
|
|
getPackEntityBase: () => '/packs',
|
|
getPackEntityAct: () => 1,
|
|
getCurrentLightingPreset: () => 'auto',
|
|
setLightingState: () => {},
|
|
getMultipliers: () => ({ densityMultiplier: 1, eliteMultiplier: 1 }),
|
|
setMultipliers: () => {},
|
|
setMonsterArtMap: () => {},
|
|
})
|
|
|
|
// Fire two rapid hero switches: 'am' (slow) then 'ba' (fast)
|
|
const p1 = controls.switchHero('am')
|
|
const p2 = controls.switchHero('ba')
|
|
|
|
const handleBa = renderer.addAtlas(new Uint8Array(4), 64, 64)
|
|
resolveSecond({ handle: handleBa, clips: {}, members: 1 } as unknown as entityArtLoader.LoadedCharacter)
|
|
await p2
|
|
|
|
expect(currentHeroToken).toBe('ba')
|
|
expect(activeCharacter?.handle).toBe(handleBa)
|
|
expect(deletedHandles).toContain(initialHandle)
|
|
|
|
// Now the stale first request ('am') resolves AFTER 'ba'
|
|
const handleAm = renderer.addAtlas(new Uint8Array(4), 64, 64)
|
|
resolveFirst({ handle: handleAm, clips: {}, members: 1 } as unknown as entityArtLoader.LoadedCharacter)
|
|
await p1
|
|
|
|
// Must keep 'ba' and immediately delete stale 'am' texture handle
|
|
expect(currentHeroToken).toBe('ba')
|
|
expect(activeCharacter?.handle).toBe(handleBa)
|
|
expect(deletedHandles).toContain(handleAm)
|
|
expect(liveHandles.size).toBe(1)
|
|
expect(liveHandles.has(handleBa)).toBe(true)
|
|
|
|
loadCharSpy.mockRestore()
|
|
})
|
|
|
|
it('discards out-of-order reloadMonsters completions and deletes replaced monster GPU textures', async () => {
|
|
const { renderer, liveHandles, deletedHandles } = createTrackingRenderer()
|
|
|
|
let resolveFirst!: (val: entityArtLoader.MonsterArtLoad) => void
|
|
let resolveSecond!: (val: entityArtLoader.MonsterArtLoad) => void
|
|
|
|
const loadMonSpy = vi
|
|
.spyOn(entityArtLoader, 'loadMonsterArtMap')
|
|
.mockImplementationOnce(
|
|
() =>
|
|
new Promise(resolve => {
|
|
resolveFirst = resolve
|
|
}),
|
|
)
|
|
.mockImplementationOnce(
|
|
() =>
|
|
new Promise(resolve => {
|
|
resolveSecond = resolve
|
|
}),
|
|
)
|
|
|
|
const initialMonHandle = renderer.addAtlas(new Uint8Array(4), 64, 64)
|
|
let monsterArtMap = new Map<string, LoadedMonsterArt>([
|
|
['fallen1', { handle: initialMonHandle, clips: {} } as any],
|
|
])
|
|
|
|
const runtime = makeDummyRuntime(2)
|
|
runtime.baseMonsterPacks = [
|
|
{
|
|
leaderId: 'fallen1',
|
|
members: [{ id: 'fallen1', rank: 'normal' }],
|
|
} as any,
|
|
]
|
|
const status = { textContent: '' } as HTMLElement
|
|
|
|
const controls = createHeroLightingAndMonsterControls({
|
|
renderer,
|
|
dropTables: { monsterKinds: new Map() } as any,
|
|
playerAnimator: new ActorAnimator(),
|
|
status,
|
|
heroSelect: null,
|
|
lightingSelect: null,
|
|
densitySelect: null,
|
|
eliteSelect: null,
|
|
getRuntime: () => runtime,
|
|
getEngine: () =>
|
|
({
|
|
terrain: {
|
|
overlap: () => 0,
|
|
isWalkable: () => true,
|
|
},
|
|
world: {
|
|
player: { x: 100, y: 100 },
|
|
monsters: [],
|
|
corpses: [],
|
|
events: [],
|
|
},
|
|
projectiles: [],
|
|
explosions: [],
|
|
}) as any,
|
|
getHudManager: () => null,
|
|
getCurrentHeroToken: () => 'so',
|
|
setCurrentHeroToken: () => {},
|
|
getCharacter: () => null,
|
|
setCharacter: () => {},
|
|
getActivePalette: () => runtime.palette,
|
|
getPackEntityBase: () => '/packs',
|
|
getPackEntityAct: () => 1,
|
|
getCurrentLightingPreset: () => 'auto',
|
|
setLightingState: () => {},
|
|
getMultipliers: () => ({ densityMultiplier: 1, eliteMultiplier: 1 }),
|
|
setMultipliers: () => {},
|
|
getMonsterArtMap: () => monsterArtMap,
|
|
setMonsterArtMap: m => {
|
|
monsterArtMap = m
|
|
},
|
|
})
|
|
|
|
const p1 = controls.reloadMonsters({ density: 2 })
|
|
const p2 = controls.reloadMonsters({ density: 5 })
|
|
|
|
const handleSecond = renderer.addAtlas(new Uint8Array(4), 64, 64)
|
|
resolveSecond({
|
|
map: new Map([['fallen1', { handle: handleSecond, clips: {} } as any]]),
|
|
missing: [],
|
|
} as unknown as entityArtLoader.MonsterArtLoad)
|
|
await p2
|
|
|
|
expect(monsterArtMap.get('fallen1')?.handle).toBe(handleSecond)
|
|
expect(deletedHandles).toContain(initialMonHandle)
|
|
|
|
// Stale first load resolves later
|
|
const handleFirst = renderer.addAtlas(new Uint8Array(4), 64, 64)
|
|
resolveFirst({
|
|
map: new Map([['fallen1', { handle: handleFirst, clips: {} } as any]]),
|
|
missing: [],
|
|
} as unknown as entityArtLoader.MonsterArtLoad)
|
|
await p1
|
|
|
|
expect(monsterArtMap.get('fallen1')?.handle).toBe(handleSecond)
|
|
expect(deletedHandles).toContain(handleFirst)
|
|
expect(liveHandles.size).toBe(1)
|
|
|
|
loadMonSpy.mockRestore()
|
|
})
|
|
|
|
it('disposes old runtime atlases and clears groundItems on swapLevel', async () => {
|
|
const { renderer, liveHandles } = createTrackingRenderer()
|
|
const page1 = renderer.addAtlas(new Uint8Array(4), 256, 256)
|
|
const page2 = renderer.addAtlas(new Uint8Array(4), 256, 256)
|
|
const oldRuntime = makeDummyRuntime(1, [page1, page2])
|
|
|
|
const nextPage = renderer.addAtlas(new Uint8Array(4), 256, 256)
|
|
const nextRuntime = makeDummyRuntime(2, [nextPage])
|
|
|
|
const loadMonSpy = vi.spyOn(entityArtLoader, 'loadMonsterArtMap').mockResolvedValue({
|
|
map: new Map(),
|
|
missing: [],
|
|
} as unknown as entityArtLoader.MonsterArtLoad)
|
|
|
|
let activeRuntime = oldRuntime
|
|
let monsterArtMap = new Map<string, LoadedMonsterArt>()
|
|
const groundItemsClear = vi.fn()
|
|
|
|
const controller = createLevelTransitionController({
|
|
canvas: { width: 800, height: 600 } as HTMLCanvasElement,
|
|
status: { textContent: '' } as HTMLElement,
|
|
renderer,
|
|
engine: {
|
|
world: {
|
|
player: { x: 100, y: 100, targetX: 100, targetY: 100, vx: 0, vy: 0 },
|
|
monsters: [],
|
|
corpses: [],
|
|
events: [],
|
|
},
|
|
ground: [{ x: 10, y: 10, item: {} as any }],
|
|
groundItems: { clear: groundItemsClear },
|
|
npcEntities: [],
|
|
projectiles: [],
|
|
explosions: [],
|
|
} as any,
|
|
camera: { setWorldSize: vi.fn(), centerOn: vi.fn() } as any,
|
|
input: { clear: vi.fn() } as any,
|
|
fade: {} as any,
|
|
minimap: { reveal: vi.fn(), revealedRoomsCount: 0 } as any,
|
|
minimapLevel: () => ({}) as any,
|
|
resetMinimapCache: vi.fn(),
|
|
waypointNetwork: { tryAutoUnlockNearest: vi.fn() } as any,
|
|
portalSlot: {} as any,
|
|
playerAnimator: new ActorAnimator(),
|
|
playerSubTile: () => ({ x: 10, y: 10 }),
|
|
populatePackSelectors: vi.fn(async () => {}),
|
|
getRuntime: () => activeRuntime,
|
|
setRuntime: next => {
|
|
activeRuntime = next
|
|
},
|
|
getHudManager: () => null,
|
|
clearMouseTarget: vi.fn(),
|
|
getCharacter: () => null,
|
|
getMonsterArtMap: () => monsterArtMap,
|
|
setMonsterArtMap: m => {
|
|
monsterArtMap = m
|
|
},
|
|
setActivePaletteAndPack: vi.fn(),
|
|
getMultipliers: () => ({ densityMultiplier: 1, eliteMultiplier: 1 }),
|
|
setBoundsState: vi.fn(),
|
|
})
|
|
|
|
await controller.swapLevel(nextRuntime)
|
|
|
|
expect(oldRuntime.disposed).toBe(true)
|
|
expect(oldRuntime.pages).toEqual([null, null])
|
|
expect(groundItemsClear).toHaveBeenCalledTimes(1)
|
|
// Only nextPage should remain alive
|
|
expect(liveHandles.size).toBe(1)
|
|
expect(liveHandles.has(nextPage)).toBe(true)
|
|
|
|
disposeRuntimeAtlases(nextRuntime, renderer)
|
|
expect(liveHandles.size).toBe(0)
|
|
|
|
loadMonSpy.mockRestore()
|
|
})
|
|
})
|