Track reopened supply chests and fix API snapshots

This commit is contained in:
Tanner
2026-11-07 17:02:29 +00:00
parent 43b37b76f6
commit 9251edc36a
8 changed files with 280 additions and 7 deletions
+19 -1
View File
@@ -94,6 +94,24 @@ def _api_plain(value, depth=0):
return str(value) return str(value)
def _api_json_safe(value, depth=0):
"""Normalize snapshots for JSON, including non-string dictionary keys."""
if depth > 12:
return '[nested data omitted]'
if value is None or isinstance(value, (str, int, float, bool)):
return value
if isinstance(value, dict):
return {str(key): _api_json_safe(item, depth + 1)
for key, item in value.items()}
if isinstance(value, (list, tuple, set)):
return [_api_json_safe(item, depth + 1) for item in value]
converted = _api_json_default(value)
if converted is value:
return str(value)
return _api_json_safe(converted, depth + 1)
def _api_json_default(value): def _api_json_default(value):
"""Serialize live protocol objects without invoking fragile __repr__ code.""" """Serialize live protocol objects without invoking fragile __repr__ code."""
from minecraft.networking.packets import Packet from minecraft.networking.packets import Packet
@@ -133,7 +151,7 @@ def api_global():
'dimension': chunks.dimension, 'dimension': chunks.dimension,
} }
response = app.response_class( response = app.response_class(
response=json.dumps(snapshot, default=_api_json_default), response=json.dumps(_api_json_safe(snapshot)),
status=200, status=200,
mimetype='application/json' mimetype='application/json'
) )
+4 -1
View File
@@ -87,12 +87,15 @@ def main(global_state=None, *, server=None, port=None, username=None,
from mosfet.world import World from mosfet.world import World
from mosfet.commands import Commands from mosfet.commands import Commands
from mosfet.protocol.modern import ModernChatManager from mosfet.protocol.modern import ModernChatManager
from mosfet.protocol.managers import EntityManager, InventoryManager from mosfet.protocol.managers import (
ChestOpenManager, EntityManager, InventoryManager,
)
from mosfet.bot import start_automation from mosfet.bot import start_automation
global_state.world = World(global_state) global_state.world = World(global_state)
global_state.chat = ModernChatManager(global_state) global_state.chat = ModernChatManager(global_state)
global_state.entities = EntityManager(global_state) global_state.entities = EntityManager(global_state)
global_state.inventory = InventoryManager(global_state) global_state.inventory = InventoryManager(global_state)
global_state.chest_open_manager = ChestOpenManager(global_state)
global_state.commands = Commands(global_state) global_state.commands = Commands(global_state)
start_automation(global_state) start_automation(global_state)
+8 -1
View File
@@ -385,8 +385,15 @@ class LegacyGame:
self.g.connection.write_packet(packet) self.g.connection.write_packet(packet)
def open_container(self, location): def open_container(self, location):
location = tuple(int(value) for value in location)
bid = self.g.chunks.get_block_at(*location) bid = self.g.chunks.get_block_at(*location)
# TODO: check if block is a chest?? block_name = blocks.BLOCKS.get(bid)
if (block_name and block_name != 'ender_chest' and
(block_name == 'chest' or block_name.endswith('_chest'))):
self.g.pending_chest_open = {
'position': location,
'requested_at': time.monotonic(),
}
self.place_block(location, BlockFace.TOP) self.place_block(location, BlockFace.TOP)
def handle_window(self, packet): def handle_window(self, packet):
+35 -2
View File
@@ -204,6 +204,28 @@ class GrabSuppliesStates:
self.ground_position = None self.ground_position = None
self.state = self.find_ground_items self.state = self.find_ground_items
@staticmethod
def _chest_event_key(event):
return (event.get('timestamp'), tuple(event.get('position', ())))
def _refresh_useless_chests(self):
events = self.g.get('chest_open_events', ())
current_events = {self._chest_event_key(event) for event in events}
for event in events:
event_key = self._chest_event_key(event)
if event_key in self._processed_chest_open_events:
continue
self._processed_chest_open_events.add(event_key)
position = tuple(event.get('position', ()))
reopened = [key for key in self.useless_chests
if position in key]
for key in reopened:
self.useless_chests.remove(key)
self.checked_supply_chests.discard(key)
print('Supply chest was opened by another player; checking it again:',
position)
self._processed_chest_open_events.intersection_update(current_events)
def find_supply_chests(self): def find_supply_chests(self):
print('Finding chests labeled for this bot\'s supplies...') print('Finding chests labeled for this bot\'s supplies...')
p = utils.pint(self.g.pos) p = utils.pint(self.g.pos)
@@ -215,10 +237,11 @@ class GrabSuppliesStates:
def choose_supply_chest(self): def choose_supply_chest(self):
print('Choosing a supply-labeled chest...') print('Choosing a supply-labeled chest...')
for container in self.supply_chests: for container in self.supply_chests:
key = container['positions'] key = tuple(container['positions'])
if key in self.checked_supply_chests: if key in self.useless_chests or key in self.checked_supply_chests:
continue continue
self.checked_supply_chests.add(key) self.checked_supply_chests.add(key)
self.current_supply_chest_key = key
self.chest = container['position'] self.chest = container['position']
self.area = self.chest self.area = self.chest
print('Chose supply chest:', self.chest) print('Chose supply chest:', self.chest)
@@ -286,6 +309,9 @@ class GrabSuppliesStates:
return return
print('No matching items in this supply chest') print('No matching items in this supply chest')
key = getattr(self, 'current_supply_chest_key', None)
if key is not None and key not in self.useless_chests:
self.useless_chests.append(key)
self.state = self.close_supply_chest self.state = self.close_supply_chest
def grab_items(self): def grab_items(self):
@@ -355,6 +381,12 @@ class GrabSuppliesStates:
self.supplies = self.default_supplies() self.supplies = self.default_supplies()
self.supply_chests = [] self.supply_chests = []
self.checked_supply_chests = set() self.checked_supply_chests = set()
self.useless_chests = []
self._processed_chest_open_events = {
self._chest_event_key(event)
for event in self.g.get('chest_open_events', ())
}
self.current_supply_chest_key = None
self.supply_chest = None self.supply_chest = None
self.checked_supplies = [] self.checked_supplies = []
self.target_items = None self.target_items = None
@@ -365,4 +397,5 @@ class GrabSuppliesStates:
self.wait_time = 0 self.wait_time = 0
def run(self): def run(self):
self._refresh_useless_chests()
self.state() self.state()
+74 -1
View File
@@ -1,10 +1,11 @@
import json import json
import time
from math import floor from math import floor
from munch import Munch from munch import Munch
from minecraft.networking.packets import clientbound, serverbound from minecraft.networking.packets import clientbound, serverbound
from minecraft.networking.modern import ( from minecraft.networking.modern import (
AddEntityPacket, BlockEntityDataPacket, ContainerOpenPacket, AddEntityPacket, BlockEntityDataPacket, BlockEventPacket, ContainerOpenPacket,
ContainerSetContentPacket, ContainerSetSlotPacket, SetEntityDataPacket, ContainerSetContentPacket, ContainerSetSlotPacket, SetEntityDataPacket,
nbt_plain, nbt_plain,
PaletteSection, PaletteSection,
@@ -355,6 +356,78 @@ class WorldState:
self.center, self.radius) self.center, self.radius)
class ChestOpenManager:
"""Track chest viewer-count events and report openings by other players."""
OWN_OPEN_TIMEOUT = 3.0
def __init__(self, global_state):
self.g = global_state
self.g.chest_viewers = self.g.get('chest_viewers') or {}
self.g.chest_open_events = self.g.get('chest_open_events') or []
self.g.last_chest_opened_by_other = None
self.g.connection.register_packet_listener(
self.handle_block_event, BlockEventPacket)
def _block_name(self, packet):
registry = getattr(self.g.connection, 'registry_ids', {}).get(
'minecraft:block', {})
name = next((name for name, block_id in registry.items()
if block_id == packet.block_id), None)
if name is not None:
return name.removeprefix('minecraft:')
location = packet.location
state_id = self.g.chunks.get_block_at(
location.x, location.y, location.z)
return blocks.BLOCKS.get(state_id)
def handle_block_event(self, packet):
block_name = self._block_name(packet)
if (block_name is None or block_name == 'ender_chest' or
not (block_name == 'chest' or block_name.endswith('_chest')) or
packet.action != 1):
return
location = packet.location
position = (location.x, location.y, location.z)
viewers = packet.parameter
previous_viewers = self.g.chest_viewers.get(position, 0)
if viewers:
self.g.chest_viewers[position] = viewers
else:
self.g.chest_viewers.pop(position, None)
pending = self.g.get('pending_chest_open')
pending_age = (time.monotonic() - pending.get('requested_at', 0)
if pending is not None else None)
pending_is_fresh = (pending_age is not None and
0 <= pending_age <= self.OWN_OPEN_TIMEOUT)
is_bot_opening = (
pending_is_fresh and
tuple(pending.get('position', ())) == position
)
if pending is not None and (is_bot_opening or not pending_is_fresh):
self.g.pending_chest_open = None
if (viewers <= previous_viewers or
(is_bot_opening and
viewers <= max(1, previous_viewers))):
return
event = {
'position': position,
'block': block_name,
'viewers': viewers,
'previous_viewers': previous_viewers,
'timestamp': time.time(),
}
self.g.last_chest_opened_by_other = event
self.g.chest_open_events.append(event)
del self.g.chest_open_events[:-100]
print('[chest] another player opened {} at {} ({} viewer{})'.format(
block_name, position, viewers, '' if viewers == 1 else 's'),
flush=True)
class EntityManager: class EntityManager:
def __init__(self, global_state): def __init__(self, global_state):
self.g = global_state self.g = global_state
+90
View File
@@ -0,0 +1,90 @@
import time
import unittest
from munch import Munch
from minecraft.networking.modern import BlockEventPacket
from mosfet.game import Game
from mosfet.protocol.managers import ChestOpenManager
class FakeConnection:
def __init__(self):
self.registry_ids = {'minecraft:block': {
'minecraft:stone': 1,
'minecraft:chest': 2,
'minecraft:trapped_chest': 3,
}}
self.listeners = []
def register_packet_listener(self, handler, packet_type):
self.listeners.append((handler, packet_type))
class ChestDetectionTests(unittest.TestCase):
def setUp(self):
self.connection = FakeConnection()
self.g = Munch(connection=self.connection)
self.manager = ChestOpenManager(self.g)
self.position = (12, 64, -7)
def event(self, block_id=2, action=1, viewers=1):
x, y, z = self.position
return Munch(
location=Munch(x=x, y=y, z=z),
block_id=block_id, action=action, parameter=viewers,
)
def test_registers_block_event_packet_and_records_other_player_open(self):
self.assertIn((self.manager.handle_block_event, BlockEventPacket),
self.connection.listeners)
self.manager.handle_block_event(self.event(viewers=1))
self.assertEqual(self.g.chest_viewers[self.position], 1)
self.assertEqual(self.g.last_chest_opened_by_other['position'],
self.position)
self.assertEqual(self.g.last_chest_opened_by_other['block'], 'chest')
self.assertEqual(len(self.g.chest_open_events), 1)
def test_bot_open_is_not_misreported_but_later_viewer_is(self):
self.g.pending_chest_open = {
'position': self.position,
'requested_at': time.monotonic(),
}
self.manager.handle_block_event(self.event(viewers=1))
self.assertIsNone(self.g.last_chest_opened_by_other)
self.assertIsNone(self.g.pending_chest_open)
self.manager.handle_block_event(self.event(viewers=2))
self.assertEqual(self.g.last_chest_opened_by_other['viewers'], 2)
self.assertEqual(self.g.last_chest_opened_by_other['previous_viewers'], 1)
def test_ignores_non_chests_and_tracks_close(self):
self.manager.handle_block_event(self.event(block_id=1, viewers=1))
self.assertIsNone(self.g.last_chest_opened_by_other)
self.manager.handle_block_event(self.event(viewers=1))
self.manager.handle_block_event(self.event(viewers=0))
self.assertNotIn(self.position, self.g.chest_viewers)
self.assertEqual(len(self.g.chest_open_events), 1)
def test_open_container_marks_bot_initiated_chest_open(self):
chest_state = 5552
sent = []
game = Game.__new__(Game)
game.g = Munch(chunks=Munch(
get_block_at=lambda *_position: chest_state))
game.place_block = lambda location, face: sent.append(
(location, face))
game.open_container(self.position)
self.assertEqual(sent[0][0], self.position)
self.assertEqual(game.g.pending_chest_open['position'], self.position)
self.assertIsInstance(game.g.pending_chest_open['requested_at'], float)
if __name__ == '__main__':
unittest.main()
+38
View File
@@ -78,6 +78,44 @@ class GroundSupplyTests(unittest.TestCase):
self.assertEqual(state.chest, position) self.assertEqual(state.chest, position)
self.assertIs(state.state.__func__, state.path_to_supply_chest.__func__) self.assertIs(state.state.__func__, state.path_to_supply_chest.__func__)
def test_empty_supply_chest_is_skipped_until_another_player_opens_it(self):
position = (4, 64, 0)
key = (position,)
event = {'position': position, 'timestamp': 1.0}
g = Munch(chest_open_events=[])
state = GrabSuppliesStates(g)
state.supplies = {(999999,): (1, 1)}
state.current_supply_chest_key = key
class FakeGame:
@staticmethod
def count_items(_item_ids):
return 1
state.g.game = FakeGame()
state.choose_items()
self.assertIn(key, state.useless_chests)
self.assertIs(state.state.__func__, state.close_supply_chest.__func__)
second_position = (8, 64, 0)
state.supply_chests = [
{'position': position, 'positions': key},
{'position': second_position, 'positions': (second_position,)},
]
state.checked_supply_chests = {key}
state.choose_supply_chest()
self.assertEqual(state.chest, second_position)
g.chest_open_events.append(event)
state.state = state.idle
state.run()
self.assertNotIn(key, state.useless_chests)
self.assertNotIn(key, state.checked_supply_chests)
state.supply_chests = [{'position': position, 'positions': key}]
state.choose_supply_chest()
self.assertEqual(state.chest, position)
def test_unreachable_supply_chest_ends_task_without_searching_barrels(self): def test_unreachable_supply_chest_ends_task_without_searching_barrels(self):
class FakeWorld: class FakeWorld:
@staticmethod @staticmethod
+12 -1
View File
@@ -31,7 +31,8 @@ class TestWebInventoryAPI(unittest.TestCase):
def setUp(self): def setUp(self):
self.g = mosfet_web.g self.g = mosfet_web.g
self.original = { self.original = {
key: self.g.get(key) for key in ('connected', 'game', 'inv') key: self.g.get(key) for key in
('connected', 'game', 'inv', 'chest_viewers')
} }
self.game = FakeGame() self.game = FakeGame()
self.g.connected = True self.g.connected = True
@@ -53,6 +54,16 @@ class TestWebInventoryAPI(unittest.TestCase):
item = response.get_json()['inv']['9'] item = response.get_json()['inv']['9']
self.assertEqual(item['components']['custom_name']['text'], 'Test Paper') self.assertEqual(item['components']['custom_name']['text'], 'Test Paper')
def test_global_api_stringifies_tuple_keys(self):
self.g.chest_viewers = {(12, 64, -7): 1}
response = self.client.get('/api/global')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.get_json()['chest_viewers'], {
'(12, 64, -7)': 1,
})
def test_drop_endpoint_supports_one_stack_and_matching_actions(self): def test_drop_endpoint_supports_one_stack_and_matching_actions(self):
one = self.client.post('/api/inventory/drop', json={ one = self.client.post('/api/inventory/drop', json={
'slot': 9, 'action': 'one'}) 'slot': 9, 'action': 'one'})