Track reopened supply chests and fix API snapshots
This commit is contained in:
@@ -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
@@ -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
@@ -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):
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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'})
|
||||||
|
|||||||
Reference in New Issue
Block a user