ldov
/

File size: 12,639 Bytes
e46c127
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
// Mineflayer skill server for minecraft.py: the NLI model picks a skill, this process executes it in a real Minecraft world.
//   node mc_bot.js [--port 25570] [--http 3007]
//   GET  /state                      -> inventory / stations / visible blocks
//   POST /act   {skill, arg, n}      -> {ok, msg}   skills: collect | craft | place | smelt | explore
//   POST /reset {x, z}               -> clear inventory, teleport to a fresh spot
const mineflayer = require('mineflayer')
const { pathfinder, Movements, goals } = require('mineflayer-pathfinder')
const { Vec3 } = require('vec3')
const http = require('http')

const argv = process.argv.slice(2)
const opt = (k, d) => { const i = argv.indexOf(k); return i >= 0 ? argv[i + 1] : d }
const bot = mineflayer.createBot({ host: '127.0.0.1', port: +opt('--port', 25570), username: opt('--name', 'jev'), version: '1.20.4' })
bot.loadPlugin(pathfinder)
let mcData, ready = false
const sleep = ms => new Promise(r => setTimeout(r, ms))

// text-level groups: the planner only ever sees these names
const BLOCKS = { log: n => n.endsWith('_log') && !n.startsWith('stripped'), stone: n => n === 'stone', iron_ore: n => n === 'iron_ore' || n === 'deepslate_iron_ore' }
const ITEMS = { log: n => n.endsWith('_log'), planks: n => n.endsWith('_planks'), stick: n => n === 'stick', cobblestone: n => n === 'cobblestone' }
const DROPS = { log: 'log', stone: 'cobblestone', iron_ore: 'raw_iron' }
const RADIUS = { log: 48, stone: 32, iron_ore: 64 }
const itemMatch = g => ITEMS[g] || (n => n === g)
const count = g => bot.inventory.items().filter(i => itemMatch(g)(i.name)).reduce((s, i) => s + i.count, 0)
const blockIds = g => Object.values(mcData.blocksByName).filter(b => BLOCKS[g](b.name)).map(b => b.id)
const bad = new Set()

bot.once('spawn', async () => {
  mcData = require('minecraft-data')(bot.version)
  const mv = new Movements(bot)
  mv.allow1by1towers = true; mv.canDig = true; mv.allowParkour = true
  bot.pathfinder.setMovements(mv)
  bot.pathfinder.thinkTimeout = 10000
  for (const c of ['gamerule doDaylightCycle false', 'time set day', 'gamerule doWeatherCycle false', 'weather clear', 'gamerule keepInventory true', 'difficulty peaceful']) { bot.chat('/' + c); await sleep(150) }
  if (opt('--viewer', null)) require('prismarine-viewer').mineflayer(bot, { port: +opt('--viewer'), firstPerson: opt('--view', 'first') === 'first', viewDistance: 5 })
  ready = true
  console.log('bot ready at', bot.entity.position.floored().toString())
})
bot.on('kicked', r => console.log('kicked', r))
bot.on('error', e => console.log('error', e.message))
bot.on('death', () => console.log('bot died'))

function findNear(g) {
  const pos = bot.findBlocks({ matching: blockIds(g), maxDistance: RADIUS[g], count: 64 }).filter(p => !bad.has(p.toString()))
  const me = bot.entity.position
  // prefer exposed blocks that are not high above the head (tree tops need towers, buried blocks need tunnels)
  const exposed = p => [[1, 0, 0], [-1, 0, 0], [0, 1, 0], [0, 0, 1], [0, 0, -1]].some(d => { const b = bot.blockAt(p.offset(...d)); return b && b.name === 'air' })
  const cost = p => p.distanceTo(me) + 4 * Math.max(0, p.y - me.y - 3) + (p.distanceTo(me) < 24 && !exposed(p) ? 10 : 0)
  return pos.map(p => [cost(p), p]).sort((a, b) => a[0] - b[0]).map(x => x[1])
}
const station = name => bot.findBlock({ matching: mcData.blocksByName[name].id, maxDistance: 32 })

function state() {
  const inv = {}
  for (const i of bot.inventory.items()) {
    const g = Object.keys(ITEMS).find(k => ITEMS[k](i.name)) || i.name
    inv[g] = (inv[g] || 0) + i.count
  }
  const p = bot.entity.position
  return {
    inventory: inv, pos: [Math.round(p.x), Math.round(p.y), Math.round(p.z)], health: bot.health,
    near: { crafting_table: !!station('crafting_table'), furnace: !!station('furnace') },
    visible: Object.fromEntries(Object.keys(BLOCKS).map(g => [g, findNear(g).length > 0]))
  }
}

