From 9783631f944695cf5f75800f55e32ce7285e2a12 Mon Sep 17 00:00:00 2001 From: Tanner Date: Sat, 7 Nov 2026 19:48:51 +0000 Subject: [PATCH] Find chest openings within interaction range --- mosfet/game.py | 16 +-- mosfet/jobs/cache_items.py | 40 ++++--- mosfet/world.py | 192 +++++++++++++++++++++++++++++++++- tests/test_cache_openings.py | 88 ++++++++++++++++ tests/test_chest_detection.py | 4 +- tests/test_sneak_placement.py | 24 +++++ 6 files changed, 337 insertions(+), 27 deletions(-) create mode 100644 tests/test_cache_openings.py diff --git a/mosfet/game.py b/mosfet/game.py index fca3d1a..1aa340e 100644 --- a/mosfet/game.py +++ b/mosfet/game.py @@ -385,7 +385,7 @@ class LegacyGame: packet.face = 1 self.g.connection.write_packet(packet) - def open_container(self, location): + def open_container(self, location, face=BlockFace.TOP, cursor=None): location = tuple(int(value) for value in location) positions = {location} world = self.g.get('world') @@ -399,7 +399,7 @@ class LegacyGame: 'positions': tuple(sorted(positions)), 'requested_at': time.monotonic(), } - self.place_block(location, BlockFace.TOP) + self.place_block(location, face, cursor=cursor) def handle_window(self, packet): print(packet) @@ -1048,14 +1048,16 @@ class Game(LegacyGame): return True def place_block(self, location, face, hand=0, cursor_y=None, - sneaking=False): + sneaking=False, cursor=None): location = tuple(int(value) for value in location) - if cursor_y is None: - cursor_y = 0.99 if int(face) == 1 else 0.5 + if cursor is None: + if cursor_y is None: + cursor_y = 0.99 if int(face) == 1 else 0.5 + cursor = (0.5, cursor_y, 0.5) packet = UseItemOnBlockPacket( hand=hand, location=location, face=int(face), - cursor_x=0.5, cursor_y=cursor_y, - cursor_z=0.5, inside_block=False, world_border_hit=False, + cursor_x=float(cursor[0]), cursor_y=float(cursor[1]), + cursor_z=float(cursor[2]), inside_block=False, world_border_hit=False, sequence=self._next_sequence()) if sneaking: self.g.connection.write_packet(PlayerInputPacket(flags=0x20), diff --git a/mosfet/jobs/cache_items.py b/mosfet/jobs/cache_items.py index f4faafb..4b416da 100644 --- a/mosfet/jobs/cache_items.py +++ b/mosfet/jobs/cache_items.py @@ -69,12 +69,13 @@ class CacheItemsStates: self.area = container['position'] self.container_item_ids = container['item_ids'] self.is_labeled_container = True - for opening in w.find_cache_openings(self.area): - navpath = w.path_to_place(p, opening) + for opening in w.find_cache_openings(self.area, p): + navpath = w.path_to_place(p, opening['position']) if navpath: print('Going to labeled chest', container['labels'], self.area) - self.opening = opening + self.opening = opening['position'] + self.open_interaction = opening self.g.path = navpath self.state = self.going_to_trapped_chest return @@ -96,11 +97,12 @@ class CacheItemsStates: chest = self.trapped_chests[0] self.area = chest - for opening in w.find_cache_openings(chest): - navpath = w.path_to_place(p, opening) + for opening in w.find_cache_openings(chest, p): + navpath = w.path_to_place(p, opening['position']) if navpath: print('navpath to trapped chest:', navpath) - self.opening = opening + self.opening = opening['position'] + self.open_interaction = opening self.g.path = navpath self.state = self.going_to_trapped_chest return @@ -110,12 +112,12 @@ class CacheItemsStates: def going_to_trapped_chest(self): if utils.pint(self.g.pos) == self.opening: - self.g.look_at = self.area + self.g.look_at = self.open_interaction['look_at'] self.wait_time = 0.5 self.state = self.turn_to_trapped_chest def turn_to_trapped_chest(self): - self.g.look_at = self.area + self.g.look_at = self.open_interaction['look_at'] self.wait_time -= utils.TICK if self.wait_time <= 0: self.state = self.open_chest @@ -144,12 +146,14 @@ class CacheItemsStates: return self.area = area - openings = w.find_cache_openings(self.area) + openings = w.find_cache_openings(self.area, p) - for o in openings: - navpath = w.path_to_place(p, o) - self.opening = o - if navpath: break + for opening in openings: + navpath = w.path_to_place(p, opening['position']) + if navpath: + self.opening = opening['position'] + self.open_interaction = opening + break else: # for print('Unable to get to cache area', self.area) self.bad_areas.append(self.area) @@ -161,12 +165,12 @@ class CacheItemsStates: def going_to_area(self): if utils.pint(self.g.pos) == self.opening: - self.g.look_at = self.area + self.g.look_at = self.open_interaction['look_at'] self.wait_time = 0.5 self.state = self.turn_to_cache_spot def turn_to_cache_spot(self): - self.g.look_at = self.area + self.g.look_at = self.open_interaction['look_at'] self.wait_time -= utils.TICK if self.wait_time <= 0: self.state = self.place_chest @@ -180,7 +184,10 @@ class CacheItemsStates: print('Opening chest') self.failed_transfer_slots.clear() self.pending_transfer = None - self.g.game.open_container(self.area) + self.g.game.open_container( + self.open_interaction['target'], + face=self.open_interaction['face'], + cursor=self.open_interaction['cursor']) self.wait_time = 1 self.state = self.wait @@ -368,6 +375,7 @@ class CacheItemsStates: self.container_item_ids = None self.area = None self.opening = None + self.open_interaction = None self.trapped_chests = [] self.bad_areas = [] self.wait_time = 0 diff --git a/mosfet/world.py b/mosfet/world.py index 89fc42f..1a1fe6d 100644 --- a/mosfet/world.py +++ b/mosfet/world.py @@ -3,6 +3,7 @@ import json import re import time import random +from math import floor from itertools import count from copy import copy @@ -11,6 +12,10 @@ from mosfet import path from mosfet.info import blocks from mosfet.info import items from mosfet.info import mobs +from minecraft.networking.types import BlockFace + +CHEST_OPENING_RADIUS = 3 + class World: def __init__(self, global_state): @@ -625,8 +630,191 @@ class World: self._bed_occupancy_states[bed] = occupied return occupied - def find_cache_openings(self, area): - return self.find_bed_openings(area) + @staticmethod + def _is_chest_state(state_id): + name = blocks.BLOCKS.get(state_id) + return (name is not None and name != 'ender_chest' and + (name == 'chest' or name.endswith('_chest'))) + + def _cache_opening_standable(self, position): + at_feet = self.block_at(*position) + above = self.block_at(*utils.padd(position, path.BLOCK_ABOVE)) + below = self.block_at(*utils.padd(position, path.BLOCK_BELOW)) + return (at_feet in blocks.NON_SOLID_IDS and + at_feet not in blocks.AVOID_IDS and + above in blocks.NON_SOLID_IDS and + above not in blocks.AVOID_IDS and + below is not None and + below not in blocks.NON_SOLID_IDS and + below not in blocks.AVOID_IDS) + + @staticmethod + def _segment_intersects_aabb(start, end, minimum, maximum): + lower, upper = 0.0, 1.0 + for axis in range(3): + origin = start[axis] + delta = end[axis] - origin + if abs(delta) < 1e-9: + if origin < minimum[axis] or origin > maximum[axis]: + return False + continue + first = (minimum[axis] - origin) / delta + second = (maximum[axis] - origin) / delta + if first > second: + first, second = second, first + lower = max(lower, first) + upper = min(upper, second) + if lower > upper: + return False + return upper >= 0.0 and lower <= 1.0 + + def _has_cache_line_of_sight(self, start, end, target): + first = tuple(floor(min(start[i], end[i])) for i in range(3)) + last = tuple(floor(max(start[i], end[i])) for i in range(3)) + for x in range(first[0], last[0] + 1): + for y in range(first[1], last[1] + 1): + for z in range(first[2], last[2] + 1): + position = (x, y, z) + if position == target: + continue + if not self._segment_intersects_aabb( + start, end, position, utils.padd(position, (1, 1, 1))): + continue + state_id = self.block_at(*position) + if state_id is None: + return False + for box in blocks.COLLISION_BOXES.get(state_id, ()): + minimum = (x + box[0], y + box[1], z + box[2]) + maximum = (x + box[3], y + box[4], z + box[5]) + if self._segment_intersects_aabb( + start, end, minimum, maximum): + return False + return True + + @staticmethod + def _chest_interaction_points(target, boxes): + points = [] + for box in boxes: + x0, y0, z0, x1, y1, z1 = box + xm, zm = (x0 + x1) / 2, (z0 + z1) / 2 + for fraction in (0.2, 0.5, 0.8): + y = y0 + (y1 - y0) * fraction + points.extend(( + (target[0] + xm, target[1] + y, target[2] + z0), + (target[0] + xm, target[1] + y, target[2] + z1), + (target[0] + x0, target[1] + y, target[2] + zm), + (target[0] + x1, target[1] + y, target[2] + zm), + )) + for x_fraction, z_fraction in ( + (0.25, 0.25), (0.25, 0.75), + (0.75, 0.25), (0.75, 0.75), (0.5, 0.5)): + points.append(( + target[0] + x0 + (x1 - x0) * x_fraction, + target[1] + y1, + target[2] + z0 + (z1 - z0) * z_fraction, + )) + return points + + @staticmethod + def _ray_chest_surface_hit(start, end, target, boxes): + negative_faces = (BlockFace.WEST, BlockFace.BOTTOM, BlockFace.NORTH) + positive_faces = (BlockFace.EAST, BlockFace.TOP, BlockFace.SOUTH) + delta = tuple(end[i] - start[i] for i in range(3)) + hits = [] + for box in boxes: + minimum = tuple(target[i] + box[i] for i in range(3)) + maximum = tuple(target[i] + box[i + 3] for i in range(3)) + enter, leave = float('-inf'), float('inf') + entering_face = None + for axis in range(3): + if abs(delta[axis]) < 1e-9: + if (start[axis] < minimum[axis] or + start[axis] > maximum[axis]): + break + continue + near = (minimum[axis] - start[axis]) / delta[axis] + far = (maximum[axis] - start[axis]) / delta[axis] + face = negative_faces[axis] + if near > far: + near, far = far, near + face = positive_faces[axis] + if near > enter: + enter = near + entering_face = face + leave = min(leave, far) + if enter > leave: + break + else: + if entering_face is None or enter < 0 or enter > 1: + continue + hit_point = tuple(start[i] + delta[i] * enter + for i in range(3)) + cursor = tuple(hit_point[i] - target[i] for i in range(3)) + hits.append((enter, entering_face, cursor, hit_point)) + if not hits: + return None + _distance, face, cursor, hit_point = min(hits, key=lambda hit: hit[0]) + return face, cursor, hit_point + + def find_cache_openings(self, area, center=None, radius=None): + """Find standable nearby spots with a clear ray to a chest surface.""" + area = tuple(area) + center = tuple(center if center is not None else area) + radius = CHEST_OPENING_RADIUS if radius is None else radius + state_id = self.block_at(*area) + targets = (self._double_chest_positions(area) + if self._is_chest_state(state_id) else (area,)) + default_chest = min(blocks.CHEST_IDS) + interactions = {} + + for target in targets: + target_state = self.block_at(*target) + boxes = blocks.COLLISION_BOXES.get(target_state) or \ + blocks.COLLISION_BOXES[default_chest] + hit_points = self._chest_interaction_points(target, boxes) + for horizontal in utils.search_2d(radius + 1): + for dy in range(-radius - 1, radius + 2): + offset = (horizontal[0], dy, horizontal[2]) + opening = utils.padd(target, offset) + if (opening in targets or + not self._cache_opening_standable(opening)): + continue + eye = (opening[0] + 0.5, opening[1] + 1.62, + opening[2] + 0.5) + for aim_point in hit_points: + if not self._has_cache_line_of_sight( + eye, aim_point, target): + continue + hit = self._ray_chest_surface_hit( + eye, aim_point, target, boxes) + if hit is None: + continue + face, cursor, hit_point = hit + hit_distance = utils.phyp(eye, hit_point) + if hit_distance > radius: + continue + interaction = { + 'position': opening, + 'target': target, + 'face': face, + 'cursor': cursor, + 'hit_point': hit_point, + 'look_at': tuple(value - 0.5 + for value in hit_point), + } + distance = utils.phyp(center, opening) + interaction['distance'] = distance + interaction['hit_distance'] = hit_distance + previous = interactions.get(opening) + if (previous is None or + interaction['hit_distance'] < + previous['hit_distance']): + interactions[opening] = interaction + break + + return sorted(interactions.values(), + key=lambda result: (result['distance'], + result['position'])) def find_objects(self, object_ids): result = [] diff --git a/tests/test_cache_openings.py b/tests/test_cache_openings.py new file mode 100644 index 0000000..daba99d --- /dev/null +++ b/tests/test_cache_openings.py @@ -0,0 +1,88 @@ +import unittest +from munch import Munch + +from minecraft.networking.types import BlockFace + +from mosfet.info import blocks +from mosfet.world import CHEST_OPENING_RADIUS, World + + +class FakeChunks: + def __init__(self, states): + self.states = states + + def get_block_at(self, x, y, z): + return self.states.get((x, y, z), blocks.AIR) + + +class CacheOpeningTests(unittest.TestCase): + def setUp(self): + self.chest = (0, 64, 0) + chest_state = min(blocks.CHEST_IDS) + stone = blocks.STONE + states = {self.chest: chest_state} + for x in range(-4, 5): + for z in range(-4, 5): + states[(x, 63, z)] = stone + self.world = World(Munch(chunks=FakeChunks(states))) + + def test_openings_include_visible_positions_up_to_configured_radius(self): + opening = (3, 64, 0) + openings = self.world.find_cache_openings( + self.chest, center=(3, 64, 0)) + by_position = {tuple(candidate['position']): candidate + for candidate in openings} + + self.assertEqual(CHEST_OPENING_RADIUS, 3) + self.assertIn(opening, by_position) + self.assertTrue(all(candidate['hit_distance'] <= CHEST_OPENING_RADIUS + for candidate in openings)) + self.assertIn(by_position[opening]['face'], ( + BlockFace.NORTH, BlockFace.SOUTH, BlockFace.WEST, + BlockFace.EAST, BlockFace.TOP, + )) + self.assertEqual(len(by_position[opening]['cursor']), 3) + + def test_range_is_measured_from_eye_to_interaction_surface(self): + opening = (3, 63, 0) + self.world.g.chunks.states[opening] = blocks.AIR + self.world.g.chunks.states[(3, 62, 0)] = blocks.STONE + + openings = self.world.find_cache_openings( + self.chest, center=opening) + by_position = {tuple(candidate['position']): candidate + for candidate in openings} + + self.assertIn(opening, by_position) + self.assertLessEqual(by_position[opening]['hit_distance'], 3) + + def test_sign_collision_shape_does_not_block_sight_but_solid_block_does(self): + start = (3.5, 65.62, 0.5) + target = (0.5, 64.875, 0.5) + sign_state = blocks.MCD_BLOCKS['oak_sign']['minStateId'] + obstruction = (1, 65, 0) + self.world.g.chunks.states[obstruction] = sign_state + + self.assertTrue(self.world._has_cache_line_of_sight( + start, target, self.chest)) + + self.world.g.chunks.states[obstruction] = blocks.STONE + self.assertFalse(self.world._has_cache_line_of_sight( + start, target, self.chest)) + + def test_virtual_chest_openings_are_found_before_placement(self): + target = (0, 65, 0) + for x in range(-3, 4): + for z in range(-3, 4): + self.world.g.chunks.states[(x, 64, z)] = blocks.STONE + + openings = self.world.find_cache_openings( + target, center=(3, 65, 0)) + + self.assertTrue(openings) + self.assertTrue(all(candidate['target'] == target + for candidate in openings)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_chest_detection.py b/tests/test_chest_detection.py index 863165f..5289b73 100644 --- a/tests/test_chest_detection.py +++ b/tests/test_chest_detection.py @@ -93,8 +93,8 @@ class ChestDetectionTests(unittest.TestCase): chunks=Munch(get_block_at=lambda *_position: None), world=Munch(_double_chest_positions=lambda _position: (self.position,))) - game.place_block = lambda location, face: sent.append( - (location, face)) + game.place_block = lambda location, face, **kwargs: sent.append( + (location, face, kwargs)) game.open_container(self.position) diff --git a/tests/test_sneak_placement.py b/tests/test_sneak_placement.py index e3a5175..cb0ddf7 100644 --- a/tests/test_sneak_placement.py +++ b/tests/test_sneak_placement.py @@ -2,7 +2,9 @@ import unittest from munch import Munch from minecraft.networking.modern import ClientTickEnd, UseItemOnBlockPacket +from minecraft.networking.types import BlockFace from mosfet.game import Game +from mosfet.info import blocks from mosfet.protocol.modern import PlayerInputPacket @@ -15,6 +17,28 @@ class FakeConnection: class SneakPlacementTests(unittest.TestCase): + def test_open_container_sends_visible_face_and_hit_cursor(self): + chest = (2, 65, -4) + connection = FakeConnection() + game = Game.__new__(Game) + game.g = Munch( + connection=connection, + chunks=Munch(get_block_at=lambda *position: + min(blocks.CHEST_IDS) if tuple(position) == chest + else None), + world=Munch()) + game.sequence = 0 + + game.open_container( + chest, face=BlockFace.EAST, cursor=(0.9375, 0.5, 0.5)) + + packet = next(packet for packet, _force in connection.packets + if isinstance(packet, UseItemOnBlockPacket)) + self.assertEqual(packet.location, chest) + self.assertEqual(packet.face, BlockFace.EAST) + self.assertEqual((packet.cursor_x, packet.cursor_y, packet.cursor_z), + (0.9375, 0.5, 0.5)) + def test_sneaking_wraps_block_placement_and_is_released(self): connection = FakeConnection() game = Game.__new__(Game)