diablo2-web/tests/client/drlg-map-motion.test.ts

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)
})
})
})