diff --git a/services/minecraft/src/cognitive/action/llm-actions.ts b/services/minecraft/src/cognitive/action/llm-actions.ts index 5bf0ab201..c45794118 100644 --- a/services/minecraft/src/cognitive/action/llm-actions.ts +++ b/services/minecraft/src/cognitive/action/llm-actions.ts @@ -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.', diff --git a/services/minecraft/src/cognitive/conscious/brain.ts b/services/minecraft/src/cognitive/conscious/brain.ts index e7ed8bdb6..f359d0772 100644 --- a/services/minecraft/src/cognitive/conscious/brain.ts +++ b/services/minecraft/src/cognitive/conscious/brain.ts @@ -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('conscious:signal:*', (event: TracedEvent) => { @@ -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, + 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, + 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, + 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') } diff --git a/services/minecraft/src/cognitive/conscious/js-planner.test.ts b/services/minecraft/src/cognitive/conscious/js-planner.test.ts index 6c23b16d2..4b6bc574f 100644 --- a/services/minecraft/src/cognitive/conscious/js-planner.test.ts +++ b/services/minecraft/src/cognitive/conscious/js-planner.test.ts @@ -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) diff --git a/services/minecraft/src/cognitive/conscious/js-planner.ts b/services/minecraft/src/cognitive/conscious/js-planner.ts index a39e7fd96..c05e7e062 100644 --- a/services/minecraft/src/cognitive/conscious/js-planner.ts +++ b/services/minecraft/src/cognitive/conscious/js-planner.ts @@ -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(value: T): T { export interface RuntimeGlobals { event: BotEvent snapshot: Record + 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, diff --git a/services/minecraft/src/cognitive/conscious/prompts/brain-prompt.test.ts b/services/minecraft/src/cognitive/conscious/prompts/brain-prompt.test.ts index 42c393958..aa3bf370d 100644 --- a/services/minecraft/src/cognitive/conscious/prompts/brain-prompt.test.ts +++ b/services/minecraft/src/cognitive/conscious/prompts/brain-prompt.test.ts @@ -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') }) }) diff --git a/services/minecraft/src/cognitive/conscious/prompts/brain-prompt.ts b/services/minecraft/src/cognitive/conscious/prompts/brain-prompt.ts index 0aec30b78..8ce7799cb 100644 --- a/services/minecraft/src/cognitive/conscious/prompts/brain-prompt.ts +++ b/services/minecraft/src/cognitive/conscious/prompts/brain-prompt.ts @@ -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. diff --git a/services/minecraft/src/cognitive/conscious/query-dsl.test.ts b/services/minecraft/src/cognitive/conscious/query-dsl.test.ts new file mode 100644 index 000000000..df425d6bd --- /dev/null +++ b/services/minecraft/src/cognitive/conscious/query-dsl.test.ts @@ -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([ + ['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']) + }) +}) diff --git a/services/minecraft/src/cognitive/conscious/query-dsl.ts b/services/minecraft/src/cognitive/conscious/query-dsl.ts new file mode 100644 index 000000000..03a4a765c --- /dev/null +++ b/services/minecraft/src/cognitive/conscious/query-dsl.ts @@ -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): 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): 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 { + return this.list().reduce((counts, item) => { + counts[item.name] = (counts[item.name] ?? 0) + item.count + return counts + }, {} as Record) + } + + 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): 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)), + } +}