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 { 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 { 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 { levelDisplayName } from '../../common/world/level-names-zh.ts'
|
||||||
import { getTblLang, lookupTbl, type TblLang } from '../i18n/lang.ts'
|
import { getTblLang, lookupTbl, type TblLang } from '../i18n/lang.ts'
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -128,69 +125,6 @@ function makeRoomKey(areaId: number, roomX: number, roomY: number): string {
|
||||||
return `${areaId}:${roomX},${roomY}`
|
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
|
* Online & offline Automap Reveal controller (`off` | `level` | `act`) consuming
|
||||||
* `LevelView` and `MapService` (`src/client/map/`).
|
* `LevelView` and `MapService` (`src/client/map/`).
|
||||||
|
|
@ -217,9 +151,6 @@ export class AutomapView {
|
||||||
this.mapService = options?.mapService ?? null
|
this.mapService = options?.mapService ?? null
|
||||||
this.mode = options?.initialMode ?? 'off'
|
this.mode = options?.initialMode ?? 'off'
|
||||||
this.actRevealLevelIds = options?.actRevealLevelIds ?? ACT_PRIMARY_LEVEL_IDS
|
this.actRevealLevelIds = options?.actRevealLevelIds ?? ACT_PRIMARY_LEVEL_IDS
|
||||||
if (!this.mapService) {
|
|
||||||
this.mapService = tryLoadPackedBundleNodeSync()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getMode(): AutomapRevealMode {
|
getMode(): AutomapRevealMode {
|
||||||
|
|
@ -257,10 +188,8 @@ export class AutomapView {
|
||||||
if (seedChanged) {
|
if (seedChanged) {
|
||||||
this.levelViews.clear()
|
this.levelViews.clear()
|
||||||
this.serverRevealedRooms.clear()
|
this.serverRevealedRooms.clear()
|
||||||
if (!this.mapService) {
|
if (this.mapService && typeof (this.mapService as Partial<MapService>).loadAct === 'function') {
|
||||||
this.mapService = tryLoadPackedBundleNodeSync()
|
try {
|
||||||
}
|
|
||||||
if (this.mapService) {
|
|
||||||
const loaded = this.mapService.loadAct({
|
const loaded = this.mapService.loadAct({
|
||||||
act: this.currentActId - 1,
|
act: this.currentActId - 1,
|
||||||
seed: this.mapSeed,
|
seed: this.mapSeed,
|
||||||
|
|
@ -271,12 +200,15 @@ export class AutomapView {
|
||||||
if (loaded) {
|
if (loaded) {
|
||||||
this.levelViews.set(areaId, loaded)
|
this.levelViews.set(areaId, loaded)
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore if mapService fails synchronously
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existingLevelView) {
|
if (existingLevelView) {
|
||||||
this.levelViews.set(areaId, existingLevelView)
|
this.levelViews.set(areaId, existingLevelView)
|
||||||
} else if (!this.levelViews.has(areaId)) {
|
} else if (this.mapService && !this.levelViews.has(areaId)) {
|
||||||
this.ensureLevelView(areaId)
|
this.ensureLevelView(areaId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -296,7 +228,7 @@ export class AutomapView {
|
||||||
const targetAreaId = this.currentAreaId
|
const targetAreaId = this.currentAreaId
|
||||||
if (existingLevelView && existingLevelView.areaId === targetAreaId) {
|
if (existingLevelView && existingLevelView.areaId === targetAreaId) {
|
||||||
this.levelViews.set(targetAreaId, existingLevelView)
|
this.levelViews.set(targetAreaId, existingLevelView)
|
||||||
} else if (!this.levelViews.has(targetAreaId)) {
|
} else if (this.mapService && !this.levelViews.has(targetAreaId)) {
|
||||||
this.ensureLevelView(targetAreaId)
|
this.ensureLevelView(targetAreaId)
|
||||||
}
|
}
|
||||||
this.applyRevealModeToViews()
|
this.applyRevealModeToViews()
|
||||||
|
|
@ -307,7 +239,9 @@ export class AutomapView {
|
||||||
* Record a server `0x07 AddMapRoom` event.
|
* Record a server `0x07 AddMapRoom` event.
|
||||||
*/
|
*/
|
||||||
onServerAddRoom(roomX: number, roomY: number, areaId: number = this.currentAreaId): void {
|
onServerAddRoom(roomX: number, roomY: number, areaId: number = this.currentAreaId): void {
|
||||||
|
if (this.mapService) {
|
||||||
this.ensureLevelView(areaId)
|
this.ensureLevelView(areaId)
|
||||||
|
}
|
||||||
this.serverRevealedRooms.set(makeRoomKey(areaId, roomX, roomY), {
|
this.serverRevealedRooms.set(makeRoomKey(areaId, roomX, roomY), {
|
||||||
areaId,
|
areaId,
|
||||||
roomX,
|
roomX,
|
||||||
|
|
@ -338,18 +272,19 @@ export class AutomapView {
|
||||||
return this.levelViews.get(areaId)
|
return this.levelViews.get(areaId)
|
||||||
}
|
}
|
||||||
|
|
||||||
private ensureLevelView(areaId: number): LevelView {
|
private ensureLevelView(areaId: number): LevelView | undefined {
|
||||||
let view = this.levelViews.get(areaId)
|
let view = this.levelViews.get(areaId)
|
||||||
if (!view) {
|
if (!view) {
|
||||||
if (!this.mapService) {
|
if (!this.mapService) {
|
||||||
this.mapService = tryLoadPackedBundleNodeSync()
|
return undefined
|
||||||
}
|
}
|
||||||
if (!this.mapService) {
|
try {
|
||||||
throw new Error(
|
if (typeof (this.mapService as Partial<MapService>).loadAct === 'function') {
|
||||||
`[AutomapView] Failed to load LevelView for area ${areaId} (seed: ${this.mapSeed}, difficulty: ${this.difficulty}): mapService is not initialized and packed tile bundle is unavailable`,
|
const activeAct =
|
||||||
)
|
typeof (this.mapService as Partial<MapService>).getActiveAct === 'function'
|
||||||
}
|
? this.mapService.getActiveAct()
|
||||||
if (this.mapService.getActiveAct() !== getActForArea(areaId) - 1) {
|
: null
|
||||||
|
if (activeAct !== getActForArea(areaId) - 1) {
|
||||||
this.mapService.loadAct({
|
this.mapService.loadAct({
|
||||||
act: getActForArea(areaId) - 1,
|
act: getActForArea(areaId) - 1,
|
||||||
seed: this.mapSeed,
|
seed: this.mapSeed,
|
||||||
|
|
@ -358,20 +293,26 @@ export class AutomapView {
|
||||||
revealMode: 'all',
|
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)
|
this.levelViews.set(areaId, view)
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
return view
|
return view
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyRevealModeToViews(): void {
|
private applyRevealModeToViews(): void {
|
||||||
|
if (!this.mapService) {
|
||||||
|
return
|
||||||
|
}
|
||||||
this.ensureLevelView(this.currentAreaId)
|
this.ensureLevelView(this.currentAreaId)
|
||||||
if (this.mode === 'act') {
|
if (this.mode === 'act' && this.mapService) {
|
||||||
const actLevels = this.actRevealLevelIds[this.currentActId] ?? [this.currentAreaId]
|
const actLevels = this.actRevealLevelIds[this.currentActId] ?? [this.currentAreaId]
|
||||||
const idsToReveal = actLevels.includes(this.currentAreaId)
|
const idsToReveal = actLevels.includes(this.currentAreaId)
|
||||||
? actLevels
|
? actLevels
|
||||||
|
|
@ -493,7 +434,8 @@ export class AutomapView {
|
||||||
const cid = u.classId ?? 0
|
const cid = u.classId ?? 0
|
||||||
const isAncientStatue = cid >= 537 && cid <= 539
|
const isAncientStatue = cid >= 537 && cid <= 539
|
||||||
const mk = typeof u.classId === 'number' ? getMonsterKindByClassId(u.classId) : undefined
|
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) {
|
if (isInteract && !isAncientStatue) {
|
||||||
entityDots.push({
|
entityDots.push({
|
||||||
worldX,
|
worldX,
|
||||||
|
|
@ -537,6 +479,11 @@ export class AutomapView {
|
||||||
}
|
}
|
||||||
const snap = this.buildSnapshot([], lang)
|
const snap = this.buildSnapshot([], lang)
|
||||||
const view = this.ensureLevelView(this.currentAreaId)
|
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 (
|
if (
|
||||||
view.cellsX <= 0 ||
|
view.cellsX <= 0 ||
|
||||||
|
|
|
||||||
|
|
@ -1143,10 +1143,6 @@ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFram
|
||||||
// 5. Automap / Minimap via WebGL2 SpriteRenderer
|
// 5. Automap / Minimap via WebGL2 SpriteRenderer
|
||||||
if (ctx.minimap && ctx.automapView) {
|
if (ctx.minimap && ctx.automapView) {
|
||||||
const mode = ctx.automapView.getMode()
|
const mode = ctx.automapView.getMode()
|
||||||
if (mode === 'off') {
|
|
||||||
ctx.minimap.visible = false
|
|
||||||
} else {
|
|
||||||
ctx.minimap.visible = true
|
|
||||||
if (levelView && levelView.areaId > 0) {
|
if (levelView && levelView.areaId > 0) {
|
||||||
ctx.automapView.syncActiveLevel(levelView.areaId, levelView)
|
ctx.automapView.syncActiveLevel(levelView.areaId, levelView)
|
||||||
} else if (snapshot.levelId > 0 && ctx.automapView.getCurrentAreaId() !== snapshot.levelId) {
|
} else if (snapshot.levelId > 0 && ctx.automapView.getCurrentAreaId() !== snapshot.levelId) {
|
||||||
|
|
@ -1173,6 +1169,11 @@ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFram
|
||||||
undefined,
|
undefined,
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (mode === 'off') {
|
||||||
|
ctx.minimap.visible = false
|
||||||
|
} else {
|
||||||
|
ctx.minimap.visible = true
|
||||||
const isEn = getTblLang() === 'ENG'
|
const isEn = getTblLang() === 'ENG'
|
||||||
const automapEntities: AutomapEntityMarker[] = []
|
const automapEntities: AutomapEntityMarker[] = []
|
||||||
const automapPortals: {
|
const automapPortals: {
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@ export class ControlBarHud {
|
||||||
lang: TblLang = 'CHI'
|
lang: TblLang = 'CHI'
|
||||||
isMultiplayer = true
|
isMultiplayer = true
|
||||||
pressedAction: MiniPanelAction | null = null
|
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[] {
|
get currentMiniPanelItems(): readonly MiniPanelItemDef[] {
|
||||||
return this.isMultiplayer ? MULTIPLAYER_MINIPANEL_ITEMS : SINGLEPLAYER_MINIPANEL_ITEMS
|
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)) {
|
} else if (this.inRect(logicalX, logicalY, RUN_BTN_BOUNDS)) {
|
||||||
this.hoveredElement = 'run'
|
this.hoveredElement = 'run'
|
||||||
} else if (this.inRect(logicalX, logicalY, MENU_TOGGLE_BOUNDS)) {
|
} 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)) {
|
} else if (this.inRect(logicalX, logicalY, STAT_PLUS_BTN_BOUNDS)) {
|
||||||
this.hoveredElement = 'statPlus'
|
this.hoveredElement = 'statPlus'
|
||||||
} else if (this.inRect(logicalX, logicalY, SKILL_PLUS_BTN_BOUNDS)) {
|
} else if (this.inRect(logicalX, logicalY, SKILL_PLUS_BTN_BOUNDS)) {
|
||||||
|
|
@ -352,7 +352,7 @@ export class ControlBarHud {
|
||||||
SKILL_PLUS_BTN_BOUNDS.x + 15,
|
SKILL_PLUS_BTN_BOUNDS.x + 15,
|
||||||
518,
|
518,
|
||||||
)
|
)
|
||||||
} else if (this.hoveredElement === 'menu') {
|
} else if (this.hoveredElement === 'menuToggle') {
|
||||||
const tip = this.miniPanelOpen
|
const tip = this.miniPanelOpen
|
||||||
? (resolveTblKey('panelcmini', this.lang) ?? (isEn ? 'Close Mini Panel' : '關閉迷你面板'))
|
? (resolveTblKey('panelcmini', this.lang) ?? (isEn ? 'Close Mini Panel' : '關閉迷你面板'))
|
||||||
: (resolveTblKey('panelmini', this.lang) ?? (isEn ? 'Open 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 centerX = Math.round(profile.width / 2)
|
||||||
const centerY = Math.round(profile.height / 2)
|
const centerY = Math.round(profile.height / 2)
|
||||||
ctx.save()
|
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 hardcoreImg = this.images.get('deathHardcore')
|
||||||
const instImg = this.images.get('deathInst')
|
const instImg = this.images.get('deathInst')
|
||||||
const y1 = centerY - 94
|
const y1 = centerY - 94
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,8 @@ import {
|
||||||
computeAutomapTilePixelCoords,
|
computeAutomapTilePixelCoords,
|
||||||
getCrosshairSegments,
|
getCrosshairSegments,
|
||||||
} from '../../common/formats/automap-table.ts'
|
} from '../../common/formats/automap-table.ts'
|
||||||
|
|
||||||
|
export type { AutomapLayerData }
|
||||||
import { lookupTbl } from '../i18n/lang.ts'
|
import { lookupTbl } from '../i18n/lang.ts'
|
||||||
import { reportMissingArt } from '../scene/world-renderer.ts'
|
import { reportMissingArt } from '../scene/world-renderer.ts'
|
||||||
import type { D2ColorCode, D2FontRenderer } from './font.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 { AutomapView } from '../../src/client/automap/automap-view.ts'
|
||||||
|
import { MapService } from '../../src/client/map/map-service.ts'
|
||||||
import {
|
import {
|
||||||
clearMissingArtEntries,
|
clearMissingArtEntries,
|
||||||
computeAnimDataFrameIndex,
|
computeAnimDataFrameIndex,
|
||||||
|
|
@ -12,6 +13,11 @@ import { ClientWorldSceneSource, type ClientSceneWorldAdapter } from '../../src/
|
||||||
import { decode0x0DPlayerStop, decode0x0FPlayerMove, decode0x10PlayerMoveToTarget } from '../../src/netproto/d2gs/s2c/movement.ts'
|
import { decode0x0DPlayerStop, decode0x0FPlayerMove, decode0x10PlayerMoveToTarget } from '../../src/netproto/d2gs/s2c/movement.ts'
|
||||||
import { PLR_MODES } from '../../src/common/units/plr-mode.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 {
|
function makeTaggedCharArt(): LoadedCharacter {
|
||||||
const modes: Array<{ key: string; group: number; frames: number; speed: number; loop: boolean }> = [
|
const modes: Array<{ key: string; group: number; frames: number; speed: number; loop: boolean }> = [
|
||||||
{ key: 'dt', group: 0, frames: 4, speed: 256, loop: false },
|
{ 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)
|
expect(remotePlayer.mode).toBe(5) // PLRMODE_TOWNNEUTRAL (TN)
|
||||||
|
|
||||||
// Render scene frame and inspect drawn sprite tags for both players
|
// Render scene frame and inspect drawn sprite tags for both players
|
||||||
const automap = new AutomapView({ initialMode: 'level' })
|
const automap = new AutomapView({ mapService: sharedMapService, initialMode: 'level' })
|
||||||
const levelView = automap.setContext(0x11223344, 0, 1)
|
const levelView = automap.setContext(0x11223344, 0, 1)!
|
||||||
const baseX = levelView.subOriginX + 50
|
const baseX = levelView.subOriginX + 50
|
||||||
const baseY = levelView.subOriginY + 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
|
// Fail-fast diagnostic when clip speed metadata is missing
|
||||||
clearMissingArtEntries()
|
clearMissingArtEntries()
|
||||||
const automap = new AutomapView({ initialMode: 'level' })
|
const automap = new AutomapView({ mapService: sharedMapService, initialMode: 'level' })
|
||||||
const levelView = automap.setContext(0x11223344, 0, 1)
|
const levelView = automap.setContext(0x11223344, 0, 1)!
|
||||||
const baseX = levelView.subOriginX + 50
|
const baseX = levelView.subOriginX + 50
|
||||||
const baseY = levelView.subOriginY + 50
|
const baseY = levelView.subOriginY + 50
|
||||||
const source = new ClientWorldSceneSource(
|
const source = new ClientWorldSceneSource(
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
import { beforeAll, describe, expect, it, vi } from 'vitest'
|
||||||
import {
|
import {
|
||||||
HttpRangeMpqSource,
|
HttpRangeMpqSource,
|
||||||
InMemoryChunkCache,
|
InMemoryChunkCache,
|
||||||
|
|
@ -80,6 +80,11 @@ import {
|
||||||
HudManager,
|
HudManager,
|
||||||
} from '../../src/client/ui/hud-manager.ts'
|
} from '../../src/client/ui/hud-manager.ts'
|
||||||
|
|
||||||
|
let sharedMapService: MapService
|
||||||
|
beforeAll(async () => {
|
||||||
|
sharedMapService = await MapService.fromPackedDir('public/act-packs/tiles')
|
||||||
|
})
|
||||||
|
|
||||||
class MemoryStorage implements StorageLike {
|
class MemoryStorage implements StorageLike {
|
||||||
readonly data = new Map<string, string>()
|
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)', () => {
|
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', () => {
|
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 levelView = automap.setContext(0x11223344, 0, 1)
|
||||||
const localSubX = Math.floor((levelView.cellsX * 5) / 2)
|
const localSubX = Math.floor((levelView.cellsX * 5) / 2)
|
||||||
const localSubY = Math.floor((levelView.cellsY * 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)', () => {
|
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)', () => {
|
it('switches cleanly between off (server rooms only), level (all current area rooms), and act (multi-level Act rooms)', () => {
|
||||||
const automap = new AutomapView({
|
const automap = new AutomapView({
|
||||||
|
mapService: sharedMapService,
|
||||||
initialMode: 'off',
|
initialMode: 'off',
|
||||||
actRevealLevelIds: {
|
actRevealLevelIds: {
|
||||||
1: [1, 2, 3],
|
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 levelView = automap.setContext(0x11223344, 0, 1)
|
||||||
const worldSubX = levelView.subOriginX + 50
|
const worldSubX = levelView.subOriginX + 50
|
||||||
const worldSubY = levelView.subOriginY + 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', () => {
|
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 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 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 levelView = automap.setContext(0x11223344, 0, 1)
|
||||||
const localSubX = Math.floor((levelView.cellsX * 5) / 2)
|
const localSubX = Math.floor((levelView.cellsX * 5) / 2)
|
||||||
const localSubY = Math.floor((levelView.cellsY * 5) / 2)
|
const localSubY = Math.floor((levelView.cellsY * 5) / 2)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue