EU-Utility/core/event_bus.py

832 lines
26 KiB
Python

"""
EU-Utility - Enhanced Event Bus
===============================
Core service for typed event handling with:
- Typed events using dataclasses
- Event filtering (mob types, damage thresholds, etc.)
- Event persistence (last 1000 events)
- Event replay (replay last N events to new subscribers)
- Async event handling (non-blocking publishers)
- Event statistics (events per minute, etc.)
Quick Start:
------------
from core.event_bus import get_event_bus, LootEvent, DamageEvent
bus = get_event_bus()
# Subscribe to events
sub_id = bus.subscribe_typed(LootEvent, on_loot)
# Publish events
bus.publish(LootEvent(mob_name="Atrox", items=[...]))
# Get recent events
recent_loot = bus.get_recent_events(LootEvent, count=10)
Event Types:
------------
- SkillGainEvent: Skill increases
- LootEvent: Loot received
- DamageEvent: Combat damage
- GlobalEvent: Global announcements
- ChatEvent: Chat messages
- EconomyEvent: Economic transactions
- SystemEvent: System notifications
"""
import asyncio
import time
import threading
from collections import deque, defaultdict
from dataclasses import dataclass, field, asdict
from datetime import datetime
from enum import Enum, auto
from typing import Any, Callable, Dict, List, Optional, Type, TypeVar, Union
# ========== Event Types ==========
class EventCategory(Enum):
"""Event categories for organization."""
SKILL = auto()
LOOT = auto()
COMBAT = auto()
ECONOMY = auto()
SYSTEM = auto()
CHAT = auto()
GLOBAL = auto()
@dataclass(frozen=True)
class BaseEvent:
"""Base class for all typed events.
Attributes:
timestamp: When the event occurred
source: Source of the event (plugin name, etc.)
"""
timestamp: datetime = field(default_factory=datetime.now)
source: str = "unknown"
@property
def event_type(self) -> str:
"""Return the event type name."""
return self.__class__.__name__
@property
def category(self) -> EventCategory:
"""Return the event category."""
return EventCategory.SYSTEM
def to_dict(self) -> Dict[str, Any]:
"""Convert event to dictionary."""
return {
'event_type': self.event_type,
'category': self.category.name,
**asdict(self)
}
@dataclass(frozen=True)
class SkillGainEvent(BaseEvent):
"""Event fired when a skill increases.
Attributes:
skill_name: Name of the skill
skill_value: New skill value
gain_amount: Amount gained
"""
skill_name: str = ""
skill_value: float = 0.0
gain_amount: float = 0.0
@property
def category(self) -> EventCategory:
return EventCategory.SKILL
@dataclass(frozen=True)
class LootEvent(BaseEvent):
"""Event fired when loot is received.
Attributes:
mob_name: Name of the mob killed
items: List of looted items
total_tt_value: Total TT value of loot
position: Optional (x, y, z) position
"""
mob_name: str = ""
items: List[Dict[str, Any]] = field(default_factory=list)
total_tt_value: float = 0.0
position: Optional[tuple] = None
@property
def category(self) -> EventCategory:
return EventCategory.LOOT
def get_item_names(self) -> List[str]:
"""Get list of item names from loot."""
return [item.get('name', 'Unknown') for item in self.items]
@dataclass(frozen=True)
class DamageEvent(BaseEvent):
"""Event fired when damage is dealt or received.
Attributes:
damage_amount: Amount of damage
damage_type: Type of damage (impact, penetration, etc.)
is_critical: Whether it was a critical hit
target_name: Name of the target
attacker_name: Name of the attacker
is_outgoing: True if player dealt damage
"""
damage_amount: float = 0.0
damage_type: str = "" # e.g., "impact", "penetration", "burn"
is_critical: bool = False
target_name: str = ""
attacker_name: str = ""
is_outgoing: bool = True
@property
def category(self) -> EventCategory:
return EventCategory.COMBAT
def is_high_damage(self, threshold: float = 100.0) -> bool:
"""Check if damage exceeds threshold."""
return self.damage_amount >= threshold
@dataclass(frozen=True)
class GlobalEvent(BaseEvent):
"""Event for global announcements.
Attributes:
player_name: Name of the player
achievement_type: Type of achievement (hof, ath, discovery)
value: Value of the achievement
item_name: Optional item name
"""
player_name: str = ""
achievement_type: str = "" # e.g., "hof", "ath", "discovery"
value: float = 0.0
item_name: Optional[str] = None
@property
def category(self) -> EventCategory:
return EventCategory.GLOBAL
@dataclass(frozen=True)
class ChatEvent(BaseEvent):
"""Event for chat messages.
Attributes:
channel: Chat channel (main, team, society, etc.)
sender: Name of the sender
message: Message content
"""
channel: str = "" # "main", "team", "society", etc.
sender: str = ""
message: str = ""
@property
def category(self) -> EventCategory:
return EventCategory.CHAT
@dataclass(frozen=True)
class EconomyEvent(BaseEvent):
"""Event for economic transactions.
Attributes:
transaction_type: Type of transaction (sale, purchase, etc.)
amount: Transaction amount
currency: Currency type (usually PED)
description: Transaction description
"""
transaction_type: str = "" # "sale", "purchase", "deposit", "withdraw"
amount: float = 0.0
currency: str = "PED"
description: str = ""
@property
def category(self) -> EventCategory:
return EventCategory.ECONOMY
@dataclass(frozen=True)
class SystemEvent(BaseEvent):
"""Event for system notifications.
Attributes:
message: System message
severity: Severity level (debug, info, warning, error, critical)
"""
message: str = ""
severity: str = "info" # "debug", "info", "warning", "error", "critical"
@property
def category(self) -> EventCategory:
return EventCategory.SYSTEM
# Type variable for event types
T = TypeVar('T', bound=BaseEvent)
# ========== Event Filter ==========
@dataclass
class EventFilter:
"""Filter criteria for event subscription.
Attributes:
event_types: List of event types to filter
categories: List of categories to filter
min_damage: Minimum damage threshold
max_damage: Maximum damage threshold
mob_types: List of mob names to filter
skill_names: List of skill names to filter
sources: List of event sources to filter
custom_predicate: Custom filter function
"""
event_types: Optional[List[Type[BaseEvent]]] = None
categories: Optional[List[EventCategory]] = None
min_damage: Optional[float] = None
max_damage: Optional[float] = None
mob_types: Optional[List[str]] = None
skill_names: Optional[List[str]] = None
sources: Optional[List[str]] = None
custom_predicate: Optional[Callable[[BaseEvent], bool]] = None
def matches(self, event: BaseEvent) -> bool:
"""Check if an event matches this filter."""
# Check event type
if self.event_types is not None:
if not any(isinstance(event, et) for et in self.event_types):
return False
# Check category
if self.categories is not None:
if event.category not in self.categories:
return False
# Check source
if self.sources is not None:
if event.source not in self.sources:
return False
# Type-specific filters
if isinstance(event, DamageEvent):
if self.min_damage is not None and event.damage_amount < self.min_damage:
return False
if self.max_damage is not None and event.damage_amount > self.max_damage:
return False
if isinstance(event, LootEvent):
if self.mob_types is not None:
if event.mob_name not in self.mob_types:
return False
if isinstance(event, SkillGainEvent):
if self.skill_names is not None:
if event.skill_name not in self.skill_names:
return False
# Custom predicate
if self.custom_predicate is not None:
return self.custom_predicate(event)
return True
# ========== Subscription ==========
@dataclass
class EventSubscription:
"""Represents an event subscription.
Attributes:
id: Unique subscription ID
callback: Function to call when event occurs
event_filter: Filter criteria
replay_history: Whether to replay recent events
replay_count: Number of events to replay
created_at: When subscription was created
event_count: Number of events delivered
last_received: When last event was received
"""
id: str
callback: Callable[[BaseEvent], Any]
event_filter: EventFilter
replay_history: bool
replay_count: int
created_at: datetime = field(default_factory=datetime.now)
event_count: int = 0
last_received: Optional[datetime] = None
def matches(self, event: BaseEvent) -> bool:
"""Check if event matches this subscription."""
return self.event_filter.matches(event)
# ========== Event Statistics ==========
@dataclass
class EventStats:
"""Statistics for event bus performance.
Attributes:
total_events_published: Total events published
total_events_delivered: Total events delivered
total_subscriptions: Total subscriptions created
active_subscriptions: Currently active subscriptions
events_by_type: Event counts by type
events_by_category: Event counts by category
events_per_minute: Current events per minute rate
average_delivery_time_ms: Average delivery time
errors: Number of delivery errors
"""
total_events_published: int = 0
total_events_delivered: int = 0
total_subscriptions: int = 0
active_subscriptions: int = 0
events_by_type: Dict[str, int] = field(default_factory=lambda: defaultdict(int))
events_by_category: Dict[str, int] = field(default_factory=lambda: defaultdict(int))
events_per_minute: float = 0.0
average_delivery_time_ms: float = 0.0
errors: int = 0
_start_time: datetime = field(default_factory=datetime.now)
_minute_window: deque = field(default_factory=lambda: deque(maxlen=60))
_delivery_times: deque = field(default_factory=lambda: deque(maxlen=100))
def record_event_published(self, event: BaseEvent) -> None:
"""Record an event publication."""
self.total_events_published += 1
self.events_by_type[event.event_type] += 1
self.events_by_category[event.category.name] += 1
self._minute_window.append(time.time())
self._update_epm()
def record_event_delivered(self, delivery_time_ms: float) -> None:
"""Record successful event delivery."""
self.total_events_delivered += 1
self._delivery_times.append(delivery_time_ms)
if len(self._delivery_times) > 0:
self.average_delivery_time_ms = sum(self._delivery_times) / len(self._delivery_times)
def record_error(self) -> None:
"""Record a delivery error."""
self.errors += 1
def _update_epm(self) -> None:
"""Update events per minute calculation."""
now = time.time()
recent = [t for t in self._minute_window if now - t < 60]
self.events_per_minute = len(recent)
def get_summary(self) -> Dict[str, Any]:
"""Get statistics summary."""
uptime = datetime.now() - self._start_time
return {
'total_published': self.total_events_published,
'total_delivered': self.total_events_delivered,
'active_subscriptions': self.active_subscriptions,
'events_per_minute': round(self.events_per_minute, 2),
'avg_delivery_ms': round(self.average_delivery_time_ms, 2),
'errors': self.errors,
'uptime_seconds': uptime.total_seconds(),
'top_event_types': dict(sorted(
self.events_by_type.items(),
key=lambda x: x[1],
reverse=True
)[:5])
}
# ========== Event Bus ==========
class EventBus:
"""Enhanced Event Bus for EU-Utility.
Features:
- Typed events using dataclasses
- Event filtering with flexible criteria
- Event persistence (configurable history size)
- Event replay for new subscribers
- Async event handling
- Event statistics
This is a singleton - use get_event_bus() to get the instance.
Example:
bus = get_event_bus()
# Subscribe to all loot events
sub_id = bus.subscribe_typed(LootEvent, handle_loot)
# Subscribe to high damage only
sub_id = bus.subscribe_typed(
DamageEvent,
handle_big_hit,
min_damage=100
)
# Publish an event
bus.publish(LootEvent(mob_name="Atrox", items=[...]))
"""
_instance: Optional['EventBus'] = None
_lock = threading.Lock()
def __new__(cls, *args: Any, **kwargs: Any) -> 'EventBus':
if cls._instance is None:
with cls._lock:
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self, max_history: int = 1000):
"""Initialize the EventBus.
Args:
max_history: Maximum number of events to keep in history
"""
if self._initialized:
return
self.max_history = max_history
self._history: deque = deque(maxlen=max_history)
self._subscriptions: Dict[str, EventSubscription] = {}
self._subscription_counter: int = 0
self._stats = EventStats()
self._lock = threading.RLock()
self._async_loop: Optional[asyncio.AbstractEventLoop] = None
self._async_thread: Optional[threading.Thread] = None
self._initialized = True
def _ensure_async_loop(self) -> None:
"""Ensure the async event loop is running."""
if self._async_loop is None or self._async_loop.is_closed():
self._start_async_loop()
def _start_async_loop(self) -> None:
"""Start the async event loop in a separate thread."""
def run_loop() -> None:
self._async_loop = asyncio.new_event_loop()
asyncio.set_event_loop(self._async_loop)
self._async_loop.run_forever()
self._async_thread = threading.Thread(target=run_loop, daemon=True)
self._async_thread.start()
def publish(self, event: BaseEvent) -> None:
"""Publish an event to all matching subscribers.
Non-blocking - returns immediately.
Args:
event: Event to publish
"""
with self._lock:
self._history.append(event)
self._stats.record_event_published(event)
matching_subs = [
sub for sub in self._subscriptions.values()
if sub.matches(event)
]
for sub in matching_subs:
self._deliver_async(sub, event)
def publish_sync(self, event: BaseEvent) -> int:
"""Publish an event synchronously.
Blocks until all callbacks complete.
Args:
event: Event to publish
Returns:
Number of subscribers notified
"""
with self._lock:
self._history.append(event)
self._stats.record_event_published(event)
matching_subs = [
sub for sub in self._subscriptions.values()
if sub.matches(event)
]
count = 0
for sub in matching_subs:
if self._deliver_sync(sub, event):
count += 1
return count
def _deliver_async(self, subscription: EventSubscription, event: BaseEvent) -> None:
"""Deliver an event asynchronously."""
self._ensure_async_loop()
def deliver() -> None:
start = time.perf_counter()
try:
subscription.callback(event)
subscription.event_count += 1
subscription.last_received = datetime.now()
elapsed = (time.perf_counter() - start) * 1000
self._stats.record_event_delivered(elapsed)
except Exception as e:
self._stats.record_error()
print(f"[EventBus] Error delivering to {subscription.id}: {e}")
if self._async_loop:
self._async_loop.call_soon_threadsafe(deliver)
def _deliver_sync(self, subscription: EventSubscription, event: BaseEvent) -> bool:
"""Deliver an event synchronously."""
start = time.perf_counter()
try:
subscription.callback(event)
subscription.event_count += 1
subscription.last_received = datetime.now()
elapsed = (time.perf_counter() - start) * 1000
self._stats.record_event_delivered(elapsed)
return True
except Exception as e:
self._stats.record_error()
print(f"[EventBus] Error delivering to {subscription.id}: {e}")
return False
def subscribe(
self,
callback: Callable[[BaseEvent], Any],
event_filter: Optional[EventFilter] = None,
replay_history: bool = False,
replay_count: int = 100,
event_types: Optional[List[Type[BaseEvent]]] = None
) -> str:
"""Subscribe to events with optional filtering.
Args:
callback: Function to call when matching events occur
event_filter: Filter criteria for events
replay_history: Whether to replay recent events
replay_count: Number of recent events to replay
event_types: Shorthand for simple type-based filtering
Returns:
Subscription ID (use for unsubscribe)
"""
with self._lock:
self._subscription_counter += 1
sub_id = f"sub_{self._subscription_counter}"
if event_filter is None and event_types is not None:
event_filter = EventFilter(event_types=event_types)
elif event_filter is None:
event_filter = EventFilter()
subscription = EventSubscription(
id=sub_id,
callback=callback,
event_filter=event_filter,
replay_history=replay_history,
replay_count=min(replay_count, self.max_history)
)
self._subscriptions[sub_id] = subscription
self._stats.active_subscriptions = len(self._subscriptions)
self._stats.total_subscriptions += 1
if replay_history:
self._replay_history(subscription)
return sub_id
def subscribe_typed(
self,
event_class: Type[T],
callback: Callable[[T], Any],
**filter_kwargs: Any
) -> str:
"""Subscribe to a specific event type with optional filtering.
Args:
event_class: The event class to subscribe to
callback: Function to call with typed event
**filter_kwargs: Additional filter criteria:
- min_damage: Minimum damage threshold
- max_damage: Maximum damage threshold
- mob_types: List of mob names to filter
- skill_names: List of skill names to filter
- sources: List of event sources to filter
- replay_last: Number of events to replay
Returns:
Subscription ID
"""
filter_args: Dict[str, Any] = {'event_types': [event_class]}
if 'min_damage' in filter_kwargs:
filter_args['min_damage'] = filter_kwargs.pop('min_damage')
if 'max_damage' in filter_kwargs:
filter_args['max_damage'] = filter_kwargs.pop('max_damage')
if 'mob_types' in filter_kwargs:
filter_args['mob_types'] = filter_kwargs.pop('mob_types')
if 'skill_names' in filter_kwargs:
filter_args['skill_names'] = filter_kwargs.pop('skill_names')
if 'sources' in filter_kwargs:
filter_args['sources'] = filter_kwargs.pop('sources')
if 'predicate' in filter_kwargs:
filter_args['custom_predicate'] = filter_kwargs.pop('predicate')
event_filter = EventFilter(**filter_args)
replay_count = filter_kwargs.pop('replay_last', 0)
def typed_callback(event: BaseEvent) -> None:
if isinstance(event, event_class):
callback(event)
return self.subscribe(
callback=typed_callback,
event_filter=event_filter,
replay_history=replay_count > 0,
replay_count=replay_count
)
def _replay_history(self, subscription: EventSubscription) -> None:
"""Replay recent events to a subscriber."""
with self._lock:
events_to_replay = [
e for e in list(self._history)[-subscription.replay_count:]
if subscription.matches(e)
]
for event in events_to_replay:
self._deliver_async(subscription, event)
def unsubscribe(self, subscription_id: str) -> bool:
"""Unsubscribe from events.
Args:
subscription_id: ID returned by subscribe()
Returns:
True if unsubscribed successfully
"""
with self._lock:
if subscription_id in self._subscriptions:
del self._subscriptions[subscription_id]
self._stats.active_subscriptions = len(self._subscriptions)
return True
return False
def get_recent_events(
self,
event_type: Optional[Type[T]] = None,
count: int = 100,
category: Optional[EventCategory] = None
) -> List[BaseEvent]:
"""Get recent events from history.
Args:
event_type: Filter by event class
count: Maximum number of events to return
category: Filter by event category
Returns:
List of matching events
"""
with self._lock:
events = list(self._history)
if event_type is not None:
events = [e for e in events if isinstance(e, event_type)]
if category is not None:
events = [e for e in events if e.category == category]
return events[-count:]
def get_events_by_time_range(
self,
start: datetime,
end: Optional[datetime] = None
) -> List[BaseEvent]:
"""Get events within a time range.
Args:
start: Start datetime
end: End datetime (defaults to now)
Returns:
List of events in the time range
"""
if end is None:
end = datetime.now()
with self._lock:
return [
e for e in self._history
if start <= e.timestamp <= end
]
def get_stats(self) -> Dict[str, Any]:
"""Get event bus statistics."""
return self._stats.get_summary()
def clear_history(self) -> None:
"""Clear event history."""
with self._lock:
self._history.clear()
def shutdown(self) -> None:
"""Shutdown the event bus and cleanup resources."""
with self._lock:
self._subscriptions.clear()
self._history.clear()
self._stats.active_subscriptions = 0
if self._async_loop and self._async_loop.is_running():
self._async_loop.call_soon_threadsafe(self._async_loop.stop)
if self._async_thread and self._async_thread.is_alive():
self._async_thread.join(timeout=2.0)
# Singleton instance
_event_bus: Optional[EventBus] = None
def get_event_bus() -> EventBus:
"""Get the global EventBus instance.
Returns:
The singleton EventBus instance
"""
global _event_bus
if _event_bus is None:
_event_bus = EventBus()
return _event_bus
def reset_event_bus() -> None:
"""Reset the global EventBus instance (mainly for testing)."""
global _event_bus
if _event_bus is not None:
_event_bus.shutdown()
_event_bus = None
# Convenience decorator
def on_event(event_class: Type[T], **filter_kwargs: Any):
"""Decorator for event subscription.
Args:
event_class: Event class to subscribe to
**filter_kwargs: Filter criteria
Example:
@on_event(DamageEvent, min_damage=100)
def handle_big_damage(event: DamageEvent):
print(f"Big hit: {event.damage_amount}")
"""
def decorator(func: Callable[[T], Any]) -> Callable[[T], Any]:
bus = get_event_bus()
bus.subscribe_typed(event_class, func, **filter_kwargs)
func._event_subscription = True # type: ignore
return func
return decorator
# Export all public symbols
__all__ = [
# Event types
'EventCategory',
'BaseEvent',
'SkillGainEvent',
'LootEvent',
'DamageEvent',
'GlobalEvent',
'ChatEvent',
'EconomyEvent',
'SystemEvent',
# Core classes
'EventBus',
'EventFilter',
'EventSubscription',
'EventStats',
# Functions
'get_event_bus',
'reset_event_bus',
'on_event',
]