1558 lines
53 KiB
TypeScript
1558 lines
53 KiB
TypeScript
#!/usr/bin/env npx tsx
|
|
/**
|
|
* Headless Diablo II 1.13c Online Bot & Wire Capture CLI (`scripts/d2-bot.ts`, Features F5 & F10).
|
|
*
|
|
* Commands:
|
|
* register | login | chars | create-char | games | create-game | join | idle | record | walk | audit-levels
|
|
*
|
|
* Options:
|
|
* --via=mixed|wss|tcp (default: mixed — TCP 101.37.117.183:6112 for bnet, WSS for realm/game)
|
|
* --host=<bnetHost> (default: 101.37.117.183)
|
|
* --ws-host=<bridgeHost> (default: www.laiseek.xyz)
|
|
* --account=<username> (default: d2webbot1)
|
|
* --char=<charName> (default: WebBotOne)
|
|
* --class=<0..6> (default: 1 Sorceress)
|
|
* --game=<gameName> (default: auto-generated)
|
|
* --pass=<gamePass> (default: "")
|
|
* --create-game Create the game before joining
|
|
* --idle=<seconds> Stay in-game for N seconds (default: 5 for join, 60 for idle)
|
|
* --world Print ClientWorld state summary
|
|
* --capture=<out.d2cap> Write .d2cap JSONL wire capture to disk
|
|
* --scenario=<name> Scenario name for record command (town|combat|items|npc|chat|full)
|
|
*/
|
|
|
|
import { randomBytes } from 'node:crypto'
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
import { dirname, resolve } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import {
|
|
MapService,
|
|
getCollisionLocalSubTile,
|
|
getCollisionWorldSubTile,
|
|
isWalkableLocalSubTile,
|
|
isWalkableWorldSubTile,
|
|
localToWorldSubTile,
|
|
type LevelView,
|
|
} from '../src/client/map/map-service.ts'
|
|
import { findPathWorld, type SubTilePoint } from '../src/client/motion/pathfind.ts'
|
|
import { LocalMovementPredictor } from '../src/client/motion/predictor.ts'
|
|
import { OutboundRateLimiter } from '../src/client/session/rate-limiter.ts'
|
|
import { ClientWorld } from '../src/client/world/client-world.ts'
|
|
import { getCanonicalItemDataTables } from '../src/client/world/item-tables-provider.ts'
|
|
import { COLLIDE_MASK_INVALID } from '../src/game/d2map.ts'
|
|
import {
|
|
InMemoryPacketTap,
|
|
createD2OnlineFlow,
|
|
createMixedResolver,
|
|
createTcpResolver,
|
|
createWsBridgeResolver,
|
|
registerDefaultTcpOpener,
|
|
unitTypeToId,
|
|
type CharClassId,
|
|
type EndpointResolver,
|
|
type GameServerAdapter,
|
|
} from '../src/netproto/index.ts'
|
|
import { connectNodeTcpStream } from '../src/netproto/transport/node-tcp-stream.ts'
|
|
import {
|
|
D2S_MAGIC,
|
|
D2S_QUEST_OFFSET,
|
|
D2S_VERSION_113C,
|
|
D2S_WAYPOINT_OFFSET,
|
|
unlockLocalD2sFile,
|
|
unlockRemotePvpgnCharSave,
|
|
verifyD2sUnlocked,
|
|
writeD2sChecksum,
|
|
} from './d2s-unlock.ts'
|
|
|
|
registerDefaultTcpOpener(connectNodeTcpStream)
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
const ROOT_DIR = resolve(__dirname, '..')
|
|
const ACCOUNTS_PATH = resolve(ROOT_DIR, 'scratch/bot-accounts.json')
|
|
|
|
export interface BotAccountRecord {
|
|
readonly username: string
|
|
readonly password: string
|
|
readonly defaultChar: string
|
|
readonly charClass: CharClassId
|
|
readonly createdAt: string
|
|
}
|
|
|
|
export interface BotAccountsFile {
|
|
readonly server: string
|
|
readonly realm: string
|
|
readonly updatedAt: string
|
|
readonly accounts: Record<string, BotAccountRecord>
|
|
}
|
|
|
|
function loadOrInitBotAccounts(serverHost: string): BotAccountsFile {
|
|
if (existsSync(ACCOUNTS_PATH)) {
|
|
try {
|
|
return JSON.parse(readFileSync(ACCOUNTS_PATH, 'utf8')) as BotAccountsFile
|
|
} catch {
|
|
// Fall through to re-init
|
|
}
|
|
}
|
|
const defaults: Record<string, BotAccountRecord> = {
|
|
d2webbot1: {
|
|
username: 'd2webbot1',
|
|
password: `d2w_${randomBytes(6).toString('hex')}`,
|
|
defaultChar: 'WebBotOne',
|
|
charClass: 1,
|
|
createdAt: new Date().toISOString(),
|
|
},
|
|
d2webbot2: {
|
|
username: 'd2webbot2',
|
|
password: `d2w_${randomBytes(6).toString('hex')}`,
|
|
defaultChar: 'WebBotTwo',
|
|
charClass: 3,
|
|
createdAt: new Date().toISOString(),
|
|
},
|
|
d2webbot3: {
|
|
username: 'd2webbot3',
|
|
password: `d2w_${randomBytes(6).toString('hex')}`,
|
|
defaultChar: 'WebBotThree',
|
|
charClass: 4,
|
|
createdAt: new Date().toISOString(),
|
|
},
|
|
}
|
|
const data: BotAccountsFile = {
|
|
server: serverHost,
|
|
realm: 'Sanctuary',
|
|
updatedAt: new Date().toISOString(),
|
|
accounts: defaults,
|
|
}
|
|
mkdirSync(dirname(ACCOUNTS_PATH), { recursive: true })
|
|
writeFileSync(ACCOUNTS_PATH, JSON.stringify(data, null, 2) + '\n', 'utf8')
|
|
return data
|
|
}
|
|
|
|
function saveBotAccounts(data: BotAccountsFile): void {
|
|
mkdirSync(dirname(ACCOUNTS_PATH), { recursive: true })
|
|
writeFileSync(ACCOUNTS_PATH, JSON.stringify(data, null, 2) + '\n', 'utf8')
|
|
}
|
|
|
|
function parseArgs(argv: readonly string[]): {
|
|
readonly cmd: string
|
|
readonly flags: Readonly<Record<string, string | boolean>>
|
|
} {
|
|
const flags: Record<string, string | boolean> = {}
|
|
let cmd = 'help'
|
|
for (const arg of argv) {
|
|
if (arg.startsWith('--')) {
|
|
const eq = arg.indexOf('=')
|
|
if (eq !== -1) {
|
|
flags[arg.slice(2, eq)] = arg.slice(eq + 1)
|
|
} else {
|
|
flags[arg.slice(2)] = true
|
|
}
|
|
} else if (cmd === 'help') {
|
|
cmd = arg
|
|
}
|
|
}
|
|
return { cmd, flags }
|
|
}
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise(r => setTimeout(r, ms))
|
|
}
|
|
|
|
function buildResolver(
|
|
via: 'mixed' | 'wss' | 'tcp',
|
|
bnetHost: string,
|
|
wsHost: string,
|
|
): EndpointResolver {
|
|
if (via === 'tcp') {
|
|
return createTcpResolver({ hostOverride: bnetHost, timeoutMs: 12_000 })
|
|
}
|
|
if (via === 'wss') {
|
|
return createWsBridgeResolver({
|
|
host: wsHost,
|
|
scheme: 'wss',
|
|
basePath: '/d2net',
|
|
timeoutMs: 12_000,
|
|
})
|
|
}
|
|
return createMixedResolver({
|
|
bnetTcpHost: bnetHost,
|
|
bnetTcpPort: 6112,
|
|
host: wsHost,
|
|
scheme: 'wss',
|
|
basePath: '/d2net',
|
|
timeoutMs: 12_000,
|
|
})
|
|
}
|
|
|
|
function printWorldSummary(world: ClientWorld, adapter: GameServerAdapter, prefix = '[world]'): void {
|
|
const players = world.getUnitsByType('player').length
|
|
const monsters = world.getUnitsByType('monster').length
|
|
const objects = world.getUnitsByType('object').length
|
|
const items = world.getUnitsByType('item').length
|
|
const tiles = world.getUnitsByType('tile').length
|
|
console.log(
|
|
`${prefix} act=${world.act} seed=0x${world.mapSeed.toString(16).padStart(8, '0')} diff=${world.difficulty} exp=${world.expansion} area=${world.areaId} loading=${world.loading} | self=${world.self.name}(#${world.selfId}, class=${world.self.charClass}, lvl=${world.self.level}) pos=(${world.self.x},${world.self.y}) hp=${world.self.life}/${world.self.maxLife} mp=${world.self.mana}/${world.self.maxMana} stam=${world.self.stamina}/${world.self.maxStamina} gold=${world.self.gold} skills=${world.self.skills.size} (L=${world.self.leftSkill},R=${world.self.rightSkill}) | units=${world.units.size} (plr=${players},mon=${monsters},obj=${objects},itm=${items},tile=${tiles}) rooms=${world.revealedRooms.size} | inv=${world.containers.inventory.size} eq=${world.containers.equipment.size} belt=${world.containers.belt.size} ground=${world.containers.ground.size} cursor=${world.containers.cursor?.code ?? 'none'} | rtt=${adapter.stats.rttMs}ms pktsIn=${adapter.stats.packetsIn} pktsOut=${adapter.stats.packetsOut} unknown=${adapter.stats.unknownPackets} opaque=${adapter.stats.opaquePackets} errs=${adapter.stats.protocolErrors}`,
|
|
)
|
|
}
|
|
|
|
async function runInGameScenario(
|
|
scenario: string,
|
|
world: ClientWorld,
|
|
adapter: GameServerAdapter,
|
|
): Promise<void> {
|
|
await sleep(800)
|
|
|
|
// 1. Overhead & regular chat
|
|
if (scenario === 'chat' || scenario === 'town' || scenario === 'full') {
|
|
adapter.send({ type: 'OverheadChat', text: 'Bot online!' })
|
|
await sleep(350)
|
|
adapter.send({ type: 'Chat', text: 'Hello Sanctuary from d2-bot' })
|
|
await sleep(350)
|
|
}
|
|
|
|
// 2. Walk and Run around current town position
|
|
if (scenario === 'town' || scenario === 'combat' || scenario === 'full') {
|
|
const startX = world.self.x
|
|
const startY = world.self.y
|
|
if (startX > 0 && startY > 0) {
|
|
const offsets: readonly [number, number, boolean][] = [
|
|
[4, 0, false],
|
|
[4, 4, true],
|
|
[0, 4, true],
|
|
[0, 0, false],
|
|
]
|
|
for (const [dx, dy, run] of offsets) {
|
|
adapter.send({
|
|
type: 'Move',
|
|
run,
|
|
to: { x: startX + dx, y: startY + dy },
|
|
})
|
|
await sleep(700)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Skill selection & cast
|
|
if (scenario === 'combat' || scenario === 'full') {
|
|
const firstSkill = Array.from(world.self.skills.keys()).find(id => id > 0) ?? 0
|
|
adapter.send({ type: 'SelectSkill', hand: 'right', skill: firstSkill })
|
|
await sleep(300)
|
|
if (world.self.x > 0 && world.self.y > 0) {
|
|
adapter.send({
|
|
type: 'SkillAt',
|
|
hand: 'right',
|
|
at: { x: world.self.x + 3, y: world.self.y + 3 },
|
|
shift: true,
|
|
})
|
|
await sleep(500)
|
|
}
|
|
}
|
|
|
|
// 4. Item actions (weapon swap, belt / inventory / equip manipulation)
|
|
if (scenario === 'items' || scenario === 'full') {
|
|
adapter.send({ type: 'SwapWeapons' })
|
|
await sleep(450)
|
|
adapter.send({ type: 'SwapWeapons' })
|
|
await sleep(450)
|
|
|
|
// Pick up an inventory item to cursor and place it back
|
|
const invItem = Array.from(world.containers.inventory.values())[0]
|
|
if (invItem) {
|
|
const origX = invItem.gridX
|
|
const origY = invItem.gridY
|
|
adapter.send({ type: 'ItemToCursor', itemId: invItem.id })
|
|
await sleep(500)
|
|
if (world.containers.cursor?.id === invItem.id) {
|
|
adapter.send({
|
|
type: 'ItemPlace',
|
|
itemId: invItem.id,
|
|
x: origX,
|
|
y: origY,
|
|
buffer: 0, // inventory buffer
|
|
})
|
|
await sleep(500)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 5. NPC interaction in town
|
|
if (scenario === 'npc' || scenario === 'full') {
|
|
const selfX = world.self.x
|
|
const selfY = world.self.y
|
|
const monsters = world.getUnitsByType('monster')
|
|
let nearestNpc = monsters[0]
|
|
let bestDist = Infinity
|
|
for (const m of monsters) {
|
|
if (m.x > 0 && m.y > 0) {
|
|
const d = Math.hypot(m.x - selfX, m.y - selfY)
|
|
if (d < bestDist) {
|
|
bestDist = d
|
|
nearestNpc = m
|
|
}
|
|
}
|
|
}
|
|
if (nearestNpc && bestDist < 60) {
|
|
adapter.send({
|
|
type: 'MoveToUnit',
|
|
run: true,
|
|
target: { unitType: unitTypeToId('monster'), id: nearestNpc.id },
|
|
})
|
|
await sleep(900)
|
|
adapter.send({
|
|
type: 'NpcInit',
|
|
target: { unitType: unitTypeToId('monster'), id: nearestNpc.id },
|
|
})
|
|
await sleep(500)
|
|
adapter.send({
|
|
type: 'Interact',
|
|
target: { unitType: unitTypeToId('monster'), id: nearestNpc.id },
|
|
})
|
|
await sleep(600)
|
|
adapter.send({
|
|
type: 'NpcCancel',
|
|
target: { unitType: unitTypeToId('monster'), id: nearestNpc.id },
|
|
})
|
|
await sleep(400)
|
|
}
|
|
}
|
|
}
|
|
|
|
function subdividePathHops(path: readonly SubTilePoint[], maxHopDist = 3.5): SubTilePoint[] {
|
|
if (path.length === 0) return []
|
|
const hops: SubTilePoint[] = []
|
|
let cur = path[0]!
|
|
for (let i = 1; i < path.length; i++) {
|
|
const target = path[i]!
|
|
const dx = target.x - cur.x
|
|
const dy = target.y - cur.y
|
|
const dist = Math.hypot(dx, dy)
|
|
if (dist <= 1e-3) continue
|
|
const steps = Math.max(1, Math.ceil(dist / maxHopDist))
|
|
for (let s = 1; s <= steps; s++) {
|
|
const ratio = s / steps
|
|
hops.push({
|
|
x: Math.round(cur.x + dx * ratio),
|
|
y: Math.round(cur.y + dy * ratio),
|
|
})
|
|
}
|
|
cur = target
|
|
}
|
|
return hops
|
|
}
|
|
|
|
function createSyntheticBotD2sBuffer(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
|
|
|
|
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)
|
|
|
|
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
|
|
}
|
|
|
|
writeD2sChecksum(buf)
|
|
return buf
|
|
}
|
|
|
|
async function runWalkSubcommand(
|
|
world: ClientWorld,
|
|
adapter: GameServerAdapter,
|
|
mapService: MapService,
|
|
): Promise<void> {
|
|
const rateLimiter = new OutboundRateLimiter({ refillPerSec: 25, burstCapacity: 8 })
|
|
const predictor = new LocalMovementPredictor({
|
|
initialX: world.self.x,
|
|
initialY: world.self.y,
|
|
rubberBandThreshold: 5.0,
|
|
})
|
|
|
|
let hardSnaps = 0
|
|
let rubberBands = 0
|
|
let serverCorrections = 0
|
|
|
|
adapter.onEvent((ev) => {
|
|
if (ev.type === 'UnitReassign' && ev.unitType === 'player' && ev.id === world.selfId) {
|
|
serverCorrections++
|
|
const res = predictor.reconcileServerPosition({
|
|
x: ev.at.x,
|
|
y: ev.at.y,
|
|
teleport: ev.snap,
|
|
})
|
|
if (!ev.snap && res.mode === 'hard-snap') {
|
|
hardSnaps++
|
|
console.warn(
|
|
`[d2-bot:walk] Unexpected UnitReassign hard-snap: drift=${res.driftDistance.toFixed(2)} to (${ev.at.x},${ev.at.y})`,
|
|
)
|
|
} else if (res.mode === 'rubber-band') {
|
|
rubberBands++
|
|
}
|
|
} else if (ev.type === 'LifeMana' && ev.at && ev.at.x > 0 && ev.at.y > 0) {
|
|
serverCorrections++
|
|
const res = predictor.reconcileServerPosition({
|
|
x: ev.at.x,
|
|
y: ev.at.y,
|
|
teleport: false,
|
|
})
|
|
if (res.mode === 'hard-snap') {
|
|
predictor.setPosition(ev.at.x, ev.at.y)
|
|
} else if (res.mode === 'rubber-band') {
|
|
rubberBands++
|
|
}
|
|
}
|
|
})
|
|
|
|
const areaId = world.areaId || 1
|
|
const view =
|
|
mapService.getActiveLevelView() ?? mapService.getLevelView(areaId, 'all', true)
|
|
|
|
console.log(
|
|
`[d2-bot:walk] Loaded LevelView area=${view.id} act=${view.act} seed=0x${view.seed.toString(16).padStart(8, '0')} origin=(${view.subOriginX},${view.subOriginY}) subSize=${view.subWidth}x${view.subHeight} walkableCells=${view.walkableCellCount} rooms=${view.rooms.length}`,
|
|
)
|
|
|
|
interface LandmarkTarget {
|
|
readonly label: string
|
|
readonly x: number
|
|
readonly y: number
|
|
}
|
|
const candidates: LandmarkTarget[] = []
|
|
|
|
for (const m of world.getUnitsByType('monster')) {
|
|
if (m.x > 0 && m.y > 0 && Math.hypot(m.x - world.self.x, m.y - world.self.y) >= 6) {
|
|
candidates.push({ label: `npc#${m.id}(class=${m.classId})`, x: m.x, y: m.y })
|
|
}
|
|
}
|
|
for (const o of world.getUnitsByType('object')) {
|
|
if (o.x > 0 && o.y > 0 && Math.hypot(o.x - world.self.x, o.y - world.self.y) >= 6) {
|
|
candidates.push({ label: `obj#${o.id}(class=${o.classId})`, x: o.x, y: o.y })
|
|
}
|
|
}
|
|
for (const ent of view.entrances) {
|
|
const worldPt = localToWorldSubTile(view, ent.interiorX * 5 + 2, ent.interiorY * 5 + 2)
|
|
candidates.push({
|
|
label: `entrance(${ent.kind}:${view.id}->${ent.toLevelId})`,
|
|
x: worldPt.worldSubX,
|
|
y: worldPt.worldSubY,
|
|
})
|
|
}
|
|
for (const pu of view.presetUnits.slice(0, 12)) {
|
|
const worldPt = localToWorldSubTile(view, pu.subX, pu.subY)
|
|
if (Math.hypot(worldPt.worldSubX - world.self.x, worldPt.worldSubY - world.self.y) >= 8) {
|
|
candidates.push({
|
|
label: `preset(type=${pu.type},id=${pu.index})`,
|
|
x: worldPt.worldSubX,
|
|
y: worldPt.worldSubY,
|
|
})
|
|
}
|
|
}
|
|
|
|
const selectedLandmarks: LandmarkTarget[] = []
|
|
for (const cand of candidates) {
|
|
if (selectedLandmarks.length >= 4) break
|
|
const tooClose = selectedLandmarks.some(
|
|
prev => Math.hypot(prev.x - cand.x, prev.y - cand.y) < 8,
|
|
)
|
|
if (tooClose) continue
|
|
const checkPath = findPathWorld(view, world.self.x, world.self.y, cand.x, cand.y, {
|
|
smooth: true,
|
|
nearestTargetRadius: 10,
|
|
maxExpandedNodes: 16384,
|
|
})
|
|
if (checkPath.reachedGoal && checkPath.path.length >= 2) {
|
|
selectedLandmarks.push(cand)
|
|
}
|
|
}
|
|
|
|
if (selectedLandmarks.length < 3) {
|
|
throw new Error(
|
|
`[d2-bot:walk] Expected at least 3 reachable town landmarks, found ${selectedLandmarks.length}`,
|
|
)
|
|
}
|
|
|
|
let landmarksVisited = 0
|
|
let totalHops = 0
|
|
let totalSubTilesWalked = 0
|
|
let curX = world.self.x
|
|
let curY = world.self.y
|
|
predictor.setPosition(curX, curY)
|
|
|
|
for (const landmark of selectedLandmarks) {
|
|
const pathRes = findPathWorld(view, curX, curY, landmark.x, landmark.y, {
|
|
smooth: true,
|
|
nearestTargetRadius: 10,
|
|
maxExpandedNodes: 16384,
|
|
})
|
|
if (pathRes.path.length < 2) continue
|
|
|
|
for (const stepPt of pathRes.rawPath) {
|
|
if (!isWalkableWorldSubTile(view, stepPt.x, stepPt.y)) {
|
|
throw new Error(
|
|
`[d2-bot:walk] Unwalkable A* step at (${stepPt.x},${stepPt.y}) en route to ${landmark.label}`,
|
|
)
|
|
}
|
|
const mask = getCollisionWorldSubTile(view, stepPt.x, stepPt.y)
|
|
if ((mask & COLLIDE_MASK_INVALID) === COLLIDE_MASK_INVALID) {
|
|
throw new Error(
|
|
`[d2-bot:walk] Void collision mask 0x${mask.toString(16)} at (${stepPt.x},${stepPt.y})`,
|
|
)
|
|
}
|
|
}
|
|
|
|
const hops = subdividePathHops(pathRes.path, 3.0)
|
|
console.log(
|
|
`[d2-bot:walk] Walking from (${Math.round(curX)},${Math.round(curY)}) -> ${landmark.label} at (${landmark.x},${landmark.y}): rawSteps=${pathRes.rawPath.length} smoothedWaypoints=${pathRes.path.length} hops=${hops.length}`,
|
|
)
|
|
|
|
for (const hop of hops) {
|
|
const hopDist = Math.hypot(hop.x - curX, hop.y - curY)
|
|
if (hopDist < 0.5) continue
|
|
|
|
const moveCmd = {
|
|
type: 'Move' as const,
|
|
run: true,
|
|
to: { x: hop.x, y: hop.y },
|
|
}
|
|
while (!rateLimiter.tryAcquire(moveCmd, Date.now()).allowed) {
|
|
await sleep(25)
|
|
}
|
|
adapter.send(moveCmd)
|
|
predictor.setPath([hop], true)
|
|
|
|
const travelMs = Math.max(120, Math.ceil((hopDist / predictor.getEffectiveSpeedSubTilesPerSec()) * 1000))
|
|
const ticks = Math.max(1, Math.ceil(travelMs / 40))
|
|
for (let t = 0; t < ticks; t++) {
|
|
await sleep(40)
|
|
predictor.update(40)
|
|
world.tick()
|
|
}
|
|
|
|
curX = hop.x
|
|
curY = hop.y
|
|
totalHops++
|
|
totalSubTilesWalked += hopDist
|
|
}
|
|
|
|
landmarksVisited++
|
|
const snap = predictor.getSnapshot()
|
|
console.log(
|
|
`[d2-bot:walk] Reached landmark ${landmarksVisited}/${selectedLandmarks.length} (${landmark.label}): predictor=(${snap.x.toFixed(1)},${snap.y.toFixed(1)}) worldSelf=(${world.self.x},${world.self.y}) hardSnaps=${hardSnaps} rubberBands=${rubberBands}`,
|
|
)
|
|
}
|
|
|
|
console.log(
|
|
`[d2-bot:walk:summary] landmarksVisited=${landmarksVisited} totalHops=${totalHops} subTilesWalked=${totalSubTilesWalked.toFixed(1)} serverCorrections=${serverCorrections} rubberBands=${rubberBands} hardSnaps=${hardSnaps}`,
|
|
)
|
|
|
|
if (landmarksVisited < 3) {
|
|
throw new Error(`[d2-bot:walk] Failed: landmarksVisited=${landmarksVisited} < 3`)
|
|
}
|
|
if (hardSnaps > 0) {
|
|
throw new Error(`[d2-bot:walk] Failed: unexpected UnitReassign hardSnaps=${hardSnaps} > 0`)
|
|
}
|
|
}
|
|
|
|
const WAYPOINT_OBJECT_CLASS_IDS = new Set<number>([
|
|
119, // Act 1 Wilderness / Rogue Encampment Waypoint
|
|
145, // Act 1 Inner Hell / Dungeon Waypoint
|
|
156, // Act 2 Town (Lut Gholein) Waypoint
|
|
157, // Act 2 Desert / Tombs Waypoint
|
|
237, // Act 3 Town (Kurast Docks) Waypoint
|
|
238, // Act 3 Jungle / Kurast Waypoint
|
|
288, // Act 2 Cellar / Harem / Palace Waypoint
|
|
323, // Act 2 Arcane Sanctuary Waypoint
|
|
324, // Act 3 Durance of Hate Waypoint
|
|
398, // Act 4 Pandemonium Fortress Waypoint
|
|
402, // Act 4 Hell / River of Flame Waypoint
|
|
429, // Act 5 Harrogath Waypoint
|
|
494, // Act 5 Ice Caves / Crystalline Passage Waypoint
|
|
496, // Act 5 Temple / Halls of Pain Waypoint
|
|
511, // Act 5 Glacial / Frozen Tundra Waypoint
|
|
539, // Act 5 Worldstone Keep Waypoint
|
|
])
|
|
|
|
const DEFAULT_AUDIT_WAYPOINT_TOUR: readonly number[] = [
|
|
// Act 1
|
|
1, 3, 6, 27, 35,
|
|
// Act 2
|
|
40, 42, 74, 46,
|
|
// Act 3
|
|
75, 79, 83, 101,
|
|
// Act 4
|
|
103, 106, 107,
|
|
// Act 5
|
|
109, 111, 113, 129,
|
|
]
|
|
|
|
function getActIndexForAreaId(areaId: number): 0 | 1 | 2 | 3 | 4 {
|
|
if (areaId <= 39) return 0
|
|
if (areaId <= 74) return 1
|
|
if (areaId <= 102) return 2
|
|
if (areaId <= 108) return 3
|
|
return 4
|
|
}
|
|
|
|
async function runAuditLevelsSubcommand(options: {
|
|
readonly via: 'mixed' | 'wss' | 'tcp'
|
|
readonly bnetHost: string
|
|
readonly wsHost: string
|
|
readonly accountsFile: BotAccountsFile
|
|
readonly tables: ReturnType<typeof getCanonicalItemDataTables>
|
|
readonly tap: InMemoryPacketTap
|
|
}): Promise<void> {
|
|
const { via, bnetHost, wsHost, accountsFile, tables, tap } = options
|
|
const botAcc = accountsFile.accounts.d2webbot2 ?? {
|
|
username: 'd2webbot2',
|
|
password: `d2w_${randomBytes(6).toString('hex')}`,
|
|
defaultChar: 'WebBotTwo',
|
|
charClass: 3 as CharClassId,
|
|
createdAt: new Date().toISOString(),
|
|
}
|
|
const auditCharName = botAcc.defaultChar || 'WebBotTwo'
|
|
|
|
const runBootstrapPrepGame = async (): Promise<void> => {
|
|
const prepFlow = createD2OnlineFlow({
|
|
resolver: buildResolver(via, bnetHost, wsHost),
|
|
bnetHost,
|
|
defaultRealm: accountsFile.realm,
|
|
itemTables: tables,
|
|
tap,
|
|
})
|
|
try {
|
|
console.log(
|
|
`[d2-bot:audit-levels] Preparing bot character "${auditCharName}" on account "${botAcc.username}"...`,
|
|
)
|
|
await prepFlow.loginOrRegister(botAcc.username, botAcc.password, { register: true })
|
|
const chars = await prepFlow.enterRealm(accountsFile.realm)
|
|
const exists = chars.some(c => c.name.toLowerCase() === auditCharName.toLowerCase())
|
|
if (!exists) {
|
|
await prepFlow.createCharacter({
|
|
name: auditCharName,
|
|
classId: botAcc.charClass,
|
|
expansion: true,
|
|
hardcore: false,
|
|
ladder: true,
|
|
})
|
|
console.log(`[d2-bot:audit-levels] Created character "${auditCharName}"`)
|
|
}
|
|
await prepFlow.selectCharacter(auditCharName)
|
|
const prepGame = `wba${Math.floor(Date.now() / 1000).toString(36).slice(-5)}`
|
|
await prepFlow.createGame({
|
|
name: prepGame,
|
|
password: '',
|
|
description: 'd2s prep',
|
|
difficulty: 0,
|
|
maxPlayers: 8,
|
|
})
|
|
const adapter = await prepFlow.joinGame(prepGame, '')
|
|
await sleep(1000)
|
|
adapter.leave()
|
|
await sleep(600)
|
|
} finally {
|
|
await prepFlow.close()
|
|
}
|
|
// Wait for d2dbs to flush the 980-byte v96 .d2s save to /usr/local/var/pvpgn/charsave/
|
|
await sleep(1500)
|
|
}
|
|
|
|
const localSaveDir = resolve(ROOT_DIR, 'scratch/charsave')
|
|
mkdirSync(localSaveDir, { recursive: true })
|
|
const localSavePath = resolve(localSaveDir, auditCharName.toLowerCase())
|
|
if (!existsSync(localSavePath)) {
|
|
writeFileSync(localSavePath, createSyntheticBotD2sBuffer(auditCharName))
|
|
}
|
|
const localUnlock = unlockLocalD2sFile(localSavePath, { progression: 15 })
|
|
console.log(
|
|
`[d2-bot:audit-levels] Verified local .d2s unlock: file=${localUnlock.filePath} backup=${localUnlock.backupPath} checksum=0x${(localUnlock.verification.actualChecksum >>> 0).toString(16)} allUnlocked=${localUnlock.verification.allUnlocked}`,
|
|
)
|
|
|
|
const isSshAuthUnavailableError = (err: unknown): boolean => {
|
|
const msg = err instanceof Error ? err.message : String(err)
|
|
const stderr =
|
|
err && typeof err === 'object' && 'stderr' in err && err.stderr != null
|
|
? String(err.stderr)
|
|
: ''
|
|
const combined = `${msg}\n${stderr}`
|
|
return /Permission denied|ControlPath|ControlSocket|publickey,password|Connection refused|Connection timed out/i.test(
|
|
combined,
|
|
)
|
|
}
|
|
|
|
let remoteUnlock: ReturnType<typeof unlockRemotePvpgnCharSave> | null = null
|
|
try {
|
|
remoteUnlock = unlockRemotePvpgnCharSave(auditCharName, {
|
|
host: bnetHost,
|
|
progression: 15,
|
|
})
|
|
} catch (err) {
|
|
if (isSshAuthUnavailableError(err)) {
|
|
console.log(
|
|
'[d2-bot:audit-levels] SSH ControlMaster inactive; verifying server-side .d2s unlock directly via live D2GS 0x63 WaypointMenu (requires 39/39 unlocked waypoints)...',
|
|
)
|
|
} else {
|
|
await runBootstrapPrepGame()
|
|
remoteUnlock = unlockRemotePvpgnCharSave(auditCharName, {
|
|
host: bnetHost,
|
|
progression: 15,
|
|
})
|
|
}
|
|
}
|
|
if (remoteUnlock !== null) {
|
|
if (!remoteUnlock.verification.allUnlocked) {
|
|
throw new Error(
|
|
`[d2-bot:audit-levels] Remote .d2s unlock verification failed for ${auditCharName}`,
|
|
)
|
|
}
|
|
console.log(
|
|
`[d2-bot:audit-levels] Unlocked remote PvPGN charsave: ${remoteUnlock.remotePath} (backup: ${remoteUnlock.backupPath}) checksum=0x${(remoteUnlock.verification.actualChecksum >>> 0).toString(16)} allUnlocked=${remoteUnlock.verification.allUnlocked}`,
|
|
)
|
|
}
|
|
|
|
const verifyRes = verifyD2sUnlocked(new Uint8Array(readFileSync(localSavePath)))
|
|
if (!verifyRes.allUnlocked) {
|
|
throw new Error(`[d2-bot:audit-levels] .d2s unlock verification failed for ${auditCharName}`)
|
|
}
|
|
|
|
const mapService = await MapService.fromPackedDir(resolve(ROOT_DIR, 'public/act-packs/tiles'))
|
|
const auditFlow = createD2OnlineFlow({
|
|
resolver: buildResolver(via, bnetHost, wsHost),
|
|
bnetHost,
|
|
defaultRealm: accountsFile.realm,
|
|
itemTables: tables,
|
|
tap,
|
|
})
|
|
|
|
try {
|
|
await auditFlow.loginOrRegister(botAcc.username, botAcc.password, { register: true })
|
|
await auditFlow.enterRealm(accountsFile.realm)
|
|
await auditFlow.selectCharacter(auditCharName)
|
|
|
|
const gameName = `wbl${Math.floor(Date.now() / 1000).toString(36).slice(-5)}`
|
|
await auditFlow.createGame({
|
|
name: gameName,
|
|
password: '',
|
|
description: 'd2-bot audit-levels',
|
|
difficulty: 0,
|
|
maxPlayers: 8,
|
|
})
|
|
const adapter = await auditFlow.joinGame(gameName, '')
|
|
const world = new ClientWorld()
|
|
|
|
let liveSeed = 0
|
|
let currentAreaId = 1
|
|
let currentActIndex: 0 | 1 | 2 | 3 | 4 = 0
|
|
let lastWaypointMenuUnitId = 0
|
|
let waypointMenuSeq = 0
|
|
let playerReassignSeq = 0
|
|
|
|
const revealedRooms: {
|
|
readonly act: 0 | 1 | 2 | 3 | 4
|
|
readonly areaId: number
|
|
readonly tileX: number
|
|
readonly tileY: number
|
|
}[] = []
|
|
const revealedAreaIds = new Set<number>()
|
|
const revealedAreasByAct = new Map<number, Set<number>>([
|
|
[0, new Set([1])],
|
|
[1, new Set()],
|
|
[2, new Set()],
|
|
[3, new Set()],
|
|
[4, new Set()],
|
|
])
|
|
const liveAssignedUnits: {
|
|
readonly act: 0 | 1 | 2 | 3 | 4
|
|
readonly areaIdHint: number
|
|
readonly unitType: string
|
|
readonly id: number
|
|
readonly classId: number
|
|
readonly x: number
|
|
readonly y: number
|
|
}[] = []
|
|
const waypointObjectsByAct = new Map<
|
|
number,
|
|
Map<number, { id: number; classId: number; x: number; y: number; mode: number }>
|
|
>([
|
|
[0, new Map()],
|
|
[1, new Map()],
|
|
[2, new Map()],
|
|
[3, new Map()],
|
|
[4, new Map()],
|
|
])
|
|
|
|
adapter.onEvent((ev) => {
|
|
world.apply(ev)
|
|
if (ev.type === 'LoadAct') {
|
|
liveSeed = ev.mapSeed >>> 0
|
|
currentActIndex = (ev.act >= 0 && ev.act <= 4 ? ev.act : 0) as 0 | 1 | 2 | 3 | 4
|
|
waypointObjectsByAct.get(currentActIndex)?.clear()
|
|
if (ev.areaId > 0) {
|
|
currentAreaId = ev.areaId
|
|
revealedAreaIds.add(ev.areaId)
|
|
revealedAreasByAct.get(currentActIndex)?.add(ev.areaId)
|
|
}
|
|
mapService.onLoadActPacket(
|
|
{ act: ev.act, mapSeed: ev.mapSeed, areaId: ev.areaId || currentAreaId },
|
|
{ difficulty: world.difficulty, revealMode: 'all' },
|
|
)
|
|
} else if (ev.type === 'RoomReveal') {
|
|
const roomAct = getActIndexForAreaId(ev.areaId)
|
|
currentActIndex = roomAct
|
|
revealedRooms.push({
|
|
act: roomAct,
|
|
areaId: ev.areaId,
|
|
tileX: ev.tileX,
|
|
tileY: ev.tileY,
|
|
})
|
|
revealedAreaIds.add(ev.areaId)
|
|
revealedAreasByAct.get(roomAct)?.add(ev.areaId)
|
|
if (mapService.getActiveAct() === roomAct && liveSeed !== 0) {
|
|
mapService.onAddRoomDataPacket({
|
|
roomX: ev.tileX,
|
|
roomY: ev.tileY,
|
|
areaId: ev.areaId,
|
|
})
|
|
}
|
|
} else if (ev.type === 'UnitAssign') {
|
|
const uType =
|
|
typeof ev.unit.unitType === 'number'
|
|
? (['player', 'monster', 'object', 'missile', 'item', 'tile'][ev.unit.unitType] ??
|
|
'player')
|
|
: ev.unit.unitType
|
|
if (uType === 'player' && (ev.isSelf || ev.unit.id === world.selfId)) {
|
|
playerReassignSeq++
|
|
}
|
|
if (ev.unit.x > 0 || ev.unit.y > 0) {
|
|
liveAssignedUnits.push({
|
|
act: currentActIndex,
|
|
areaIdHint: currentAreaId,
|
|
unitType: uType,
|
|
id: ev.unit.id,
|
|
classId: ev.unit.classId,
|
|
x: ev.unit.x,
|
|
y: ev.unit.y,
|
|
})
|
|
}
|
|
if (uType === 'object' && WAYPOINT_OBJECT_CLASS_IDS.has(ev.unit.classId)) {
|
|
waypointObjectsByAct.get(currentActIndex)?.set(ev.unit.id, {
|
|
id: ev.unit.id,
|
|
classId: ev.unit.classId,
|
|
x: ev.unit.x,
|
|
y: ev.unit.y,
|
|
mode: ev.unit.mode ?? 0,
|
|
})
|
|
}
|
|
} else if (ev.type === 'UnitMode') {
|
|
const wpMap = waypointObjectsByAct.get(currentActIndex)
|
|
const existingWp = wpMap?.get(ev.id)
|
|
if (existingWp) {
|
|
existingWp.mode = ev.mode
|
|
}
|
|
} else if (
|
|
ev.type === 'UnitReassign' &&
|
|
(ev.unitType === 'player' || ev.unitType === 0) &&
|
|
(ev.id === world.selfId || world.selfId === 0)
|
|
) {
|
|
playerReassignSeq++
|
|
} else if (ev.type === 'WaypointMenu') {
|
|
lastWaypointMenuUnitId = ev.unitId
|
|
waypointMenuSeq++
|
|
}
|
|
})
|
|
|
|
const waitStart = Date.now()
|
|
while ((world.loading || liveSeed === 0) && Date.now() - waitStart < 6000) {
|
|
await sleep(80)
|
|
}
|
|
await sleep(350)
|
|
|
|
printWorldSummary(world, adapter, '[d2-bot:audit-levels:entered]')
|
|
|
|
if (liveSeed === 0) {
|
|
throw new Error('[d2-bot:audit-levels] Expected non-zero mapSeed from live D2GS 0x03 LoadAct')
|
|
}
|
|
|
|
const levelViewCache = new Map<number, LevelView>()
|
|
const getLevelViewForArea = (areaId: number): LevelView => {
|
|
const cached = levelViewCache.get(areaId)
|
|
if (cached) return cached
|
|
const actIdx = getActIndexForAreaId(areaId)
|
|
if (mapService.getActiveAct() !== actIdx || mapService.getActiveSeed() !== liveSeed) {
|
|
mapService.loadAct({ act: actIdx, seed: liveSeed, difficulty: world.difficulty })
|
|
}
|
|
const lv = mapService.getLevelView(areaId, 'all')
|
|
levelViewCache.set(areaId, lv)
|
|
return lv
|
|
}
|
|
|
|
let curX = world.self.x
|
|
let curY = world.self.y
|
|
|
|
const findNearestWaypointInAct = (
|
|
actIdx: 0 | 1 | 2 | 3 | 4,
|
|
refX: number,
|
|
refY: number,
|
|
maxDist = Infinity,
|
|
): { id: number; classId: number; x: number; y: number; mode: number } | null => {
|
|
const wpMap = waypointObjectsByAct.get(actIdx)
|
|
if (!wpMap || wpMap.size === 0) return null
|
|
let best: { id: number; classId: number; x: number; y: number; mode: number } | null = null
|
|
let bestDist = maxDist
|
|
for (const wp of wpMap.values()) {
|
|
const d = Math.hypot(wp.x - refX, wp.y - refY)
|
|
if (d <= bestDist) {
|
|
bestDist = d
|
|
best = wp
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
const walkAlongLevelTo = async (
|
|
lv: LevelView,
|
|
targetX: number,
|
|
targetY: number,
|
|
stopDist = 3.5,
|
|
): Promise<void> => {
|
|
const dist0 = Math.hypot(targetX - curX, targetY - curY)
|
|
if (dist0 <= stopDist) return
|
|
const pathRes = findPathWorld(lv, curX, curY, targetX, targetY, {
|
|
smooth: true,
|
|
nearestTargetRadius: 8,
|
|
maxExpandedNodes: 16384,
|
|
})
|
|
const waypoints: readonly SubTilePoint[] =
|
|
pathRes.path.length >= 2
|
|
? pathRes.path
|
|
: [
|
|
{ x: curX, y: curY },
|
|
{ x: targetX, y: targetY },
|
|
]
|
|
const hops = subdividePathHops(waypoints, 3.5)
|
|
for (const hop of hops) {
|
|
if (Math.hypot(targetX - curX, targetY - curY) <= stopDist) break
|
|
adapter.send({ type: 'Move', run: true, to: { x: hop.x, y: hop.y } })
|
|
curX = hop.x
|
|
curY = hop.y
|
|
await sleep(200)
|
|
}
|
|
await sleep(220)
|
|
}
|
|
|
|
const openWaypointMenu = async (wp: {
|
|
id: number
|
|
classId: number
|
|
x: number
|
|
y: number
|
|
}): Promise<void> => {
|
|
for (let attempt = 1; attempt <= 4; attempt++) {
|
|
const prevSeq = waypointMenuSeq
|
|
adapter.send({
|
|
type: 'Interact',
|
|
target: { unitType: 'object', id: wp.id },
|
|
})
|
|
const waitMenuStart = Date.now()
|
|
while (Date.now() - waitMenuStart < 650) {
|
|
if (waypointMenuSeq > prevSeq && lastWaypointMenuUnitId === wp.id) {
|
|
return
|
|
}
|
|
await sleep(40)
|
|
}
|
|
}
|
|
throw new Error(
|
|
`[d2-bot:audit-levels] Failed to receive 0x63 WaypointMenu after interacting with waypoint #${wp.id} (class=${wp.classId}) at (${wp.x},${wp.y}) from player (${curX},${curY})`,
|
|
)
|
|
}
|
|
|
|
// 1. In the starting town (e.g. areaId = 1), locate and walk to the town waypoint
|
|
const entryAreaId = world.areaId || currentAreaId || 1
|
|
const entryActIdx = getActIndexForAreaId(entryAreaId)
|
|
currentAreaId = entryAreaId
|
|
currentActIndex = entryActIdx
|
|
|
|
const townView = getLevelViewForArea(entryAreaId)
|
|
let currentWp = findNearestWaypointInAct(entryActIdx, curX, curY)
|
|
if (!currentWp) {
|
|
const presetWp = townView.presetUnits.find(
|
|
pu => pu.type === 2 && WAYPOINT_OBJECT_CLASS_IDS.has(pu.index),
|
|
)
|
|
if (presetWp) {
|
|
const wpWorld = localToWorldSubTile(townView, presetWp.subX, presetWp.subY)
|
|
await walkAlongLevelTo(townView, wpWorld.worldSubX, wpWorld.worldSubY, 3.5)
|
|
}
|
|
currentWp = findNearestWaypointInAct(entryActIdx, curX, curY)
|
|
}
|
|
if (!currentWp) {
|
|
throw new Error(
|
|
`[d2-bot:audit-levels] Could not locate starting town waypoint in areaId=${entryAreaId}`,
|
|
)
|
|
}
|
|
|
|
await walkAlongLevelTo(townView, currentWp.x, currentWp.y, 3.5)
|
|
await openWaypointMenu(currentWp)
|
|
|
|
console.log(
|
|
`[d2-bot:audit-levels] Opened WaypointMenu at area=${entryAreaId} wp=#${currentWp.id}(class=${currentWp.classId}) unlockedWaypoints=${world.waypoints.size}/39`,
|
|
)
|
|
if (world.waypoints.size < 39) {
|
|
throw new Error(
|
|
`[d2-bot:audit-levels] Expected all 39 waypoints unlocked in 0x63 WaypointMenu, got ${world.waypoints.size}` +
|
|
(remoteUnlock === null
|
|
? ` (server-side .d2s for ${auditCharName} on ${bnetHost} is not unlocked; start SSH ControlMaster socket /tmp/d2host-ssh.sock or set D2HOST_PW to unlock it)`
|
|
: ''),
|
|
)
|
|
}
|
|
|
|
const actsVisited = new Set<number>([entryActIdx + 1])
|
|
const waypointsVisited = new Set<number>([entryAreaId])
|
|
|
|
const tourAreas = [
|
|
...(entryAreaId === 1 ? DEFAULT_AUDIT_WAYPOINT_TOUR.slice(1) : DEFAULT_AUDIT_WAYPOINT_TOUR),
|
|
1,
|
|
]
|
|
|
|
// 2. Chain live 0x49 WaypointGo teleports across all 5 Acts and return to Rogue Encampment (1)
|
|
for (const destAreaId of tourAreas) {
|
|
if (destAreaId === currentAreaId) continue
|
|
const destActIdx = getActIndexForAreaId(destAreaId)
|
|
if (lastWaypointMenuUnitId !== currentWp.id) {
|
|
const curLv = getLevelViewForArea(currentAreaId)
|
|
await walkAlongLevelTo(curLv, currentWp.x, currentWp.y, 3.5)
|
|
await openWaypointMenu(currentWp)
|
|
}
|
|
|
|
const prevReassignSeq = playerReassignSeq
|
|
currentAreaId = destAreaId
|
|
adapter.send({
|
|
type: 'WaypointGo',
|
|
waypointId: currentWp.id,
|
|
destAreaId,
|
|
})
|
|
|
|
const tpStart = Date.now()
|
|
while (Date.now() - tpStart < 6000) {
|
|
if (revealedAreaIds.has(destAreaId) && playerReassignSeq > prevReassignSeq) {
|
|
break
|
|
}
|
|
await sleep(60)
|
|
}
|
|
await sleep(300)
|
|
|
|
if (!revealedAreaIds.has(destAreaId)) {
|
|
throw new Error(
|
|
`[d2-bot:audit-levels] WaypointGo to areaId=${destAreaId} (Act ${destActIdx + 1}) did not receive 0x07 RoomReveal`,
|
|
)
|
|
}
|
|
|
|
curX = world.self.x
|
|
curY = world.self.y
|
|
const destLv = getLevelViewForArea(destAreaId)
|
|
|
|
let destWp = findNearestWaypointInAct(destActIdx, curX, curY, 45)
|
|
if (!destWp) {
|
|
const presetWp = destLv.presetUnits.find(
|
|
pu => pu.type === 2 && WAYPOINT_OBJECT_CLASS_IDS.has(pu.index),
|
|
)
|
|
if (presetWp) {
|
|
const wpWorld = localToWorldSubTile(destLv, presetWp.subX, presetWp.subY)
|
|
await walkAlongLevelTo(destLv, wpWorld.worldSubX, wpWorld.worldSubY, 3.5)
|
|
destWp = findNearestWaypointInAct(destActIdx, curX, curY, 45)
|
|
}
|
|
}
|
|
if (!destWp) {
|
|
throw new Error(
|
|
`[d2-bot:audit-levels] Missing destination waypoint object (0x51) after teleporting to areaId=${destAreaId} (Act ${destActIdx + 1})`,
|
|
)
|
|
}
|
|
|
|
if (Math.hypot(destWp.x - curX, destWp.y - curY) > 3.5) {
|
|
await walkAlongLevelTo(destLv, destWp.x, destWp.y, 3.5)
|
|
}
|
|
|
|
actsVisited.add(destActIdx + 1)
|
|
waypointsVisited.add(destAreaId)
|
|
currentWp = destWp
|
|
lastWaypointMenuUnitId = 0
|
|
|
|
console.log(
|
|
`[d2-bot:audit-levels:hop] act=${destActIdx + 1} areaId=${destAreaId} pos=(${curX},${curY}) wp=#${destWp.id}(class=${destWp.classId},at=${destWp.x},${destWp.y}) totalRevealedRooms=${revealedRooms.length}`,
|
|
)
|
|
}
|
|
|
|
// 3. Audit all live 0x07 RoomReveal rooms and 0x51/0xAC/0x09 UnitAssign packets across Acts 1-5
|
|
let liveRoomsAudited = 0
|
|
let liveUnitsAudited = 0
|
|
let outOfBoundsUnits = 0
|
|
let voidSpawnCollisions = 0
|
|
|
|
for (const r of revealedRooms) {
|
|
const lv = getLevelViewForArea(r.areaId)
|
|
const inBounds =
|
|
r.tileX >= lv.tileOriginX - 16 &&
|
|
r.tileX <= lv.tileOriginX + lv.tileWidth + 16 &&
|
|
r.tileY >= lv.tileOriginY - 16 &&
|
|
r.tileY <= lv.tileOriginY + lv.tileHeight + 16
|
|
if (!inBounds) {
|
|
throw new Error(
|
|
`[d2-bot:audit-levels] Server RoomReveal (${r.tileX},${r.tileY}) out of bounds for area ${r.areaId} (Act ${r.act + 1})`,
|
|
)
|
|
}
|
|
liveRoomsAudited++
|
|
}
|
|
|
|
for (const u of liveAssignedUnits) {
|
|
if (u.x <= 0 && u.y <= 0) continue
|
|
let matchedView: LevelView | null = null
|
|
const candidateAreas = new Set<number>([
|
|
u.areaIdHint,
|
|
...(revealedAreasByAct.get(u.act) ?? []),
|
|
])
|
|
for (const areaId of candidateAreas) {
|
|
if (areaId <= 0) continue
|
|
const lv = getLevelViewForArea(areaId)
|
|
if (
|
|
u.x >= lv.subOriginX &&
|
|
u.x < lv.subOriginX + lv.subWidth &&
|
|
u.y >= lv.subOriginY &&
|
|
u.y < lv.subOriginY + lv.subHeight
|
|
) {
|
|
matchedView = lv
|
|
break
|
|
}
|
|
}
|
|
if (!matchedView) {
|
|
outOfBoundsUnits++
|
|
console.warn(
|
|
`[d2-bot:audit-levels] Unit ${u.unitType}#${u.id} (class=${u.classId}, act=${u.act + 1}) at (${u.x},${u.y}) outside loaded level bounds`,
|
|
)
|
|
} else {
|
|
liveUnitsAudited++
|
|
if (u.unitType === 'player') {
|
|
const coll = getCollisionWorldSubTile(matchedView, u.x, u.y)
|
|
if ((coll & COLLIDE_MASK_INVALID) === COLLIDE_MASK_INVALID) {
|
|
voidSpawnCollisions++
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4. Full 136-level DRLG structural & connectivity audit across Acts 1-5 using the live seed
|
|
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 },
|
|
]
|
|
|
|
let levelsVerified = 0
|
|
let totalPresetUnitsVerified = 0
|
|
let totalWarpsVerified = 0
|
|
let totalRoomsVerified = 0
|
|
|
|
for (const range of actLevelRanges) {
|
|
mapService.loadAct({ act: range.act, seed: liveSeed, difficulty: world.difficulty })
|
|
for (let areaId = range.start; areaId <= range.end; areaId++) {
|
|
const lv = mapService.getLevelView(areaId, 'all')
|
|
if (lv.rooms.length === 0 || lv.walkableCellCount === 0) {
|
|
throw new Error(
|
|
`[d2-bot:audit-levels] Level ${areaId} (Act ${range.act + 1}) has rooms=${lv.rooms.length}, walkable=${lv.walkableCellCount}`,
|
|
)
|
|
}
|
|
|
|
let spawnWalkable = false
|
|
for (let dy = -2; dy <= 7 && !spawnWalkable; dy++) {
|
|
for (let dx = -2; dx <= 7; dx++) {
|
|
const sx = lv.spawnX * 5 + dx
|
|
const sy = lv.spawnY * 5 + dy
|
|
if (
|
|
isWalkableLocalSubTile(lv, sx, sy) &&
|
|
(getCollisionLocalSubTile(lv, sx, sy) & COLLIDE_MASK_INVALID) !== COLLIDE_MASK_INVALID
|
|
) {
|
|
spawnWalkable = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if (!spawnWalkable) {
|
|
voidSpawnCollisions++
|
|
throw new Error(`[d2-bot:audit-levels] Level ${areaId} spawn (${lv.spawnX},${lv.spawnY}) is solid void`)
|
|
}
|
|
|
|
for (const pu of lv.presetUnits) {
|
|
if (pu.subX < 0 || pu.subX >= lv.subWidth || pu.subY < 0 || pu.subY >= lv.subHeight) {
|
|
outOfBoundsUnits++
|
|
} else {
|
|
totalPresetUnitsVerified++
|
|
}
|
|
}
|
|
for (const warp of lv.warps) {
|
|
if (warp.room < 0 || warp.room >= lv.rooms.length || warp.toLevelId <= 0) {
|
|
outOfBoundsUnits++
|
|
} else {
|
|
totalWarpsVerified++
|
|
}
|
|
}
|
|
|
|
totalRoomsVerified += lv.rooms.length
|
|
levelsVerified++
|
|
}
|
|
}
|
|
|
|
console.log(
|
|
`[d2-bot:audit-levels:summary] liveSeed=0x${liveSeed.toString(16).padStart(8, '0')} actsVisited=${actsVisited.size}/5 waypointsVisited=${waypointsVisited.size} levelsVerified=${levelsVerified}/136 totalRooms=${totalRoomsVerified} presetUnits=${totalPresetUnitsVerified} warps=${totalWarpsVerified} liveRoomsAudited=${liveRoomsAudited} liveUnitsAudited=${liveUnitsAudited} outOfBoundsUnits=${outOfBoundsUnits} voidSpawnCollisions=${voidSpawnCollisions}`,
|
|
)
|
|
|
|
adapter.leave()
|
|
await sleep(300)
|
|
|
|
if (
|
|
actsVisited.size < 5 ||
|
|
waypointsVisited.size < 10 ||
|
|
liveRoomsAudited < 50 ||
|
|
levelsVerified !== 136 ||
|
|
liveUnitsAudited === 0 ||
|
|
outOfBoundsUnits > 0 ||
|
|
voidSpawnCollisions > 0
|
|
) {
|
|
throw new Error(
|
|
`[d2-bot:audit-levels] Audit failed: actsVisited=${actsVisited.size}/5 waypointsVisited=${waypointsVisited.size} levels=${levelsVerified}/136 liveRooms=${liveRoomsAudited} liveUnits=${liveUnitsAudited} outOfBounds=${outOfBoundsUnits} voidCollisions=${voidSpawnCollisions}`,
|
|
)
|
|
}
|
|
} finally {
|
|
await auditFlow.close()
|
|
}
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
const { cmd, flags } = parseArgs(process.argv.slice(2))
|
|
const via = ((typeof flags.via === 'string' ? flags.via : 'mixed') as 'mixed' | 'wss' | 'tcp')
|
|
const bnetHost = typeof flags.host === 'string' ? flags.host : '101.37.117.183'
|
|
const wsHost = typeof flags['ws-host'] === 'string' ? flags['ws-host'] : 'www.laiseek.xyz'
|
|
const showWorld = Boolean(flags.world)
|
|
const capturePath = typeof flags.capture === 'string' ? resolve(flags.capture) : undefined
|
|
const scenario = typeof flags.scenario === 'string' ? flags.scenario : 'full'
|
|
|
|
const accountsFile = loadOrInitBotAccounts(bnetHost)
|
|
const defaultAccountKey = cmd === 'walk' || cmd === 'audit-levels' ? 'd2webbot2' : 'd2webbot1'
|
|
const accountKey = typeof flags.account === 'string' ? flags.account : defaultAccountKey
|
|
let accountRec = accountsFile.accounts[accountKey]
|
|
if (!accountRec) {
|
|
accountRec = {
|
|
username: accountKey,
|
|
password: typeof flags.password === 'string' ? flags.password : `d2w_${randomBytes(6).toString('hex')}`,
|
|
defaultChar: typeof flags.char === 'string' ? flags.char : `${accountKey}c`,
|
|
charClass: (typeof flags.class === 'string' ? Number(flags.class) : 1) as CharClassId,
|
|
createdAt: new Date().toISOString(),
|
|
}
|
|
accountsFile.accounts[accountKey] = accountRec
|
|
saveBotAccounts(accountsFile)
|
|
}
|
|
|
|
const charName = typeof flags.char === 'string' ? flags.char : accountRec.defaultChar
|
|
const charClass = (typeof flags.class === 'string' ? Number(flags.class) : accountRec.charClass) as CharClassId
|
|
|
|
const tap = new InMemoryPacketTap(50_000)
|
|
const tables = getCanonicalItemDataTables()
|
|
|
|
let captureWritten = false
|
|
const writeCaptureIfRequested = (scenLabel: string): void => {
|
|
if (!capturePath || captureWritten) return
|
|
captureWritten = true
|
|
mkdirSync(dirname(capturePath), { recursive: true })
|
|
const jsonl = tap.toD2CapJsonl({
|
|
format: 'd2cap-v1',
|
|
createdAt: new Date().toISOString(),
|
|
server: `${bnetHost} (via=${via})`,
|
|
account: '<redacted>',
|
|
character: charName,
|
|
scenario: scenLabel,
|
|
})
|
|
writeFileSync(capturePath, jsonl, 'utf8')
|
|
console.log(`[d2-bot] Saved capture (${tap.entries.length} packets) -> ${capturePath}`)
|
|
}
|
|
|
|
if (cmd === 'register') {
|
|
const targets =
|
|
typeof flags.account === 'string'
|
|
? [accountRec]
|
|
: Object.values(accountsFile.accounts)
|
|
|
|
for (const acc of targets) {
|
|
const flow = createD2OnlineFlow({
|
|
resolver: buildResolver(via, bnetHost, wsHost),
|
|
bnetHost,
|
|
defaultRealm: accountsFile.realm,
|
|
itemTables: tables,
|
|
tap,
|
|
})
|
|
try {
|
|
console.log(`[d2-bot] Registering/verifying account "${acc.username}" via ${via}...`)
|
|
await flow.loginOrRegister(acc.username, acc.password, { register: true })
|
|
const chars = await flow.enterRealm(accountsFile.realm)
|
|
console.log(
|
|
`[d2-bot] Account "${acc.username}" authenticated on realm "${accountsFile.realm}" (${chars.length} chars)`,
|
|
)
|
|
const hasChar = chars.some(c => c.name.toLowerCase() === acc.defaultChar.toLowerCase())
|
|
if (!hasChar) {
|
|
console.log(`[d2-bot] Creating default character "${acc.defaultChar}" (class=${acc.charClass})...`)
|
|
await flow.createCharacter({
|
|
name: acc.defaultChar,
|
|
classId: acc.charClass,
|
|
expansion: true,
|
|
hardcore: false,
|
|
ladder: true,
|
|
})
|
|
console.log(`[d2-bot] Created character "${acc.defaultChar}".`)
|
|
}
|
|
} finally {
|
|
await flow.close()
|
|
}
|
|
}
|
|
saveBotAccounts(accountsFile)
|
|
writeCaptureIfRequested('register')
|
|
return
|
|
}
|
|
|
|
if (cmd === 'audit-levels') {
|
|
await runAuditLevelsSubcommand({
|
|
via,
|
|
bnetHost,
|
|
wsHost,
|
|
accountsFile,
|
|
tables,
|
|
tap,
|
|
})
|
|
writeCaptureIfRequested('audit-levels')
|
|
return
|
|
}
|
|
|
|
const flow = createD2OnlineFlow({
|
|
resolver: buildResolver(via, bnetHost, wsHost),
|
|
bnetHost,
|
|
defaultRealm: accountsFile.realm,
|
|
itemTables: tables,
|
|
tap,
|
|
})
|
|
|
|
try {
|
|
console.log(`[d2-bot] Connecting to ${bnetHost} (via=${via}) as "${accountRec.username}"...`)
|
|
await flow.loginOrRegister(accountRec.username, accountRec.password, { register: true })
|
|
|
|
if (cmd === 'login') {
|
|
const realms = await flow.listRealms()
|
|
console.log(`[d2-bot] Logged in. Realms:`, realms)
|
|
const chars = await flow.enterRealm(accountsFile.realm)
|
|
console.log(`[d2-bot] Entered realm "${accountsFile.realm}", characters (${chars.length}):`, chars.map(c => c.name))
|
|
writeCaptureIfRequested('login')
|
|
return
|
|
}
|
|
|
|
const chars = await flow.enterRealm(accountsFile.realm)
|
|
|
|
if (cmd === 'chars') {
|
|
console.log(
|
|
`[d2-bot] Characters (${chars.length}):`,
|
|
chars.map(c => ({
|
|
name: c.name,
|
|
class: c.charClass,
|
|
level: c.level,
|
|
exp: c.expansion,
|
|
hc: c.hardcore,
|
|
ladder: c.ladder,
|
|
})),
|
|
)
|
|
writeCaptureIfRequested('chars')
|
|
return
|
|
}
|
|
|
|
if (cmd === 'create-char') {
|
|
const exists = chars.some(c => c.name.toLowerCase() === charName.toLowerCase())
|
|
if (!exists) {
|
|
await flow.createCharacter({
|
|
name: charName,
|
|
classId: charClass,
|
|
expansion: true,
|
|
hardcore: false,
|
|
ladder: true,
|
|
})
|
|
console.log(`[d2-bot] Created character "${charName}" (class=${charClass})`)
|
|
} else {
|
|
console.log(`[d2-bot] Character "${charName}" already exists`)
|
|
}
|
|
writeCaptureIfRequested('create-char')
|
|
return
|
|
}
|
|
|
|
// Ensure target character exists and select it
|
|
const hasTargetChar = chars.some(c => c.name.toLowerCase() === charName.toLowerCase())
|
|
if (!hasTargetChar) {
|
|
console.log(`[d2-bot] Creating character "${charName}" (class=${charClass})...`)
|
|
await flow.createCharacter({
|
|
name: charName,
|
|
classId: charClass,
|
|
expansion: true,
|
|
hardcore: false,
|
|
ladder: true,
|
|
})
|
|
}
|
|
await flow.selectCharacter(charName)
|
|
console.log(`[d2-bot] Selected character "${charName}"`)
|
|
|
|
if (cmd === 'games') {
|
|
const games = await flow.listGames('')
|
|
console.log(`[d2-bot] Active games (${games.length}):`, games)
|
|
writeCaptureIfRequested('games')
|
|
return
|
|
}
|
|
|
|
const gameName =
|
|
typeof flags.game === 'string'
|
|
? flags.game
|
|
: `wb${Math.floor(Date.now() / 1000)
|
|
.toString(36)
|
|
.slice(-5)}`
|
|
const gamePass = typeof flags.pass === 'string' ? flags.pass : ''
|
|
|
|
if (cmd === 'create-game') {
|
|
await flow.createGame({
|
|
name: gameName,
|
|
password: gamePass,
|
|
description: 'd2-bot test',
|
|
difficulty: 0,
|
|
maxPlayers: 8,
|
|
})
|
|
console.log(`[d2-bot] Created game "${gameName}"`)
|
|
writeCaptureIfRequested('create-game')
|
|
return
|
|
}
|
|
|
|
if (cmd === 'join' || cmd === 'idle' || cmd === 'record' || cmd === 'walk') {
|
|
const shouldCreate =
|
|
Boolean(flags['create-game']) || typeof flags.game !== 'string' || cmd === 'record' || cmd === 'walk'
|
|
if (shouldCreate) {
|
|
console.log(`[d2-bot] Creating game "${gameName}"...`)
|
|
await flow.createGame({
|
|
name: gameName,
|
|
password: gamePass,
|
|
description: `d2-bot ${cmd}`,
|
|
difficulty: 0,
|
|
maxPlayers: 8,
|
|
})
|
|
}
|
|
|
|
const mapService =
|
|
cmd === 'walk'
|
|
? await MapService.fromPackedDir(resolve(ROOT_DIR, 'public/act-packs/tiles'))
|
|
: null
|
|
|
|
console.log(`[d2-bot] Joining game "${gameName}"...`)
|
|
const adapter = await flow.joinGame(gameName, gamePass)
|
|
const world = new ClientWorld()
|
|
|
|
adapter.onEvent((ev) => {
|
|
world.apply(ev)
|
|
if (mapService) {
|
|
if (ev.type === 'LoadAct') {
|
|
mapService.onLoadActPacket(
|
|
{ act: ev.act, mapSeed: ev.mapSeed, areaId: ev.areaId || 1 },
|
|
{ difficulty: world.difficulty, revealMode: 'all' },
|
|
)
|
|
} else if (ev.type === 'RoomReveal') {
|
|
mapService.onAddRoomDataPacket({
|
|
roomX: ev.tileX,
|
|
roomY: ev.tileY,
|
|
areaId: ev.areaId,
|
|
})
|
|
} else if (ev.type === 'RoomHide') {
|
|
mapService.onRemoveRoomDataPacket({
|
|
roomX: ev.tileX,
|
|
roomY: ev.tileY,
|
|
areaId: ev.areaId,
|
|
})
|
|
}
|
|
}
|
|
})
|
|
|
|
// Wait briefly for initial load burst to settle
|
|
const waitStart = Date.now()
|
|
while (world.loading && Date.now() - waitStart < 5000) {
|
|
await sleep(100)
|
|
}
|
|
|
|
if (showWorld || cmd === 'join' || cmd === 'idle' || cmd === 'record' || cmd === 'walk') {
|
|
printWorldSummary(world, adapter, '[d2-bot:entered]')
|
|
}
|
|
|
|
if (cmd === 'walk' && mapService) {
|
|
await sleep(300)
|
|
await runWalkSubcommand(world, adapter, mapService)
|
|
printWorldSummary(world, adapter, '[d2-bot:final]')
|
|
adapter.leave()
|
|
await sleep(300)
|
|
writeCaptureIfRequested('walk')
|
|
return
|
|
}
|
|
|
|
if (cmd === 'record') {
|
|
console.log(`[d2-bot] Running scenario="${scenario}"...`)
|
|
await runInGameScenario(scenario, world, adapter)
|
|
}
|
|
|
|
const defaultIdleSec = cmd === 'idle' ? 60 : cmd === 'record' ? 6 : 5
|
|
const idleSec =
|
|
typeof flags.idle === 'string' ? Math.max(1, Number(flags.idle)) : defaultIdleSec
|
|
|
|
console.log(`[d2-bot] Remaining in game "${gameName}" for ${idleSec}s...`)
|
|
const endAt = Date.now() + idleSec * 1000
|
|
let nextLogAt = Date.now() + 10_000
|
|
while (Date.now() < endAt && adapter.state !== 'closed') {
|
|
await sleep(250)
|
|
world.tick()
|
|
if (Date.now() >= nextLogAt) {
|
|
printWorldSummary(world, adapter, '[d2-bot:tick]')
|
|
nextLogAt = Date.now() + 10_000
|
|
}
|
|
}
|
|
|
|
printWorldSummary(world, adapter, '[d2-bot:final]')
|
|
adapter.leave()
|
|
await sleep(300)
|
|
writeCaptureIfRequested(cmd === 'record' ? scenario : cmd)
|
|
return
|
|
}
|
|
|
|
console.error(`Unknown d2-bot command: "${cmd}"`)
|
|
process.exit(1)
|
|
} finally {
|
|
await flow.close()
|
|
writeCaptureIfRequested(cmd)
|
|
}
|
|
}
|
|
|
|
const isMain = process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))
|
|
if (isMain) {
|
|
main().catch((err) => {
|
|
console.error('[d2-bot] FATAL:', err)
|
|
process.exit(1)
|
|
})
|
|
}
|