Asset Collection System Design

Generic Story-Layer Framework for Managing Asset Collections

Status: Draft/Tentative - Pending MVP validation
Layer: Application (tangl.story.concepts.asset)
Version: 3.8


Executive Summary

This document defines a generic asset collection framework at the story (application) layer that supports:

  • Wallets - countable/fungible assets (gold, tokens, currency)

  • Inventories - unconstrained discrete asset collections (bag of items)

  • Loadouts - constrained discrete asset collections with slots/validation (outfits, vehicles, credentials)

Domain-specific implementations (wearables, vehicle parts, documents) are author-layer mechanics that build on these application-layer primitives.


Architectural Layers

┌─────────────────────────────────────────────────────────────┐
│ AUTHOR LAYER (tangl.mechanics)                              │
│                                                             │
│  OutfitManager    VehicleManager    CredentialsPacket       │
│  DeckManager      RobotLoadout      etc.                    │
│                                                             │
│  Domain-specific rules, validation, rendering               │
└─────────────────────────────────────────────────────────────┘
                          ↓ extends
┌─────────────────────────────────────────────────────────────┐
│ APPLICATION LAYER (tangl.story.concepts.asset)              │
│                                                             │
│  AssetCollection (abstract base)                            │
│    ├─ AssetWallet (countable assets)                        │
│    ├─ AssetInventory (discrete, no constraints)             │
│    └─ ComponentManager (discrete, with constraints)         │
│                                                             │
│  Generic patterns, reusable validation, dispatch hooks      │
└─────────────────────────────────────────────────────────────┘
                          ↓ uses
┌─────────────────────────────────────────────────────────────┐
│ CORE LAYER (tangl.core)                                     │
│                                                             │
│  AssetType (singleton definition)                           │
│  DiscreteAsset (wrapped node for graph)                     │
│  CountableAsset (unwrapped type for counting)               │
│                                                             │
│  Foundational primitives, no domain knowledge               │
└─────────────────────────────────────────────────────────────┘

Core Concepts

Asset Types

AssetType (singleton definition)

  • Immutable definition shared across all instances

  • Loaded from YAML or created programmatically

  • Instance inheritance via from_ref pattern

  • Examples: “iron_sword”, “gold_coin”, “passport”

CountableAsset (fungible)

  • Tracked by count/quantity in a wallet

  • No graph nodes created

  • Examples: currency, tokens, resources

DiscreteAsset (unique)

  • Wrapped in graph nodes with instance state

  • Can be linked, owned, modified individually

  • Examples: specific sword, specific document

Collection Types

1. AssetWallet (Countable Collections)

Purpose: Manage counts of fungible assets

Use Cases:

  • Currency systems (gold, gems, credits)

  • Resources (wood, stone, food)

  • Tokens/points (experience, reputation)

  • Consumables (potions, ammo)

Characteristics:

  • No graph nodes

  • Fast addition/subtraction

  • Overflow/underflow checking

  • Value aggregation

Example:

wallet.gain(gold=50, gems=3)
if wallet.can_afford(gold=30):
    wallet.spend(gold=30)
total_value = wallet.total_value()

2. AssetInventory (Unconstrained Discrete Collections)

Purpose: Simple bag/container of discrete assets

Use Cases:

  • General inventory (backpack, chest)

  • Quest items collection

  • Loot drops

  • Storage containers

Characteristics:

  • Graph nodes for each item

  • No slot constraints

  • Optional weight/capacity limits

  • Simple add/remove

Example:

inventory.add(sword_token)
inventory.add(potion_token)
items = inventory.items  # All items
weapons = inventory.items_of_type(Weapon)

3. ComponentManager (Constrained Discrete Collections)

Purpose: Complex collections with slots, validation, dependencies

Use Cases:

  • Outfits (clothing layers, body coverage)

  • Vehicles (parts, power budgets, slots)

  • Credentials (requirements, expiry, dependencies)

  • Card decks (zones, limits, costs)

  • Robot loadouts (compatibility, resources)

Characteristics:

  • Graph nodes for each component

  • Slot/position constraints

  • Resource budgets

  • Dependencies between components

  • Validation rules

  • Aggregate properties

Example:

outfit.put_on(jacket)
outfit.open(jacket)  # State transition
errors = outfit.validate_configuration()
desc = outfit.describe()  # "wearing open jacket, jeans"

Design: AssetCollection Base

