"""Mystery Oracle - The isolated truth keeper.
The Oracle knows the full truth of the mystery (who is guilty, secrets, etc.)
but only reveals information through controlled interfaces. This prevents
the player-facing agent from accidentally accessing spoilers.
"""
import logging
from typing import Optional, Any
from dataclasses import dataclass, field
from openai import AsyncOpenAI
logger = logging.getLogger(__name__)
@dataclass
class InterrogationResult:
"""Result of interrogating a suspect through the Oracle."""
response: str
trust_delta: int = 0 # Change in trust (-10 to +10)
nervousness_delta: int = 0 # Change in nervousness (-10 to +10)
revealed_location: Optional[str] = None # New location unlocked
revealed_secret: bool = False # Did they reveal their secret?
@dataclass
class SearchResult:
"""Result of searching a location through the Oracle."""
scene_description: str
clue_found: Optional[str] = None
clue_id: Optional[str] = None
significance: Optional[str] = None
class MysteryOracle:
"""The truth keeper for a murder mystery.
The Oracle holds the complete mystery including:
- Who is the murderer
- Each suspect's secret
- The full encounter graph
- All clues and their locations
It provides controlled access through methods that only reveal
what the player should know at each point.
"""
def __init__(self):
self.mystery = None
self._initialized = False
self._client = None
def initialize(self, mystery: Any):
"""Initialize the Oracle with a mystery.
Args:
mystery: The complete Mystery object with all truth
"""
self.mystery = mystery
self._initialized = True
self._client = AsyncOpenAI()
logger.info(f"Oracle initialized with mystery: {mystery.setting}")
@property
def is_initialized(self) -> bool:
return self._initialized
def get_murderer_name(self) -> Optional[str]:
"""Get the murderer's name (only call for game-over reveals)."""
if not self._initialized:
return None
murderer = self.mystery.get_murderer()
return murderer.name if murderer else None
def check_accusation(self, suspect_name: str) -> bool:
"""Check if an accusation is correct.
Args:
suspect_name: Name of the accused suspect
Returns:
True if this is the murderer, False otherwise
"""
if not self._initialized:
return False
murderer = self.mystery.get_murderer()
if not murderer:
return False
# Case-insensitive comparison
return suspect_name.lower().strip() == murderer.name.lower().strip()
async def interrogate(
self,
suspect_name: str,
question: str,
trust: int,
nervousness: int,
conversation_history: list[dict],
) -> InterrogationResult:
"""Generate a suspect's response to interrogation.
The Oracle generates responses that:
- Stay in character for the suspect
- Consider their emotional state (trust/nervousness)
- May reveal information based on rapport
- Never directly reveal if they're the murderer
Args:
suspect_name: Name of the suspect being interrogated
question: The player's question
trust: Current trust level (0-100)
nervousness: Current nervousness level (0-100)
conversation_history: Previous exchanges with this suspect
Returns:
InterrogationResult with response and state changes
"""
if not self._initialized:
return InterrogationResult(
response="[Error: Mystery not initialized]"
)
# Find the suspect
suspect = None
for s in self.mystery.suspects:
if s.name.lower() == suspect_name.lower():
suspect = s
break
if not suspect:
return InterrogationResult(
response=f"[Error: Suspect '{suspect_name}' not found]"
)
# Build the prompt for the LLM
system_prompt = f"""You are playing the role of {suspect.name}, a {suspect.role} in a murder mystery.
Your personality: {suspect.personality}
Your alibi: {suspect.alibi}
Your secret (do NOT reveal unless trust is very high): {suspect.secret}
{"You ARE the murderer. Never confess directly, but you may slip up under pressure." if suspect.is_guilty else "You are innocent but may have information."}
Current emotional state:
- Trust in the detective: {trust}% (higher = more cooperative)
- Nervousness: {nervousness}% (higher = more likely to slip up)
Respond in character. Keep responses to 2-3 sentences unless asked for details.
If nervousness is high (>70%), you may stutter or contradict yourself.
If trust is high (>70%), you may share more information.
"""
# Format conversation history
messages = [{"role": "system", "content": system_prompt}]
for entry in conversation_history[-5:]: # Last 5 exchanges
messages.append({"role": "user", "content": entry.get("question", "")})
messages.append({"role": "assistant", "content": entry.get("response", "")})
messages.append({"role": "user", "content": question})
try:
response = await self._client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
max_tokens=300,
temperature=0.8,
)
response_text = response.choices[0].message.content
# Determine emotional changes based on question tone
trust_delta = 0
nervousness_delta = 0
# Aggressive questions increase nervousness
if any(word in question.lower() for word in ["lie", "guilty", "murder", "kill", "confess"]):
nervousness_delta = 5
trust_delta = -3
# Friendly questions build trust
elif any(word in question.lower() for word in ["help", "understand", "sorry", "please"]):
trust_delta = 5
nervousness_delta = -3
# Check if they might reveal their secret
revealed_secret = False
if trust > 80 and "secret" in question.lower():
revealed_secret = True
return InterrogationResult(
response=response_text,
trust_delta=trust_delta,
nervousness_delta=nervousness_delta,
revealed_secret=revealed_secret,
)
except Exception as e:
logger.exception("Error generating interrogation response")
return InterrogationResult(
response=f"*{suspect.name} seems lost in thought and doesn't respond.*"
)
async def search_location(self, location: str) -> SearchResult:
"""Search a location for clues.
Args:
location: Name of the location to search
Returns:
SearchResult with description and any clue found
"""
if not self._initialized:
return SearchResult(
scene_description="[Error: Mystery not initialized]"
)
# Find clues at this location
clue_found = None
for clue in self.mystery.clues:
if clue.location.lower() == location.lower():
clue_found = clue
break
# Generate scene description
scene_desc = f"You carefully search the {location}. "
if clue_found:
scene_desc += f"After some investigation, you discover something interesting..."
return SearchResult(
scene_description=scene_desc,
clue_found=clue_found.description,
clue_id=clue_found.id,
significance=clue_found.significance,
)
else:
scene_desc += "You don't find anything of particular interest."
return SearchResult(scene_description=scene_desc)