| """ |
| DungeonMaster AI - Event Logger |
| |
| Logs game events for context building and session history. |
| Syncs events to MCP session manager asynchronously. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import asyncio |
| import logging |
| import uuid |
| from datetime import datetime |
| from typing import TYPE_CHECKING |
|
|
| from .models import EventType, SessionEvent |
|
|
| if TYPE_CHECKING: |
| from src.mcp_integration.toolkit_client import TTRPGToolkitClient |
|
|
| logger = logging.getLogger(__name__) |
|
|
|
|
| |
| MCP_EVENT_TYPE_MAP: dict[EventType, str] = { |
| EventType.ROLL: "combat", |
| EventType.COMBAT_START: "combat", |
| EventType.COMBAT_END: "combat", |
| EventType.COMBAT_ACTION: "combat", |
| EventType.DAMAGE: "combat", |
| EventType.HEALING: "combat", |
| EventType.MOVEMENT: "discovery", |
| EventType.DIALOGUE: "roleplay", |
| EventType.DISCOVERY: "discovery", |
| EventType.ITEM_ACQUIRED: "loot", |
| EventType.REST: "rest", |
| EventType.LEVEL_UP: "level_up", |
| EventType.DEATH: "death", |
| EventType.STORY_FLAG: "note", |
| EventType.SYSTEM: "note", |
| } |
|
|
|
|
| class EventLogger: |
| """ |
| Logs and manages game session events. |
| |
| Provides type-specific logging methods and syncs events to MCP. |
| Events are stored locally and optionally synced to the MCP |
| session manager for persistent history. |
| """ |
|
|
| def __init__( |
| self, |
| toolkit_client: TTRPGToolkitClient | None = None, |
| max_events: int = 100, |
| ) -> None: |
| """ |
| Initialize the event logger. |
| |
| Args: |
| toolkit_client: Optional MCP toolkit client for syncing |
| max_events: Maximum events to keep in memory |
| """ |
| self._toolkit_client = toolkit_client |
| self._max_events = max_events |
| self._events: list[SessionEvent] = [] |
| self._current_turn = 0 |
| self._mcp_session_id: str | None = None |
|
|
| def set_toolkit_client(self, client: TTRPGToolkitClient | None) -> None: |
| """Set or update the toolkit client.""" |
| self._toolkit_client = client |
|
|
| def set_mcp_session_id(self, session_id: str | None) -> None: |
| """Set the MCP session ID for syncing.""" |
| self._mcp_session_id = session_id |
|
|
| def set_current_turn(self, turn: int) -> None: |
| """Update the current turn number.""" |
| self._current_turn = turn |
|
|
| @property |
| def events(self) -> list[SessionEvent]: |
| """Get all logged events.""" |
| return self._events.copy() |
|
|
| |
| |
| |
|
|
| def _create_event( |
| self, |
| event_type: EventType, |
| description: str, |
| data: dict[str, object] | None = None, |
| is_significant: bool = False, |
| ) -> SessionEvent: |
| """ |
| Create and store a new event. |
| |
| Args: |
| event_type: Type of event |
| description: Human-readable description |
| data: Event-specific data |
| is_significant: Whether event is significant for context |
| |
| Returns: |
| The created SessionEvent |
| """ |
| event = SessionEvent( |
| event_id=str(uuid.uuid4()), |
| event_type=event_type, |
| description=description, |
| data=data or {}, |
| timestamp=datetime.now(), |
| turn=self._current_turn, |
| is_significant=is_significant, |
| ) |
|
|
| self._events.append(event) |
|
|
| |
| if len(self._events) > self._max_events: |
| self._events = self._events[-self._max_events :] |
|
|
| |
| self._sync_to_mcp(event) |
|
|
| return event |
|
|
| def _sync_to_mcp(self, event: SessionEvent) -> None: |
| """ |
| Sync an event to MCP session manager (fire-and-forget). |
| |
| Args: |
| event: Event to sync |
| """ |
| if not self._toolkit_client or not self._mcp_session_id: |
| return |
|
|
| try: |
| |
| loop = asyncio.get_running_loop() |
| |
| loop.create_task(self._async_sync_event(event)) |
| except RuntimeError: |
| |
| logger.debug("Not in async context, skipping MCP event sync") |
|
|
| async def _async_sync_event(self, event: SessionEvent) -> None: |
| """ |
| Async helper to sync event to MCP. |
| |
| Args: |
| event: Event to sync |
| """ |
| if not self._toolkit_client: |
| return |
|
|
| try: |
| mcp_event_type = MCP_EVENT_TYPE_MAP.get(event.event_type, "note") |
|
|
| await self._toolkit_client.call_tool( |
| "mcp_log_event", |
| { |
| "event_type": mcp_event_type, |
| "description": event.description, |
| "important": event.is_significant, |
| }, |
| ) |
| except Exception as e: |
| |
| logger.debug(f"Failed to sync event to MCP: {e}") |
|
|
| |
| |
| |
|
|
| def log_roll( |
| self, |
| notation: str, |
| total: int, |
| roll_type: str = "standard", |
| is_critical: bool = False, |
| is_fumble: bool = False, |
| character_name: str | None = None, |
| ) -> SessionEvent: |
| """ |
| Log a dice roll event. |
| |
| Args: |
| notation: Dice notation (e.g., "1d20+5") |
| total: Roll total |
| roll_type: Type of roll (attack, save, check, etc.) |
| is_critical: Whether this is a natural 20 |
| is_fumble: Whether this is a natural 1 |
| character_name: Name of character rolling |
| |
| Returns: |
| Created SessionEvent |
| """ |
| actor = character_name or "Unknown" |
|
|
| |
| if is_critical: |
| desc = f"{actor} rolled {notation}: {total} - CRITICAL!" |
| elif is_fumble: |
| desc = f"{actor} rolled {notation}: {total} - Fumble!" |
| else: |
| desc = f"{actor} rolled {notation}: {total}" |
|
|
| if roll_type != "standard": |
| desc += f" ({roll_type})" |
|
|
| return self._create_event( |
| event_type=EventType.ROLL, |
| description=desc, |
| data={ |
| "notation": notation, |
| "total": total, |
| "roll_type": roll_type, |
| "is_critical": is_critical, |
| "is_fumble": is_fumble, |
| "character": character_name, |
| }, |
| is_significant=is_critical or is_fumble, |
| ) |
|
|
| def log_combat_action( |
| self, |
| actor: str, |
| action: str, |
| target: str | None = None, |
| damage: int | None = None, |
| hit: bool | None = None, |
| ) -> SessionEvent: |
| """ |
| Log a combat action event. |
| |
| Args: |
| actor: Who performed the action |
| action: What action was taken (attack, cast, etc.) |
| target: Target of the action |
| damage: Damage dealt if applicable |
| hit: Whether attack hit if applicable |
| |
| Returns: |
| Created SessionEvent |
| """ |
| parts = [f"{actor} {action}"] |
|
|
| if target: |
| parts.append(f"targeting {target}") |
|
|
| if hit is not None: |
| if hit: |
| parts.append("- Hit!") |
| if damage: |
| parts.append(f"for {damage} damage") |
| else: |
| parts.append("- Miss!") |
|
|
| desc = " ".join(parts) |
|
|
| return self._create_event( |
| event_type=EventType.COMBAT_ACTION, |
| description=desc, |
| data={ |
| "actor": actor, |
| "action": action, |
| "target": target, |
| "damage": damage, |
| "hit": hit, |
| }, |
| is_significant=damage is not None and damage >= 10, |
| ) |
|
|
| def log_damage( |
| self, |
| character_name: str, |
| amount: int, |
| damage_type: str = "untyped", |
| source: str = "unknown", |
| is_lethal: bool = False, |
| ) -> SessionEvent: |
| """ |
| Log a damage event. |
| |
| Args: |
| character_name: Who took damage |
| amount: Amount of damage |
| damage_type: Type of damage |
| source: Source of damage |
| is_lethal: Whether damage was lethal |
| |
| Returns: |
| Created SessionEvent |
| """ |
| if is_lethal: |
| desc = f"{character_name} took {amount} {damage_type} damage from {source} and fell unconscious!" |
| else: |
| desc = f"{character_name} took {amount} {damage_type} damage from {source}" |
|
|
| return self._create_event( |
| event_type=EventType.DAMAGE, |
| description=desc, |
| data={ |
| "character": character_name, |
| "amount": amount, |
| "damage_type": damage_type, |
| "source": source, |
| "is_lethal": is_lethal, |
| }, |
| is_significant=is_lethal or amount >= 10, |
| ) |
|
|
| def log_healing( |
| self, |
| character_name: str, |
| amount: int, |
| source: str = "unknown", |
| ) -> SessionEvent: |
| """ |
| Log a healing event. |
| |
| Args: |
| character_name: Who was healed |
| amount: Amount of healing |
| source: Source of healing |
| |
| Returns: |
| Created SessionEvent |
| """ |
| desc = f"{character_name} healed {amount} HP from {source}" |
|
|
| return self._create_event( |
| event_type=EventType.HEALING, |
| description=desc, |
| data={ |
| "character": character_name, |
| "amount": amount, |
| "source": source, |
| }, |
| is_significant=amount >= 10, |
| ) |
|
|
| def log_dialogue( |
| self, |
| speaker: str, |
| summary: str, |
| is_npc: bool = True, |
| ) -> SessionEvent: |
| """ |
| Log a dialogue event. |
| |
| Args: |
| speaker: Who is speaking |
| summary: Summary of what was said |
| is_npc: Whether speaker is an NPC |
| |
| Returns: |
| Created SessionEvent |
| """ |
| desc = f'{speaker}: "{summary}"' |
|
|
| return self._create_event( |
| event_type=EventType.DIALOGUE, |
| description=desc, |
| data={ |
| "speaker": speaker, |
| "summary": summary, |
| "is_npc": is_npc, |
| }, |
| is_significant=False, |
| ) |
|
|
| def log_discovery( |
| self, |
| what: str, |
| details: str = "", |
| character_name: str | None = None, |
| ) -> SessionEvent: |
| """ |
| Log a discovery event. |
| |
| Args: |
| what: What was discovered |
| details: Additional details |
| character_name: Who made the discovery |
| |
| Returns: |
| Created SessionEvent |
| """ |
| actor = character_name or "The party" |
|
|
| if details: |
| desc = f"{actor} discovered: {what} - {details}" |
| else: |
| desc = f"{actor} discovered: {what}" |
|
|
| return self._create_event( |
| event_type=EventType.DISCOVERY, |
| description=desc, |
| data={ |
| "what": what, |
| "details": details, |
| "character": character_name, |
| }, |
| is_significant=True, |
| ) |
|
|
| def log_movement( |
| self, |
| from_location: str, |
| to_location: str, |
| ) -> SessionEvent: |
| """ |
| Log a movement/location change event. |
| |
| Args: |
| from_location: Previous location |
| to_location: New location |
| |
| Returns: |
| Created SessionEvent |
| """ |
| desc = f"Moved from {from_location} to {to_location}" |
|
|
| return self._create_event( |
| event_type=EventType.MOVEMENT, |
| description=desc, |
| data={ |
| "from": from_location, |
| "to": to_location, |
| }, |
| is_significant=True, |
| ) |
|
|
| def log_combat_start( |
| self, |
| description: str, |
| combatants: list[str] | None = None, |
| ) -> SessionEvent: |
| """ |
| Log combat starting. |
| |
| Args: |
| description: Description of combat start |
| combatants: List of combatant names |
| |
| Returns: |
| Created SessionEvent |
| """ |
| return self._create_event( |
| event_type=EventType.COMBAT_START, |
| description=f"Combat began: {description}", |
| data={ |
| "combatants": combatants or [], |
| }, |
| is_significant=True, |
| ) |
|
|
| def log_combat_end( |
| self, |
| outcome: str = "victory", |
| description: str = "", |
| ) -> SessionEvent: |
| """ |
| Log combat ending. |
| |
| Args: |
| outcome: Combat outcome (victory, defeat, fled) |
| description: Additional description |
| |
| Returns: |
| Created SessionEvent |
| """ |
| desc = f"Combat ended: {outcome}" |
| if description: |
| desc += f" - {description}" |
|
|
| return self._create_event( |
| event_type=EventType.COMBAT_END, |
| description=desc, |
| data={ |
| "outcome": outcome, |
| }, |
| is_significant=True, |
| ) |
|
|
| def log_item_acquired( |
| self, |
| item_name: str, |
| character_name: str | None = None, |
| quantity: int = 1, |
| ) -> SessionEvent: |
| """ |
| Log acquiring an item. |
| |
| Args: |
| item_name: Name of item |
| character_name: Who got the item |
| quantity: Number of items |
| |
| Returns: |
| Created SessionEvent |
| """ |
| actor = character_name or "The party" |
|
|
| if quantity > 1: |
| desc = f"{actor} acquired {quantity}x {item_name}" |
| else: |
| desc = f"{actor} acquired {item_name}" |
|
|
| return self._create_event( |
| event_type=EventType.ITEM_ACQUIRED, |
| description=desc, |
| data={ |
| "item": item_name, |
| "character": character_name, |
| "quantity": quantity, |
| }, |
| is_significant=True, |
| ) |
|
|
| def log_rest( |
| self, |
| rest_type: str, |
| character_name: str | None = None, |
| hp_recovered: int = 0, |
| ) -> SessionEvent: |
| """ |
| Log a rest event. |
| |
| Args: |
| rest_type: Type of rest (short, long) |
| character_name: Who rested |
| hp_recovered: HP recovered if applicable |
| |
| Returns: |
| Created SessionEvent |
| """ |
| actor = character_name or "The party" |
| desc = f"{actor} took a {rest_type} rest" |
|
|
| if hp_recovered > 0: |
| desc += f" and recovered {hp_recovered} HP" |
|
|
| return self._create_event( |
| event_type=EventType.REST, |
| description=desc, |
| data={ |
| "rest_type": rest_type, |
| "character": character_name, |
| "hp_recovered": hp_recovered, |
| }, |
| is_significant=True, |
| ) |
|
|
| def log_death( |
| self, |
| character_name: str, |
| cause: str = "unknown", |
| ) -> SessionEvent: |
| """ |
| Log a character death. |
| |
| Args: |
| character_name: Who died |
| cause: Cause of death |
| |
| Returns: |
| Created SessionEvent |
| """ |
| return self._create_event( |
| event_type=EventType.DEATH, |
| description=f"{character_name} has fallen! Cause: {cause}", |
| data={ |
| "character": character_name, |
| "cause": cause, |
| }, |
| is_significant=True, |
| ) |
|
|
| def log_level_up( |
| self, |
| character_name: str, |
| new_level: int, |
| ) -> SessionEvent: |
| """ |
| Log a level up. |
| |
| Args: |
| character_name: Who leveled up |
| new_level: New level |
| |
| Returns: |
| Created SessionEvent |
| """ |
| return self._create_event( |
| event_type=EventType.LEVEL_UP, |
| description=f"{character_name} reached level {new_level}!", |
| data={ |
| "character": character_name, |
| "level": new_level, |
| }, |
| is_significant=True, |
| ) |
|
|
| def log_story_flag( |
| self, |
| flag: str, |
| value: object, |
| description: str = "", |
| ) -> SessionEvent: |
| """ |
| Log a story flag change. |
| |
| Args: |
| flag: Flag name |
| value: New value |
| description: Optional description |
| |
| Returns: |
| Created SessionEvent |
| """ |
| desc = description or f"Story progress: {flag}" |
|
|
| return self._create_event( |
| event_type=EventType.STORY_FLAG, |
| description=desc, |
| data={ |
| "flag": flag, |
| "value": value, |
| }, |
| is_significant=True, |
| ) |
|
|
| def log_system( |
| self, |
| message: str, |
| data: dict[str, object] | None = None, |
| ) -> SessionEvent: |
| """ |
| Log a system event. |
| |
| Args: |
| message: System message |
| data: Optional data |
| |
| Returns: |
| Created SessionEvent |
| """ |
| return self._create_event( |
| event_type=EventType.SYSTEM, |
| description=message, |
| data=data or {}, |
| is_significant=False, |
| ) |
|
|
| |
| |
| |
|
|
| def get_recent( |
| self, |
| count: int = 10, |
| event_type: EventType | None = None, |
| significant_only: bool = False, |
| ) -> list[SessionEvent]: |
| """ |
| Get recent events with optional filtering. |
| |
| Args: |
| count: Maximum events to return |
| event_type: Filter by event type |
| significant_only: Only return significant events |
| |
| Returns: |
| List of matching events (most recent first) |
| """ |
| filtered = self._events.copy() |
|
|
| if event_type is not None: |
| filtered = [e for e in filtered if e.event_type == event_type] |
|
|
| if significant_only: |
| filtered = [e for e in filtered if e.is_significant] |
|
|
| |
| return list(reversed(filtered[-count:])) |
|
|
| def get_events_for_turn(self, turn: int) -> list[SessionEvent]: |
| """ |
| Get all events for a specific turn. |
| |
| Args: |
| turn: Turn number |
| |
| Returns: |
| List of events from that turn |
| """ |
| return [e for e in self._events if e.turn == turn] |
|
|
| def get_events_since(self, timestamp: datetime) -> list[SessionEvent]: |
| """ |
| Get all events since a given timestamp. |
| |
| Args: |
| timestamp: Cutoff timestamp |
| |
| Returns: |
| List of events after timestamp |
| """ |
| return [e for e in self._events if e.timestamp > timestamp] |
|
|
| def clear(self) -> None: |
| """Clear all logged events.""" |
| self._events.clear() |
|
|
| def __len__(self) -> int: |
| """Return number of logged events.""" |
| return len(self._events) |
|
|