async function withTimeout(p, ms, what) {
  let t
  // stop() with no active path would cancel the *next* goto, so only clean up after a real timeout
  const timer = new Promise((_, rej) => { t = setTimeout(() => { bot.pathfinder.setGoal(null); try { bot.stopDigging() } catch (e) {} ; rej(new Error(`timeout: ${what}`)) }, ms) })
  try { return await Promise.race([p, timer]) } finally { clearTimeout(t) }
}

async function pickupNear(pos) {
  await sleep(350)
  for (let k = 0; k < 3; k++) {
    const e = Object.values(bot.entities).filter(e => e.name === 'item' && e.position.distanceTo(pos) < 6).sort((a, b) => a.position.distanceTo(bot.entity.position) - b.position.distanceTo(bot.entity.position))[0]
    if (!e) return
    try { await withTimeout(bot.pathfinder.goto(new goals.GoalNear(e.position.x, e.position.y, e.position.z, 0.7)), 15000, 'pickup') } catch (err) { return }
    await sleep(300)
  }
}

async function collect(g, n) {
  if (!BLOCKS[g]) return { ok: false, msg: `unknown block group ${g}` }
  const drop = DROPS[g], start = count(drop)
  for (let tries = 0; count(drop) < start + n && tries < n * 3 + 4; tries++) {
    const cands = findNear(g)
    if (!cands.length) break
    const block = bot.blockAt(cands[0])
    try {
      await withTimeout(bot.pathfinder.goto(new goals.GoalNear(block.position.x, block.position.y, block.position.z, 3)), 60000, 'path to ' + g)
      if (!bot.canDigBlock(block)) throw new Error('block out of reach')
      const tool = bot.pathfinder.bestHarvestTool(block)
      if (tool) await bot.equip(tool, 'hand')
      if (!block.canHarvest(bot.heldItem ? bot.heldItem.type : null)) return { ok: false, msg: `cannot harvest ${g} without a suitable pickaxe` }
      await withTimeout(bot.dig(block), 30000, 'dig ' + g)
      await pickupNear(block.position)
    } catch (e) { bad.add(cands[0].toString()); console.log('collect err', e.message) }
  }
  const got = count(drop) - start
  return got > 0 ? { ok: true, msg: `collected ${got} ${drop}` } : { ok: false, msg: `no reachable ${g} block found nearby` }
}

// bot.craft() clicks the next ingredient while the previous one is still on the cursor (swap -> garbage in the grid),
// so the clicks are done here: pick a stack, right-click one item into each grid slot, put the rest back, take the result.
async function craftOnce(recipe, table) {
  const win = recipe.requiresTable ? await bot.openBlock(table) : bot.inventory, w = recipe.requiresTable ? 3 : 2
  const click = async (slot, btn) => { await bot.clickWindow(slot, btn, 0); await sleep(60) }
  try {
    const dests = {}
    if (recipe.inShape) recipe.inShape.forEach((row, y) => row.forEach((ing, x) => { if (ing.id !== -1) (dests[ing.id] = dests[ing.id] || []).push(1 + x + w * y) }))
    else recipe.ingredients.forEach((ing, k) => (dests[ing.id] = dests[ing.id] || []).push(1 + k))
    for (const [id, slots] of Object.entries(dests)) {
      let from = null
      for (const d of slots) {
        if (!win.selectedItem) {  // stacks may be fragmented (4 + 4 + 2 planks): take the next one when the cursor runs empty
          const src = win.findInventoryItem(+id, null)
          if (!src) throw new Error('missing ingredient')
          from = src.slot
          await click(from, 0)
        }
        await click(d, 1)
      }
      if (win.selectedItem) await click(from, 0)
    }
    for (let i = 0; i < 40 && !win.slots[0]; i++) await sleep(50)
    if (!win.slots[0]) throw new Error('no crafting result appeared')
    await click(0, 0)
    await click(win.firstEmptyInventorySlot(), 0)
  } finally {
    if (recipe.requiresTable) bot.closeWindow(win)
    await sleep(250)
  }
}

async function craft(name) {
  const ids = Object.values(mcData.itemsByName).filter(i => itemMatch(name)(i.name)).map(i => i.id)
  const table = station('crafting_table')
  let recipe = null
  for (const id of ids) { recipe = bot.recipesFor(id, null, 1, table || null)[0]; if (recipe) break }
  if (!recipe) return { ok: false, msg: `cannot craft ${name}: missing ingredients${table ? '' : ' or no crafting table nearby'}` }
  if (recipe.requiresTable) await withTimeout(bot.pathfinder.goto(new goals.GoalNear(table.position.x, table.position.y, table.position.z, 2)), 60000, 'path to table')
  const before = count(name)
  try { await craftOnce(recipe, table) } catch (e) { console.log('craft err', e.message); return { ok: false, msg: `crafting ${name} failed: ${e.message}` } }
  return { ok: count(name) > before, msg: `crafted ${count(name) - before} ${name}` }
}

