diff --git a/src/client/render/renderer.ts b/src/client/render/renderer.ts index bf41549..d72784f 100644 --- a/src/client/render/renderer.ts +++ b/src/client/render/renderer.ts @@ -86,8 +86,8 @@ export interface DrawOptions { readonly width?: number /** Destination height in pixels (defaults to frame.height). */ readonly height?: number - /** Blend mode for rendering: 'normal' (alpha blend) or 'additive' (translucent spell glow). */ - readonly blendMode?: 'normal' | 'additive' + /** Blend mode for rendering: 'normal' (alpha blend), 'additive'/'highlight', or any D2Gfx DrawMode. */ + readonly blendMode?: DrawBlendMode /** * Optional vertical screen height (in scene pixels) of the sprite's top edge above its ground base. * Compensates isometric lightmap UV projection for upright geometry (walls, pillars, actors) @@ -96,6 +96,31 @@ export interface DrawOptions { readonly elevationY?: number } +/** + * Authentic D2Gfx 8 blending modes plus legacy 'additive' alias. + * + * Ground truth (D2MOO D2Gfx/include/DrawMode.h:3-12): + * - normal: Standard alpha composite + * - additive: Full additive blend (S + D), alias for highlight + * - trans25: 25% opacity (0.25*S + 0.75*D) + * - trans50: 50% opacity (0.50*S + 0.50*D) + * - trans75: 75% opacity (0.75*S + 0.25*D) + * - modulate: Multiplicative color blend ((S * D) / 255) + * - burn: Subtractive darkening / color burn (max(0, D + S - 255)) + * - transhighlight: Translucent additive highlight (D + 0.5*S) + * - highlight: Full additive highlight (min(255, S + D)) + */ +export type DrawBlendMode = + | 'normal' + | 'additive' + | 'trans25' + | 'trans50' + | 'trans75' + | 'modulate' + | 'burn' + | 'transhighlight' + | 'highlight' + /** * Optional transform tables or full PL2 file for authentic 1.13c hardware palette rows. */ @@ -382,8 +407,8 @@ export class SpriteRenderer { private frameDrawCalls = 0 /** Quads submitted since the last {@link begin}. */ private frameQuads = 0 - /** Current active blend mode ('normal' or 'additive'). */ - private currentBlendMode: 'normal' | 'additive' = 'normal' + /** Current active blend mode. */ + private currentBlendMode: DrawBlendMode = 'normal' /** All textures allocated and managed by this renderer. */ private readonly allocatedTextures = new Set() @@ -577,8 +602,8 @@ export class SpriteRenderer { return this.maxBatchTextures } - /** Current blending mode ('normal' or 'additive'). */ - get blendMode(): 'normal' | 'additive' { + /** Current blending mode. */ + get blendMode(): DrawBlendMode { return this.currentBlendMode } @@ -586,16 +611,31 @@ export class SpriteRenderer { * Set the blend mode for subsequent quads. * Flushes any pending quads before switching GPU blend states. */ - setBlendMode(mode: 'normal' | 'additive'): void { + setBlendMode(mode: DrawBlendMode): void { if (this.disposed) return if (this.currentBlendMode === mode) return this.flush() this.currentBlendMode = mode const gl = this.gl - if (mode === 'additive') { - gl.blendFunc(gl.SRC_ALPHA, gl.ONE) - } else { - gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA) + switch (mode) { + case 'additive': + case 'highlight': + case 'transhighlight': + gl.blendFunc(gl.SRC_ALPHA, gl.ONE) + break + case 'modulate': + gl.blendFunc(gl.DST_COLOR, gl.ZERO) + break + case 'burn': + gl.blendFunc(gl.ZERO, gl.SRC_COLOR) + break + case 'trans25': + case 'trans50': + case 'trans75': + case 'normal': + default: + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA) + break } } @@ -1153,6 +1193,13 @@ export class SpriteRenderer { const u1 = (frame.x + frame.width) / page.width const v1 = (frame.y + frame.height) / page.height const tint = options.tint + let defaultAlpha = 1.0 + if (blendMode === 'trans25') defaultAlpha = 0.25 + else if (blendMode === 'trans50') defaultAlpha = 0.50 + else if (blendMode === 'trans75') defaultAlpha = 0.75 + else if (blendMode === 'transhighlight') defaultAlpha = 0.50 + const quadAlpha = tint === undefined ? defaultAlpha : tint[3] * defaultAlpha + const flipped = options.flipX === true const paletteRow = page.indexed === true ? (options.paletteRow ?? 0) : -1 const elevationY = options.elevationY !== undefined && options.elevationY > 0 ? Math.round(options.elevationY) : 0 @@ -1169,7 +1216,7 @@ export class SpriteRenderer { tint === undefined ? 1 : tint[0], tint === undefined ? 1 : tint[1], tint === undefined ? 1 : tint[2], - tint === undefined ? 1 : tint[3], + quadAlpha, unit, paletteRow, topPalette, @@ -1212,6 +1259,13 @@ export class SpriteRenderer { const u1 = (frame.x + frame.width) / page.width const v1 = (frame.y + frame.height) / page.height const tint = options.tint + let defaultAlpha = 1.0 + if (blendMode === 'trans25') defaultAlpha = 0.25 + else if (blendMode === 'trans50') defaultAlpha = 0.50 + else if (blendMode === 'trans75') defaultAlpha = 0.75 + else if (blendMode === 'transhighlight') defaultAlpha = 0.50 + const quadAlpha = tint === undefined ? defaultAlpha : tint[3] * defaultAlpha + const flipped = options.flipX === true const paletteRow = page.indexed === true ? (options.paletteRow ?? 0) : -1 const elevationY = options.elevationY !== undefined && options.elevationY > 0 ? Math.round(options.elevationY) : 0 @@ -1223,7 +1277,7 @@ export class SpriteRenderer { tint === undefined ? 1 : tint[0], tint === undefined ? 1 : tint[1], tint === undefined ? 1 : tint[2], - tint === undefined ? 1 : tint[3], + quadAlpha, unit, paletteRow, topPalette, @@ -1246,7 +1300,7 @@ export class SpriteRenderer { width: number, height: number, color: readonly [number, number, number, number], - options: { blendMode?: 'normal' | 'additive' } = {}, + options: { blendMode?: DrawBlendMode } = {}, ): void { if (this.disposed) return const blendMode = options.blendMode ?? 'normal' @@ -1274,7 +1328,7 @@ export class SpriteRenderer { x2: number, y2: number, x3: number, y3: number, color: readonly [number, number, number, number], - options: { blendMode?: 'normal' | 'additive' } = {}, + options: { blendMode?: DrawBlendMode } = {}, ): void { if (this.disposed) return const blendMode = options.blendMode ?? 'normal' @@ -1396,7 +1450,11 @@ export class SpriteRenderer { this.cachedLightMode = lightMode } - const blendMode = this.currentBlendMode === 'additive' ? 1 : 0 + const isAdditive = + this.currentBlendMode === 'additive' || + this.currentBlendMode === 'highlight' || + this.currentBlendMode === 'transhighlight' + const blendMode = isAdditive ? 1 : 0 if (blendMode !== this.cachedBlendMode && this.uniformBlendMode !== null) { gl.uniform1i(this.uniformBlendMode, blendMode) this.cachedBlendMode = blendMode diff --git a/src/client/scene/world-renderer.ts b/src/client/scene/world-renderer.ts index 5fc8e6e..bb165f5 100644 --- a/src/client/scene/world-renderer.ts +++ b/src/client/scene/world-renderer.ts @@ -813,8 +813,8 @@ function drawSceneUnitDrawable( const framesProgress = Math.floor((elapsedTicks * stepSpeed256) / 256) frameIndex = Math.min(effectiveTotalFrames - 1, startFrame + framesProgress) } else { - const speed = objArt.frameDelta?.[modeIdx] || clip?.speed || 0 - if (!(speed > 0)) { + const rawSpeed = objArt.frameDelta?.[modeIdx] || clip?.speed || 0 + if (!(rawSpeed > 0)) { reportMissingArt( 'object-clip', `${unit.classId}:${clipKey}`, @@ -822,7 +822,24 @@ function drawSceneUnitDrawable( ) return } - frameIndex = computeAnimDataFrameIndex(nowMs, speed, frameCount, true) + // Issue #607: Looping map objects evaluate Sync. + // For Sync == 0, apply 1.13c speed jitter: + // speed = FrameDelta - (FrameDelta >> 4) + hash(unit.id, FrameDelta >> 3) + // so torches/flames do not animate in lockstep. + const isSync = + unit.sync !== undefined + ? Boolean(unit.sync) + : (objArt as any).sync !== undefined + ? Boolean((objArt as any).sync) + : false + let speed = rawSpeed + if (!isSync) { + const range = Math.max(1, rawSpeed >> 3) + const unitHash = ((Math.imul(unit.unitId, 1664525) + 1013904223) >>> 0) % range + speed = Math.max(1, rawSpeed - (rawSpeed >> 4) + unitHash) + } + const startFrame = (objArt as any).start?.[modeIdx] ?? 0 + frameIndex = (startFrame + computeAnimDataFrameIndex(nowMs, speed, frameCount, true)) % frameCount } } const frame = group?.[frameIndex] ?? group?.[0] diff --git a/src/client/view/scene-source.ts b/src/client/view/scene-source.ts index 135916f..7b903bb 100644 --- a/src/client/view/scene-source.ts +++ b/src/client/view/scene-source.ts @@ -293,7 +293,7 @@ export class ClientWorldSceneSource implements SceneSource { const playerWorld = this.getPlayerWorldOrtho() return { x: playerWorld.x - profile.width / 2, - y: playerWorld.y - profile.height / 2, + y: playerWorld.y + CAMERA_Y_OFFSET - profile.height / 2, } } @@ -452,6 +452,7 @@ export class ClientWorldSceneSource implements SceneSource { let best: SceneUnitDrawable | null = null let bestDistSq = hitRadiusPx * hitRadiusPx + let bestFromHitBox = false const localPlayerX = st.playerSubX - shiftX const localPlayerY = st.playerSubY - shiftY @@ -484,13 +485,18 @@ export class ClientWorldSceneSource implements SceneSource { const centerX = (boxLeft + boxRight) / 2 const centerY = (boxTop + boxBottom) / 2 const distSq = (clickWorld.x - centerX) ** 2 + (clickWorld.y - centerY) ** 2 - if (best === null || distSq < bestDistSq) { + if (!bestFromHitBox || distSq < bestDistSq) { bestDistSq = distSq best = u + bestFromHitBox = true } } continue } + // Prioritize units whose bounding boxes contain the click over units evaluated via radial distance fallback + if (bestFromHitBox) { + continue + } // Sprite visual center is roughly 24px above foot anchor; warp/tile/item also test foot anchor directly const dx = clickWorld.x - u.worldX const dyBody = clickWorld.y - (u.worldY - 24) diff --git a/src/client/view/unit-drawables.ts b/src/client/view/unit-drawables.ts index 2b08331..3df8a07 100644 --- a/src/client/view/unit-drawables.ts +++ b/src/client/view/unit-drawables.ts @@ -3,6 +3,7 @@ import { CANONICAL_113C_OVERLAYS, MISSILE_CLASS_ID_TO_KEY, getMissileTxtData, + getOverlayTxtData, getSkillCastOverlay, } from '../../common/skills/missiles-data.ts' import { BAKED_UI_MANIFEST } from '../../common/items/baked-ui-meta.ts' @@ -14,7 +15,54 @@ import { subTileToWorldOrtho } from './viewport-profile.ts' type MissileTxtEntry = NonNullable> -export type DrawableBlendMode = 'normal' | 'additive' +export type DrawableBlendMode = + | 'normal' + | 'additive' + | 'trans25' + | 'trans50' + | 'trans75' + | 'modulate' + | 'burn' + | 'transhighlight' + | 'highlight' + +/** + * Map authentic D2Gfx DrawMode / MPQ `trans` values (from Overlay.txt and Missiles.txt) + * to {@link DrawableBlendMode}. + * + * Ground truth (D2MOO D2Gfx/include/DrawMode.h:3-12, Overlay.txt, Missiles.txt): + * - 0 -> 'trans25' (Mode 0: TRANS25, 25% opacity) + * - 1 -> 'highlight' (Mode 7: HIGHLIGHT, additive blend S + D) + * - 2 -> 'trans75' (Mode 2: TRANS75, 75% opacity) + * - 3 -> 'trans50' (Mode 1: TRANS50, 50% opacity) + * - 4 -> 'burn' (Mode 4: BURN, color burn) + * - 5 -> 'normal' (Mode 5: NORMAL, standard alpha composite) + * - 6 -> 'modulate' (Mode 3: MODULATE, color multiplication S x D) + * - 7 -> 'transhighlight' (Mode 6: TRANSHIGHLIGHT, blended additive highlight) + */ +export function transToBlendMode(trans: number | undefined): DrawableBlendMode { + switch (trans) { + case 0: + return 'trans25' + case 1: + return 'highlight' + case 2: + return 'trans75' + case 3: + return 'trans50' + case 4: + return 'burn' + case 5: + return 'normal' + case 6: + return 'modulate' + case 7: + return 'transhighlight' + default: + return 'normal' + } +} + export type SceneUnitKind = UnitKind | 'warp' export interface UnitOverlayState { @@ -22,7 +70,7 @@ export interface UnitOverlayState { readonly token?: string | undefined readonly frame?: number | undefined readonly totalFrames?: number | undefined - readonly blendMode?: DrawableBlendMode | 0 | 1 | 2 | 3 | undefined + readonly blendMode?: DrawableBlendMode | number | undefined readonly yOffset?: number | undefined readonly loop?: boolean | undefined readonly lightRadius?: number | undefined @@ -102,6 +150,7 @@ export interface RenderableUnitInput { readonly noSel?: boolean | undefined readonly corpseSel?: boolean | undefined readonly paletteRow?: number | undefined + readonly sync?: number | boolean | undefined } export type RenderableUnit = RenderableUnitInput @@ -154,6 +203,7 @@ export interface SceneUnitDrawable { readonly noSel?: boolean | undefined readonly corpseSel?: boolean | undefined readonly paletteRow?: number | undefined + readonly sync?: number | boolean | undefined } export interface SceneMissileDrawable { @@ -452,7 +502,7 @@ export function buildSceneDrawables( const resolved = resolveMissileMetadata(unit.classId, unit.missileKey ?? unit.token ?? unit.name) if (!resolved) continue // Unknown Missiles.txt id: not drawn (no substitute art). const { key, entry } = resolved - const blendMode: DrawableBlendMode = entry.trans === 1 ? 'additive' : 'normal' + const blendMode: DrawableBlendMode = transToBlendMode(entry.trans) const dir = computeMissileClipDirection(unit, entry.numDirections || 16) const totalFrames = Math.max(1, unit.totalFrames ?? entry.animLen ?? 8) const loopAnim = unit.loopAnim !== undefined ? unit.loopAnim : entry.loopAnim !== 0 @@ -528,14 +578,10 @@ export function buildSceneDrawables( typeof ov.blendMode === 'string' ? ov.blendMode : typeof ov.blendMode === 'number' - ? ov.blendMode === 0 - ? 'normal' - : 'additive' + ? transToBlendMode(ov.blendMode) : ovTxt - ? ovTxt.trans === 0 - ? 'normal' - : 'additive' - : (meta?.blendMode ?? 'additive') + ? transToBlendMode(ovTxt.trans) + : (meta?.blendMode ?? 'normal') const ovDrawable: SceneOverlayDrawable = { drawableKind: 'overlay', ownerUnitId: unit.id, @@ -675,15 +721,13 @@ export function buildSceneDrawables( castOverlayKey && !unitOverlays.some((o) => o.token.toLowerCase() === castOverlayKey.toLowerCase()) ) { - const ovTxt = CANONICAL_113C_OVERLAYS[castOverlayKey.toLowerCase()] + const ovTxt = + CANONICAL_113C_OVERLAYS[castOverlayKey.toLowerCase()] ?? + getOverlayTxtData(castOverlayKey) if (ovTxt) { const totalFrames = Math.max(1, ovTxt.frames) const animRate = Math.max(1, ovTxt.animRate || 16) - const elapsedMs = - activeCastState.elapsedMs ?? - (activeCastState.startedAtMs && typeof performance !== 'undefined' - ? Math.max(0, performance.now() - activeCastState.startedAtMs) - : 0) + const elapsedMs = activeCastState.elapsedMs ?? 0 const elapsedTicks = elapsedMs / 40 const frame = Math.min( totalFrames - 1, @@ -700,7 +744,7 @@ export function buildSceneDrawables( worldX, worldY: worldY - (ovTxt.yOffset ?? 0), sortY: worldY, - blendMode: ovTxt.trans === 0 ? 'normal' : 'additive', + blendMode: transToBlendMode(ovTxt.trans), } unitOverlays.push(ovDrawable) overlayDrawables.push(ovDrawable) @@ -780,6 +824,7 @@ export function buildSceneDrawables( ...(unit.noSel !== undefined ? { noSel: unit.noSel } : {}), ...(unit.corpseSel !== undefined ? { corpseSel: unit.corpseSel } : {}), ...(activePaletteRow !== undefined ? { paletteRow: activePaletteRow } : {}), + ...(unit.sync !== undefined ? { sync: unit.sync } : {}), } unitDrawables.push(unitDrawable) } diff --git a/src/client/view/viewport-profile.ts b/src/client/view/viewport-profile.ts index 6065091..45b8a5e 100644 --- a/src/client/view/viewport-profile.ts +++ b/src/client/view/viewport-profile.ts @@ -301,7 +301,7 @@ export function subTileToScreen( const dSubY = subY - camSubY return { x: profile.width / 2 + (dSubX - dSubY) * ORTHO_SUB_TILE_WIDTH, - y: profile.height / 2 + (dSubX + dSubY) * ORTHO_SUB_TILE_HEIGHT, + y: profile.height / 2 + CAMERA_Y_OFFSET + (dSubX + dSubY) * ORTHO_SUB_TILE_HEIGHT, } } @@ -317,7 +317,7 @@ export function screenToSubTile( profile: ViewportProfile = VIEWPORT_800x600, ): { x: number; y: number } { const relX = screenX - profile.width / 2 - const relY = screenY - profile.height / 2 + const relY = screenY - (profile.height / 2 + CAMERA_Y_OFFSET) const dx = relX / ORTHO_SUB_TILE_WIDTH const dy = relY / ORTHO_SUB_TILE_HEIGHT return { diff --git a/tests/challenger-m4-animation-dcc-stress.test.ts b/tests/challenger-m4-animation-dcc-stress.test.ts new file mode 100644 index 0000000..960353f --- /dev/null +++ b/tests/challenger-m4-animation-dcc-stress.test.ts @@ -0,0 +1,980 @@ +/** + * 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, + 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() + 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 = { + 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() + 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 = { + 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([ + ['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 = { + 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 = { + 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) + }) +}) diff --git a/tests/challenger-m4-lighting-automap-stress.test.ts b/tests/challenger-m4-lighting-automap-stress.test.ts new file mode 100644 index 0000000..61786c0 --- /dev/null +++ b/tests/challenger-m4-lighting-automap-stress.test.ts @@ -0,0 +1,1023 @@ +/** + * Challenger M4-2 — Empirical Lighting, Shaders, Flippy & Automap Cels Stress Test Suite + * + * Adversarially verifies: + * 1. Data-Driven Point Lights & Player Radius (#605, #606): + * - Canonical MPQ static tables for Objects, Monsters, Missiles, and Overlays. + * - Mode 10 casting light removal from non-casting monsters (MonMode S3, #605). + * - Base 11 sub-tiles player radius with bonus math, zero sine flicker, zero 20/22 inflation. + * - True pitch-black darkness: ambient 0 produces [0, 0, 0] ambient light without floor clamping. + * 2. Elevation-Aware Vertex Lighting (#614): + * - SpriteRenderer quad top vertices modified by `elevationY * 16`. + * - 10-float vertex stride preservation under elevation offsets. + * - Fragment shader applies linear light curves (`dynamicLight = lightSample.rgb;`). + * 3. Ground Item Flippy Resolution & Fail-Fast Hardening (#609): + * - Unique and set items prioritize authentic `flippyFile` over base item row. + * - Fail-fast throwing on missing/empty item code without silent fallback to ring ('rin'). + * 4. Automap Cel Extraction & Landmark Cleanliness (#622, #623): + * - Zero synthetic automap cels (no floor cel 0 or lava wall cel 9). + * - Zero synthetic markers (no cel 309 well, no cel 306 seal). + * - Waypoint markers placed strictly from valid entrance rooms, never phantom room 0. + */ + +import { describe, it, expect } from 'vitest' +import { + applyLightingInputs, + CANONICAL_MONSTER_LIGHTS, + CANONICAL_OBJECT_LIGHTS, + CANONICAL_MISSILE_LIGHTS, + CANONICAL_OVERLAY_LIGHTS, + getMonsterLightConfig, + getObjectLightConfig, + getMissileLightConfig, + getOverlayLightConfig, + type PointLightInput, +} from '../src/client/scene/lighting-presets.ts' +import { LightGrid, LIGHT_GRID_SIZE } from '../src/common/drlg/light-grid.ts' +import type { CollisionGrid } from '../src/common/world/d2map.ts' +import { + SpriteRenderer, + fragmentShaderSource, + type AtlasHandle, +} from '../src/client/render/renderer.ts' +import { + buildSceneDrawables, + type RenderableUnitInput, +} from '../src/client/view/unit-drawables.ts' +import { + onlineItemDataToUiInventoryItem, + itemToUiInventoryItem, +} from '../src/common/items/item-bridge.ts' +import { getEmbeddedDropTables } from '../src/common/items/embedded-drop-tables.ts' +import { + Minimap, + type MinimapLevel, + type MinimapMarker, +} from '../src/client/ui/minimap.ts' +import { AutomapView } from '../src/client/automap/automap-view.ts' +import type { LevelView } from '../src/client/map/level-view.ts' +import type { Item, ItemBase } from '../src/common/items/items.ts' +import { ItemQuality } from '../src/common/items/items.ts' + +// --- Mock Helpers --- + +function createMockCollisionGrid(subWidth = 100, subHeight = 100): CollisionGrid { + return { + cellsX: Math.ceil(subWidth / 5), + cellsY: Math.ceil(subHeight / 5), + gridWidth: subWidth, + gridHeight: subHeight, + originX: 0, + originY: 0, + blocked: new Uint8Array(subWidth * subHeight), + collisionMasks: new Uint16Array(subWidth * subHeight), + } as unknown as CollisionGrid +} + +function createMockRendererCanvas(): { canvas: any; gl: any; shaderSources: string[] } { + let nextId = 1 + const shaderSources: string[] = [] + + const gl: any = { + VERTEX_SHADER: 35633, + FRAGMENT_SHADER: 35632, + COMPILE_STATUS: 35713, + LINK_STATUS: 35714, + MAX_TEXTURE_SIZE: 4096, + MAX_TEXTURE_IMAGE_UNITS: 16, + TEXTURE_2D: 3553, + RGBA: 6408, + RGBA8: 32856, + R8: 33321, + RED: 6403, + UNSIGNED_BYTE: 5121, + UNSIGNED_INT: 5125, + FLOAT: 5126, + ARRAY_BUFFER: 34962, + ELEMENT_ARRAY_BUFFER: 34963, + DYNAMIC_DRAW: 35048, + STATIC_DRAW: 35044, + TRIANGLES: 4, + TEXTURE_MIN_FILTER: 10241, + TEXTURE_MAG_FILTER: 10240, + TEXTURE_WRAP_S: 10242, + TEXTURE_WRAP_T: 10243, + CLAMP_TO_EDGE: 33071, + NEAREST: 9728, + UNPACK_ALIGNMENT: 3317, + BLEND: 3042, + SRC_ALPHA: 770, + ONE_MINUS_SRC_ALPHA: 771, + DEPTH_TEST: 2929, + COLOR_BUFFER_BIT: 16384, + TEXTURE0: 33984, + + drawingBufferWidth: 800, + drawingBufferHeight: 600, + + isContextLost: () => false, + + createProgram: () => ({ id: nextId++, type: 'program' }), + deleteProgram: () => {}, + attachShader: () => {}, + detachShader: () => {}, + linkProgram: () => {}, + getProgramParameter: () => true, + getProgramInfoLog: () => '', + useProgram: () => {}, + + createShader: (type: number) => ({ id: nextId++, type }), + shaderSource: (_shader: any, source: string) => { shaderSources.push(source) }, + compileShader: () => {}, + getShaderParameter: () => true, + getShaderInfoLog: () => '', + deleteShader: () => {}, + + createVertexArray: () => ({ id: nextId++, type: 'vao' }), + deleteVertexArray: () => {}, + bindVertexArray: () => {}, + + createBuffer: () => ({ id: nextId++, type: 'buffer' }), + deleteBuffer: () => {}, + bindBuffer: () => {}, + bufferData: () => {}, + bufferSubData: () => {}, + + getAttribLocation: (_p: any, name: string) => { + switch (name) { + case 'a_position': return 0 + case 'a_uv': return 1 + case 'a_tint': return 2 + case 'a_unit': return 3 + case 'a_palette': return 4 + default: return -1 + } + }, + enableVertexAttribArray: () => {}, + vertexAttribPointer: () => {}, + + createTexture: () => ({ id: nextId++, type: 'texture' }), + deleteTexture: () => {}, + bindTexture: () => {}, + texParameteri: () => {}, + pixelStorei: () => {}, + texImage2D: () => {}, + texSubImage2D: () => {}, + texStorage2D: () => {}, + + getParameter: (param: number) => (param === 3379 ? 4096 : param === 34930 ? 16 : 0), + getUniformLocation: (_p: any, name: string) => ({ name }), + + uniform2f: () => {}, + uniform1f: () => {}, + uniform1i: () => {}, + uniform1iv: () => {}, + + enable: () => {}, + disable: () => {}, + blendFunc: () => {}, + viewport: () => {}, + clearColor: () => {}, + clear: () => {}, + activeTexture: () => {}, + drawArrays: () => {}, + drawElements: () => {}, + } + + const canvas: any = { + getContext: (type: string) => (type === 'webgl2' ? gl : null), + addEventListener: () => {}, + removeEventListener: () => {}, + drawingBufferWidth: 800, + drawingBufferHeight: 600, + } + + return { canvas, gl, shaderSources } +} + +describe('Challenger M4-2: Empirical Verification Suite', () => { + const dropTables = getEmbeddedDropTables() + + // ========================================================================= + // Section 1: Data-Driven Point Lights Parity & Canonical MPQ Metadata (#605, #606) + // ========================================================================= + describe('1. Data-Driven Point Lights & Mode 10 Parity (#605, #606)', () => { + it('verifies canonical MPQ objects.txt point lights metadata', () => { + expect(Object.keys(CANONICAL_OBJECT_LIGHTS).length).toBeGreaterThanOrEqual(200) + + // Object 26: lit radius 10 in mode 1, [255, 255, 255], flicker 0 + const obj26 = getObjectLightConfig(26, 1) + expect(obj26).not.toBeNull() + expect(obj26!.radius).toBe(10) + expect(obj26!.r).toBe(255) + expect(obj26!.g).toBe(255) + expect(obj26!.b).toBe(255) + + // Object 38: lit radius 19 in mode 1, RGB [255, 236, 176], flicker 1 + const obj38 = getObjectLightConfig(38, 1) + expect(obj38).not.toBeNull() + expect(obj38!.radius).toBe(19) + expect(obj38!.r).toBe(255) + expect(obj38!.g).toBe(236) + expect(obj38!.b).toBe(176) + expect(obj38!.flicker).toBe(1) + + // Object 48: lit radius 19, RGB [255, 236, 176], flicker 1 + const obj48 = getObjectLightConfig(48) + expect(obj48).not.toBeNull() + expect(obj48!.radius).toBe(19) + expect(obj48!.r).toBe(255) + expect(obj48!.g).toBe(236) + expect(obj48!.b).toBe(176) + + // Object 119: Waypoint (mode 1 radius 18, RGB [100, 100, 255], flicker 1) + const waypoint = getObjectLightConfig(119) + expect(waypoint).not.toBeNull() + expect(waypoint!.radius).toBe(18) + expect(waypoint!.r).toBe(100) + expect(waypoint!.g).toBe(100) + expect(waypoint!.b).toBe(255) + expect(waypoint!.flicker).toBe(1) + + // Object 59: Town Portal (mode 1 radius 18, RGB [120, 120, 255], flicker 1) + const townPortal = getObjectLightConfig(59) + expect(townPortal).not.toBeNull() + expect(townPortal!.radius).toBe(18) + expect(townPortal!.r).toBe(120) + expect(townPortal!.g).toBe(120) + expect(townPortal!.b).toBe(255) + expect(townPortal!.flicker).toBe(1) + + // Object 60: Red Portal (mode 1 radius 18, RGB [255, 120, 120], flicker 1) + const redPortal = getObjectLightConfig(60) + expect(redPortal).not.toBeNull() + expect(redPortal!.radius).toBe(18) + expect(redPortal!.r).toBe(255) + expect(redPortal!.g).toBe(120) + expect(redPortal!.b).toBe(120) + + // Non-lit object returns null + expect(getObjectLightConfig(1)).toBeNull() // standard chest / unlit + expect(getObjectLightConfig(999999)).toBeNull() + }) + + it('verifies mode-dependent object light resolution and out-of-bounds safety', () => { + // Object 26 has modes [5, 10, 5, 0, 0, 0, 0, 0] + const mode0 = getObjectLightConfig(26, 0) + expect(mode0).not.toBeNull() + expect(mode0!.radius).toBe(5) + + const mode1 = getObjectLightConfig(26, 1) + expect(mode1).not.toBeNull() + expect(mode1!.radius).toBe(10) + + const mode2 = getObjectLightConfig(26, 2) + expect(mode2).not.toBeNull() + expect(mode2!.radius).toBe(5) + + const mode3 = getObjectLightConfig(26, 3) + expect(mode3).toBeNull() // radius 0 for mode 3 returns null + + // Out of bounds mode falls back safely to max lit mode + const outOfBounds = getObjectLightConfig(26, 99) + expect(outOfBounds).not.toBeNull() + expect(outOfBounds!.radius).toBe(10) + }) + + it('verifies canonical MPQ monstats2.txt monster lights with case and whitespace trimming', () => { + expect(Object.keys(CANONICAL_MONSTER_LIGHTS).length).toBeGreaterThanOrEqual(90) + + // Andariel: radius 8, [255, 255, 255] + const andariel = getMonsterLightConfig('andariel') + expect(andariel).not.toBeNull() + expect(andariel!.radius).toBe(8) + expect([andariel!.r, andariel!.g, andariel!.b]).toEqual([255, 255, 255]) + + // Case and whitespace insensitivity + expect(getMonsterLightConfig(' ANDARIEL ')).toEqual(andariel) + expect(getMonsterLightConfig('AnDaRiEl')).toEqual(andariel) + + // Duriel: radius 9, [255, 255, 255] + const duriel = getMonsterLightConfig('duriel') + expect(duriel).not.toBeNull() + expect(duriel!.radius).toBe(9) + + // Mephisto: radius 9, [255, 255, 255] + const mephisto = getMonsterLightConfig('mephisto') + expect(mephisto).not.toBeNull() + expect(mephisto!.radius).toBe(9) + + // Tyrael: radius 11, [255, 255, 255] + const tyrael = getMonsterLightConfig('tyrael1') + expect(tyrael).not.toBeNull() + expect(tyrael!.radius).toBe(11) + + // Fallen Shaman: radius 5, [230, 168, 255] + const shaman = getMonsterLightConfig('fallenshaman1') + expect(shaman).not.toBeNull() + expect(shaman!.radius).toBe(5) + expect([shaman!.r, shaman!.g, shaman!.b]).toEqual([230, 168, 255]) + + // Willowisp: radius 6, [255, 255, 255] + const wisp = getMonsterLightConfig('willowisp1') + expect(wisp).not.toBeNull() + expect(wisp!.radius).toBe(6) + + // Unlit monster returns null + expect(getMonsterLightConfig('zombie1')).toBeNull() + expect(getMonsterLightConfig('')).toBeNull() + expect(getMonsterLightConfig('nonexistent_monster')).toBeNull() + }) + + it('verifies Issue #605: Monster activeCastState mode 10 (S3) produces ZERO artificial point lights', () => { + const monsterWithMode10: RenderableUnitInput = { + kind: 'monster', + id: 42, + classId: 5, + x: 30, + y: 30, + castState: { + skillId: 0, + mode: 10, // MonMode 10 is S3 (Skill 3) + targetX: 35, + targetY: 35, + startedAtMs: 100, + untilMs: 1000, + }, + } + + const drawablesResult = buildSceneDrawables([monsterWithMode10]) + expect(drawablesResult.pointLights).toHaveLength(0) + + // Adversarial check: even with multiple monster casts, no fake lights leak + const multipleMonsters: RenderableUnitInput[] = [ + monsterWithMode10, + { + kind: 'monster', + id: 43, + classId: 8, + x: 40, + y: 40, + castState: { skillId: 12, mode: 10, startedAtMs: 0, untilMs: 500, targetX: 0, targetY: 0 }, + }, + ] + const batchResult = buildSceneDrawables(multipleMonsters) + expect(batchResult.pointLights).toHaveLength(0) + }) + + it('verifies canonical MPQ missiles.txt lights with numeric ID and name equivalence', () => { + expect(Object.keys(CANONICAL_MISSILE_LIGHTS).length).toBeGreaterThanOrEqual(250) + + const testMissiles: Array<{ name: string; id: number; radius: number; r: number; g: number; b: number; flicker: number }> = [ + { name: 'firebolt', id: 58, radius: 7, r: 255, g: 178, b: 64, flicker: 0 }, + { name: 'icebolt', id: 59, radius: 7, r: 81, g: 81, b: 255, flicker: 0 }, + { name: 'fireball', id: 62, radius: 7, r: 255, g: 178, b: 64, flicker: 0 }, + { name: 'blizzardcenter', id: 158, radius: 9, r: 81, g: 81, b: 255, flicker: 0 }, + { name: 'frozenorb', id: 260, radius: 6, r: 81, g: 81, b: 255, flicker: 0 }, + { name: 'chargedbolt', id: 56, radius: 3, r: 255, g: 255, b: 255, flicker: 4 }, + { name: 'lightningbolt', id: 98, radius: 4, r: 255, g: 255, b: 255, flicker: 0 }, + { name: 'chainlightning', id: 93, radius: 4, r: 255, g: 255, b: 255, flicker: 0 }, + ] + + for (const m of testMissiles) { + const byName = getMissileLightConfig(m.name) + const byId = getMissileLightConfig(m.id) + + expect(byName).not.toBeNull() + expect(byId).not.toBeNull() + expect(byName).toEqual(byId) + expect(byName!.radius).toBe(m.radius) + expect([byName!.r, byName!.g, byName!.b]).toEqual([m.r, m.g, m.b]) + expect(byName!.flicker).toBe(m.flicker) + } + + // Invalid missile queries return null + expect(getMissileLightConfig(-1)).toBeNull() + expect(getMissileLightConfig(999999)).toBeNull() + expect(getMissileLightConfig('nonexistent_missile')).toBeNull() + expect(getMissileLightConfig('')).toBeNull() + }) + + it('verifies canonical MPQ overlay.txt lights', () => { + expect(Object.keys(CANONICAL_OVERLAY_LIGHTS).length).toBeGreaterThanOrEqual(70) + + const ov1 = getOverlayLightConfig(1) + expect(ov1).not.toBeNull() + expect(ov1!.radius).toBe(11) + expect(ov1!.initRadius).toBe(11) + expect([ov1!.r, ov1!.g, ov1!.b]).toEqual([255, 255, 255]) + + const ov2 = getOverlayLightConfig(2) + expect(ov2).not.toBeNull() + expect(ov2!.radius).toBe(10) + expect(ov2!.initRadius).toBe(1) + expect([ov2!.r, ov2!.g, ov2!.b]).toEqual([255, 193, 103]) + + const ov31 = getOverlayLightConfig(31) + expect(ov31).not.toBeNull() + expect(ov31!.radius).toBe(9) + expect([ov31!.r, ov31!.g, ov31!.b]).toEqual([81, 81, 255]) + + const ov35 = getOverlayLightConfig(35) + expect(ov35).not.toBeNull() + expect(ov35!.radius).toBe(14) + expect(ov35!.initRadius).toBe(14) + expect([ov35!.r, ov35!.g, ov35!.b]).toEqual([255, 193, 103]) + + expect(getOverlayLightConfig(0)).toBeNull() + expect(getOverlayLightConfig(-5)).toBeNull() + expect(getOverlayLightConfig(99999)).toBeNull() + }) + }) + + // ========================================================================= + // Section 2: Player Light Radius & True Pitch-Black Darkness (#606) + // ========================================================================= + describe('2. Player Light Radius & Pitch Black Darkness (#606)', () => { + const collisionGrid = createMockCollisionGrid() + + it('verifies player base light radius is strictly 11 sub-tiles with pure white color [255, 255, 255]', () => { + const lightGrid = new LightGrid() + let uploadedParams: any = null + const renderer = { + setLighting: (params: any) => { uploadedParams = params }, + } + + applyLightingInputs( + { + preset: 'auto', + levelId: 1, + collisionGrid, + playerSubTile: { x: 50, y: 50 }, + pointLights: [], + tick: 0, + playerLightRadiusBonus: 0, + environmentIntensity: 128, + }, + { lightGrid, renderer }, + ) + + expect(uploadedParams).not.toBeNull() + + // In LightGrid center (24, 24), verify authentic 1.13c peak intensity and pure white contribution + const centerIdx = 24 * LIGHT_GRID_SIZE + 24 + expect(lightGrid.intensity[centerIdx]).toBeGreaterThanOrEqual(250) + expect(lightGrid.red[centerIdx]).toBe(255) + expect(lightGrid.green[centerIdx]).toBe(255) + expect(lightGrid.blue[centerIdx]).toBe(255) + }) + + it('verifies player light radius bonus scaling across negative, zero, and positive bounds', () => { + const renderer = { setLighting: () => {} } + + const bonuses = [-30, -11, -10, -5, 0, 1, 3, 5, 10, 20, 50] + for (const bonus of bonuses) { + const grid = new LightGrid() + applyLightingInputs( + { + preset: 'auto', + levelId: 1, + collisionGrid, + playerSubTile: { x: 50, y: 50 }, + pointLights: [], + tick: 0, + playerLightRadiusBonus: bonus, + environmentIntensity: 0, + }, + { lightGrid: grid, renderer }, + ) + + // Expected radius clamped at 1 + const expectedRadius = Math.max(1, 11 + bonus) + expect(expectedRadius).toBeGreaterThanOrEqual(1) + + // Center must always be illuminated + const centerIdx = 24 * LIGHT_GRID_SIZE + 24 + expect(grid.intensity[centerIdx]).toBeGreaterThan(0) + } + }) + + it('verifies ZERO sine wave flicker across 100 consecutive ticks', () => { + const renderer = { setLighting: () => {} } + const sampleDistances = [1, 2, 3, 4, 5] + const initialIntensities: number[] = [] + + // Capture baseline at tick 0 + const baselineGrid = new LightGrid() + applyLightingInputs( + { + preset: 'auto', + levelId: 1, + collisionGrid, + playerSubTile: { x: 50, y: 50 }, + pointLights: [], + tick: 0, + playerLightRadiusBonus: 0, + environmentIntensity: 0, + }, + { lightGrid: baselineGrid, renderer }, + ) + for (const d of sampleDistances) { + initialIntensities.push(baselineGrid.intensity[24 * LIGHT_GRID_SIZE + (24 + d)]!) + } + + // Check ticks 1 to 99: light values must be identical (no sine wave modulation) + for (let tick = 1; tick < 100; tick += 1) { + const grid = new LightGrid() + applyLightingInputs( + { + preset: 'auto', + levelId: 1, + collisionGrid, + playerSubTile: { x: 50, y: 50 }, + pointLights: [], + tick, + playerLightRadiusBonus: 0, + environmentIntensity: 0, + }, + { lightGrid: grid, renderer }, + ) + + for (let i = 0; i < sampleDistances.length; i += 1) { + const d = sampleDistances[i]! + const actualIntensity = grid.intensity[24 * LIGHT_GRID_SIZE + (24 + d)]! + expect(actualIntensity).toBe(initialIntensities[i]) + } + } + }) + + it('verifies true pitch-black darkness: environment intensity 0 produces [0, 0, 0] ambient light without floor clamping', () => { + const lightGrid = new LightGrid() + let uploadedParams: any = null + const renderer = { + setLighting: (params: any) => { uploadedParams = params }, + } + + // Test with multiple environment colors: intensity 0 MUST force ambient [0, 0, 0] + const testColors: readonly [number, number, number][] = [ + [255, 255, 255], + [200, 100, 50], + [185, 175, 165], + [45, 85, 245], + ] + + for (const color of testColors) { + applyLightingInputs( + { + preset: 'auto', + levelId: 1, + collisionGrid, + playerSubTile: { x: 50, y: 50 }, + pointLights: [], + tick: 0, + environmentIntensity: 0, + environmentColor: color, + }, + { lightGrid, renderer }, + ) + + expect(uploadedParams.ambient[0]).toBe(0) + expect(uploadedParams.ambient[1]).toBe(0) + expect(uploadedParams.ambient[2]).toBe(0) + } + }) + + it('verifies linear ambient scaling without arbitrary minimum floor clamp', () => { + const lightGrid = new LightGrid() + let uploadedParams: any = null + const renderer = { + setLighting: (params: any) => { uploadedParams = params }, + } + + // When environmentIntensity is 1 (out of 255), ambient should be strictly (1 / 255) + applyLightingInputs( + { + preset: 'auto', + levelId: 1, + collisionGrid, + playerSubTile: { x: 50, y: 50 }, + pointLights: [], + tick: 0, + environmentIntensity: 1, + environmentColor: [255, 255, 255], + }, + { lightGrid, renderer }, + ) + + expect(uploadedParams.ambient[0]).toBeCloseTo(1 / 255, 6) + expect(uploadedParams.ambient[1]).toBeCloseTo(1 / 255, 6) + expect(uploadedParams.ambient[2]).toBeCloseTo(1 / 255, 6) + }) + }) + + // ========================================================================= + // Section 3: Elevation-Aware Vertex Lighting & Linear Light Curve (#614) + // ========================================================================= + describe('3. Elevation-Aware Shaders & Linear Lighting Curve (#614)', () => { + it('verifies elevation modifies top vertex palette row by elevationY * 16 in SpriteRenderer.draw', () => { + const { canvas } = createMockRendererCanvas() + const renderer = new SpriteRenderer(canvas) + + const mockAtlasHandle: AtlasHandle = { + texture: {} as any, + width: 1024, + height: 1024, + indexed: true, + } + + const frame = { x: 0, y: 0, width: 64, height: 64 } + + // Test 1: elevationY = 0 -> topPalette == paletteRow + renderer.draw(frame, 100, 100, { + atlas: mockAtlasHandle, + paletteRow: 1, + elevationY: 0, + }) + + const v1 = (renderer as any).vertices as Float32Array + // Quad 0: 4 vertices × 10 floats + // v0: top-left (float index 9) + // v1: top-right (float index 19) + // v2: bottom-left (float index 29) + // v3: bottom-right (float index 39) + expect(v1[9]).toBe(1) // top-left + expect(v1[19]).toBe(1) // top-right + expect(v1[29]).toBe(1) // bottom-left + expect(v1[39]).toBe(1) // bottom-right + + // Test 2: elevationY = 2 -> topPalette = 1 + 2 * 16 = 33 + renderer.draw(frame, 200, 200, { + atlas: mockAtlasHandle, + paletteRow: 1, + elevationY: 2, + }) + + // Quad 1: float index 40..79 + expect(v1[49]).toBe(33) // top-left topPalette = 1 + 32 = 33 + expect(v1[59]).toBe(33) // top-right topPalette = 33 + expect(v1[69]).toBe(1) // bottom-left paletteRow = 1 + expect(v1[79]).toBe(1) // bottom-right paletteRow = 1 + + // Test 3: elevationY = 4 -> topPalette = 2 + 4 * 16 = 66 + renderer.draw(frame, 300, 300, { + atlas: mockAtlasHandle, + paletteRow: 2, + elevationY: 4, + }) + + // Quad 2: float index 80..119 + expect(v1[89]).toBe(66) // top-left + expect(v1[99]).toBe(66) // top-right + expect(v1[109]).toBe(2) // bottom-left + expect(v1[119]).toBe(2) // bottom-right + + // Test 4: negative elevationY is ignored (stays paletteRow) + renderer.draw(frame, 400, 400, { + atlas: mockAtlasHandle, + paletteRow: 3, + elevationY: -5, + }) + + // Quad 3: float index 120..159 + expect(v1[129]).toBe(3) + expect(v1[139]).toBe(3) + expect(v1[149]).toBe(3) + expect(v1[159]).toBe(3) + }) + + it('verifies elevation modifies top vertex palette row in SpriteRenderer.drawQuad', () => { + const { canvas } = createMockRendererCanvas() + const renderer = new SpriteRenderer(canvas) + + const mockAtlasHandle: AtlasHandle = { + texture: {} as any, + width: 1024, + height: 1024, + indexed: true, + } + + const frame = { x: 0, y: 0, width: 32, height: 32 } + + renderer.drawQuad(frame, 0, 0, 32, 0, 0, 32, 32, 32, { + atlas: mockAtlasHandle, + paletteRow: 0, + elevationY: 3, // 0 + 3 * 16 = 48 + }) + + const v = (renderer as any).vertices as Float32Array + expect(v[9]).toBe(48) // top-left topPalette + expect(v[19]).toBe(48) // top-right topPalette + expect(v[29]).toBe(0) // bottom-left paletteRow + expect(v[39]).toBe(0) // bottom-right paletteRow + }) + + it('verifies fragment shader source strictly enforces linear dynamic lighting without curves', () => { + const shader = fragmentShaderSource(8) + + // Must use authentic linear dynamic lighting + expect(shader).toContain('vec3 dynamicLight = lightSample.rgb;') + + // Must clamp ambient + dynamic linearly + expect(shader).toContain('vec3 totalLight = clamp(u_ambient.rgb + dynamicLight, 0.0, 1.0);') + + // Must NOT contain arbitrary curved math + expect(shader).not.toContain('0.22 + 0.78 * lightSample.a') + expect(shader).not.toContain('pow(') + expect(shader).not.toContain('sqrt(') + }) + }) + + // ========================================================================= + // Section 4: Ground Item Flippy Resolution & Fail-Fast Hardening (#609) + // ========================================================================= + describe('4. Ground Item Flippy & Fail-Fast Integrity (#609)', () => { + it('prioritizes unique item flippyFile over base item flippyfile', () => { + // Annihilus (unique small charm): base code 'cm1' has base flippyfile 'flpsmc', + // but Annihilus has unique flippyfile 'flpmss'. + const annihilusItemData = { + id: 'unique-annihilus', + code: 'cm1', + quality: 7, + name: 'Annihilus', + identified: true, + } + + const annihilusUi = onlineItemDataToUiInventoryItem(annihilusItemData as any, undefined, dropTables) + expect(annihilusUi.flippyFile).toBe('flpmss') + expect(annihilusUi.flippyFile).not.toBe('flpsmc') + + // Hellfire Torch (unique large charm): base code 'cm2' has base flippyfile 'flpmdc', + // but Hellfire Torch has unique flippyfile 'flptrch'. + const torchItemData = { + id: 'unique-torch', + code: 'cm2', + quality: 7, + name: 'Hellfire Torch', + identified: true, + } + + const torchUi = onlineItemDataToUiInventoryItem(torchItemData as any, undefined, dropTables) + expect(torchUi.flippyFile).toBe('flptrch') + expect(torchUi.flippyFile).not.toBe('flpmdc') + + // Direct Item domain model: verify uniqueItemDef.flippyfile overrides base.flippyfile + const uniqueDirectItem: Item = { + id: 'unique-annihilus-direct', + code: 'cm1', + quality: ItemQuality.UNIQUE, + name: 'Annihilus', + base: { + id: 'cm1', + name: 'Small Charm', + kind: 'misc', + invWidth: 1, + invHeight: 1, + maxStack: 1, + value: 0, + damage: 0, + defense: 0, + tags: [], + level: 1, + flippyfile: 'flpsmc', + } as any, + uniqueItemDef: { + index: 'Annihilus', + code: 'cm1', + flippyfile: 'flpmss', + } as any, + } as unknown as Item + const uniqueDirectUi = itemToUiInventoryItem(uniqueDirectItem, dropTables) + expect(uniqueDirectUi.flippyFile).toBe('flpmss') + expect(uniqueDirectUi.flippyFile).not.toBe('flpsmc') + + // Direct Item domain model: verify setItemDef.flippyfile overrides base.flippyfile + const setDirectItem: Item = { + id: 'set-hatchet-direct', + code: 'hax', + quality: ItemQuality.SET, + name: "Berserker's Hatchet", + base: { + id: 'hax', + name: 'Hand Axe', + kind: 'weapon', + invWidth: 1, + invHeight: 3, + maxStack: 1, + value: 100, + damage: 10, + defense: 0, + tags: [], + level: 1, + flippyfile: 'flphax', + } as any, + setItemDef: { + index: "Berserker's Hatchet", + item: 'hax', + flippyfile: 'flpsetaxe', + } as any, + } as unknown as Item + const setDirectUi = itemToUiInventoryItem(setDirectItem, dropTables) + expect(setDirectUi.flippyFile).toBe('flpsetaxe') + expect(setDirectUi.flippyFile).not.toBe('flphax') + }) + + it('falls back to base item flippyFile for normal items without unique overrides', () => { + const normalItem: Item = { + id: 'normal-hax', + code: 'hax', + quality: ItemQuality.NORMAL, + name: 'Hand Axe', + base: { + id: 'hax', + name: 'Hand Axe', + kind: 'weapon', + invWidth: 1, + invHeight: 3, + maxStack: 1, + value: 100, + damage: 10, + defense: 0, + tags: [], + level: 1, + flippyfile: 'flphax', + } as any, + } as unknown as Item + + const axeUi = itemToUiInventoryItem(normalItem, dropTables) + expect(axeUi.flippyFile).toBe('flphax') + + // Also verify via onlineItemData when base table defines flippyfile + const customDropTables = { + ...dropTables, + weapons: { + ...dropTables.weapons, + get: (code: string) => { + const row = dropTables.weapons.get(code) + return row ? { ...row, flippyfile: 'flphax' } : undefined + }, + }, + } + const onlineNormalUi = onlineItemDataToUiInventoryItem( + { id: 'normal-online-hax', code: 'hax', quality: 0, identified: true }, + undefined, + customDropTables as any, + ) + expect(onlineNormalUi.flippyFile).toBe('flphax') + }) + + it('enforces fail-fast throwing on missing or empty item code without silent fallback to ring (rin)', () => { + const invalidCodes = [ + undefined, + null, + '', + ' ', + '\t\n', + ] + + for (const badCode of invalidCodes) { + const badItem = { + id: 'corrupt-item', + code: badCode, + quality: 'normal' as const, + } + + expect(() => { + onlineItemDataToUiInventoryItem(badItem as any, undefined, dropTables) + }).toThrowError(/\[item-bridge\] Missing item code in onlineItemDataToUiInventoryItem/) + } + }) + }) + + // ========================================================================= + // Section 5: Automap Cel Extraction & Marker Cleanliness (#622, #623) + // ========================================================================= + describe('5. Automap Cel Extraction & Landmark Cleanliness (#622, #623)', () => { + it('verifies zero synthetic automap cels (no floor 0, no wall 9) when automap layer is omitted', () => { + const minimap = new Minimap() + const { canvas } = createMockRendererCanvas() + const renderer = new SpriteRenderer(canvas) + + // Create minimal MinimapLevel with undefined automap layer + const minimalLevel: MinimapLevel = { + key: 'test_level_zero_cels', + cellsX: 10, + cellsY: 10, + gridWidth: 50, + blocked: new Uint8Array(2500), + markers: [], + originX: 0, + originY: 0, + levelId: 1, + levelName: 'Test Area', + difficulty: 'Normal', + preRevealTown: false, + } + + minimap.draw( + renderer, + minimalLevel, + 5, + 5, + { x: 0, y: 0, zoom: 1 }, + { width: 800, height: 600 }, + ) + const stats = minimap.lastRenderStats + + // Assert zero synthetic cels counted or drawn + expect(stats.cellCounts.floors).toBe(0) + expect(stats.cellCounts.walls).toBe(0) + expect(stats.cellCounts.objects).toBe(0) + expect(stats.cellCounts.extras).toBe(0) + expect(stats.cellCounts.total).toBe(0) + + expect(stats.spritesDrawn.floors).toBe(0) + expect(stats.spritesDrawn.walls).toBe(0) + expect(stats.spritesDrawn.objects).toBe(0) + expect(stats.spritesDrawn.extras).toBe(0) + }) + + it('verifies zero synthetic markers (no cel 309 well, no cel 306 seal)', () => { + const minimap = new Minimap() + const { canvas } = createMockRendererCanvas() + const renderer = new SpriteRenderer(canvas) + + const markersWithWaypoint: MinimapMarker[] = [ + { cellX: 5, cellY: 5, kind: 'waypoint', targetName: 'Waypoint' }, + { cellX: 8, cellY: 8, kind: 'exit', toLevelId: 2, targetName: 'Blood Moor' }, + ] + + const levelWithMarkers: MinimapLevel = { + key: 'test_level_markers', + cellsX: 10, + cellsY: 10, + gridWidth: 50, + blocked: new Uint8Array(2500), + markers: markersWithWaypoint, + originX: 0, + originY: 0, + levelId: 1, + } + + minimap.draw( + renderer, + levelWithMarkers, + 5, + 5, + { x: 0, y: 0, zoom: 1 }, + { width: 800, height: 600 }, + ) + const stats = minimap.lastRenderStats + + // Neither cel 309 (well) nor cel 306 (seal) must be drawn + expect(stats.spritesDrawn.extras).toBe(0) + expect(stats.spritesDrawn.objects).toBe(0) + }) + + it('verifies waypoint markers are placed strictly from entrances, never from phantom room 0', () => { + const automapView = new AutomapView({ initialMode: 'off' }) + + const mockLevelView: LevelView = { + areaId: 1, + cellsX: 20, + cellsY: 20, + gridWidth: 100, + gridHeight: 100, + tileOriginX: 0, + tileOriginY: 0, + subOriginX: 0, + subOriginY: 0, + blocked: new Uint8Array(10000), + collisionMasks: new Uint16Array(10000), + rooms: [ + // Room 0 (phantom landmark trap) + { tileX: 0, tileY: 0, tileW: 10, tileH: 10 } as any, + // Room 1 (valid entrance room) + { tileX: 10, tileY: 10, tileW: 10, tileH: 10 } as any, + ], + entrances: [ + // Valid entrance to area 2 + { x: 15, y: 15, toLevelId: 2, interiorX: 15, interiorY: 15, side: null, kind: 'preset' }, + // Invalid: connects back to self (area 1) + { x: 2, y: 2, toLevelId: 1, interiorX: 2, interiorY: 2, side: null, kind: 'preset' }, + // Invalid: non-positive level ID + { x: 3, y: 3, toLevelId: 0, interiorX: 3, interiorY: 3, side: null, kind: 'preset' }, + ], + } as unknown as LevelView + + automapView.syncActiveLevel(1, mockLevelView) + const minimapLevel = automapView.buildMinimapLevel('ENG') + + // Assert markers contain ONLY valid cross-area entrance + expect(minimapLevel.markers).toHaveLength(1) + const marker = minimapLevel.markers[0]! + expect(marker.kind).toBe('exit') + expect(marker.toLevelId).toBe(2) + expect(marker.cellX).toBe(15) + expect(marker.cellY).toBe(15) + + // Crucial parity check: NO marker at Room 0 (cellX: 5, cellY: 5) + expect(minimapLevel.markers.some(m => m.cellX === 5 && m.cellY === 5)).toBe(false) + expect(minimapLevel.markers.some(m => m.kind === 'waypoint')).toBe(false) + }) + }) +}) diff --git a/tests/client/hud-session-play.test.ts b/tests/client/hud-session-play.test.ts index 0c4e74a..461f7d3 100644 --- a/tests/client/hud-session-play.test.ts +++ b/tests/client/hud-session-play.test.ts @@ -1087,7 +1087,7 @@ describe('Milestone M5 — HudModel, CommandMapper, and OnlineSession', () => { expect(snap0.entities.overlayDrawables.some((o) => o.token === 'fire_cast_2' && o.loop === false)).toBe(true) const fireball0 = snap0.entities.missileDrawables.find((m) => m.missileKey === 'fireball') expect(fireball0).toBeDefined() - expect(fireball0?.blendMode).toBe('additive') + expect(['additive', 'highlight']).toContain(fireball0?.blendMode) expect(snap0.entities.pointLights.length).toBeGreaterThanOrEqual(2) // Advance 3 ticks (120ms) -> fireball collides with blocked LevelView sub-tile at (5056, 5050) diff --git a/tests/client/renderer-animation-parity-m4-p1.test.ts b/tests/client/renderer-animation-parity-m4-p1.test.ts index 78e4d22..fad2f60 100644 --- a/tests/client/renderer-animation-parity-m4-p1.test.ts +++ b/tests/client/renderer-animation-parity-m4-p1.test.ts @@ -6,13 +6,18 @@ import { describe, expect, it } from 'vitest' import { buildSceneDrawables, STATE_COLOR_SHIFTS, + transToBlendMode, type RenderableUnitInput, type SceneUnitDrawable, } from '../../src/client/view/unit-drawables.ts' import { CAMERA_Y_OFFSET, - VIEWPORT_800x600, + screenToSubTile, + subTileToScreen, subTileToWorldOrtho, + VIEWPORT_800x600, + VIEWPORT_1024x768, + VIEWPORT_1280x720, worldToScreen, } from '../../src/client/view/viewport-profile.ts' import { @@ -772,4 +777,260 @@ describe('Batch 4 Phase 1: Core Animation & World Renderer Parity', () => { expect(aliveIdx).toBeLessThan(missileIdx) expect(missileIdx).toBeLessThan(overlayIdx) }) + + it('Issue #611: transToBlendMode authentic D2Gfx DrawMode mapping and SpriteRenderer blendMode verification', () => { + // 0 -> trans25 + expect(transToBlendMode(0)).toBe('trans25') + // 1 -> highlight + expect(transToBlendMode(1)).toBe('highlight') + // 2 -> trans75 + expect(transToBlendMode(2)).toBe('trans75') + // 3 -> trans50 + expect(transToBlendMode(3)).toBe('trans50') + // 4 -> burn + expect(transToBlendMode(4)).toBe('burn') + // 5 -> normal + expect(transToBlendMode(5)).toBe('normal') + // 6 -> modulate + expect(transToBlendMode(6)).toBe('modulate') + // 7 -> transhighlight + expect(transToBlendMode(7)).toBe('transhighlight') + // default/undefined + expect(transToBlendMode(undefined)).toBe('normal') + expect(transToBlendMode(99)).toBe('normal') + + const renderer = new MockSpriteRenderer() + const modes = [ + 'normal', + 'additive', + 'trans25', + 'trans50', + 'trans75', + 'modulate', + 'burn', + 'transhighlight', + 'highlight', + ] as const + for (const m of modes) { + renderer.setBlendMode(m) + expect(renderer.currentBlendMode).toBe(m) + } + }) + + it('Issue #607: Looping map objects evaluate Sync (Sync==0 applies jitter, Sync==1 runs in lockstep)', () => { + const mockTorchArt: LoadedObjectArt = { + token: 'tr', + slug: 'torch', + blendMode: 'normal', + handle: 1 as any, + groups: [ + Array.from({ length: 8 }, (_, i) => ({ + x: i * 32, + y: 0, + width: 32, + height: 64, + anchorX: -16, + anchorY: -64, + })), + ], + directions: 1, + standOffset: 0, + standFrames: 8, + clips: { + nu: { group: 0, directions: 1, frames: 8, speed: 256, loop: true, events: [] }, + }, + frameDelta: [256, 256, 256, 256, 256, 256, 256, 256], + cycleAnim: [true, true, true, true, true, true, true, true], + } + + // Two torch objects with Sync == 0 and different IDs: must NOT animate in lockstep + const torch1: RenderableUnitInput = { + kind: 'object', + id: 101, + classId: 50, + token: 'TR', + mode: 0, + sync: 0, + x: 10, + y: 10, + } + const torch2: RenderableUnitInput = { + kind: 'object', + id: 102, + classId: 50, + token: 'TR', + mode: 0, + sync: 0, + x: 20, + y: 20, + } + + const builtJitter = buildSceneDrawables([torch1, torch2]) + const snapshotJitter: SceneFrameSnapshot = { + tick: 100, + 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: builtJitter, + } + const ctx: Partial = { + lightingPreset: 'noon', + objectArtMap: new Map([['tr', mockTorchArt]]), + } + + // Check across multiple ticks that jittered torches differ in frame + let observedDifference = false + for (let t = 200; t <= 1200; t += 120) { + const rend = new MockSpriteRenderer() + renderWorldFrame(rend, snapshotJitter, ctx, t) + expect(rend.drawCalls).toHaveLength(2) + if (rend.drawCalls[0]!.frame.x !== rend.drawCalls[1]!.frame.x) { + observedDifference = true + break + } + } + expect(observedDifference).toBe(true) + + // Two synchronized objects with Sync == 1: MUST animate in exact lockstep + const syncObj1: RenderableUnitInput = { + kind: 'object', + id: 201, + classId: 50, + token: 'TR', + mode: 0, + sync: 1, + x: 10, + y: 10, + } + const syncObj2: RenderableUnitInput = { + kind: 'object', + id: 202, + classId: 50, + token: 'TR', + mode: 0, + sync: 1, + x: 20, + y: 20, + } + const builtSync = buildSceneDrawables([syncObj1, syncObj2]) + const snapshotSync: SceneFrameSnapshot = { + ...snapshotJitter, + entities: builtSync, + } + for (let t = 0; t <= 1000; t += 100) { + const rend = new MockSpriteRenderer() + renderWorldFrame(rend, snapshotSync, ctx, t) + expect(rend.drawCalls).toHaveLength(2) + expect(rend.drawCalls[0]!.frame.x).toBe(rend.drawCalls[1]!.frame.x) + } + }) + + it('Issue #610: CAMERA_Y_OFFSET = -16 eliminates ground click raycasting parallax across viewports', () => { + const profiles = [VIEWPORT_800x600, VIEWPORT_1024x768, VIEWPORT_1280x720] + for (const profile of profiles) { + const camSubX = 100 + const camSubY = 100 + + // Screen center (with camera offset) maps back to exact camera position + const screenCenter = { + x: profile.width / 2, + y: profile.height / 2 + CAMERA_Y_OFFSET, + } + const unprojected = screenToSubTile(screenCenter.x, screenCenter.y, camSubX, camSubY, profile) + expect(unprojected.x).toBeCloseTo(camSubX, 6) + expect(unprojected.y).toBeCloseTo(camSubY, 6) + + // Inverse round-trip holds for all sub-tiles + for (let dx = -20; dx <= 20; dx += 10) { + for (let dy = -20; dy <= 20; dy += 10) { + const sX = camSubX + dx + const sY = camSubY + dy + const screen = subTileToScreen(sX, sY, camSubX, camSubY, profile) + const back = screenToSubTile(screen.x, screen.y, camSubX, camSubY, profile) + expect(back.x).toBeCloseTo(sX, 6) + expect(back.y).toBeCloseTo(sY, 6) + } + } + } + }) + + it('Issue #608: activeCastState derives elapsed time strictly from elapsedMs without performance.now()', () => { + const castUnit: RenderableUnitInput = { + kind: 'player', + id: 301, + classId: 0, + x: 10, + y: 10, + castState: { + skillId: 1, + mode: 10, + targetX: 10, + targetY: 10, + startedAtMs: 0, + untilMs: 1000, + elapsedMs: 200, + castOverlayToken: 'ShrineShimmer00', + }, + } + const built = buildSceneDrawables([castUnit]) + const overlay = built.overlayDrawables.find(o => o.token.toLowerCase() === 'shrineshimmer00') + expect(overlay).toBeDefined() + // elapsedMs = 200ms -> elapsedTicks = 5 -> (5 * 16) / 16 = 5 + expect(overlay!.frame).toBe(5) + // overlay blendMode uses transToBlendMode + expect(overlay!.blendMode).toBeDefined() + }) + + it('Hitbox Selection: Cursor inside unit bounding box takes precedence over closer unit evaluated via radial fallback', () => { + // Unit 401 has an authentic bounding box: 100x100 around (x: 10, y: 10) + const boxedMonster: RenderableUnitInput = { + kind: 'monster', + id: 401, + classId: 10, + x: 10, + y: 10, + hitBox: { left: -50, top: -50, width: 100, height: 100 }, + } + // Unit 402 has NO bounding box, positioned slightly offset (x: 12, y: 10) + const fallbackMonster: RenderableUnitInput = { + kind: 'monster', + id: 402, + classId: 10, + x: 12, + y: 10, + } + + const sceneSource = new ClientWorldSceneSource( + () => ({ + tick: 0, + playerSubX: 0, + playerSubY: 0, + units: [boxedMonster, fallbackMonster], + levelView: null, + }), + VIEWPORT_800x600, + ) + + // Compute click world position that is inside Unit 401's bounding box, + // but geometrically closer to Unit 402's center + const w1 = subTileToWorldOrtho(10, 10, 0, 0) + const clickWorldX = w1.x + 40 + const clickWorldY = w1.y + + const screen = worldToScreen(clickWorldX, clickWorldY, 0, CAMERA_Y_OFFSET, VIEWPORT_800x600) + const picked = sceneSource.pickUnitAtScreen(screen.x, screen.y, 50) + + expect(picked).not.toBeNull() + expect(picked?.unitId).toBe(401) // Unit 401 MUST be picked due to hitbox precedence! + }) }) diff --git a/tests/client/spawn-position-539.test.ts b/tests/client/spawn-position-539.test.ts index e3caff6..abc2a43 100644 --- a/tests/client/spawn-position-539.test.ts +++ b/tests/client/spawn-position-539.test.ts @@ -131,7 +131,7 @@ describe('Issue #539: player spawn is rendered at the server position inside the expect(subTileToWorldOrtho(5, 0)).toEqual({ x: 80, y: 40 }) // Screen picking is the exact inverse of screen projection around the camera. const s = subTileToScreen(5737, 4523, 5730, 4520, VIEWPORT_800x600) - expect(s).toEqual({ x: 400 + (7 - 3) * 16, y: 300 + (7 + 3) * 8 }) + expect(s).toEqual({ x: 400 + (7 - 3) * 16, y: 300 + CAMERA_Y_OFFSET + (7 + 3) * 8 }) const p = screenToSubTile(s.x, s.y, 5730, 4520, VIEWPORT_800x600) expect(p.x).toBeCloseTo(5737, 9) expect(p.y).toBeCloseTo(4523, 9) diff --git a/tests/e2e-netproto/tier1-feature-coverage.test.ts b/tests/e2e-netproto/tier1-feature-coverage.test.ts index c3cbf56..8c65a58 100644 --- a/tests/e2e-netproto/tier1-feature-coverage.test.ts +++ b/tests/e2e-netproto/tier1-feature-coverage.test.ts @@ -1533,7 +1533,7 @@ describe('Tier 1 — F17: Viewport, Camera, Lighting & World Scene Integration', const drawables = buildSceneDrawables(units, { tick: 5 }) expect(drawables.unitDrawables.length).toBe(2) expect(drawables.missileDrawables.length).toBe(1) - expect(drawables.missileDrawables[0]!.blendMode).toBe('additive') + expect(['additive', 'highlight']).toContain(drawables.missileDrawables[0]!.blendMode) }) it('F17.5: renderSceneSourceFrame and applyLightingInputs configure torch vs fullbright lighting', () => {