scripts/inventory.pyΒΆ

Part of Dungeon Explorer.

  1"""Inventory grid logic: add/remove/equip/stack items."""
  2
  3from .item import EQUIPMENT_SLOTS, Item
  4
  5
  6class Inventory:
  7    """Grid-based inventory with equipment slots.
  8
  9    Args:
 10        rows: Number of inventory rows.
 11        cols: Number of inventory columns.
 12    """
 13
 14    def __init__(self, rows: int = 5, cols: int = 8):
 15        self.rows = rows
 16        self.cols = cols
 17        self._grid: list[Item | None] = [None] * (rows * cols)
 18        self._equipment: dict[str, Item | None] = dict.fromkeys(EQUIPMENT_SLOTS)
 19
 20    @property
 21    def capacity(self) -> int:
 22        return self.rows * self.cols
 23
 24    @property
 25    def item_count(self) -> int:
 26        return sum(1 for item in self._grid if item is not None)
 27
 28    @property
 29    def is_full(self) -> bool:
 30        return all(slot is not None for slot in self._grid)
 31
 32    def get(self, index: int) -> Item | None:
 33        """Get item at grid index."""
 34        if 0 <= index < len(self._grid):
 35            return self._grid[index]
 36        return None
 37
 38    def set(self, index: int, item: Item | None) -> None:
 39        """Set item at grid index."""
 40        if 0 <= index < len(self._grid):
 41            self._grid[index] = item
 42
 43    def add(self, item: Item) -> bool:
 44        """Add item to first available slot. Returns True if successful.
 45
 46        Stackable items are stacked first, then placed in empty slots.
 47        """
 48        if item.is_stackable:
 49            # Try to stack with existing
 50            for existing in self._grid:
 51                if (
 52                    existing is not None
 53                    and existing.template_key == item.template_key
 54                    and existing.stack_count < existing.stack_size
 55                ):
 56                    space = existing.stack_size - existing.stack_count
 57                    transfer = min(item.stack_count, space)
 58                    existing.stack_count += transfer
 59                    item.stack_count -= transfer
 60                    if item.stack_count <= 0:
 61                        return True
 62
 63        # Place in empty slot
 64        for i, slot in enumerate(self._grid):
 65            if slot is None:
 66                self._grid[i] = item
 67                return True
 68        return False
 69
 70    def remove(self, index: int) -> Item | None:
 71        """Remove and return item at index."""
 72        if 0 <= index < len(self._grid):
 73            item = self._grid[index]
 74            self._grid[index] = None
 75            return item
 76        return None
 77
 78    def equip(self, item: Item) -> Item | None:
 79        """Equip an item, returning the previously equipped item (or None)."""
 80        if not item.is_equipment:
 81            return None
 82        slot = item.slot
 83        previous = self._equipment.get(slot)
 84        self._equipment[slot] = item
 85        return previous
 86
 87    def unequip(self, slot: str) -> Item | None:
 88        """Unequip item from a slot. Returns the item, or None if slot was empty."""
 89        item = self._equipment.get(slot)
 90        if item is not None:
 91            self._equipment[slot] = None
 92        return item
 93
 94    def get_equipped(self, slot: str) -> Item | None:
 95        """Get the currently equipped item in a slot."""
 96        return self._equipment.get(slot)
 97
 98    def all_equipped(self) -> dict[str, Item | None]:
 99        """Return dict of all equipment slots."""
100        return dict(self._equipment)
101
102    def equipment_stats(self) -> dict[str, float]:
103        """Compute total stats from all equipped items."""
104        stats: dict[str, float] = {}
105        for item in self._equipment.values():
106            if item is None:
107                continue
108            for key, val in item.total_stats().items():
109                stats[key] = stats.get(key, 0) + val
110        return stats
111
112    def items(self) -> list[tuple[int, Item]]:
113        """Return list of (index, item) for non-empty slots."""
114        return [(i, item) for i, item in enumerate(self._grid) if item is not None]
115
116    def find_by_template(self, template_key: str) -> list[tuple[int, Item]]:
117        """Find all items matching a template key."""
118        return [
119            (i, item) for i, item in enumerate(self._grid) if item is not None and item.template_key == template_key
120        ]
121
122    def swap(self, idx_a: int, idx_b: int) -> None:
123        """Swap items at two indices."""
124        self._grid[idx_a], self._grid[idx_b] = self._grid[idx_b], self._grid[idx_a]
125
126    def to_dict(self) -> dict:
127        """Serialise inventory to dict."""
128        return {
129            "rows": self.rows,
130            "cols": self.cols,
131            "grid": [item.to_dict() if item else None for item in self._grid],
132            "equipment": {slot: (item.to_dict() if item else None) for slot, item in self._equipment.items()},
133        }
134
135    @classmethod
136    def from_dict(cls, d: dict) -> Inventory:
137        """Deserialise from dict."""
138        inv = cls(rows=d.get("rows", 5), cols=d.get("cols", 8))
139        for i, item_data in enumerate(d.get("grid", [])):
140            if item_data and i < len(inv._grid):
141                inv._grid[i] = Item.from_dict(item_data)
142        for slot, item_data in d.get("equipment", {}).items():
143            if item_data and slot in inv._equipment:
144                inv._equipment[slot] = Item.from_dict(item_data)
145        return inv