Add inventory controls and enhance dashboard
This commit is contained in:
@@ -14,7 +14,7 @@ import logging
|
|||||||
log = logging.getLogger('werkzeug')
|
log = logging.getLogger('werkzeug')
|
||||||
log.setLevel(logging.ERROR)
|
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
|
from flask_cors import CORS
|
||||||
build_folder = 'web_interface/build'
|
build_folder = 'web_interface/build'
|
||||||
app = Flask(__name__, template_folder=build_folder, static_folder=build_folder, static_url_path='')
|
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 = {}
|
||||||
_item_icon_locks_guard = threading.Lock()
|
_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):
|
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
|
||||||
@@ -72,11 +104,10 @@ def _api_json_default(value):
|
|||||||
return {'__type__': type(value).__name__,
|
return {'__type__': type(value).__name__,
|
||||||
'packet_name': getattr(value, 'packet_name', None),
|
'packet_name': getattr(value, 'packet_name', None),
|
||||||
'fields': fields}
|
'fields': fields}
|
||||||
if (type(value).__name__ == 'Slot' and
|
if (type(value).__name__ in ('Slot', 'ItemStackData') and
|
||||||
all(hasattr(value, key) for key in
|
all(hasattr(value, key) for key in
|
||||||
('present', 'item_id', 'item_count'))):
|
('present', 'item_id', 'item_count'))):
|
||||||
return {'present': value.present, 'item_id': value.item_id,
|
return _api_plain(value)
|
||||||
'item_count': value.item_count}
|
|
||||||
if all(hasattr(value, key) for key in ('x', 'y', 'z')):
|
if all(hasattr(value, key) for key in ('x', 'y', 'z')):
|
||||||
return {'x': value.x, 'y': value.y, 'z': value.z}
|
return {'x': value.x, 'y': value.y, 'z': value.z}
|
||||||
if isinstance(value, (bytes, bytearray)):
|
if isinstance(value, (bytes, bytearray)):
|
||||||
@@ -117,6 +148,44 @@ def api_items():
|
|||||||
response=json.dumps(data), status=200, mimetype='application/json')
|
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):
|
def _item_texture_urls(name):
|
||||||
root = 'https://assets.mcasset.cloud/26.3/assets/minecraft/textures'
|
root = 'https://assets.mcasset.cloud/26.3/assets/minecraft/textures'
|
||||||
yield root + '/item/{}.png'.format(name)
|
yield root + '/item/{}.png'.format(name)
|
||||||
|
|||||||
+19
-1
@@ -289,6 +289,11 @@ def init(global_state):
|
|||||||
g.break_time = 0
|
g.break_time = 0
|
||||||
|
|
||||||
g.dumping = None
|
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.draining = False
|
||||||
g.item_lock = False
|
g.item_lock = False
|
||||||
g.command_lock = False
|
g.command_lock = False
|
||||||
@@ -413,6 +418,12 @@ def start_automation(global_state, initialize=True):
|
|||||||
g.item_lock = False
|
g.item_lock = False
|
||||||
g.minimum_cache_slots = g.get('minimum_cache_slots', 27)
|
g.minimum_cache_slots = g.get('minimum_cache_slots', 27)
|
||||||
g.draining = False
|
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_slots = []
|
||||||
g.drain_waiting = None
|
g.drain_waiting = None
|
||||||
g.drain_dropped = 0
|
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
|
# Preserve active work during hot reloads, but give the worker all fields
|
||||||
# introduced by the currently migrated inventory controls.
|
# introduced by the currently migrated inventory controls.
|
||||||
g.setdefault('draining', False)
|
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_slots', [])
|
||||||
g.setdefault('drain_waiting', None)
|
g.setdefault('drain_waiting', None)
|
||||||
g.setdefault('drain_dropped', 0)
|
g.setdefault('drain_dropped', 0)
|
||||||
@@ -485,7 +502,8 @@ def start_automation(global_state, initialize=True):
|
|||||||
movement_ready = initial_world_ready
|
movement_ready = initial_world_ready
|
||||||
job_active = (g.job is not None and g.job.state != g.job.idle)
|
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
|
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'):
|
if g.get('movement_paused'):
|
||||||
set_status('paused')
|
set_status('paused')
|
||||||
elif has_work and not movement_ready:
|
elif has_work and not movement_ready:
|
||||||
|
|||||||
+7
-9
@@ -67,8 +67,7 @@ class Commands:
|
|||||||
|
|
||||||
unsupported = {
|
unsupported = {
|
||||||
'gather', 'trade', 'gapple', 'fill',
|
'gather', 'trade', 'gapple', 'fill',
|
||||||
'drop', 'dump', 'break', 'open', 'click', 'use',
|
'break', 'open', 'click', 'use', 'interact',
|
||||||
'interact',
|
|
||||||
}
|
}
|
||||||
if (command in unsupported or
|
if (command in unsupported or
|
||||||
(command == 'farm' and not data.lower().startswith('crop'))):
|
(command == 'farm' and not data.lower().startswith('crop'))):
|
||||||
@@ -471,7 +470,7 @@ class Commands:
|
|||||||
|
|
||||||
## 1drop - drops the current stack its holding
|
## 1drop - drops the current stack its holding
|
||||||
if command == 'drop':
|
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
|
## 1select [id] - moves item with id into main hand
|
||||||
if command == 'select' and data:
|
if command == 'select' and data:
|
||||||
@@ -484,16 +483,15 @@ class Commands:
|
|||||||
## 1dump [id] - drops all items matching id
|
## 1dump [id] - drops all items matching id
|
||||||
if command == 'dump' and data:
|
if command == 'dump' and data:
|
||||||
item = int(data)
|
item = int(data)
|
||||||
if self.g.game.count_items([item]):
|
count = self.g.game.start_dump(item)
|
||||||
self.g.dumping = item
|
reply = ('busy' if count is None else
|
||||||
reply = 'ok'
|
'ok' if count else 'not found')
|
||||||
else:
|
|
||||||
reply = 'not found'
|
|
||||||
|
|
||||||
## 1drain - drops all items in inventory
|
## 1drain - drops all items in inventory
|
||||||
if command == 'drain':
|
if command == 'drain':
|
||||||
count = self.g.game.start_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':
|
if command == 'gapple':
|
||||||
self.g.job.state = self.g.job.find_gapple
|
self.g.job.state = self.g.job.find_gapple
|
||||||
|
|||||||
+133
-2
@@ -8,7 +8,7 @@ from minecraft.networking.packets import Packet, clientbound, serverbound
|
|||||||
from minecraft.networking.types import BlockFace, Byte, Short, VarInt
|
from minecraft.networking.types import BlockFace, Byte, Short, VarInt
|
||||||
from minecraft.networking.modern import (
|
from minecraft.networking.modern import (
|
||||||
ClientTickEnd, ContainerClickSwapPacket, PlayerActionPacket,
|
ClientTickEnd, ContainerClickSwapPacket, PlayerActionPacket,
|
||||||
SetCarriedItemPacket, UseItemOnBlockPacket,
|
SetCarriedItemPacket, SetHeldSlotPacket, UseItemOnBlockPacket,
|
||||||
)
|
)
|
||||||
|
|
||||||
from mosfet.protocol.packets import (
|
from mosfet.protocol.packets import (
|
||||||
@@ -45,7 +45,7 @@ class ContainerClickDropPacket(Packet):
|
|||||||
VarInt.send(self.container_id, packet_buffer)
|
VarInt.send(self.container_id, packet_buffer)
|
||||||
VarInt.send(self.state_id, packet_buffer)
|
VarInt.send(self.state_id, packet_buffer)
|
||||||
Short.send(self.slot, 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(4, packet_buffer) # ContainerInput.THROW
|
||||||
VarInt.send(0, packet_buffer) # no changed-slot predictions
|
VarInt.send(0, packet_buffer) # no changed-slot predictions
|
||||||
VarInt.send(0, packet_buffer) # empty carried item stack
|
VarInt.send(0, packet_buffer) # empty carried item stack
|
||||||
@@ -166,6 +166,16 @@ class LegacyGame:
|
|||||||
if new_path:
|
if new_path:
|
||||||
self.g.path = 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):
|
def handle_time_update(self, packet):
|
||||||
if hasattr(packet, 'time_of_day'):
|
if hasattr(packet, 'time_of_day'):
|
||||||
self.g.time = packet.time_of_day % 24000
|
self.g.time = packet.time_of_day % 24000
|
||||||
@@ -665,6 +675,8 @@ class Game(LegacyGame):
|
|||||||
self.handle_set_health, SetHealthPacket)
|
self.handle_set_health, SetHealthPacket)
|
||||||
self.g.connection.register_packet_listener(
|
self.g.connection.register_packet_listener(
|
||||||
self.handle_time_update, UpdateTimePacket)
|
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):
|
if not self.g.get('health_update_received', False):
|
||||||
self.g.health = None
|
self.g.health = None
|
||||||
@@ -717,6 +729,118 @@ class Game(LegacyGame):
|
|||||||
ClientCommandPacket(action_id=0), force=True)
|
ClientCommandPacket(action_id=0), force=True)
|
||||||
self.g.connection.write_packet(ClientTickEnd(), 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):
|
def leave_bed(self):
|
||||||
self._send_action(PlayerCommandPacket(
|
self._send_action(PlayerCommandPacket(
|
||||||
entity_id=self.g.eid, action_id=0, jump_boost=0))
|
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))))
|
pitch=float(self.g.get('pitch', 0.0))))
|
||||||
|
|
||||||
def start_drain(self):
|
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())
|
slots = [slot for slot, item in sorted(self.g.inv.items())
|
||||||
if 9 <= slot <= 45 and getattr(item, 'present', False)]
|
if 9 <= slot <= 45 and getattr(item, 'present', False)]
|
||||||
self.g.drain_slots = slots
|
self.g.drain_slots = slots
|
||||||
@@ -787,6 +915,9 @@ class Game(LegacyGame):
|
|||||||
self.g.drain_dropped), flush=True)
|
self.g.drain_dropped), flush=True)
|
||||||
|
|
||||||
def tick(self):
|
def tick(self):
|
||||||
|
self._tick_single_drop()
|
||||||
|
if self.g.get('dumping') is not None:
|
||||||
|
self._tick_dump()
|
||||||
if self.g.get('draining'):
|
if self.g.get('draining'):
|
||||||
self._tick_drain()
|
self._tick_drain()
|
||||||
if not self.g.get('breaking') or self.break_deadline is None:
|
if not self.g.get('breaking') or self.break_deadline is None:
|
||||||
|
|||||||
@@ -471,6 +471,7 @@ class InventoryManager:
|
|||||||
self.g.inv = slots
|
self.g.inv = slots
|
||||||
self.g.inventory_partial = packet.partial
|
self.g.inventory_partial = packet.partial
|
||||||
self.g.cursor = self.to_slot(packet.carried_item)
|
self.g.cursor = self.to_slot(packet.carried_item)
|
||||||
|
self._sync_held_item()
|
||||||
elif (self.g.get('window') and
|
elif (self.g.get('window') and
|
||||||
packet.container_id == self.g.window.window_id):
|
packet.container_id == self.g.window.window_id):
|
||||||
self.g.window.contents = slots
|
self.g.window.contents = slots
|
||||||
@@ -488,6 +489,9 @@ class InventoryManager:
|
|||||||
if packet.container_id == 0:
|
if packet.container_id == 0:
|
||||||
self.g.inv[packet.slot] = item
|
self.g.inv[packet.slot] = item
|
||||||
self.g.item_lock = False
|
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:
|
elif packet.container_id == -1 and packet.slot == -1:
|
||||||
self.g.cursor = item
|
self.g.cursor = item
|
||||||
elif (self.g.get('window') and
|
elif (self.g.get('window') and
|
||||||
@@ -501,6 +505,14 @@ class InventoryManager:
|
|||||||
self.g.inv[36 + offset - 27] = item
|
self.g.inv[36 + offset - 27] = item
|
||||||
self.g.item_lock = False
|
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):
|
def handle_cursor(self, packet):
|
||||||
if packet.item.components_unknown:
|
if packet.item.components_unknown:
|
||||||
self.g.inventory_partial = True
|
self.g.inventory_partial = True
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -30,6 +30,28 @@ class TestInventoryDrain(unittest.TestCase):
|
|||||||
self.assertEqual(payload.get_writable(), bytes.fromhex(
|
self.assertEqual(payload.get_writable(), bytes.fromhex(
|
||||||
'0007002d01040000'))
|
'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):
|
def test_quick_move_packet_encodes_shift_click(self):
|
||||||
packet = ContainerClickQuickMovePacket(
|
packet = ContainerClickQuickMovePacket(
|
||||||
container_id=3, state_id=7, slot=42)
|
container_id=3, state_id=7, slot=42)
|
||||||
@@ -48,6 +70,89 @@ class TestInventoryDrain(unittest.TestCase):
|
|||||||
self.assertEqual(packet.inventory_type, 2)
|
self.assertEqual(packet.inventory_type, 2)
|
||||||
self.assertIsNone(packet.title)
|
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):
|
def test_drain_waits_for_authoritative_slot_update(self):
|
||||||
connection = FakeConnection()
|
connection = FakeConnection()
|
||||||
game = Game.__new__(Game)
|
game = Game.__new__(Game)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -6,8 +6,8 @@
|
|||||||
<meta name="theme-color" content="#10130f" />
|
<meta name="theme-color" content="#10130f" />
|
||||||
<meta name="description" content="Live Minecraft bot dashboard" />
|
<meta name="description" content="Live Minecraft bot dashboard" />
|
||||||
<title>Mosfet · Minecraft Bot</title>
|
<title>Mosfet · Minecraft Bot</title>
|
||||||
<link rel="stylesheet" href="/static/css/dashboard.css?v=4" />
|
<link rel="stylesheet" href="/static/css/dashboard.css?v=16" />
|
||||||
<script defer src="/static/js/dashboard.js?v=7"></script>
|
<script defer src="/static/js/dashboard.js?v=14"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
Some item components are not decoded yet; displayed contents may be partial.
|
Some item components are not decoded yet; displayed contents may be partial.
|
||||||
</div>
|
</div>
|
||||||
<div id="inventory-grid" class="inventory-grid" aria-label="Minecraft inventory"></div>
|
<div id="inventory-grid" class="inventory-grid" aria-label="Minecraft inventory"></div>
|
||||||
<div class="inventory-legend"><span class="legend-swatch"></span> Empty slot <span class="legend-note">Hover an item for its name and stack size</span></div>
|
<div class="inventory-legend"><span class="legend-swatch"></span> Empty slot <span class="legend-selected"></span> Selected hotbar slot <span class="legend-note">Hover for details · click an item for actions</span></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside class="side-column">
|
<aside class="side-column">
|
||||||
@@ -103,6 +103,18 @@
|
|||||||
</footer>
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="item-tooltip" class="item-tooltip" role="tooltip" hidden></div>
|
||||||
|
<div id="inventory-menu" class="inventory-menu" role="menu" aria-label="Item actions" hidden>
|
||||||
|
<div class="inventory-menu-heading">
|
||||||
|
<strong id="inventory-menu-title">Item</strong>
|
||||||
|
<span id="inventory-menu-meta"></span>
|
||||||
|
</div>
|
||||||
|
<button type="button" data-inventory-action="one" role="menuitem">Drop one</button>
|
||||||
|
<button type="button" data-inventory-action="stack" role="menuitem">Drop whole stack</button>
|
||||||
|
<button type="button" data-inventory-action="matching" role="menuitem">Drop all matching</button>
|
||||||
|
<div id="inventory-menu-status" class="inventory-menu-status" aria-live="polite" hidden></div>
|
||||||
|
<button type="button" class="menu-cancel" data-inventory-action="cancel" role="menuitem">Cancel</button>
|
||||||
|
</div>
|
||||||
<div id="toast" class="toast" role="status" aria-live="polite"></div>
|
<div id="toast" class="toast" role="status" aria-live="polite"></div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
<meta name="theme-color" content="#10130f" />
|
<meta name="theme-color" content="#10130f" />
|
||||||
<meta name="description" content="Live Minecraft bot dashboard" />
|
<meta name="description" content="Live Minecraft bot dashboard" />
|
||||||
<title>Mosfet · Minecraft Bot</title>
|
<title>Mosfet · Minecraft Bot</title>
|
||||||
<link rel="stylesheet" href="/static/css/dashboard.css?v=4" />
|
<link rel="stylesheet" href="/static/css/dashboard.css?v=16" />
|
||||||
<script defer src="/static/js/dashboard.js?v=7"></script>
|
<script defer src="/static/js/dashboard.js?v=14"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
Some item components are not decoded yet; displayed contents may be partial.
|
Some item components are not decoded yet; displayed contents may be partial.
|
||||||
</div>
|
</div>
|
||||||
<div id="inventory-grid" class="inventory-grid" aria-label="Minecraft inventory"></div>
|
<div id="inventory-grid" class="inventory-grid" aria-label="Minecraft inventory"></div>
|
||||||
<div class="inventory-legend"><span class="legend-swatch"></span> Empty slot <span class="legend-note">Hover an item for its name and stack size</span></div>
|
<div class="inventory-legend"><span class="legend-swatch"></span> Empty slot <span class="legend-selected"></span> Selected hotbar slot <span class="legend-note">Hover for details · click an item for actions</span></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside class="side-column">
|
<aside class="side-column">
|
||||||
@@ -103,6 +103,18 @@
|
|||||||
</footer>
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="item-tooltip" class="item-tooltip" role="tooltip" hidden></div>
|
||||||
|
<div id="inventory-menu" class="inventory-menu" role="menu" aria-label="Item actions" hidden>
|
||||||
|
<div class="inventory-menu-heading">
|
||||||
|
<strong id="inventory-menu-title">Item</strong>
|
||||||
|
<span id="inventory-menu-meta"></span>
|
||||||
|
</div>
|
||||||
|
<button type="button" data-inventory-action="one" role="menuitem">Drop one</button>
|
||||||
|
<button type="button" data-inventory-action="stack" role="menuitem">Drop whole stack</button>
|
||||||
|
<button type="button" data-inventory-action="matching" role="menuitem">Drop all matching</button>
|
||||||
|
<div id="inventory-menu-status" class="inventory-menu-status" aria-live="polite" hidden></div>
|
||||||
|
<button type="button" class="menu-cancel" data-inventory-action="cancel" role="menuitem">Cancel</button>
|
||||||
|
</div>
|
||||||
<div id="toast" class="toast" role="status" aria-live="polite"></div>
|
<div id="toast" class="toast" role="status" aria-live="polite"></div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -83,10 +83,45 @@ h1 { margin: 5px 0 3px; font-size: 34px; line-height: 1.05; letter-spacing: -.02
|
|||||||
h2 { margin: 3px 0 0; font-size: 20px; letter-spacing: -.01em; font-weight: 600; }
|
h2 { margin: 3px 0 0; font-size: 20px; letter-spacing: -.01em; font-weight: 600; }
|
||||||
.inventory-summary { display: flex; gap: 8px; align-items: center; color: var(--muted); font: 11px var(--mono); }
|
.inventory-summary { display: flex; gap: 8px; align-items: center; color: var(--muted); font: 11px var(--mono); }
|
||||||
.heading-divider { width: 1px; height: 12px; background: var(--border); }
|
.heading-divider { width: 1px; height: 12px; background: var(--border); }
|
||||||
.inventory-grid { display: grid; grid-template-columns: repeat(9, minmax(0, 1fr)); gap: 4px; padding: 8px; background: #111411; border: 1px solid var(--border-soft); border-radius: 3px; }
|
.inventory-grid { --slot-gap: 4px; display: grid; gap: 8px; padding: 8px; background: #111411; border: 1px solid var(--border-soft); border-radius: 3px; }
|
||||||
|
.inventory-upper { display: grid; grid-template-columns: repeat(10, minmax(0, 1fr)); gap: var(--slot-gap); align-items: start; }
|
||||||
|
.inventory-lower-headings, .inventory-lower-grid { display: grid; grid-template-columns: repeat(10, minmax(0, 1fr)); column-gap: 10px; row-gap: var(--slot-gap); align-items: start; }
|
||||||
|
.inventory-lower-headings .inventory-group-label { margin-bottom: 0; }
|
||||||
|
.inventory-lower-grid { align-items: stretch; }
|
||||||
|
.inventory-group { min-width: 0; }
|
||||||
|
.inventory-group-label { min-height: 14px; margin-bottom: 4px; color: var(--subtle); font: 9px var(--mono); letter-spacing: .05em; }
|
||||||
|
.upper-spacer { grid-column: 1; }
|
||||||
|
.crafting-group { grid-column: 2 / span 8; }
|
||||||
|
.crafting-labels { display: grid; grid-template-columns: repeat(8, minmax(0, 1fr)); gap: var(--slot-gap); }
|
||||||
|
.crafting-label-offhand { grid-column: 1; }
|
||||||
|
.crafting-label-grid { grid-column: 3 / span 4; }
|
||||||
|
.crafting-label-output { grid-column: 8; }
|
||||||
|
.crafting-layout { display: grid; grid-template-columns: repeat(8, minmax(0, 1fr)); gap: var(--slot-gap); align-items: center; }
|
||||||
|
.crafting-input-1 { grid-column: 3; }
|
||||||
|
.crafting-input-2 { grid-column: 4; }
|
||||||
|
.crafting-input-3 { grid-column: 5; }
|
||||||
|
.crafting-input-4 { grid-column: 6; }
|
||||||
|
.crafting-offhand { grid-column: 1; }
|
||||||
|
.crafting-arrow { grid-column: 7; display: grid; place-items: center; color: var(--muted); font: 22px var(--sans); }
|
||||||
|
.crafting-result { grid-column: 8; }
|
||||||
|
.inventory-lower-headings > :first-child, .armor-slots { grid-column: 1; }
|
||||||
|
.inventory-lower-headings > :last-child { grid-column: 2 / span 9; }
|
||||||
|
.armor-slots { display: flex; height: 100%; flex-direction: column; justify-content: flex-start; gap: var(--slot-gap); }
|
||||||
|
.inventory-body { grid-column: 2 / span 9; display: grid; row-gap: 6px; }
|
||||||
|
.hotbar-label { min-height: 12px; color: var(--subtle); font: 9px/12px var(--mono); letter-spacing: .05em; }
|
||||||
|
.main-inventory { display: grid; grid-template-columns: repeat(9, minmax(0, 1fr)); gap: var(--slot-gap); }
|
||||||
|
.hotbar-inventory { display: grid; grid-template-columns: repeat(9, minmax(0, 1fr)); gap: var(--slot-gap); }
|
||||||
.inventory-slot { aspect-ratio: 1; min-width: 0; position: relative; display: grid; place-items: center; border: 1px solid #3d433d; border-radius: 2px; background: var(--slot-empty); }
|
.inventory-slot { aspect-ratio: 1; min-width: 0; position: relative; display: grid; place-items: center; border: 1px solid #3d433d; border-radius: 2px; background: var(--slot-empty); }
|
||||||
.inventory-slot.occupied { border-color: #596259; background: var(--slot); }
|
.inventory-slot.occupied { border-color: #596259; background: var(--slot); }
|
||||||
|
.inventory-slot.interactive { cursor: pointer; }
|
||||||
|
.inventory-slot.interactive:hover { border-color: #d7e8cf; background: #343b33; }
|
||||||
|
.inventory-slot.interactive:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; z-index: 2; }
|
||||||
.inventory-slot.hotbar-slot { border-bottom: 2px solid #66834e; }
|
.inventory-slot.hotbar-slot { border-bottom: 2px solid #66834e; }
|
||||||
|
.inventory-slot.selected-slot {
|
||||||
|
z-index: 2;
|
||||||
|
border-color: #f5f6e8;
|
||||||
|
box-shadow: inset 0 0 0 2px #e9ead8, 0 0 0 1px #121411;
|
||||||
|
}
|
||||||
.slot-number { position: absolute; top: 2px; left: 3px; z-index: 1; color: #c3cbc1; font: 10px var(--mono); }
|
.slot-number { position: absolute; top: 2px; left: 3px; z-index: 1; color: #c3cbc1; font: 10px var(--mono); }
|
||||||
.item-art { width: 67%; height: 67%; display: grid; place-items: center; }
|
.item-art { width: 67%; height: 67%; display: grid; place-items: center; }
|
||||||
.item-art img { display: block; width: 100%; height: 100%; object-fit: contain; image-rendering: pixelated; filter: drop-shadow(1px 1px 1px #000); }
|
.item-art img { display: block; width: 100%; height: 100%; object-fit: contain; image-rendering: pixelated; filter: drop-shadow(1px 1px 1px #000); }
|
||||||
@@ -95,6 +130,7 @@ h2 { margin: 3px 0 0; font-size: 20px; letter-spacing: -.01em; font-weight: 600;
|
|||||||
.stack-count { position: absolute; z-index: 2; right: 2px; bottom: 0; color: #fff; line-height: .9; font: 700 24px var(--mono); -webkit-text-stroke: 1px #000; text-shadow: 2px 2px 2px #000, -2px -2px 2px #000; }
|
.stack-count { position: absolute; z-index: 2; right: 2px; bottom: 0; color: #fff; line-height: .9; font: 700 24px var(--mono); -webkit-text-stroke: 1px #000; text-shadow: 2px 2px 2px #000, -2px -2px 2px #000; }
|
||||||
.inventory-legend { display: flex; align-items: center; gap: 6px; margin-top: 8px; color: var(--muted); font-size: 11px; }
|
.inventory-legend { display: flex; align-items: center; gap: 6px; margin-top: 8px; color: var(--muted); font-size: 11px; }
|
||||||
.legend-swatch { width: 8px; height: 8px; border: 1px solid var(--border); border-radius: 1px; }
|
.legend-swatch { width: 8px; height: 8px; border: 1px solid var(--border); border-radius: 1px; }
|
||||||
|
.legend-selected { width: 10px; height: 10px; margin-left: 12px; border: 2px solid #f5f6e8; border-radius: 1px; }
|
||||||
.legend-note { margin-left: auto; color: var(--subtle); }
|
.legend-note { margin-left: auto; color: var(--subtle); }
|
||||||
.inventory-warning { margin: 0 0 9px; padding: 7px 9px; border: 1px solid #927542; border-radius: 3px; background: #332b1c; color: #f0c36c; font-size: 12px; }
|
.inventory-warning { margin: 0 0 9px; padding: 7px 9px; border: 1px solid #927542; border-radius: 3px; background: #332b1c; color: #f0c36c; font-size: 12px; }
|
||||||
|
|
||||||
@@ -119,6 +155,65 @@ h2 { margin: 3px 0 0; font-size: 20px; letter-spacing: -.01em; font-weight: 600;
|
|||||||
.footer-mark { color: var(--accent); font-size: 7px; }
|
.footer-mark { color: var(--accent); font-size: 7px; }
|
||||||
.toast { position: fixed; right: 12px; bottom: 12px; max-width: 340px; padding: 9px 12px; border: 1px solid var(--border); border-radius: 3px; color: var(--text); background: var(--panel); opacity: 0; transform: translateY(5px); transition: .15s; pointer-events: none; }
|
.toast { position: fixed; right: 12px; bottom: 12px; max-width: 340px; padding: 9px 12px; border: 1px solid var(--border); border-radius: 3px; color: var(--text); background: var(--panel); opacity: 0; transform: translateY(5px); transition: .15s; pointer-events: none; }
|
||||||
.toast.visible { opacity: 1; transform: translateY(0); }
|
.toast.visible { opacity: 1; transform: translateY(0); }
|
||||||
|
.toast.is-error { border-color: #8f4848; color: #ff9999; }
|
||||||
|
|
||||||
|
.item-tooltip {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1000;
|
||||||
|
width: max-content;
|
||||||
|
max-width: min(320px, calc(100vw - 16px));
|
||||||
|
max-height: calc(100vh - 16px);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 7px 9px;
|
||||||
|
border: 2px solid #2b1744;
|
||||||
|
outline: 1px solid #09070d;
|
||||||
|
color: #f5f5f5;
|
||||||
|
background: linear-gradient(135deg, rgba(16, 0, 32, .98), rgba(24, 14, 38, .98));
|
||||||
|
box-shadow: 3px 3px 0 rgba(0, 0, 0, .75);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.35;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.item-tooltip[hidden], .inventory-menu[hidden] { display: none; }
|
||||||
|
.tooltip-name { margin-bottom: 2px; color: #fff; font-size: 13px; font-weight: 700; }
|
||||||
|
.tooltip-name.rarity-uncommon { color: #ffff55; }
|
||||||
|
.tooltip-name.rarity-rare { color: #55ffff; }
|
||||||
|
.tooltip-name.rarity-epic { color: #ff55ff; }
|
||||||
|
.tooltip-meta { color: #aaa; font: 10px var(--mono); }
|
||||||
|
.tooltip-divider { height: 1px; margin: 5px 0; background: #746181; }
|
||||||
|
.tooltip-property { overflow-wrap: anywhere; color: #ddd; }
|
||||||
|
.tooltip-enchantment { color: #b9a5ff; }
|
||||||
|
.tooltip-lore { color: #aaaaff; font-style: italic; }
|
||||||
|
.tooltip-muted { color: #999; }
|
||||||
|
.tooltip-durability { display: flex; justify-content: space-between; gap: 10px; color: #ddd; }
|
||||||
|
.tooltip-durability b { font-weight: 400; }
|
||||||
|
.tooltip-durability i { height: 3px; flex: 0 0 48px; align-self: center; overflow: hidden; background: #4b2525; }
|
||||||
|
.tooltip-durability em { display: block; height: 100%; background: #62c752; }
|
||||||
|
|
||||||
|
.inventory-menu {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1001;
|
||||||
|
display: grid;
|
||||||
|
width: 224px;
|
||||||
|
max-height: calc(100vh - 16px);
|
||||||
|
overflow-y: auto;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 6px;
|
||||||
|
border: 1px solid #596259;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: #171a17;
|
||||||
|
box-shadow: 0 8px 24px #000b;
|
||||||
|
}
|
||||||
|
.inventory-menu-heading { display: grid; gap: 2px; padding: 5px 6px 7px; border-bottom: 1px solid var(--border); }
|
||||||
|
.inventory-menu-heading strong { overflow: hidden; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.inventory-menu-heading span { color: var(--muted); font: 10px var(--mono); }
|
||||||
|
.inventory-menu button { min-height: 32px; padding: 6px 8px; border: 1px solid transparent; border-radius: 2px; color: var(--text); background: transparent; text-align: left; font: 12px var(--sans); cursor: pointer; }
|
||||||
|
.inventory-menu button:hover:not(:disabled) { border-color: #506044; background: #2a3227; }
|
||||||
|
.inventory-menu button:focus-visible { outline: 2px solid var(--accent); }
|
||||||
|
.inventory-menu button:disabled { color: #747a73; cursor: not-allowed; }
|
||||||
|
.inventory-menu .menu-cancel { margin-top: 2px; border-top: 1px solid var(--border-soft); border-radius: 0; color: var(--muted); }
|
||||||
|
.inventory-menu-status { padding: 4px 6px; color: var(--amber); font-size: 11px; overflow-wrap: anywhere; }
|
||||||
|
.inventory-menu-status.is-error { color: var(--red); }
|
||||||
|
|
||||||
@media (max-width: 850px) {
|
@media (max-width: 850px) {
|
||||||
.shell { width: min(100% - 24px, 700px); }
|
.shell { width: min(100% - 24px, 700px); }
|
||||||
@@ -139,7 +234,7 @@ h2 { margin: 3px 0 0; font-size: 20px; letter-spacing: -.01em; font-weight: 600;
|
|||||||
.content-grid { gap: 5px; margin-top: 5px; }
|
.content-grid { gap: 5px; margin-top: 5px; }
|
||||||
.inventory-panel { padding: 10px 7px; }
|
.inventory-panel { padding: 10px 7px; }
|
||||||
.inventory-summary { font-size: 10px; gap: 5px; }
|
.inventory-summary { font-size: 10px; gap: 5px; }
|
||||||
.inventory-grid { gap: 3px; padding: 5px; }
|
.inventory-grid { --slot-gap: 3px; gap: 6px; padding: 5px; }
|
||||||
.slot-number { top: 2px; left: 2px; font-size: 9px; }
|
.slot-number { top: 2px; left: 2px; font-size: 9px; }
|
||||||
.stack-count { right: 1px; bottom: 0; font-size: 20px; }
|
.stack-count { right: 1px; bottom: 0; font-size: 20px; }
|
||||||
.side-column { grid-template-columns: 1fr; gap: 5px; }
|
.side-column { grid-template-columns: 1fr; gap: 5px; }
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
let itemNames = {};
|
let itemNames = {};
|
||||||
let lastInventorySignature = '';
|
let lastInventorySignature = '';
|
||||||
let lastState = null;
|
let lastState = null;
|
||||||
|
let selectedInventoryItem = null;
|
||||||
|
let menuAnchor = null;
|
||||||
|
let toastTimer = null;
|
||||||
|
|
||||||
const escapeHTML = (value) => String(value ?? '').replace(/[&<>"']/g, (ch) => ({
|
const escapeHTML = (value) => String(value ?? '').replace(/[&<>"']/g, (ch) => ({
|
||||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||||
@@ -16,6 +19,26 @@
|
|||||||
.replaceAll('_', ' ').replace(/\b\w/g, (letter) => letter.toUpperCase());
|
.replaceAll('_', ' ').replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function componentText(value) {
|
||||||
|
if (value == null) return '';
|
||||||
|
if (typeof value === 'string' || typeof value === 'number') return String(value);
|
||||||
|
if (Array.isArray(value)) return value.map(componentText).filter(Boolean).join('');
|
||||||
|
if (typeof value !== 'object') return '';
|
||||||
|
if ('raw' in value) return componentText(value.raw);
|
||||||
|
if ('text' in value) return componentText(value.text) + componentText(value.extra);
|
||||||
|
if ('translate' in value) {
|
||||||
|
const args = Array.isArray(value.with) ? value.with.map(componentText).join(' ') : '';
|
||||||
|
return `${prettyName(value.translate)}${args ? ` ${args}` : ''}${componentText(value.extra)}`;
|
||||||
|
}
|
||||||
|
if ('value' in value) return componentText(value.value);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemDisplayName(item, itemName) {
|
||||||
|
const components = item?.components || {};
|
||||||
|
return componentText(components.custom_name || components.item_name) || prettyName(itemName);
|
||||||
|
}
|
||||||
|
|
||||||
function vectorText(pos) {
|
function vectorText(pos) {
|
||||||
if (!pos) return '—';
|
if (!pos) return '—';
|
||||||
if (typeof pos === 'object' && ['x', 'y', 'z'].every((key) => key in pos)) {
|
if (typeof pos === 'object' && ['x', 'y', 'z'].every((key) => key in pos)) {
|
||||||
@@ -67,15 +90,43 @@
|
|||||||
|
|
||||||
function inventorySignature(inv, partial) {
|
function inventorySignature(inv, partial) {
|
||||||
return JSON.stringify([Boolean(partial), Object.entries(inv || {}).map(([slot, item]) => [
|
return JSON.stringify([Boolean(partial), Object.entries(inv || {}).map(([slot, item]) => [
|
||||||
slot, item && item.present, item && item.item_id, item && item.item_count
|
slot, item && item.present, item && item.item_id, item && item.item_count,
|
||||||
|
item && item.components
|
||||||
])]);
|
])]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectedHotbarSlot(state) {
|
||||||
|
if (!state.connected) return null;
|
||||||
|
const selected = Number(state.holding_slot);
|
||||||
|
return Number.isInteger(selected) && selected >= 0 && selected <= 8
|
||||||
|
? selected + 36 : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSelectedSlot(slot) {
|
||||||
|
const grid = $('inventory-grid');
|
||||||
|
grid.querySelectorAll('.selected-slot').forEach((element) => {
|
||||||
|
element.classList.remove('selected-slot');
|
||||||
|
element.removeAttribute('aria-current');
|
||||||
|
});
|
||||||
|
if (slot == null) return;
|
||||||
|
const selected = grid.querySelector(`.inventory-slot[data-slot="${slot}"]`);
|
||||||
|
if (selected) {
|
||||||
|
selected.classList.add('selected-slot');
|
||||||
|
selected.setAttribute('aria-current', 'true');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderInventory(state) {
|
function renderInventory(state) {
|
||||||
const inv = state.inv || {};
|
const inv = state.inv || {};
|
||||||
const signature = inventorySignature(inv, state.inventory_partial);
|
const signature = inventorySignature(inv, state.inventory_partial);
|
||||||
if (signature === lastInventorySignature) return;
|
const selectedSlot = selectedHotbarSlot(state);
|
||||||
|
if (signature === lastInventorySignature) {
|
||||||
|
updateSelectedSlot(selectedSlot);
|
||||||
|
return;
|
||||||
|
}
|
||||||
lastInventorySignature = signature;
|
lastInventorySignature = signature;
|
||||||
|
hideItemTooltip();
|
||||||
|
closeInventoryMenu();
|
||||||
|
|
||||||
const grid = $('inventory-grid');
|
const grid = $('inventory-grid');
|
||||||
const items = [];
|
const items = [];
|
||||||
@@ -90,32 +141,266 @@
|
|||||||
items.push({slot, item});
|
items.push({slot, item});
|
||||||
}
|
}
|
||||||
|
|
||||||
grid.innerHTML = items.map(({slot, item}) => {
|
const bySlot = new Map(items.map((entry) => [entry.slot, entry.item]));
|
||||||
|
const slotHTML = (slot, layoutClass = '') => {
|
||||||
|
const item = bySlot.get(slot);
|
||||||
const present = Boolean(item && item.present);
|
const present = Boolean(item && item.present);
|
||||||
const itemId = present ? item.item_id : null;
|
const itemId = present ? item.item_id : null;
|
||||||
const name = present ? (itemNames[String(itemId)] || `item_${itemId}`) : '';
|
const name = present ? (itemNames[String(itemId)] || `item_${itemId}`) : '';
|
||||||
const count = present ? Number(item.item_count || 0) : 0;
|
const count = present ? Number(item.item_count || 0) : 0;
|
||||||
const title = present ? `${prettyName(name)} × ${count} (id ${itemId}, slot ${slot})` : `Empty slot ${slot}`;
|
const displayName = present ? itemDisplayName(item, name) : `Empty slot ${slot}`;
|
||||||
return `<div class="inventory-slot${present ? ' occupied' : ''}${slot >= 36 && slot <= 44 ? ' hotbar-slot' : ''}"
|
const label = present ? `${displayName}, ${count} item${count === 1 ? '' : 's'}, slot ${slot}` : displayName;
|
||||||
title="${escapeHTML(title)}" aria-label="${escapeHTML(title)}">
|
return `<div class="inventory-slot${layoutClass ? ` ${layoutClass}` : ''}${present ? ' occupied interactive' : ''}${slot >= 36 && slot <= 44 ? ' hotbar-slot' : ''}${slot === selectedSlot ? ' selected-slot' : ''}"
|
||||||
|
${present ? `data-slot="${slot}" role="button" tabindex="0" aria-haspopup="menu" aria-expanded="false"` : ''}
|
||||||
|
aria-label="${escapeHTML(label)}"${slot === selectedSlot ? ' aria-current="true"' : ''}>
|
||||||
<span class="slot-number">${slot}</span>
|
<span class="slot-number">${slot}</span>
|
||||||
${present ? `<span class="item-art"><img alt="" loading="lazy"><span class="icon-fallback" hidden></span></span>
|
${present ? `<span class="item-art"><img alt="" loading="lazy"><span class="icon-fallback" hidden></span></span>
|
||||||
<span class="stack-count">${count > 1 ? count : ''}</span>` : ''}
|
<span class="stack-count">${count > 1 ? count : ''}</span>` : ''}
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
};
|
||||||
|
const slotsHTML = (first, last) => {
|
||||||
|
let html = '';
|
||||||
|
for (let slot = first; slot <= last; slot++) html += slotHTML(slot);
|
||||||
|
return html;
|
||||||
|
};
|
||||||
|
|
||||||
|
grid.innerHTML = `<div class="inventory-upper">
|
||||||
|
<div class="upper-spacer" aria-hidden="true"></div>
|
||||||
|
<section class="inventory-group crafting-group" aria-label="Crafting">
|
||||||
|
<div class="crafting-labels">
|
||||||
|
<div class="inventory-group-label crafting-label-offhand">OFFHAND</div>
|
||||||
|
<div class="inventory-group-label crafting-label-grid">CRAFTING</div>
|
||||||
|
<div class="inventory-group-label crafting-label-output">OUTPUT</div>
|
||||||
|
</div>
|
||||||
|
<div id="crafting-grid" class="crafting-layout">
|
||||||
|
${slotHTML(45, 'crafting-offhand')}
|
||||||
|
${slotHTML(1, 'crafting-input crafting-input-1')}
|
||||||
|
${slotHTML(2, 'crafting-input crafting-input-2')}
|
||||||
|
${slotHTML(3, 'crafting-input crafting-input-3')}
|
||||||
|
${slotHTML(4, 'crafting-input crafting-input-4')}
|
||||||
|
<span class="crafting-arrow" aria-label="Crafting result">→</span>
|
||||||
|
${slotHTML(0, 'crafting-result')}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<div class="inventory-lower-headings">
|
||||||
|
<div class="inventory-group-label">ARMOUR</div>
|
||||||
|
<div class="inventory-group-label">INVENTORY</div>
|
||||||
|
</div>
|
||||||
|
<div class="inventory-lower-grid">
|
||||||
|
<div id="armor-slots" class="armor-slots">${slotsHTML(5, 8)}</div>
|
||||||
|
<div class="inventory-body">
|
||||||
|
<div id="main-inventory" class="main-inventory">${slotsHTML(9, 35)}</div>
|
||||||
|
<div class="hotbar-label">HOTBAR</div>
|
||||||
|
<div id="hotbar-inventory" class="hotbar-inventory">${slotsHTML(36, 44)}</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
items.forEach(({slot, item}) => {
|
items.forEach(({slot, item}) => {
|
||||||
if (!item || !item.present) return;
|
if (!item || !item.present) return;
|
||||||
const icon = grid.children[slot]?.querySelector('img');
|
const element = grid.querySelector(`.inventory-slot[data-slot="${slot}"]`);
|
||||||
const fallback = grid.children[slot]?.querySelector('.icon-fallback');
|
const icon = element?.querySelector('img');
|
||||||
|
const fallback = element?.querySelector('.icon-fallback');
|
||||||
if (icon && fallback) renderItemIcon(icon, fallback, itemNames[String(item.item_id)] || `item_${item.item_id}`);
|
if (icon && fallback) renderItemIcon(icon, fallback, itemNames[String(item.item_id)] || `item_${item.item_id}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
updateSelectedSlot(selectedSlot);
|
||||||
$('inventory-count').textContent = `${totalCount} items`;
|
$('inventory-count').textContent = `${totalCount} items`;
|
||||||
$('inventory-slots').textContent = `${stackCount} / 46 slots`;
|
$('inventory-slots').textContent = `${stackCount} / 46 slots`;
|
||||||
$('inventory-warning').hidden = !state.inventory_partial;
|
$('inventory-warning').hidden = !state.inventory_partial;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function romanNumeral(number) {
|
||||||
|
let value = Math.max(0, Math.floor(Number(number) || 0));
|
||||||
|
const numerals = [[1000, 'M'], [900, 'CM'], [500, 'D'], [400, 'CD'],
|
||||||
|
[100, 'C'], [90, 'XC'], [50, 'L'], [40, 'XL'], [10, 'X'],
|
||||||
|
[9, 'IX'], [5, 'V'], [4, 'IV'], [1, 'I']];
|
||||||
|
let result = '';
|
||||||
|
for (const [amount, glyph] of numerals) {
|
||||||
|
while (value >= amount) { result += glyph; value -= amount; }
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeProperty(value) {
|
||||||
|
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
|
||||||
|
if (typeof value === 'string' || typeof value === 'number') return String(value);
|
||||||
|
if (Array.isArray(value)) return `${value.length} entries`;
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
const text = componentText(value);
|
||||||
|
return text || `${Object.keys(value).length} fields`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function tooltipHTML(item, itemName, slot) {
|
||||||
|
const components = item.components || {};
|
||||||
|
const displayName = itemDisplayName(item, itemName);
|
||||||
|
const rarity = String(components.rarity || 'common').toLowerCase();
|
||||||
|
const titleClass = /^[a-z]+$/.test(rarity) ? ` rarity-${rarity}` : '';
|
||||||
|
const rows = [];
|
||||||
|
const add = (text, className = '') => {
|
||||||
|
if (text) rows.push(`<div class="tooltip-property${className ? ` ${className}` : ''}">${escapeHTML(text)}</div>`);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const key of ['enchantments', 'stored_enchantments']) {
|
||||||
|
const enchantments = components[key];
|
||||||
|
if (!enchantments || typeof enchantments !== 'object') continue;
|
||||||
|
for (const [name, level] of Object.entries(enchantments)) {
|
||||||
|
add(`${prettyName(name)} ${romanNumeral(level)}`, 'tooltip-enchantment');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const damage = Number(components.damage);
|
||||||
|
const maxDamage = Number(components.max_damage);
|
||||||
|
if (Number.isFinite(damage) && Number.isFinite(maxDamage) && maxDamage > 0) {
|
||||||
|
const remaining = Math.max(0, maxDamage - damage);
|
||||||
|
const percent = Math.max(0, Math.min(100, remaining / maxDamage * 100));
|
||||||
|
rows.push(`<div class="tooltip-durability"><span>Durability</span><b>${remaining} / ${maxDamage}</b><i><em style="width:${percent}%"></em></i></div>`);
|
||||||
|
} else if (damage > 0) {
|
||||||
|
add(`Damage: ${damage}`);
|
||||||
|
}
|
||||||
|
if (components.unbreakable) add('Unbreakable', 'tooltip-enchantment');
|
||||||
|
if (components.max_stack_size != null) add(`Maximum stack size: ${components.max_stack_size}`);
|
||||||
|
|
||||||
|
const lore = Array.isArray(components.lore) ? components.lore : [];
|
||||||
|
for (const line of lore) add(componentText(line), 'tooltip-lore');
|
||||||
|
|
||||||
|
const container = components.container;
|
||||||
|
if (Array.isArray(container)) {
|
||||||
|
const contents = container.filter((entry) => entry && entry.present).length;
|
||||||
|
add(`${contents} item${contents === 1 ? '' : 's'} inside`);
|
||||||
|
}
|
||||||
|
const book = components.written_book_content;
|
||||||
|
if (book) {
|
||||||
|
const title = componentText(book.title) || 'Untitled';
|
||||||
|
add(`Book: ${title} (${(book.pages || []).length} pages)`);
|
||||||
|
}
|
||||||
|
if (components.dyed_color != null) {
|
||||||
|
add(`Dyed color: #${(Number(components.dyed_color) & 0xffffff).toString(16).padStart(6, '0').toUpperCase()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const shown = new Set([
|
||||||
|
'custom_name', 'item_name', 'rarity', 'enchantments', 'stored_enchantments',
|
||||||
|
'damage', 'max_damage', 'unbreakable', 'max_stack_size', 'lore',
|
||||||
|
'container', 'written_book_content', 'writable_book_content', 'dyed_color',
|
||||||
|
'custom_data', 'custom_model_data', 'tooltip_display',
|
||||||
|
]);
|
||||||
|
for (const [key, value] of Object.entries(components)) {
|
||||||
|
if (shown.has(key) || value == null || value === false) continue;
|
||||||
|
add(`${prettyName(key)}: ${summarizeProperty(value)}`);
|
||||||
|
}
|
||||||
|
if (item.components_unknown) add('Some item properties could not be decoded.', 'tooltip-muted');
|
||||||
|
if (!rows.length) add('No additional properties', 'tooltip-muted');
|
||||||
|
|
||||||
|
return `<div class="tooltip-name${titleClass}">${escapeHTML(displayName)}</div>
|
||||||
|
<div class="tooltip-meta">Stack ${Number(item.item_count || 0)} · Slot ${slot} · ID ${item.item_id}</div>
|
||||||
|
<div class="tooltip-divider"></div>${rows.join('')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionFloating(element, x, y) {
|
||||||
|
element.style.left = '0px';
|
||||||
|
element.style.top = '0px';
|
||||||
|
const bounds = element.getBoundingClientRect();
|
||||||
|
const margin = 8;
|
||||||
|
const left = Math.max(margin, Math.min(x, window.innerWidth - bounds.width - margin));
|
||||||
|
const top = y + bounds.height + margin <= window.innerHeight
|
||||||
|
? y : Math.max(margin, y - bounds.height - margin);
|
||||||
|
element.style.left = `${left}px`;
|
||||||
|
element.style.top = `${top}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideItemTooltip() {
|
||||||
|
const tooltip = $('item-tooltip');
|
||||||
|
if (tooltip) tooltip.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showItemTooltip(slotElement, point) {
|
||||||
|
if (selectedInventoryItem) return;
|
||||||
|
const slot = Number(slotElement.dataset.slot);
|
||||||
|
const item = lastState?.inv?.[String(slot)] || lastState?.inv?.[slot];
|
||||||
|
if (!item?.present) return hideItemTooltip();
|
||||||
|
const name = itemNames[String(item.item_id)] || `item_${item.item_id}`;
|
||||||
|
const tooltip = $('item-tooltip');
|
||||||
|
tooltip.innerHTML = tooltipHTML(item, name, slot);
|
||||||
|
tooltip.hidden = false;
|
||||||
|
positionFloating(tooltip, point.x + 14, point.y + 14);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeInventoryMenu() {
|
||||||
|
const menu = $('inventory-menu');
|
||||||
|
if (menu) menu.hidden = true;
|
||||||
|
if (menuAnchor) menuAnchor.setAttribute('aria-expanded', 'false');
|
||||||
|
menuAnchor = null;
|
||||||
|
selectedInventoryItem = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openInventoryMenu(slotElement) {
|
||||||
|
const slot = Number(slotElement.dataset.slot);
|
||||||
|
const item = lastState?.inv?.[String(slot)] || lastState?.inv?.[slot];
|
||||||
|
if (!item?.present) return closeInventoryMenu();
|
||||||
|
hideItemTooltip();
|
||||||
|
closeInventoryMenu();
|
||||||
|
const name = itemNames[String(item.item_id)] || `item_${item.item_id}`;
|
||||||
|
selectedInventoryItem = {slot, itemId: item.item_id, count: Number(item.item_count || 0)};
|
||||||
|
menuAnchor = slotElement;
|
||||||
|
menuAnchor.setAttribute('aria-expanded', 'true');
|
||||||
|
$('inventory-menu-title').textContent = itemDisplayName(item, name);
|
||||||
|
$('inventory-menu-meta').textContent = `Slot ${slot} · ${selectedInventoryItem.count} item${selectedInventoryItem.count === 1 ? '' : 's'}`;
|
||||||
|
$('inventory-menu-status').classList.remove('is-error');
|
||||||
|
$('inventory-menu-status').textContent = slot < 5
|
||||||
|
? 'Crafting slots cannot be dropped from the dashboard.' : '';
|
||||||
|
$('inventory-menu-status').hidden = slot >= 5;
|
||||||
|
const unavailable = !lastState?.connected || slot < 5;
|
||||||
|
$('inventory-menu').querySelectorAll('[data-inventory-action]:not([data-inventory-action="cancel"])')
|
||||||
|
.forEach((button) => { button.disabled = unavailable; });
|
||||||
|
const rect = slotElement.getBoundingClientRect();
|
||||||
|
const menu = $('inventory-menu');
|
||||||
|
menu.hidden = false;
|
||||||
|
positionFloating(menu, rect.left, rect.bottom + 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(message, isError = false) {
|
||||||
|
const toast = $('toast');
|
||||||
|
toast.textContent = message;
|
||||||
|
toast.classList.toggle('is-error', isError);
|
||||||
|
toast.classList.add('visible');
|
||||||
|
window.clearTimeout(toastTimer);
|
||||||
|
toastTimer = window.setTimeout(() => toast.classList.remove('visible'), 2400);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runInventoryAction(action) {
|
||||||
|
if (!selectedInventoryItem) return;
|
||||||
|
const selected = {...selectedInventoryItem};
|
||||||
|
const buttons = $('inventory-menu').querySelectorAll('button');
|
||||||
|
buttons.forEach((button) => { button.disabled = true; });
|
||||||
|
$('inventory-menu-status').hidden = false;
|
||||||
|
$('inventory-menu-status').textContent = 'Sending action…';
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/inventory/drop', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({slot: selected.slot, action}),
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok || !result.ok) throw new Error(result.error || `API ${response.status}`);
|
||||||
|
closeInventoryMenu();
|
||||||
|
const message = action === 'matching'
|
||||||
|
? `Dropping ${result.stacks} matching stack${result.stacks === 1 ? '' : 's'}`
|
||||||
|
: action === 'one' ? 'Dropped one item' : 'Dropped the stack';
|
||||||
|
showToast(message);
|
||||||
|
lastInventorySignature = '';
|
||||||
|
await poll();
|
||||||
|
} catch (error) {
|
||||||
|
$('inventory-menu-status').textContent = error.message;
|
||||||
|
$('inventory-menu-status').classList.add('is-error');
|
||||||
|
buttons.forEach((button) => {
|
||||||
|
const cancel = button.dataset.inventoryAction === 'cancel';
|
||||||
|
button.disabled = !cancel && (!lastState?.connected || selected.slot < 5);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderPlayers(state) {
|
function renderPlayers(state) {
|
||||||
const players = Object.values(state.players || {});
|
const players = Object.values(state.players || {});
|
||||||
$('player-count').textContent = String(players.length);
|
$('player-count').textContent = String(players.length);
|
||||||
@@ -206,6 +491,70 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const inventoryGrid = $('inventory-grid');
|
||||||
|
inventoryGrid.addEventListener('pointerover', (event) => {
|
||||||
|
const slot = event.target.closest('.inventory-slot.interactive');
|
||||||
|
if (!slot || selectedInventoryItem) return;
|
||||||
|
showItemTooltip(slot, {x: event.clientX, y: event.clientY});
|
||||||
|
});
|
||||||
|
inventoryGrid.addEventListener('pointermove', (event) => {
|
||||||
|
const tooltip = $('item-tooltip');
|
||||||
|
if (!tooltip.hidden && !selectedInventoryItem) {
|
||||||
|
positionFloating(tooltip, event.clientX + 14, event.clientY + 14);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
inventoryGrid.addEventListener('pointerout', (event) => {
|
||||||
|
const slot = event.target.closest('.inventory-slot.interactive');
|
||||||
|
if (slot && !slot.contains(event.relatedTarget)) hideItemTooltip();
|
||||||
|
});
|
||||||
|
inventoryGrid.addEventListener('focusin', (event) => {
|
||||||
|
const slot = event.target.closest('.inventory-slot.interactive');
|
||||||
|
if (!slot) return;
|
||||||
|
const rect = slot.getBoundingClientRect();
|
||||||
|
showItemTooltip(slot, {x: rect.right, y: rect.top});
|
||||||
|
});
|
||||||
|
inventoryGrid.addEventListener('focusout', (event) => {
|
||||||
|
if (!event.relatedTarget?.closest('.inventory-slot.interactive')) hideItemTooltip();
|
||||||
|
});
|
||||||
|
inventoryGrid.addEventListener('click', (event) => {
|
||||||
|
const slot = event.target.closest('.inventory-slot');
|
||||||
|
if (!slot) return;
|
||||||
|
if (slot.classList.contains('interactive')) openInventoryMenu(slot);
|
||||||
|
else closeInventoryMenu();
|
||||||
|
});
|
||||||
|
inventoryGrid.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||||
|
const slot = event.target.closest('.inventory-slot.interactive');
|
||||||
|
if (!slot) return;
|
||||||
|
event.preventDefault();
|
||||||
|
openInventoryMenu(slot);
|
||||||
|
});
|
||||||
|
|
||||||
|
$('inventory-menu').addEventListener('click', (event) => {
|
||||||
|
const button = event.target.closest('button[data-inventory-action]');
|
||||||
|
if (!button || button.disabled) return;
|
||||||
|
const action = button.dataset.inventoryAction;
|
||||||
|
if (action === 'cancel') closeInventoryMenu();
|
||||||
|
else runInventoryAction(action);
|
||||||
|
});
|
||||||
|
document.addEventListener('click', (event) => {
|
||||||
|
if ($('inventory-menu').hidden) return;
|
||||||
|
if (event.target.closest('#inventory-menu') ||
|
||||||
|
event.target.closest('.inventory-slot.interactive')) return;
|
||||||
|
closeInventoryMenu();
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
hideItemTooltip();
|
||||||
|
closeInventoryMenu();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
if (!selectedInventoryItem || !menuAnchor?.isConnected) return;
|
||||||
|
const rect = menuAnchor.getBoundingClientRect();
|
||||||
|
positionFloating($('inventory-menu'), rect.left, rect.bottom + 4);
|
||||||
|
});
|
||||||
|
|
||||||
loadItems();
|
loadItems();
|
||||||
poll();
|
poll();
|
||||||
window.setInterval(poll, POLL_MS);
|
window.setInterval(poll, POLL_MS);
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
<meta name="theme-color" content="#10130f" />
|
<meta name="theme-color" content="#10130f" />
|
||||||
<meta name="description" content="Live Minecraft bot dashboard" />
|
<meta name="description" content="Live Minecraft bot dashboard" />
|
||||||
<title>Mosfet · Minecraft Bot</title>
|
<title>Mosfet · Minecraft Bot</title>
|
||||||
<link rel="stylesheet" href="/static/css/dashboard.css?v=4" />
|
<link rel="stylesheet" href="/static/css/dashboard.css?v=16" />
|
||||||
<script defer src="/static/js/dashboard.js?v=7"></script>
|
<script defer src="/static/js/dashboard.js?v=14"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
Some item components are not decoded yet; displayed contents may be partial.
|
Some item components are not decoded yet; displayed contents may be partial.
|
||||||
</div>
|
</div>
|
||||||
<div id="inventory-grid" class="inventory-grid" aria-label="Minecraft inventory"></div>
|
<div id="inventory-grid" class="inventory-grid" aria-label="Minecraft inventory"></div>
|
||||||
<div class="inventory-legend"><span class="legend-swatch"></span> Empty slot <span class="legend-note">Hover an item for its name and stack size</span></div>
|
<div class="inventory-legend"><span class="legend-swatch"></span> Empty slot <span class="legend-selected"></span> Selected hotbar slot <span class="legend-note">Hover for details · click an item for actions</span></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside class="side-column">
|
<aside class="side-column">
|
||||||
@@ -103,6 +103,18 @@
|
|||||||
</footer>
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="item-tooltip" class="item-tooltip" role="tooltip" hidden></div>
|
||||||
|
<div id="inventory-menu" class="inventory-menu" role="menu" aria-label="Item actions" hidden>
|
||||||
|
<div class="inventory-menu-heading">
|
||||||
|
<strong id="inventory-menu-title">Item</strong>
|
||||||
|
<span id="inventory-menu-meta"></span>
|
||||||
|
</div>
|
||||||
|
<button type="button" data-inventory-action="one" role="menuitem">Drop one</button>
|
||||||
|
<button type="button" data-inventory-action="stack" role="menuitem">Drop whole stack</button>
|
||||||
|
<button type="button" data-inventory-action="matching" role="menuitem">Drop all matching</button>
|
||||||
|
<div id="inventory-menu-status" class="inventory-menu-status" aria-live="polite" hidden></div>
|
||||||
|
<button type="button" class="menu-cancel" data-inventory-action="cancel" role="menuitem">Cancel</button>
|
||||||
|
</div>
|
||||||
<div id="toast" class="toast" role="status" aria-live="polite"></div>
|
<div id="toast" class="toast" role="status" aria-live="polite"></div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user