662 lines
23 KiB
TypeScript
662 lines
23 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
AUTHENTIC_113C_OVERLAYS,
|
|
OVERLAY_METAS,
|
|
STATE_OVERLAYS,
|
|
} from '../../src/client/render/overlays-meta.ts'
|
|
import {
|
|
buildSceneDrawables,
|
|
OVERLAY_METADATA,
|
|
type RenderableUnitInput,
|
|
} from '../../src/client/view/unit-drawables.ts'
|
|
import {
|
|
buildCollisionGridFromLevelView,
|
|
ClientWorldSceneSource,
|
|
type ClientSceneWorldAdapter,
|
|
type SceneFrameSnapshot,
|
|
} from '../../src/client/view/scene-source.ts'
|
|
import {
|
|
computePanelCameraShift,
|
|
createViewportProfile,
|
|
getViewportProfile,
|
|
screenToSubTile,
|
|
subTileToScreen,
|
|
VIEWPORT_640x480,
|
|
VIEWPORT_800x600,
|
|
VIEWPORT_PROFILES,
|
|
} from '../../src/client/view/viewport-profile.ts'
|
|
import { VALID_VIEWPORT_RESOLUTIONS } from '../../src/client/settings/client-settings.ts'
|
|
import { renderSceneSourceFrame } from '../../src/client/scene/world-renderer.ts'
|
|
import { AutomapView, isTownLevel } from '../../src/client/automap/automap-view.ts'
|
|
import type { LevelView } from '../../src/client/map/level-view.ts'
|
|
|
|
function createFakeRenderer(drawn: unknown[] = []) {
|
|
return {
|
|
begin() {},
|
|
draw(frame: unknown, x?: number, y?: number, opts?: unknown) {
|
|
drawn.push({ frame, x, y, opts })
|
|
},
|
|
drawSolidQuad() {},
|
|
setLighting() {},
|
|
setPalette() {},
|
|
flush() {},
|
|
getGl() {
|
|
return null
|
|
},
|
|
}
|
|
}
|
|
|
|
function createFakeTileGpuCache() {
|
|
return {
|
|
loadedPageCount: 1,
|
|
ensureActPalette() {},
|
|
getPageHandle() {
|
|
return { index: 1, width: 2048, height: 1932 }
|
|
},
|
|
}
|
|
}
|
|
|
|
describe('Batch 5 Phase 1: Renderer Overlays, Viewport & 25Hz Clock Parity', () => {
|
|
describe('Issue #602: Single 25Hz Tick Animation for Missiles and Overlays', () => {
|
|
it('advances missile animation strictly by stepped 25Hz frame without secondary wall-clock multiplication', () => {
|
|
const drawn: any[] = []
|
|
const fakeRenderer = createFakeRenderer(drawn)
|
|
const fakeMissileArt = new Map<string, any>([
|
|
[
|
|
'fireball',
|
|
{
|
|
handle: { index: 1, width: 64, height: 64 },
|
|
meta: { directions: 1, animSpeed: 16 },
|
|
frames: [
|
|
[
|
|
{ width: 32, height: 32, tag: 'FB_0' },
|
|
{ width: 32, height: 32, tag: 'FB_1' },
|
|
{ width: 32, height: 32, tag: 'FB_2' },
|
|
{ width: 32, height: 32, tag: 'FB_3' },
|
|
],
|
|
],
|
|
},
|
|
],
|
|
])
|
|
|
|
const makeSource = (frameVal: number) => ({
|
|
getViewportProfile: () => VIEWPORT_800x600,
|
|
setViewportProfile: () => VIEWPORT_800x600,
|
|
getActId: () => 1 as const,
|
|
getLevelId: () => 1,
|
|
getTick: () => 10,
|
|
getPlayerSubTile: () => ({ x: 100, y: 100 }),
|
|
isWorldReady: () => true,
|
|
getPlayerLocalSubTile: () => ({ x: 100, y: 100 }),
|
|
getPlayerWorldOrtho: () => ({ x: 0, y: 0 }),
|
|
getCameraTopLeft: () => ({ x: -400, y: -300 }),
|
|
getLevelView: () => null,
|
|
isSubTileWalkable: () => true,
|
|
buildFrameSnapshot: (): SceneFrameSnapshot => ({
|
|
tick: 10,
|
|
actId: 1,
|
|
levelId: 1,
|
|
viewportProfile: VIEWPORT_800x600,
|
|
worldReady: true,
|
|
playerSubTile: { x: 100, y: 100 },
|
|
playerLocalSubTile: { x: 100, y: 100 },
|
|
playerWorld: { x: 0, y: 0 },
|
|
cameraTopLeft: { x: -400, y: -300 },
|
|
floorQuads: [],
|
|
shadowQuads: [],
|
|
lowerWallQuads: [],
|
|
upperWallQuads: [],
|
|
roofQuads: [],
|
|
entities: {
|
|
unitDrawables: [],
|
|
missileDrawables: [
|
|
{
|
|
drawableKind: 'missile',
|
|
unitId: 1,
|
|
missileClassId: 1,
|
|
missileKey: 'fireball',
|
|
celFile: 'FB',
|
|
direction: 0,
|
|
frame: frameVal,
|
|
totalFrames: 4,
|
|
subX: 0,
|
|
subY: 0,
|
|
worldX: 0,
|
|
worldY: 0,
|
|
sortY: 0,
|
|
blendMode: 'trans50',
|
|
trans: 3,
|
|
lightRadius: 0,
|
|
lightColor: [0, 0, 0],
|
|
},
|
|
],
|
|
overlayDrawables: [],
|
|
sortedDrawables: [
|
|
{
|
|
drawableKind: 'missile',
|
|
unitId: 1,
|
|
missileClassId: 1,
|
|
missileKey: 'fireball',
|
|
celFile: 'FB',
|
|
direction: 0,
|
|
frame: frameVal,
|
|
totalFrames: 4,
|
|
subX: 0,
|
|
subY: 0,
|
|
worldX: 0,
|
|
worldY: 0,
|
|
sortY: 0,
|
|
blendMode: 'trans50',
|
|
trans: 3,
|
|
lightRadius: 0,
|
|
lightColor: [0, 0, 0],
|
|
},
|
|
],
|
|
pointLights: [],
|
|
},
|
|
}),
|
|
buildLightingInputs: () => ({} as any),
|
|
pickUnitAtScreen: () => null,
|
|
})
|
|
|
|
// Frame 0 at t=0ms and t=1000ms both draw FB_0 (frame index governed by discrete stepped frame, not nowMs)
|
|
drawn.length = 0
|
|
renderSceneSourceFrame({
|
|
source: makeSource(0),
|
|
lightingPreset: 'auto',
|
|
renderer: fakeRenderer as any,
|
|
missileArtMap: fakeMissileArt,
|
|
nowMs: 0,
|
|
})
|
|
expect(drawn[0]?.frame?.tag).toBe('FB_0')
|
|
|
|
drawn.length = 0
|
|
renderSceneSourceFrame({
|
|
source: makeSource(0),
|
|
lightingPreset: 'auto',
|
|
renderer: fakeRenderer as any,
|
|
missileArtMap: fakeMissileArt,
|
|
nowMs: 12345, // Arbitrary wall clock must not advance frame 0
|
|
})
|
|
expect(drawn[0]?.frame?.tag).toBe('FB_0')
|
|
|
|
// Stepped frame 2 draws FB_2 regardless of nowMs
|
|
drawn.length = 0
|
|
renderSceneSourceFrame({
|
|
source: makeSource(2),
|
|
lightingPreset: 'auto',
|
|
renderer: fakeRenderer as any,
|
|
missileArtMap: fakeMissileArt,
|
|
nowMs: 99999,
|
|
})
|
|
expect(drawn[0]?.frame?.tag).toBe('FB_2')
|
|
})
|
|
|
|
it('advances overlay animation strictly by stepped 25Hz frame without secondary wall-clock multiplication', () => {
|
|
const drawn: any[] = []
|
|
const fakeRenderer = createFakeRenderer(drawn)
|
|
const fakeOverlayArt = new Map<string, any>([
|
|
[
|
|
'curseamplifydamage',
|
|
{
|
|
handle: { index: 1, width: 64, height: 64 },
|
|
meta: { directions: 1, animRate: 16 },
|
|
frames: [
|
|
[
|
|
{ width: 32, height: 32, tag: 'AMP_0' },
|
|
{ width: 32, height: 32, tag: 'AMP_1' },
|
|
{ width: 32, height: 32, tag: 'AMP_2' },
|
|
{ width: 32, height: 32, tag: 'AMP_3' },
|
|
],
|
|
],
|
|
},
|
|
],
|
|
])
|
|
|
|
const makeSource = (frameVal: number) => ({
|
|
getViewportProfile: () => VIEWPORT_800x600,
|
|
setViewportProfile: () => VIEWPORT_800x600,
|
|
getActId: () => 1 as const,
|
|
getLevelId: () => 1,
|
|
getTick: () => 10,
|
|
getPlayerSubTile: () => ({ x: 100, y: 100 }),
|
|
isWorldReady: () => true,
|
|
getPlayerLocalSubTile: () => ({ x: 100, y: 100 }),
|
|
getPlayerWorldOrtho: () => ({ x: 0, y: 0 }),
|
|
getCameraTopLeft: () => ({ x: -400, y: -300 }),
|
|
getLevelView: () => null,
|
|
isSubTileWalkable: () => true,
|
|
buildFrameSnapshot: (): SceneFrameSnapshot => ({
|
|
tick: 10,
|
|
actId: 1,
|
|
levelId: 1,
|
|
viewportProfile: VIEWPORT_800x600,
|
|
worldReady: true,
|
|
playerSubTile: { x: 100, y: 100 },
|
|
playerLocalSubTile: { x: 100, y: 100 },
|
|
playerWorld: { x: 0, y: 0 },
|
|
cameraTopLeft: { x: -400, y: -300 },
|
|
floorQuads: [],
|
|
shadowQuads: [],
|
|
lowerWallQuads: [],
|
|
upperWallQuads: [],
|
|
roofQuads: [],
|
|
entities: {
|
|
unitDrawables: [],
|
|
missileDrawables: [],
|
|
overlayDrawables: [
|
|
{
|
|
drawableKind: 'overlay',
|
|
ownerUnitId: 1,
|
|
overlayId: 9,
|
|
token: 'curseamplifydamage',
|
|
frame: frameVal,
|
|
totalFrames: 4,
|
|
loop: true,
|
|
worldX: 0,
|
|
worldY: -60,
|
|
sortY: 0,
|
|
blendMode: 'trans50',
|
|
},
|
|
],
|
|
sortedDrawables: [
|
|
{
|
|
drawableKind: 'overlay',
|
|
ownerUnitId: 1,
|
|
overlayId: 9,
|
|
token: 'curseamplifydamage',
|
|
frame: frameVal,
|
|
totalFrames: 4,
|
|
loop: true,
|
|
worldX: 0,
|
|
worldY: -60,
|
|
sortY: 0,
|
|
blendMode: 'trans50',
|
|
},
|
|
],
|
|
pointLights: [],
|
|
},
|
|
}),
|
|
buildLightingInputs: () => ({} as any),
|
|
pickUnitAtScreen: () => null,
|
|
})
|
|
|
|
// Frame 1 at t=0ms and t=5555ms both draw AMP_1
|
|
drawn.length = 0
|
|
renderSceneSourceFrame({
|
|
source: makeSource(1),
|
|
lightingPreset: 'auto',
|
|
renderer: fakeRenderer as any,
|
|
overlayArtMap: fakeOverlayArt,
|
|
nowMs: 0,
|
|
})
|
|
expect(drawn[0]?.frame?.tag).toBe('AMP_1')
|
|
|
|
drawn.length = 0
|
|
renderSceneSourceFrame({
|
|
source: makeSource(1),
|
|
lightingPreset: 'auto',
|
|
renderer: fakeRenderer as any,
|
|
overlayArtMap: fakeOverlayArt,
|
|
nowMs: 88888,
|
|
})
|
|
expect(drawn[0]?.frame?.tag).toBe('AMP_1')
|
|
})
|
|
})
|
|
|
|
describe('Issue #603: Authentic 1.13c Overlay.txt Metadata & States Integration', () => {
|
|
it('populates authentic 1.13c curse overlays with 24 frames, trans50 blendMode, and height4=-60', () => {
|
|
const amp = AUTHENTIC_113C_OVERLAYS['curseamplifydamage']
|
|
expect(amp).toBeDefined()
|
|
expect(amp?.frames).toBe(24)
|
|
expect(amp?.animRate).toBe(16)
|
|
expect(amp?.trans).toBe(3) // trans50
|
|
expect(amp?.height4).toBe(-60)
|
|
|
|
const dec = AUTHENTIC_113C_OVERLAYS['cursedecrepify']
|
|
expect(dec).toBeDefined()
|
|
expect(dec?.frames).toBe(24)
|
|
expect(dec?.height4).toBe(-60)
|
|
|
|
const weaken = AUTHENTIC_113C_OVERLAYS['curseweaken']
|
|
expect(weaken?.frames).toBe(24)
|
|
expect(weaken?.height4).toBe(-60)
|
|
|
|
const lowerRes = AUTHENTIC_113C_OVERLAYS['curselowerresist']
|
|
expect(lowerRes?.frames).toBe(29)
|
|
expect(lowerRes?.height4).toBe(-60)
|
|
})
|
|
|
|
it('populates Energy Shield with 8 frames and normal (trans=5) blendMode', () => {
|
|
const es = AUTHENTIC_113C_OVERLAYS['energyshield']
|
|
expect(es).toBeDefined()
|
|
expect(es?.frames).toBe(8)
|
|
expect(es?.animRate).toBe(8)
|
|
expect(es?.trans).toBe(5) // normal blendMode
|
|
})
|
|
|
|
it('populates Frozen Armor and Chill Armor with 24 frames and trans50 blendMode', () => {
|
|
const fa = AUTHENTIC_113C_OVERLAYS['frozenarmor']
|
|
expect(fa?.frames).toBe(24)
|
|
expect(fa?.trans).toBe(3)
|
|
|
|
const ca = AUTHENTIC_113C_OVERLAYS['chillarmor']
|
|
expect(ca?.frames).toBe(24)
|
|
expect(ca?.trans).toBe(3)
|
|
})
|
|
|
|
it('does NOT create fake overlays for state 1 (Freeze) and state 2 (Poison)', () => {
|
|
// States 1 and 2 in Diablo II 1.13c are color shifts only; they do not have overlay entries in States.txt
|
|
expect(STATE_OVERLAYS[1]).toBeUndefined()
|
|
expect(STATE_OVERLAYS[2]).toBeUndefined()
|
|
|
|
const units: RenderableUnitInput[] = [
|
|
{
|
|
kind: 'monster',
|
|
id: 50,
|
|
classId: 1,
|
|
x: 100,
|
|
y: 100,
|
|
states: [1, 2], // Freeze + Poison
|
|
},
|
|
]
|
|
|
|
const res = buildSceneDrawables(units, { originX: 0, originY: 0, tick: 5 })
|
|
// No overlay drawables generated for state 1 or state 2
|
|
expect(res.overlayDrawables).toHaveLength(0)
|
|
// Unit has frozen blue/cyan tint applied
|
|
expect(res.unitDrawables[0]?.tintColor).toEqual([150, 215, 255])
|
|
})
|
|
|
|
it('generates authentic overlay drawable and point light for curse states', () => {
|
|
const units: RenderableUnitInput[] = [
|
|
{
|
|
kind: 'monster',
|
|
id: 55,
|
|
classId: 1,
|
|
x: 200,
|
|
y: 200,
|
|
states: [9], // Amplify Damage (State 9)
|
|
},
|
|
]
|
|
|
|
const res = buildSceneDrawables(units, { originX: 0, originY: 0, tick: 12 })
|
|
expect(res.overlayDrawables).toHaveLength(1)
|
|
const ov = res.overlayDrawables[0]!
|
|
expect(ov.token).toBe('curseamplifydamage')
|
|
expect(ov.totalFrames).toBe(24)
|
|
expect(ov.blendMode).toBe('trans50')
|
|
expect(ov.worldY).toBe(ov.sortY - 60) // height4 offset of -60px
|
|
// Point light configured for curse
|
|
expect(res.pointLights.some((l) => l.r === 255 && l.g === 64 && l.b === 64)).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('Issue #592: Strict Layer Z-Ordering & Fail Fast on Missing Room Data', () => {
|
|
it('sorts ground items and corpses at layer 0 below living units and missiles', () => {
|
|
const units: RenderableUnitInput[] = [
|
|
// Living monster at worldY = 100
|
|
{ kind: 'monster', id: 1, classId: 1, x: 10, y: 10 },
|
|
// Ground item at same worldY = 100
|
|
{ kind: 'item', id: 2, classId: 0, x: 10, y: 10, itemCode: 'hax' },
|
|
// Corpse at same worldY = 100
|
|
{ kind: 'monster', id: 3, classId: 1, x: 10, y: 10, isCorpse: true },
|
|
]
|
|
|
|
const res = buildSceneDrawables(units, { originX: 0, originY: 0, tick: 0 })
|
|
const sorted = res.sortedDrawables
|
|
|
|
// Both the item and corpse must precede the living monster in sorted drawables
|
|
const livingIdx = sorted.findIndex((d) => d.drawableKind === 'unit' && d.unitId === 1)
|
|
const itemIdx = sorted.findIndex((d) => d.drawableKind === 'unit' && d.unitId === 2)
|
|
const corpseIdx = sorted.findIndex((d) => d.drawableKind === 'unit' && d.unitId === 3)
|
|
|
|
expect(itemIdx).toBeLessThan(livingIdx)
|
|
expect(corpseIdx).toBeLessThan(livingIdx)
|
|
})
|
|
|
|
it('throws fast in buildCollisionGridFromLevelView when levelView or rooms are missing', () => {
|
|
expect(() => buildCollisionGridFromLevelView(null, 0, 0)).toThrowError(
|
|
'buildCollisionGridFromLevelView: missing room data',
|
|
)
|
|
expect(() => buildCollisionGridFromLevelView(undefined, 0, 0)).toThrowError(
|
|
'buildCollisionGridFromLevelView: missing room data',
|
|
)
|
|
const emptyLevelView = {
|
|
areaId: 1,
|
|
act: 0,
|
|
rooms: [],
|
|
cellsX: 10,
|
|
cellsY: 10,
|
|
gridWidth: 50,
|
|
subOriginX: 0,
|
|
subOriginY: 0,
|
|
isoOriginX: 0,
|
|
isoOriginY: 0,
|
|
blocked: new Uint8Array(2500),
|
|
collisionMasks: new Uint16Array(2500),
|
|
floors: [],
|
|
walls: [],
|
|
shadows: [],
|
|
roofs: [],
|
|
} as unknown as LevelView
|
|
expect(() => buildCollisionGridFromLevelView(emptyLevelView, 0, 0)).toThrowError(
|
|
'buildCollisionGridFromLevelView: missing room data',
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('Issue #596: Camera Panel Horizontal Shift (+/-160px)', () => {
|
|
it('computes correct panel camera shift (+160 for left, -160 for right, 0 for both or none)', () => {
|
|
expect(computePanelCameraShift(false, false)).toBe(0)
|
|
expect(computePanelCameraShift(true, false)).toBe(160)
|
|
expect(computePanelCameraShift(false, true)).toBe(-160)
|
|
expect(computePanelCameraShift(true, true)).toBe(0)
|
|
})
|
|
|
|
it('shifts cameraTopLeft and mouse picking when side panels are open in SceneSource', () => {
|
|
let leftOpen = false
|
|
let rightOpen = false
|
|
const source = new ClientWorldSceneSource((): ClientSceneWorldAdapter => ({
|
|
tick: 1,
|
|
actId: 1,
|
|
levelId: 1,
|
|
playerSubX: 50,
|
|
playerSubY: 50,
|
|
leftPanelOpen: leftOpen,
|
|
rightPanelOpen: rightOpen,
|
|
units: [],
|
|
levelView: null,
|
|
}))
|
|
|
|
// Default (no panels open): shift is 0
|
|
const centerCam = source.getCameraTopLeft()
|
|
const centerSnap = source.buildFrameSnapshot()
|
|
expect(centerSnap.panelShiftX).toBe(0)
|
|
|
|
// Left panel open (Character/Quest/Skills): camera shifts right (+160px)
|
|
leftOpen = true
|
|
rightOpen = false
|
|
const leftCam = source.getCameraTopLeft()
|
|
const leftSnap = source.buildFrameSnapshot()
|
|
expect(leftSnap.panelShiftX).toBe(160)
|
|
expect(leftCam.x - centerCam.x).toBe(160)
|
|
|
|
// Right panel open (Inventory/Stash): camera shifts left (-160px)
|
|
leftOpen = false
|
|
rightOpen = true
|
|
const rightCam = source.getCameraTopLeft()
|
|
const rightSnap = source.buildFrameSnapshot()
|
|
expect(rightSnap.panelShiftX).toBe(-160)
|
|
expect(rightCam.x - centerCam.x).toBe(-160)
|
|
})
|
|
|
|
it('round-trips subTileToScreen and screenToSubTile accurately with panelShiftX', () => {
|
|
const profile = VIEWPORT_800x600
|
|
const camSubX = 100
|
|
const camSubY = 100
|
|
const targetSubX = 105
|
|
const targetSubY = 98
|
|
const panelShiftX = 160 // Left panel open
|
|
|
|
const screenPos = subTileToScreen(targetSubX, targetSubY, camSubX, camSubY, profile, panelShiftX)
|
|
const roundTrip = screenToSubTile(screenPos.x, screenPos.y, camSubX, camSubY, profile, panelShiftX)
|
|
|
|
expect(Math.round(roundTrip.x)).toBe(targetSubX)
|
|
expect(Math.round(roundTrip.y)).toBe(targetSubY)
|
|
})
|
|
})
|
|
|
|
describe('Issue #597: Viewport Resolution Profiles & Fail-Fast Lookup', () => {
|
|
it('supports canonical 640x480 resolution profile in addition to 800x600 and widescreen profiles', () => {
|
|
expect(VALID_VIEWPORT_RESOLUTIONS).toContain('640x480')
|
|
expect(VIEWPORT_PROFILES['640x480']).toBeDefined()
|
|
expect(VIEWPORT_640x480.width).toBe(640)
|
|
expect(VIEWPORT_640x480.height).toBe(480)
|
|
expect(VIEWPORT_640x480.barOffsetX).toBe(-80)
|
|
expect(VIEWPORT_640x480.panelOffsetY).toBe(-60)
|
|
})
|
|
|
|
it('rejects profiles smaller than 640x480 minimum dimensions in createViewportProfile', () => {
|
|
expect(() => createViewportProfile('639x480' as any, 639, 480)).toThrowError(
|
|
/smaller than the minimum 640x480/,
|
|
)
|
|
expect(() => createViewportProfile('640x479' as any, 640, 479)).toThrowError(
|
|
/smaller than the minimum 640x480/,
|
|
)
|
|
})
|
|
|
|
it('fails fast on unknown string resolutions in getViewportProfile', () => {
|
|
expect(() => getViewportProfile('1920x1080')).toThrowError('Unknown viewport profile "1920x1080"')
|
|
expect(() => getViewportProfile('4k')).toThrowError('Unknown viewport profile "4k"')
|
|
expect(() => getViewportProfile('invalid_res')).toThrowError('Unknown viewport profile "invalid_res"')
|
|
|
|
// null, undefined, or empty returns default 800x600
|
|
expect(getViewportProfile(null)).toBe(VIEWPORT_800x600)
|
|
expect(getViewportProfile(undefined)).toBe(VIEWPORT_800x600)
|
|
expect(getViewportProfile('')).toBe(VIEWPORT_800x600)
|
|
expect(getViewportProfile('800x600')).toBe(VIEWPORT_800x600)
|
|
expect(getViewportProfile('640x480')).toBe(VIEWPORT_640x480)
|
|
})
|
|
})
|
|
|
|
describe('Issue #584: Automap Tab Toggle & Pre-Revealed Towns', () => {
|
|
it('pre-reveals authentic town levels (1, 40, 75, 103, 109)', () => {
|
|
expect(isTownLevel(1)).toBe(true) // Rogue Encampment
|
|
expect(isTownLevel(40)).toBe(true) // Lut Gholein
|
|
expect(isTownLevel(75)).toBe(true) // Kurast Docks
|
|
expect(isTownLevel(103)).toBe(true) // Pandemonium Fortress
|
|
expect(isTownLevel(109)).toBe(true) // Harrogath
|
|
|
|
expect(isTownLevel(2)).toBe(false) // Blood Moor
|
|
expect(isTownLevel(8)).toBe(false) // Den of Evil
|
|
})
|
|
|
|
it('sets preRevealTown flag on buildMinimapLevel for town areas', () => {
|
|
const automap = new AutomapView({ initialMode: 'off' })
|
|
const fakeTownLevelView = {
|
|
areaId: 1,
|
|
act: 0,
|
|
rooms: [{ tileX: 0, tileY: 0, tileW: 10, tileH: 10 }],
|
|
cellsX: 10,
|
|
cellsY: 10,
|
|
gridWidth: 50,
|
|
subOriginX: 0,
|
|
subOriginY: 0,
|
|
isoOriginX: 0,
|
|
isoOriginY: 0,
|
|
blocked: new Uint8Array(2500),
|
|
collisionMasks: new Uint16Array(2500),
|
|
floors: [],
|
|
walls: [],
|
|
shadows: [],
|
|
roofs: [],
|
|
} as unknown as LevelView
|
|
automap.syncActiveLevel(1, fakeTownLevelView)
|
|
const minimapLevel = automap.buildMinimapLevel()
|
|
expect(minimapLevel.preRevealTown).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('Issue #599: 25Hz Discrete Logic Clock Accumulator', () => {
|
|
it('steps simulation strictly in discrete 40ms intervals without fractional drift', () => {
|
|
let accumulatedMs = 0
|
|
const tickLog: number[] = []
|
|
const fakeSession = {
|
|
tick(dtMs: number) {
|
|
tickLog.push(dtMs)
|
|
},
|
|
}
|
|
|
|
const simulateFrame = (dtMs: number) => {
|
|
accumulatedMs = Math.min(200, accumulatedMs + dtMs)
|
|
while (accumulatedMs >= 40) {
|
|
fakeSession.tick(40)
|
|
accumulatedMs -= 40
|
|
}
|
|
}
|
|
|
|
// Frame 1: 16.6ms -> 0 ticks, accum = 16.6
|
|
simulateFrame(16.6)
|
|
expect(tickLog).toHaveLength(0)
|
|
expect(accumulatedMs).toBeCloseTo(16.6)
|
|
|
|
// Frame 2: 16.6ms -> 0 ticks, accum = 33.2
|
|
simulateFrame(16.6)
|
|
expect(tickLog).toHaveLength(0)
|
|
expect(accumulatedMs).toBeCloseTo(33.2)
|
|
|
|
// Frame 3: 16.6ms -> 1 tick of exactly 40ms, accum = 9.8
|
|
simulateFrame(16.6)
|
|
expect(tickLog).toEqual([40])
|
|
expect(accumulatedMs).toBeCloseTo(9.8)
|
|
|
|
// Frame 4: 75ms (lag spike) -> 2 ticks of exactly 40ms, accum = 4.8
|
|
simulateFrame(75)
|
|
expect(tickLog).toEqual([40, 40, 40])
|
|
expect(accumulatedMs).toBeCloseTo(4.8)
|
|
})
|
|
})
|
|
|
|
describe('Issue #601: In-Game Chat Bounds and Literal Whitespace', () => {
|
|
it('preserves leading and trailing literal whitespace when sending in-game chat', () => {
|
|
const dispatchedCommands: any[] = []
|
|
const mockSession = {
|
|
dispatchCommand(cmd: any) {
|
|
dispatchedCommands.push(cmd)
|
|
},
|
|
}
|
|
|
|
const chatInputEl = {
|
|
value: ' hello world ',
|
|
hidden: false,
|
|
blur() {},
|
|
}
|
|
|
|
// Simulate toggleInGameChatInput logic from main.ts
|
|
if (!chatInputEl.hidden) {
|
|
const msg = chatInputEl.value
|
|
chatInputEl.value = ''
|
|
chatInputEl.hidden = true
|
|
chatInputEl.blur()
|
|
if (msg.length > 0) {
|
|
mockSession.dispatchCommand({ type: 'Chat', text: msg })
|
|
}
|
|
}
|
|
|
|
expect(dispatchedCommands).toHaveLength(1)
|
|
expect(dispatchedCommands[0]).toEqual({
|
|
type: 'Chat',
|
|
text: ' hello world ', // Literal spaces preserved!
|
|
})
|
|
})
|
|
|
|
it('enforces responsive max-width calc and border-box sizing on chat input element', () => {
|
|
const chatStyle =
|
|
'position:absolute;left:24px;bottom:64px;width:360px;max-width:calc(100% - 48px);box-sizing:border-box;z-index:25;background:rgba(10,8,6,0.92);border:1px solid #c8a858;color:#f2e6c8;padding:4px 8px;font:13px "D2Formal436",serif;outline:none;'
|
|
expect(chatStyle).toContain('max-width:calc(100% - 48px)')
|
|
expect(chatStyle).toContain('box-sizing:border-box')
|
|
})
|
|
})
|
|
})
|