class AssetCollection(ABC):
    """
    Abstract base for all asset collection types.
    
    Provides common interface for:
    - Checking contents
    - Adding/removing assets
    - Validation
    - Serialization
    - Description/rendering
    
    Subclasses implement specific storage and validation logic.
    """
    
    def __init__(self, owner: Node):
        """
        Args:
            owner: The node that owns this collection
        """
        self.owner = owner
    
    # ==================
    # Abstract Interface
    # ==================
    
    @abstractmethod
    def contains(self, asset_identifier: str) -> bool:
        """Check if collection contains asset by label/type."""
        ...
    
    @abstractmethod
    def add(self, asset, *, ctx: Context = None) -> None:
        """Add asset to collection with validation."""
        ...
    
    @abstractmethod
    def remove(self, asset, *, ctx: Context = None) -> None:
        """Remove asset from collection."""
        ...
    
    @abstractmethod
    def clear(self) -> None:
        """Remove all assets."""
        ...
    
    @abstractmethod
    def validate(self, *, context: dict = None) -> list[str]:
        """
        Validate current collection state.
        
        Returns:
            List of error messages (empty if valid)
        """
        ...
    
    @abstractmethod
    def describe(self) -> str:
        """Generate narrative description of collection."""
        ...
    
    # ==================
    # Common Helpers
    # ==================
    
    def is_valid(self, *, context: dict = None) -> bool:
        """Check if collection is valid."""
        return len(self.validate(context=context)) == 0
    
    def get_errors(self, *, context: dict = None) -> list[str]:
        """Get validation errors."""
        return self.validate(context=context)

Design: AssetWallet (Countable)

class AssetWallet(Counter[str], AssetCollection):
    """
    Collection for countable/fungible assets.
    
    Stores counts keyed by asset label. Does not create graph nodes.
    
    Example:
        wallet = AssetWallet(player)
        wallet.gain(gold=50, gems=3)
        wallet.spend(gold=10)
        wallet.can_afford(gold=30)  # True
        wallet.total_value()  # 43.0 (if gold=1.0, gems=10.0)
    """
    
    def __init__(self, owner: Node):
        AssetCollection.__init__(self, owner)
        Counter.__init__(self)
    
    # ==================
    # Wallet-Specific API
    # ==================
    
    def gain(self, **amounts: float) -> None:
        """
        Add countable assets.
        
        Args:
            **amounts: Keyword args of asset_label=count
            
        Example:
            wallet.gain(gold=50, gems=3)
        """
        self.update(amounts)
    
    def can_afford(self, **amounts: float) -> bool:
        """
        Check if wallet contains sufficient assets.
        
        Args:
            **amounts: Required amounts by asset label
            
        Returns:
            True if all requirements met
        """
        for label, required in amounts.items():
            if self[label] < required:
                return False
        return True
    
    def spend(self, **amounts: float) -> None:
        """
        Remove assets (raises if insufficient).
        
        Args:
            **amounts: Amounts to remove by asset label
            
        Raises:
            ValueError: If insufficient assets
        """
        if not self.can_afford(**amounts):
            raise ValueError(
                f"Insufficient assets: need {amounts}, have {dict(self)}"
            )
        for label, amount in amounts.items():
            self[label] -= amount
    
    def total_value(self, asset_manager: AssetManager = None) -> float:
        """
        Calculate total value of all assets.
        
        Args:
            asset_manager: Manager to look up asset values
            
        Returns:
            Sum of (count * asset.value) for all assets
        """
        if not asset_manager:
            # Try to get from owner's world
            asset_manager = getattr(self.owner.graph, 'asset_manager', None)
        
        if not asset_manager:
            raise ValueError("No asset_manager available for value calculation")
        
        total = 0.0
        for label, count in self.items():
            asset_type = asset_manager.get_countable_type('currency', label)
            total += asset_type.value * count
        return total
    
    # ==================
    # AssetCollection Interface
    # ==================
    
    def contains(self, asset_label: str) -> bool:
        """Check if wallet has any of this asset."""
        return self[asset_label] > 0
    
    def add(self, asset_label: str, amount: float = 1.0, *, ctx: Context = None):
        """Add amount of asset."""
        self.gain(**{asset_label: amount})
    
    def remove(self, asset_label: str, amount: float = 1.0, *, ctx: Context = None):
        """Remove amount of asset."""
        self.spend(**{asset_label: amount})
    
    def clear(self):
        """Remove all assets."""
        Counter.clear(self)
    
    def validate(self, *, context: dict = None) -> list[str]:
        """Validate wallet state (always valid for basic wallet)."""
        return []
    
    def describe(self) -> str:
        """Describe wallet contents."""
        if not self:
            return "empty wallet"
        
        items = sorted(self.items(), key=lambda x: -x[1])
        parts = [f"{count:.0f} {label}" for label, count in items if count > 0]
        return ", ".join(parts)

