576 lines
22 KiB
TypeScript
576 lines
22 KiB
TypeScript
import * as fs from 'node:fs'
|
|
import * as os from 'node:os'
|
|
import * as path from 'node:path'
|
|
import { beforeAll, describe, expect, it } from 'vitest'
|
|
import {
|
|
D2S_MAGIC,
|
|
D2S_MIN_HEADER_SIZE,
|
|
D2S_QUEST_OFFSET,
|
|
D2S_VERSION_113C,
|
|
D2S_WAYPOINT_OFFSET,
|
|
unlockD2sBuffer,
|
|
unlockLocalD2sFile,
|
|
validateBotCharacterName,
|
|
verifyD2sUnlocked,
|
|
writeD2sChecksum,
|
|
} from '../../scripts/d2s-unlock.ts'
|
|
import type { PackedTileCatalogIndex } from '../../scripts/pack-tiles.ts'
|
|
import {
|
|
MapService,
|
|
MapWorkerEngine,
|
|
TileAtlas,
|
|
getCollisionLocalSubTile,
|
|
getCollisionWorldSubTile,
|
|
isWalkableLocalSubTile,
|
|
isWalkableWorldSubTile,
|
|
localSubTileToOrtho,
|
|
localToWorldSubTile,
|
|
orthoToLocalSubTile,
|
|
worldToLocalSubTile,
|
|
type MapWorkerRequest,
|
|
type MapWorkerResponse,
|
|
type WorkerLike,
|
|
} from '../../src/client/map/map-service.ts'
|
|
import { RemoteEntityInterpolator } from '../../src/client/motion/interpolate.ts'
|
|
import {
|
|
findNearestWalkableLocal,
|
|
findPathLocal,
|
|
findPathWorld,
|
|
hasLineOfSightLocal,
|
|
type SubTileWalkabilityGrid,
|
|
} from '../../src/client/motion/pathfind.ts'
|
|
import {
|
|
D2_BASE_RUN_SUBTILES_PER_SEC,
|
|
D2_BASE_WALK_SUBTILES_PER_SEC,
|
|
LocalMovementPredictor,
|
|
} from '../../src/client/motion/predictor.ts'
|
|
import { COLLIDE_MASK_INVALID } from '../../src/game/d2map.ts'
|
|
|
|
const TILES_PACK_DIR = path.resolve('public/act-packs/tiles')
|
|
|
|
function createSyntheticD2sBuffer(charName: string): Uint8Array {
|
|
const buf = new Uint8Array(765)
|
|
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength)
|
|
view.setUint32(0x00, D2S_MAGIC, true)
|
|
view.setUint32(0x04, D2S_VERSION_113C, true)
|
|
view.setUint32(0x08, buf.byteLength, true)
|
|
|
|
for (let i = 0; i < Math.min(15, charName.length); i++) {
|
|
buf[0x14 + i] = charName.charCodeAt(i) & 0x7f
|
|
}
|
|
buf[0x24] = 0x20 // Expansion
|
|
buf[0x25] = 0x00 // Progression 0
|
|
buf[0x2b] = 1 // Level 1
|
|
|
|
// "Woo!" quest header at 0x14F
|
|
buf[D2S_QUEST_OFFSET] = 0x57 // 'W'
|
|
buf[D2S_QUEST_OFFSET + 1] = 0x6f // 'o'
|
|
buf[D2S_QUEST_OFFSET + 2] = 0x6f // 'o'
|
|
buf[D2S_QUEST_OFFSET + 3] = 0x21 // '!'
|
|
view.setUint32(D2S_QUEST_OFFSET + 4, 6, true)
|
|
view.setUint16(D2S_QUEST_OFFSET + 8, 298, true)
|
|
|
|
// "WS" waypoint header at 0x279
|
|
buf[D2S_WAYPOINT_OFFSET] = 0x57 // 'W'
|
|
buf[D2S_WAYPOINT_OFFSET + 1] = 0x53 // 'S'
|
|
view.setUint32(D2S_WAYPOINT_OFFSET + 2, 1, true)
|
|
view.setUint16(D2S_WAYPOINT_OFFSET + 6, 80, true)
|
|
for (let diff = 0; diff < 3; diff++) {
|
|
const wpBase = 0x281 + diff * 24
|
|
buf[wpBase] = 0x02
|
|
buf[wpBase + 1] = 0x01
|
|
buf[wpBase + 2] = 0x01 // Only Rogue Encampment wp unlocked initially
|
|
}
|
|
|
|
writeD2sChecksum(buf)
|
|
return buf
|
|
}
|
|
|
|
describe('Milestone M3 — TileAtlas, Runtime DRLG (136 Levels), Client Motion & d2s-unlock', () => {
|
|
let mapService: MapService
|
|
let tileAtlas: TileAtlas
|
|
|
|
beforeAll(async () => {
|
|
mapService = await MapService.fromPackedDir(TILES_PACK_DIR)
|
|
const atlas = mapService.getTileAtlas()
|
|
if (!atlas) {
|
|
throw new Error('Expected TileAtlas to be initialized from public/act-packs/tiles')
|
|
}
|
|
tileAtlas = atlas
|
|
}, 60_000)
|
|
|
|
describe('F11 — Seed-Independent Tile Library Asset Pack & TileAtlas', () => {
|
|
it('packs all 233 DT1 libraries across Acts 1-5 with 0 missing files and universal DT1s', () => {
|
|
const indexJsonPath = path.join(TILES_PACK_DIR, 'index.json')
|
|
expect(fs.existsSync(indexJsonPath)).toBe(true)
|
|
const catalog = JSON.parse(fs.readFileSync(indexJsonPath, 'utf8')) as PackedTileCatalogIndex
|
|
expect(catalog.schema).toBe(1)
|
|
expect(catalog.missingFiles).toEqual([])
|
|
expect(catalog.totalLibraries).toBeGreaterThanOrEqual(230)
|
|
expect(catalog.invisWalFloorVerified).toBe(true)
|
|
|
|
for (const act of [1, 2, 3, 4, 5] as const) {
|
|
const actDir = path.join(TILES_PACK_DIR, `act${String(act)}`)
|
|
expect(fs.existsSync(path.join(actDir, 'palette.bin'))).toBe(true)
|
|
expect(fs.existsSync(path.join(actDir, 'index.json'))).toBe(true)
|
|
expect(fs.existsSync(path.join(actDir, 'tiles-0.r8'))).toBe(true)
|
|
expect(fs.existsSync(path.join(actDir, 'tiles-0.png'))).toBe(true)
|
|
}
|
|
|
|
const univ = tileAtlas.verifyUniversalDt1s()
|
|
expect(univ).toEqual({
|
|
blankFound: true,
|
|
invisWalFound: true,
|
|
invisWalFloor49_7Found: true,
|
|
warpFound: true,
|
|
allValid: true,
|
|
})
|
|
})
|
|
|
|
it('resolves DRLG-pinned tiles by (dt1Path, tileIndex) and falls back deterministically via SplitMix64', () => {
|
|
const invisWalPath = 'data\\global\\tiles\\act1\\barracks\\inviswal.dt1'
|
|
const lib = tileAtlas.getLibrary(invisWalPath)
|
|
expect(lib).not.toBeNull()
|
|
expect(lib!.tiles.length).toBeGreaterThan(0)
|
|
|
|
// Pinned lookup
|
|
const pinned = tileAtlas.resolveCellTile({
|
|
dt1Path: invisWalPath,
|
|
tileIndex: 0,
|
|
style: lib!.tiles[0]!.style,
|
|
sequence: lib!.tiles[0]!.sequence,
|
|
tileType: lib!.tiles[0]!.type,
|
|
x: 10,
|
|
y: 20,
|
|
})
|
|
expect(pinned).not.toBeNull()
|
|
expect(pinned!.matchKind).toBe('pinned')
|
|
expect(pinned!.tileIndex).toBe(0)
|
|
|
|
// Fallback lookup with out-of-bounds tileIndex on InvisWal floor (49, 7, 0)
|
|
const fallbackA = tileAtlas.resolveCellTile({
|
|
dt1Path: invisWalPath,
|
|
tileIndex: 99999,
|
|
style: 49,
|
|
sequence: 7,
|
|
tileType: 0,
|
|
x: 17,
|
|
y: 33,
|
|
})
|
|
const fallbackB = tileAtlas.resolveCellTile({
|
|
dt1Path: invisWalPath,
|
|
tileIndex: 99999,
|
|
style: 49,
|
|
sequence: 7,
|
|
tileType: 0,
|
|
x: 17,
|
|
y: 33,
|
|
})
|
|
expect(fallbackA).not.toBeNull()
|
|
expect(fallbackA!.matchKind).toBe('fallback')
|
|
expect(fallbackA!.dt1Path).toBe(invisWalPath)
|
|
expect(fallbackA!.tileIndex).toBe(fallbackB!.tileIndex)
|
|
})
|
|
})
|
|
|
|
describe('F12 — Runtime DRLG Session, LevelView, Room Reveal & All 136 Levels Parity', () => {
|
|
it('generates valid LevelViews with walkable floors and void blocking across all 136 levels (Acts 1-5)', () => {
|
|
const actLevelRanges: readonly { readonly act: number; readonly start: number; readonly end: number }[] = [
|
|
{ act: 0, start: 1, end: 39 },
|
|
{ act: 1, start: 40, end: 74 },
|
|
{ act: 2, start: 75, end: 102 },
|
|
{ act: 3, start: 103, end: 108 },
|
|
{ act: 4, start: 109, end: 136 },
|
|
]
|
|
|
|
const seed = 0x12345678
|
|
let verifiedLevels = 0
|
|
|
|
for (const range of actLevelRanges) {
|
|
mapService.loadAct({ act: range.act, seed, difficulty: 0 })
|
|
for (let areaId = range.start; areaId <= range.end; areaId++) {
|
|
const view = mapService.getLevelView(areaId, 'all')
|
|
expect(view.id).toBe(areaId)
|
|
expect(view.act).toBe(range.act)
|
|
expect(view.seed).toBe(seed)
|
|
expect(view.width).toBeGreaterThan(0)
|
|
expect(view.height).toBeGreaterThan(0)
|
|
expect(view.subWidth).toBe(view.width * 5)
|
|
expect(view.subHeight).toBe(view.height * 5)
|
|
expect(view.rooms.length).toBeGreaterThan(0)
|
|
expect(view.walkableCellCount).toBeGreaterThan(0)
|
|
expect(view.collision.length).toBe(view.subWidth * view.subHeight)
|
|
expect(view.walkable.length).toBe(view.subWidth * view.subHeight)
|
|
|
|
// Verify spawn tile is inside bounds
|
|
expect(view.spawnX).toBeGreaterThanOrEqual(0)
|
|
expect(view.spawnX).toBeLessThan(view.width)
|
|
expect(view.spawnY).toBeGreaterThanOrEqual(0)
|
|
expect(view.spawnY).toBeLessThan(view.height)
|
|
|
|
// Verify at least one sub-tile in or adjacent to the spawn tile is walkable
|
|
let spawnAreaWalkable = false
|
|
for (let dy = -2; dy <= 7 && !spawnAreaWalkable; dy++) {
|
|
for (let dx = -2; dx <= 7; dx++) {
|
|
if (isWalkableLocalSubTile(view, view.spawnX * 5 + dx, view.spawnY * 5 + dy)) {
|
|
spawnAreaWalkable = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
expect(spawnAreaWalkable).toBe(true)
|
|
|
|
// Verify out-of-bounds queries return COLLIDE_MASK_INVALID and non-walkable
|
|
expect(getCollisionLocalSubTile(view, -1, -1)).toBe(COLLIDE_MASK_INVALID)
|
|
expect(isWalkableLocalSubTile(view, -1, -1)).toBe(false)
|
|
|
|
verifiedLevels++
|
|
}
|
|
}
|
|
|
|
expect(verifiedLevels).toBe(136)
|
|
}, 120_000)
|
|
|
|
it('round-trips world sub-tile, local sub-tile, and isometric ortho coordinates accurately', () => {
|
|
const view = mapService.onLoadActPacket({
|
|
act: 0,
|
|
mapSeed: 0x00c0ffee,
|
|
areaId: 1,
|
|
})
|
|
expect(view).not.toBeNull()
|
|
const lv = view!
|
|
|
|
const localX = lv.spawnX * 5 + 2
|
|
const localY = lv.spawnY * 5 + 2
|
|
const world = localToWorldSubTile(lv, localX, localY)
|
|
expect(world.worldSubX).toBe(lv.subOriginX + localX)
|
|
expect(world.worldSubY).toBe(lv.subOriginY + localY)
|
|
|
|
const backLocal = worldToLocalSubTile(lv, world.worldSubX, world.worldSubY)
|
|
expect(backLocal.localSubX).toBe(localX)
|
|
expect(backLocal.localSubY).toBe(localY)
|
|
|
|
expect(isWalkableWorldSubTile(lv, world.worldSubX, world.worldSubY)).toBe(
|
|
isWalkableLocalSubTile(lv, localX, localY),
|
|
)
|
|
expect(getCollisionWorldSubTile(lv, world.worldSubX, world.worldSubY)).toBe(
|
|
getCollisionLocalSubTile(lv, localX, localY),
|
|
)
|
|
|
|
const ortho = localSubTileToOrtho(localX, localY)
|
|
const fromOrtho = orthoToLocalSubTile(ortho.orthoX, ortho.orthoY)
|
|
expect(fromOrtho.localSubX).toBeCloseTo(localX, 6)
|
|
expect(fromOrtho.localSubY).toBeCloseTo(localY, 6)
|
|
})
|
|
|
|
it('supports progressive room reveal via 0x07 AddRoomData and 0x08 RemoveRoomData', () => {
|
|
mapService.loadAct({ act: 0, seed: 0x12345678, difficulty: 0 })
|
|
const fullBloodMoor = mapService.getLevelView(2, 'all')
|
|
expect(fullBloodMoor.rooms.length).toBeGreaterThan(0)
|
|
|
|
// Initially in 'revealed-only' mode, no rooms in Blood Moor are revealed
|
|
const unrevealedView = mapService.getLevelView(2, 'revealed-only', true)
|
|
expect(unrevealedView.walkableCellCount).toBe(0)
|
|
|
|
// Find a room that has walkable cells when revealed
|
|
let targetRoom = fullBloodMoor.rooms[0]!
|
|
for (const room of fullBloodMoor.rooms) {
|
|
const revealed = mapService.onAddRoomDataPacket(
|
|
{ areaId: 2, roomX: room.tileX, roomY: room.tileY },
|
|
{ rebuildView: true, revealMode: 'revealed-only' },
|
|
)
|
|
expect(revealed.room).not.toBeNull()
|
|
expect(revealed.room!.revealed).toBe(true)
|
|
if (revealed.view && revealed.view.walkableCellCount > 0) {
|
|
targetRoom = room
|
|
break
|
|
}
|
|
}
|
|
|
|
const partialView = mapService.getLevelView(2, 'revealed-only', true)
|
|
expect(partialView.walkableCellCount).toBeGreaterThan(0)
|
|
expect(partialView.walkableCellCount).toBeLessThan(fullBloodMoor.walkableCellCount)
|
|
|
|
// Hide rooms again via 0x08 RemoveRoomData
|
|
for (const room of fullBloodMoor.rooms) {
|
|
mapService.onRemoveRoomDataPacket(
|
|
{ areaId: 2, roomX: room.tileX, roomY: room.tileY },
|
|
{ rebuildView: false },
|
|
)
|
|
}
|
|
const hiddenAgain = mapService.getLevelView(2, 'revealed-only', true)
|
|
expect(hiddenAgain.walkableCellCount).toBe(0)
|
|
expect(targetRoom.areaId).toBe(2)
|
|
})
|
|
|
|
it('supports asynchronous Web Worker requests via MapService.fromWorker', async () => {
|
|
const engine = (mapService as unknown as { engine: MapWorkerEngine }).engine
|
|
const listeners: ((event: { readonly data: MapWorkerResponse }) => void)[] = []
|
|
const mockWorker: WorkerLike = {
|
|
postMessage(req: MapWorkerRequest) {
|
|
const res = engine.handleRequest(req)
|
|
queueMicrotask(() => {
|
|
for (const l of listeners) l({ data: res })
|
|
})
|
|
},
|
|
addEventListener(_type, listener) {
|
|
listeners.push(listener)
|
|
},
|
|
}
|
|
|
|
const workerService = MapService.fromWorker(mockWorker, tileAtlas)
|
|
const view = await workerService.loadActAsync({
|
|
act: 0,
|
|
seed: 0x12345678,
|
|
areaId: 1,
|
|
})
|
|
expect(view).not.toBeNull()
|
|
expect(view!.id).toBe(1)
|
|
expect(view!.walkableCellCount).toBeGreaterThan(0)
|
|
})
|
|
})
|
|
|
|
describe('F12 — Client Pathfinding, Movement Prediction & Entity Interpolation', () => {
|
|
it('computes 8-directional A* paths, prevents diagonal corner-cutting, and smooths via Bresenham LOS', () => {
|
|
// 10x10 grid with a diagonal pinch obstacle at (4, 4) and (5, 3)
|
|
const subWidth = 10
|
|
const subHeight = 10
|
|
const walkable = new Uint8Array(subWidth * subHeight)
|
|
walkable.fill(1)
|
|
walkable[4 * subWidth + 4] = 0
|
|
walkable[3 * subWidth + 5] = 0
|
|
const grid: SubTileWalkabilityGrid = { subWidth, subHeight, walkable }
|
|
|
|
// Direct diagonal move from (4,3) to (5,4) must be forbidden because (4,4) and (5,3) are blocked
|
|
expect(hasLineOfSightLocal(grid, 4, 3, 5, 4)).toBe(false)
|
|
|
|
const res = findPathLocal(grid, 4, 3, 5, 4, { smooth: false })
|
|
expect(res.reachedGoal).toBe(true)
|
|
expect(res.rawPath.length).toBeGreaterThan(2)
|
|
// Verify no step in rawPath cuts directly from (4,3) to (5,4)
|
|
for (let i = 1; i < res.rawPath.length; i++) {
|
|
const prev = res.rawPath[i - 1]!
|
|
const curr = res.rawPath[i]!
|
|
const isDirectPinch = prev.x === 4 && prev.y === 3 && curr.x === 5 && curr.y === 4
|
|
expect(isDirectPinch).toBe(false)
|
|
expect(walkable[curr.y * subWidth + curr.x]).toBe(1)
|
|
}
|
|
|
|
// Open corridor string-pulling compresses straight line to 2 points
|
|
const openWalkable = new Uint8Array(100)
|
|
openWalkable.fill(1)
|
|
const openGrid: SubTileWalkabilityGrid = { subWidth: 10, subHeight: 10, walkable: openWalkable }
|
|
const smoothRes = findPathLocal(openGrid, 1, 1, 8, 6, { smooth: true })
|
|
expect(smoothRes.reachedGoal).toBe(true)
|
|
expect(smoothRes.path).toEqual([
|
|
{ x: 1, y: 1 },
|
|
{ x: 8, y: 6 },
|
|
])
|
|
|
|
// Blocked goal resolves to nearest walkable cell
|
|
walkable[8 * subWidth + 8] = 0
|
|
const nearest = findNearestWalkableLocal(grid, 8, 8, 3, 0, 0)
|
|
expect(nearest).not.toBeNull()
|
|
expect(walkable[nearest!.y * subWidth + nearest!.x]).toBe(1)
|
|
})
|
|
|
|
it('finds world sub-tile paths on a real DRLG level (Rogue Encampment) in < 10ms', () => {
|
|
const view = mapService.loadAct({ act: 0, seed: 0x12345678, areaId: 1 })!
|
|
const startLocal = findNearestWalkableLocal(view, view.spawnX * 5 + 2, view.spawnY * 5 + 2, 10)!
|
|
const goalLocal = findNearestWalkableLocal(
|
|
view,
|
|
Math.min(view.subWidth - 5, startLocal.x + 25),
|
|
Math.min(view.subHeight - 5, startLocal.y + 15),
|
|
15,
|
|
)!
|
|
|
|
const startWorld = localToWorldSubTile(view, startLocal.x, startLocal.y)
|
|
const goalWorld = localToWorldSubTile(view, goalLocal.x, goalLocal.y)
|
|
|
|
const t0 = performance.now()
|
|
const pathRes = findPathWorld(
|
|
view,
|
|
startWorld.worldSubX,
|
|
startWorld.worldSubY,
|
|
goalWorld.worldSubX,
|
|
goalWorld.worldSubY,
|
|
)
|
|
const elapsedMs = performance.now() - t0
|
|
|
|
expect(elapsedMs).toBeLessThan(50)
|
|
expect(pathRes.path.length).toBeGreaterThanOrEqual(2)
|
|
for (const step of pathRes.rawPath) {
|
|
expect(isWalkableWorldSubTile(view, step.x, step.y)).toBe(true)
|
|
}
|
|
})
|
|
|
|
it('predicts 25Hz local movement and reconciles server updates via smooth rubber-band (<=5) and hard-snap (>5)', () => {
|
|
const predictor = new LocalMovementPredictor({
|
|
initialX: 100,
|
|
initialY: 200,
|
|
})
|
|
predictor.setPath(
|
|
[
|
|
{ x: 100, y: 200 },
|
|
{ x: 127, y: 200 },
|
|
],
|
|
true,
|
|
)
|
|
|
|
expect(predictor.getEffectiveSpeedSubTilesPerSec()).toBeCloseTo(D2_BASE_RUN_SUBTILES_PER_SEC, 5)
|
|
|
|
// Advance 25 ticks = 1.0s => 13.5 sub-tiles along +X
|
|
const after1s = predictor.stepTicks(25)
|
|
expect(after1s.logicalX).toBeCloseTo(113.5, 4)
|
|
expect(after1s.logicalY).toBeCloseTo(200, 4)
|
|
expect(after1s.moving).toBe(true)
|
|
|
|
// Reconcile small drift (2.0 sub-tiles <= 5 threshold): rubber-band
|
|
const rb = predictor.reconcileServerPosition({ x: 111.5, y: 200 })
|
|
expect(rb.mode).toBe('rubber-band')
|
|
expect(rb.driftDistance).toBeCloseTo(2.0, 4)
|
|
// Visual position remains 113.5 immediately after reconcile (no pop), logical is 111.5
|
|
const snapAfterRb = predictor.getSnapshot()
|
|
expect(snapAfterRb.logicalX).toBeCloseTo(111.5, 4)
|
|
expect(snapAfterRb.x).toBeCloseTo(113.5, 4)
|
|
expect(snapAfterRb.errorOffsetX).toBeCloseTo(2.0, 4)
|
|
|
|
// Stepping ticks decays errorOffsetX toward 0
|
|
const decayed = predictor.stepTicks(15)
|
|
expect(Math.abs(decayed.errorOffsetX)).toBeLessThan(0.05)
|
|
|
|
// Reconcile large drift (8.0 sub-tiles > 5 threshold): hard-snap
|
|
const currentLogicalX = predictor.getSnapshot().logicalX
|
|
const hs = predictor.reconcileServerPosition({ x: currentLogicalX + 8.0, y: 210 })
|
|
expect(hs.mode).toBe('hard-snap')
|
|
const snapAfterHs = predictor.getSnapshot()
|
|
expect(snapAfterHs.logicalX).toBeCloseTo(currentLogicalX + 8.0, 4)
|
|
expect(snapAfterHs.x).toBeCloseTo(currentLogicalX + 8.0, 4)
|
|
expect(snapAfterHs.errorOffsetX).toBe(0)
|
|
expect(snapAfterHs.moving).toBe(false)
|
|
|
|
// Walk speed verification
|
|
predictor.setRunning(false)
|
|
expect(predictor.getEffectiveSpeedSubTilesPerSec()).toBeCloseTo(D2_BASE_WALK_SUBTILES_PER_SEC, 5)
|
|
})
|
|
|
|
it('interpolates remote entities smoothly along move segments and settles on stop packets', () => {
|
|
const interp = new RemoteEntityInterpolator()
|
|
interp.spawnOrSnap({ unitType: 1, unitId: 42, x: 5000, y: 5000 })
|
|
|
|
// 0x67 NPCMove (walking: moveType = 0x01) from (5000, 5000) -> (5009, 5000)
|
|
const started = interp.startMove({
|
|
unitType: 1,
|
|
unitId: 42,
|
|
currentX: 5000,
|
|
currentY: 5000,
|
|
targetX: 5009,
|
|
targetY: 5000,
|
|
moveType: 0x01,
|
|
})
|
|
expect(started.moving).toBe(true)
|
|
expect(started.running).toBe(false)
|
|
|
|
// Advance 500ms at 9 sub-tiles/s => 4.5 sub-tiles
|
|
interp.update(500)
|
|
const midway = interp.getEntityState(1, 42)!
|
|
expect(midway.x).toBeCloseTo(5004.5, 4)
|
|
expect(midway.moving).toBe(true)
|
|
|
|
// Advance another 600ms => reaches target (5009, 5000) and stops
|
|
interp.update(600)
|
|
const arrived = interp.getEntityState(1, 42)!
|
|
expect(arrived.x).toBeCloseTo(5009, 4)
|
|
expect(arrived.moving).toBe(false)
|
|
|
|
// 0x6D NPCStop with large drift snaps immediately
|
|
const stopped = interp.stopEntity({ unitType: 1, unitId: 42, x: 5020, y: 5020 })
|
|
expect(stopped.x).toBe(5020)
|
|
expect(stopped.y).toBe(5020)
|
|
expect(stopped.moving).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('F13 — Safe .d2s Waypoint & Quest Unlocker (scripts/d2s-unlock.ts)', () => {
|
|
it('enforces R6 character name guard (rejects any name not matching /^d2webbot/i)', () => {
|
|
expect(() => validateBotCharacterName('Conan')).toThrow(/R6 Safety Violation/)
|
|
expect(() => validateBotCharacterName('Sorceress')).toThrow(/R6 Safety Violation/)
|
|
expect(() => validateBotCharacterName('my_d2webbot')).toThrow(/R6 Safety Violation/)
|
|
expect(() => validateBotCharacterName('d2webbot')).not.toThrow()
|
|
expect(() => validateBotCharacterName('D2WebBot_01')).not.toThrow()
|
|
|
|
const nonBotBuf = createSyntheticD2sBuffer('LegitPlayer')
|
|
expect(() => unlockD2sBuffer(nonBotBuf)).toThrow(/R6 Safety Violation/)
|
|
})
|
|
|
|
it('unlocks all 39 waypoints x 3 difficulties, Act 1-5 quest transitions, progression, and checksum', () => {
|
|
const initialBuf = createSyntheticD2sBuffer('d2webbot_m3')
|
|
const beforeVerify = verifyD2sUnlocked(initialBuf)
|
|
expect(beforeVerify.validMagic).toBe(true)
|
|
expect(beforeVerify.checksumValid).toBe(true)
|
|
expect(beforeVerify.allUnlocked).toBe(false)
|
|
expect(beforeVerify.difficulties[0].unlockedWaypointCount).toBe(1)
|
|
|
|
const unlockedBuf = unlockD2sBuffer(initialBuf)
|
|
const afterVerify = verifyD2sUnlocked(unlockedBuf)
|
|
expect(afterVerify.validMagic).toBe(true)
|
|
expect(afterVerify.version).toBe(D2S_VERSION_113C)
|
|
expect(afterVerify.fileSizeMatches).toBe(true)
|
|
expect(afterVerify.checksumValid).toBe(true)
|
|
expect(afterVerify.characterName).toBe('d2webbot_m3')
|
|
expect(afterVerify.isBotCharacterName).toBe(true)
|
|
expect(afterVerify.isExpansion).toBe(true)
|
|
expect(afterVerify.progression).toBe(15)
|
|
expect(afterVerify.wooHeaderValid).toBe(true)
|
|
expect(afterVerify.wsHeaderValid).toBe(true)
|
|
|
|
for (const diff of afterVerify.difficulties) {
|
|
expect(diff.waypointHeaderValid).toBe(true)
|
|
expect(diff.unlockedWaypointCount).toBe(39)
|
|
expect(diff.all39WaypointsUnlocked).toBe(true)
|
|
expect(diff.act1To5TransitionsUnlocked).toBe(true)
|
|
}
|
|
expect(afterVerify.allUnlocked).toBe(true)
|
|
})
|
|
|
|
it('creates a timestamped .bak.<timestamp> backup before unlocking a local .d2s file', () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'd2s-unlock-test-'))
|
|
try {
|
|
const d2sPath = path.join(tmpDir, 'd2webbot_local.d2s')
|
|
const originalBytes = createSyntheticD2sBuffer('d2webbot_local')
|
|
fs.writeFileSync(d2sPath, originalBytes)
|
|
|
|
const res = unlockLocalD2sFile(d2sPath, { backupTimestamp: 1700000000000 })
|
|
expect(res.backupPath).toBe(`${d2sPath}.bak.1700000000000`)
|
|
expect(fs.existsSync(res.backupPath)).toBe(true)
|
|
|
|
// Backup preserves exact original bytes
|
|
const backupBytes = new Uint8Array(fs.readFileSync(res.backupPath))
|
|
expect(backupBytes).toEqual(originalBytes)
|
|
|
|
// Target file now has all waypoints and quests unlocked with valid checksum
|
|
const updatedBytes = new Uint8Array(fs.readFileSync(d2sPath))
|
|
expect(updatedBytes.byteLength).toBeGreaterThanOrEqual(D2S_MIN_HEADER_SIZE)
|
|
const verify = verifyD2sUnlocked(updatedBytes)
|
|
expect(verify.allUnlocked).toBe(true)
|
|
expect(verify.checksumValid).toBe(true)
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('preserves character header level at 0x2B by default to match gf stat 12, while honoring explicit minLevel', () => {
|
|
const initialBuf = createSyntheticD2sBuffer('WebBotTwo')
|
|
expect(initialBuf[0x2b]).toBe(1)
|
|
|
|
const defaultUnlocked = unlockD2sBuffer(initialBuf, { progression: 15 })
|
|
expect(defaultUnlocked[0x2b]).toBe(1)
|
|
expect(verifyD2sUnlocked(defaultUnlocked).allUnlocked).toBe(true)
|
|
|
|
const boostedUnlocked = unlockD2sBuffer(initialBuf, { progression: 15, minLevel: 60 })
|
|
expect(boostedUnlocked[0x2b]).toBe(60)
|
|
expect(verifyD2sUnlocked(boostedUnlocked).allUnlocked).toBe(true)
|
|
})
|
|
})
|
|
})
|