DungeonMaster-AI / src /game /event_logger.py
bhupesh-sf's picture
first commit
f8ba6bf verified
"""
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__)
# Map EventType to MCP session manager event types
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()
# =========================================================================
# Core Logging
# =========================================================================
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)
# Trim to max size
if len(self._events) > self._max_events:
self._events = self._events[-self._max_events :]
# Fire-and-forget sync to MCP
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:
# Check if we're in an async context
loop = asyncio.get_running_loop()
# Create task for async sync
loop.create_task(self._async_sync_event(event))
except RuntimeError:
# Not in async context, skip sync
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:
# Silent failure - don't block gameplay for sync failures
logger.debug(f"Failed to sync event to MCP: {e}")
# =========================================================================
# Type-Specific Logging Methods
# =========================================================================
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"
# Build description
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, # Discoveries are always significant
)
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, # Location changes are significant
)
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, # Deaths are always significant
)
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,
)
# =========================================================================
# Retrieval Methods
# =========================================================================
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 most recent first
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)