Design: AssetInventory (Unconstrained Discrete)

class AssetInventory(AssetCollection):
    """
    Collection for discrete assets without constraints.
    
    Simple bag/container that holds discrete asset nodes.
    Optional capacity limits, but no slot/compatibility rules.
    
    Example:
        inventory = AssetInventory(player)
        inventory.add(sword_token)
        inventory.add(potion_token)
        items = inventory.items  # All items
        weapons = inventory.items_of_type(Weapon)
    """
    
    def __init__(self, owner: Node, *, max_items: int = None, max_weight: float = None):
        """
        Args:
            owner: Owning node
            max_items: Maximum number of items (None = unlimited)
            max_weight: Maximum weight capacity (None = unlimited)
        """
        super().__init__(owner)
        self.max_items = max_items
        self.max_weight = max_weight
    
    # ==================
    # Collection Access
    # ==================
    
    @property
    def items(self) -> list[DiscreteAsset]:
        """All items in inventory."""
        return self.owner.find_children(DiscreteAsset)
    
    def items_of_type(self, asset_type: Type[DiscreteAsset]) -> list[DiscreteAsset]:
        """Get items of specific type."""
        return [item for item in self.items if isinstance(item, asset_type)]
    
    def get_item(self, label: str) -> Optional[DiscreteAsset]:
        """Get specific item by label."""
        matches = [item for item in self.items if item.label == label]
        return matches[0] if matches else None
    
    def count(self) -> int:
        """Number of items in inventory."""
        return len(self.items)
    
    def total_weight(self) -> float:
        """Total weight of all items."""
        return sum(
            getattr(item.singleton, 'weight', 0.0) 
            for item in self.items
        )
    
    # ==================
    # Validation
    # ==================
    
    def can_add(self, item: DiscreteAsset) -> tuple[bool, list[str]]:
        """
        Check if item can be added.
        
        Returns:
            (can_add, error_messages)
        """
        errors = []
        
        # Check item count
        if self.max_items is not None:
            if self.count() >= self.max_items:
                errors.append(f"Inventory full ({self.max_items} items)")
        
        # Check weight
        if self.max_weight is not None:
            item_weight = getattr(item.singleton, 'weight', 0.0)
            if self.total_weight() + item_weight > self.max_weight:
                errors.append(
                    f"Too heavy: {self.total_weight() + item_weight:.1f} > {self.max_weight}"
                )
        
        return len(errors) == 0, errors
    
    def validate(self, *, context: dict = None) -> list[str]:
        """Validate current inventory state."""
        errors = []
        
        # Check total count
        if self.max_items is not None:
            if self.count() > self.max_items:
                errors.append(f"Too many items: {self.count()} > {self.max_items}")
        
        # Check total weight
        if self.max_weight is not None:
            total = self.total_weight()
            if total > self.max_weight:
                errors.append(f"Overweight: {total:.1f} > {self.max_weight}")
        
        return errors
    
    # ==================
    # Mutation API
    # ==================
    
    def add(self, item: DiscreteAsset, *, ctx: Context = None) -> None:
        """
        Add item to inventory.
        
        Args:
            item: Discrete asset node
            ctx: Optional context for dispatch
            
        Raises:
            ValueError: If capacity exceeded
        """
        can_add, errors = self.can_add(item)
        if not can_add:
            raise ValueError(f"Cannot add item: {errors}")
        
        # Dispatch validation
        if ctx:
            from tangl.story.dispatch import story_dispatch
            story_dispatch.dispatch(
                item, ctx=ctx, task="validate_inventory_add", inventory=self
            )
        
        # Link item to owner
        self.owner.add_child(item)
        item.owner_id = self.owner.uid
        
        # Lifecycle event
        if ctx:
            story_dispatch.dispatch(
                item, ctx=ctx, task="inventory_add", inventory=self
            )
    
    def remove(self, item: DiscreteAsset, *, ctx: Context = None) -> None:
        """
        Remove item from inventory.
        
        Args:
            item: Item to remove
            ctx: Optional context for dispatch
        """
        if item not in self.items:
            raise ValueError(f"Item {item.label} not in inventory")
        
        # Lifecycle event
        if ctx:
            from tangl.story.dispatch import story_dispatch
            story_dispatch.dispatch(
                item, ctx=ctx, task="inventory_remove", inventory=self
            )
        
        # Unlink from owner
        item.owner_id = None
        self.owner.remove_child(item)
    
    def clear(self):
        """Remove all items."""
        for item in list(self.items):
            self.remove(item)
    
    # ==================
    # AssetCollection Interface
    # ==================
    
    def contains(self, asset_label: str) -> bool:
        """Check if inventory contains item."""
        return self.get_item(asset_label) is not None
    
    def describe(self) -> str:
        """Describe inventory contents."""
        if not self.items:
            return "empty inventory"
        
        count = self.count()
        weight_info = f", {self.total_weight():.1f} lbs" if self.max_weight else ""
        return f"{count} items{weight_info}"

