From 659fa49ea1e332b6483f7ff3ecafea7b1ffe21f1 Mon Sep 17 00:00:00 2001 From: Tanner Date: Thu, 5 Nov 2026 03:00:13 +0000 Subject: [PATCH] Add inventory controls and enhance dashboard --- mosfet.py | 77 ++++++- mosfet/bot.py | 20 +- mosfet/commands.py | 16 +- mosfet/game.py | 135 +++++++++++- mosfet/protocol/managers.py | 12 + tests/test_inventory_commands.py | 51 +++++ tests/test_inventory_drain.py | 105 +++++++++ tests/test_web_inventory_api.py | 79 +++++++ web_interface/build/index.html | 18 +- web_interface/public/index.html | 18 +- web_interface/src/dashboard.css | 99 ++++++++- web_interface/src/dashboard.js | 367 ++++++++++++++++++++++++++++++- web_interface/src/index.html | 18 +- 13 files changed, 979 insertions(+), 36 deletions(-) create mode 100644 tests/test_inventory_commands.py create mode 100644 tests/test_web_inventory_api.py diff --git a/mosfet.py b/mosfet.py index 120eda2..e513d33 100644 --- a/mosfet.py +++ b/mosfet.py @@ -14,7 +14,7 @@ import logging log = logging.getLogger('werkzeug') log.setLevel(logging.ERROR) -from flask import Flask, abort, send_file +from flask import Flask, abort, jsonify, request, send_file from flask_cors import CORS build_folder = 'web_interface/build' app = Flask(__name__, template_folder=build_folder, static_folder=build_folder, static_url_path='') @@ -62,6 +62,38 @@ _item_icon_cache = Path(__file__).resolve().parent / 'web_interface' / 'item_cac _item_icon_locks = {} _item_icon_locks_guard = threading.Lock() +def _api_plain(value, depth=0): + """Convert item component values into bounded JSON-safe structures.""" + 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_plain(item, depth + 1) + for key, item in value.items()} + if isinstance(value, (list, tuple, set)): + return [_api_plain(item, depth + 1) for item in value] + if (type(value).__name__ in ('Slot', 'ItemStackData') and + all(hasattr(value, key) for key in + ('present', 'item_id', 'item_count'))): + return { + 'present': value.present, + 'item_id': value.item_id, + 'item_count': value.item_count, + 'components': _api_plain(getattr(value, 'components', {}), depth + 1), + 'components_unknown': bool(getattr(value, 'components_unknown', False)), + } + if hasattr(value, 'value'): + return _api_plain(value.value, depth + 1) + if hasattr(value, 'items'): + try: + return {str(key): _api_plain(item, depth + 1) + for key, item in value.items()} + except Exception: + pass + return str(value) + + def _api_json_default(value): """Serialize live protocol objects without invoking fragile __repr__ code.""" from minecraft.networking.packets import Packet @@ -72,11 +104,10 @@ def _api_json_default(value): return {'__type__': type(value).__name__, 'packet_name': getattr(value, 'packet_name', None), 'fields': fields} - if (type(value).__name__ == 'Slot' and + if (type(value).__name__ in ('Slot', 'ItemStackData') and all(hasattr(value, key) for key in ('present', 'item_id', 'item_count'))): - return {'present': value.present, 'item_id': value.item_id, - 'item_count': value.item_count} + return _api_plain(value) if all(hasattr(value, key) for key in ('x', 'y', 'z')): return {'x': value.x, 'y': value.y, 'z': value.z} if isinstance(value, (bytes, bytearray)): @@ -117,6 +148,44 @@ def api_items(): response=json.dumps(data), status=200, mimetype='application/json') +@app.route('/api/inventory/drop', methods=['POST']) +def api_inventory_drop(): + origin = request.headers.get('Origin') + if origin: + from urllib.parse import urlparse + if urlparse(origin).netloc.lower() != request.host.lower(): + return jsonify(ok=False, error='cross-origin inventory actions are blocked'), 403 + + payload = request.get_json(silent=True) or {} + if not isinstance(payload, dict): + return jsonify(ok=False, error='JSON body must be an object'), 400 + try: + slot = int(payload.get('slot')) + except (TypeError, ValueError): + return jsonify(ok=False, error='slot must be an integer'), 400 + action = payload.get('action') + if action not in ('one', 'stack', 'matching'): + return jsonify(ok=False, error='unknown inventory action'), 400 + if not g.get('connected') or not g.get('game'): + return jsonify(ok=False, error='bot is not connected'), 503 + + with g.reload_lock: + item = g.inv.get(slot) + if slot < 5 or slot > 45 or not item or not getattr(item, 'present', False): + return jsonify(ok=False, error='that inventory slot is empty or protected'), 409 + if action == 'matching': + count = g.game.start_dump(item.item_id) + if count is None: + return jsonify(ok=False, error='another inventory action is running'), 409 + if not count: + return jsonify(ok=False, error='no matching stacks to drop'), 409 + return jsonify(ok=True, action=action, stacks=count) + if not g.game.drop_inventory_slot( + slot, whole_stack=(action == 'stack')): + return jsonify(ok=False, error='another inventory action is running or the menu is open'), 409 + return jsonify(ok=True, action=action, slot=slot) + + def _item_texture_urls(name): root = 'https://assets.mcasset.cloud/26.3/assets/minecraft/textures' yield root + '/item/{}.png'.format(name) diff --git a/mosfet/bot.py b/mosfet/bot.py index a8eb0b5..71d5c61 100644 --- a/mosfet/bot.py +++ b/mosfet/bot.py @@ -289,6 +289,11 @@ def init(global_state): g.break_time = 0 g.dumping = None + g.dump_waiting = None + g.dump_dropped = 0 + g.dump_total = 0 + g.dump_error = None + g.drop_waiting = None g.draining = False g.item_lock = False g.command_lock = False @@ -413,6 +418,12 @@ def start_automation(global_state, initialize=True): g.item_lock = False g.minimum_cache_slots = g.get('minimum_cache_slots', 27) g.draining = False + g.dumping = None + g.dump_waiting = None + g.dump_dropped = 0 + g.dump_total = 0 + g.dump_error = None + g.drop_waiting = None g.drain_slots = [] g.drain_waiting = None g.drain_dropped = 0 @@ -437,6 +448,12 @@ def start_automation(global_state, initialize=True): # Preserve active work during hot reloads, but give the worker all fields # introduced by the currently migrated inventory controls. g.setdefault('draining', False) + g.setdefault('dumping', None) + g.setdefault('dump_waiting', None) + g.setdefault('dump_dropped', 0) + g.setdefault('dump_total', 0) + g.setdefault('dump_error', None) + g.setdefault('drop_waiting', None) g.setdefault('drain_slots', []) g.setdefault('drain_waiting', None) g.setdefault('drain_dropped', 0) @@ -485,7 +502,8 @@ def start_automation(global_state, initialize=True): movement_ready = initial_world_ready job_active = (g.job is not None and g.job.state != g.job.idle) has_work = (bool(g.path) or job_active or bool(g.breaking) or - bool(g.draining)) + bool(g.draining) or g.get('dumping') is not None or + bool(g.get('drop_waiting'))) if g.get('movement_paused'): set_status('paused') elif has_work and not movement_ready: diff --git a/mosfet/commands.py b/mosfet/commands.py index bfc48f6..b721386 100644 --- a/mosfet/commands.py +++ b/mosfet/commands.py @@ -67,8 +67,7 @@ class Commands: unsupported = { 'gather', 'trade', 'gapple', 'fill', - 'drop', 'dump', 'break', 'open', 'click', 'use', - 'interact', + 'break', 'open', 'click', 'use', 'interact', } if (command in unsupported or (command == 'farm' and not data.lower().startswith('crop'))): @@ -471,7 +470,7 @@ class Commands: ## 1drop - drops the current stack its holding if command == 'drop': - self.g.game.drop_stack() + reply = 'ok' if self.g.game.drop_stack() else 'empty or busy' ## 1select [id] - moves item with id into main hand if command == 'select' and data: @@ -484,16 +483,15 @@ class Commands: ## 1dump [id] - drops all items matching id if command == 'dump' and data: item = int(data) - if self.g.game.count_items([item]): - self.g.dumping = item - reply = 'ok' - else: - reply = 'not found' + count = self.g.game.start_dump(item) + reply = ('busy' if count is None else + 'ok' if count else 'not found') ## 1drain - drops all items in inventory if command == 'drain': count = self.g.game.start_drain() - reply = 'ok' if count else 'empty' + reply = ('busy' if count is None else + 'ok' if count else 'empty') if command == 'gapple': self.g.job.state = self.g.job.find_gapple diff --git a/mosfet/game.py b/mosfet/game.py index fcbd569..4802b20 100644 --- a/mosfet/game.py +++ b/mosfet/game.py @@ -8,7 +8,7 @@ from minecraft.networking.packets import Packet, clientbound, serverbound from minecraft.networking.types import BlockFace, Byte, Short, VarInt from minecraft.networking.modern import ( ClientTickEnd, ContainerClickSwapPacket, PlayerActionPacket, - SetCarriedItemPacket, UseItemOnBlockPacket, + SetCarriedItemPacket, SetHeldSlotPacket, UseItemOnBlockPacket, ) from mosfet.protocol.packets import ( @@ -45,7 +45,7 @@ class ContainerClickDropPacket(Packet): VarInt.send(self.container_id, packet_buffer) VarInt.send(self.state_id, packet_buffer) Short.send(self.slot, packet_buffer) - Byte.send(1, packet_buffer) # drop the whole stack + Byte.send(getattr(self, 'button', 1), packet_buffer) VarInt.send(4, packet_buffer) # ContainerInput.THROW VarInt.send(0, packet_buffer) # no changed-slot predictions VarInt.send(0, packet_buffer) # empty carried item stack @@ -166,6 +166,16 @@ class LegacyGame: if new_path: self.g.path = new_path + def handle_set_held_slot(self, packet): + slot = int(packet.slot) + if not 0 <= slot <= 8: + return + self.g.holding_slot = slot + self.g.holding_hand = 0 + item = self.g.inv.get(36 + slot) + self.g.holding = (item.item_id if item and + getattr(item, 'present', False) else 0) + def handle_time_update(self, packet): if hasattr(packet, 'time_of_day'): self.g.time = packet.time_of_day % 24000 @@ -665,6 +675,8 @@ class Game(LegacyGame): self.handle_set_health, SetHealthPacket) self.g.connection.register_packet_listener( self.handle_time_update, UpdateTimePacket) + self.g.connection.register_packet_listener( + self.handle_set_held_slot, SetHeldSlotPacket) if not self.g.get('health_update_received', False): self.g.health = None @@ -717,6 +729,118 @@ class Game(LegacyGame): ClientCommandPacket(action_id=0), force=True) self.g.connection.write_packet(ClientTickEnd(), force=True) + def _send_drop_click(self, slot, button=1): + state_id = self.g.inventory_state_ids.get(0, 0) + self.g.connection.write_packet(ContainerClickDropPacket( + container_id=0, state_id=state_id, slot=slot, + button=button), force=True) + self.g.connection.write_packet(ClientTickEnd(), force=True) + return state_id + + def drop_inventory_slot(self, slot, whole_stack=True): + """Drop one item or the full stack from a player inventory slot.""" + try: + slot = int(slot) + except (TypeError, ValueError): + return False + if (not 5 <= slot <= 45 or self.g.get('window') is not None or + self.g.get('draining') or self.g.get('dumping') is not None or + self.g.get('drop_waiting')): + return False + item = self.g.inv.get(slot) + if not item or not getattr(item, 'present', False): + return False + count = int(item.item_count or 0) + if count <= 0: + return False + button = 1 if whole_stack else 0 + state_id = self._send_drop_click(slot, button) + self.g.drop_waiting = { + 'slot': slot, 'item_id': item.item_id, + 'expected_count': 0 if whole_stack else count - 1, + 'sent_at': time.monotonic(), 'state_id': state_id, + } + return True + + def drop_stack(self): + """Drop the full stack in the currently held hand (26.3 click).""" + slot = (45 if self.g.get('holding_hand', 0) == 1 else + 36 + int(self.g.get('holding_slot', 0) or 0)) + return self.drop_inventory_slot(slot, whole_stack=True) + + def _tick_single_drop(self): + waiting = self.g.get('drop_waiting') + if not waiting: + return + item = self.g.inv.get(waiting['slot']) + remaining = (item.item_count if item and + getattr(item, 'present', False) and + item.item_id == waiting['item_id'] else 0) + if remaining <= waiting['expected_count']: + print('[drop] server confirmed slot {} updated'.format( + waiting['slot']), flush=True) + self.g.drop_waiting = None + elif time.monotonic() - waiting['sent_at'] > 3.0: + print('[drop] timed out waiting for inventory update on slot {}'.format( + waiting['slot']), flush=True) + self.g.drop_waiting = None + + def start_dump(self, item_id): + """Start dropping every stack of an item id from player inventory.""" + if (self.g.get('window') is not None or self.g.get('draining') or + self.g.get('dumping') is not None or self.g.get('drop_waiting')): + return None + slots = [slot for slot, item in self.g.inv.items() + if 5 <= slot <= 45 and getattr(item, 'present', False) + and item.item_id == item_id] + if not slots: + return 0 + self.g.dumping = item_id + self.g.dump_waiting = None + self.g.dump_dropped = 0 + self.g.dump_total = len(slots) + self.g.dump_error = None + return len(slots) + + def _tick_dump(self): + item_id = self.g.get('dumping') + if item_id is None: + return + waiting = self.g.get('dump_waiting') + if waiting: + item = self.g.inv.get(waiting['slot']) + if (not item or not getattr(item, 'present', False) or + item.item_id != item_id): + self.g.dump_dropped += 1 + print('[dump] dropped stack from slot {}; {}/{} complete'.format( + waiting['slot'], self.g.dump_dropped, + self.g.dump_total), flush=True) + self.g.dump_waiting = None + elif time.monotonic() - waiting['sent_at'] > 3.0: + self.g.dump_error = 'server did not clear inventory slot {}'.format( + waiting['slot']) + print('[dump] stopped: ' + self.g.dump_error, flush=True) + self.g.dumping = None + self.g.dump_waiting = None + return + else: + return + + for slot in range(5, 46): + item = self.g.inv.get(slot) + if item and getattr(item, 'present', False) and item.item_id == item_id: + state_id = self._send_drop_click(slot) + self.g.dump_waiting = { + 'slot': slot, 'item_count': item.item_count, + 'sent_at': time.monotonic(), 'state_id': state_id, + } + return + + print('[dump] complete: dropped {} stacks of item {}'.format( + self.g.dump_dropped, item_id), flush=True) + self.g.dumping = None + self.g.dump_waiting = None + def leave_bed(self): self._send_action(PlayerCommandPacket( entity_id=self.g.eid, action_id=0, jump_boost=0)) @@ -728,6 +852,10 @@ class Game(LegacyGame): pitch=float(self.g.get('pitch', 0.0)))) def start_drain(self): + if (self.g.get('window') is not None or + self.g.get('dumping') is not None or + self.g.get('drop_waiting')): + return None slots = [slot for slot, item in sorted(self.g.inv.items()) if 9 <= slot <= 45 and getattr(item, 'present', False)] self.g.drain_slots = slots @@ -787,6 +915,9 @@ class Game(LegacyGame): self.g.drain_dropped), flush=True) def tick(self): + self._tick_single_drop() + if self.g.get('dumping') is not None: + self._tick_dump() if self.g.get('draining'): self._tick_drain() if not self.g.get('breaking') or self.break_deadline is None: diff --git a/mosfet/protocol/managers.py b/mosfet/protocol/managers.py index fc3326b..f963946 100644 --- a/mosfet/protocol/managers.py +++ b/mosfet/protocol/managers.py @@ -471,6 +471,7 @@ class InventoryManager: self.g.inv = slots self.g.inventory_partial = packet.partial self.g.cursor = self.to_slot(packet.carried_item) + self._sync_held_item() elif (self.g.get('window') and packet.container_id == self.g.window.window_id): self.g.window.contents = slots @@ -488,6 +489,9 @@ class InventoryManager: if packet.container_id == 0: self.g.inv[packet.slot] = item self.g.item_lock = False + if (self.g.get('holding_hand', 0) == 0 and + packet.slot == 36 + self.g.get('holding_slot', 0)): + self.g.holding = item.item_id if item.present else 0 elif packet.container_id == -1 and packet.slot == -1: self.g.cursor = item elif (self.g.get('window') and @@ -501,6 +505,14 @@ class InventoryManager: self.g.inv[36 + offset - 27] = item self.g.item_lock = False + def _sync_held_item(self): + if self.g.get('holding_hand', 0) != 0: + return + hotbar_slot = int(self.g.get('holding_slot', 0) or 0) + item = self.g.inv.get(36 + hotbar_slot) + self.g.holding = (item.item_id if item and + getattr(item, 'present', False) else 0) + def handle_cursor(self, packet): if packet.item.components_unknown: self.g.inventory_partial = True diff --git a/tests/test_inventory_commands.py b/tests/test_inventory_commands.py new file mode 100644 index 0000000..7be6f89 --- /dev/null +++ b/tests/test_inventory_commands.py @@ -0,0 +1,51 @@ +import unittest + +from munch import Munch + +from mosfet.commands import Commands + + +class FakeChat: + def __init__(self): + self.sent = [] + + def set_handler(self, handler): + self.handler = handler + + def send(self, text): + self.sent.append(text) + + +class FakeGame: + def __init__(self): + self.dump_result = 2 + self.drop_result = True + self.dump_item = None + + def drop_stack(self): + return self.drop_result + + def start_dump(self, item_id): + self.dump_item = item_id + return self.dump_result + + +class TestInventoryCommands(unittest.TestCase): + def setUp(self): + self.chat = FakeChat() + self.game = FakeGame() + self.commands = Commands(Munch( + name='mosfet1', chat=self.chat, game=self.game)) + + def test_drop_command_is_enabled_for_bot(self): + self.commands.handle_chat(('CHAT', 'tester', 'mosfet1: 1drop')) + self.assertEqual(self.chat.sent, ['ok']) + + def test_dump_command_starts_dump_for_item_id(self): + self.commands.handle_chat(('CHAT', 'tester', 'mosfet1: 1dump 62')) + self.assertEqual(self.game.dump_item, 62) + self.assertEqual(self.chat.sent, ['ok']) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_inventory_drain.py b/tests/test_inventory_drain.py index 27d1560..efedf8f 100644 --- a/tests/test_inventory_drain.py +++ b/tests/test_inventory_drain.py @@ -30,6 +30,28 @@ class TestInventoryDrain(unittest.TestCase): self.assertEqual(payload.get_writable(), bytes.fromhex( '0007002d01040000')) + def test_server_held_slot_updates_drop_target(self): + game = Game.__new__(Game) + game.g = Munch( + inv={38: Slot(True, 864, 1)}, holding_slot=0, + holding_hand=1, holding=355, + ) + + game.handle_set_held_slot(Munch(slot=2)) + + self.assertEqual(game.g.holding_slot, 2) + self.assertEqual(game.g.holding_hand, 0) + self.assertEqual(game.g.holding, 864) + + def test_drop_packet_encodes_throw_one_item(self): + packet = ContainerClickDropPacket( + container_id=0, state_id=7, slot=45, button=0) + payload = PacketBuffer() + packet.write_fields(payload) + + self.assertEqual(payload.get_writable(), bytes.fromhex( + '0007002d00040000')) + def test_quick_move_packet_encodes_shift_click(self): packet = ContainerClickQuickMovePacket( container_id=3, state_id=7, slot=42) @@ -48,6 +70,89 @@ class TestInventoryDrain(unittest.TestCase): self.assertEqual(packet.inventory_type, 2) self.assertIsNone(packet.title) + def test_drop_stack_targets_currently_held_slot(self): + connection = FakeConnection() + game = Game.__new__(Game) + game.g = Munch( + inv={38: Slot(True, 62, 3)}, + inventory_state_ids={0: 7}, + holding_hand=0, holding_slot=2, + connection=connection, draining=False, dumping=None, + drop_waiting=None, + ) + + self.assertTrue(game.drop_stack()) + packet = connection.packets[0][0] + self.assertIsInstance(packet, ContainerClickDropPacket) + self.assertEqual((packet.container_id, packet.state_id, packet.slot), + (0, 7, 38)) + self.assertIsInstance(connection.packets[1][0], ClientTickEnd) + self.assertEqual(game.g.drop_waiting['slot'], 38) + + def test_drop_stack_uses_offhand_when_that_is_the_held_hand(self): + connection = FakeConnection() + game = Game.__new__(Game) + game.g = Munch( + inv={45: Slot(True, 355, 1)}, + inventory_state_ids={0: 2}, + holding_hand=1, holding_slot=0, + connection=connection, draining=False, dumping=None, + drop_waiting=None, + ) + + self.assertTrue(game.drop_stack()) + self.assertEqual(connection.packets[0][0].slot, 45) + + def test_dump_drops_every_matching_stack_sequentially(self): + connection = FakeConnection() + game = Game.__new__(Game) + game.g = Munch( + inv={9: Slot(True, 62, 5), 36: Slot(True, 62, 2), + 37: Slot(True, 1, 1)}, + inventory_state_ids={0: 3}, connection=connection, + draining=False, dumping=None, drop_waiting=None, + ) + + self.assertEqual(game.start_dump(62), 2) + game._tick_dump() + self.assertEqual(connection.packets[0][0].slot, 9) + + game.g.inventory_state_ids[0] = 4 + game.g.inv[9] = Slot(False) + game._tick_dump() + self.assertEqual(connection.packets[2][0].slot, 36) + self.assertEqual(game.g.dump_dropped, 1) + + game.g.inventory_state_ids[0] = 5 + game.g.inv[36] = Slot(False) + game._tick_dump() + self.assertIsNone(game.g.dumping) + self.assertEqual(game.g.dump_dropped, 2) + + def test_drop_one_waits_for_decreased_stack_count(self): + connection = FakeConnection() + game = Game.__new__(Game) + game.g = Munch( + inv={38: Slot(True, 62, 3)}, inventory_state_ids={0: 4}, + holding_hand=0, holding_slot=2, connection=connection, + draining=False, dumping=None, drop_waiting=None, window=None, + ) + + self.assertTrue(game.drop_inventory_slot(38, whole_stack=False)) + packet = connection.packets[0][0] + self.assertEqual((packet.slot, packet.button), (38, 0)) + waiting = game.g.drop_waiting + self.assertEqual(waiting['expected_count'], 2) + game.g.inv[38] = Slot(True, 62, 2) + game._tick_single_drop() + self.assertIsNone(game.g.drop_waiting) + + def test_dump_refuses_to_overlap_inventory_drain(self): + game = Game.__new__(Game) + game.g = Munch(inv={9: Slot(True, 62, 1)}, draining=True, + dumping=None, drop_waiting=None) + self.assertIsNone(game.start_dump(62)) + def test_drain_waits_for_authoritative_slot_update(self): connection = FakeConnection() game = Game.__new__(Game) diff --git a/tests/test_web_inventory_api.py b/tests/test_web_inventory_api.py new file mode 100644 index 0000000..423e590 --- /dev/null +++ b/tests/test_web_inventory_api.py @@ -0,0 +1,79 @@ +import importlib.util +from pathlib import Path +import threading +import unittest + +from mosfet.protocol.types import Slot + + +ENTRY = Path(__file__).resolve().parents[1] / 'mosfet.py' +SPEC = importlib.util.spec_from_file_location('mosfet_web_entry', ENTRY) +mosfet_web = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(mosfet_web) + + +class FakeGame: + def __init__(self): + self.drop_calls = [] + self.dump_item = None + self.dump_result = 2 + + def drop_inventory_slot(self, slot, whole_stack=True): + self.drop_calls.append((slot, whole_stack)) + return True + + def start_dump(self, item_id): + self.dump_item = item_id + return self.dump_result + + +class TestWebInventoryAPI(unittest.TestCase): + def setUp(self): + self.g = mosfet_web.g + self.original = { + key: self.g.get(key) for key in ('connected', 'game', 'inv') + } + self.game = FakeGame() + self.g.connected = True + self.g.game = self.game + self.g.inv = { + 9: Slot(True, 1145, 3, + components={'custom_name': {'text': 'Test Paper'}}) + } + self.g.reload_lock = threading.RLock() + self.client = mosfet_web.app.test_client() + + def tearDown(self): + for key, value in self.original.items(): + self.g[key] = value + + def test_inventory_api_exposes_slot_components(self): + response = self.client.get('/api/global') + self.assertEqual(response.status_code, 200) + item = response.get_json()['inv']['9'] + self.assertEqual(item['components']['custom_name']['text'], 'Test Paper') + + def test_drop_endpoint_supports_one_stack_and_matching_actions(self): + one = self.client.post('/api/inventory/drop', json={ + 'slot': 9, 'action': 'one'}) + stack = self.client.post('/api/inventory/drop', json={ + 'slot': 9, 'action': 'stack'}) + matching = self.client.post('/api/inventory/drop', json={ + 'slot': 9, 'action': 'matching'}) + + self.assertEqual(one.status_code, 200) + self.assertEqual(stack.status_code, 200) + self.assertEqual(matching.status_code, 200) + self.assertEqual(self.game.drop_calls, [(9, False), (9, True)]) + self.assertEqual(self.game.dump_item, 1145) + self.assertEqual(matching.get_json()['stacks'], 2) + + def test_drop_endpoint_rejects_cross_origin_actions(self): + response = self.client.post('/api/inventory/drop', json={ + 'slot': 9, 'action': 'stack'}, + headers={'Origin': 'https://elsewhere.example'}) + self.assertEqual(response.status_code, 403) + + +if __name__ == '__main__': + unittest.main() diff --git a/web_interface/build/index.html b/web_interface/build/index.html index da1d87a..6fb265d 100644 --- a/web_interface/build/index.html +++ b/web_interface/build/index.html @@ -6,8 +6,8 @@ Mosfet · Minecraft Bot - - + +
@@ -68,7 +68,7 @@ Some item components are not decoded yet; displayed contents may be partial.
-
Empty slot Hover an item for its name and stack size
+
Empty slot Selected hotbar slot Hover for details · click an item for actions