|
|
""" |
|
|
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) |
|
|
|