Fix automap boundaries, unhandled setContext, town NPC dots, and background exploration (Batch 2 Gate Remediation)
This commit is contained in:
parent
036d36bccb
commit
11cd616847
|
|
@ -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,10 +188,8 @@ export class AutomapView {
|
|||
if (seedChanged) {
|
||||
this.levelViews.clear()
|
||||
this.serverRevealedRooms.clear()
|
||||
if (!this.mapService) {
|
||||
this.mapService = tryLoadPackedBundleNodeSync()
|
||||
}
|
||||
if (this.mapService) {
|
||||
if (this.mapService && typeof (this.mapService as Partial<MapService>).loadAct === 'function') {
|
||||
try {
|
||||
const loaded = this.mapService.loadAct({
|
||||
act: this.currentActId - 1,
|
||||
seed: this.mapSeed,
|
||||
|
|
@ -271,12 +200,15 @@ export class AutomapView {
|
|||
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 {
|
||||
if (this.mapService) {
|
||||
this.ensureLevelView(areaId)
|
||||
}
|
||||
this.serverRevealedRooms.set(makeRoomKey(areaId, roomX, roomY), {
|
||||
areaId,
|
||||
roomX,
|
||||
|
|
@ -338,18 +272,19 @@ 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`,
|
||||
)
|
||||
}
|
||||
if (this.mapService.getActiveAct() !== getActForArea(areaId) - 1) {
|
||||
try {
|
||||
if (typeof (this.mapService as Partial<MapService>).loadAct === 'function') {
|
||||
const activeAct =
|
||||
typeof (this.mapService as Partial<MapService>).getActiveAct === 'function'
|
||||
? this.mapService.getActiveAct()
|
||||
: null
|
||||
if (activeAct !== getActForArea(areaId) - 1) {
|
||||
this.mapService.loadAct({
|
||||
act: getActForArea(areaId) - 1,
|
||||
seed: this.mapSeed,
|
||||
|
|
@ -358,20 +293,26 @@ export class AutomapView {
|
|||
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})`,
|
||||
)
|
||||
}
|
||||
if (typeof (this.mapService as Partial<MapService>).getLevelView === 'function') {
|
||||
view = this.mapService.getLevelView(areaId, 'all') ?? undefined
|
||||
}
|
||||
if (view) {
|
||||
this.levelViews.set(areaId, view)
|
||||
}
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
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 ||
|
||||
|
|
|
|||
|
|
@ -1143,10 +1143,6 @@ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFram
|
|||
// 5. Automap / Minimap via WebGL2 SpriteRenderer
|
||||
if (ctx.minimap && ctx.automapView) {
|
||||
const mode = ctx.automapView.getMode()
|
||||
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) {
|
||||
|
|
@ -1173,6 +1169,11 @@ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFram
|
|||
undefined,
|
||||
true,
|
||||
)
|
||||
|
||||
if (mode === 'off') {
|
||||
ctx.minimap.visible = false
|
||||
} else {
|
||||
ctx.minimap.visible = true
|
||||
const isEn = getTblLang() === 'ENG'
|
||||
const automapEntities: AutomapEntityMarker[] = []
|
||||
const automapPortals: {
|
||||
|
|
|
|||
|
|
@ -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' : '開啟迷你面板'))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
): 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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<string, number[]> {
|
||||
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<string, number[]> = {
|
||||
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()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<string, string>()
|
||||
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue