diff --git a/src/client/automap/automap-view.ts b/src/client/automap/automap-view.ts index 9092af4..6d5ed1e 100644 --- a/src/client/automap/automap-view.ts +++ b/src/client/automap/automap-view.ts @@ -1,9 +1,6 @@ import { DRLG_GetActNoFromLevelId } from '../../common/drlg/drlg-drlg.ts' -import type { - PackedDrlgBundleManifest, - PackedTileCatalogIndex, -} from '../../common/pack-contract/index.ts' import { getMonsterKindByClassId } from '../../common/units/monsters-data.ts' +import { NPC_HCIDX_TO_DESCRIPTOR_ID } from '../../common/units/npc-table.ts' import { levelDisplayName } from '../../common/world/level-names-zh.ts' import { getTblLang, lookupTbl, type TblLang } from '../i18n/lang.ts' import type { @@ -128,69 +125,6 @@ function makeRoomKey(areaId: number, roomX: number, roomY: number): string { return `${areaId}:${roomX},${roomY}` } -interface NodeFsLike { - readFileSync(path: string, encoding: string): string - readFileSync(path: string): { buffer: ArrayBuffer; byteOffset: number; byteLength: number } - existsSync(path: string): boolean -} - -interface NodePathLike { - resolve(...args: string[]): string - join(...args: string[]): string -} - -/** - * Synchronously loads the packed tile bundle in Node/test environments when MapService - * was not injected via AutomapViewOptions. - */ -function tryLoadPackedBundleNodeSync(): MapService | null { - const proc = ( - globalThis as unknown as { - process?: { versions?: { node?: string }; cwd?: () => string } - } - ).process - if (!proc || !proc.versions?.node) { - return null - } - try { - const req = - typeof (globalThis as unknown as { require?: unknown }).require === 'function' - ? (globalThis as unknown as { require: (id: string) => unknown }).require - : null - if (!req) return null - const fs = req('node:fs') as NodeFsLike | undefined - const path = req('node:path') as NodePathLike | undefined - if (!fs || !path) return null - const cwd = typeof proc.cwd === 'function' ? proc.cwd() : '.' - const candidates = [ - path.resolve(cwd, 'public/act-packs/tiles'), - path.resolve(cwd, 'public/assets/act-packs/tiles'), - path.resolve('/usr/local/google/home/taodao/diablo2-web/public/act-packs/tiles'), - ] - let resolvedDir: string | null = null - for (const dir of candidates) { - if (fs.existsSync(path.join(dir, 'drlg-bundle.json'))) { - resolvedDir = dir - break - } - } - if (!resolvedDir) return null - const indexJson = JSON.parse( - fs.readFileSync(path.join(resolvedDir, 'index.json'), 'utf8'), - ) as PackedTileCatalogIndex - const manifest = JSON.parse( - fs.readFileSync(path.join(resolvedDir, 'drlg-bundle.json'), 'utf8'), - ) as PackedDrlgBundleManifest - const buf = fs.readFileSync(path.join(resolvedDir, 'drlg-bundle.bin')) - const bundleBytes = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength) - return MapService.fromPackedBundle(manifest, bundleBytes, indexJson) - } catch (err) { - throw new Error( - `[AutomapView] Failed to synchronously load packed tile bundle from public/act-packs/tiles: ${err instanceof Error ? err.message : String(err)}`, - ) - } -} - /** * Online & offline Automap Reveal controller (`off` | `level` | `act`) consuming * `LevelView` and `MapService` (`src/client/map/`). @@ -217,9 +151,6 @@ export class AutomapView { this.mapService = options?.mapService ?? null this.mode = options?.initialMode ?? 'off' this.actRevealLevelIds = options?.actRevealLevelIds ?? ACT_PRIMARY_LEVEL_IDS - if (!this.mapService) { - this.mapService = tryLoadPackedBundleNodeSync() - } } getMode(): AutomapRevealMode { @@ -257,26 +188,27 @@ export class AutomapView { if (seedChanged) { this.levelViews.clear() this.serverRevealedRooms.clear() - if (!this.mapService) { - this.mapService = tryLoadPackedBundleNodeSync() - } - if (this.mapService) { - const loaded = this.mapService.loadAct({ - act: this.currentActId - 1, - seed: this.mapSeed, - difficulty: this.difficulty, - areaId, - revealMode: 'all', - }) - if (loaded) { - this.levelViews.set(areaId, loaded) + if (this.mapService && typeof (this.mapService as Partial).loadAct === 'function') { + try { + const loaded = this.mapService.loadAct({ + act: this.currentActId - 1, + seed: this.mapSeed, + difficulty: this.difficulty, + areaId, + revealMode: 'all', + }) + if (loaded) { + this.levelViews.set(areaId, loaded) + } + } catch { + // Ignore if mapService fails synchronously } } } if (existingLevelView) { this.levelViews.set(areaId, existingLevelView) - } else if (!this.levelViews.has(areaId)) { + } else if (this.mapService && !this.levelViews.has(areaId)) { this.ensureLevelView(areaId) } @@ -296,7 +228,7 @@ export class AutomapView { const targetAreaId = this.currentAreaId if (existingLevelView && existingLevelView.areaId === targetAreaId) { this.levelViews.set(targetAreaId, existingLevelView) - } else if (!this.levelViews.has(targetAreaId)) { + } else if (this.mapService && !this.levelViews.has(targetAreaId)) { this.ensureLevelView(targetAreaId) } this.applyRevealModeToViews() @@ -307,7 +239,9 @@ export class AutomapView { * Record a server `0x07 AddMapRoom` event. */ onServerAddRoom(roomX: number, roomY: number, areaId: number = this.currentAreaId): void { - this.ensureLevelView(areaId) + if (this.mapService) { + this.ensureLevelView(areaId) + } this.serverRevealedRooms.set(makeRoomKey(areaId, roomX, roomY), { areaId, roomX, @@ -338,40 +272,47 @@ export class AutomapView { return this.levelViews.get(areaId) } - private ensureLevelView(areaId: number): LevelView { + private ensureLevelView(areaId: number): LevelView | undefined { let view = this.levelViews.get(areaId) if (!view) { if (!this.mapService) { - this.mapService = tryLoadPackedBundleNodeSync() + return undefined } - if (!this.mapService) { - throw new Error( - `[AutomapView] Failed to load LevelView for area ${areaId} (seed: ${this.mapSeed}, difficulty: ${this.difficulty}): mapService is not initialized and packed tile bundle is unavailable`, - ) + try { + if (typeof (this.mapService as Partial).loadAct === 'function') { + const activeAct = + typeof (this.mapService as Partial).getActiveAct === 'function' + ? this.mapService.getActiveAct() + : null + if (activeAct !== getActForArea(areaId) - 1) { + this.mapService.loadAct({ + act: getActForArea(areaId) - 1, + seed: this.mapSeed, + difficulty: this.difficulty, + areaId, + revealMode: 'all', + }) + } + } + if (typeof (this.mapService as Partial).getLevelView === 'function') { + view = this.mapService.getLevelView(areaId, 'all') ?? undefined + } + if (view) { + this.levelViews.set(areaId, view) + } + } catch { + return undefined } - if (this.mapService.getActiveAct() !== getActForArea(areaId) - 1) { - this.mapService.loadAct({ - act: getActForArea(areaId) - 1, - seed: this.mapSeed, - difficulty: this.difficulty, - areaId, - revealMode: 'all', - }) - } - view = this.mapService.getLevelView(areaId, 'all') - if (!view) { - throw new Error( - `[AutomapView] MapService failed to produce LevelView for area ${areaId} (seed: ${this.mapSeed}, difficulty: ${this.difficulty})`, - ) - } - this.levelViews.set(areaId, view) } return view } private applyRevealModeToViews(): void { + if (!this.mapService) { + return + } this.ensureLevelView(this.currentAreaId) - if (this.mode === 'act') { + if (this.mode === 'act' && this.mapService) { const actLevels = this.actRevealLevelIds[this.currentActId] ?? [this.currentAreaId] const idsToReveal = actLevels.includes(this.currentAreaId) ? actLevels @@ -493,7 +434,8 @@ export class AutomapView { const cid = u.classId ?? 0 const isAncientStatue = cid >= 537 && cid <= 539 const mk = typeof u.classId === 'number' ? getMonsterKindByClassId(u.classId) : undefined - const isInteract = mk ? mk.interact : Boolean((u as { isNpc?: boolean }).isNpc) + const isTownNpc = Boolean(NPC_HCIDX_TO_DESCRIPTOR_ID.has(cid) || NPC_HCIDX_TO_DESCRIPTOR_ID[cid]) + const isInteract = Boolean(mk?.interact || (u as { isNpc?: boolean }).isNpc || isTownNpc) if (isInteract && !isAncientStatue) { entityDots.push({ worldX, @@ -537,6 +479,11 @@ export class AutomapView { } const snap = this.buildSnapshot([], lang) const view = this.ensureLevelView(this.currentAreaId) + if (!view) { + throw new Error( + `[AutomapView] Failed to load LevelView for area ${this.currentAreaId} (seed: ${this.mapSeed}, difficulty: ${this.difficulty}): mapService is not initialized and packed tile bundle is unavailable`, + ) + } if ( view.cellsX <= 0 || diff --git a/src/client/scene/world-renderer.ts b/src/client/scene/world-renderer.ts index 94d3bc6..868d15c 100644 --- a/src/client/scene/world-renderer.ts +++ b/src/client/scene/world-renderer.ts @@ -1143,36 +1143,37 @@ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFram // 5. Automap / Minimap via WebGL2 SpriteRenderer if (ctx.minimap && ctx.automapView) { const mode = ctx.automapView.getMode() + if (levelView && levelView.areaId > 0) { + ctx.automapView.syncActiveLevel(levelView.areaId, levelView) + } else if (snapshot.levelId > 0 && ctx.automapView.getCurrentAreaId() !== snapshot.levelId) { + ctx.automapView.syncActiveLevel(snapshot.levelId) + } + const baseMinimapLevel = ctx.automapView.buildMinimapLevel( + undefined, + levelView?.areaId ?? snapshot.levelId, + ) + const effectiveMinimapLevel: MinimapLevel = { + ...baseMinimapLevel, + originX: levelView?.isoOriginX ?? baseMinimapLevel.originX ?? 0, + originY: levelView?.isoOriginY ?? baseMinimapLevel.originY ?? 0, + preRevealTown: isTownLevel(snapshot.levelId), + } + const localSub = snapshot.playerLocalSubTile + const playerCellX = Math.floor(localSub.x / 5) + const playerCellY = Math.floor(localSub.y / 5) + ctx.minimap.reveal( + effectiveMinimapLevel, + playerCellX, + playerCellY, + undefined, + undefined, + true, + ) + if (mode === 'off') { ctx.minimap.visible = false } else { ctx.minimap.visible = true - if (levelView && levelView.areaId > 0) { - ctx.automapView.syncActiveLevel(levelView.areaId, levelView) - } else if (snapshot.levelId > 0 && ctx.automapView.getCurrentAreaId() !== snapshot.levelId) { - ctx.automapView.syncActiveLevel(snapshot.levelId) - } - const baseMinimapLevel = ctx.automapView.buildMinimapLevel( - undefined, - levelView?.areaId ?? snapshot.levelId, - ) - const effectiveMinimapLevel: MinimapLevel = { - ...baseMinimapLevel, - originX: levelView?.isoOriginX ?? baseMinimapLevel.originX ?? 0, - originY: levelView?.isoOriginY ?? baseMinimapLevel.originY ?? 0, - preRevealTown: isTownLevel(snapshot.levelId), - } - const localSub = snapshot.playerLocalSubTile - const playerCellX = Math.floor(localSub.x / 5) - const playerCellY = Math.floor(localSub.y / 5) - ctx.minimap.reveal( - effectiveMinimapLevel, - playerCellX, - playerCellY, - undefined, - undefined, - true, - ) const isEn = getTblLang() === 'ENG' const automapEntities: AutomapEntityMarker[] = [] const automapPortals: { diff --git a/src/client/ui/control-bar.ts b/src/client/ui/control-bar.ts index c9c3990..543b388 100644 --- a/src/client/ui/control-bar.ts +++ b/src/client/ui/control-bar.ts @@ -92,7 +92,7 @@ export class ControlBarHud { lang: TblLang = 'CHI' isMultiplayer = true pressedAction: MiniPanelAction | null = null - hoveredElement: 'exp' | 'stamina' | 'run' | 'menu' | 'statPlus' | 'skillPlus' | MiniPanelAction | null = null + hoveredElement: 'exp' | 'stamina' | 'run' | 'menuToggle' | 'statPlus' | 'skillPlus' | MiniPanelAction | null = null get currentMiniPanelItems(): readonly MiniPanelItemDef[] { return this.isMultiplayer ? MULTIPLAYER_MINIPANEL_ITEMS : SINGLEPLAYER_MINIPANEL_ITEMS @@ -129,7 +129,7 @@ export class ControlBarHud { } else if (this.inRect(logicalX, logicalY, RUN_BTN_BOUNDS)) { this.hoveredElement = 'run' } else if (this.inRect(logicalX, logicalY, MENU_TOGGLE_BOUNDS)) { - this.hoveredElement = 'menu' + this.hoveredElement = 'menuToggle' } else if (this.inRect(logicalX, logicalY, STAT_PLUS_BTN_BOUNDS)) { this.hoveredElement = 'statPlus' } else if (this.inRect(logicalX, logicalY, SKILL_PLUS_BTN_BOUNDS)) { @@ -352,7 +352,7 @@ export class ControlBarHud { SKILL_PLUS_BTN_BOUNDS.x + 15, 518, ) - } else if (this.hoveredElement === 'menu') { + } else if (this.hoveredElement === 'menuToggle') { const tip = this.miniPanelOpen ? (resolveTblKey('panelcmini', this.lang) ?? (isEn ? 'Close Mini Panel' : '關閉迷你面板')) : (resolveTblKey('panelmini', this.lang) ?? (isEn ? 'Open Mini Panel' : '開啟迷你面板')) diff --git a/src/client/ui/hud-manager.ts b/src/client/ui/hud-manager.ts index f0e4bef..e3cfe55 100644 --- a/src/client/ui/hud-manager.ts +++ b/src/client/ui/hud-manager.ts @@ -2908,6 +2908,8 @@ export class HudManager { const centerX = Math.round(profile.width / 2) const centerY = Math.round(profile.height / 2) ctx.save() + ctx.fillStyle = 'rgba(80, 0, 0, 0.45)' + ctx.fillRect(0, 0, profile.width, profile.height) const hardcoreImg = this.images.get('deathHardcore') const instImg = this.images.get('deathInst') const y1 = centerY - 94 diff --git a/src/client/ui/minimap.ts b/src/client/ui/minimap.ts index e61d3b7..0f0f21b 100644 --- a/src/client/ui/minimap.ts +++ b/src/client/ui/minimap.ts @@ -44,6 +44,8 @@ import { computeAutomapTilePixelCoords, getCrosshairSegments, } from '../../common/formats/automap-table.ts' + +export type { AutomapLayerData } import { lookupTbl } from '../i18n/lang.ts' import { reportMissingArt } from '../scene/world-renderer.ts' import type { D2ColorCode, D2FontRenderer } from './font.ts' diff --git a/tests/challenger-batch2-stress.test.ts b/tests/challenger-batch2-stress.test.ts new file mode 100644 index 0000000..ac05673 --- /dev/null +++ b/tests/challenger-batch2-stress.test.ts @@ -0,0 +1,674 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { beforeAll, describe, expect, it } from 'vitest' +import '../tools/lib/node-map-loader.ts' +import { + AUTOMAP_CROSSHAIR_COLORS, + AUTOMAP_FADE_ALPHAS, + type AutomapEntityMarker, + type AutomapLayerData, + computeAutomapViewport, + Minimap, + type MinimapLevel, +} from '../src/client/ui/minimap.ts' +import { + AutomapView, + type MinimapEntityDot, +} from '../src/client/automap/automap-view.ts' +import { MapService } from '../src/client/map/map-service.ts' +import { renderSceneSourceFrame } from '../src/client/scene/world-renderer.ts' +import { OnlineSession } from '../src/client/session/online-session.ts' +import { createSettingsStore } from '../src/client/settings/settings-store.ts' +import { getMonsterKindByClassId } from '../src/common/units/monsters-data.ts' +import { NPC_HCIDX_TO_DESCRIPTOR_ID } from '../src/common/units/npc-table.ts' +import type { AtlasFrame } from '../src/client/render/atlas.ts' +import type { AtlasHandle, SpriteRenderer } from '../src/client/render/renderer.ts' +import type { RenderableUnitInput } from '../src/client/view/unit-drawables.ts' + +interface RecordedSpriteDraw { + readonly kind: 'sprite' + readonly frame: AtlasFrame + readonly x: number + readonly y: number + readonly atlas?: AtlasHandle | undefined + readonly width?: number | undefined + readonly height?: number | undefined + readonly tint?: readonly [number, number, number, number] | undefined +} + +interface RecordedSolidDraw { + readonly kind: 'solid' + readonly x: number + readonly y: number + readonly width: number + readonly height: number + readonly color: readonly [number, number, number, number] +} + +interface RecordedQuadDraw { + readonly kind: 'quad' + readonly x0: number + readonly y0: number + readonly x1: number + readonly y1: number + readonly x2: number + readonly y2: number + readonly x3: number + readonly y3: number + readonly color: readonly [number, number, number, number] +} + +type RecordedDraw = RecordedSpriteDraw | RecordedSolidDraw | RecordedQuadDraw + +function createMockRenderer(): { + renderer: SpriteRenderer + draws: RecordedDraw[] + scissors: ({ x: number; y: number; width: number; height: number } | null)[] +} { + const draws: RecordedDraw[] = [] + const scissors: ({ x: number; y: number; width: number; height: number } | null)[] = [] + let activeScissor: { x: number; y: number; width: number; height: number } | null = null + + const mockGl = { + SCISSOR_TEST: 0x0c11, + enable(cap: number) { + if (cap === 0x0c11) { + activeScissor = { x: 0, y: 0, width: 0, height: 0 } + } + }, + scissor(x: number, y: number, width: number, height: number) { + activeScissor = { x, y, width, height } + scissors.push(activeScissor) + }, + disable(cap: number) { + if (cap === 0x0c11) { + activeScissor = null + scissors.push(null) + } + }, + } + + const renderer = { + gl: mockGl, + flush() {}, + draw( + frame: AtlasFrame, + x: number, + y: number, + options?: { + atlas?: AtlasHandle + width?: number + height?: number + tint?: readonly [number, number, number, number] + }, + ) { + draws.push({ + kind: 'sprite', + frame, + x, + y, + atlas: options?.atlas, + width: options?.width, + height: options?.height, + tint: options?.tint, + }) + }, + drawSolid( + x: number, + y: number, + width: number, + height: number, + color: readonly [number, number, number, number], + ) { + draws.push({ kind: 'solid', x, y, width, height, color }) + }, + drawQuad( + x0: number, + y0: number, + x1: number, + y1: number, + x2: number, + y2: number, + x3: number, + y3: number, + color: readonly [number, number, number, number], + ) { + draws.push({ kind: 'quad', x0, y0, x1, y1, x2, y2, x3, y3, color }) + }, + } as unknown as SpriteRenderer + + return { renderer, draws, scissors } +} + +function registerSyntheticAtlases(minimap: Minimap): void { + const keys = [ + 'MaxiMap', + 'MaxiMapS', + 'Act2Map', + 'Act2MapS', + 'Act4Map', + 'Act4MapS', + 'ExTnMap', + 'ExTnMapS', + ] as const + for (const key of keys) { + const meta = minimap.getSheetMeta(key) + minimap.setAtlasHandle(key, { + texture: key as unknown as WebGLTexture, + width: meta.atlasWidth, + height: meta.atlasHeight, + }) + } +} + +function makeSyntheticLevel( + key: string, + cellsX: number, + cellsY: number, + overrides?: Partial, +): MinimapLevel { + const gridWidth = cellsX * 5 + const blocked = new Uint8Array(gridWidth * cellsY * 5) + const floors: AutomapLayerData['floors'][number][] = [] + + for (let cy = 0; cy < cellsY; cy += 4) { + for (let cx = 0; cx < cellsX; cx += 4) { + floors.push({ + cellNo: 1, + xPixel: (cx - cy) * 16, + yPixel: (cx + cy) * 8, + cellX: cx, + cellY: cy, + fSaved: 0, + weight: 0, + }) + } + } + + const automap: AutomapLayerData = { + layerId: 1, + levelTypeId: 1, + floors, + walls: [], + objects: [], + extras: [], + } + + return { + key, + cellsX, + cellsY, + blocked, + gridWidth, + originX: 0, + originY: 0, + automap, + levelId: 1, + levelName: 'Test Area', + difficulty: 'Normal', + markers: [], + ...overrides, + } +} + +let sharedMapService: MapService + +beforeAll(async () => { + sharedMapService = await MapService.fromPackedDir('public/act-packs/tiles') +}) + +describe('Challenger 1 Adversarial Stress Suite: Batch 2 Parity Gate', () => { + describe('Dimension 1: Fog of War, Monotonicity & Exploration State', () => { + it('reveals cells strictly monotonically across 500 random walk room transitions', () => { + const minimap = new Minimap() + const level = makeSyntheticLevel('test-wilderness', 100, 100) + + let prevRevealedRooms = 0 + let prevExploredRatio = 0 + + let playerCellX = 10 + let playerCellY = 10 + + let seed = 1337 + const nextRandom = (): number => { + seed = (seed * 1664525 + 1013904223) >>> 0 + return seed / 4294967296 + } + + for (let step = 0; step < 500; step++) { + const dx = Math.floor(nextRandom() * 5) - 2 + const dy = Math.floor(nextRandom() * 5) - 2 + playerCellX = Math.max(2, Math.min(level.cellsX - 3, playerCellX + dx)) + playerCellY = Math.max(2, Math.min(level.cellsY - 3, playerCellY + dy)) + + minimap.reveal(level, playerCellX, playerCellY) + + const currentRevealedRooms = minimap.revealedRoomCount(level.key) + const currentExploredRatio = minimap.exploredFraction(level) + + expect(currentRevealedRooms).toBeGreaterThanOrEqual(prevRevealedRooms) + expect(currentExploredRatio).toBeGreaterThanOrEqual(prevExploredRatio) + + prevRevealedRooms = currentRevealedRooms + prevExploredRatio = currentExploredRatio + } + + expect(prevRevealedRooms).toBeGreaterThan(10) + expect(prevExploredRatio).toBeGreaterThan(0.05) + }) + + it('shares and preserves exploration grid identically between Corner Minimap and Full Automap modes', () => { + const minimap = new Minimap() + const level = makeSyntheticLevel('test-shared', 40, 40) + + minimap.mode = 'minimap' + minimap.reveal(level, 8, 8) + const countMinimap = minimap.revealedRoomCount(level.key) + const ratioMinimap = minimap.exploredFraction(level) + + minimap.mode = 'full' + const countFull = minimap.revealedRoomCount(level.key) + const ratioFull = minimap.exploredFraction(level) + + expect(countFull).toBe(countMinimap) + expect(ratioFull).toBe(ratioMinimap) + + minimap.reveal(level, 24, 24) + const countUpdatedFull = minimap.revealedRoomCount(level.key) + expect(countUpdatedFull).toBeGreaterThan(countFull) + + minimap.mode = 'minimap' + const countRestoredMinimap = minimap.revealedRoomCount(level.key) + expect(countRestoredMinimap).toBe(countUpdatedFull) + }) + + it('isolates revealed rooms strictly by level key across multi-level area transitions', () => { + const minimap = new Minimap() + const townLevel = makeSyntheticLevel('town-key', 30, 30) + const wildernessLevel = makeSyntheticLevel('wilderness-key', 50, 50) + + minimap.reveal(townLevel, 5, 5) + expect(minimap.revealedRoomCount('town-key')).toBeGreaterThan(0) + expect(minimap.revealedRoomCount('wilderness-key')).toBe(0) + + minimap.reveal(wildernessLevel, 20, 20) + expect(minimap.revealedRoomCount('town-key')).toBeGreaterThan(0) + expect(minimap.revealedRoomCount('wilderness-key')).toBeGreaterThan(0) + + const townCount = minimap.revealedRoomCount('town-key') + const wildCount = minimap.revealedRoomCount('wilderness-key') + expect(townCount).not.toBe(wildCount) + }) + + it('world-renderer reveals fog-of-war even when automap mode is off', () => { + const text = readFileSync(resolve('tests/fixtures/netproto/04-d2gs-idle-60s.d2cap'), 'utf8') + const settingsStore = createSettingsStore() + const session = new OnlineSession({ settingsStore, mapService: sharedMapService }) + session.loadD2CapReplay(text) + session.replayAllD2Cap() + + const minimap = new Minimap() + session.automapView.setMode('off') + + renderSceneSourceFrame({ + source: session.sceneSource, + lightingPreset: 'fullbright', + renderer: { + begin() {}, + draw() {}, + drawSolidQuad() {}, + setLighting() {}, + setPalette() {}, + flush() {}, + getGl() { + return null + }, + } as never, + tileGpuCache: { + loadedPageCount: 1, + ensureActPalette() {}, + getPageHandle() { + return { index: 1, width: 2048, height: 1932 } + }, + } as never, + minimap, + automapView: session.automapView, + }) + + expect(minimap.revealedRoomsCount).toBeGreaterThan(0) + + session.automapView.setMode('level') + renderSceneSourceFrame({ + source: session.sceneSource, + lightingPreset: 'fullbright', + renderer: { + begin() {}, + draw() {}, + drawSolidQuad() {}, + setLighting() {}, + setPalette() {}, + flush() {}, + getGl() { + return null + }, + } as never, + tileGpuCache: { + loadedPageCount: 1, + ensureActPalette() {}, + getPageHandle() { + return { index: 1, width: 2048, height: 1932 } + }, + } as never, + minimap, + automapView: session.automapView, + }) + + expect(minimap.revealedRoomsCount).toBeGreaterThan(0) + }) + }) + + describe('Dimension 2: Viewport Geometry, Dynamic Resizing & Scissor Bounds', () => { + it('maintains valid viewport bounds and scissor clipping across 500 random resolution combinations', () => { + let seed = 4242 + const nextRandom = (): number => { + seed = (seed * 1664525 + 1013904223) >>> 0 + return seed / 4294967296 + } + + for (let i = 0; i < 500; i++) { + const screenW = Math.max(1, Math.floor(nextRandom() * 7680) + 1) + const screenH = Math.max(1, Math.floor(nextRandom() * 4320) + 1) + const leftPanel = nextRandom() > 0.5 + const rightPanel = nextRandom() > 0.5 + const panX = (nextRandom() - 0.5) * 2000 + const panY = (nextRandom() - 0.5) * 2000 + + const vpMinimap = computeAutomapViewport( + screenW, + screenH, + true, + leftPanel, + rightPanel, + panX, + panY, + ) + + expect(vpMinimap.bMinimap).toBe(true) + expect(vpMinimap.divisor).toBe(20) + expect(vpMinimap.width).toBeGreaterThan(0) + expect(vpMinimap.height).toBeGreaterThan(0) + expect(vpMinimap.x).toBeGreaterThanOrEqual(0) + expect(vpMinimap.y).toBeGreaterThanOrEqual(0) + + if (screenW >= 280) { + expect(vpMinimap.width).toBeLessThanOrEqual(screenW) + } + + const vpFull = computeAutomapViewport( + screenW, + screenH, + false, + leftPanel, + rightPanel, + panX, + panY, + ) + + expect(vpFull.bMinimap).toBe(false) + expect(vpFull.divisor).toBe(10) + expect(vpFull.x).toBe(0) + expect(vpFull.y).toBe(0) + expect(vpFull.width).toBe(screenW) + expect(vpFull.height).toBe(screenH) + + const neutralCenterX = Math.trunc(screenW / 2) + const shiftAmount = screenW >> 2 + if (leftPanel && !rightPanel) { + expect(vpFull.centerX).toBe(neutralCenterX + shiftAmount) + } else if (rightPanel && !leftPanel) { + expect(vpFull.centerX).toBe(neutralCenterX - shiftAmount) + } else { + expect(vpFull.centerX).toBe(neutralCenterX) + } + } + }) + + it('enables and disables hardware scissor test around automap sprite rendering', () => { + const minimap = new Minimap() + registerSyntheticAtlases(minimap) + minimap.mode = 'full' + const level = makeSyntheticLevel('test-scissor', 20, 20) + minimap.reveal(level, 4, 4) + + const { renderer, scissors } = createMockRenderer() + minimap.draw( + renderer, + level, + 4, + 4, + { x: 0, y: 0, zoom: 1 }, + { width: 1920, height: 1080 }, + { playerWorldX: 0, playerWorldY: 0, levelId: 1 }, + ) + + expect(scissors.length).toBe(2) + expect(scissors[0]).toEqual({ x: 0, y: 0, width: 1920, height: 1080 }) + expect(scissors[1]).toBeNull() + }) + }) + + describe('Dimension 3: Fade State Cycling & Distance Opacity Mapping', () => { + it('cycles through all 4 discrete fade states and updates alpha values correctly', () => { + const minimap = new Minimap() + + expect(minimap.fadeState).toBe(0) + expect(minimap.fadeAlpha).toBe(AUTOMAP_FADE_ALPHAS[0]) + + minimap.cycleFade() + expect(minimap.fadeState).toBe(1) + expect(minimap.fadeAlpha).toBe(AUTOMAP_FADE_ALPHAS[1]) + + minimap.cycleFade() + expect(minimap.fadeState).toBe(2) + expect(minimap.fadeAlpha).toBe(AUTOMAP_FADE_ALPHAS[2]) + + minimap.cycleFade() + expect(minimap.fadeState).toBe(3) + expect(minimap.fadeAlpha).toBe(AUTOMAP_FADE_ALPHAS[3]) + + minimap.cycleFade() + expect(minimap.fadeState).toBe(0) + expect(minimap.fadeAlpha).toBe(AUTOMAP_FADE_ALPHAS[0]) + }) + + it('applies distance-based alpha factor in Full Automap mode according to radius bands', () => { + const minimap = new Minimap() + registerSyntheticAtlases(minimap) + minimap.mode = 'full' + + const level = makeSyntheticLevel('test-fade-dist', 50, 50) + minimap.reveal(level, 0, 0) + + const { renderer, draws } = createMockRenderer() + minimap.draw( + renderer, + level, + 0, + 0, + { x: 0, y: 0, zoom: 1 }, + { width: 800, height: 600 }, + { playerWorldX: 0, playerWorldY: 0, levelId: 1 }, + ) + + const spriteDraws = draws.filter((d): d is RecordedSpriteDraw => d.kind === 'sprite') + expect(spriteDraws.length).toBeGreaterThan(0) + + for (const draw of spriteDraws) { + expect(draw.tint).toBeDefined() + const alpha = draw.tint![3] + expect(alpha).toBeGreaterThan(0) + expect(alpha).toBeLessThanOrEqual(1.0) + } + }) + }) + + describe('Dimension 4: Dynamic Unit Marker Fuzzing & Palette Verification', () => { + it('strictly excludes hostile monsters, bosses, ancient statues, and dying monsters from the automap', () => { + const minimap = new Minimap() + registerSyntheticAtlases(minimap) + minimap.mode = 'full' + const level = makeSyntheticLevel('test-entities', 50, 50) + minimap.reveal(level, 25, 25) + + const hostileEntities: AutomapEntityMarker[] = [ + { x: 100, y: 100, kind: 'boss', alive: true, classId: 1 }, + { x: 110, y: 110, kind: 'boss', alive: true, classId: 156 }, + { x: 120, y: 120, kind: 'boss', alive: true, classId: 211 }, + { x: 130, y: 130, kind: 'boss', alive: true, classId: 242 }, + { x: 140, y: 140, kind: 'boss', alive: true, classId: 243 }, + { x: 150, y: 150, kind: 'boss', alive: true, classId: 544 }, + { x: 160, y: 160, kind: 'npc', alive: true, classId: 537 }, + { x: 170, y: 170, kind: 'npc', alive: true, classId: 538 }, + { x: 180, y: 180, kind: 'npc', alive: true, classId: 539 }, + { x: 190, y: 190, kind: 'boss', alive: false, classId: 1 }, + ] + + const { renderer } = createMockRenderer() + minimap.draw( + renderer, + level, + 25, + 25, + { x: 0, y: 0, zoom: 1 }, + { width: 800, height: 600 }, + { + playerWorldX: 0, + playerWorldY: 0, + levelId: 1, + entities: hostileEntities, + }, + ) + + expect(minimap.lastRenderStats.crosshairsDrawn.npcs).toBe(0) + }) + + it('fuzzes 1000 unit entities and asserts friendly palette conformity and corpse rendering', () => { + const minimap = new Minimap() + registerSyntheticAtlases(minimap) + minimap.mode = 'full' + const level = makeSyntheticLevel('test-fuzz-1000', 100, 100) + minimap.reveal(level, 50, 50) + + const entities: AutomapEntityMarker[] = [] + let seed = 9999 + const nextRandom = (): number => { + seed = (seed * 1664525 + 1013904223) >>> 0 + return seed / 4294967296 + } + + for (let i = 0; i < 1000; i++) { + const r = nextRandom() + const x = Math.floor((nextRandom() - 0.5) * 800) + const y = Math.floor((nextRandom() - 0.5) * 600) + + if (r < 0.2) { + entities.push({ x, y, kind: 'mercenary', alive: true, name: `Merc ${i}` }) + } else if (r < 0.4) { + entities.push({ x, y, kind: 'corpse', alive: false, name: `Corpse ${i}` }) + } else if (r < 0.6) { + entities.push({ x, y, kind: 'party', alive: true, name: `Ally ${i}` }) + } else if (r < 0.8) { + entities.push({ x, y, kind: 'summon', alive: true, classId: 357 }) + } else { + entities.push({ x, y, kind: 'summon', alive: true, classId: 291 }) + } + } + + const { renderer, draws } = createMockRenderer() + minimap.draw( + renderer, + level, + 50, + 50, + { x: 0, y: 0, zoom: 1 }, + { width: 800, height: 600 }, + { + playerWorldX: 0, + playerWorldY: 0, + levelId: 1, + entities, + }, + ) + + expect(minimap.lastRenderStats.crosshairsDrawn.total).toBeGreaterThan(100) + expect(minimap.lastRenderStats.crosshairsDrawn.mercenaries).toBeGreaterThan(0) + + const quadDraws = draws.filter((d): d is RecordedQuadDraw => d.kind === 'quad') + for (const quad of quadDraws) { + for (const channel of quad.color) { + expect(channel).toBeGreaterThanOrEqual(0) + expect(channel).toBeLessThanOrEqual(1.0001) + } + } + }) + + it('renders town NPCs as npc entityDots in AutomapView', () => { + sharedMapService.loadAct({ act: 0, seed: 0x12345, areaId: 1 }) + const automapView = new AutomapView({ mapService: sharedMapService, initialMode: 'level' }) + + const townNpcs: (RenderableUnitInput & { isNpc?: boolean })[] = [ + { kind: 'monster', id: 101, classId: 146, x: 10, y: 10, isNpc: true, name: 'Deckard Cain' }, + { kind: 'monster', id: 102, classId: 147, x: 12, y: 12, isNpc: true, name: 'Gheed' }, + { kind: 'monster', id: 103, classId: 148, x: 14, y: 14, isNpc: true, name: 'Akara' }, + { kind: 'monster', id: 104, classId: 150, x: 16, y: 16, isNpc: true, name: 'Kashya' }, + { kind: 'monster', id: 105, classId: 154, x: 18, y: 18, isNpc: true, name: 'Charsi' }, + { kind: 'monster', id: 106, classId: 155, x: 20, y: 20, isNpc: true, name: 'Warriv' }, + ] + + for (const npc of townNpcs) { + const mk = getMonsterKindByClassId(npc.classId) + expect(mk).toBeDefined() + expect(mk!.interact).toBe(false) + expect(NPC_HCIDX_TO_DESCRIPTOR_ID.has(npc.classId) || NPC_HCIDX_TO_DESCRIPTOR_ID[npc.classId]).toBeTruthy() + } + + const snapshot = automapView.buildSnapshot(townNpcs) + + const npcsInSnapshot = snapshot.entityDots.filter((d: MinimapEntityDot) => d.kind === 'npc') + expect(npcsInSnapshot.length).toBe(townNpcs.length) + }) + }) + + describe('Dimension 5: Grounded Spawn Verification & Topological Robustness', () => { + it('verifies grounded spawn across diverse game areas avoids naive geometric centroid', () => { + const testAreaIds = [1, 2, 3, 4, 8] + for (const areaId of testAreaIds) { + const levelView = sharedMapService.loadAct({ act: 0, seed: 0x12345, areaId })! + + expect(levelView.spawnX).toBeGreaterThanOrEqual(0) + expect(levelView.spawnX).toBeLessThan(levelView.cellsX) + expect(levelView.spawnY).toBeGreaterThanOrEqual(0) + expect(levelView.spawnY).toBeLessThan(levelView.cellsY) + + const geometricCenterX = Math.floor(levelView.cellsX / 2) + const geometricCenterY = Math.floor(levelView.cellsY / 2) + const isGeometricCentroid = + levelView.spawnX === geometricCenterX && levelView.spawnY === geometricCenterY + expect(isGeometricCentroid).toBe(false) + + let walkableSubCount = 0 + for (let dy = 0; dy < 5; dy++) { + for (let dx = 0; dx < 5; dx++) { + const idx = + (levelView.spawnY * 5 + dy) * levelView.gridWidth + + (levelView.spawnX * 5 + dx) + if (levelView.walkable[idx] === 1) { + walkableSubCount++ + } + } + } + expect(walkableSubCount).toBeGreaterThan(0) + } + }) + }) +}) diff --git a/tests/client/challenger-hud-belts-exp-stress.test.ts b/tests/client/challenger-hud-belts-exp-stress.test.ts new file mode 100644 index 0000000..c49551e --- /dev/null +++ b/tests/client/challenger-hud-belts-exp-stress.test.ts @@ -0,0 +1,682 @@ +/** + * Adversarial Stress Test Suite for Batch 2 Gate (Challenger 2). + * + * Rigorously stress-tests: + * 1. Belt Dynamic Resizing & Potion Slotting: + * - Rapid equip/unequip cycles across belt capacities (1 to 4 rows). + * - Potion overflow retention, gravity shifting, and non-corruption. + * - Slot indexing bounds fuzzing and left-click pick-to-cursor vs right-click auto-drink separation. + * - Scroll (tsc, isc) manual placement, two-pass auto-slotting, and non-beltable rejection. + * 2. Experience Table Parity: + * - All 7 classes (Ama, Sor, Nec, Pal, Bar, Dru, Ass) across levels 1..99 against independent raw oracle. + * - Exact boundary and level cap invariants (lvl 1, 98->99, lvl 99 cap, invalid levels). + * 3. Mini-Panel & Death Screen: + * - 8-button (MP) vs 7-button (SP) mode toggling, boundaries, and button hit dispatching. + * - Death screen DC6 sprite centering across diverse aspect ratios and resolutions. + */ + +import { describe, expect, it, vi } from 'vitest' +import { + BeltModel, + BELT_ROWS, + BELT_COLS, + BELT_BASE_X, + BELT_BASE_Y, + BELT_PITCH_X, + BELT_PITCH_Y, + BELT_CELL_SIZE, + BELT_POTION_CATALOG, + getBeltRowsForCode, + getBeltSlotBox, + itemToBeltPotion, + areBeltPotionsCompatible, + type BeltPotion, +} from '../../src/common/items/belt-model.ts' +import { + ControlBarHud, + MULTIPLAYER_MINIPANEL_ITEMS, + SINGLEPLAYER_MINIPANEL_ITEMS, + MINIPANEL_BOUNDS, + MINIPANEL_SINGLE_BOUNDS, + getMiniPanelTooltip, +} from '../../src/client/ui/control-bar.ts' +import { + D2_EXPERIENCE_TABLE_99, + getExperienceTable, + getExperienceForLevel, + getNextLevelExperience, + EXPERIENCE_TXT_RAW, + EXPERIENCE_TABLE_BY_CLASS, +} from '../../src/common/skills/formulas.ts' +import { HudManager } from '../../src/client/ui/hud-manager.ts' +import { HudModel } from '../../src/client/ui-model/hud-model.ts' +import { ClientWorld } from '../../src/client/world/client-world.ts' +import { getViewportProfile, type ViewportResolution } from '../../src/client/view/viewport-profile.ts' +import type { ClientCommand, ItemData } from '../../src/netproto/index.ts' + +// --- Helpers for Test Harness --- + +function setupHudEnvironment() { + const world = new ClientWorld() + const hud = new HudManager() + const commands: ClientCommand[] = [] + const model = new HudModel({ world, hudManager: hud, onCommand: (c) => commands.push(c) }) + return { world, hud, model, commands } +} + +function makeTestPotion(code: string, id: string | number): BeltPotion { + const pot = itemToBeltPotion({ code, id: String(id) }) + if (!pot) throw new Error(`Failed to create test potion for code ${code}`) + return pot +} + +describe('Challenger 2 Empirical Stress Test Harness — HUD, Belts & Experience', () => { + // ========================================================================= + // SUITE 1: Belt Dynamic Resizing & Potion Slotting Stress Harness + // ========================================================================= + describe('Suite 1: Belt Dynamic Resizing & Potion Slotting', () => { + it('1.1 Rapid equip/unequip fuzzing across 1,000 cycles preserves state without memory corruption', () => { + const belt = new BeltModel() + const beltCodes = [ + null, // 1 row (default unequipped) + 'lbl', // 2 rows (Sash) + 'vbl', // 2 rows (Light Belt) + 'mbl', // 3 rows (Belt) + 'tbl', // 3 rows (Heavy Belt) + 'hbl', // 4 rows (Plated Belt) + 'zlb', // 4 rows (Demonhide Sash) + 'zvb', // 4 rows (Sharkskin Belt) + 'zmb', // 4 rows (Mesh Belt) + 'ztb', // 4 rows (Battle Belt) + 'zhb', // 4 rows (War Belt) + 'ulc', // 4 rows (Spiderweb Sash) + 'umc', // 4 rows (Mithril Coil) + 'uhc', // 4 rows (Colossus Girdle) + ] + + // Populate initial 4x4 belt + belt.setEquippedBelt('hbl') + expect(belt.maxRows).toBe(4) + for (let r = 0; r < 4; r++) { + for (let c = 0; c < 4; c++) { + belt.setSlot(c, r, makeTestPotion('hp1', `pot-init-${r}-${c}`)) + } + } + expect(belt.countTotalPotions()).toBe(16) + + // Stress loop: 1,000 rapid switches + for (let cycle = 0; cycle < 1000; cycle++) { + const code = beltCodes[cycle % beltCodes.length] + belt.setEquippedBelt(code) + const expectedRows = getBeltRowsForCode(code) + expect(belt.maxRows).toBe(expectedRows) + expect(belt.grid.length).toBe(4) + for (let r = 0; r < 4; r++) { + expect(belt.grid[r]!.length).toBe(4) + } + + // Active rows must follow expansion state + belt.lockedOpen = false + belt.hovered = false + expect(belt.activeRows).toBe(1) + expect(belt.isExpanded).toBe(false) + + belt.lockedOpen = true + if (expectedRows > 1) { + expect(belt.isExpanded).toBe(true) + expect(belt.activeRows).toBe(expectedRows) + } else { + expect(belt.isExpanded).toBe(false) + expect(belt.activeRows).toBe(1) + } + } + + // After 1,000 cycles, re-equipping a 4-row belt must reveal all 16 original potions intact + belt.setEquippedBelt('hbl') + expect(belt.maxRows).toBe(4) + expect(belt.countTotalPotions()).toBe(16) + for (let r = 0; r < 4; r++) { + for (let c = 0; c < 4; c++) { + const pot = belt.getSlot(c, r) + expect(pot).not.toBeNull() + expect(pot?.id).toBe(`pot-init-${r}-${c}`) + } + } + }) + + it('1.2 Potion overflow retention and gravity drain when unequipping from 4-row to 1-row', () => { + const belt = new BeltModel() + belt.setEquippedBelt('hbl') // 4 rows + + // Put distinct potion codes in column 0: row 0 = hp1, row 1 = hp2, row 2 = hp3, row 3 = hp4 + belt.setSlot(0, 0, makeTestPotion('hp1', 'pot-0')) + belt.setSlot(0, 1, makeTestPotion('hp2', 'pot-1')) + belt.setSlot(0, 2, makeTestPotion('hp3', 'pot-2')) + belt.setSlot(0, 3, makeTestPotion('hp4', 'pot-3')) + + // Unequip belt -> capacity drops to 1 row + belt.setEquippedBelt(null) + expect(belt.maxRows).toBe(1) + + // Only row 0 can be directly consumed via useSlot(0, 0) + // useSlot(0, 1) must be rejected because row 1 >= maxRows + expect(belt.useSlot(0, 1)).toBeNull() + expect(belt.useSlot(0, 2)).toBeNull() + expect(belt.useSlot(0, 3)).toBeNull() + + // Consume row 0: consumes 'hp1' + const consumed1 = belt.useSlot(0, 0) + expect(consumed1?.code).toBe('hp1') + + // Gravity shift: row 1 ('hp2') has shifted down into row 0! + expect(belt.getSlot(0, 0)?.code).toBe('hp2') + + // Consume row 0 again: consumes 'hp2' + const consumed2 = belt.useSlot(0, 0) + expect(consumed2?.code).toBe('hp2') + + // Gravity shift: row 2 ('hp3') has shifted down into row 0! + expect(belt.getSlot(0, 0)?.code).toBe('hp3') + + // Consume row 0 again: consumes 'hp3' + const consumed3 = belt.useSlot(0, 0) + expect(consumed3?.code).toBe('hp3') + + // Gravity shift: row 3 ('hp4') has shifted down into row 0! + expect(belt.getSlot(0, 0)?.code).toBe('hp4') + + // Consume row 0 again: consumes 'hp4' + const consumed4 = belt.useSlot(0, 0) + expect(consumed4?.code).toBe('hp4') + + // Now column 0 is completely empty + expect(belt.getSlot(0, 0)).toBeNull() + expect(belt.useSlot(0, 0)).toBeNull() + }) + + it('1.3 Fuzzes slot indexing across 5,000 bounds and boundary inputs with zero unhandled exceptions', () => { + const belt = new BeltModel() + belt.setEquippedBelt('hbl') + const potion = makeTestPotion('hp1', 'fuzz-pot') + + // Explicit boundary coordinates + const testCoordinates: [number, number][] = [ + [-1000, 0], + [0, -1000], + [-1, 0], + [0, -1], + [-1, -1], + [4, 0], + [0, 4], + [4, 4], + [15, 0], + [0, 15], + [16, 16], + [1000, 1000], + [0, 0], + [3, 0], + [0, 3], + [3, 3], + ] + + for (const [col, row] of testCoordinates) { + const isColValid = col >= 0 && col < BELT_COLS + const isRowValid = row >= 0 && row < BELT_ROWS + + if (isColValid && isRowValid) { + expect(belt.setSlot(col, row, potion)).toBe(true) + expect(belt.getSlot(col, row)).toStrictEqual(potion) + expect(belt.setSlot(col, row, null)).toBe(true) + expect(belt.getSlot(col, row)).toBeNull() + } else { + expect(belt.setSlot(col, row, potion)).toBe(false) + expect(belt.getSlot(col, row)).toBeNull() + expect(belt.useSlot(col, row)).toBeNull() + expect(belt.placePotion(potion, col, row)).toBe(potion) + } + } + + // Fuzz 5,000 randomized integers + for (let i = 0; i < 5000; i++) { + const randCol = Math.floor(Math.random() * 200) - 100 + const randRow = Math.floor(Math.random() * 200) - 100 + + const isColValid = randCol >= 0 && randCol < BELT_COLS + const isRowValid = randRow >= 0 && randRow < belt.maxRows + + if (isColValid && isRowValid) { + expect(belt.setSlot(randCol, randRow, potion)).toBe(true) + expect(belt.getSlot(randCol, randRow)?.code).toBe(potion.code) + belt.setSlot(randCol, randRow, null) + } else { + expect(belt.setSlot(randCol, randRow, potion)).toBe(false) + expect(belt.placePotion(potion, randCol, randRow)).toBe(potion) + } + } + }) + + it('1.4 Left-click interaction picks up potion to cursor without auto-drinking, right-click drinks', () => { + const { world, hud, model, commands } = setupHudEnvironment() + hud.commandSink = model + + // Put an online item into belt slot 0 + const onlinePotion = { + id: 777, + itemId: 777, + opcode: 0x9c, + code: 'hp1', + rawCode: 'hp1 ', + action: 4, + category: 1, + location: 2, // belt + container: 'belt', + gridX: 0, + gridY: 0, + bodyLoc: 0, + equippedSlot: 0, + storePage: 1, + storagePage: 1, + worldX: 0, + worldY: 0, + } as unknown as ItemData + world.apply({ type: 'ItemEvent', action: 4, item: onlinePotion, container: 'belt' }) + model.syncFromWorld() + + // Verify the belt slot is populated in HUD + const beltPotion = hud.belt.getSlot(0, 0) + expect(beltPotion).not.toBeNull() + expect(beltPotion?.code).toBe('hp1') + + // Get slot 0 bounding box coordinates + const box = getBeltSlotBox(0, 0) + const clickX = box.x + 10 + const clickY = box.y + 10 + + // Spy on drinkBeltSlot to guarantee left-click NEVER drinks + const drinkSpy = vi.spyOn(hud, 'drinkBeltSlot') + + // --- Interaction 1: Left-click on occupied slot with empty cursor --- + hud.inventory.cursorItem = null + hud.handleHudMouseDown({ + button: 0, // Left click + shiftKey: false, + clientX: clickX, + clientY: clickY, + preventDefault: () => {}, + stopPropagation: () => {}, + } as unknown as MouseEvent) + + // Must NOT drink + expect(drinkSpy).not.toHaveBeenCalled() + // Must emit BeltRemove (0x24) to pick up the potion to cursor + expect(commands).toHaveLength(1) + expect(commands[0]).toEqual({ type: 'BeltRemove', itemId: 777 }) + + commands.length = 0 + drinkSpy.mockClear() + + // --- Interaction 2: Left-click with cursor item onto belt slot --- + const cursorItem = { id: 888, code: 'mp1' } as any + hud.inventory.cursorItem = cursorItem + hud.handleHudMouseDown({ + button: 0, // Left click + shiftKey: false, + clientX: clickX, + clientY: clickY, + preventDefault: () => {}, + stopPropagation: () => {}, + } as unknown as MouseEvent) + + expect(drinkSpy).not.toHaveBeenCalled() + // Occupied slot + cursor -> BeltSwap (0x25) + expect(commands).toHaveLength(1) + expect(commands[0]).toEqual({ type: 'BeltSwap', cursorItemId: 888, beltItemId: 777 }) + + commands.length = 0 + drinkSpy.mockClear() + + // --- Interaction 3: Right-click on belt slot -> must DRINK potion --- + hud.handleHudMouseDown({ + button: 2, // Right click + shiftKey: false, + clientX: clickX, + clientY: clickY, + preventDefault: () => {}, + stopPropagation: () => {}, + } as unknown as MouseEvent) + + expect(drinkSpy).toHaveBeenCalledWith(0, false) + expect(commands).toHaveLength(1) + expect(commands[0]).toEqual({ type: 'BeltUse', itemId: 777, shift: false }) + + commands.length = 0 + drinkSpy.mockClear() + + // --- Interaction 4: Press key '1' -> must DRINK slot 0 --- + hud.handleKeyDown({ + key: '1', + shiftKey: false, + preventDefault: () => {}, + } as unknown as KeyboardEvent) + + expect(drinkSpy).toHaveBeenCalledWith(0, false) + expect(commands).toHaveLength(1) + expect(commands[0]).toEqual({ type: 'BeltUse', itemId: 777, shift: false }) + }) + + it('1.5 Scrolls (tsc, isc) auto-slotting, column segregation and non-beltable rejection', () => { + const belt = new BeltModel() + belt.setEquippedBelt('hbl') // 4 rows + + const tsc1 = itemToBeltPotion({ code: 'tsc', id: 'tp-1' })! + const tsc2 = itemToBeltPotion({ code: 'tsc', id: 'tp-2' })! + const isc1 = itemToBeltPotion({ code: 'isc', id: 'id-1' })! + const isc2 = itemToBeltPotion({ code: 'isc', id: 'id-2' })! + const hp1 = itemToBeltPotion({ code: 'hp1', id: 'hp-1' })! + + expect(tsc1.kind).toBe('scroll') + expect(tsc1.invFile).toBe('invbsc') + expect(isc1.kind).toBe('scroll') + expect(isc1.invFile).toBe('invrsc') + + // Step A: Auto-slot first tsc -> goes to col 0, row 0 + const res1 = belt.autoPlacePotionSlot(tsc1) + expect(res1).toEqual({ col: 0, row: 0, potion: tsc1 }) + + // Step B: Auto-slot second tsc -> matches col 0 family, goes to col 0, row 1 + const res2 = belt.autoPlacePotionSlot(tsc2) + expect(res2).toEqual({ col: 0, row: 1, potion: tsc2 }) + + // Step C: Auto-slot isc -> does NOT match col 0 (tsc != isc), goes to col 1, row 0 + const res3 = belt.autoPlacePotionSlot(isc1) + expect(res3).toEqual({ col: 1, row: 0, potion: isc1 }) + + // Step D: Auto-slot second isc -> matches col 1 family, goes to col 1, row 1 + const res4 = belt.autoPlacePotionSlot(isc2) + expect(res4).toEqual({ col: 1, row: 1, potion: isc2 }) + + // Step E: Auto-slot healing potion -> goes to col 2, row 0 + const res5 = belt.autoPlacePotionSlot(hp1) + expect(res5).toEqual({ col: 2, row: 0, potion: hp1 }) + + // Verify slot placements + expect(belt.getSlot(0, 0)?.code).toBe('tsc') + expect(belt.getSlot(0, 1)?.code).toBe('tsc') + expect(belt.getSlot(1, 0)?.code).toBe('isc') + expect(belt.getSlot(1, 1)?.code).toBe('isc') + expect(belt.getSlot(2, 0)?.code).toBe('hp1') + + // Step F: Verify strict rejection of non-beltable items + const invalidItems = [ + { code: 'gpm' }, // Strangling Gas Potion (throwing weapon) + { code: 'opm' }, // Fulminating Potion (throwing weapon) + { code: 'gpl' }, // Choking Gas Potion + { code: 'opl' }, // Exploding Potion + { code: 'gps' }, // Rancid Gas Potion + { code: 'ops' }, // Oil Potion + { code: 'axe', kind: 'weapon' }, + { code: 'swor', kind: 'weapon' }, + { code: 'shld', kind: 'armor' }, + { code: 'key' }, + { code: 'r01' }, // El Rune + { code: 'gcv' }, // Chipped Amethyst + ] + for (const item of invalidItems) { + expect(itemToBeltPotion(item)).toBeNull() + expect(belt.autoPlacePotion(item)).toBe(false) + expect(belt.setSlot(3, 0, item)).toBe(false) + } + }) + }) + + // ========================================================================= + // SUITE 2: Experience Table Parity Across All 7 Classes × 99 Levels + // ========================================================================= + describe('Suite 2: Experience Table Parity Across All 7 Classes × 99 Levels', () => { + // Independent clean-room oracle parser directly parsing the raw TS text constant + function buildExperienceOracle(): Record { + const lines = EXPERIENCE_TXT_RAW.split(/\r?\n/).filter((l) => l.trim().length > 0) + const header = lines[0]!.split('\t').map((h) => h.trim().toLowerCase()) + const oracle: Record = { + amazon: new Array(100).fill(0), + sorceress: new Array(100).fill(0), + necromancer: new Array(100).fill(0), + paladin: new Array(100).fill(0), + barbarian: new Array(100).fill(0), + druid: new Array(100).fill(0), + assassin: new Array(100).fill(0), + } + + for (let i = 1; i < lines.length; i++) { + const parts = lines[i]!.split('\t') + const lvlStr = parts[0]!.trim() + if (lvlStr === 'MaxLvl') continue + const rowLvl = parseInt(lvlStr, 10) + if (Number.isNaN(rowLvl)) continue + const charLvl = rowLvl + 1 + if (charLvl >= 1 && charLvl <= 99) { + for (const cls of Object.keys(oracle)) { + const colIdx = header.indexOf(cls) + if (colIdx !== -1) { + oracle[cls]![charLvl] = parseInt(parts[colIdx] ?? '0', 10) + } + } + } + } + return oracle + } + + const oracle = buildExperienceOracle() + + const classAliases: [string, string][] = [ + ['amazon', 'Ama'], + ['sorceress', 'Sor'], + ['necromancer', 'Nec'], + ['paladin', 'Pal'], + ['barbarian', 'Bar'], + ['druid', 'Dru'], + ['assassin', 'Ass'], + ] + + it('2.1 Verifies all 7 classes across all 99 levels against independent raw oracle (693 assertions)', () => { + for (const [canonicalName, alias] of classAliases) { + const expectedTable = oracle[canonicalName]! + expect(expectedTable).toBeDefined() + expect(expectedTable.length).toBe(100) + + const tableFromFn = getExperienceTable(canonicalName) + const tableFromAlias = getExperienceTable(alias) + + for (let lvl = 1; lvl <= 99; lvl++) { + const expectedXp = expectedTable[lvl]! + expect(tableFromFn[lvl]).toBe(expectedXp) + expect(tableFromAlias[lvl]).toBe(expectedXp) + expect(getExperienceForLevel(lvl, canonicalName)).toBe(expectedXp) + expect(getExperienceForLevel(lvl, alias)).toBe(expectedXp) + } + } + }) + + it('2.2 Boundary conditions, exact deltas and level cap enforcement', () => { + // Level 1 baseline + expect(getExperienceForLevel(1)).toBe(0) + expect(getNextLevelExperience(1)).toBe(500) + + // Level 2 baseline + expect(getExperienceForLevel(2)).toBe(500) + expect(getNextLevelExperience(2)).toBe(1500) + + // Key milestone levels + expect(getExperienceForLevel(17)).toBe(275207) + expect(getExperienceForLevel(30)).toBe(4663553) + expect(getExperienceForLevel(90)).toBe(1618470619) + + // Level 98 -> 99 delta + const xp98 = getExperienceForLevel(98) + const xp99 = getExperienceForLevel(99) + expect(xp98).toBe(3229426756) + expect(xp99).toBe(3520485254) + expect(xp99 - xp98).toBe(291058498) // Exact 1.13c delta + + // Level 98 next level experience + expect(getNextLevelExperience(98)).toBe(3520485254) + + // Level 99 CAP: next level experience remains 3,520,485,254 and does not overflow or become NaN + expect(getNextLevelExperience(99)).toBe(3520485254) + expect(getNextLevelExperience(100)).toBe(3520485254) + + // Degenerate / invalid inputs + expect(getExperienceForLevel(0)).toBe(0) + expect(getExperienceForLevel(-10)).toBe(0) + expect(getExperienceForLevel(100)).toBe(3520485254) + expect(getExperienceForLevel(999)).toBe(3520485254) + }) + }) + + // ========================================================================= + // SUITE 3: Mini-Panel & Death Screen Layout & Responsiveness + // ========================================================================= + describe('Suite 3: Mini-Panel & Death Screen', () => { + it('3.1 Multiplayer (8 buttons) vs Single-Player (7 buttons) rapid toggle & click dispatch', () => { + const hud = new ControlBarHud() + const onMiniPanelAction = vi.fn() + const dummyState = { + level: 80, + xp: 1000000, + currentLevelXp: 0, + nextLevelXp: 2000000, + stamina: 100, + maxStamina: 100, + isRunning: false, + unspentStatPoints: 0, + unspentSkillPoints: 0, + } + + // 500 rapid toggles between MP and SP + for (let i = 0; i < 500; i++) { + hud.isMultiplayer = i % 2 === 0 + hud.miniPanelOpen = true + + if (hud.isMultiplayer) { + expect(hud.currentMiniPanelItems.length).toBe(8) + expect(hud.currentMiniPanelBounds).toEqual(MINIPANEL_BOUNDS) + expect(hud.currentButtonStartX).toBe(320) + expect(hud.currentMiniPanelItems.map((m) => m.action)).toContain('party') + } else { + expect(hud.currentMiniPanelItems.length).toBe(7) + expect(hud.currentMiniPanelBounds).toEqual(MINIPANEL_SINGLE_BOUNDS) + expect(hud.currentButtonStartX).toBe(330) + expect(hud.currentMiniPanelItems.map((m) => m.action)).not.toContain('party') + } + } + + // Test all 8 button hit dispatches in Multiplayer mode + hud.isMultiplayer = true + hud.miniPanelOpen = true + const mpActions = MULTIPLAYER_MINIPANEL_ITEMS.map((m) => m.action) + for (let idx = 0; idx < mpActions.length; idx++) { + onMiniPanelAction.mockClear() + const clickX = 320 + idx * 20 + 10 + const clickY = 535 + hud.handleMouseMove(clickX, clickY) + expect(hud.hoveredElement).toBe(mpActions[idx]) + hud.handleClick(clickX, clickY, dummyState, { onToggleRun: vi.fn(), onMiniPanelAction }) + expect(onMiniPanelAction).toHaveBeenCalledWith(mpActions[idx]) + } + + // Test all 7 button hit dispatches in Single-Player mode + hud.isMultiplayer = false + hud.miniPanelOpen = true + const spActions = SINGLEPLAYER_MINIPANEL_ITEMS.map((m) => m.action) + for (let idx = 0; idx < spActions.length; idx++) { + onMiniPanelAction.mockClear() + const clickX = 330 + idx * 20 + 10 + const clickY = 535 + hud.handleMouseMove(clickX, clickY) + expect(hud.hoveredElement).toBe(spActions[idx]) + hud.handleClick(clickX, clickY, dummyState, { onToggleRun: vi.fn(), onMiniPanelAction }) + expect(onMiniPanelAction).toHaveBeenCalledWith(spActions[idx]) + } + }) + + it('3.2 Death screen DC6 sprite positioning across diverse resolutions and aspect ratios', () => { + const resolutions: ViewportResolution[] = [ + '800x600', // 4:3 standard + '1024x768', // 4:3 + '1068x600', // wide + '1280x720', // 16:9 widescreen + ] + + const { hud } = setupHudEnvironment() + hud.isDead = true + + for (const res of resolutions) { + const profile = getViewportProfile(res) + hud.setViewportProfile(profile) + + const expectedCenterX = Math.round(profile.width / 2) + const expectedCenterY = Math.round(profile.height / 2) + const expectedYHardcore = expectedCenterY - 94 + const expectedYInst = expectedCenterY - 46 + + // Mock canvas context + const drawImageMock = vi.fn() + const mockCtx = { + save: vi.fn(), + restore: vi.fn(), + drawImage: drawImageMock, + fillRect: vi.fn(), + fillText: vi.fn(), + } as any + + hud.ctx = mockCtx + + // Mock death DC6 images with distinct widths and heights + const mockHardcoreImg = { width: 340, height: 40 } as HTMLImageElement + const mockInstImg = { width: 420, height: 24 } as HTMLImageElement + ;(hud as any).images.set('deathHardcore', mockHardcoreImg) + ;(hud as any).images.set('deathInst', mockInstImg) + + hud.render() + + // Verify deathHardcore image drawn centered at (centerX - width/2, expectedYHardcore) + expect(drawImageMock).toHaveBeenCalledWith( + mockHardcoreImg, + expectedCenterX - Math.round(mockHardcoreImg.width / 2), + expectedYHardcore, + ) + + // Verify deathInst image drawn centered at (centerX - width/2, expectedYInst) + expect(drawImageMock).toHaveBeenCalledWith( + mockInstImg, + expectedCenterX - Math.round(mockInstImg.width / 2), + expectedYInst, + ) + + // Test fallback text rendering when images are absent + ;(hud as any).images.delete('deathHardcore') + ;(hud as any).images.delete('deathInst') + const fontSpy = vi.spyOn(hud.font, 'drawText') + + hud.render() + + expect(fontSpy).toHaveBeenCalledWith( + mockCtx, + 'YOU HAVE DIED', + expectedCenterX, + expectedYHardcore + 18, + expect.objectContaining({ align: 'center', color: 'red' }), + ) + expect(fontSpy).toHaveBeenCalledWith( + mockCtx, + 'PRESS ESC TO CONTINUE.', + expectedCenterX, + expectedYInst + 18, + expect.objectContaining({ align: 'center', color: 'gold' }), + ) + + fontSpy.mockRestore() + } + }) + }) +}) diff --git a/tests/client/player-anim-modes-544.test.ts b/tests/client/player-anim-modes-544.test.ts index 1f215e7..4877804 100644 --- a/tests/client/player-anim-modes-544.test.ts +++ b/tests/client/player-anim-modes-544.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, it } from 'vitest' +import { beforeAll, describe, expect, it } from 'vitest' import { AutomapView } from '../../src/client/automap/automap-view.ts' +import { MapService } from '../../src/client/map/map-service.ts' import { clearMissingArtEntries, computeAnimDataFrameIndex, @@ -12,6 +13,11 @@ import { ClientWorldSceneSource, type ClientSceneWorldAdapter } from '../../src/ import { decode0x0DPlayerStop, decode0x0FPlayerMove, decode0x10PlayerMoveToTarget } from '../../src/netproto/d2gs/s2c/movement.ts' import { PLR_MODES } from '../../src/common/units/plr-mode.ts' +let sharedMapService: MapService +beforeAll(async () => { + sharedMapService = await MapService.fromPackedDir('public/act-packs/tiles') +}) + function makeTaggedCharArt(): LoadedCharacter { const modes: Array<{ key: string; group: number; frames: number; speed: number; loop: boolean }> = [ { key: 'dt', group: 0, frames: 4, speed: 256, loop: false }, @@ -106,8 +112,8 @@ describe('Issue #544: 1.13c Player Animation Modes (TN/TW/RN/DD), Per-Unit Isola expect(remotePlayer.mode).toBe(5) // PLRMODE_TOWNNEUTRAL (TN) // Render scene frame and inspect drawn sprite tags for both players - const automap = new AutomapView({ initialMode: 'level' }) - const levelView = automap.setContext(0x11223344, 0, 1) + const automap = new AutomapView({ mapService: sharedMapService, initialMode: 'level' }) + const levelView = automap.setContext(0x11223344, 0, 1)! const baseX = levelView.subOriginX + 50 const baseY = levelView.subOriginY + 50 @@ -362,8 +368,8 @@ describe('Issue #544: 1.13c Player Animation Modes (TN/TW/RN/DD), Per-Unit Isola // Fail-fast diagnostic when clip speed metadata is missing clearMissingArtEntries() - const automap = new AutomapView({ initialMode: 'level' }) - const levelView = automap.setContext(0x11223344, 0, 1) + const automap = new AutomapView({ mapService: sharedMapService, initialMode: 'level' }) + const levelView = automap.setContext(0x11223344, 0, 1)! const baseX = levelView.subOriginX + 50 const baseY = levelView.subOriginY + 50 const source = new ClientWorldSceneSource( diff --git a/tests/client/settings-toolbar-view.test.ts b/tests/client/settings-toolbar-view.test.ts index 35ad6e6..1e15d13 100644 --- a/tests/client/settings-toolbar-view.test.ts +++ b/tests/client/settings-toolbar-view.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { beforeAll, describe, expect, it, vi } from 'vitest' import { HttpRangeMpqSource, InMemoryChunkCache, @@ -80,6 +80,11 @@ import { HudManager, } from '../../src/client/ui/hud-manager.ts' +let sharedMapService: MapService +beforeAll(async () => { + sharedMapService = await MapService.fromPackedDir('public/act-packs/tiles') +}) + class MemoryStorage implements StorageLike { readonly data = new Map() @@ -421,7 +426,7 @@ describe('Dual Viewport Profiles, HudManager Anchoring & Coordinate Transforms ( describe('SceneSource, UnitDrawables, Missiles/Overlays & LightingInputs (F15, F17)', () => { it('normalizes D2GS world sub-tiles by LevelView.subOriginX/Y and submits tile + unit quads to SpriteRenderer', () => { - const automap = new AutomapView({ initialMode: 'level' }) + const automap = new AutomapView({ mapService: sharedMapService, initialMode: 'level' }) const levelView = automap.setContext(0x11223344, 0, 1) const localSubX = Math.floor((levelView.cellsX * 5) / 2) const localSubY = Math.floor((levelView.cellsY * 5) / 2) @@ -535,6 +540,7 @@ describe('SceneSource, UnitDrawables, Missiles/Overlays & LightingInputs (F15, F describe('AutomapView Reveal Modes: off / level / act (F17)', () => { it('switches cleanly between off (server rooms only), level (all current area rooms), and act (multi-level Act rooms)', () => { const automap = new AutomapView({ + mapService: sharedMapService, initialMode: 'off', actRevealLevelIds: { 1: [1, 2, 3], @@ -893,7 +899,7 @@ describe('Issue #540 R2 & R3: EntityArtCache, Object/Ground Item/Animated Tile R }, } - const automap = new AutomapView({ initialMode: 'level' }) + const automap = new AutomapView({ mapService: sharedMapService, initialMode: 'level' }) const levelView = automap.setContext(0x11223344, 0, 1) const worldSubX = levelView.subOriginX + 50 const worldSubY = levelView.subOriginY + 50 @@ -1000,7 +1006,7 @@ describe('Issue #540 R2 & R3: EntityArtCache, Object/Ground Item/Animated Tile R it('cycles animatedPlacements frames in drawTileQuad using sceneTick', () => { const p0 = { act: 1, page: 0, x: 10, y: 20, w: 160, h: 80, dx: -80, dy: -40 } const p1 = { act: 1, page: 0, x: 200, y: 20, w: 160, h: 80, dx: -80, dy: -40 } - const automap = new AutomapView({ initialMode: 'level' }) + const automap = new AutomapView({ mapService: sharedMapService, initialMode: 'level' }) const levelView = automap.setContext(0x11223344, 0, 1) const localSubX = Math.floor((levelView.cellsX * 5) / 2) const localSubY = Math.floor((levelView.cellsY * 5) / 2)