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:
@@ -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)),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user