async function place(name) {
  const item = bot.inventory.items().find(i => i.name === name)
  if (!item) return { ok: false, msg: `no ${name} in the inventory` }
  const p = bot.entity.position.floored()
  for (let r = 1; r <= 3; r++) for (let dx = -r; dx <= r; dx++) for (let dz = -r; dz <= r; dz++) for (const dy of [0, 1, -1]) {
    if (Math.max(Math.abs(dx), Math.abs(dz)) !== r) continue
    const ref = bot.blockAt(p.offset(dx, dy - 1, dz)), above = bot.blockAt(p.offset(dx, dy, dz))
    if (!ref || !above || ref.boundingBox !== 'block' || above.name !== 'air') continue
    try {
      await bot.equip(item, 'hand')
      await bot.lookAt(ref.position.offset(0.5, 1, 0.5))
      await bot.placeBlock(ref, new Vec3(0, 1, 0))
      return { ok: true, msg: `placed ${name}` }
    } catch (e) { console.log('place err', e.message) }
  }
  return { ok: false, msg: `no free spot to place ${name}` }
}

async function smelt(input) {
  const fb = station('furnace')
  if (!fb) return { ok: false, msg: 'no furnace nearby' }
  const raw = bot.inventory.items().find(i => i.name === input), fuel = bot.inventory.items().find(i => ITEMS.planks(i.name) || i.name === 'coal')
  if (!raw) return { ok: false, msg: `no ${input} in the inventory` }
  if (!fuel) return { ok: false, msg: 'no fuel (planks) in the inventory' }
  await withTimeout(bot.pathfinder.goto(new goals.GoalNear(fb.position.x, fb.position.y, fb.position.z, 2)), 60000, 'path to furnace')
  const f = await bot.openFurnace(fb), n = raw.count
  const nfuel = Math.min(fuel.count, fuel.name === 'coal' ? Math.ceil(n / 8) : Math.ceil(n * 2 / 3))
  await f.putFuel(fuel.type, null, nfuel); await f.putInput(raw.type, null, n)
  for (let t = 0; t < n * 12 + 8; t++) { await sleep(1000); const o = f.outputItem(); if (o && o.count >= n) break; if (!f.inputItem() && f.fuel <= 0 && t > 3) break }
  const out = f.outputItem()
  if (out) await f.takeOutput()
  if (f.inputItem()) await f.takeInput()
  f.close()
  return out ? { ok: true, msg: `smelted ${out.count} ${out.name}` } : { ok: false, msg: 'smelting produced nothing (not enough fuel?)' }
}

let heading = Math.random() * 2 * Math.PI  // kept per episode: a fresh random direction every call is a random walk that goes nowhere
async function explore() {
  const a = heading + (Math.random() - 0.5) * 0.6, p = bot.entity.position.clone()
  try { await withTimeout(bot.pathfinder.goto(new goals.GoalXZ(p.x + 60 * Math.cos(a), p.z + 60 * Math.sin(a))), 60000, 'explore') } catch (e) { heading += 1.2 }
  return { ok: true, msg: `walked ${Math.round(bot.entity.position.distanceTo(p))} blocks` }
}

async function reset(x, z) {
  bot.chat('/clear'); await sleep(200)
  // start on dry land with trees in sight (otherwise half of the episodes begin in the ocean)
  for (let k = 0; k < 12; k++) {
    bot.chat(`/spreadplayers ${x + 300 * k} ${z} 0 40 false ${bot.username}`); await sleep(4000)
    const feet = bot.blockAt(bot.entity.position.floored())
    if (findNear('log').length > 3 && feet && feet.name !== 'water') break
  }
  bad.clear(); heading = Math.random() * 2 * Math.PI
  return { ok: true, msg: 'reset to ' + bot.entity.position.floored().toString() }
}

let busy = false
http.createServer((req, res) => {
  let body = ''
  req.on('data', c => { body += c })
  req.on('end', async () => {
    const send = o => { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)) }
    if (!ready) return send({ ok: false, msg: 'bot not ready' })
    if (req.url === '/state') return send(state())
    if (busy) return send({ ok: false, msg: 'busy' })
    busy = true
    let out
    try {
      const a = JSON.parse(body || '{}')
      if (req.url === '/reset') out = await reset(a.x || 0, a.z || 0)
      else out = await withTimeout({ collect: () => collect(a.arg, a.n || 1), craft: () => craft(a.arg), place: () => place(a.arg), smelt: () => smelt(a.arg), explore }[a.skill](), 240000, a.skill)
    } catch (e) { out = { ok: false, msg: 'error: ' + e.message } }
    busy = false
    send({ ...out, state: state() })
  })
}).listen(+opt('--http', 3007), '127.0.0.1', () => console.log('skill server on', opt('--http', 3007)))