feat(minecraft): add map() query DSL for ASCII spatial awareness and terrain visualization
Adds `query.map(options?)` to Brain's query runtime, returning ASCII top-down or cross-section maps with configurable radius (1-32), entity/elevation overlays, and yLevel slicing. Symbols include ground, stone, water, lava, trees, ores, chests, players, and mobs. Enables spatial navigation, resource finding, and underground exploration.
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
import { Vec3 } from 'vec3'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { renderMap } from './map-renderer'
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
function createBotStub(options: {
|
||||
position?: Vec3
|
||||
blocks?: Map<string, { name: string }>
|
||||
entities?: Record<number, any>
|
||||
} = {}) {
|
||||
const pos = options.position ?? new Vec3(0, 64, 0)
|
||||
const blocks = options.blocks ?? new Map()
|
||||
const entities = options.entities ?? {}
|
||||
|
||||
// Add bot entity to entities
|
||||
const botEntity = {
|
||||
id: 99,
|
||||
position: pos,
|
||||
type: 'player',
|
||||
username: 'Bot',
|
||||
name: 'player',
|
||||
}
|
||||
entities[99] = botEntity
|
||||
|
||||
return {
|
||||
entity: botEntity,
|
||||
entities,
|
||||
blockAt: (queryPos: Vec3) => {
|
||||
const key = `${Math.floor(queryPos.x)},${Math.floor(queryPos.y)},${Math.floor(queryPos.z)}`
|
||||
const found = blocks.get(key)
|
||||
if (found)
|
||||
return { name: found.name, position: new Vec3(Math.floor(queryPos.x), Math.floor(queryPos.y), Math.floor(queryPos.z)), diggable: true, boundingBox: 'block' }
|
||||
return { name: 'air', position: new Vec3(Math.floor(queryPos.x), Math.floor(queryPos.y), Math.floor(queryPos.z)), diggable: false, boundingBox: 'empty' }
|
||||
},
|
||||
} as any
|
||||
}
|
||||
|
||||
function buildBlockMap(entries: Array<[number, number, number, string]>): Map<string, { name: string }> {
|
||||
const map = new Map<string, { name: string }>()
|
||||
for (const [x, y, z, name] of entries) {
|
||||
map.set(`${x},${y},${z}`, { name })
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
// ─── Tests ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('map-renderer', () => {
|
||||
describe('renderMap top-down', () => {
|
||||
it('calls blockAt with Vec3-compatible positions', () => {
|
||||
const pos = new Vec3(0, 64, 0)
|
||||
const bot = {
|
||||
entity: {
|
||||
id: 99,
|
||||
position: pos,
|
||||
type: 'player',
|
||||
username: 'Bot',
|
||||
name: 'player',
|
||||
},
|
||||
entities: {},
|
||||
blockAt: (queryPos: Vec3) => {
|
||||
queryPos.floored()
|
||||
return { name: 'air', position: pos, diggable: false, boundingBox: 'empty' }
|
||||
},
|
||||
} as any
|
||||
|
||||
expect(() => renderMap(bot, { radius: 1, showEntities: false, showElevation: false })).not.toThrow()
|
||||
})
|
||||
|
||||
it('renders a basic map with the bot at center', () => {
|
||||
const bot = createBotStub()
|
||||
const result = renderMap(bot, { radius: 3, showElevation: false })
|
||||
|
||||
expect(result.view).toBe('top-down')
|
||||
expect(result.center).toEqual({ x: 0, y: 64, z: 0 })
|
||||
expect(result.radius).toBe(3)
|
||||
expect(result.map).toContain('@') // Bot marker
|
||||
})
|
||||
|
||||
it('classifies ground blocks correctly', () => {
|
||||
const blocks = buildBlockMap([
|
||||
[0, 64, 0, 'grass_block'],
|
||||
[1, 64, 0, 'grass_block'],
|
||||
[-1, 64, 0, 'dirt'],
|
||||
])
|
||||
const bot = createBotStub({ blocks })
|
||||
const result = renderMap(bot, { radius: 2, showEntities: false, showElevation: false })
|
||||
|
||||
// Ground blocks should appear as '.'
|
||||
expect(result.map).toContain('.')
|
||||
expect(result.legend).toContain('grass/dirt')
|
||||
})
|
||||
|
||||
it('shows water blocks as ~', () => {
|
||||
const blocks = buildBlockMap([
|
||||
[2, 64, 0, 'water'],
|
||||
[3, 64, 0, 'water'],
|
||||
])
|
||||
const bot = createBotStub({ blocks })
|
||||
const result = renderMap(bot, { radius: 4, showEntities: false, showElevation: false })
|
||||
|
||||
expect(result.map).toContain('~')
|
||||
expect(result.legend).toContain('water')
|
||||
})
|
||||
|
||||
it('shows tree trunks as T', () => {
|
||||
const blocks = buildBlockMap([
|
||||
[3, 64, 0, 'oak_log'],
|
||||
[3, 65, 0, 'oak_log'],
|
||||
[3, 66, 0, 'oak_log'],
|
||||
[3, 67, 0, 'oak_leaves'],
|
||||
])
|
||||
const bot = createBotStub({ blocks })
|
||||
const result = renderMap(bot, { radius: 4, showEntities: false, showElevation: false })
|
||||
|
||||
// Top-down view should show the topmost block — leaves on top of the log column
|
||||
// But the log at surface level should be visible if it's the surface block
|
||||
expect(result.legend).toMatch(/tree trunk|leaves/)
|
||||
})
|
||||
|
||||
it('shows ores as $', () => {
|
||||
const blocks = buildBlockMap([
|
||||
[1, 64, 0, 'iron_ore'],
|
||||
[2, 64, 0, 'diamond_ore'],
|
||||
])
|
||||
const bot = createBotStub({ blocks })
|
||||
const result = renderMap(bot, { radius: 3, showEntities: false, showElevation: false })
|
||||
|
||||
expect(result.map).toContain('$')
|
||||
expect(result.legend).toContain('ore')
|
||||
})
|
||||
|
||||
it('shows interactive blocks as !', () => {
|
||||
const blocks = buildBlockMap([
|
||||
[1, 64, 0, 'crafting_table'],
|
||||
[-1, 64, 0, 'chest'],
|
||||
])
|
||||
const bot = createBotStub({ blocks })
|
||||
const result = renderMap(bot, { radius: 2, showEntities: false, showElevation: false })
|
||||
|
||||
expect(result.map).toContain('!')
|
||||
expect(result.legend).toContain('chest/furnace/table')
|
||||
})
|
||||
|
||||
it('overlays entities on the map', () => {
|
||||
const entities: Record<number, any> = {
|
||||
1: { id: 1, name: 'zombie', type: 'mob', position: new Vec3(2, 64, 0) },
|
||||
2: { id: 2, name: 'cow', type: 'mob', position: new Vec3(-1, 64, 1) },
|
||||
3: { id: 3, name: 'player', type: 'player', username: 'Alex', position: new Vec3(0, 64, -2) },
|
||||
}
|
||||
const bot = createBotStub({ entities })
|
||||
const result = renderMap(bot, { radius: 4, showElevation: false })
|
||||
|
||||
expect(result.map).toContain('M') // hostile mob
|
||||
expect(result.map).toContain('A') // passive mob (animal)
|
||||
expect(result.map).toContain('P') // player
|
||||
expect(result.map).toContain('@') // self
|
||||
expect(result.map).toContain('zombie')
|
||||
expect(result.map).toContain('Alex')
|
||||
})
|
||||
|
||||
it('respects radius clamping', () => {
|
||||
const bot = createBotStub()
|
||||
const result = renderMap(bot, { radius: 100 })
|
||||
expect(result.radius).toBe(32) // MAX_RADIUS
|
||||
|
||||
const result2 = renderMap(bot, { radius: 0 })
|
||||
expect(result2.radius).toBe(1) // min 1
|
||||
})
|
||||
|
||||
it('shows compass directions', () => {
|
||||
const bot = createBotStub()
|
||||
const result = renderMap(bot, { radius: 3 })
|
||||
|
||||
expect(result.map).toContain('N(-Z)')
|
||||
expect(result.map).toContain('S(+Z)')
|
||||
expect(result.map).toContain('W(-X)')
|
||||
expect(result.map).toContain('E(+X)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderMap cross-section', () => {
|
||||
it('renders a cross-section at the bot Y level', () => {
|
||||
const blocks = buildBlockMap([
|
||||
[0, 63, 0, 'stone'],
|
||||
[0, 64, 0, 'grass_block'],
|
||||
[1, 63, 0, 'stone'],
|
||||
[1, 64, 0, 'grass_block'],
|
||||
[-1, 63, 0, 'stone'],
|
||||
[-1, 64, 0, 'dirt'],
|
||||
])
|
||||
const bot = createBotStub({ blocks })
|
||||
const result = renderMap(bot, { radius: 3, view: 'cross-section' })
|
||||
|
||||
expect(result.view).toBe('cross-section')
|
||||
expect(result.map).toContain('Cross-section')
|
||||
expect(result.map).toContain('@') // Bot marker
|
||||
})
|
||||
})
|
||||
|
||||
describe('legend', () => {
|
||||
it('only includes categories present on the map', () => {
|
||||
// Map with only ground and water
|
||||
const blocks = buildBlockMap([
|
||||
[0, 64, 0, 'grass_block'],
|
||||
[1, 64, 0, 'water'],
|
||||
])
|
||||
const bot = createBotStub({ blocks })
|
||||
const result = renderMap(bot, { radius: 2, showEntities: false, showElevation: false })
|
||||
|
||||
expect(result.legend).toContain('grass/dirt')
|
||||
expect(result.legend).toContain('water')
|
||||
// Should NOT contain categories not on the map
|
||||
expect(result.legend).not.toContain('lava')
|
||||
expect(result.legend).not.toContain('ore')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,543 @@
|
||||
import type { Bot } from 'mineflayer'
|
||||
|
||||
import { Vec3 } from 'vec3'
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────
|
||||
|
||||
export type MapViewType = 'top-down' | 'cross-section'
|
||||
|
||||
export interface MapOptions {
|
||||
/** Radius in blocks from center (default: 16, max: 32) */
|
||||
radius?: number
|
||||
/** View type (default: 'top-down') */
|
||||
view?: MapViewType
|
||||
/** Whether to show entities on the map (default: true) */
|
||||
showEntities?: boolean
|
||||
/** Whether to show elevation numbers (default: true) */
|
||||
showElevation?: boolean
|
||||
/** Y level for cross-section view (default: bot's Y) */
|
||||
yLevel?: number
|
||||
}
|
||||
|
||||
export interface MapResult {
|
||||
/** The rendered ASCII map string */
|
||||
map: string
|
||||
/** Legend explaining the symbols used */
|
||||
legend: string
|
||||
/** Center position of the map */
|
||||
center: { x: number, y: number, z: number }
|
||||
/** Radius used */
|
||||
radius: number
|
||||
/** View type used */
|
||||
view: MapViewType
|
||||
}
|
||||
|
||||
// ─── Block Category Classification ──────────────────────────────────
|
||||
//
|
||||
// We collapse hundreds of block names into a small set of semantic
|
||||
// categories. Each category gets a single ASCII symbol. The goal is
|
||||
// maximum information density with minimum noise.
|
||||
|
||||
type BlockCategory
|
||||
= | 'air'
|
||||
| 'ground'
|
||||
| 'stone'
|
||||
| 'sand'
|
||||
| 'water'
|
||||
| 'lava'
|
||||
| 'log'
|
||||
| 'leaves'
|
||||
| 'ore'
|
||||
| 'crop'
|
||||
| 'path'
|
||||
| 'wood_structure'
|
||||
| 'stone_structure'
|
||||
| 'interactive'
|
||||
| 'danger'
|
||||
| 'snow'
|
||||
| 'ice'
|
||||
| 'glass'
|
||||
| 'unknown'
|
||||
|
||||
// NOTICE: Symbol choices are intentionally single-character ASCII that
|
||||
// are visually distinct in monospace fonts and semantically suggestive.
|
||||
const CATEGORY_SYMBOLS: Record<BlockCategory, string> = {
|
||||
air: ' ',
|
||||
ground: '.',
|
||||
stone: '#',
|
||||
sand: ':',
|
||||
water: '~',
|
||||
lava: '%',
|
||||
log: 'T',
|
||||
leaves: '*',
|
||||
ore: '$',
|
||||
crop: ';',
|
||||
path: '_',
|
||||
wood_structure: '=',
|
||||
stone_structure: 'B',
|
||||
interactive: '!',
|
||||
danger: 'X',
|
||||
snow: '\'',
|
||||
ice: '-',
|
||||
glass: 'o',
|
||||
unknown: '?',
|
||||
}
|
||||
|
||||
const CATEGORY_LEGEND: Record<BlockCategory, string> = {
|
||||
air: 'air/void',
|
||||
ground: 'grass/dirt',
|
||||
stone: 'stone/rock',
|
||||
sand: 'sand/gravel',
|
||||
water: 'water',
|
||||
lava: 'lava',
|
||||
log: 'tree trunk',
|
||||
leaves: 'leaves',
|
||||
ore: 'ore',
|
||||
crop: 'crops/farmland',
|
||||
path: 'path/road',
|
||||
wood_structure: 'wood building',
|
||||
stone_structure: 'stone building',
|
||||
interactive: 'chest/furnace/table',
|
||||
danger: 'danger (cactus/fire/magma)',
|
||||
snow: 'snow',
|
||||
ice: 'ice',
|
||||
glass: 'glass',
|
||||
unknown: 'unknown',
|
||||
}
|
||||
|
||||
// Entity symbols placed on top of terrain
|
||||
const ENTITY_SYMBOLS = {
|
||||
self: '@',
|
||||
player: 'P',
|
||||
hostile: 'M',
|
||||
passive: 'A',
|
||||
neutral: 'N',
|
||||
item: 'i',
|
||||
} as const
|
||||
|
||||
// ─── Block Name → Category Mapping ──────────────────────────────────
|
||||
|
||||
const BLOCK_PATTERNS: Array<[RegExp | string[], BlockCategory]> = [
|
||||
// Interactive (highest priority — these are actionable)
|
||||
[['crafting_table', 'furnace', 'blast_furnace', 'smoker', 'chest', 'trapped_chest', 'ender_chest', 'barrel', 'anvil', 'enchanting_table', 'brewing_stand', 'grindstone', 'stonecutter', 'loom', 'cartography_table', 'smithing_table', 'composter', 'lectern', 'bed'], 'interactive'],
|
||||
|
||||
// Danger
|
||||
[['lava', 'flowing_lava'], 'lava'],
|
||||
[['cactus', 'fire', 'soul_fire', 'magma_block', 'sweet_berry_bush', 'wither_rose', 'pointed_dripstone'], 'danger'],
|
||||
|
||||
// Water
|
||||
[['water', 'flowing_water', 'bubble_column', 'kelp', 'kelp_plant', 'seagrass', 'tall_seagrass'], 'water'],
|
||||
|
||||
// Ores
|
||||
[/_(ore|_ore)$/, 'ore'],
|
||||
[['ancient_debris', 'raw_iron_block', 'raw_gold_block', 'raw_copper_block'], 'ore'],
|
||||
|
||||
// Trees
|
||||
[/_log$/, 'log'],
|
||||
[['mushroom_stem'], 'log'],
|
||||
[/_leaves$/, 'leaves'],
|
||||
[['mangrove_roots', 'muddy_mangrove_roots'], 'log'],
|
||||
|
||||
// Crops / farming
|
||||
[['farmland', 'wheat', 'carrots', 'potatoes', 'beetroots', 'melon', 'pumpkin', 'melon_stem', 'pumpkin_stem', 'sugar_cane', 'bamboo', 'cocoa', 'nether_wart', 'sweet_berries', 'cave_vines', 'cave_vines_plant'], 'crop'],
|
||||
|
||||
// Paths
|
||||
[['dirt_path', 'grass_path'], 'path'],
|
||||
[/_slab$/, 'path'],
|
||||
|
||||
// Wood structures (planks, fences, doors, stairs)
|
||||
[/_planks$/, 'wood_structure'],
|
||||
[/_fence$/, 'wood_structure'],
|
||||
[/_door$/, 'wood_structure'],
|
||||
[/_stairs$/, 'wood_structure'],
|
||||
[/_trapdoor$/, 'wood_structure'],
|
||||
[/_wall$/, 'stone_structure'],
|
||||
|
||||
// Stone structures
|
||||
[/_bricks?$/, 'stone_structure'],
|
||||
[['cobblestone', 'mossy_cobblestone', 'smooth_stone', 'polished_andesite', 'polished_diorite', 'polished_granite', 'cut_sandstone', 'smooth_sandstone'], 'stone_structure'],
|
||||
|
||||
// Ice / snow
|
||||
[['snow', 'snow_block', 'powder_snow'], 'snow'],
|
||||
[['ice', 'packed_ice', 'blue_ice', 'frosted_ice'], 'ice'],
|
||||
|
||||
// Glass
|
||||
[/glass/, 'glass'],
|
||||
|
||||
// Sand / gravel
|
||||
[['sand', 'red_sand', 'gravel', 'soul_sand', 'soul_soil', 'clay'], 'sand'],
|
||||
|
||||
// Stone (natural)
|
||||
[['stone', 'deepslate', 'andesite', 'diorite', 'granite', 'tuff', 'calcite', 'dripstone_block', 'basalt', 'smooth_basalt', 'blackstone', 'netherrack', 'end_stone', 'obsidian', 'crying_obsidian', 'bedrock', 'terracotta'], 'stone'],
|
||||
[/_terracotta$/, 'stone'],
|
||||
|
||||
// Ground (dirt, grass, etc.)
|
||||
[['grass_block', 'dirt', 'coarse_dirt', 'rooted_dirt', 'podzol', 'mycelium', 'mud', 'packed_mud', 'moss_block', 'muddy_mangrove_roots'], 'ground'],
|
||||
]
|
||||
|
||||
function classifyBlock(blockName: string): BlockCategory {
|
||||
if (blockName === 'air' || blockName === 'cave_air' || blockName === 'void_air')
|
||||
return 'air'
|
||||
|
||||
for (const [pattern, category] of BLOCK_PATTERNS) {
|
||||
if (pattern instanceof RegExp) {
|
||||
if (pattern.test(blockName))
|
||||
return category
|
||||
}
|
||||
else if (pattern.includes(blockName)) {
|
||||
return category
|
||||
}
|
||||
}
|
||||
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
// ─── Entity Classification ──────────────────────────────────────────
|
||||
|
||||
const HOSTILE_MOBS = new Set([
|
||||
'zombie',
|
||||
'skeleton',
|
||||
'creeper',
|
||||
'spider',
|
||||
'cave_spider',
|
||||
'enderman',
|
||||
'witch',
|
||||
'slime',
|
||||
'magma_cube',
|
||||
'blaze',
|
||||
'ghast',
|
||||
'wither_skeleton',
|
||||
'phantom',
|
||||
'drowned',
|
||||
'husk',
|
||||
'stray',
|
||||
'pillager',
|
||||
'vindicator',
|
||||
'ravager',
|
||||
'vex',
|
||||
'evoker',
|
||||
'guardian',
|
||||
'elder_guardian',
|
||||
'hoglin',
|
||||
'piglin_brute',
|
||||
'warden',
|
||||
'breeze',
|
||||
])
|
||||
|
||||
const PASSIVE_MOBS = new Set([
|
||||
'cow',
|
||||
'sheep',
|
||||
'pig',
|
||||
'chicken',
|
||||
'horse',
|
||||
'donkey',
|
||||
'mule',
|
||||
'rabbit',
|
||||
'cat',
|
||||
'ocelot',
|
||||
'parrot',
|
||||
'fox',
|
||||
'turtle',
|
||||
'axolotl',
|
||||
'glow_squid',
|
||||
'squid',
|
||||
'bat',
|
||||
'cod',
|
||||
'salmon',
|
||||
'tropical_fish',
|
||||
'pufferfish',
|
||||
'mooshroom',
|
||||
'strider',
|
||||
'frog',
|
||||
'tadpole',
|
||||
'allay',
|
||||
'sniffer',
|
||||
'camel',
|
||||
'armadillo',
|
||||
'villager',
|
||||
'wandering_trader',
|
||||
])
|
||||
|
||||
function classifyEntity(entityName: string): keyof typeof ENTITY_SYMBOLS {
|
||||
if (HOSTILE_MOBS.has(entityName))
|
||||
return 'hostile'
|
||||
if (PASSIVE_MOBS.has(entityName))
|
||||
return 'passive'
|
||||
return 'neutral'
|
||||
}
|
||||
|
||||
// ─── Surface Finding ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Find the topmost non-air block at (x, z) by scanning downward from
|
||||
* a reasonable height. Returns the block name and Y level, or null if
|
||||
* the column is entirely air (unloaded chunk).
|
||||
*/
|
||||
function findSurfaceBlock(
|
||||
bot: Bot,
|
||||
x: number,
|
||||
z: number,
|
||||
startY: number,
|
||||
): { name: string, y: number } | null {
|
||||
// Scan from startY + 16 down to startY - 32 to handle hills and valleys
|
||||
const top = Math.min(startY + 16, 319)
|
||||
const bottom = Math.max(startY - 48, -64)
|
||||
|
||||
for (let y = top; y >= bottom; y--) {
|
||||
const block = bot.blockAt(new Vec3(x, y, z))
|
||||
if (block && block.name !== 'air' && block.name !== 'cave_air' && block.name !== 'void_air') {
|
||||
return { name: block.name, y }
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// ─── Renderers ──────────────────────────────────────────────────────
|
||||
|
||||
function renderTopDown(bot: Bot, options: Required<MapOptions>): MapResult {
|
||||
const center = bot.entity.position
|
||||
const cx = Math.floor(center.x)
|
||||
const cy = Math.floor(center.y)
|
||||
const cz = Math.floor(center.z)
|
||||
const r = options.radius
|
||||
|
||||
// Build the grid: each cell is [symbol, elevation_delta]
|
||||
const size = r * 2 + 1
|
||||
const grid: string[][] = Array.from({ length: size }, () => Array.from({ length: size }, () => ' '))
|
||||
const elevations: (number | null)[][] = Array.from({ length: size }, () => Array.from({ length: size }, () => null))
|
||||
const usedCategories = new Set<BlockCategory>()
|
||||
|
||||
for (let dz = -r; dz <= r; dz++) {
|
||||
for (let dx = -r; dx <= r; dx++) {
|
||||
const wx = cx + dx
|
||||
const wz = cz + dz
|
||||
const gx = dx + r
|
||||
const gz = dz + r
|
||||
|
||||
const surface = findSurfaceBlock(bot, wx, wz, cy)
|
||||
if (!surface) {
|
||||
grid[gz][gx] = ' '
|
||||
continue
|
||||
}
|
||||
|
||||
const category = classifyBlock(surface.name)
|
||||
usedCategories.add(category)
|
||||
grid[gz][gx] = CATEGORY_SYMBOLS[category]
|
||||
elevations[gz][gx] = surface.y - cy
|
||||
}
|
||||
}
|
||||
|
||||
// Overlay entities
|
||||
const entityOverlays: Array<{ gx: number, gz: number, symbol: string, label: string }> = []
|
||||
|
||||
if (options.showEntities) {
|
||||
// Bot itself
|
||||
grid[r][r] = ENTITY_SYMBOLS.self
|
||||
entityOverlays.push({ gx: r, gz: r, symbol: ENTITY_SYMBOLS.self, label: 'You' })
|
||||
|
||||
// Other entities
|
||||
for (const entity of Object.values(bot.entities)) {
|
||||
if (entity === bot.entity)
|
||||
continue
|
||||
|
||||
const ex = Math.floor(entity.position.x) - cx
|
||||
const ez = Math.floor(entity.position.z) - cz
|
||||
|
||||
if (Math.abs(ex) > r || Math.abs(ez) > r)
|
||||
continue
|
||||
|
||||
const gx = ex + r
|
||||
const gz = ez + r
|
||||
|
||||
if (entity.type === 'player') {
|
||||
grid[gz][gx] = ENTITY_SYMBOLS.player
|
||||
entityOverlays.push({ gx, gz, symbol: ENTITY_SYMBOLS.player, label: entity.username ?? 'player' })
|
||||
}
|
||||
else if (entity.type === 'mob') {
|
||||
const kind = classifyEntity(entity.name ?? '')
|
||||
grid[gz][gx] = ENTITY_SYMBOLS[kind]
|
||||
entityOverlays.push({ gx, gz, symbol: ENTITY_SYMBOLS[kind], label: entity.name ?? 'mob' })
|
||||
}
|
||||
else if (entity.type === 'object' && entity.name === 'item') {
|
||||
grid[gz][gx] = ENTITY_SYMBOLS.item
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build the map string
|
||||
const lines: string[] = []
|
||||
|
||||
// Header with compass and coordinates
|
||||
lines.push(` Top-down view centered at (${cx}, ${cy}, ${cz}) | radius: ${r}`)
|
||||
lines.push(` N(-Z)`)
|
||||
lines.push(` |`)
|
||||
|
||||
// Column header (X axis markers at edges)
|
||||
const xLeft = cx - r
|
||||
const xRight = cx + r
|
||||
lines.push(`W(-X)${'─'.repeat(size + 2)}E(+X) [${xLeft}..${xRight}]`)
|
||||
|
||||
for (let gz = 0; gz < size; gz++) {
|
||||
const row = grid[gz].join('')
|
||||
// Add elevation markers on the right side for every 4th row
|
||||
if (options.showElevation && gz % 4 === 0) {
|
||||
const elevSamples = elevations[gz]
|
||||
.filter((e): e is number => e !== null)
|
||||
if (elevSamples.length > 0) {
|
||||
const minE = Math.min(...elevSamples)
|
||||
const maxE = Math.max(...elevSamples)
|
||||
lines.push(` |${row}| dy:${minE > 0 ? '+' : ''}${minE}..${maxE > 0 ? '+' : ''}${maxE}`)
|
||||
}
|
||||
else {
|
||||
lines.push(` |${row}|`)
|
||||
}
|
||||
}
|
||||
else {
|
||||
lines.push(` |${row}|`)
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(` ${'─'.repeat(size + 2)}`)
|
||||
lines.push(` |`)
|
||||
lines.push(` S(+Z) [Z: ${cz - r}..${cz + r}]`)
|
||||
|
||||
// Entity list
|
||||
if (entityOverlays.length > 1) {
|
||||
lines.push('')
|
||||
lines.push('Entities:')
|
||||
for (const e of entityOverlays) {
|
||||
if (e.symbol === ENTITY_SYMBOLS.self)
|
||||
continue
|
||||
const dx = e.gx - r
|
||||
const dz = e.gz - r
|
||||
lines.push(` ${e.symbol} ${e.label} (${dx > 0 ? '+' : ''}${dx}, ${dz > 0 ? '+' : ''}${dz})`)
|
||||
}
|
||||
}
|
||||
|
||||
// Legend (only show categories that appear on this map)
|
||||
const legendParts: string[] = []
|
||||
for (const cat of usedCategories) {
|
||||
if (cat === 'air')
|
||||
continue
|
||||
legendParts.push(`${CATEGORY_SYMBOLS[cat]}=${CATEGORY_LEGEND[cat]}`)
|
||||
}
|
||||
// Always include entity symbols if entities are shown
|
||||
if (options.showEntities) {
|
||||
legendParts.push(`${ENTITY_SYMBOLS.self}=you`)
|
||||
legendParts.push(`${ENTITY_SYMBOLS.player}=player`)
|
||||
legendParts.push(`${ENTITY_SYMBOLS.hostile}=hostile mob`)
|
||||
legendParts.push(`${ENTITY_SYMBOLS.passive}=animal`)
|
||||
}
|
||||
|
||||
const legend = legendParts.join(' ')
|
||||
|
||||
return {
|
||||
map: lines.join('\n'),
|
||||
legend,
|
||||
center: { x: cx, y: cy, z: cz },
|
||||
radius: r,
|
||||
view: 'top-down',
|
||||
}
|
||||
}
|
||||
|
||||
function renderCrossSection(bot: Bot, options: Required<MapOptions>): MapResult {
|
||||
const center = bot.entity.position
|
||||
const cx = Math.floor(center.x)
|
||||
const cy = options.yLevel
|
||||
const cz = Math.floor(center.z)
|
||||
const r = options.radius
|
||||
|
||||
// Cross-section: X horizontal, Y vertical, at fixed Z = cz
|
||||
const width = r * 2 + 1
|
||||
const height = r * 2 + 1
|
||||
const yTop = cy + r
|
||||
const yBottom = cy - r
|
||||
|
||||
const grid: string[][] = Array.from({ length: height }, () => Array.from({ length: width }, () => ' '))
|
||||
const usedCategories = new Set<BlockCategory>()
|
||||
|
||||
for (let dy = -r; dy <= r; dy++) {
|
||||
for (let dx = -r; dx <= r; dx++) {
|
||||
const wx = cx + dx
|
||||
const wy = cy - dy // Flip Y so top of grid = higher Y
|
||||
const gx = dx + r
|
||||
const gy = dy + r
|
||||
|
||||
const block = bot.blockAt(new Vec3(wx, wy, cz))
|
||||
if (!block) {
|
||||
grid[gy][gx] = ' '
|
||||
continue
|
||||
}
|
||||
|
||||
const category = classifyBlock(block.name)
|
||||
usedCategories.add(category)
|
||||
grid[gy][gx] = CATEGORY_SYMBOLS[category]
|
||||
}
|
||||
}
|
||||
|
||||
// Mark bot position if visible
|
||||
const botDx = 0
|
||||
const botDy = cy - Math.floor(center.y)
|
||||
if (Math.abs(botDx) <= r && Math.abs(botDy) <= r) {
|
||||
grid[botDy + r][botDx + r] = ENTITY_SYMBOLS.self
|
||||
}
|
||||
|
||||
const lines: string[] = []
|
||||
lines.push(` Cross-section at Z=${cz} centered at (${cx}, ${cy}) | radius: ${r}`)
|
||||
lines.push(` Y=${yTop}`)
|
||||
|
||||
for (let gy = 0; gy < height; gy++) {
|
||||
const row = grid[gy].join('')
|
||||
const worldY = yTop - gy
|
||||
if (gy % 4 === 0) {
|
||||
lines.push(`${String(worldY).padStart(4)}|${row}|`)
|
||||
}
|
||||
else {
|
||||
lines.push(` |${row}|`)
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(` Y=${yBottom}`)
|
||||
lines.push(` [X: ${cx - r}..${cx + r}]`)
|
||||
|
||||
// Legend
|
||||
const legendParts: string[] = []
|
||||
for (const cat of usedCategories) {
|
||||
if (cat === 'air')
|
||||
continue
|
||||
legendParts.push(`${CATEGORY_SYMBOLS[cat]}=${CATEGORY_LEGEND[cat]}`)
|
||||
}
|
||||
legendParts.push(`${ENTITY_SYMBOLS.self}=you`)
|
||||
|
||||
return {
|
||||
map: lines.join('\n'),
|
||||
legend: legendParts.join(' '),
|
||||
center: { x: cx, y: cy, z: cz },
|
||||
radius: r,
|
||||
view: 'cross-section',
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Public API ─────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_RADIUS = 16
|
||||
const MAX_RADIUS = 32
|
||||
|
||||
export function renderMap(bot: Bot, options: MapOptions = {}): MapResult {
|
||||
const resolved: Required<MapOptions> = {
|
||||
radius: Math.min(Math.max(options.radius ?? DEFAULT_RADIUS, 1), MAX_RADIUS),
|
||||
view: options.view ?? 'top-down',
|
||||
showEntities: options.showEntities ?? true,
|
||||
showElevation: options.showElevation ?? true,
|
||||
yLevel: options.yLevel ?? Math.floor(bot.entity.position.y),
|
||||
}
|
||||
|
||||
switch (resolved.view) {
|
||||
case 'cross-section':
|
||||
return renderCrossSection(bot, resolved)
|
||||
case 'top-down':
|
||||
default:
|
||||
return renderTopDown(bot, resolved)
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,12 @@ Core query entrypoints:
|
||||
- `query.entities()`: nearby entities with chain methods (`within`, `limit`, `whereType`, `names`, `first`, `list`)
|
||||
- `query.inventory()`: inventory stacks (`whereName`, `names`, `countByName`, `count`, `has`, `summary`, `list`)
|
||||
- `query.craftable()`: craftable item names (supports `uniq`, `whereIncludes`, `list`)
|
||||
- `query.gaze(options?)`: where nearby players are looking (`playerName`, `lookPoint`, `hitBlock`)
|
||||
- `query.map(options?)`: ASCII top-down or cross-section map of surroundings. Returns `{ map, legend, center, radius, view }`.
|
||||
- Options: `{ radius?: number (1-32, default 16), view?: "top-down" | "cross-section", showEntities?: boolean, showElevation?: boolean, yLevel?: number }`
|
||||
- Symbols: `.`=ground `#`=stone `~`=water `%`=lava `T`=tree trunk `$`=ore `!`=chest/furnace/table `@`=you `P`=player `M`=hostile `A`=animal
|
||||
- Use `query.map()` for spatial awareness — finding trees, water, ores, structures, and navigating terrain.
|
||||
- Use `query.map({ view: "cross-section" })` to see underground layers (caves, ore veins, elevation).
|
||||
|
||||
Composable patterns:
|
||||
- `const ores = query.blocks().within(24).isOre().names().uniq().list()`
|
||||
@@ -79,6 +85,8 @@ Composable patterns:
|
||||
- `const invSummary = query.inventory().summary(); invSummary`
|
||||
- `const invLine = query.inventory().summary().map(({ name, count }) => `${count} ${name}`).join(", "); invLine`
|
||||
- `const craftableTools = query.craftable().whereIncludes("pickaxe").uniq().list()`
|
||||
- `const area = query.map({ radius: 16 }); area.map` — top-down ASCII map of surroundings
|
||||
- `const underground = query.map({ view: "cross-section", radius: 8 }); underground.map` — vertical slice showing caves/ores
|
||||
|
||||
Inventory summary shape reminder:
|
||||
- `query.inventory().summary()` returns an **array** of `{ name, count }`.
|
||||
|
||||
@@ -8,6 +8,7 @@ import { inspect } from 'node:util'
|
||||
import { Vec3 } from 'vec3'
|
||||
|
||||
import { computeNearbyPlayerGaze } from '../perception/gaze'
|
||||
import { renderMap } from './map-renderer'
|
||||
|
||||
import * as world from '../../skills/world'
|
||||
|
||||
@@ -447,5 +448,8 @@ export function createQueryRuntime(mineflayer: Mineflayer) {
|
||||
nearbyDistance: options?.range ?? 16,
|
||||
})
|
||||
},
|
||||
map: (options?: { radius?: number, view?: 'top-down' | 'cross-section', showEntities?: boolean, showElevation?: boolean, yLevel?: number }) => {
|
||||
return renderMap(mineflayer.bot, options)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user