diablo2-web/tests/client/spawn-position-539.test.ts

267 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Issue #539 regression: the local player must be rendered exactly at the server-assigned
* world position, on Rogue Encampment walkable floor, across many map seeds.
*
* Root cause (fixed): `subTileToWorldOrtho` / `subTileToScreen` projected unit sub-tiles on a
* 16×8 px lattice (`ORTHO_SUB_TILE_* / 2`) while the DT1 floor tiles are laid out on the 1.13c
* 160×80 px tile = 5×5 sub-tiles of 32×16 px lattice (`(x - y) * 16`, `(x + y) * 8`). The player
* sprite and the camera following it were drawn at half their distance from the level iso origin,
* i.e. on the palisade / forest outside the camp, while the server (and our DRLG collision) had
* the player standing on camp floor.
*
* The test drives the real `OnlineSession` replay path (D2GS S→C decode → ClientWorld →
* predictor → `MapService` DRLG from `0x03 LoadAct` → `ClientWorldSceneSource`) with live 1.13c
* captures (`tests/fixtures/netproto/*.d2cap`, one fresh game / map seed each) and compares the
* rendered result with ground truth decoded independently by `replayD2CapText`.
*/
import { readdirSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { beforeAll, describe, expect, it } from 'vitest'
import '../../tools/lib/node-map-loader.ts'
import { replayD2CapText } from '../../tools/netproto-replay.ts'
import {
MapService,
getCollisionWorldSubTile,
isWalkableWorldSubTile,
localSubTileToOrtho,
orthoToLocalSubTile,
type LevelView,
} from '../../src/client/map/map-service.ts'
import { OnlineSession } from '../../src/client/session/online-session.ts'
import { createSettingsStore } from '../../src/client/settings/settings-store.ts'
import {
VIEWPORT_800x600,
screenToSubTile,
subTileToScreen,
subTileToWorldOrtho,
worldOrthoToSubTile,
} from '../../src/client/view/viewport-profile.ts'
import { COLLIDE_BLANK, COLLIDE_MASK_INVALID, COLLIDE_WALL } from '../../src/common/world/d2map.ts'
const FIXTURE_DIR = resolve('tests/fixtures/netproto')
const ROGUE_ENCAMPMENT = 1
/** Objects.txt id 119: Act 1 town waypoint (`waypoint`, Rogue Encampment). */
const ACT1_TOWN_WAYPOINT_CLASS = 119
/** Every capture that enters a game (01..04 legacy fixtures + 10-d2gs-spawn-seed-* fresh games). */
const SPAWN_FIXTURES = readdirSync(FIXTURE_DIR)
.filter(name => name.endsWith('.d2cap'))
.filter(name => replayD2CapText(readFileSync(resolve(FIXTURE_DIR, name), 'utf8')).world.mapSeed !== 0)
.sort()
/** 4-connected walkable component labels over the LevelView sub-tile grid. */
function labelWalkableComponents(view: LevelView): { readonly label: Int32Array; readonly sizes: number[] } {
const w = view.subWidth
const h = view.subHeight
const label = new Int32Array(w * h).fill(-1)
const sizes: number[] = []
for (let start = 0; start < w * h; start++) {
if (view.blocked[start] !== 0 || label[start]! >= 0) continue
const id = sizes.length
let size = 0
const stack = [start]
label[start] = id
while (stack.length > 0) {
const i = stack.pop()!
size++
const x = i % w
const y = (i - x) / w
const neighbors = [
x + 1 < w ? i + 1 : -1,
x > 0 ? i - 1 : -1,
y + 1 < h ? i + w : -1,
y > 0 ? i - w : -1,
]
for (const n of neighbors) {
if (n < 0 || view.blocked[n] !== 0 || label[n]! >= 0) continue
label[n] = id
stack.push(n)
}
}
sizes.push(size)
}
return { label, sizes }
}
describe('Issue #539: player spawn is rendered at the server position inside the Rogue Encampment', () => {
let mapService: MapService
beforeAll(async () => {
mapService = await MapService.fromPackedDir(resolve('public/act-packs/tiles'))
})
it('has spawn captures from several distinct map seeds and town layouts', () => {
const seeds = new Set<number>()
const layouts = new Set<string>()
for (const name of SPAWN_FIXTURES) {
const world = replayD2CapText(readFileSync(resolve(FIXTURE_DIR, name), 'utf8')).world
seeds.add(world.mapSeed >>> 0)
const view = mapService.loadAct({
act: world.act,
seed: world.mapSeed,
difficulty: world.difficulty as 0 | 1 | 2,
areaId: world.areaId,
})
expect(view).not.toBeNull()
layouts.add(`${String(world.self.x - view!.subOriginX)},${String(world.self.y - view!.subOriginY)}`)
}
expect(seeds.size).toBeGreaterThanOrEqual(8)
// The Rogue Encampment preset orientation is seed-dependent; cover more than one variant.
expect(layouts.size).toBeGreaterThanOrEqual(3)
})
it('sub-tile projection uses the same 32×16 px lattice as the DT1 floor tiles', () => {
const view = { isoOriginX: 3280, isoOriginY: 0 }
for (const [x, y] of [
[0, 0],
[5, 0],
[0, 5],
[137, 123],
[153, 68],
] as const) {
const unit = subTileToWorldOrtho(x, y, view.isoOriginX, view.isoOriginY)
const tile = localSubTileToOrtho(view, x, y)
expect(unit).toEqual({ x: tile.x, y: tile.y })
const back = worldOrthoToSubTile(unit.x, unit.y, view.isoOriginX, view.isoOriginY)
expect(back.x).toBeCloseTo(x, 9)
expect(back.y).toBeCloseTo(y, 9)
}
// One tile (5 sub-tiles) along +x is (80, 40) px, exactly one ORTHO cell.
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 })
const p = screenToSubTile(s.x, s.y, 5730, 4520, VIEWPORT_800x600)
expect(p.x).toBeCloseTo(5737, 9)
expect(p.y).toBeCloseTo(4523, 9)
})
for (const name of SPAWN_FIXTURES) {
it(`${name}: rendered player == server position, on camp floor`, () => {
const text = readFileSync(resolve(FIXTURE_DIR, name), 'utf8')
// Independent ground truth: plain protocol replay into a bare ClientWorld.
const truth = replayD2CapText(text, { strict: true }).world
expect(truth.areaId).toBe(ROGUE_ENCAMPMENT)
expect(truth.self.x).toBeGreaterThan(0)
expect(truth.self.y).toBeGreaterThan(0)
const session = new OnlineSession({ settingsStore: createSettingsStore(), mapService })
session.loadD2CapReplay(text)
session.replayAllD2Cap()
// Let any rubber-band reconciliation settle; no movement commands are issued.
for (let i = 0; i < 50; i++) session.tick(40, 1000 + i * 40)
expect(session.disconnectReason).toBeNull()
// The level the scene renders must be the one containing the server position (#539 root
// cause #2): AutomapView builds neighbouring levels (Blood Moor) for 0x07 reveals near the
// spawn, which used to switch MapService's "active" view — and therefore the rendered map.
const view = session.sceneSource.getLevelView()
expect(view).not.toBeNull()
expect(view!.areaId).toBe(ROGUE_ENCAMPMENT)
expect(view!.seed).toBe(truth.mapSeed >>> 0)
const snap = session.sceneSource.buildFrameSnapshot()
expect(snap.worldReady).toBe(true)
expect(snap.levelId).toBe(ROGUE_ENCAMPMENT)
// 1. Player / camera anchor == server position (no locally computed spawn).
expect(snap.playerSubTile.x).toBeCloseTo(truth.self.x, 6)
expect(snap.playerSubTile.y).toBeCloseTo(truth.self.y, 6)
expect(session.camera.subX).toBeCloseTo(truth.self.x, 6)
expect(session.camera.subY).toBeCloseTo(truth.self.y, 6)
expect(snap.playerLocalSubTile.x).toBeCloseTo(truth.self.x - view!.subOriginX, 6)
expect(snap.playerLocalSubTile.y).toBeCloseTo(truth.self.y - view!.subOriginY, 6)
// 2. The pixel where the player sprite / camera is drawn lies over the server sub-tile in the
// tile lattice (old half-scale projection put it ~(68,61) sub-tiles away, on the palisade).
const under = orthoToLocalSubTile(view!, snap.playerWorld.x, snap.playerWorld.y)
expect(under.x).toBeCloseTo(snap.playerLocalSubTile.x, 6)
expect(under.y).toBeCloseTo(snap.playerLocalSubTile.y, 6)
const selfDrawable = snap.entities.unitDrawables.find(
u => u.unitKind === 'player' && u.unitId === truth.selfId,
)
expect(selfDrawable).toBeDefined()
expect(selfDrawable!.worldX).toBeCloseTo(snap.playerWorld.x, 6)
expect(selfDrawable!.worldY).toBeCloseTo(snap.playerWorld.y, 6)
expect(snap.cameraTopLeft.x).toBeCloseTo(snap.playerWorld.x - snap.viewportProfile.width / 2, 6)
expect(snap.cameraTopLeft.y).toBeCloseTo(snap.playerWorld.y - snap.viewportProfile.height / 2, 6)
// 3. The server sub-tile is walkable camp floor in the client DRLG collision.
const sx = Math.floor(truth.self.x)
const sy = Math.floor(truth.self.y)
expect(isWalkableWorldSubTile(view!, sx, sy)).toBe(true)
const mask = getCollisionWorldSubTile(view!, sx, sy)
expect(mask & (COLLIDE_WALL | COLLIDE_BLANK)).toBe(0)
expect(mask & COLLIDE_MASK_INVALID).not.toBe(COLLIDE_MASK_INVALID)
// 4. Inside the camp: the player's walkable component is the level's main floor and reaches the
// town waypoint the server spawned for this game.
const { label, sizes } = labelWalkableComponents(view!)
const playerComp = label[(sy - view!.subOriginY) * view!.subWidth + (sx - view!.subOriginX)]!
expect(playerComp).toBeGreaterThanOrEqual(0)
expect(sizes[playerComp]).toBe(Math.max(...sizes))
const waypoints = truth.getUnitsByType('object').filter(o => o.classId === ACT1_TOWN_WAYPOINT_CLASS)
expect(waypoints.length).toBe(1)
const wp = waypoints[0]!
let waypointReachable = false
for (let dy = -4; dy <= 4 && !waypointReachable; dy++) {
for (let dx = -4; dx <= 4 && !waypointReachable; dx++) {
const lx = wp.x + dx - view!.subOriginX
const ly = wp.y + dy - view!.subOriginY
if (lx < 0 || ly < 0 || lx >= view!.subWidth || ly >= view!.subHeight) continue
waypointReachable = label[ly * view!.subWidth + lx] === playerComp
}
}
expect(waypointReachable).toBe(true)
// 5. Every other server unit is drawn over its own server sub-tile as well (same lattice).
for (const u of snap.entities.unitDrawables) {
const back = orthoToLocalSubTile(view!, u.worldX, u.worldY)
// `u.subX/subY` are level-local (`world - subOrigin`) after scene normalisation.
expect(back.x).toBeCloseTo(u.subX, 6)
expect(back.y).toBeCloseTo(u.subY, 6)
}
})
}
it('0x07 reveals of a neighbouring level (Blood Moor) do not switch the rendered level', () => {
// 04-d2gs-idle-60s spawns near the camp's north gate: the server reveals Blood Moor (area 2)
// rooms, AutomapView builds that LevelView and MapService's "active" view becomes area 2.
const text = readFileSync(resolve(FIXTURE_DIR, '04-d2gs-idle-60s.d2cap'), 'utf8')
const session = new OnlineSession({ settingsStore: createSettingsStore(), mapService })
session.loadD2CapReplay(text)
session.replayAllD2Cap()
expect(session.world.getRevealedAreaIds()).toContain(2)
expect(mapService.getActiveLevelView()?.areaId).toBe(2) // precondition: the old hijack source
const view = session.sceneSource.getLevelView()
expect(view?.areaId).toBe(ROGUE_ENCAMPMENT)
expect(session.sceneSource.buildFrameSnapshot().levelId).toBe(ROGUE_ENCAMPMENT)
})
it('reports not-ready (never anchors the camera) before the server assigns a position', () => {
const text = readFileSync(resolve(FIXTURE_DIR, '10-d2gs-spawn-seed-52e4e6ff.d2cap'), 'utf8')
const truth = replayD2CapText(text, { strict: true }).world
const session = new OnlineSession({ settingsStore: createSettingsStore(), mapService })
session.handleServerEvent({ type: 'LoadAct', act: truth.act, mapSeed: truth.mapSeed, areaId: ROGUE_ENCAMPMENT })
const snap = session.sceneSource.buildFrameSnapshot()
expect(snap.worldReady).toBe(false)
expect(session.sceneSource.getLevelView()).toBeNull()
session.handleServerEvent({
type: 'UnitReassign',
unitType: 'player',
id: truth.selfId,
at: { x: truth.self.x, y: truth.self.y },
snap: true,
})
expect(session.sceneSource.buildFrameSnapshot().worldReady).toBe(true)
expect(session.sceneSource.getLevelView()?.areaId).toBe(ROGUE_ENCAMPMENT)
})
it('fails fast on a 0x03 LoadAct without an areaId instead of defaulting to a town', () => {
const session = new OnlineSession({ settingsStore: createSettingsStore(), mapService })
session.handleServerEvent({ type: 'LoadAct', act: 0, mapSeed: 0x52e4e6ff, areaId: 0 })
expect(session.disconnectReason).toMatch(/LoadAct carried no areaId/)
expect(session.sceneSource.buildFrameSnapshot().worldReady).toBe(false)
})
})