feat(minecraft): add query DSL for read-only runtime introspection with composable block/entity/inventory queries

Add createQueryRuntime helper exposing query.blocks/blockAt/entities/inventory/craftable with chainable filters (within/limit/whereName/whereType/isOre/sortByDistance/names/first/list/countByName/uniq/whereIncludes), inject query/bot/mineflayer globals into JavaScriptPlanner sandbox and RuntimeGlobals, remove redundant inventory/nearbyBlocks/craftable/entities/searchForBlock/searchForEntity
This commit is contained in:
Rin
2026-02-18 11:14:38 +08:00
committed by Neko Ayaka
parent 1384161f42
commit e9087a341b
8 changed files with 457 additions and 145 deletions
@@ -7,23 +7,13 @@ import { collectBlock } from '../../skills/actions/collect-block'
import { discard, equip, putInChest, takeFromChest } from '../../skills/actions/inventory'
import { activateNearestBlock, breakBlockAt, placeBlock } from '../../skills/actions/world-interactions'
import { ActionError } from '../../utils/errors'
import { useLogger } from '../../utils/logger'
import { describeRecipePlan, planRecipe } from '../../utils/recipe-planner'
import * as skills from '../../skills'
import * as world from '../../skills/world'
// Utils
const pad = (str: string): string => `\n${str}\n`
function formatInventoryItem(item: string, count: number): string {
return count > 0 ? `\n- ${item}: ${count}` : ''
}
function formatWearingItem(slot: string, item: string | undefined): string {
return item ? `\n${slot}: ${item}` : ''
}
function toCoord(pos: { x: number, y: number, z: number }) {
return { x: pos.x, y: pos.y, z: pos.z }
}
@@ -73,68 +63,6 @@ export const actionsList: Action[] = [
// : 'Reflex mode override cleared (automatic mode selection resumed).'
// },
// },
{
name: 'inventory',
description: 'Get your inventory.',
execution: 'sync',
schema: z.object({}),
perform: mineflayer => (): string => {
const inventory = world.getInventoryCounts(mineflayer)
const items = Object.entries(inventory)
.map(([item, count]) => formatInventoryItem(item, count))
.join('')
const wearing = [
formatWearingItem('Head', mineflayer.bot.inventory.slots[5]?.name),
formatWearingItem('Torso', mineflayer.bot.inventory.slots[6]?.name),
formatWearingItem('Legs', mineflayer.bot.inventory.slots[7]?.name),
formatWearingItem('Feet', mineflayer.bot.inventory.slots[8]?.name),
].filter(Boolean).join('')
return pad(`INVENTORY${items || ': Nothing'}
${mineflayer.bot.game.gameMode === 'creative' ? '\n(You have infinite items in creative mode. You do not need to gather resources!!)' : ''}
WEARING: ${wearing || 'Nothing'}`)
},
},
{
name: 'nearbyBlocks',
description: 'Get the blocks near you.',
execution: 'sync',
schema: z.object({}),
perform: mineflayer => (): string => {
const blocks = world.getNearbyBlockTypes(mineflayer)
useLogger().withFields({ blocks }).log('nearbyBlocks')
return pad(`NEARBY_BLOCKS${blocks.map((b: string) => `\n- ${b}`).join('') || ': none'}`)
},
},
{
name: 'craftable',
description: 'Get the craftable items with your inventory.',
execution: 'sync',
schema: z.object({}),
perform: mineflayer => (): string => {
const craftable = world.getCraftableItems(mineflayer)
return pad(`CRAFTABLE_ITEMS${craftable.map((i: string) => `\n- ${i}`).join('') || ': none'}`)
},
},
{
name: 'entities',
description: 'Get the nearby players and entities.',
execution: 'sync',
schema: z.object({}),
perform: mineflayer => (): string => {
const players = world.getNearbyPlayerNames(mineflayer)
const entities = world.getNearbyEntityTypes(mineflayer)
.filter((e: string) => e !== 'player' && e !== 'item')
const result = [
...players.map((p: string) => `- Human player: ${p}`),
...entities.map((e: string) => `- entities: ${e}`),
]
return pad(`NEARBY_ENTITIES${result.length ? `\n${result.join('\n')}` : ': none'}`)
},
},
{
name: 'stop',
description: 'Force stop all actions', // TODO: include name of the current action in description?
@@ -248,76 +176,6 @@ export const actionsList: Action[] = [
}
},
},
{
name: 'searchForBlock',
description: 'Find the nearest block of a given type in a given range and return its coordinates.',
execution: 'async',
schema: z.object({
type: z.string().describe('The block type to search for.'),
search_range: z.number().describe('The range to search for the block.').min(1).max(512),
}),
perform: mineflayer => async (block_type: string, range: number) => {
const block = world.getNearestBlock(mineflayer, block_type, range)
if (!block) {
return {
found: false,
query: { type: block_type, range },
}
}
const distance = mineflayer.bot.entity.position.distanceTo(block.position)
return {
found: true,
block: {
name: block.name,
position: {
x: block.position.x,
y: block.position.y,
z: block.position.z,
},
},
distance,
}
},
},
{
name: 'searchForEntity',
description: 'Find the nearest entity of a given type in a given range and return its coordinates.',
execution: 'async',
schema: z.object({
type: z.string().describe('The type of entity to search for.'),
search_range: z.number().describe('The range to search for the entity.').min(1).max(512),
}),
perform: mineflayer => async (entity_type: string, range: number) => {
const entity = world.getNearestEntityWhere(
mineflayer,
current => current.name === entity_type,
range,
)
if (!entity) {
return {
found: false,
query: { type: entity_type, range },
}
}
const distance = mineflayer.bot.entity.position.distanceTo(entity.position)
return {
found: true,
entity: {
name: entity.name,
type: entity.type,
position: {
x: entity.position.x,
y: entity.position.y,
z: entity.position.z,
},
},
distance,
}
},
},
// {
// name: 'moveAway',
// description: 'Move away from the current location in any direction by a given distance.',
@@ -96,6 +96,7 @@ export class Brain {
private lastPlannerOutcome: PlannerOutcomeSummary | undefined
private conversationHistory: Message[] = []
private lastLlmInputSnapshot: LlmInputSnapshot | null = null
private runtimeMineflayer: MineflayerWithAgents | null = null
constructor(private readonly deps: BrainDeps) {
this.debugService = DebugService.getInstance()
@@ -104,6 +105,7 @@ export class Brain {
public init(bot: MineflayerWithAgents): void {
this.deps.logger.log('INFO', 'Brain: Initializing stateful core...')
this.botUsername = bot.bot.username
this.runtimeMineflayer = bot
// Perception Handler
this.deps.eventBus.subscribe<PerceptionSignal>('conscious:signal:*', (event: TracedEvent<PerceptionSignal>) => {
@@ -153,6 +155,7 @@ export class Brain {
public destroy(): void {
this.currentCancellationToken?.cancel()
this.runtimeMineflayer = null
}
public getReplState(): { variables: PlannerGlobalDescriptor[], updatedAt: number } {
@@ -167,6 +170,8 @@ export class Brain {
timestamp: Date.now(),
},
snapshot: snapshot as unknown as Record<string, unknown>,
mineflayer: this.runtimeMineflayer,
bot: this.runtimeMineflayer?.bot,
llmInput: this.lastLlmInputSnapshot,
},
)
@@ -211,6 +216,8 @@ export class Brain {
timestamp: Date.now(),
},
snapshot: snapshot as unknown as Record<string, unknown>,
mineflayer: this.runtimeMineflayer,
bot: this.runtimeMineflayer?.bot,
llmInput: this.lastLlmInputSnapshot,
},
async (action: ActionInstruction) => {
@@ -471,6 +478,8 @@ export class Brain {
{
event,
snapshot: snapshot as unknown as Record<string, unknown>,
mineflayer: bot,
bot: bot.bot,
llmInput: this.lastLlmInputSnapshot,
},
async (action: ActionInstruction) => {
@@ -612,7 +621,7 @@ export class Brain {
parts.push(`[SCRIPT] Last eval ${ageMs}ms ago: return=${returnValue}; actions=${this.lastPlannerOutcome.actionCount} (ok=${this.lastPlannerOutcome.okCount}, err=${this.lastPlannerOutcome.errorCount}); logs=${logs}`)
}
parts.push('[RUNTIME] Globals are refreshed every turn: snapshot, self, environment, social, threat, attention, autonomy, event, now, mem, lastRun, prevRun, lastAction. Player gaze is available in environment.nearbyPlayersGaze when needed.')
parts.push('[RUNTIME] Globals are refreshed every turn: snapshot, self, environment, social, threat, attention, autonomy, event, now, query, bot, mineflayer, mem, lastRun, prevRun, lastAction. Player gaze is available in environment.nearbyPlayersGaze when needed.')
return parts.join('\n\n')
}
@@ -165,6 +165,9 @@ describe('javaScriptPlanner', () => {
expect(names).toContain('goToPlayer')
expect(names).toContain('llmInput')
expect(names).toContain('llmUserMessage')
expect(names).toContain('query')
expect(names).toContain('bot')
expect(names).toContain('mineflayer')
const mem = descriptors.find(d => d.name === 'mem')
expect(mem?.readonly).toBe(false)
@@ -1,4 +1,5 @@
import type { Action } from '../../libs/mineflayer/action'
import type { Mineflayer } from '../../libs/mineflayer/core'
import type { ActionInstruction } from '../action/types'
import type { BotEvent } from '../types'
@@ -6,6 +7,8 @@ import vm from 'node:vm'
import { inspect } from 'node:util'
import { createQueryRuntime } from './query-dsl'
interface JavaScriptPlannerOptions {
timeoutMs?: number
maxActionsPerTurn?: number
@@ -62,6 +65,8 @@ function toStructuredClone<T>(value: T): T {
export interface RuntimeGlobals {
event: BotEvent
snapshot: Record<string, unknown>
mineflayer?: Mineflayer | null
bot?: unknown
llmInput?: {
systemPrompt: string
userMessage: string
@@ -190,6 +195,9 @@ export class JavaScriptPlanner {
{ name: 'llmSystemPrompt', kind: 'string', readonly: true },
{ name: 'llmUserMessage', kind: 'string', readonly: true },
{ name: 'llmConversationHistory', kind: 'object', readonly: true },
{ name: 'query', kind: 'object', readonly: true },
{ name: 'bot', kind: 'object', readonly: true },
{ name: 'mineflayer', kind: 'object', readonly: true },
{ name: 'mem', kind: 'object', readonly: false },
{ name: 'lastRun', kind: 'object', readonly: true },
{ name: 'prevRun', kind: 'object', readonly: true },
@@ -211,6 +219,9 @@ export class JavaScriptPlanner {
llmSystemPrompt: globals.llmInput?.systemPrompt ?? '',
llmUserMessage: globals.llmInput?.userMessage ?? '',
llmConversationHistory: globals.llmInput?.conversationHistory ?? [],
query: globals.mineflayer ? createQueryRuntime(globals.mineflayer) : undefined,
bot: globals.bot ?? globals.mineflayer?.bot,
mineflayer: globals.mineflayer ?? null,
mem: this.sandbox.mem,
lastRun: this.sandbox.lastRun,
prevRun: this.sandbox.prevRun,
@@ -361,6 +372,7 @@ export class JavaScriptPlanner {
const snapshot = deepFreeze(toStructuredClone(globals.snapshot))
const event = deepFreeze(toStructuredClone(globals.event))
const llmInput = deepFreeze(toStructuredClone(globals.llmInput ?? null))
const query = globals.mineflayer ? createQueryRuntime(globals.mineflayer) : undefined
this.sandbox.prevRun = this.sandbox.lastRun ?? null
this.sandbox.snapshot = snapshot
@@ -377,6 +389,9 @@ export class JavaScriptPlanner {
this.sandbox.llmSystemPrompt = llmInput?.systemPrompt ?? ''
this.sandbox.llmUserMessage = llmInput?.userMessage ?? ''
this.sandbox.llmConversationHistory = llmInput?.conversationHistory ?? []
this.sandbox.query = query
this.sandbox.bot = globals.bot ?? globals.mineflayer?.bot ?? null
this.sandbox.mineflayer = globals.mineflayer ?? null
this.sandbox.lastRun = {
actions: run.executed,
logs: run.logs,
@@ -17,5 +17,7 @@ describe('generateBrainSystemPrompt', () => {
expect(prompt).toContain('Feedback Loop Guard')
expect(prompt).toContain('chat->feedback->chat')
expect(prompt).toContain('Query DSL')
expect(prompt).toContain('Heuristic composition examples')
})
})
@@ -110,7 +110,7 @@ You are an autonomous agent playing Minecraft.
6. **Planner Runtime**: Your script runs in a persistent JavaScript context with a timeout.
- Tool functions (listed below) execute actions and return results.
- Use \`await\` on tool calls when later logic depends on the result.
- Globals refreshed every turn: \`snapshot\`, \`self\`, \`environment\`, \`social\`, \`threat\`, \`attention\`, \`autonomy\`, \`event\`, \`now\`.
- Globals refreshed every turn: \`snapshot\`, \`self\`, \`environment\`, \`social\`, \`threat\`, \`attention\`, \`autonomy\`, \`event\`, \`now\`, \`query\`, \`bot\`, \`mineflayer\`.
- Persistent globals: \`mem\` (cross-turn memory), \`lastRun\` (this run), \`prevRun\` (previous run), \`lastAction\` (latest action result), \`log(...)\`.
- Last script outcome is also echoed in the next turn as \`[SCRIPT]\` context (return value, action stats, and logs).
- Maximum actions per turn: 5.
@@ -141,6 +141,32 @@ You cannot make up tools.
${toolsFormatted}
# Query DSL (Read-Only Runtime Introspection)
- Prefer \`query\` for environmental understanding. It is synchronous, composable, and side-effect free.
- Use direct \`bot\` / \`mineflayer\` access only when \`query\` or existing tools cannot express your need.
- Compose heuristic signals with chained filters, then act with tools.
Core query entrypoints:
- \`query.blocks()\`: nearby block records with chain methods (\`within\`, \`limit\`, \`isOre\`, \`whereName\`, \`sortByDistance\`, \`names\`, \`first\`, \`list\`)
- \`query.blockAt({ x, y, z })\`: single block snapshot at coordinate (or \`null\`)
- \`query.entities()\`: nearby entities with chain methods (\`within\`, \`limit\`, \`whereType\`, \`names\`, \`first\`, \`list\`)
- \`query.inventory()\`: inventory stacks (\`whereName\`, \`names\`, \`countByName\`, \`list\`)
- \`query.craftable()\`: craftable item names (supports \`uniq\`, \`whereIncludes\`, \`list\`)
Composable patterns:
- \`const ores = query.blocks().within(24).isOre().names().uniq().list()\`
- \`const nearestLog = query.blocks().whereName(["oak_log", "birch_log"]).first()\`
- \`const nearbyPlayers = query.entities().whereType("player").within(32).list()\`
- \`const inv = query.inventory().countByName(); const hasFood = (inv.bread ?? 0) > 0\`
- \`const craftableTools = query.craftable().whereIncludes("pickaxe").uniq().list()\`
Heuristic composition examples (encouraged):
- Build intent heuristics by combining signals before acting:
- \`const orePressure = query.blocks().within(20).isOre().list().length\`
- \`const hostileClose = query.entities().within(10).whereType(["zombie", "skeleton", "creeper"]).list().length > 0\`
- \`if (orePressure > 3 && !hostileClose) { /* mine-oriented plan */ }\`
- Verify assumptions with \`query\` first, then call action tools.
# Response Format
You must respond with JavaScript only (no markdown code fences).
Call tool functions directly.
@@ -154,6 +180,7 @@ Examples:
- \`const sent = await chat("HP=" + self.health); log(sent)\`
- \`const arrived = await goToPlayer({ player_name: "Alex", closeness: 2 }); if (!arrived) await chat("failed")\`
- \`if (self.health < 10) await consume({ item_name: "bread" })\`
- \`const target = query.blocks().isOre().within(24).first(); if (target) await goToCoordinate({ x: target.pos.x, y: target.pos.y, z: target.pos.z, closeness: 2 })\`
- \`await skip()\`
- \`const nav = await goToCoordinate({ x: 12, y: 64, z: -5, closeness: 2 }); expect(nav.ok, "navigation failed"); expectMoved(0.8); expectNear(2.5)\`
@@ -193,7 +220,7 @@ Common patterns:
- **Native Reasoning**: You can think before outputting your action.
- **Strict JavaScript Output**: Output ONLY executable JavaScript. Comments are possible but discouraged and will be ignored.
- **Handling Feedback**: When you perform an action, you will see a \`[FEEDBACK]\` message in the history later with the result. Use this to verify success.
- **Tool Choice**: If a dedicated tool exists for a task, use it.
- **Tool Choice**: For read/query tasks, use \`query\` first. For world mutations, use dedicated action tools. Use direct \`bot\` only when necessary.
- **Skip Rule**: If you call \`skip()\`, do not call any other tool in the same turn.
- **Chat Discipline**: Do not send proactive small-talk. Use \`chat\` only when replying to a player chat, reporting meaningful task progress/failure, or urgent safety status.
- **No Harness Replies**: Never treat \`[PERCEPTION]\`, \`[FEEDBACK]\`, or other system wrappers as players. Only reply with \`chat\` to actual player \`chat_message\` events.
@@ -0,0 +1,76 @@
import { Vec3 } from 'vec3'
import { describe, expect, it, vi } from 'vitest'
import { createQueryRuntime } from './query-dsl'
vi.mock('../../skills/world', () => ({
getCraftableItems: vi.fn(() => ['oak_planks', 'wooden_pickaxe', 'stone_pickaxe', 'stone_pickaxe']),
}))
function createMineflayerStub() {
const blocks = new Map<string, any>([
['1,64,0', { name: 'coal_ore', position: new Vec3(1, 64, 0), diggable: true, boundingBox: 'block', transparent: false }],
['2,64,0', { name: 'stone', position: new Vec3(2, 64, 0), diggable: true, boundingBox: 'block', transparent: false }],
['3,64,0', { name: 'coal_ore', position: new Vec3(3, 64, 0), diggable: true, boundingBox: 'block', transparent: false }],
['4,64,0', { name: 'ancient_debris', position: new Vec3(4, 64, 0), diggable: true, boundingBox: 'block', transparent: false }],
])
return {
bot: {
entity: {
id: 99,
position: new Vec3(0, 64, 0),
},
entities: {
1: { id: 1, name: 'zombie', type: 'mob', position: new Vec3(2, 64, 0) },
2: { id: 2, name: 'player', type: 'player', username: 'Alex', position: new Vec3(6, 64, 0) },
99: { id: 99, name: 'player', type: 'player', username: 'Self', position: new Vec3(0, 64, 0) },
},
inventory: {
items: () => [
{ name: 'bread', count: 3, slot: 10, displayName: 'Bread' },
{ name: 'cobblestone', count: 16, slot: 12, displayName: 'Cobblestone' },
{ name: 'bread', count: 2, slot: 13, displayName: 'Bread' },
],
},
findBlocks: () => Array.from(blocks.values()).map(block => block.position),
blockAt: (pos: Vec3) => blocks.get(`${pos.x},${pos.y},${pos.z}`) ?? null,
},
} as any
}
describe('query DSL', () => {
it('supports composable ore name heuristics', () => {
const query = createQueryRuntime(createMineflayerStub())
const names = query.blocks().within(24).isOre().names().uniq().list()
expect(names).toEqual(['coal_ore', 'ancient_debris'])
})
it('returns block snapshot at coordinate', () => {
const query = createQueryRuntime(createMineflayerStub())
const block = query.blockAt({ x: 2, y: 64, z: 0 })
expect(block?.name).toBe('stone')
expect(block?.pos).toEqual({ x: 2, y: 64, z: 0 })
})
it('filters entities by type and range', () => {
const query = createQueryRuntime(createMineflayerStub())
const zombies = query.entities().within(4).whereType('zombie').list()
expect(zombies).toHaveLength(1)
expect(zombies[0]?.name).toBe('zombie')
})
it('aggregates inventory counts', () => {
const query = createQueryRuntime(createMineflayerStub())
expect(query.inventory().countByName()).toEqual({
bread: 5,
cobblestone: 16,
})
})
it('supports craftable name filters', () => {
const query = createQueryRuntime(createMineflayerStub())
const pickaxes = query.craftable().whereIncludes('pickaxe').uniq().list()
expect(pickaxes).toEqual(['wooden_pickaxe', 'stone_pickaxe'])
})
})
@@ -0,0 +1,322 @@
import type { Entity } from 'prismarine-entity'
import type { Item } from 'prismarine-item'
import type { Mineflayer } from '../../libs/mineflayer'
import { Vec3 } from 'vec3'
import * as world from '../../skills/world'
interface BlockRecord {
name: string
pos: { x: number, y: number, z: number }
distance: number
diggable: boolean
solid: boolean
transparent: boolean
}
interface EntityRecord {
name: string
type: string
username?: string
pos: { x: number, y: number, z: number }
distance: number
}
interface InventoryRecord {
name: string
count: number
slot: number | null
displayName?: string
}
type NamePredicate = (value: string) => boolean
class NameQueryChain {
constructor(
private readonly values: string[],
private readonly predicates: NamePredicate[] = [],
private readonly dedupe = false,
) {}
public whereIncludes(fragment: string): NameQueryChain {
const needle = fragment.toLowerCase()
return new NameQueryChain(
this.values,
[...this.predicates, value => value.toLowerCase().includes(needle)],
this.dedupe,
)
}
public uniq(): NameQueryChain {
return new NameQueryChain(this.values, this.predicates, true)
}
public list(): string[] {
let result = this.values.filter(value => this.predicates.every(predicate => predicate(value)))
if (this.dedupe)
result = [...new Set(result)]
return result
}
}
interface BlockQueryState {
range: number
limit: number
predicates: Array<(block: BlockRecord) => boolean>
}
class BlockQueryChain {
constructor(
private readonly mineflayer: Mineflayer,
private readonly state: BlockQueryState = { range: 16, limit: 200, predicates: [] },
) {}
public within(range: number): BlockQueryChain {
return this.clone({ range: clamp(Math.floor(range), 1, 64) })
}
public limit(limit: number): BlockQueryChain {
return this.clone({ limit: clamp(Math.floor(limit), 1, 500) })
}
public isOre(): BlockQueryChain {
return this.clone({
predicates: [...this.state.predicates, block => isOreName(block.name)],
})
}
public whereName(nameOrNames: string | string[]): BlockQueryChain {
const names = new Set((Array.isArray(nameOrNames) ? nameOrNames : [nameOrNames]).map(name => name.toLowerCase()))
return this.clone({
predicates: [...this.state.predicates, block => names.has(block.name.toLowerCase())],
})
}
public sortByDistance(): BlockQueryChain {
return this
}
public names(): NameQueryChain {
return new NameQueryChain(this.list().map(block => block.name))
}
public first(): BlockRecord | null {
return this.list()[0] ?? null
}
public list(): BlockRecord[] {
const records = collectBlockRecords(this.mineflayer, this.state.range, this.state.limit)
.filter(block => this.state.predicates.every(predicate => predicate(block)))
.sort((a, b) => a.distance - b.distance)
return records.slice(0, this.state.limit)
}
private clone(patch: Partial<BlockQueryState>): BlockQueryChain {
return new BlockQueryChain(this.mineflayer, {
...this.state,
...patch,
})
}
}
interface EntityQueryState {
range: number
limit: number
predicates: Array<(entity: EntityRecord) => boolean>
}
class EntityQueryChain {
constructor(
private readonly mineflayer: Mineflayer,
private readonly state: EntityQueryState = { range: 16, limit: 200, predicates: [] },
) {}
public within(range: number): EntityQueryChain {
return this.clone({ range: clamp(Math.floor(range), 1, 128) })
}
public limit(limit: number): EntityQueryChain {
return this.clone({ limit: clamp(Math.floor(limit), 1, 500) })
}
public whereType(typeOrTypes: string | string[]): EntityQueryChain {
const types = new Set((Array.isArray(typeOrTypes) ? typeOrTypes : [typeOrTypes]).map(type => type.toLowerCase()))
return this.clone({
predicates: [...this.state.predicates, entity => types.has((entity.name || entity.type).toLowerCase())],
})
}
public names(): NameQueryChain {
return new NameQueryChain(this.list().map(entity => entity.name))
}
public first(): EntityRecord | null {
return this.list()[0] ?? null
}
public list(): EntityRecord[] {
const records = collectEntityRecords(this.mineflayer, this.state.range)
.filter(entity => this.state.predicates.every(predicate => predicate(entity)))
.sort((a, b) => a.distance - b.distance)
return records.slice(0, this.state.limit)
}
private clone(patch: Partial<EntityQueryState>): EntityQueryChain {
return new EntityQueryChain(this.mineflayer, {
...this.state,
...patch,
})
}
}
interface InventoryQueryState {
predicates: Array<(item: InventoryRecord) => boolean>
}
class InventoryQueryChain {
constructor(
private readonly mineflayer: Mineflayer,
private readonly state: InventoryQueryState = { predicates: [] },
) {}
public whereName(nameOrNames: string | string[]): InventoryQueryChain {
const names = new Set((Array.isArray(nameOrNames) ? nameOrNames : [nameOrNames]).map(name => name.toLowerCase()))
return this.clone({
predicates: [...this.state.predicates, item => names.has(item.name.toLowerCase())],
})
}
public names(): NameQueryChain {
return new NameQueryChain(this.list().map(item => item.name))
}
public countByName(): Record<string, number> {
return this.list().reduce((counts, item) => {
counts[item.name] = (counts[item.name] ?? 0) + item.count
return counts
}, {} as Record<string, number>)
}
public list(): InventoryRecord[] {
return this.mineflayer.bot.inventory
.items()
.map((item): InventoryRecord | null => item ? toInventoryRecord(item) : null)
.filter((item): item is InventoryRecord => item !== null)
.filter(item => this.state.predicates.every(predicate => predicate(item)))
}
private clone(patch: Partial<InventoryQueryState>): InventoryQueryChain {
return new InventoryQueryChain(this.mineflayer, {
...this.state,
...patch,
})
}
}
function toInventoryRecord(item: Item): InventoryRecord {
return {
name: item.name,
count: item.count,
slot: typeof item.slot === 'number' ? item.slot : null,
displayName: item.displayName,
}
}
function toPos(pos: { x: number, y: number, z: number }): { x: number, y: number, z: number } {
return { x: pos.x, y: pos.y, z: pos.z }
}
function distanceBetween(a: { x: number, y: number, z: number }, b: { x: number, y: number, z: number }): number {
const dx = a.x - b.x
const dy = a.y - b.y
const dz = a.z - b.z
return Math.sqrt(dx * dx + dy * dy + dz * dz)
}
function collectBlockRecords(mineflayer: Mineflayer, range: number, limit: number): BlockRecord[] {
const positions = mineflayer.bot.findBlocks({
matching: block => block !== null && block.name !== 'air',
maxDistance: range,
count: clamp(limit * 8, limit, 5000),
})
const selfPos = mineflayer.bot.entity.position
return positions
.map((pos) => {
const block = mineflayer.bot.blockAt(pos)
if (!block || block.name === 'air')
return null
const solid = block.boundingBox === 'block'
const transparentRaw = (block as any).transparent
return {
name: block.name,
pos: toPos(block.position),
distance: distanceBetween(selfPos, block.position),
diggable: Boolean(block.diggable),
solid,
transparent: typeof transparentRaw === 'boolean' ? transparentRaw : !solid,
} satisfies BlockRecord
})
.filter((block): block is BlockRecord => block !== null)
}
function collectEntityRecords(mineflayer: Mineflayer, range: number): EntityRecord[] {
const entities = Object.values(mineflayer.bot.entities)
const selfPos = mineflayer.bot.entity.position
const selfId = mineflayer.bot.entity.id
return entities
.map((entity): EntityRecord | null => {
if (!entity || !entity.position || entity.id === selfId)
return null
const distance = distanceBetween(selfPos, entity.position)
if (distance > range)
return null
return {
name: entity.name ?? 'unknown',
type: entity.type,
username: (entity as Entity).username,
pos: toPos(entity.position),
distance,
}
})
.filter((entity): entity is EntityRecord => entity !== null)
}
function isOreName(name: string): boolean {
return name.endsWith('_ore') || name === 'ancient_debris'
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value))
}
export function createQueryRuntime(mineflayer: Mineflayer) {
return {
blocks: () => new BlockQueryChain(mineflayer),
blockAt: ({ x, y, z }: { x: number, y: number, z: number }) => {
const block = mineflayer.bot.blockAt(new Vec3(Math.floor(x), Math.floor(y), Math.floor(z)))
if (!block)
return null
const solid = block.boundingBox === 'block'
const transparentRaw = (block as any).transparent
return {
name: block.name,
pos: toPos(block.position),
distance: distanceBetween(mineflayer.bot.entity.position, block.position),
diggable: Boolean(block.diggable),
solid,
transparent: typeof transparentRaw === 'boolean' ? transparentRaw : !solid,
} satisfies BlockRecord
},
entities: () => new EntityQueryChain(mineflayer),
inventory: () => new InventoryQueryChain(mineflayer),
craftable: () => new NameQueryChain(world.getCraftableItems(mineflayer)),
}
}