Design: ComponentManager (Constrained Discrete)

class ComponentManager(AssetCollection, Generic[CT]):
    """
    Collection for discrete assets with complex constraints.
    
    Supports:
    - Slot/position assignments
    - Layering/priority
    - Resource budgets (power, weight, etc.)
    - Inter-component dependencies
    - State machines
    - Temporal validation (expiry, cooldowns)
    - Context-aware rules (destination, mission)
    - Aggregate property calculation
    
    This is the base class for domain-specific managers like
    OutfitManager, VehicleManager, CredentialsPacket, etc.
    
    Example:
        class OutfitManager(ComponentManager[Wearable]):
            slot_capacity = {BodyRegion.UPPER: 10, ...}
            tracked_resources = []
            
            def is_visible(self, item):
                # Custom visibility logic
                ...
    """
    
    def __init__(self, owner: Node):
        super().__init__(owner)
    
    # ==================
    # Configuration (Override in Subclass)
    # ==================
    
    slot_capacity: ClassVar[dict[str, int]] = {}
    """Maximum components per slot. Empty = no slot constraints."""
    
    tracked_resources: ClassVar[list[str]] = []
    """Resource names to validate (power, weight, etc.)."""
    
    required_components: ClassVar[list[str]] = []
    """Component labels that must be present."""
    
    # ==================
    # Collection Access
    # ==================
    
    @property
    def components(self) -> list[CT]:
        """All components managed by this collection."""
        return self.owner.find_children(DiscreteAsset)
    
    def get_component(self, label: str) -> Optional[CT]:
        """Get component by label."""
        matches = [c for c in self.components if c.label == label]
        return matches[0] if matches else None
    
    def get_component_of_type(self, component_type: Type[CT]) -> Optional[CT]:
        """Get single component of specific type (chassis, engine, etc)."""
        results = [c for c in self.components if isinstance(c, component_type)]
        return results[0] if results else None
    
    def get_components_of_type(self, component_type: Type[CT]) -> list[CT]:
        """Get all components of specific type (weapons, gadgets, etc)."""
        return [c for c in self.components if isinstance(c, component_type)]
    
    def by_slot(self, slot: str) -> list[CT]:
        """Get components in a specific slot."""
        return [
            c for c in self.components 
            if getattr(c.singleton, 'slot', None) == slot
        ]
    
    def by_layer(self, layer: int) -> list[CT]:
        """Get components at a specific layer/priority."""
        return [
            c for c in self.components 
            if getattr(c.singleton, 'layer', 0) == layer
        ]
    
    # ==================
    # Visibility (Override for Complex Rules)
    # ==================
    
    def is_visible(self, component: CT) -> bool:
        """
        Check if component is visible/active.
        
        Override in subclass for domain-specific visibility rules
        (e.g., layering, coverage, occlusion).
        
        Default: Visible if not in 'off' or 'disabled' state.
        """
        state = getattr(component, 'state', None)
        return state not in {'off', 'disabled', 'removed'}
    
    def visible_components(self) -> list[CT]:
        """Get all visible components."""
        return [c for c in self.components if self.is_visible(c)]
    
    # ==================
    # Validation
    # ==================
    
    def validate(self, *, context: dict = None) -> list[str]:
        """
        Full validation. Returns list of error messages.
        
        Runs all validation checks:
        - Required components
        - Resource budgets
        - Inter-component dependencies
        - Temporal constraints
        - Slot capacity
        - Context-specific rules
        - Custom subclass rules
        """
        errors = []
        
        # Required components
        errors.extend(self._check_required_components())
        
        # Resource budgets
        errors.extend(self._validate_resources())
        
        # Dependencies between components
        errors.extend(self._check_dependencies())
        
        # Temporal constraints
        if context and 'current_time' in context:
            errors.extend(self._check_temporal_validity(
                current_time=context['current_time']
            ))
        
        # Slot capacity
        errors.extend(self._check_slot_capacity())
        
        # Context-specific rules
        if context:
            errors.extend(self._validate_in_context(context=context))
        
        # Subclass-specific rules
        errors.extend(self._validate_custom())
        
        return errors
    
    def _check_required_components(self) -> list[str]:
        """Check that required components are present."""
        errors = []
        for required_label in self.required_components:
            if not self.get_component(required_label):
                errors.append(f"Missing required component: {required_label}")
        return errors
    
    def _validate_resources(self) -> list[str]:
        """Check resource budgets (power, weight, etc)."""
        errors = []
        for resource in self.tracked_resources:
            used, capacity = self._check_resource_budget(resource)
            if capacity is not None and used > capacity:
                errors.append(
                    f"{resource} overload: {used:.1f} > {capacity:.1f}"
                )
        return errors
    
    def _check_resource_budget(self, resource: str) -> tuple[float, Optional[float]]:
        """
        Calculate resource usage.
        
        Returns:
            (used, capacity) tuple. capacity=None means unlimited.
        """
        # Get capacity from owner
        capacity = getattr(self.owner, f'max_{resource}', None)
        
        # Sum costs from components
        used = sum(
            getattr(c.singleton, f'{resource}_cost', 0.0)
            for c in self.components
        )
        
        return used, capacity
    
    def _check_dependencies(self) -> list[str]:
        """Validate inter-component dependencies."""
        errors = []
        for component in self.components:
            requires = getattr(component.singleton, 'requires', [])
            for req in requires:
                if not self.get_component(req):
                    errors.append(
                        f"{component.label} requires {req}"
                    )
        return errors
    
    def _check_temporal_validity(self, *, current_time: int) -> list[str]:
        """Check time-based validity (expiry, cooldowns)."""
        errors = []
        for component in self.components:
            # Check expiry
            expiry = getattr(component, 'expiry_time', None)
            if expiry is not None and expiry < current_time:
                errors.append(f"{component.label} expired")
            
            # Check cooldown
            cooldown = getattr(component, 'cooldown_until', None)
            if cooldown is not None and cooldown > current_time:
                errors.append(f"{component.label} on cooldown")
        
        return errors
    
    def _check_slot_capacity(self) -> list[str]:
        """Check slot capacity constraints."""
        errors = []
        for slot, max_count in self.slot_capacity.items():
            actual = len(self.by_slot(slot))
            if actual > max_count:
                errors.append(
                    f"Slot '{slot}' has {actual} components (max {max_count})"
                )
        return errors
    
    def _validate_in_context(self, *, context: dict) -> list[str]:
        """
        Context-specific validation (destination, mission, etc).
        
        Override in subclass for domain-specific context rules.
        """
        return []
    
    def _validate_custom(self) -> list[str]:
        """
        Subclass-specific validation rules.
        
        Override in subclass for additional domain logic.
        """
        return []
    
    # ==================
    # Aggregate Properties
    # ==================
    
    def aggregate_property(self, prop: str, default: float = 0) -> float:
        """
        Sum a property across all components.
        
        Includes:
        - Base value from singleton definition
        - Bonuses from instance state
        - Penalties from instance state
        
        Example:
            total_defense = manager.aggregate_property('defense_bonus')
        """
        total = default
        for component in self.components:
            # From singleton definition
            total += getattr(component.singleton, prop, 0)
            # From instance bonuses
            total += getattr(component, f'{prop}_bonus', 0)
            # From instance penalties
            total -= getattr(component, f'{prop}_penalty', 0)
        return total
    
    # ==================
    # Mutation API
    # ==================
    
    def can_add(self, component: CT) -> tuple[bool, list[str]]:
        """
        Check if component can be added.
        
        Returns:
            (can_add, error_messages)
        """
        # Would need to temporarily add and validate
        # For now, just return True
        # Subclasses can override for pre-checks
        return True, []
    
    def add(self, component: CT, *, ctx: Context = None) -> None:
        """
        Add component with validation.
        
        Args:
            component: Component to add
            ctx: Optional context for dispatch
            
        Raises:
            ValueError: If validation fails
        """
        can_add, errors = self.can_add(component)
        if not can_add:
            raise ValueError(f"Cannot add component: {errors}")
        
        # Dispatch validation
        if ctx:
            from tangl.story.dispatch import story_dispatch
            story_dispatch.dispatch(
                component, ctx=ctx, task="validate_component_add", manager=self
            )
        
        # Link to owner
        self.owner.add_child(component)
        component.owner_id = self.owner.uid
        
        # Lifecycle event
        if ctx:
            story_dispatch.dispatch(
                component, ctx=ctx, task="component_add", manager=self
            )
    
    def remove(self, component: CT, *, ctx: Context = None) -> None:
        """Remove component from manager."""
        if component not in self.components:
            raise ValueError(f"{component.label} not in collection")
        
        # Lifecycle event
        if ctx:
            from tangl.story.dispatch import story_dispatch
            story_dispatch.dispatch(
                component, ctx=ctx, task="component_remove", manager=self
            )
        
        # Unlink
        component.owner_id = None
        self.owner.remove_child(component)
    
    def clear(self):
        """Remove all components."""
        for component in list(self.components):
            self.remove(component)
    
    # ==================
    # AssetCollection Interface
    # ==================
    
    def contains(self, asset_label: str) -> bool:
        """Check if collection contains component."""
        return self.get_component(asset_label) is not None
    
    def describe(self) -> str:
        """
        Generate narrative description.
        
        Override in subclass for rich domain-specific descriptions.
        Default: Simple count.
        """
        visible = self.visible_components()
        if not visible:
            return "no components"
        return f"{len(visible)} components"

