diff --git a/src/jwinventoryapi/listener.py b/src/jwinventoryapi/listener.py index e654e18..46ed85b 100644 --- a/src/jwinventoryapi/listener.py +++ b/src/jwinventoryapi/listener.py @@ -1,27 +1,25 @@ from bedrock_protocol.packets import MinecraftPacketIds from bedrock_protocol.packets.enums import ItemStackRequestActionType -from bedrock_protocol.packets.packet import ContainerClosePacket, ItemRegistryPacket, ItemStackRequestPacket -from bstream import BinaryStream +from bedrock_protocol.packets.packet import ( + ContainerClosePacket, + ItemRegistryPacket, + ItemStackRequestPacket, + ItemStackResponsePacket, + NetworkStackLatencyPacket, +) +from bedrock_protocol.packets.types.item_stack_response import ItemStackResponse from endstone.event import event_handler, EventPriority, PlayerQuitEvent, PacketReceiveEvent, PacketSendEvent from endstone.inventory import ItemStack from endstone.plugin import Plugin from .manager import Session +from .manager.container.item_stack_response_builder import ItemStackResponseBuilder from .manager.player_manager import find_session, close_session +from .network.container_ui_ids import ContainerUIIds from .network.network_stack_latency_packet import NetworkStackLatencyPacket from .util.item_utils import all_item_data, add_item_data, is_air _CONTAINER_ENUM_GUI = 7 -_ITEM_STACK_RESPONSE_ID = 148 - - -def _build_reject_response(request_ids: list[int]) -> bytes: - stream = BinaryStream() - stream.write_unsigned_varint(len(request_ids)) - for rid in request_ids: - stream.write_byte(1) # result = Error/Reject - stream.write_varint(rid) - return stream.copy_buffer() class EventListener: @@ -33,9 +31,7 @@ def on_packet_receive(self, event: PacketReceiveEvent): player = event.player if player is None: return - packet_id = event.packet_id - if packet_id == MinecraftPacketIds.Ping: self._handle_ping(event) elif packet_id == MinecraftPacketIds.ContainerClose: @@ -43,7 +39,7 @@ def on_packet_receive(self, event: PacketReceiveEvent): elif packet_id == MinecraftPacketIds.PacketViolationWarning: self._handle_violation_warning(event) elif packet_id == MinecraftPacketIds.ItemStackRequest: - self._handle_item_stack_request(event) + self._handle_item_stack_request_packet(event) @event_handler def on_packet_send(self, event: PacketSendEvent): @@ -61,16 +57,15 @@ def _handle_ping(self, event: PacketReceiveEvent): session = find_session(event.player) if session is None: return - pk = NetworkStackLatencyPacket() pk.deserialize(event.payload) - if session.ack_timestamp != pk.timestamp: return - match session.state: case Session.State.GRAPHIC_SENT: session.update_state(Session.State.GRAPHIC_RECEIVED) + case Session.State.GRAPHIC_DATA_SENT: + session.update_state(Session.State.GRAPHIC_DATA_RECEIVED) case Session.State.OPENING: if session.open_attempts >= Session.MAX_OPEN_ATTEMPTS: session.close() @@ -83,42 +78,73 @@ def _handle_container_close(self, event: PacketReceiveEvent): session = find_session(player) if session is None: return - pk = ContainerClosePacket() pk.deserialize(event.payload) - if pk.container_id != Session.CONTAINER_ID: return - if session.menu is not None and session.menu._close_listener is not None: session.menu._close_listener(player) - if session.pending: if session.state != Session.State.CLOSING: session.close() session.menu = session.pending.popleft() session.send_menu() else: + session.close(sync_inventory=True) close_session(player) def _handle_violation_warning(self, event: PacketReceiveEvent): session = find_session(event.player) if session is None: return - if session.state == Session.State.OPENING: session.update_state(Session.State.OPEN) if session.menu is not None and session.menu._open_listener is not None: session.menu._open_listener(event.player) - def _handle_item_stack_request(self, event: PacketReceiveEvent): + def _send_item_stack_responses(self, player, responses: list[ItemStackResponse]) -> None: + if not responses: + return + pk = ItemStackResponsePacket(responses) + player.send_packet(pk.get_packet_id(), pk.serialize()) + + def _reject_item_stack_request(self, player, session, responses: list[ItemStackResponse], client_request_id: int) -> None: + session.container_manager.discard_transaction() + responses.append(ItemStackResponseBuilder.build_error(client_request_id)) + self._send_item_stack_responses(player, responses) + + def _handle_item_stack_request(self, player, session, menu, pk: ItemStackRequestPacket) -> None: + responses: list[ItemStackResponse] = [] + for req_data in pk.request.request_data: + session.container_manager.begin_request(req_data.client_request_id) + try: + for action in req_data.request_actions: + action_type = action.action_type + source = action.action_data.source + destination = action.action_data.distination + if action_type == ItemStackRequestActionType.Drop: + session.container_manager.handle_drop(source, action.action_data.amount) + elif action_type == ItemStackRequestActionType.Swap: + session.container_manager.handle_swap(source, destination) + elif action_type == ItemStackRequestActionType.Take or action_type == ItemStackRequestActionType.Place: + session.container_manager.transfer_items(source, destination, action.action_data.amount) + elif action_type == ItemStackRequestActionType.Destroy: + continue + responses.append(session.container_manager.commit_transaction()) + except Exception as error: + self._plugin.logger.debug(f"Error handling item stack request: {error}") + self._reject_item_stack_request(player, session, responses, req_data.client_request_id) + return + self._send_item_stack_responses(player, responses) + + def _handle_item_stack_request_packet(self, event: PacketReceiveEvent): player = event.player session = find_session(player) if session is None or session.state != Session.State.OPEN: return - + pk = ItemStackRequestPacket() + pk.deserialize(event.payload) menu = session.menu - if menu.is_locked: self._handle_locked(event, session) elif menu.is_editable: @@ -128,13 +154,14 @@ def _handle_item_stack_request(self, event: PacketReceiveEvent): def _handle_locked(self, event: PacketReceiveEvent, session: Session): event.is_cancelled = True - # Send explicit rejection so client snaps items back immediately pk = ItemStackRequestPacket() pk.deserialize(event.payload) - request_ids = [rd.client_request_id for rd in pk.request.request_data] - if request_ids: - payload = _build_reject_response(request_ids) - session.player.send_packet(_ITEM_STACK_RESPONSE_ID, payload) + responses = [] + for req_data in pk.request.request_data: + session.container_manager.begin_request(req_data.client_request_id) + responses.append(ItemStackResponseBuilder.build_error(req_data.client_request_id)) + session.container_manager.discard_transaction() + self._send_item_stack_responses(session.player, responses) session.send_contents() session.send_player_inventory() @@ -142,60 +169,44 @@ def _handle_editable(self, event: PacketReceiveEvent, session: Session): player = event.player menu = session.menu inv = menu.inventory - pk = ItemStackRequestPacket() pk.deserialize(event.payload) - inv.begin_batch() try: for req_data in pk.request.request_data: for action in req_data.request_actions: action_type = action.action_type - if action_type == ItemStackRequestActionType.Destroy: continue - - if action_type not in ( - ItemStackRequestActionType.Take, - ItemStackRequestActionType.Place, - ItemStackRequestActionType.Swap, - ): + if action_type not in (ItemStackRequestActionType.Take, ItemStackRequestActionType.Place, ItemStackRequestActionType.Swap): continue - if action_type == ItemStackRequestActionType.Swap: self._process_swap(action, inv, player) else: self._process_move(action, inv, player) finally: - inv._dirty_slots.clear() - inv._batch_mode = False - + inv.end_batch() event.is_cancelled = True - request_ids = [rd.client_request_id for rd in pk.request.request_data] - if request_ids: - payload = _build_reject_response(request_ids) - session.player.send_packet(_ITEM_STACK_RESPONSE_ID, payload) + responses = [] + for req_data in pk.request.request_data: + session.container_manager.begin_request(req_data.client_request_id) + responses.append(ItemStackResponseBuilder.build_error(req_data.client_request_id)) + session.container_manager.discard_transaction() + self._send_item_stack_responses(session.player, responses) session.send_contents() session.send_player_inventory() def _handle_click_only(self, event: PacketReceiveEvent, session: Session): player = event.player menu = session.menu - pk = ItemStackRequestPacket() pk.deserialize(event.payload) - for req_data in pk.request.request_data: for action in req_data.request_actions: - if action.action_type not in ( - ItemStackRequestActionType.Take, - ItemStackRequestActionType.Place, - ): + if action.action_type not in (ItemStackRequestActionType.Take, ItemStackRequestActionType.Place): continue - src = action.action_data.source source_is_gui = src.container.container_enum == _CONTAINER_ENUM_GUI - if source_is_gui: item_clicked = menu.inventory.get_item(src.slot) menu._handle_click(player, src.slot, item_clicked) @@ -203,13 +214,13 @@ def _handle_click_only(self, event: PacketReceiveEvent, session: Session): if menu._place_listener is not None: item_from_player = player.inventory.get_item(src.slot) menu._place_listener(player, src.slot, item_from_player, menu.inventory) - event.is_cancelled = True - # Reject so client doesn't hold item on cursor - request_ids = [rd.client_request_id for rd in pk.request.request_data] - if request_ids: - payload = _build_reject_response(request_ids) - session.player.send_packet(_ITEM_STACK_RESPONSE_ID, payload) + responses = [] + for rd in pk.request.request_data: + session.container_manager.begin_request(rd.client_request_id) + responses.append(ItemStackResponseBuilder.build_error(rd.client_request_id)) + session.container_manager.discard_transaction() + self._send_item_stack_responses(session.player, responses) session.send_contents() session.send_player_inventory() return @@ -218,10 +229,8 @@ def _process_move(self, action, inv, player): src = action.action_data.source dst = action.action_data.distination count = action.action_data.amount - src_is_gui = src.container.container_enum == _CONTAINER_ENUM_GUI dst_is_gui = dst.container.container_enum == _CONTAINER_ENUM_GUI - if src_is_gui and not dst_is_gui: self._take_from_gui(inv, player, src.slot, count) elif dst_is_gui and not src_is_gui: @@ -232,10 +241,8 @@ def _process_move(self, action, inv, player): def _process_swap(self, action, inv, player): src = action.action_data.source dst = action.action_data.distination - src_is_gui = src.container.container_enum == _CONTAINER_ENUM_GUI dst_is_gui = dst.container.container_enum == _CONTAINER_ENUM_GUI - if src_is_gui and dst_is_gui: item_a = inv.get_item(src.slot) item_b = inv.get_item(dst.slot) @@ -256,17 +263,14 @@ def _take_from_gui(self, inv, player, slot: int, count: int): current = inv.get_item(slot) if current is None or is_air(current): return - take_count = min(count, current.amount) new_amount = current.amount - take_count - if new_amount <= 0: inv.set_item(slot, None) else: updated = ItemStack(current.type.id, new_amount, current.data) updated.set_item_meta(current.item_meta) inv.set_item(slot, updated) - give_item = ItemStack(current.type.id, take_count, current.data) give_item.set_item_meta(current.item_meta) player.inventory.add_item(give_item) @@ -275,10 +279,8 @@ def _place_into_gui(self, inv, player, player_slot: int, gui_slot: int, count: i player_item = player.inventory.get_item(player_slot) if player_item is None or is_air(player_item): return - take_count = min(count, player_item.amount) existing = inv.get_item(gui_slot) - if existing is not None and not is_air(existing): if existing.type.id == player_item.type.id: cap = min(inv.max_stack_size, existing.max_stack_size) @@ -296,7 +298,6 @@ def _place_into_gui(self, inv, player, player_slot: int, gui_slot: int, count: i placed = ItemStack(player_item.type.id, take_count, player_item.data) placed.set_item_meta(player_item.item_meta) inv.set_item(gui_slot, placed) - new_player_amount = player_item.amount - take_count if new_player_amount <= 0: player.inventory.set_item(player_slot, None) @@ -309,10 +310,8 @@ def _move_within_gui(self, inv, src_slot: int, dst_slot: int, count: int): current = inv.get_item(src_slot) if current is None or is_air(current): return - take_count = min(count, current.amount) existing_dst = inv.get_item(dst_slot) - new_src_amount = current.amount - take_count if new_src_amount <= 0: inv.set_item(src_slot, None) @@ -320,7 +319,6 @@ def _move_within_gui(self, inv, src_slot: int, dst_slot: int, count: int): updated_src = ItemStack(current.type.id, new_src_amount, current.data) updated_src.set_item_meta(current.item_meta) inv.set_item(src_slot, updated_src) - if existing_dst is not None and not is_air(existing_dst) and existing_dst.type.id == current.type.id: cap = min(inv.max_stack_size, existing_dst.max_stack_size) merged_amount = min(existing_dst.amount + take_count, cap) @@ -330,4 +328,4 @@ def _move_within_gui(self, inv, src_slot: int, dst_slot: int, count: int): else: moved = ItemStack(current.type.id, take_count, current.data) moved.set_item_meta(current.item_meta) - inv.set_item(dst_slot, moved) + inv.set_item(dst_slot, moved) \ No newline at end of file diff --git a/src/jwinventoryapi/manager/container_manager/container_adapter.py b/src/jwinventoryapi/manager/container_manager/container_adapter.py new file mode 100644 index 0000000..8473e0d --- /dev/null +++ b/src/jwinventoryapi/manager/container_manager/container_adapter.py @@ -0,0 +1,52 @@ +from typing import Protocol, TYPE_CHECKING + +from endstone import Player +from endstone.inventory import ItemStack + +if TYPE_CHECKING: + from jwinventoryapi.menu.menu_inventory import MenuInventory + + +class ContainerAdapter(Protocol): + def get(self, slot: int) -> ItemStack | None: ... + def set(self, slot: int, item: ItemStack | None) -> None: ... + + +class PlayerInventoryAdapter: + def __init__(self, player: Player): + self.player = player + + def get(self, slot: int) -> ItemStack | None: + return self.player.inventory.get_item(slot) + + def set(self, slot: int, item: ItemStack | None) -> None: + if item is None: + self.player.inventory.clear(slot) + else: + self.player.inventory.set_item(slot, item) + + +class VirtualInventoryAdapter: + def __init__(self, inventory: "MenuInventory"): + self.inventory = inventory + + def get(self, slot: int) -> ItemStack | None: + return self.inventory.get_item(slot) + + def set(self, slot: int, item: ItemStack | None) -> None: + if item is None: + self.inventory.clear(slot) + else: + self.inventory.set_item(slot, item) + + +class CursorAdapter: + + def __init__(self): + self.cursor_item: ItemStack | None = None + + def get(self, slot: int = 0) -> ItemStack | None: + return self.cursor_item + + def set(self, slot: int, item: ItemStack | None) -> None: + self.cursor_item = item \ No newline at end of file diff --git a/src/jwinventoryapi/manager/container_manager/container_manager.py b/src/jwinventoryapi/manager/container_manager/container_manager.py new file mode 100644 index 0000000..2381547 --- /dev/null +++ b/src/jwinventoryapi/manager/container_manager/container_manager.py @@ -0,0 +1,159 @@ +from typing import TYPE_CHECKING + +from bedrock_protocol.packets.types import ItemStackRequestSlotInfo +from bedrock_protocol.packets.types.item_stack_response import ItemStackResponse +from endstone import Player +from endstone.inventory import ItemStack + +from jwinventoryapi.network.container_ui_ids import ContainerUIIds +from jwinventoryapi.util.item_utils import pop_item, can_stack, clone_item, is_air + +from .container_adapter import ContainerAdapter, PlayerInventoryAdapter, VirtualInventoryAdapter, CursorAdapter +from .transaction_container import TransactionContainer +from .item_stack_response_builder import ItemStackResponseBuilder +from .item_stack_tracker import ItemStackTracker + +if TYPE_CHECKING: + from jwinventoryapi.menu.menu_inventory import MenuInventory + + +class ContainerManager: + + def __init__(self, player: Player, inventory: "MenuInventory"): + self.player = player + self.tracker = ItemStackTracker() + self.player_container = TransactionContainer(PlayerInventoryAdapter(player)) + self.virtual_container = TransactionContainer(VirtualInventoryAdapter(inventory)) + self.cursor_container = TransactionContainer(CursorAdapter()) + self._response_builder: ItemStackResponseBuilder | None = None + self._modified_player_slots: set[int] = set() + + def begin_request(self, request_id: int) -> None: + self._response_builder = ItemStackResponseBuilder(request_id, self.tracker, self) + + @staticmethod + def _is_player_container(container_enum: int) -> bool: + return container_enum in ( + ContainerUIIds.INVENTORY, + ContainerUIIds.HOTBAR, + ContainerUIIds.COMBINED_HOTBAR_AND_INVENTORY, + ) + + def track_request_slot(self, slot_info: ItemStackRequestSlotInfo) -> None: + self.tracker.seed_from_request(slot_info) + if self._is_player_container(slot_info.container.container_enum): + self._modified_player_slots.add(slot_info.slot) + if self._response_builder is not None: + self._response_builder.add_slot(slot_info.container.container_enum, slot_info.slot) + + def get_container_adapter_and_slot(self, slot_info: ItemStackRequestSlotInfo) -> tuple[ContainerAdapter, int] | None: + container_type = slot_info.container.container_enum + slot = slot_info.slot + if container_type == ContainerUIIds.LEVEL_ENTITY: + return self.virtual_container, slot + elif container_type == ContainerUIIds.CURSOR: + return self.cursor_container, slot + elif container_type in (ContainerUIIds.INVENTORY, ContainerUIIds.HOTBAR, ContainerUIIds.COMBINED_HOTBAR_AND_INVENTORY): + return self.player_container, slot + raise ValueError(f"Unsupported container type: {container_type}") + + def get_item_at(self, container_enum: int, slot: int) -> ItemStack | None: + if container_enum == ContainerUIIds.LEVEL_ENTITY: + return self.virtual_container.actual.get(slot) + if container_enum == ContainerUIIds.CURSOR: + return self.cursor_container.actual.get(slot) + if container_enum in (ContainerUIIds.INVENTORY, ContainerUIIds.HOTBAR, ContainerUIIds.COMBINED_HOTBAR_AND_INVENTORY): + return self.player_container.actual.get(slot) + raise ValueError(f"Unsupported container type: {container_enum}") + + def assign_virtual_slot(self, slot: int, item: ItemStack | None) -> int: + return self.tracker.assign_slot(ContainerUIIds.LEVEL_ENTITY, slot, item) + + def transfer_items(self, source: ItemStackRequestSlotInfo, destination: ItemStackRequestSlotInfo, count: int): + self.track_request_slot(source) + self.track_request_slot(destination) + removed = self.remove_item_from_slot(source, count) + self.add_item_to_slot(destination, removed, count) + + def remove_item_from_slot(self, slot_info: ItemStackRequestSlotInfo, count: int) -> ItemStack: + self.track_request_slot(slot_info) + container, slot = self.get_container_adapter_and_slot(slot_info) + if count < 1: + raise ValueError("item count is less than 1") + item: ItemStack = container.get(slot) + if item is None or item.amount < count: + raise ValueError("item is None or existing item < count") + removed, remainder = pop_item(item, count) + if remainder is None or is_air(remainder): + container.set(slot, None) + else: + container.set(slot, remainder) + return removed + + def add_item_to_slot(self, slot_info: ItemStackRequestSlotInfo, item: ItemStack, count: int): + self.track_request_slot(slot_info) + if item is None or is_air(item): + raise ValueError("item is None or existing item < count") + container, slot = self.get_container_adapter_and_slot(slot_info) + if count < 1: + return + existing_item: ItemStack = container.get(slot) or ItemStack("minecraft:air") + if existing_item is None or is_air(existing_item): + container.set(slot, clone_item(item)) + return + if not can_stack(existing_item, item): + raise ValueError("cannot stack items") + merged = clone_item(existing_item) + merged.amount += item.amount + container.set(slot, merged) + + def handle_swap(self, slot1: ItemStackRequestSlotInfo, slot2: ItemStackRequestSlotInfo): + self.track_request_slot(slot1) + self.track_request_slot(slot2) + c1, s1 = self.get_container_adapter_and_slot(slot1) + c2, s2 = self.get_container_adapter_and_slot(slot2) + item1 = c1.get(s1) + item2 = c2.get(s2) + c1.set(s1, item2) + c2.set(s2, item1) + + def handle_drop(self, source: ItemStackRequestSlotInfo, count: int): + self.track_request_slot(source) + dropped = self.remove_item_from_slot(source, count) + if is_air(dropped): + raise ValueError("cannot drop empty item stack") + if dropped.amount > dropped.max_stack_size: + raise ValueError("cannot drop item stack larger than max stack size") + self.player.dimension.drop_item(self.player.location, dropped) + + def commit_transaction(self) -> ItemStackResponse: + self.player_container.commit() + self.virtual_container.commit() + self.cursor_container.commit() + if self._response_builder is None: + raise RuntimeError("No active item stack request") + for container_enum, slots in self._response_builder.changed_slots().items(): + for slot in slots.values(): + item = self.get_item_at(container_enum, slot) + self.tracker.assign_slot(container_enum, slot, item) + response = self._response_builder.build() + self._response_builder = None + return response + + def discard_transaction(self) -> None: + self.player_container.discard() + self.virtual_container.discard() + self.cursor_container.discard() + self._response_builder = None + + def sync_player_inventory(self) -> None: + if not self._modified_player_slots: + return + inventory = self.player.inventory + for slot in self._modified_player_slots: + item = inventory.get_item(slot) + inventory.clear(slot) + if item is not None and not is_air(item): + inventory.set_item(slot, clone_item(item)) + self._modified_player_slots.clear() + self.tracker.clear_player_slots() \ No newline at end of file diff --git a/src/jwinventoryapi/manager/container_manager/item_stack_response_builder.py b/src/jwinventoryapi/manager/container_manager/item_stack_response_builder.py new file mode 100644 index 0000000..029a60c --- /dev/null +++ b/src/jwinventoryapi/manager/container_manager/item_stack_response_builder.py @@ -0,0 +1,79 @@ +from typing import TYPE_CHECKING + +from bedrock_protocol.packets.types import FullContainerName +from bedrock_protocol.packets.types.item_stack_response import ( + ItemStackResponse, + ItemStackResponseContainerInfo, + ItemStackResponseSlotInfo, +) +from endstone.inventory import ItemStack + +from jwinventoryapi.util.item_utils import is_air + +from .item_stack_tracker import ItemStackTracker + +if TYPE_CHECKING: + from .container_manager import ContainerManager + + +class ItemStackResponseBuilder: + def __init__(self, request_id: int, tracker: ItemStackTracker, container_manager: "ContainerManager"): + self._request_id = request_id + self._tracker = tracker + self._container_manager = container_manager + self._changed_slots: dict[int, dict[int, int]] = {} + + def add_slot(self, container_enum: int, slot: int) -> None: + self._changed_slots.setdefault(container_enum, {})[slot] = slot + + @staticmethod + def _get_custom_name(item: ItemStack | None) -> str: + if item is None or is_air(item): + return "" + item_meta = item.item_meta + if item_meta is not None and item_meta.has_display_name: + return item_meta.display_name + return "" + + @staticmethod + def _get_durability_correction(item: ItemStack | None) -> int: + if item is None or is_air(item): + return 0 + return item.data + + def build(self) -> ItemStackResponse: + container_infos: list[ItemStackResponseContainerInfo] = [] + for container_enum, slots in self._changed_slots.items(): + slot_infos: list[ItemStackResponseSlotInfo] = [] + for slot in slots.values(): + item = self._container_manager.get_item_at(container_enum, slot) + stack_id = self._tracker.get_stack_id(container_enum, slot) + custom_name = self._get_custom_name(item) + slot_infos.append(ItemStackResponseSlotInfo( + slot=slot, + hotbar_slot=slot, + count=0 if item is None or is_air(item) else item.amount, + item_stack_id=stack_id, + custom_name=custom_name, + filtered_custom_name=custom_name, + durability_correction=self._get_durability_correction(item), + )) + container_infos.append(ItemStackResponseContainerInfo( + container=FullContainerName(container_enum), + slots=slot_infos, + )) + return ItemStackResponse( + result=ItemStackResponse.RESULT_OK, + request_id=self._request_id, + container_infos=container_infos, + ) + + def changed_slots(self) -> dict[int, dict[int, int]]: + return self._changed_slots + + @classmethod + def build_error(cls, request_id: int) -> ItemStackResponse: + return ItemStackResponse( + result=ItemStackResponse.RESULT_ERROR, + request_id=request_id, + ) \ No newline at end of file diff --git a/src/jwinventoryapi/manager/container_manager/item_stack_tracker.py b/src/jwinventoryapi/manager/container_manager/item_stack_tracker.py new file mode 100644 index 0000000..b556b06 --- /dev/null +++ b/src/jwinventoryapi/manager/container_manager/item_stack_tracker.py @@ -0,0 +1,43 @@ +from bedrock_protocol.packets.types import ItemStackRequestSlotInfo +from endstone.inventory import ItemStack + +from jwinventoryapi.network.container_ui_ids import ContainerUIIds +from jwinventoryapi.util.item_utils import is_air + + +class ItemStackTracker: + def __init__(self): + self._next_stack_id = 1 + self._stack_ids: dict[tuple[int, int], int] = {} + + def seed_from_request(self, slot_info: ItemStackRequestSlotInfo) -> None: + key = (slot_info.container.container_enum, slot_info.slot) + if key in self._stack_ids: + return + if slot_info.net_id > 0: + self._stack_ids[key] = slot_info.net_id + self._next_stack_id = max(self._next_stack_id, slot_info.net_id + 1) + + def assign_slot(self, container_enum: int, slot: int, item: ItemStack | None) -> int: + if item is None or is_air(item): + stack_id = 0 + else: + stack_id = self._next_stack_id + self._next_stack_id += 1 + self._stack_ids[(container_enum, slot)] = stack_id + return stack_id + + def get_stack_id(self, container_enum: int, slot: int) -> int: + return self._stack_ids.get((container_enum, slot), 0) + + def clear_player_slots(self) -> None: + player_containers = ( + ContainerUIIds.INVENTORY, + ContainerUIIds.HOTBAR, + ContainerUIIds.COMBINED_HOTBAR_AND_INVENTORY, + ) + self._stack_ids = { + key: stack_id + for key, stack_id in self._stack_ids.items() + if key[0] not in player_containers + } \ No newline at end of file diff --git a/src/jwinventoryapi/manager/container_manager/transaction_container.py b/src/jwinventoryapi/manager/container_manager/transaction_container.py new file mode 100644 index 0000000..b887212 --- /dev/null +++ b/src/jwinventoryapi/manager/container_manager/transaction_container.py @@ -0,0 +1,33 @@ +from endstone.inventory import ItemStack + +from jwinventoryapi.manager.container.container_adapter import ContainerAdapter +from jwinventoryapi.util.item_utils import is_air, clone_item + + +class TransactionContainer: + def __init__(self, actual: ContainerAdapter): + self.actual = actual + self.changed_slots: dict[int, ItemStack | None] = {} + + def get(self, slot: int) -> ItemStack | None: + if slot in self.changed_slots: + item = self.changed_slots[slot] + else: + item = self.actual.get(slot) + if item is None or is_air(item): + return item + return clone_item(item) + + def set(self, slot: int, item: ItemStack | None) -> None: + if item is None or is_air(item): + self.changed_slots[slot] = None + else: + self.changed_slots[slot] = clone_item(item) + + def commit(self) -> None: + for slot, item in self.changed_slots.items(): + self.actual.set(slot, item) + self.changed_slots.clear() + + def discard(self) -> None: + self.changed_slots.clear() \ No newline at end of file diff --git a/src/jwinventoryapi/manager/player_manager.py b/src/jwinventoryapi/manager/player_manager.py index 03a9cd1..f5df241 100644 --- a/src/jwinventoryapi/manager/player_manager.py +++ b/src/jwinventoryapi/manager/player_manager.py @@ -20,9 +20,9 @@ def create_session(player: Player) -> 'Session': return session -def close_session(player: Player): +def close_session(player: Player, sync_inventory: bool = False): session = sessions.pop(player.unique_id, None) if session is not None: if session.menu is not None: session.menu._remove_session(session) - session.close() \ No newline at end of file + session.close(sync_inventory=sync_inventory) \ No newline at end of file diff --git a/src/jwinventoryapi/manager/session.py b/src/jwinventoryapi/manager/session.py index ab87dd1..1bb0c79 100644 --- a/src/jwinventoryapi/manager/session.py +++ b/src/jwinventoryapi/manager/session.py @@ -6,6 +6,7 @@ from endstone import Player from endstone.inventory import ItemStack +from jwinventoryapi.manager.container.container_manager import ContainerManager from jwinventoryapi.menu.graphic.block_graphic import BlockGraphic from jwinventoryapi.menu.graphic.block_pair_graphic import BlockPairGraphic from jwinventoryapi.menu.graphic.graphic import Graphic @@ -21,24 +22,27 @@ _PLAYER_CONTAINER_ID = 28 - class Session: - CONTAINER_ID: int = 200 + CONTAINER_ID: int = 2 MAX_OPEN_ATTEMPTS: int = 10 class State(Enum): NONE = 0 GRAPHIC_SENT = 1 GRAPHIC_RECEIVED = 2 - OPENING = 3 - OPEN = 4 - CLOSING = 5 + GRAPHIC_DATA_SENT = 3 + GRAPHIC_DATA_RECEIVED = 4 + OPENING = 5 + OPEN = 6 + CLOSING = 7 def __init__(self, player: Player): self.player: Player = player self._menu: 'Menu | None' = None self.state: Session.State = self.State.NONE self.graphic: Graphic | None = None + self.container_manager: ContainerManager | None = None + self.block_pos: list[BlockPos] = [] self.open_attempts: int = 0 self.ack_timestamp: int = 0 self.pending: deque['Menu'] = deque() @@ -62,6 +66,7 @@ def menu(self, value: 'Menu | None') -> None: self._menu = value if value is not None: value._add_session(self) + self.container_manager = ContainerManager(self.player, value.inventory) def send_menu(self): self.open_attempts = 0 @@ -75,10 +80,14 @@ def send_menu(self): def _send_graphic(self): self.graphic.send(self.player) - self.graphic.send_data(self.player) self.state = self.State.GRAPHIC_SENT self.ack_timestamp = send_ack_packet(self.player) + def send_graphic_data(self): + self.graphic.send_data(self.player) + self.state = self.State.GRAPHIC_DATA_SENT + self.ack_timestamp = send_ack_packet(self.player) + def open(self): self.state = self.State.OPENING self.graphic.open(self.player) @@ -90,9 +99,9 @@ def send_contents(self): for i in range(inventory.size): item_stack = inventory.get_item(i) if is_air(item_stack): - pk.items.append(ItemStackWrapper(0, item_stack)) + pk.items.append(ItemStackWrapper(0, item_stack, 0)) else: - pk.items.append(ItemStackWrapper(self._alloc_stack_id(), item_stack)) + pk.items.append(ItemStackWrapper(self._alloc_stack_id(), item_stack, 0)) self.player.send_packet(pk.get_packet_id(), pk.serialize()) def send_player_inventory(self): @@ -101,28 +110,40 @@ def send_player_inventory(self): for i in range(player_inv.size): item = player_inv.get_item(i) if item is None or is_air(item): - pk.items.append(ItemStackWrapper(0, ItemStack("minecraft:air"))) + pk.items.append(ItemStackWrapper(0, ItemStack("minecraft:air"), 0)) else: - pk.items.append(ItemStackWrapper(self._alloc_stack_id(), item)) + pk.items.append(ItemStackWrapper(self._alloc_stack_id(), item, 0)) self.player.send_packet(pk.get_packet_id(), pk.serialize()) def update_slot(self, slot: int): item = self.menu.inventory.get_item(slot) if is_air(item): - pk = InventorySlotPacket(self.CONTAINER_ID, slot, item=ItemStackWrapper(0, item)) + pk = InventorySlotPacket(self.CONTAINER_ID, slot, item=ItemStackWrapper(0, item, 0)) else: - pk = InventorySlotPacket(self.CONTAINER_ID, slot, item=ItemStackWrapper(self._alloc_stack_id(), item)) + pk = InventorySlotPacket(self.CONTAINER_ID, slot, item=ItemStackWrapper(self._alloc_stack_id(), item, 0)) self.player.send_packet(pk.get_packet_id(), pk.serialize()) - def close(self): + def close(self, sync_inventory: bool = False): self.state = self.State.CLOSING if self.graphic is not None: self.graphic.remove(self.player) + if self.container_manager is not None: + cursor_item = self.container_manager.cursor_container.get(0) + if cursor_item is not None: + self.player.inventory.add_item(cursor_item) + self.container_manager.cursor_container.set(0, None) + if sync_inventory: + self.container_manager.sync_player_inventory() def update_state(self, state: State): self.state = state match state: case self.State.GRAPHIC_RECEIVED: + self.send_graphic_data() + case self.State.GRAPHIC_DATA_RECEIVED: self.open() case self.State.OPEN: self.send_contents() + + def __del__(self): + self.close() \ No newline at end of file diff --git a/src/jwinventoryapi/menu/menu_transaction.py b/src/jwinventoryapi/menu/menu_transaction.py new file mode 100644 index 0000000..0a5c45d --- /dev/null +++ b/src/jwinventoryapi/menu/menu_transaction.py @@ -0,0 +1,77 @@ +from dataclasses import dataclass +from enum import Enum +from bedrock_protocol.packets.enums import ItemStackRequestActionType +from bedrock_protocol.packets.types import ItemStackRequestSlotInfo +from endstone import Player +from endstone.inventory import ItemStack + +class MenuTransactionResultType(Enum): + CONTINUE = "continue" + DISCARD = "discard" + + +@dataclass(frozen=True) +class MenuTransactionResult: + type: MenuTransactionResultType + + @property + def should_continue(self) -> bool: + return self.type == MenuTransactionResultType.CONTINUE + + @property + def should_discard(self) -> bool: + return self.type == MenuTransactionResultType.DISCARD + + +class MenuTransaction: + def __init__( + self, + player: Player, + slot: int, + item_clicked: ItemStack, + item_clicked_with: ItemStack, + action_type: ItemStackRequestActionType, + source: ItemStackRequestSlotInfo, + destination: ItemStackRequestSlotInfo, + ): + self._player = player + self._slot = slot + self._item_clicked = item_clicked + self._item_clicked_with = item_clicked_with + self._action_type = action_type + self._source = source + self._destination = destination + + @property + def player(self) -> Player: + return self._player + + @property + def slot(self) -> int: + return self._slot + + @property + def item_clicked(self) -> ItemStack: + return self._item_clicked + + @property + def item_clicked_with(self) -> ItemStack: + return self._item_clicked_with + + @property + def action_type(self) -> ItemStackRequestActionType: + return self._action_type + + @property + def source(self) -> ItemStackRequestSlotInfo: + return self._source + + @property + def destination(self) -> ItemStackRequestSlotInfo: + return self._destination + + def proceed(self) -> MenuTransactionResult: + return MenuTransactionResult(MenuTransactionResultType.CONTINUE) + + def discard(self) -> MenuTransactionResult: + return MenuTransactionResult(MenuTransactionResultType.DISCARD) \ No newline at end of file diff --git a/src/jwinventoryapi/network/container_ui_ids.py b/src/jwinventoryapi/network/container_ui_ids.py new file mode 100644 index 0000000..7911695 --- /dev/null +++ b/src/jwinventoryapi/network/container_ui_ids.py @@ -0,0 +1,8 @@ +from enum import IntEnum + +class ContainerUIIds(IntEnum): + LEVEL_ENTITY = 7 + COMBINED_HOTBAR_AND_INVENTORY = 12 + HOTBAR = 28 + INVENTORY = 29 + CURSOR = 59 \ No newline at end of file diff --git a/src/jwinventoryapi/network/inventory_content_packet.py b/src/jwinventoryapi/network/inventory_content_packet.py index 554bfc7..20a33ef 100644 --- a/src/jwinventoryapi/network/inventory_content_packet.py +++ b/src/jwinventoryapi/network/inventory_content_packet.py @@ -30,10 +30,9 @@ def write(self, stream: BinaryStream) -> None: stream.write_unsigned_varint(self.container_id) stream.write_unsigned_varint(len(self.items)) for item in self.items: - item.write(stream) + item.write_descriptor(stream) self.container_name.write(stream) - self.storage.write(stream) + self.storage.write_descriptor(stream) def read(self, stream: ReadOnlyBinaryStream) -> None: - self.container_id = stream.get_unsigned_varint() - pass + self.container_id = stream.get_unsigned_varint() \ No newline at end of file diff --git a/src/jwinventoryapi/network/inventory_slot_packet.py b/src/jwinventoryapi/network/inventory_slot_packet.py index bedf779..d232669 100644 --- a/src/jwinventoryapi/network/inventory_slot_packet.py +++ b/src/jwinventoryapi/network/inventory_slot_packet.py @@ -18,8 +18,8 @@ def __init__(self, super().__init__() self.container_id = container_id self.slot = slot - self.container_name = container_name or FullContainerName() - self.storage = storage or ItemStackWrapper() + self.container_name = container_name + self.storage = storage self.item = item or ItemStackWrapper() def get_packet_id(self) -> MinecraftPacketIds: @@ -31,9 +31,13 @@ def get_packet_name(self) -> str: def write(self, stream: BinaryStream) -> None: stream.write_unsigned_varint(self.container_id) stream.write_unsigned_varint(self.slot) - self.container_name.write(stream) - self.storage.write_header(stream) - self.item.write(stream) + stream.write_bool(self.container_name is not None) + if self.container_name is not None: + self.container_name.write(stream) + stream.write_bool(self.storage is not None) + if self.storage is not None: + self.storage.write(stream) + self.item.write_descriptor(stream) def read(self, stream: ReadOnlyBinaryStream) -> None: - pass + pass \ No newline at end of file diff --git a/src/jwinventoryapi/network/item_stack_wrapper.py b/src/jwinventoryapi/network/item_stack_wrapper.py index 3ff7376..85d8ab2 100644 --- a/src/jwinventoryapi/network/item_stack_wrapper.py +++ b/src/jwinventoryapi/network/item_stack_wrapper.py @@ -2,13 +2,13 @@ from bstream import BinaryStream from endstone.inventory import ItemStack -from jwinventoryapi.util.item_utils import build_tag, is_air +from jwinventoryapi.util.item_utils import build_tag, is_air, get_item_data class ItemStackWrapper: - def __init__(self, stack_id: int = 0, item_stack: ItemStack | None = None): - from jwinventoryapi.util.item_utils import get_item_data + def __init__(self, stack_id: int = 0, item_stack: ItemStack | None = None, stack_id_variant: int = 0): self.stack_id: int = stack_id + self.stack_id_variant: int = stack_id_variant self.item_stack: ItemStack = item_stack or ItemStack("minecraft:air") data = get_item_data(self.item_stack.type.id) if data is None: @@ -33,7 +33,6 @@ def write_footer(self, stream: BinaryStream): stream.write_raw_bytes(tag.to_binary_nbt()) else: stream.write_signed_short(0) - stream.write_unsigned_int(0) stream.write_unsigned_int(0) @@ -43,9 +42,25 @@ def write(self, stream: BinaryStream): stream.write_bool(has_net_id) if has_net_id: stream.write_varint(self.stack_id) - stream.write_varint(0) - user_data = BinaryStream() self.write_footer(user_data) stream.write_bytes(user_data.copy_buffer()) + + def write_descriptor(self, stream: BinaryStream): + is_air_item = is_air(self.item_stack) + has_net_id = self.stack_id != 0 + stream.write_signed_short(0 if is_air_item else self.data.item_id) + stream.write_unsigned_short(self.item_stack.amount) + stream.write_unsigned_varint(self.item_stack.data) + stream.write_bool(has_net_id) + if has_net_id: + stream.write_unsigned_varint(0) + stream.write_varint(self.stack_id) + stream.write_unsigned_varint(0) + if is_air_item: + stream.write_unsigned_varint(0) + return + user_data = BinaryStream() + self.write_footer(user_data) + stream.write_bytes(user_data.copy_buffer()) \ No newline at end of file diff --git a/src/jwinventoryapi/util/item_utils.py b/src/jwinventoryapi/util/item_utils.py index 142a488..a1e7e51 100644 --- a/src/jwinventoryapi/util/item_utils.py +++ b/src/jwinventoryapi/util/item_utils.py @@ -59,10 +59,30 @@ def is_air(item_stack: ItemStack | None) -> bool: def clone_item(item_stack: ItemStack) -> ItemStack: new_item = ItemStack(item_stack.type.id, item_stack.amount, item_stack.data) - new_item.set_item_meta(item_stack.item_meta.clone()) + if item_stack.item_meta is not None: + new_item.set_item_meta(item_stack.item_meta.clone()) return new_item +def pop_item(item_stack: ItemStack, count: int) -> tuple[ItemStack, ItemStack | None]: + if count < 1: + raise ValueError("count must be > 0") + if count > item_stack.amount: + raise ValueError(f"Cannot pop {count} items from stack of {item_stack.amount}") + removed = clone_item(item_stack) + removed.amount = count + remaining = item_stack.amount - count + if remaining == 0: + return removed, None + remainder = clone_item(item_stack) + remainder.amount = remaining + return removed, remainder + + +def can_stack(item1: ItemStack, item2: ItemStack) -> bool: + return item1.is_similar(item2) + + def all_item_data() -> dict[str, ItemData]: return _cached_items @@ -81,7 +101,6 @@ def get_enchant_type(enchant: str) -> int: def build_tag(item_meta: ItemMeta) -> CompoundTag: tag = CompoundTag() - if item_meta.has_display_name or item_meta.has_lore: display_tag = CompoundTag() if item_meta.has_display_name: @@ -92,7 +111,6 @@ def build_tag(item_meta: ItemMeta) -> CompoundTag: lore_list.append(line) display_tag.set("Lore", lore_list) tag.set("display", display_tag) - if item_meta.has_enchants: ench_list = ListTag() for enchant, level in item_meta.enchants.items(): @@ -101,11 +119,8 @@ def build_tag(item_meta: ItemMeta) -> CompoundTag: ench_tag.set("lvl", c_int16(level)) ench_list.append(ench_tag) tag.set("ench", ench_list) - if item_meta.has_repair_cost: tag.set("RepairCost", item_meta.repair_cost) - if item_meta.is_unbreakable: tag.set("Unbreakable", 1) - - return tag + return tag \ No newline at end of file