279 lines
13 KiB
TypeScript
279 lines
13 KiB
TypeScript
/**
|
||
* 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 truthResult = replayD2CapText(text, { strict: true })
|
||
const truth = truthResult.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))
|
||
// The waypoint is a static object; captures that walk away from it (e.g. 06/07 vendor runs)
|
||
// see the server remove it (0x0A) before the capture ends, so take its server UnitAssign
|
||
// from anywhere in the capture rather than the final world state.
|
||
const waypointIds = new Set<number>()
|
||
const waypoints: { readonly x: number; readonly y: number }[] = []
|
||
for (const ev of truthResult.events) {
|
||
if (ev.type !== 'UnitAssign') continue
|
||
const u = ev.unit
|
||
if (u.unitType !== 2 || u.classId !== ACT1_TOWN_WAYPOINT_CLASS || waypointIds.has(u.id)) continue
|
||
waypointIds.add(u.id)
|
||
waypoints.push({ x: u.x, y: u.y })
|
||
}
|
||
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)
|
||
})
|
||
})
|