Mixin Pattern for Owners

class HasAssetWallet:
    """Mixin for nodes with countable asset wallets."""
    
    _wallet: Optional[AssetWallet] = None
    
    @property
    def wallet(self) -> AssetWallet:
        """Access the asset wallet."""
        if self._wallet is None:
            self._wallet = AssetWallet(self)
        return self._wallet


class HasAssetInventory:
    """Mixin for nodes with unconstrained inventories."""
    
    _inventory: Optional[AssetInventory] = None
    
    @property
    def inventory(self) -> AssetInventory:
        """Access the inventory."""
        if self._inventory is None:
            self._inventory = AssetInventory(self)
        return self._inventory


class HasComponents:
    """Mixin for nodes with component managers."""
    
    # Subclass must override
    _component_manager_class: ClassVar[Type[ComponentManager]] = ComponentManager
    
    @property
    def components(self) -> ComponentManager:
        """Access the component manager."""
        return self._component_manager_class(self)

Usage Examples

Example 1: Wallet (Countable Assets)

from tangl.story.concepts.asset import CountableAsset, AssetWallet, HasAssetWallet

# Define currency types
class Currency(CountableAsset):
    value: float = 1.0
    symbol: str = "$"

Currency(label='gold', value=1.0, symbol='🪙')
Currency(label='gems', value=10.0, symbol='💎')

