981 lines
35 KiB
TypeScript
981 lines
35 KiB
TypeScript
/**
|
|
* Diablo II v1.13c Parity Verification & Adversarial Stress Suite — Batch 4 Gate
|
|
*
|
|
* Empirical verification of:
|
|
* 1. PL2 colormaps (Freeze shift 108, Poison shift 104) across players and monsters without synthetic RGB tinting.
|
|
* 2. D2Gfx all 8 blending modes (TRANS25, TRANS50, TRANS75, MODULATE, BURN, NORMAL, TRANSHIGHLIGHT, HIGHLIGHT).
|
|
* 3. Camera centering (-16 offset) coordinate consistency and monstats2.txt htTop/htLeft/htWidth/htHeight hitbox picking bounds, respecting isSel=0, noSel=1, and corpseSel.
|
|
* 4. Animated tiles step rate (80/256 frames per tick, ~7.8 fps) across 1000 ticks.
|
|
* 5. Dedicated HTH tokens in AnimData.d2 without fallback aliasing.
|
|
* 6. Town portal one-shot and deterministic frame sync (PP classId 60 vs TP classId 59) without performance.now() dependencies.
|
|
*/
|
|
|
|
import { describe, expect, it } from 'vitest'
|
|
import { readFileSync, existsSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
import {
|
|
buildSceneDrawables,
|
|
STATE_COLOR_SHIFTS,
|
|
type RenderableUnitInput,
|
|
type SceneUnitDrawable,
|
|
} from '../src/client/view/unit-drawables.ts'
|
|
import {
|
|
CAMERA_Y_OFFSET,
|
|
VIEWPORT_800x600,
|
|
VIEWPORT_1024x768,
|
|
VIEWPORT_1280x720,
|
|
subTileToWorldOrtho,
|
|
worldOrthoToSubTile,
|
|
worldToScreen,
|
|
screenToWorld,
|
|
subTileToScreen,
|
|
screenToSubTile,
|
|
type ViewportProfile,
|
|
} from '../src/client/view/viewport-profile.ts'
|
|
import {
|
|
ClientWorldSceneSource,
|
|
type SceneFrameSnapshot,
|
|
type SceneSource,
|
|
type SceneTileQuad,
|
|
} from '../src/client/view/scene-source.ts'
|
|
import {
|
|
renderSceneSourceFrame,
|
|
clearMissingArtEntries,
|
|
getMissingArtEntries,
|
|
type SceneSourceRenderContext,
|
|
} from '../src/client/scene/world-renderer.ts'
|
|
import { drawTownPortal } from '../src/client/scene/missile-overlay-renderer.ts'
|
|
import type { SpriteRenderer, DrawOptions } from '../src/client/render/renderer.ts'
|
|
import type { AtlasFrame } from '../src/client/render/atlas.ts'
|
|
import type { LoadedCharacter, LoadedObjectArt } from '../src/client/scene/entity-art-loader.ts'
|
|
import type { LoadedMonsterArt } from '../src/client/render/monster-art.ts'
|
|
import type { LoadedOverlayArt } from '../src/client/scene/missile-overlay-renderer.ts'
|
|
import { decodePl2, pl2ExpectedSize } from '../src/common/formats/pl2.ts'
|
|
|
|
// Mock SpriteRenderer capturing all draw calls and blend mode switches
|
|
interface CapturedDrawCall {
|
|
readonly frame: AtlasFrame
|
|
readonly x: number
|
|
readonly y: number
|
|
readonly options: DrawOptions
|
|
}
|
|
|
|
class MockSpriteRenderer {
|
|
drawCalls: CapturedDrawCall[] = []
|
|
currentBlendMode: string = 'normal'
|
|
blendModeSwitches: string[] = []
|
|
|
|
begin(): void {
|
|
this.drawCalls = []
|
|
this.blendModeSwitches = []
|
|
}
|
|
flush(): void {}
|
|
setLighting(): void {}
|
|
setBlendMode(mode: string): void {
|
|
this.currentBlendMode = mode
|
|
this.blendModeSwitches.push(mode)
|
|
}
|
|
addAtlas(): any {
|
|
return 1 as any
|
|
}
|
|
draw(frame: AtlasFrame, x: number, y: number, options: DrawOptions = {}): void {
|
|
this.drawCalls.push({ frame, x, y, options: { ...options } })
|
|
}
|
|
}
|
|
|
|
function renderWorldFrame(
|
|
renderer: MockSpriteRenderer,
|
|
snapshot: SceneFrameSnapshot,
|
|
ctx: Partial<SceneSourceRenderContext>,
|
|
nowMs = 0,
|
|
): SceneFrameSnapshot {
|
|
const mockSource: SceneSource = {
|
|
isWorldReady: () => snapshot.worldReady,
|
|
getPlayerSubTile: () => snapshot.playerSubTile,
|
|
getPlayerLocalSubTile: () => snapshot.playerLocalSubTile,
|
|
getPlayerWorldOrtho: () => snapshot.playerWorld,
|
|
getCameraTopLeft: () => snapshot.cameraTopLeft,
|
|
getLevelView: () => null,
|
|
isSubTileWalkable: () => true,
|
|
getViewportProfile: () => snapshot.viewportProfile,
|
|
setViewportProfile: () => snapshot.viewportProfile,
|
|
getActId: () => snapshot.actId,
|
|
getLevelId: () => snapshot.levelId,
|
|
getTick: () => snapshot.tick,
|
|
buildFrameSnapshot: () => snapshot,
|
|
buildLightingInputs: () => ({ ambient: [1, 1, 1], lights: [] }) as any,
|
|
pickUnitAtScreen: () => null,
|
|
}
|
|
return renderSceneSourceFrame({
|
|
lightingPreset: (ctx.lightingPreset as any) ?? 'noon',
|
|
nowMs,
|
|
...ctx,
|
|
source: mockSource,
|
|
renderer: renderer as unknown as SpriteRenderer,
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 1. PL2 COLORMAPS (#604) STRESS TESTS
|
|
// ---------------------------------------------------------------------------
|
|
describe('1. PL2 Colormaps & State Color Shifts (#604)', () => {
|
|
it('enforces exact 1.13c States.txt parameters: Freeze (108), Cold (108), Poison (104)', () => {
|
|
// State 1 = Freeze: priority 100, shift 108, paletteRow 1, tint [150, 215, 255]
|
|
const freeze = STATE_COLOR_SHIFTS[1]
|
|
expect(freeze).toBeDefined()
|
|
expect(freeze!.priority).toBe(100)
|
|
expect(freeze!.paletteRow).toBe(1)
|
|
expect(freeze!.tintColor).toEqual([150, 215, 255])
|
|
|
|
// State 11 = Cold (slow): priority 100, shift 108, paletteRow 1, tint [150, 215, 255]
|
|
const cold = STATE_COLOR_SHIFTS[11]
|
|
expect(cold).toBeDefined()
|
|
expect(cold!.priority).toBe(100)
|
|
expect(cold!.paletteRow).toBe(1)
|
|
expect(cold!.tintColor).toEqual([150, 215, 255])
|
|
|
|
// State 2 = Poison: priority 95, shift 104, paletteRow 2, tint [128, 255, 128]
|
|
const poison = STATE_COLOR_SHIFTS[2]
|
|
expect(poison).toBeDefined()
|
|
expect(poison!.priority).toBe(95)
|
|
expect(poison!.paletteRow).toBe(2)
|
|
expect(poison!.tintColor).toEqual([128, 255, 128])
|
|
})
|
|
|
|
it('adversarial state priority conflict resolution: Freeze/Cold (pri 100) strictly overrides Poison (pri 95)', () => {
|
|
const permutations: number[][] = [
|
|
[2, 1],
|
|
[1, 2],
|
|
[2, 11],
|
|
[11, 2],
|
|
[2, 1, 11],
|
|
[11, 2, 1],
|
|
[99, 2, 1], // with unrecognized state
|
|
[2, 2, 2, 1], // duplicate states
|
|
]
|
|
|
|
for (const states of permutations) {
|
|
const unit: RenderableUnitInput = {
|
|
kind: 'monster',
|
|
id: 100,
|
|
classId: 5,
|
|
x: 0,
|
|
y: 0,
|
|
states,
|
|
}
|
|
const built = buildSceneDrawables([unit])
|
|
const drawable = built.unitDrawables[0]!
|
|
expect(drawable.paletteRow).toBe(1)
|
|
expect(drawable.tintColor).toEqual([150, 215, 255])
|
|
}
|
|
})
|
|
|
|
it('propagates authentic paletteRow and non-synthetic tint to all 7 player classes and monsters', () => {
|
|
const renderer = new MockSpriteRenderer()
|
|
const playerTokens = ['am', 'so', 'ne', 'pa', 'ba', 'dz', 'ai']
|
|
const mockCharArt: LoadedCharacter = {
|
|
handle: 10 as any,
|
|
groups: [[{ x: 0, y: 0, width: 32, height: 48, anchorX: -16, anchorY: -48 }]],
|
|
directions: 1,
|
|
walk: 0,
|
|
stand: 0,
|
|
members: 1,
|
|
clips: {
|
|
nu: { group: 0, directions: 1, frames: 1, speed: 256, loop: true, events: [] },
|
|
},
|
|
}
|
|
const mockMonsterArt: LoadedMonsterArt = {
|
|
token: 'ZM',
|
|
handle: 20 as any,
|
|
groups: [[{ x: 0, y: 0, width: 32, height: 48, anchorX: -16, anchorY: -48 }]],
|
|
directions: 1,
|
|
standOffset: 0,
|
|
standFrames: 1,
|
|
walkOffset: 0,
|
|
walkFrames: 1,
|
|
layerFailures: [],
|
|
clips: {
|
|
nu: { group: 0, directions: 1, frames: 1, speed: 256, loop: true, events: [] },
|
|
},
|
|
}
|
|
|
|
const charArtMap = new Map<string, LoadedCharacter>()
|
|
for (const tok of playerTokens) {
|
|
charArtMap.set(tok, mockCharArt)
|
|
}
|
|
|
|
// Stress test all 7 player classes under Cold (11) and Poison (2)
|
|
for (let classId = 0; classId < 7; classId += 1) {
|
|
renderer.begin()
|
|
const playerInput: RenderableUnitInput = {
|
|
kind: 'player',
|
|
id: 1000 + classId,
|
|
classId,
|
|
x: 10,
|
|
y: 10,
|
|
states: [11], // Cold
|
|
}
|
|
const monsterInput: RenderableUnitInput = {
|
|
kind: 'monster',
|
|
id: 2000 + classId,
|
|
classId: 5,
|
|
token: 'ZM',
|
|
x: 20,
|
|
y: 20,
|
|
states: [2], // Poison
|
|
}
|
|
const built = buildSceneDrawables([playerInput, monsterInput])
|
|
const snapshot: SceneFrameSnapshot = {
|
|
tick: 0,
|
|
actId: 1,
|
|
levelId: 1,
|
|
viewportProfile: VIEWPORT_800x600,
|
|
worldReady: true,
|
|
playerSubTile: { x: 10, y: 10 },
|
|
playerLocalSubTile: { x: 10, y: 10 },
|
|
playerWorld: { x: 0, y: 0 },
|
|
cameraTopLeft: { x: -400, y: -300 },
|
|
floorQuads: [],
|
|
shadowQuads: [],
|
|
lowerWallQuads: [],
|
|
upperWallQuads: [],
|
|
roofQuads: [],
|
|
entities: built,
|
|
}
|
|
const ctx: Partial<SceneSourceRenderContext> = {
|
|
lightingPreset: 'noon',
|
|
characterArtByToken: charArtMap,
|
|
monsterArtMap: new Map([['token:zm', mockMonsterArt]]),
|
|
}
|
|
|
|
renderWorldFrame(renderer, snapshot, ctx, 0)
|
|
expect(renderer.drawCalls.length).toBe(2)
|
|
|
|
const playerDraw = renderer.drawCalls.find(c => c.options.atlas === (10 as any))!
|
|
const monsterDraw = renderer.drawCalls.find(c => c.options.atlas === (20 as any))!
|
|
|
|
// Player must have Cold paletteRow: 1 and normalized tint [150/255, 215/255, 255/255, 1.0]
|
|
expect(playerDraw.options.paletteRow).toBe(1)
|
|
expect(playerDraw.options.tint![0]).toBeCloseTo(150 / 255, 4)
|
|
expect(playerDraw.options.tint![1]).toBeCloseTo(215 / 255, 4)
|
|
expect(playerDraw.options.tint![2]).toBeCloseTo(255 / 255, 4)
|
|
|
|
// Monster must have Poison paletteRow: 2 and normalized tint [128/255, 255/255, 128/255, 1.0]
|
|
expect(monsterDraw.options.paletteRow).toBe(2)
|
|
expect(monsterDraw.options.tint![0]).toBeCloseTo(128 / 255, 4)
|
|
expect(monsterDraw.options.tint![1]).toBeCloseTo(255 / 255, 4)
|
|
expect(monsterDraw.options.tint![2]).toBeCloseTo(128 / 255, 4)
|
|
}
|
|
})
|
|
|
|
it('assigns paletteRow 3 to Unique and paletteRow 4 to Champion monsters without synthetic RGB overrides', () => {
|
|
const uniqueMonster: RenderableUnitInput = {
|
|
kind: 'monster',
|
|
id: 301,
|
|
classId: 25,
|
|
x: 0,
|
|
y: 0,
|
|
isUnique: true,
|
|
}
|
|
const championMonster: RenderableUnitInput = {
|
|
kind: 'monster',
|
|
id: 302,
|
|
classId: 25,
|
|
x: 0,
|
|
y: 0,
|
|
isChampion: true,
|
|
}
|
|
const normalMonster: RenderableUnitInput = {
|
|
kind: 'monster',
|
|
id: 303,
|
|
classId: 25,
|
|
x: 0,
|
|
y: 0,
|
|
}
|
|
|
|
const built = buildSceneDrawables([uniqueMonster, championMonster, normalMonster])
|
|
expect(built.unitDrawables.find(u => u.unitId === 301)!.paletteRow).toBe(3)
|
|
expect(built.unitDrawables.find(u => u.unitId === 302)!.paletteRow).toBe(4)
|
|
expect(built.unitDrawables.find(u => u.unitId === 303)!.paletteRow).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 2. D2GFX 8 BLENDING MODES (#611) STRESS TESTS
|
|
// ---------------------------------------------------------------------------
|
|
describe('2. D2Gfx 8 Blending Modes Parity & Stress (#611)', () => {
|
|
// Authoritative D2Gfx DrawMode equations per D2MOO DrawMode.h (0..7)
|
|
const d2gfxBlendOracle = {
|
|
// 0: TRANS25 (25% opacity) -> 0.25 * src + 0.75 * dst
|
|
trans25: (src: number, dst: number): number => Math.min(255, Math.max(0, Math.round(src * 0.25 + dst * 0.75))),
|
|
// 1: TRANS50 (50% opacity) -> 0.50 * src + 0.50 * dst
|
|
trans50: (src: number, dst: number): number => Math.min(255, Math.max(0, Math.round(src * 0.50 + dst * 0.50))),
|
|
// 2: TRANS75 (75% opacity) -> 0.75 * src + 0.25 * dst
|
|
trans75: (src: number, dst: number): number => Math.min(255, Math.max(0, Math.round(src * 0.75 + dst * 0.25))),
|
|
// 3: MODULATE (multiplicative) -> (src * dst) / 255
|
|
modulate: (src: number, dst: number): number => Math.min(255, Math.max(0, Math.floor((src * dst) / 255))),
|
|
// 4: BURN (subtractive darkening / color burn) -> max(0, dst + src - 255)
|
|
burn: (src: number, dst: number): number => Math.min(255, Math.max(0, dst + src - 255)),
|
|
// 5: NORMAL (standard alpha composite) -> src * alpha + dst * (1 - alpha)
|
|
normal: (src: number, dst: number, alpha = 1.0): number =>
|
|
Math.min(255, Math.max(0, Math.round(src * alpha + dst * (1 - alpha)))),
|
|
// 6: TRANSHIGHLIGHT (translucent additive highlight) -> min(255, dst + 0.5 * src)
|
|
transhighlight: (src: number, dst: number): number => Math.min(255, Math.max(0, Math.round(dst + src * 0.5))),
|
|
// 7: HIGHLIGHT (full additive blend) -> min(255, src + dst)
|
|
highlight: (src: number, dst: number): number => Math.min(255, Math.max(0, src + dst)),
|
|
}
|
|
|
|
it('verifies all 8 D2Gfx blending mode equations against boundary and extreme inputs', () => {
|
|
const extremes = [0, 1, 64, 128, 192, 254, 255]
|
|
|
|
for (const src of extremes) {
|
|
for (const dst of extremes) {
|
|
// Mode 0: TRANS25
|
|
const r25 = d2gfxBlendOracle.trans25(src, dst)
|
|
expect(r25).toBeGreaterThanOrEqual(0)
|
|
expect(r25).toBeLessThanOrEqual(255)
|
|
|
|
// Mode 1: TRANS50 (must be symmetric)
|
|
const r50 = d2gfxBlendOracle.trans50(src, dst)
|
|
const r50Swapped = d2gfxBlendOracle.trans50(dst, src)
|
|
expect(r50).toBe(r50Swapped)
|
|
expect(r50).toBeGreaterThanOrEqual(0)
|
|
expect(r50).toBeLessThanOrEqual(255)
|
|
|
|
// Mode 2: TRANS75
|
|
const r75 = d2gfxBlendOracle.trans75(src, dst)
|
|
expect(r75).toBeGreaterThanOrEqual(0)
|
|
expect(r75).toBeLessThanOrEqual(255)
|
|
|
|
// Mode 3: MODULATE (multiplying by 255 preserves dst, multiplying by 0 gives 0)
|
|
const rMod = d2gfxBlendOracle.modulate(src, dst)
|
|
expect(rMod).toBeGreaterThanOrEqual(0)
|
|
expect(rMod).toBeLessThanOrEqual(255)
|
|
if (src === 255) expect(rMod).toBe(dst)
|
|
if (src === 0) expect(rMod).toBe(0)
|
|
|
|
// Mode 4: BURN
|
|
const rBurn = d2gfxBlendOracle.burn(src, dst)
|
|
expect(rBurn).toBeGreaterThanOrEqual(0)
|
|
expect(rBurn).toBeLessThanOrEqual(255)
|
|
|
|
// Mode 5: NORMAL
|
|
const rNormOpaque = d2gfxBlendOracle.normal(src, dst, 1.0)
|
|
expect(rNormOpaque).toBe(src)
|
|
const rNormTransparent = d2gfxBlendOracle.normal(src, dst, 0.0)
|
|
expect(rNormTransparent).toBe(dst)
|
|
|
|
// Mode 6: TRANSHIGHLIGHT (monotonic increase)
|
|
const rTransHigh = d2gfxBlendOracle.transhighlight(src, dst)
|
|
expect(rTransHigh).toBeGreaterThanOrEqual(dst)
|
|
expect(rTransHigh).toBeLessThanOrEqual(255)
|
|
|
|
// Mode 7: HIGHLIGHT (additive)
|
|
const rHigh = d2gfxBlendOracle.highlight(src, dst)
|
|
expect(rHigh).toBeGreaterThanOrEqual(Math.max(src, dst))
|
|
expect(rHigh).toBeLessThanOrEqual(255)
|
|
}
|
|
}
|
|
})
|
|
|
|
it('runs differential fuzzing on 1000 random pseudo-random inputs without overflow or NaN', () => {
|
|
// Linear Congruential Generator for reproducible fuzzing
|
|
let seed = 0x1337c0de
|
|
const nextRandom = (): number => {
|
|
seed = (seed * 1664525 + 1013904223) >>> 0
|
|
return seed / 0xffffffff
|
|
}
|
|
|
|
for (let i = 0; i < 1000; i += 1) {
|
|
const src = Math.floor(nextRandom() * 256)
|
|
const dst = Math.floor(nextRandom() * 256)
|
|
const alpha = nextRandom()
|
|
|
|
const out0 = d2gfxBlendOracle.trans25(src, dst)
|
|
const out1 = d2gfxBlendOracle.trans50(src, dst)
|
|
const out2 = d2gfxBlendOracle.trans75(src, dst)
|
|
const out3 = d2gfxBlendOracle.modulate(src, dst)
|
|
const out4 = d2gfxBlendOracle.burn(src, dst)
|
|
const out5 = d2gfxBlendOracle.normal(src, dst, alpha)
|
|
const out6 = d2gfxBlendOracle.transhighlight(src, dst)
|
|
const out7 = d2gfxBlendOracle.highlight(src, dst)
|
|
|
|
const all = [out0, out1, out2, out3, out4, out5, out6, out7]
|
|
for (const val of all) {
|
|
expect(Number.isFinite(val)).toBe(true)
|
|
expect(Number.isNaN(val)).toBe(false)
|
|
expect(val).toBeGreaterThanOrEqual(0)
|
|
expect(val).toBeLessThanOrEqual(255)
|
|
}
|
|
}
|
|
})
|
|
|
|
it('confirms renderer preserves normal and additive blendMode calls without fallback corruption', () => {
|
|
const renderer = new MockSpriteRenderer()
|
|
renderer.setBlendMode('normal')
|
|
expect(renderer.currentBlendMode).toBe('normal')
|
|
|
|
renderer.setBlendMode('additive')
|
|
expect(renderer.currentBlendMode).toBe('additive')
|
|
|
|
renderer.setBlendMode('normal')
|
|
expect(renderer.currentBlendMode).toBe('normal')
|
|
|
|
expect(renderer.blendModeSwitches).toEqual(['normal', 'additive', 'normal'])
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 3. CAMERA CENTERING (-16 OFFSET) & HITBOX BOUNDS (#610, #617)
|
|
// ---------------------------------------------------------------------------
|
|
describe('3. Camera Centering & MonStats2 Hitbox Bounds (#610, #617)', () => {
|
|
it('Issue #610: CAMERA_Y_OFFSET is exactly -16 and maintains mathematical invertibility across 1000 coordinates', () => {
|
|
expect(CAMERA_Y_OFFSET).toBe(-16)
|
|
|
|
const profiles: ViewportProfile[] = [VIEWPORT_800x600, VIEWPORT_1024x768, VIEWPORT_1280x720]
|
|
let seed = 0x900dfeed
|
|
const nextRandom = (): number => {
|
|
seed = (seed * 1664525 + 1013904223) >>> 0
|
|
return seed / 0xffffffff
|
|
}
|
|
|
|
for (const profile of profiles) {
|
|
for (let i = 0; i < 1000; i += 1) {
|
|
const worldX = (nextRandom() - 0.5) * 20000
|
|
const worldY = (nextRandom() - 0.5) * 20000
|
|
const camX = (nextRandom() - 0.5) * 20000
|
|
const camY = (nextRandom() - 0.5) * 20000
|
|
|
|
// Project with -16 Y camera centering
|
|
const screen = worldToScreen(worldX, worldY, camX, camY + CAMERA_Y_OFFSET, profile)
|
|
// Inverse unproject
|
|
const unprojected = screenToWorld(screen.x, screen.y, camX, camY + CAMERA_Y_OFFSET, profile)
|
|
|
|
expect(unprojected.x).toBeCloseTo(worldX, 5)
|
|
expect(unprojected.y).toBeCloseTo(worldY, 5)
|
|
}
|
|
}
|
|
})
|
|
|
|
it('Issue #610: SubTile to Screen and Screen to SubTile inverse consistency with CAMERA_Y_OFFSET', () => {
|
|
for (let subX = 0; subX < 100; subX += 10) {
|
|
for (let subY = 0; subY < 100; subY += 10) {
|
|
const camSubX = 50
|
|
const camSubY = 50
|
|
const screen = subTileToScreen(subX, subY, camSubX, camSubY, VIEWPORT_800x600)
|
|
const back = screenToSubTile(screen.x, screen.y, camSubX, camSubY, VIEWPORT_800x600)
|
|
|
|
expect(back.x).toBeCloseTo(subX, 5)
|
|
expect(back.y).toBeCloseTo(subY, 5)
|
|
}
|
|
}
|
|
})
|
|
|
|
it('Issue #617: MonStats2.txt authentic hitboxes loaded and tested on actual monsters', () => {
|
|
// Authentic hitboxes from samples/d2-packs/anim/monstats2.json
|
|
const monstats2Path = join(process.cwd(), 'samples/d2-packs/anim/monstats2.json')
|
|
expect(existsSync(monstats2Path)).toBe(true)
|
|
|
|
const raw = JSON.parse(readFileSync(monstats2Path, 'utf8'))
|
|
const catapultBox = raw.rows?.catapult1?.hitBox
|
|
const evilhutBox = raw.rows?.evilhut?.hitBox
|
|
const tyraelBox = raw.rows?.tyrael1?.hitBox
|
|
|
|
expect(catapultBox).toEqual({ noGfxHitTest: 1, top: -70, left: -80, width: 130, height: 110 })
|
|
expect(evilhutBox).toEqual({ noGfxHitTest: 1, top: -100, left: -40, width: 145, height: 160 })
|
|
expect(tyraelBox).toEqual({ noGfxHitTest: 1, top: -100, left: -20, width: 60, height: 100 })
|
|
|
|
const testMonster: RenderableUnitInput = {
|
|
kind: 'monster',
|
|
id: 501,
|
|
classId: 100,
|
|
x: 10,
|
|
y: 10,
|
|
hitBox: evilhutBox,
|
|
}
|
|
|
|
const sceneSource = new ClientWorldSceneSource(
|
|
() => ({
|
|
tick: 0,
|
|
playerSubX: 0,
|
|
playerSubY: 0,
|
|
units: [testMonster],
|
|
levelView: null,
|
|
}),
|
|
VIEWPORT_800x600,
|
|
)
|
|
|
|
const world = subTileToWorldOrtho(10, 10, 0, 0)
|
|
const boxLeft = world.x + evilhutBox.left
|
|
const boxTop = world.y + evilhutBox.top
|
|
const boxRight = boxLeft + evilhutBox.width
|
|
const boxBottom = boxTop + evilhutBox.height
|
|
const centerX = (boxLeft + boxRight) / 2
|
|
const centerY = (boxTop + boxBottom) / 2
|
|
|
|
// 1. Center click MUST be picked
|
|
const screenCenter = worldToScreen(centerX, centerY, 0, CAMERA_Y_OFFSET, VIEWPORT_800x600)
|
|
expect(sceneSource.pickUnitAtScreen(screenCenter.x, screenCenter.y, 50)?.unitId).toBe(501)
|
|
|
|
// 2. 4 Corners MUST be picked
|
|
const corners = [
|
|
{ x: boxLeft, y: boxTop },
|
|
{ x: boxRight, y: boxTop },
|
|
{ x: boxLeft, y: boxBottom },
|
|
{ x: boxRight, y: boxBottom },
|
|
]
|
|
for (const c of corners) {
|
|
const scr = worldToScreen(c.x, c.y, 0, CAMERA_Y_OFFSET, VIEWPORT_800x600)
|
|
expect(sceneSource.pickUnitAtScreen(scr.x, scr.y, 50)?.unitId).toBe(501)
|
|
}
|
|
|
|
// 3. Points 2px outside the bounding box MUST NOT be picked
|
|
const outsidePoints = [
|
|
{ x: boxLeft - 2, y: centerY },
|
|
{ x: boxRight + 2, y: centerY },
|
|
{ x: centerX, y: boxTop - 2 },
|
|
{ x: centerX, y: boxBottom + 2 },
|
|
]
|
|
for (const o of outsidePoints) {
|
|
const scr = worldToScreen(o.x, o.y, 0, CAMERA_Y_OFFSET, VIEWPORT_800x600)
|
|
expect(sceneSource.pickUnitAtScreen(scr.x, scr.y, 50)).toBeNull()
|
|
}
|
|
})
|
|
|
|
it('Issue #617: Selection flags matrix respects isSel=false, noSel=true, and corpseSel', () => {
|
|
const unselectable1: RenderableUnitInput = {
|
|
kind: 'monster',
|
|
id: 601,
|
|
classId: 10,
|
|
x: 20,
|
|
y: 20,
|
|
noSel: true,
|
|
hitBox: { left: -20, top: -20, width: 40, height: 40 },
|
|
}
|
|
const unselectable2: RenderableUnitInput = {
|
|
kind: 'monster',
|
|
id: 602,
|
|
classId: 10,
|
|
x: 30,
|
|
y: 30,
|
|
isSel: false,
|
|
hitBox: { left: -20, top: -20, width: 40, height: 40 },
|
|
}
|
|
const corpseUnselectable: RenderableUnitInput = {
|
|
kind: 'monster',
|
|
id: 603,
|
|
classId: 10,
|
|
x: 40,
|
|
y: 40,
|
|
isCorpse: true,
|
|
corpseSel: false,
|
|
hitBox: { left: -20, top: -20, width: 40, height: 40 },
|
|
}
|
|
const corpseSelectable: RenderableUnitInput = {
|
|
kind: 'monster',
|
|
id: 604,
|
|
classId: 10,
|
|
x: 50,
|
|
y: 50,
|
|
isCorpse: true,
|
|
corpseSel: true,
|
|
hitBox: { left: -20, top: -20, width: 40, height: 40 },
|
|
}
|
|
|
|
const sceneSource = new ClientWorldSceneSource(
|
|
() => ({
|
|
tick: 0,
|
|
playerSubX: 0,
|
|
playerSubY: 0,
|
|
units: [unselectable1, unselectable2, corpseUnselectable, corpseSelectable],
|
|
levelView: null,
|
|
}),
|
|
VIEWPORT_800x600,
|
|
)
|
|
|
|
// noSel: true -> rejected
|
|
const w1 = subTileToWorldOrtho(20, 20, 0, 0)
|
|
const s1 = worldToScreen(w1.x, w1.y, 0, CAMERA_Y_OFFSET, VIEWPORT_800x600)
|
|
expect(sceneSource.pickUnitAtScreen(s1.x, s1.y, 50)).toBeNull()
|
|
|
|
// isSel: false -> rejected
|
|
const w2 = subTileToWorldOrtho(30, 30, 0, 0)
|
|
const s2 = worldToScreen(w2.x, w2.y, 0, CAMERA_Y_OFFSET, VIEWPORT_800x600)
|
|
expect(sceneSource.pickUnitAtScreen(s2.x, s2.y, 50)).toBeNull()
|
|
|
|
// isCorpse: true, corpseSel: false -> rejected
|
|
const w3 = subTileToWorldOrtho(40, 40, 0, 0)
|
|
const s3 = worldToScreen(w3.x, w3.y, 0, CAMERA_Y_OFFSET, VIEWPORT_800x600)
|
|
expect(sceneSource.pickUnitAtScreen(s3.x, s3.y, 50)).toBeNull()
|
|
|
|
// isCorpse: true, corpseSel: true -> accepted
|
|
const w4 = subTileToWorldOrtho(50, 50, 0, 0)
|
|
const s4 = worldToScreen(w4.x, w4.y, 0, CAMERA_Y_OFFSET, VIEWPORT_800x600)
|
|
expect(sceneSource.pickUnitAtScreen(s4.x, s4.y, 50)?.unitId).toBe(604)
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 4. ANIMATED TILES STEP RATE (80/256 FRAMES/TICK, ~7.8 FPS) ACROSS 1000 TICKS
|
|
// ---------------------------------------------------------------------------
|
|
describe('4. Animated Tiles 1000-Tick Step Rate (#613, #615)', () => {
|
|
it('advances at strictly 80/256 frames per tick without skipping frames across 1000 ticks', () => {
|
|
let prevStep = 0
|
|
let totalFrameAdvances = 0
|
|
|
|
for (let tick = 0; tick <= 1000; tick += 1) {
|
|
const step = Math.floor((tick * 80) / 256)
|
|
const bitwiseStep = (tick * 80) >> 8
|
|
|
|
// 1. Bitwise shift identity must hold identically for all non-negative ticks
|
|
expect(step).toBe(bitwiseStep)
|
|
|
|
if (tick > 0) {
|
|
const delta = step - prevStep
|
|
// 2. Each tick step delta MUST be either 0 or 1 (never skip a frame!)
|
|
expect(delta === 0 || delta === 1).toBe(true)
|
|
if (delta === 1) totalFrameAdvances += 1
|
|
}
|
|
prevStep = step
|
|
}
|
|
|
|
// 3. At tick 1000: floor(1000 * 80 / 256) = floor(312.5) = 312
|
|
expect(prevStep).toBe(312)
|
|
expect(totalFrameAdvances).toBe(312)
|
|
|
|
// 4. Average frame rate across 1000 ticks (40 seconds at 25Hz):
|
|
// 312.5 frames / 40 seconds = 7.8125 fps (~7.8 fps)
|
|
const elapsedSeconds = (1000 * 40) / 1000
|
|
const measuredFps = 312.5 / elapsedSeconds
|
|
expect(measuredFps).toBeCloseTo(7.8125, 4)
|
|
})
|
|
|
|
it('verifies cyclic modulo indexing for arbitrary frame counts (2, 4, 8, 16) without dead frames', () => {
|
|
const frameCounts = [2, 3, 4, 5, 8, 12, 16]
|
|
|
|
for (const count of frameCounts) {
|
|
const visitedFrames = new Set<number>()
|
|
for (let tick = 0; tick <= 1000; tick += 1) {
|
|
const step = Math.floor((tick * 80) / 256)
|
|
const frameIdx = Math.abs(step) % count
|
|
visitedFrames.add(frameIdx)
|
|
}
|
|
// Across 1000 ticks, all frame indices in the animation cycle MUST be visited
|
|
expect(visitedFrames.size).toBe(count)
|
|
for (let f = 0; f < count; f += 1) {
|
|
expect(visitedFrames.has(f)).toBe(true)
|
|
}
|
|
}
|
|
})
|
|
|
|
it('guarantees lockstep equivalence between snapshot.tick and nowMs', () => {
|
|
for (let tick = 0; tick <= 500; tick += 1) {
|
|
const nowMs = tick * 40 // 40ms per tick
|
|
const tickStep = Math.floor((tick * 80) / 256)
|
|
const msStep = Math.floor((nowMs * 80) / (40 * 256))
|
|
expect(tickStep).toBe(msStep)
|
|
}
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 5. DEDICATED HTH TOKENS IN ANIMDATA.D2 (#613)
|
|
// ---------------------------------------------------------------------------
|
|
describe('5. Dedicated HTH Tokens in AnimData.d2 (#613)', () => {
|
|
it('verifies authentic AnimData.d2 records in animdata.json have dedicated HTH clips for all 7 classes', () => {
|
|
const animdataPath = join(process.cwd(), 'samples/d2-packs/anim/animdata.json')
|
|
expect(existsSync(animdataPath)).toBe(true)
|
|
|
|
const animdata = JSON.parse(readFileSync(animdataPath, 'utf8'))
|
|
const clips = animdata.clips
|
|
expect(clips).toBeDefined()
|
|
|
|
// 1. Sorceress dedicated HTH clips
|
|
expect(clips.SOBLHTH).toBeDefined()
|
|
expect(clips.SOBLHTH.fpd).toBe(5)
|
|
expect(clips.SOBLHTH.speed).toBe(256)
|
|
|
|
expect(clips.SOKKHTH).toBeDefined()
|
|
expect(clips.SOKKHTH.fpd).toBe(12)
|
|
expect(clips.SOKKHTH.speed).toBe(256)
|
|
|
|
expect(clips.SOTHHTH).toBeDefined()
|
|
expect(clips.SOTHHTH.fpd).toBe(20)
|
|
expect(clips.SOTHHTH.speed).toBe(256)
|
|
|
|
expect(clips.SOS1HTH).toBeDefined()
|
|
expect(clips.SOS1HTH.fpd).toBe(14)
|
|
expect(clips.SOS1HTH.speed).toBe(256)
|
|
|
|
// 2. Paladin dedicated HTH clips
|
|
expect(clips.PABLHTH).toBeDefined()
|
|
expect(clips.PABLHTH.fpd).toBe(3)
|
|
expect(clips.PAKKHTH).toBeDefined()
|
|
expect(clips.PAKKHTH.fpd).toBe(12)
|
|
expect(clips.PATHHTH).toBeDefined()
|
|
expect(clips.PATHHTH.fpd).toBe(12)
|
|
|
|
// 3. Barbarian dedicated HTH clips
|
|
expect(clips.BABLHTH).toBeDefined()
|
|
expect(clips.BABLHTH.fpd).toBe(4)
|
|
expect(clips.BATHHTH).toBeDefined()
|
|
expect(clips.BATHHTH.fpd).toBe(16)
|
|
|
|
// 4. Assassin dedicated HTH clips
|
|
expect(clips.AIBLHTH).toBeDefined()
|
|
expect(clips.AIBLHTH.fpd).toBe(3)
|
|
expect(clips.AIKKHTH).toBeDefined()
|
|
expect(clips.AIKKHTH.fpd).toBe(13)
|
|
expect(clips.AIS2HTH).toBeDefined()
|
|
expect(clips.AIS2HTH.fpd).toBe(8)
|
|
expect(clips.AIS2HTH.speed).toBe(128)
|
|
})
|
|
|
|
it('rejects synthetic fallback aliasing when a player clip is missing: reports missing art without drawing wrong action', () => {
|
|
clearMissingArtEntries()
|
|
const renderer = new MockSpriteRenderer()
|
|
const mockCharArt: LoadedCharacter = {
|
|
handle: 100 as any,
|
|
groups: [[{ x: 0, y: 0, width: 32, height: 48, anchorX: -16, anchorY: -48 }]],
|
|
directions: 1,
|
|
walk: 0,
|
|
stand: 0,
|
|
members: 1,
|
|
clips: {
|
|
nu: { group: 0, directions: 1, frames: 1, speed: 256, loop: true, events: [] },
|
|
a1: { group: 0, directions: 1, frames: 1, speed: 256, loop: false, events: [] },
|
|
},
|
|
}
|
|
|
|
// Requesting Throw (mode 11, 'th') which is NOT registered in clips
|
|
const throwingSorceress: RenderableUnitInput = {
|
|
kind: 'player',
|
|
id: 777,
|
|
classId: 1, // SO
|
|
mode: 11, // TH
|
|
x: 0,
|
|
y: 0,
|
|
}
|
|
|
|
const built = buildSceneDrawables([throwingSorceress])
|
|
const snapshot: SceneFrameSnapshot = {
|
|
tick: 0,
|
|
actId: 1,
|
|
levelId: 1,
|
|
viewportProfile: VIEWPORT_800x600,
|
|
worldReady: true,
|
|
playerSubTile: { x: 0, y: 0 },
|
|
playerLocalSubTile: { x: 0, y: 0 },
|
|
playerWorld: { x: 0, y: 0 },
|
|
cameraTopLeft: { x: -400, y: -300 },
|
|
floorQuads: [],
|
|
shadowQuads: [],
|
|
lowerWallQuads: [],
|
|
upperWallQuads: [],
|
|
roofQuads: [],
|
|
entities: built,
|
|
}
|
|
const ctx: Partial<SceneSourceRenderContext> = {
|
|
lightingPreset: 'noon',
|
|
characterArtByToken: new Map([['so', mockCharArt]]),
|
|
}
|
|
|
|
renderWorldFrame(renderer, snapshot, ctx, 0)
|
|
|
|
// MUST NOT draw 'a1' or 'nu' as fallback!
|
|
expect(renderer.drawCalls).toHaveLength(0)
|
|
|
|
// MUST record the exact missing art entry
|
|
const missing = getMissingArtEntries()
|
|
expect(missing.some(m => m.category === 'player-clip' && m.id === 'SO:th')).toBe(true)
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 6. TOWN PORTAL ONE-SHOT & DETERMINISTIC FRAME SYNC (#608)
|
|
// ---------------------------------------------------------------------------
|
|
describe('6. Town Portal One-Shot & Deterministic Frame Sync (#608)', () => {
|
|
const mockTrArt: LoadedOverlayArt = {
|
|
meta: { name: 'town_portal_op_tr', width: 64, height: 64, groups: [[[0, 0, 64, 64, -32, -32]]] } as any,
|
|
handle: 41 as any,
|
|
frames: [
|
|
Array.from({ length: 15 }, (_, i) => ({
|
|
x: i * 64,
|
|
y: 0,
|
|
width: 64,
|
|
height: 64,
|
|
anchorX: -32,
|
|
anchorY: -32,
|
|
})),
|
|
],
|
|
}
|
|
const mockHdArt: LoadedOverlayArt = {
|
|
meta: { name: 'town_portal_op_hd', width: 64, height: 64, groups: [[[0, 0, 64, 64, -32, -32]]] } as any,
|
|
handle: 42 as any,
|
|
frames: [
|
|
Array.from({ length: 15 }, (_, i) => ({
|
|
x: i * 64,
|
|
y: 0,
|
|
width: 64,
|
|
height: 64,
|
|
anchorX: -32,
|
|
anchorY: -32,
|
|
})),
|
|
],
|
|
}
|
|
const overlayMap = new Map<string, LoadedOverlayArt>([
|
|
['town_portal_op_tr', mockTrArt],
|
|
['town_portal_on_tr', mockTrArt],
|
|
['town_portal_op_hd', mockHdArt],
|
|
['town_portal_on_hd', mockHdArt],
|
|
])
|
|
|
|
it('determines 15-frame opening sequence at 51.2ms/frame (768ms total) and transitions to operating loop', () => {
|
|
const renderer = new MockSpriteRenderer()
|
|
|
|
// Test OP sequence progression across frames 0..14
|
|
for (let frameIdx = 0; frameIdx < 15; frameIdx += 1) {
|
|
renderer.begin()
|
|
const elapsedMs = frameIdx * 51.2 + 1.0 // within frame duration
|
|
drawTownPortal(renderer as unknown as SpriteRenderer, 100, 100, elapsedMs, overlayMap)
|
|
|
|
expect(renderer.drawCalls.length).toBe(2)
|
|
// Frame index on TR ground shadow layer
|
|
expect(renderer.drawCalls[0]!.frame.x).toBe(frameIdx * 64)
|
|
expect(renderer.drawCalls[0]!.options.blendMode).toBe('normal')
|
|
|
|
// Frame index on HD vortex layer
|
|
expect(renderer.drawCalls[1]!.frame.x).toBe(frameIdx * 64)
|
|
expect(renderer.drawCalls[1]!.options.blendMode).toBe('additive')
|
|
}
|
|
|
|
// At elapsedMs = 768ms (exactly end of OP): enters ON looping sequence at frame 0
|
|
renderer.begin()
|
|
drawTownPortal(renderer as unknown as SpriteRenderer, 100, 100, 768, overlayMap)
|
|
expect(renderer.drawCalls[0]!.frame.x).toBe(0)
|
|
|
|
// At elapsedMs = 768 + 51.2 * 3 = 921.6ms: frame 3 of ON sequence
|
|
renderer.begin()
|
|
drawTownPortal(renderer as unknown as SpriteRenderer, 100, 100, 921.6, overlayMap)
|
|
expect(renderer.drawCalls[0]!.frame.x).toBe(3 * 64)
|
|
})
|
|
|
|
it('classId 60 (Permanent Red Portal PP) renders with red hue [1.0, 120/255, 120/255, 1.0] across both layers', () => {
|
|
const renderer = new MockSpriteRenderer()
|
|
const redPortal: RenderableUnitInput = {
|
|
kind: 'object',
|
|
id: 801,
|
|
classId: 60, // Permanent Portal PP
|
|
x: 10,
|
|
y: 10,
|
|
animStartedAtMs: 1000,
|
|
}
|
|
|
|
const built = buildSceneDrawables([redPortal])
|
|
const snapshot: SceneFrameSnapshot = {
|
|
tick: 30,
|
|
actId: 1,
|
|
levelId: 1,
|
|
viewportProfile: VIEWPORT_800x600,
|
|
worldReady: true,
|
|
playerSubTile: { x: 10, y: 10 },
|
|
playerLocalSubTile: { x: 10, y: 10 },
|
|
playerWorld: { x: 0, y: 0 },
|
|
cameraTopLeft: { x: -400, y: -300 },
|
|
floorQuads: [],
|
|
shadowQuads: [],
|
|
lowerWallQuads: [],
|
|
upperWallQuads: [],
|
|
roofQuads: [],
|
|
entities: built,
|
|
}
|
|
const ctx: Partial<SceneSourceRenderContext> = {
|
|
lightingPreset: 'noon',
|
|
overlayArtMap: overlayMap,
|
|
}
|
|
|
|
// At nowMs = 1200ms (elapsed = 200ms < 768ms -> in opening sequence)
|
|
renderWorldFrame(renderer, snapshot, ctx, 1200)
|
|
|
|
expect(renderer.drawCalls.length).toBe(2)
|
|
const trCall = renderer.drawCalls[0]!
|
|
const hdCall = renderer.drawCalls[1]!
|
|
|
|
// Red portal MUST have authentic red tint [1.0, 120/255, 120/255, 1.0]
|
|
expect(trCall.options.tint).toBeDefined()
|
|
expect(trCall.options.tint![0]).toBeCloseTo(1.0, 3)
|
|
expect(trCall.options.tint![1]).toBeCloseTo(120 / 255, 3)
|
|
expect(trCall.options.tint![2]).toBeCloseTo(120 / 255, 3)
|
|
expect(trCall.options.blendMode).toBe('normal')
|
|
|
|
expect(hdCall.options.tint).toBeDefined()
|
|
expect(hdCall.options.tint![0]).toBeCloseTo(1.0, 3)
|
|
expect(hdCall.options.tint![1]).toBeCloseTo(120 / 255, 3)
|
|
expect(hdCall.options.tint![2]).toBeCloseTo(120 / 255, 3)
|
|
expect(hdCall.options.blendMode).toBe('additive')
|
|
})
|
|
|
|
it('deterministic phase synchronization between multiple portals without performance.now() drifting', () => {
|
|
const renderer = new MockSpriteRenderer()
|
|
|
|
// Portal A spawned at t=1000, Portal B spawned at t=2000
|
|
const portalA: RenderableUnitInput = {
|
|
kind: 'object',
|
|
id: 901,
|
|
classId: 59,
|
|
x: 10,
|
|
y: 10,
|
|
animStartedAtMs: 1000,
|
|
}
|
|
const portalB: RenderableUnitInput = {
|
|
kind: 'object',
|
|
id: 902,
|
|
classId: 59,
|
|
x: 30,
|
|
y: 20,
|
|
animStartedAtMs: 2000,
|
|
}
|
|
|
|
const built = buildSceneDrawables([portalA, portalB])
|
|
const snapshot: SceneFrameSnapshot = {
|
|
tick: 50,
|
|
actId: 1,
|
|
levelId: 1,
|
|
viewportProfile: VIEWPORT_800x600,
|
|
worldReady: true,
|
|
playerSubTile: { x: 10, y: 10 },
|
|
playerLocalSubTile: { x: 10, y: 10 },
|
|
playerWorld: { x: 0, y: 0 },
|
|
cameraTopLeft: { x: -400, y: -300 },
|
|
floorQuads: [],
|
|
shadowQuads: [],
|
|
lowerWallQuads: [],
|
|
upperWallQuads: [],
|
|
roofQuads: [],
|
|
entities: built,
|
|
}
|
|
const ctx: Partial<SceneSourceRenderContext> = {
|
|
lightingPreset: 'noon',
|
|
overlayArtMap: overlayMap,
|
|
}
|
|
|
|
// At nowMs = 2100ms:
|
|
// Portal A has elapsed 1100ms (> 768ms) -> ON loop frame: (1100 - 768) / 51.2 = 6.48 -> frame 6
|
|
// Portal B has elapsed 100ms (< 768ms) -> OP opening frame: 100 / 51.2 = 1.95 -> frame 1
|
|
renderWorldFrame(renderer, snapshot, ctx, 2100)
|
|
expect(renderer.drawCalls.length).toBe(4)
|
|
|
|
const expectedWorldA = subTileToWorldOrtho(10, 10, 0, 0)
|
|
const expectedWorldB = subTileToWorldOrtho(30, 20, 0, 0)
|
|
|
|
const portalADraws = renderer.drawCalls.filter(c => c.x === expectedWorldA.x - 32 && c.y === expectedWorldA.y - 32)
|
|
const portalBDraws = renderer.drawCalls.filter(c => c.x === expectedWorldB.x - 32 && c.y === expectedWorldB.y - 32)
|
|
|
|
expect(portalADraws.length).toBe(2)
|
|
expect(portalBDraws.length).toBe(2)
|
|
|
|
expect(portalADraws[0]!.frame.x).toBe(6 * 64)
|
|
expect(portalBDraws[0]!.frame.x).toBe(1 * 64)
|
|
})
|
|
})
|