Files
minecraft-bot/mosfet/world.py
T

586 lines
23 KiB
Python

import collections
import json
import re
import time
import random
from itertools import count
from copy import copy
from mosfet import utils
from mosfet import path
from mosfet.info import blocks
from mosfet.info import mobs
class World:
def __init__(self, global_state):
self.g = global_state
self._bed_occupancy_states = {}
def block_at(self, x, y, z):
return self.g.chunks.get_block_at(x, y, z)
@staticmethod
def _sign_component_text(value):
if value is None:
return ''
if isinstance(value, str):
stripped = value.strip()
if stripped.startswith(('{', '[')):
try:
parsed = json.loads(stripped)
except ValueError:
return value
if parsed != value:
return World._sign_component_text(parsed)
return value
if isinstance(value, (list, tuple)):
return ''.join(World._sign_component_text(part) for part in value)
if isinstance(value, dict):
text = value.get('text', '')
if not text and value.get('translate'):
text = value['translate']
if isinstance(text, (dict, list)):
text = World._sign_component_text(text)
return (str(text) +
World._sign_component_text(value.get('with', [])) +
World._sign_component_text(value.get('extra', [])))
return str(value)
def find_signs(self, center, distance=16):
"""Return nearby loaded signs as position/side/four-lines records."""
result = []
for position, entity in self.g.chunks.block_entities.items():
if utils.phyp(center, position) > distance:
continue
data = entity.get('data') or {}
for side in ('front', 'back'):
text_data = data.get(side + '_text')
if not isinstance(text_data, dict):
continue
messages = text_data.get('messages', [])
lines = tuple(self._sign_component_text(line)
for line in messages[:4])
lines += ('',) * (4 - len(lines))
if any(line.strip() for line in lines):
result.append({
'position': position, 'side': side, 'lines': lines,
})
if not any(key in data for key in ('front_text', 'back_text')):
old_lines = tuple(self._sign_component_text(data.get(
'Text{}'.format(index), '')) for index in range(1, 5))
if any(line.strip() for line in old_lines):
result.append({
'position': position, 'side': 'front',
'lines': old_lines,
})
result.sort(key=lambda sign: utils.phyp(center, sign['position']))
return result
def sign_policies(self, bot_name, distance=64):
"""Parse sign policies addressed to this bot or its numbered group."""
bot_name = str(bot_name).casefold()
group_name = (bot_name[:-1] if len(bot_name) > 1 and
bot_name[-1] in '123' else None)
addresses = {bot_name}
if group_name:
addresses.add(group_name)
center = utils.pint(self.g.pos)
policies = []
active_sign_position = None
for sign in self.find_signs(center, distance):
lines = [line.strip() for line in sign['lines'] if line.strip()]
if not lines:
continue
first = lines[0].split(maxsplit=1)
address = first[0].rstrip(':,').casefold()
if address not in addresses:
continue
directives = ([first[1]] if len(first) > 1 else []) + lines[1:]
text = ' '.join(directives).casefold()
sign_policies = []
for match in re.finditer(
r'\bstay\s+within\s*(\d+)(?:\s+(squares?|circle))?\b',
text):
shape = match.group(2) or 'circle'
if shape == 'squares':
shape = 'square'
sign_policies.append({
'position': sign['position'],
'distance': int(match.group(1)),
'shape': shape,
'side': sign['side'],
})
if not sign_policies:
continue
if active_sign_position is None:
# The nearest addressed policy sign overrides farther signs.
active_sign_position = sign['position']
if sign['position'] == active_sign_position:
policies.extend(sign_policies)
return policies
def _active_work_policies(self):
job = self.g.get('job')
machine = getattr(job, 'active_machine', None) if job else None
if machine is None:
return []
support_machines = (
'grab_supplies_states', 'sleep_with_bed_states',
'eat_food_states', 'cache_items_states',
'check_threats_states',
)
if any(machine is getattr(job, name, None)
for name in support_machines):
return []
return self.sign_policies(self.g.get('name', ''))
@staticmethod
def _sign_policy_distance(position, policy):
# Work zones are horizontal; crop height or terrain elevation does not
# consume the sign's radius.
dx = abs(position[0] - policy['position'][0])
dz = abs(position[2] - policy['position'][2])
if policy['shape'] == 'square':
return max(dx, dz)
return utils.hypot(dx, dz)
@classmethod
def _inside_sign_policy(cls, position, policy):
return cls._sign_policy_distance(position, policy) <= policy['distance']
def work_position_allowed(self, position):
"""Whether a work target satisfies all currently active sign rules."""
return all(self._inside_sign_policy(position, policy)
for policy in self._active_work_policies())
def filter_work_positions(self, positions):
"""Filter a candidate set once against the active sign policies."""
policies = self._active_work_policies()
positions = list(positions)
if not policies:
return positions
return [position for position in positions
if all(self._inside_sign_policy(position, policy)
for policy in policies)]
@classmethod
def _path_allowed_by_sign(cls, points, policy):
distances = [cls._sign_policy_distance(point, policy)
for point in points]
radius = policy['distance']
if distances[0] <= radius:
return all(distance <= radius for distance in distances)
# If the bot starts just outside a newly read boundary, let it return
# without allowing it to travel farther away from that sign.
start_distance = distances[0]
entered = False
for distance in distances[1:]:
if distance > start_distance or (entered and distance > radius):
return False
if distance <= radius:
entered = True
return entered
def check_air_column(self, pos, distance):
for i in range(distance):
check = utils.padd(pos, (0, i, 0))
if self.block_at(*check) not in blocks.NON_SOLID_IDS:
return False
return True
def find_blocks_3d(self, center, block_ids, distance=0, y_limit=0, thru_air=False):
to_visit = collections.deque([(0, 0, 0)])
visited = set()
while to_visit:
cur = to_visit.pop()
if cur in visited:
continue
if y_limit and abs(cur[1]) > y_limit:
continue
if distance and utils.hypot(*cur) > distance:
continue
check = utils.padd(center, cur)
if not thru_air or self.block_at(*check) in blocks.NON_SOLID_IDS:
for neighbor in utils.get_neighbors_3d(*cur):
to_visit.appendleft(neighbor)
visited.add(cur)
if self.block_at(*check) in block_ids:
yield check
def find_blocks_indexed(self, center, block_ids, distance=0):
index = []
for bid in block_ids:
index.extend(self.g.chunks.index.get(bid, []))
result = []
for block in index:
if self.block_at(*block) not in block_ids:
continue
if distance and utils.phyp(center, block) > distance:
continue
if block not in result:
result.append(block)
result.sort(key=lambda x: utils.phyp(center, x))
return result
def find_blocks(self, center, distance, block_ids, limit=0):
# search in a spiral from center to all blocks with ID
result = []
for n in count():
offset = utils.spiral(n)
check = utils.padd(center, offset)
if self.block_at(*check) in block_ids:
if utils.hypot(*offset) < distance:
result.append(check)
if limit and len(result) == limit:
return result
if offset[0] > distance:
return result
def find_trees(self, center, distance):
found_trees = set()
# 26.3 block-state caches keep an index of tree logs. Searching a
# three-dimensional radius voxel-by-voxel is prohibitively expensive
# and can walk through unloaded space, so use loaded indexed states.
for candidate in self.find_blocks_indexed(
center, blocks.LOG_IDS, distance):
# Crawl to the bottom log of this trunk.
log = candidate
while self.block_at(*utils.padd(log, path.BLOCK_BELOW)) in blocks.LOG_IDS:
log = utils.padd(log, path.BLOCK_BELOW)
base = log
if base in found_trees:
continue
# Only classify trees when their supporting block is known.
support = self.block_at(*utils.padd(base, path.BLOCK_BELOW))
if support is None or support in blocks.NON_SOLID_IDS:
continue
# Crawl to the top log to count trunk blocks and check for leaves.
log_count = 1
good_leaves = False
while self.block_at(*utils.padd(log, path.BLOCK_ABOVE)) in blocks.LOG_IDS:
log = utils.padd(log, path.BLOCK_ABOVE)
log_count += 1
for offset in path.CHECK_DIRECTIONS:
if self.block_at(*utils.padd(log, offset)) in blocks.LEAF_IDS:
good_leaves = True
if not good_leaves or log_count < 3:
continue
found_trees.add(base)
yield base
def find_tree_openings(self, tree):
# returns coords in a cardinal direction where we can stand by tree
maze_solver = path.Pathfinder(self.g)
result = []
# TODO: make sure only non-solid and leaves between
# make sure traversable too and non-avoid
for distance in range(5):
for direction in path.CHECK_DIRECTIONS:
offset = utils.pmul(direction, distance+1)
if maze_solver.check_traverse(tree, offset):
result.append(utils.padd(tree, offset))
return result
def path_to_place(self, start, place):
maze_solver = path.Pathfinder(self.g)
try:
solution = maze_solver.astar(start, place)
if not solution:
return None
navpath = list(solution)
except path.AStarTimeout:
return None
policies = self._active_work_policies()
points = [tuple(start)] + [tuple(point) for point in navpath]
for policy in policies:
if not self._path_allowed_by_sign(points, policy):
print('[sign policy] rejected work path outside {}-block {} '
'around {}'.format(policy['distance'], policy['shape'],
policy['position']), flush=True)
return None
return navpath
def path_to_place_faked(self, start, place):
# same as above, but adds a fake block below and air before pathfinding
# so that the pathfinder can actually make it to the block
c = self.g.chunks
above = utils.padd(place, path.BLOCK_ABOVE)
below = utils.padd(place, path.BLOCK_BELOW)
tmp = c.get_block_at(*place)
tmp2 = c.get_block_at(*above)
tmp3 = c.get_block_at(*below)
c.set_block_at(*place, blocks.AIR)
c.set_block_at(*above, blocks.AIR)
c.set_block_at(*below, blocks.STONE)
navpath = self.path_to_place(start, place)
c.set_block_at(*place, tmp)
c.set_block_at(*above, tmp2)
c.set_block_at(*below, tmp3)
return navpath
def find_bed_areas(self, center, distance):
"""Yield nearby two-block bed footprints on solid, safe ground."""
# Only search loaded-neighborhood distance. The old 3-D flood fill
# explored millions of air blocks and used overly broad 5x5 clearance.
distance = min(distance, 32)
headroom = path.BLOCK_ABOVE
support = path.BLOCK_BELOW
for offset in utils.search_2d(distance):
if utils.hypot(offset[0], offset[2]) > distance:
continue
for dy in (0, 1, -1, 2, -2):
area = (center[0] + offset[0], center[1] + dy,
center[2] + offset[2])
if area == center:
continue
if self.block_at(*area) != blocks.AIR:
continue
if self.block_at(*utils.padd(area, headroom)) != blocks.AIR:
continue
support_block = self.block_at(*utils.padd(area, support))
if (support_block is None or
support_block in blocks.NON_SOLID_IDS or
support_block in blocks.AVOID_IDS):
continue
footprint_found = False
for direction in path.CHECK_DIRECTIONS:
head = utils.padd(area, direction)
if (self.block_at(*head) != blocks.AIR or
self.block_at(*utils.padd(head, headroom)) != blocks.AIR):
continue
head_support = self.block_at(*utils.padd(head, support))
if (head_support is None or
head_support in blocks.NON_SOLID_IDS or
head_support in blocks.AVOID_IDS):
continue
footprint_found = True
break
if not footprint_found:
continue
# Avoid placing a bed within range of nearby monsters.
if self.find_monsters(area, 20):
continue
yield area
def find_cache_areas(self, center, distance):
"""Yield nearby, supported air blocks suitable for placing a chest."""
center = tuple(center)
for offset in utils.search_2d(distance):
if utils.hypot(offset[0], offset[2]) > distance:
continue
for dy in (0, 1, -1, 2, -2):
area = (center[0] + offset[0], center[1] + dy,
center[2] + offset[2])
if area == center:
continue
if self.block_at(*area) != blocks.AIR:
continue
if self.block_at(*utils.padd(area, path.BLOCK_ABOVE)) != blocks.AIR:
continue
support = self.block_at(*utils.padd(area, path.BLOCK_BELOW))
if (support is None or support in blocks.NON_SOLID_IDS or
support in blocks.AVOID_IDS):
continue
yield area
def sand_adjacent_safe(self, sand):
for direction in path.CHECK_DIRECTIONS:
if self.block_at(*utils.padd(sand, direction)) in blocks.AVOID_IDS:
return False
return True
def find_sand(self, center, distance, player):
sand = []
sand.extend(self.find_blocks(center, distance, [blocks.SAND], 25))
safe_sand = []
for s in sand:
# make sure it has solid below
if self.block_at(*utils.padd(s, path.BLOCK_BELOW)) in blocks.NON_SOLID_IDS:
continue
# make sure it has solid two below - prevent hanging sand
if self.block_at(*utils.padd(s, path.BLOCK_BELOW2)) in blocks.NON_SOLID_IDS:
continue
# and walkable air above
if self.block_at(*utils.padd(s, path.BLOCK_ABOVE)) not in blocks.NON_SOLID_IDS:
continue
if not self.sand_adjacent_safe(s):
continue
safe_sand.append(s)
safe_sand.sort(key=lambda x: utils.phyp(player, x))
return safe_sand
def check_sand_slice(self, center):
# checks if a 5x5x1 slice has sand in it
for i in range(9):
s = utils.padd(center, utils.spiral(i))
if self.block_at(*s) != blocks.SAND:
continue
# make sure it has solid below
if self.block_at(*utils.padd(s, path.BLOCK_BELOW)) in blocks.NON_SOLID_IDS:
continue
# make sure it has solid two below - prevent hanging sand
if self.block_at(*utils.padd(s, path.BLOCK_BELOW2)) in blocks.NON_SOLID_IDS:
continue
# and walkable air above
if self.block_at(*utils.padd(s, path.BLOCK_ABOVE)) not in blocks.NON_SOLID_IDS:
continue
if not self.sand_adjacent_safe(s):
continue
return True
return False
def find_sand_slice(self, center, distance, y_limit=0, bad_slices=[], prev_layer=0):
# returns the centre coord of the next 5x5x1 slice that still has
# diggable sand in it. lower slices are only valid if there's an
# adjacent slice farther at the same level. this should ensure an
# upside down pyramid gets excavated so the edges are still climbable
for v in count(prev_layer):
peak = utils.padd(center, (0, 10-v, 0))
slices = []
layer = 0
for step in count():
offset = utils.spiral(step)
layer = max(layer, *offset)
offset = utils.pmul(offset, 3)
check = utils.padd(peak, offset)
check = utils.padd(check, (0, layer, 0))
if y_limit and check[1] - center[1] > y_limit:
break
if utils.phyp_king(center, check) > distance:
break
if self.check_sand_slice(check) and check not in bad_slices:
slices.append(check)
if len(slices):
return v, slices[-1]
elif v > 40:
return None, None
def find_bed_openings(self, area):
# returns coords in a cardinal direction where we can stand by bed
result = []
for direction in path.CHECK_DIRECTIONS:
result.append(utils.padd(area, direction))
return result
def check_bed_occupied(self, bed):
"""Return occupancy, logging only the first observation or a change."""
bed = tuple(bed)
bid = self.g.chunks.get_block_at(*bed)
occupied = blocks.PROPS.get(bid, {}).get('occupied') == 'true'
previous = self._bed_occupancy_states.get(bed)
if previous is None or previous != occupied:
print('Checking bed occupancy:', bed, '->',
'occupied' if occupied else 'free')
self._bed_occupancy_states[bed] = occupied
return occupied
def find_cache_openings(self, area):
return self.find_bed_openings(area)
def find_objects(self, object_ids):
result = []
for eid, obj in copy(self.g.objects).items():
if obj.get('item_id', None) in object_ids:
result.append(obj)
return result
def find_leaves(self, center, distance):
for a in self.find_blocks_3d(center, blocks.LEAF_IDS, distance, 10):
yield a
def find_monsters(self, center, distance):
# finds monsters within distance
result = []
for eid, mob in copy(self.g.mobs).items():
if mob.type not in mobs.EVIL_IDS:
continue
pos = utils.pint((mob.x, mob.y, mob.z))
if utils.phyp(center, pos) > distance:
continue
result.append(mob)
result.sort(key=lambda mob: utils.phyp(center, (mob.x, mob.y, mob.z)))
return result
def find_threats(self, center, distance):
# finds monsters on the surface within distance
monsters = self.find_monsters(center, distance)
result = []
for mob in monsters:
pos = utils.pint((mob.x, mob.y, mob.z))
# check distance number of blocks above, close enough?
if not self.check_air_column(pos, distance):
continue
result.append(mob)
result.sort(key=lambda mob: utils.phyp(center, (mob.x, mob.y, mob.z)))
return result
def find_villagers(self, center, distance):
# finds villagers within distance
result = []
for eid, mob in copy(self.g.mobs).items():
type_name = mobs.MOB_NAMES[mob.type]
if type_name != 'villager' : continue
pos = utils.pint((mob.x, mob.y, mob.z))
if utils.phyp(center, pos) > distance:
continue
result.append(mob)
result.sort(key=lambda mob: utils.phyp(center, (mob.x, mob.y, mob.z)))
return result
def find_villager_openings(self, villager):
# returns coords in a cardinal direction where we can stand by a villager
maze_solver = path.Pathfinder(self.g)
result = []
for distance in range(3):
for direction in path.CHECK_DIRECTIONS:
offset = utils.pmul(direction, distance+1)
if not maze_solver.check_traverse(villager, offset):
continue
# check for line of sight
for check in range(distance+1):
offset2 = utils.pmul(direction, check+1)
offset2 = utils.padd(offset2, path.BLOCK_ABOVE)
check = utils.padd(villager, offset2)
if self.block_at(*check) not in blocks.NON_SOLID_IDS:
break
else: # for
result.append(utils.padd(villager, offset))
return result