# Use wallet
class Player(Actor, HasAssetWallet):
    pass

player = Player(label='alice', graph=story_graph)

# Transactions
player.wallet.gain(gold=50, gems=3)
print(player.wallet.describe())  # "50 gold, 3 gems"

if player.wallet.can_afford(gold=30):
    player.wallet.spend(gold=30)
    print("Bought sword!")

total = player.wallet.total_value()  # 20 + 30 = 50

Example 2: Inventory (Unconstrained Discrete)

from tangl.story.concepts.asset import AssetType, DiscreteAsset, AssetInventory

# Define item types
class Item(AssetType):
    weight: float = 1.0
    value: int = 10

Item(label='sword', weight=3.5, value=50)
Item(label='potion', weight=0.5, value=20)

# Create tokens
graph = Graph(label='game')
sword = DiscreteAsset[Item](label='sword', graph=graph)
potion = DiscreteAsset[Item](label='potion', graph=graph)

# Use inventory
player = Player(label='bob', graph=graph)
player.inventory.add(sword)
player.inventory.add(potion)

print(player.inventory.describe())  # "2 items, 4.0 lbs"

# Check capacity
if player.inventory.count() < player.inventory.max_items:
    player.inventory.add(another_item)

Example 3: Components (Constrained Discrete)

# See OutfitManager, VehicleManager examples in previous docs
# These are author-layer implementations in tangl.mechanics

Migration Path

Phase 1: MVP - Core Infrastructure

Goal: Get basic discrete and countable assets working

Tasks:

  1. Fix DiscreteAsset generic typing

  2. Implement AssetWallet (countable)

  3. Implement AssetInventory (simple discrete)

  4. Complete AssetManager with YAML loading

  5. Comprehensive tests for all three

Deliverables:

  • test_countable_asset.py (8 tests)

  • test_discrete_asset.py (10 tests)

  • test_asset_wallet.py (12 tests)

  • test_asset_inventory.py (15 tests)

  • test_asset_manager.py (12 tests)

  • Example YAML files

Time: ~1 week

Phase 2: Application Layer - Component Manager Base

Goal: Extract and generalize the pattern

Tasks:

  1. Create ComponentManager base class

  2. Port OutfitManager to use base

  3. Add dispatch hooks for lifecycle events

  4. Documentation and examples

Deliverables:

  • test_component_manager.py (20 tests)

  • test_outfit_manager.py (25 tests)

  • API documentation

  • Extension guide

Time: ~1 week

Phase 3: Author Layer - Domain Implementations

Goal: Prove generalization with multiple domains

Tasks:

  1. Implement VehicleManager in tangl.mechanics

  2. Implement CredentialsPacket in tangl.mechanics

  3. Each with full tests and examples

Deliverables:

  • Working vehicle builder

  • Working credentials validator

  • Example stories using both

Time: ~2 weeks (1 week per domain)


Open Design Questions

1. Naming

Current:

  • AssetWallet - countable

  • AssetInventory - discrete, unconstrained

  • ComponentManager - discrete, constrained

Alternatives:

  • AssetBag instead of AssetInventory?

  • AssetLoadout instead of ComponentManager?

  • SlottedCollection instead of ComponentManager?

2. Single vs Multiple Managers

Option A: One node can have multiple collection types

class Player(Actor, HasAssetWallet, HasAssetInventory, HasComponents):
    @property
    def outfit(self) -> OutfitManager:
        return OutfitManager(self)

Option B: Collections are exclusive

class Player(Actor, HasAssetWallet):
    # Can only have wallet, not inventory

Recommendation: Option A - allow mixing

3. Dispatch Integration

When to emit events?

  • On add/remove? (Yes)

  • On state transitions? (Yes)

  • On validation? (Maybe - for vetoing)

How to pass context?

  • Always require ctx parameter?

  • Make optional with ctx=None?

Recommendation: Optional context, emit on mutations

4. Serialization

How to serialize collections?

  • Wallet: Just counts dict {"gold": 50}

  • Inventory: Reference item UIDs ["uid1", "uid2"]

  • ComponentManager: Same as inventory?

What about state?

  • Wallet: No special state

  • Inventory: Capacity limits persisted?

  • ComponentManager: Slot assignments persisted on items?

Recommendation: Collections serialize as item references, state on items


Success Criteria

MVP (Phase 1)

AssetWallet works with countable assets
AssetInventory works with discrete assets
✅ Both integrate with AssetManager
✅ YAML loading for asset definitions
✅ All tests pass
✅ Can create player with wallet and inventory

Application Layer (Phase 2)

ComponentManager base class defined
✅ Supports multiple component types
✅ Resource budgets, dependencies, validation
✅ Dispatch hooks for lifecycle events
OutfitManager uses base

Author Layer (Phase 3)

✅ At least 2 additional domains implemented
✅ Each domain has full tests
✅ Example stories demonstrating usage
✅ Documentation for creating new managers


Conclusion

This design provides:

  1. Clear layering - Core → Application → Author

  2. Incremental implementation - MVP first, then generalize

  3. Flexibility - Simple wallets to complex loadouts

  4. Extensibility - Easy to add new domains

The key insight: asset collections are a spectrum, from simple counts to complex constrained systems. The design supports all cases with a common base abstraction while allowing domain-specific specialization.

Next steps: Validate core infrastructure (Phase 1 MVP), then extract patterns (Phase 2), then prove generalization (Phase 3).