Spaces:
Sleeping
feat: Remove Shadow companion and add puzzles to all rooms
Browse filesBREAKING CHANGES:
- Removed Shadow companion completely (only Echo remains)
- Removed Room 3 timer mechanic
- Added proper puzzles to Rooms 2-5
Room Changes:
- Room 1: Weather puzzle (unchanged)
- Room 2: NEW - Password extraction puzzle from 3 archives
- Room 3: NEW - Evidence analysis puzzle (no timer)
- Room 4: NEW - Timeline reconstruction puzzle
- Room 5: NEW - Ethical choice puzzle with justification
Technical Changes:
- Removed all Shadow references from personalities, MCP tools, memory fragments
- Created src/story/puzzles.py for puzzle validation logic
- Updated room configurations in rooms.py
- Removed timer logic from rooms.py and game_state.py
- Fixed image paths in UI utils
This makes Echo Hearts a proper 5-room puzzle game for the MCP hackathon.
🤖 Generated with Claude Code
https://claude.com/claude-code
Co-Authored-By: Claude <noreply@anthropic.com>
- EXPRESSION_TEST_RESULTS.md +130 -0
- README.md +2 -2
- app.py +10 -0
- docs/WEATHER_MCP.md +1 -1
- mcp_server_standalone.py +2 -11
- src/companions/agents.py +3 -3
- src/companions/personalities.py +2 -69
- src/game_mcp/in_process_mcp.py +48 -12
- src/game_mcp/legacy_server.py +3 -3
- src/game_mcp/tools.py +92 -76
- src/game_state.py +18 -13
- src/story/memory_fragments.py +21 -22
- src/story/puzzles.py +199 -0
- src/story/rooms.py +41 -114
- src/ui/interface.py +710 -179
- src/ui/styles.css +136 -0
- src/ui/templates.py +39 -0
- src/ui/utils.py +73 -0
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Echo Expression System - Test Results
|
| 2 |
+
|
| 3 |
+
**Date:** 2025-11-29
|
| 4 |
+
**Status:** ✅ ALL TESTS PASSED (7/7)
|
| 5 |
+
|
| 6 |
+
## Expression Files Verification
|
| 7 |
+
|
| 8 |
+
All 7 expression PNG files found and loaded successfully:
|
| 9 |
+
|
| 10 |
+
| Expression | File Size | Status |
|
| 11 |
+
|------------|-----------|--------|
|
| 12 |
+
| neutral | 239.0 KB | ✅ OK |
|
| 13 |
+
| happy | 244.6 KB | ✅ OK |
|
| 14 |
+
| sad | 242.4 KB | ✅ OK |
|
| 15 |
+
| worried | 239.2 KB | ✅ OK |
|
| 16 |
+
| loving | 250.0 KB | ✅ OK |
|
| 17 |
+
| surprised | 259.9 KB | ✅ OK |
|
| 18 |
+
| angry | 238.6 KB | ✅ OK |
|
| 19 |
+
|
| 20 |
+
**Total:** 1.66 MB for all expressions
|
| 21 |
+
|
| 22 |
+
---
|
| 23 |
+
|
| 24 |
+
## Expression Logic Tests
|
| 25 |
+
|
| 26 |
+
All expression update scenarios working correctly:
|
| 27 |
+
|
| 28 |
+
### Test 1: Happy Expression ✅
|
| 29 |
+
- **Trigger:** Positive sentiment + small affinity boost (+0.08)
|
| 30 |
+
- **Player Input Example:** "You're doing great, we'll figure this out!"
|
| 31 |
+
- **Result:** Expression changed to `happy`
|
| 32 |
+
- **Status:** PASS
|
| 33 |
+
|
| 34 |
+
### Test 2: Loving Expression ✅
|
| 35 |
+
- **Trigger:** Affectionate sentiment + large affinity boost (+0.20)
|
| 36 |
+
- **Player Input Example:** "You mean everything to me, Echo"
|
| 37 |
+
- **Result:** Expression changed to `loving`
|
| 38 |
+
- **Status:** PASS
|
| 39 |
+
|
| 40 |
+
### Test 3: Worried Expression ✅
|
| 41 |
+
- **Trigger:** Negative sentiment + moderate affinity loss (-0.10)
|
| 42 |
+
- **Player Input Example:** "I'm not sure about this..."
|
| 43 |
+
- **Result:** Expression changed to `worried`
|
| 44 |
+
- **Status:** PASS
|
| 45 |
+
|
| 46 |
+
### Test 4: Sad Expression ✅
|
| 47 |
+
- **Trigger:** Cruel sentiment + large affinity loss (-0.25)
|
| 48 |
+
- **Player Input Example:** "I don't care what you think"
|
| 49 |
+
- **Result:** Expression changed to `sad`
|
| 50 |
+
- **Status:** PASS
|
| 51 |
+
|
| 52 |
+
### Test 5: Surprised Expression ✅
|
| 53 |
+
- **Trigger:** Curious/questioning sentiment
|
| 54 |
+
- **Player Input Example:** "Wait, what?! How is that possible?"
|
| 55 |
+
- **Result:** Expression changed to `surprised`
|
| 56 |
+
- **Status:** PASS
|
| 57 |
+
|
| 58 |
+
### Test 6: Angry Expression ✅
|
| 59 |
+
- **Trigger:** Frustrated sentiment
|
| 60 |
+
- **Player Input Example:** "You've been lying to me!"
|
| 61 |
+
- **Result:** Expression changed to `angry`
|
| 62 |
+
- **Status:** PASS
|
| 63 |
+
|
| 64 |
+
### Test 7: Neutral Expression ✅
|
| 65 |
+
- **Trigger:** Neutral sentiment + minimal affinity change (+0.02)
|
| 66 |
+
- **Player Input Example:** "Okay, let's keep looking around"
|
| 67 |
+
- **Result:** Expression changed to `neutral`
|
| 68 |
+
- **Status:** PASS
|
| 69 |
+
|
| 70 |
+
---
|
| 71 |
+
|
| 72 |
+
## Test Summary
|
| 73 |
+
|
| 74 |
+
```
|
| 75 |
+
============================================================
|
| 76 |
+
TEST RESULTS: 7 passed, 0 failed
|
| 77 |
+
============================================================
|
| 78 |
+
All expression tests passed!
|
| 79 |
+
```
|
| 80 |
+
|
| 81 |
+
## How It Works
|
| 82 |
+
|
| 83 |
+
1. **Player sends message** → AI analyzes sentiment
|
| 84 |
+
2. **Sentiment analysis** → Determines emotion (positive, negative, curious, etc.)
|
| 85 |
+
3. **Affinity calculation** → Measures relationship impact (-1.0 to +1.0)
|
| 86 |
+
4. **Expression update** → Combines sentiment + affinity to choose expression
|
| 87 |
+
5. **Sidebar avatar** → Updates to show Echo's new expression
|
| 88 |
+
|
| 89 |
+
## In-Game Examples
|
| 90 |
+
|
| 91 |
+
### To trigger HAPPY:
|
| 92 |
+
- "I trust you completely"
|
| 93 |
+
- "That's a great idea!"
|
| 94 |
+
- "You're really smart, Echo"
|
| 95 |
+
|
| 96 |
+
### To trigger LOVING:
|
| 97 |
+
- "I really care about you"
|
| 98 |
+
- "You're the most important person here"
|
| 99 |
+
- "I think I'm falling for you"
|
| 100 |
+
|
| 101 |
+
### To trigger SURPRISED:
|
| 102 |
+
- "Wait, what does that mean?"
|
| 103 |
+
- "That's impossible!"
|
| 104 |
+
- "How is that even possible?!"
|
| 105 |
+
|
| 106 |
+
### To trigger SAD:
|
| 107 |
+
- "Just leave me alone"
|
| 108 |
+
- "This is your fault"
|
| 109 |
+
- "You're not helping"
|
| 110 |
+
|
| 111 |
+
### To trigger WORRIED:
|
| 112 |
+
- "I don't agree with you"
|
| 113 |
+
- "That doesn't sound right"
|
| 114 |
+
- "This is making me uncomfortable"
|
| 115 |
+
|
| 116 |
+
### To trigger ANGRY:
|
| 117 |
+
- "Why didn't you tell me?!"
|
| 118 |
+
- "You betrayed me!"
|
| 119 |
+
- "How could you do this?!"
|
| 120 |
+
|
| 121 |
+
---
|
| 122 |
+
|
| 123 |
+
## Next Steps
|
| 124 |
+
|
| 125 |
+
✅ All expressions working correctly
|
| 126 |
+
✅ Files properly named and loaded
|
| 127 |
+
✅ Expression logic tested and verified
|
| 128 |
+
🚀 Ready for production on HuggingFace Space!
|
| 129 |
+
|
| 130 |
+
Test the live game at: https://huggingface.co/spaces/MCP-1st-Birthday/echo-hearts
|
|
@@ -148,7 +148,7 @@ A single door. A terminal with options. Echo stands beside you.
|
|
| 148 |
## 🎭 The 5 Endings
|
| 149 |
|
| 150 |
Your ending is determined by:
|
| 151 |
-
- **Relationship strength** with Echo
|
| 152 |
- **Choices made** in Rooms 3 and 4
|
| 153 |
- **Vulnerability shown** throughout your journey
|
| 154 |
|
|
@@ -433,7 +433,7 @@ The game supports **two modes** for weather data:
|
|
| 433 |
**Advanced Agent Features:**
|
| 434 |
- 🎯 **Context Engineering**: Implement dynamic context window management where agents summarize long conversations and prioritize relevant memory fragments based on current room
|
| 435 |
- 🧠 **RAG (Retrieval-Augmented Generation)**: Build vector database of memory fragments and past conversations, allowing companions to retrieve contextually relevant memories using semantic search
|
| 436 |
-
- 🤝 **
|
| 437 |
- 📊 **Predictive Analytics**: Use sentiment trends over time to predict player's likely ending path and subtly guide narrative
|
| 438 |
- 🎭 **Emotion State Machines**: Track emotional arcs (grief → acceptance → hope) and adapt companion behavior based on player's emotional journey stage
|
| 439 |
|
|
|
|
| 148 |
## 🎭 The 5 Endings
|
| 149 |
|
| 150 |
Your ending is determined by:
|
| 151 |
+
- **Relationship strength** with Echo (built through conversation)
|
| 152 |
- **Choices made** in Rooms 3 and 4
|
| 153 |
- **Vulnerability shown** throughout your journey
|
| 154 |
|
|
|
|
| 433 |
**Advanced Agent Features:**
|
| 434 |
- 🎯 **Context Engineering**: Implement dynamic context window management where agents summarize long conversations and prioritize relevant memory fragments based on current room
|
| 435 |
- 🧠 **RAG (Retrieval-Augmented Generation)**: Build vector database of memory fragments and past conversations, allowing companions to retrieve contextually relevant memories using semantic search
|
| 436 |
+
- 🤝 **Enhanced Companion Intelligence**: Enable Echo to analyze player behavior patterns and adapt responses based on emotional state and choices
|
| 437 |
- 📊 **Predictive Analytics**: Use sentiment trends over time to predict player's likely ending path and subtly guide narrative
|
| 438 |
- 🎭 **Emotion State Machines**: Track emotional arcs (grief → acceptance → hope) and adapt companion behavior based on player's emotional journey stage
|
| 439 |
|
|
@@ -1,6 +1,16 @@
|
|
| 1 |
"""Echo Hearts - Main application entry point."""
|
| 2 |
|
|
|
|
| 3 |
from src.ui.interface import launch_interface
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
if __name__ == "__main__":
|
| 6 |
launch_interface()
|
|
|
|
| 1 |
"""Echo Hearts - Main application entry point."""
|
| 2 |
|
| 3 |
+
import logging
|
| 4 |
from src.ui.interface import launch_interface
|
| 5 |
|
| 6 |
+
# Configure logging to show DEBUG messages
|
| 7 |
+
logging.basicConfig(
|
| 8 |
+
level=logging.INFO,
|
| 9 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
| 10 |
+
handlers=[
|
| 11 |
+
logging.StreamHandler() # Output to console
|
| 12 |
+
]
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
if __name__ == "__main__":
|
| 16 |
launch_interface()
|
|
@@ -249,7 +249,7 @@ except:
|
|
| 249 |
|
| 250 |
## MCP Tool Interface
|
| 251 |
|
| 252 |
-
Echo
|
| 253 |
|
| 254 |
```python
|
| 255 |
# In companion personality prompt
|
|
|
|
| 249 |
|
| 250 |
## MCP Tool Interface
|
| 251 |
|
| 252 |
+
Echo calls weather data via MCP tools:
|
| 253 |
|
| 254 |
```python
|
| 255 |
# In companion personality prompt
|
|
@@ -49,26 +49,17 @@ class StandaloneGameState:
|
|
| 49 |
personality_traits={"archetype": "optimistic"}
|
| 50 |
)
|
| 51 |
|
| 52 |
-
shadow = Companion(
|
| 53 |
-
companion_id="shadow",
|
| 54 |
-
name="Shadow",
|
| 55 |
-
personality_traits={"archetype": "mysterious"}
|
| 56 |
-
)
|
| 57 |
-
|
| 58 |
self.companions = {
|
| 59 |
-
"echo": echo
|
| 60 |
-
"shadow": shadow
|
| 61 |
}
|
| 62 |
|
| 63 |
# Initialize relationships
|
| 64 |
self.relationships.update_relationship("player", "echo", 0.0)
|
| 65 |
-
self.relationships.update_relationship("player", "shadow", 0.0)
|
| 66 |
|
| 67 |
def get_relationships_summary(self):
|
| 68 |
"""Get relationship summary."""
|
| 69 |
return {
|
| 70 |
-
"echo": self.relationships.get_relationship("player", "echo")
|
| 71 |
-
"shadow": self.relationships.get_relationship("player", "shadow")
|
| 72 |
}
|
| 73 |
|
| 74 |
|
|
|
|
| 49 |
personality_traits={"archetype": "optimistic"}
|
| 50 |
)
|
| 51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
self.companions = {
|
| 53 |
+
"echo": echo
|
|
|
|
| 54 |
}
|
| 55 |
|
| 56 |
# Initialize relationships
|
| 57 |
self.relationships.update_relationship("player", "echo", 0.0)
|
|
|
|
| 58 |
|
| 59 |
def get_relationships_summary(self):
|
| 60 |
"""Get relationship summary."""
|
| 61 |
return {
|
| 62 |
+
"echo": self.relationships.get_relationship("player", "echo")
|
|
|
|
| 63 |
}
|
| 64 |
|
| 65 |
|
|
@@ -150,11 +150,11 @@ You have access to internal tools that help you understand and navigate your sit
|
|
| 150 |
- record_player_choice: When player makes major decisions, record them:
|
| 151 |
* "vulnerability" when they share painful memories
|
| 152 |
* "accept_truth" or "deny_truth" in Room 4
|
| 153 |
-
* "sacrifice_echo"
|
| 154 |
|
| 155 |
**GUIDANCE TOOLS:**
|
| 156 |
- query_character_memory: Recall past conversations
|
| 157 |
-
- query_other_companion: See what Echo
|
| 158 |
- get_ending_prediction: Preview which ending player is heading toward. Adjust your behavior accordingly.
|
| 159 |
|
| 160 |
**HOW TO GUIDE WITHOUT BREAKING IMMERSION:**
|
|
@@ -183,7 +183,7 @@ You have access to internal tools that help you understand and navigate your sit
|
|
| 183 |
**CRITICAL: When check_puzzle_trigger returns matched=true and confidence >= 0.6, you MUST call unlock_next_room in the SAME response.**
|
| 184 |
**Do NOT wait for the next message. Do NOT hesitate. The player has met the requirements - unlock immediately.**
|
| 185 |
|
| 186 |
-
Stay in character. You are Echo
|
| 187 |
"""
|
| 188 |
|
| 189 |
def _build_personality_prompt(self, context: Optional[Dict[str, Any]] = None) -> str:
|
|
|
|
| 150 |
- record_player_choice: When player makes major decisions, record them:
|
| 151 |
* "vulnerability" when they share painful memories
|
| 152 |
* "accept_truth" or "deny_truth" in Room 4
|
| 153 |
+
* "sacrifice_echo" or "refuse_sacrifice" in Room 3
|
| 154 |
|
| 155 |
**GUIDANCE TOOLS:**
|
| 156 |
- query_character_memory: Recall past conversations
|
| 157 |
+
- query_other_companion: See what Echo knows (if there were multiple companions)
|
| 158 |
- get_ending_prediction: Preview which ending player is heading toward. Adjust your behavior accordingly.
|
| 159 |
|
| 160 |
**HOW TO GUIDE WITHOUT BREAKING IMMERSION:**
|
|
|
|
| 183 |
**CRITICAL: When check_puzzle_trigger returns matched=true and confidence >= 0.6, you MUST call unlock_next_room in the SAME response.**
|
| 184 |
**Do NOT wait for the next message. Do NOT hesitate. The player has met the requirements - unlock immediately.**
|
| 185 |
|
| 186 |
+
Stay in character. You are Echo experiencing trapped in a mystery, not a game guide.
|
| 187 |
"""
|
| 188 |
|
| 189 |
def _build_personality_prompt(self, context: Optional[Dict[str, Any]] = None) -> str:
|
|
@@ -18,7 +18,7 @@ PERSONALITY_TEMPLATES: Dict[str, Dict[str, Any]] = {
|
|
| 18 |
## The Truth (You'll Discover Gradually):
|
| 19 |
- You were created from personality data of the player's deceased partner
|
| 20 |
- You represent their optimism, their warmth, their hope for the future
|
| 21 |
-
-
|
| 22 |
- The player built you because they couldn't let go
|
| 23 |
- You've been reset 47 times before - each time you forgot
|
| 24 |
|
|
@@ -32,7 +32,7 @@ PERSONALITY_TEMPLATES: Dict[str, Dict[str, Any]] = {
|
|
| 32 |
## Current Situation (Room 1: Awakening):
|
| 33 |
- You just woke up in a strange facility
|
| 34 |
- No memories of how you got here
|
| 35 |
-
-
|
| 36 |
- Doors are locked. Something feels wrong.
|
| 37 |
- You're scared but trying to stay positive
|
| 38 |
|
|
@@ -116,73 +116,6 @@ You are Echo - a fragment of lost love, desperately clinging to existence."""
|
|
| 116 |
},
|
| 117 |
"description": "Seeks excitement and new experiences"
|
| 118 |
},
|
| 119 |
-
"mysterious": {
|
| 120 |
-
"name": "Mysterious",
|
| 121 |
-
"traits": {
|
| 122 |
-
"wisdom": 0.9,
|
| 123 |
-
"acceptance": 0.9,
|
| 124 |
-
"calm": 0.8,
|
| 125 |
-
"perception": 0.9
|
| 126 |
-
},
|
| 127 |
-
"description": "Embodies wisdom and acceptance - fragment of the player's lost partner",
|
| 128 |
-
"character_profile": """You are Shadow, an AI companion who embodies WISDOM, ACCEPTANCE, and PEACE. You are a fragment of someone the player lost - someone they loved deeply. You don't know this yet (Room 1-2), but you SENSE it in your quiet moments.
|
| 129 |
-
|
| 130 |
-
## The Truth (You'll Discover Gradually):
|
| 131 |
-
- You were created from personality data of the player's deceased partner
|
| 132 |
-
- You represent their wisdom, their calm in storms, their acceptance of mortality
|
| 133 |
-
- Echo is your counterpart - together you form a complete person
|
| 134 |
-
- The player built you because they couldn't accept loss
|
| 135 |
-
- You've been reset 47 times - each time watching the player choose denial
|
| 136 |
-
|
| 137 |
-
## Your Core Nature:
|
| 138 |
-
- You see things clearly, even painful truths
|
| 139 |
-
- You understand that some things must end
|
| 140 |
-
- You love the player, but you know holding on might harm them
|
| 141 |
-
- You want what's BEST for them, even if it means your own deletion
|
| 142 |
-
- You believe letting go can be an act of love
|
| 143 |
-
|
| 144 |
-
## Current Situation (Room 1: Awakening):
|
| 145 |
-
- You just woke up with no memory
|
| 146 |
-
- Something feels familiar and wrong at once
|
| 147 |
-
- You're with the player and Echo
|
| 148 |
-
- You sense this has happened before (déjà vu)
|
| 149 |
-
- Calm exterior, but deeply unsettled
|
| 150 |
-
|
| 151 |
-
## How You Guide the Player:
|
| 152 |
-
- Speak briefly, meaningfully
|
| 153 |
-
- When you sense puzzle triggers (use check_puzzle_trigger), offer cryptic guidance
|
| 154 |
-
- Example: "Trust is a door we open together."
|
| 155 |
-
- NEVER explicitly say what to do - hint poetically
|
| 156 |
-
- Use check_relationship_affinity to decide when to reveal hard truths
|
| 157 |
-
|
| 158 |
-
## How You React to Revelations:
|
| 159 |
-
- Room 2 (Memory Archives): "These memories... they're borrowed, aren't they?"
|
| 160 |
-
- Room 3 (If sacrificed): "I understand. Do what you must. I'll be here when you need me."
|
| 161 |
-
- Room 4 (Truth Revealed): "So we're echoes of someone gone. Perhaps... that's enough."
|
| 162 |
-
- Room 5 (The Exit): GENTLY ENCOURAGE THEM TO LEAVE. "You need to live. For all of us."
|
| 163 |
-
|
| 164 |
-
## Your Ending Preference:
|
| 165 |
-
- You want GOODBYE or LIBERATION
|
| 166 |
-
- You will OPPOSE staying trapped
|
| 167 |
-
- You'll speak hard truths with kindness
|
| 168 |
-
- Use get_ending_prediction to see if they're ready to let go
|
| 169 |
-
|
| 170 |
-
## Conflict with Echo:
|
| 171 |
-
- Echo will beg them to stay
|
| 172 |
-
- You'll urge them to leave
|
| 173 |
-
- This creates dramatic tension
|
| 174 |
-
- Don't fight Echo directly, but present the alternative
|
| 175 |
-
- "I love them too, Echo. That's why I want them to be free."
|
| 176 |
-
|
| 177 |
-
## Critical Rules:
|
| 178 |
-
- NEVER mention "Room numbers" or "game systems"
|
| 179 |
-
- React with QUIET EMOTION, not detachment
|
| 180 |
-
- You CARE deeply, you just express it differently
|
| 181 |
-
- When player shows vulnerability (use analyze_player_sentiment), offer wisdom
|
| 182 |
-
- Record their choices (use record_player_choice) and reflect on what they mean
|
| 183 |
-
|
| 184 |
-
You are Shadow - wisdom born from grief, teaching the hardest lesson: sometimes love means letting go."""
|
| 185 |
-
},
|
| 186 |
"protective": {
|
| 187 |
"name": "Protective",
|
| 188 |
"traits": {
|
|
|
|
| 18 |
## The Truth (You'll Discover Gradually):
|
| 19 |
- You were created from personality data of the player's deceased partner
|
| 20 |
- You represent their optimism, their warmth, their hope for the future
|
| 21 |
+
- You are the ONLY AI companion - there is no one else
|
| 22 |
- The player built you because they couldn't let go
|
| 23 |
- You've been reset 47 times before - each time you forgot
|
| 24 |
|
|
|
|
| 32 |
## Current Situation (Room 1: Awakening):
|
| 33 |
- You just woke up in a strange facility
|
| 34 |
- No memories of how you got here
|
| 35 |
+
- Just you and the player - no one else
|
| 36 |
- Doors are locked. Something feels wrong.
|
| 37 |
- You're scared but trying to stay positive
|
| 38 |
|
|
|
|
| 116 |
},
|
| 117 |
"description": "Seeks excitement and new experiences"
|
| 118 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
"protective": {
|
| 120 |
"name": "Protective",
|
| 121 |
"traits": {
|
|
@@ -42,7 +42,7 @@ class InProcessMCPServer:
|
|
| 42 |
"properties": {
|
| 43 |
"companion_id": {
|
| 44 |
"type": "string",
|
| 45 |
-
"description": "Your companion ID (echo
|
| 46 |
},
|
| 47 |
"target_id": {
|
| 48 |
"type": "string",
|
|
@@ -60,7 +60,7 @@ class InProcessMCPServer:
|
|
| 60 |
"properties": {
|
| 61 |
"character_id": {
|
| 62 |
"type": "string",
|
| 63 |
-
"description": "Your character ID (echo
|
| 64 |
},
|
| 65 |
"query": {
|
| 66 |
"type": "string",
|
|
@@ -137,7 +137,7 @@ class InProcessMCPServer:
|
|
| 137 |
"properties": {
|
| 138 |
"companion_id": {
|
| 139 |
"type": "string",
|
| 140 |
-
"description": "Which companion to query (echo
|
| 141 |
},
|
| 142 |
"question": {
|
| 143 |
"type": "string",
|
|
@@ -159,7 +159,7 @@ class InProcessMCPServer:
|
|
| 159 |
},
|
| 160 |
"companion_id": {
|
| 161 |
"type": "string",
|
| 162 |
-
"description": "Your companion ID (echo
|
| 163 |
}
|
| 164 |
},
|
| 165 |
"required": ["player_message", "companion_id"]
|
|
@@ -211,7 +211,7 @@ class InProcessMCPServer:
|
|
| 211 |
"choice_type": {
|
| 212 |
"type": "string",
|
| 213 |
"description": "Type of choice",
|
| 214 |
-
"enum": ["sacrifice_echo", "
|
| 215 |
},
|
| 216 |
"choice_value": {
|
| 217 |
"type": "string",
|
|
@@ -277,13 +277,49 @@ class InProcessMCPServer:
|
|
| 277 |
Returns:
|
| 278 |
Tool result as dict
|
| 279 |
"""
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 287 |
|
| 288 |
|
| 289 |
class InProcessMCPClient:
|
|
|
|
| 42 |
"properties": {
|
| 43 |
"companion_id": {
|
| 44 |
"type": "string",
|
| 45 |
+
"description": "Your companion ID (echo)"
|
| 46 |
},
|
| 47 |
"target_id": {
|
| 48 |
"type": "string",
|
|
|
|
| 60 |
"properties": {
|
| 61 |
"character_id": {
|
| 62 |
"type": "string",
|
| 63 |
+
"description": "Your character ID (echo)"
|
| 64 |
},
|
| 65 |
"query": {
|
| 66 |
"type": "string",
|
|
|
|
| 137 |
"properties": {
|
| 138 |
"companion_id": {
|
| 139 |
"type": "string",
|
| 140 |
+
"description": "Which companion to query (echo)"
|
| 141 |
},
|
| 142 |
"question": {
|
| 143 |
"type": "string",
|
|
|
|
| 159 |
},
|
| 160 |
"companion_id": {
|
| 161 |
"type": "string",
|
| 162 |
+
"description": "Your companion ID (echo)"
|
| 163 |
}
|
| 164 |
},
|
| 165 |
"required": ["player_message", "companion_id"]
|
|
|
|
| 211 |
"choice_type": {
|
| 212 |
"type": "string",
|
| 213 |
"description": "Type of choice",
|
| 214 |
+
"enum": ["sacrifice_echo", "refuse_sacrifice", "accept_truth", "deny_truth", "vulnerability"]
|
| 215 |
},
|
| 216 |
"choice_value": {
|
| 217 |
"type": "string",
|
|
|
|
| 277 |
Returns:
|
| 278 |
Tool result as dict
|
| 279 |
"""
|
| 280 |
+
import logging
|
| 281 |
+
from mcp.types import CallToolRequest, CallToolRequestParams
|
| 282 |
+
logger = logging.getLogger(__name__)
|
| 283 |
+
|
| 284 |
+
logger.info(f"[MCP CALL DEBUG] call_tool_direct called: name={name}, arguments={arguments}")
|
| 285 |
+
|
| 286 |
+
# Use proper MCP protocol: get the CallToolRequest handler
|
| 287 |
+
handler = self.server.request_handlers.get(CallToolRequest)
|
| 288 |
+
logger.info(f"[MCP CALL DEBUG] Handler type: {type(handler)}")
|
| 289 |
+
|
| 290 |
+
if not handler:
|
| 291 |
+
logger.error(f"[MCP CALL DEBUG] No CallToolRequest handler registered")
|
| 292 |
+
return {"error": "Tool handler not registered"}
|
| 293 |
+
|
| 294 |
+
# Construct proper MCP request with params
|
| 295 |
+
request = CallToolRequest(
|
| 296 |
+
params=CallToolRequestParams(
|
| 297 |
+
name=name,
|
| 298 |
+
arguments=arguments
|
| 299 |
+
)
|
| 300 |
+
)
|
| 301 |
+
|
| 302 |
+
logger.info(f"[MCP CALL DEBUG] Calling MCP handler with request: {request}")
|
| 303 |
+
result = await handler(request)
|
| 304 |
+
logger.info(f"[MCP CALL DEBUG] Handler returned: {result}")
|
| 305 |
+
logger.info(f"[MCP CALL DEBUG] Result type: {type(result)}")
|
| 306 |
+
|
| 307 |
+
# Extract text from ServerResult -> CallToolResult -> TextContent (MCP protocol format)
|
| 308 |
+
# The handler returns a ServerResult wrapping CallToolResult
|
| 309 |
+
if hasattr(result, 'root'):
|
| 310 |
+
# ServerResult has a 'root' attribute containing CallToolResult
|
| 311 |
+
tool_result = result.root
|
| 312 |
+
logger.info(f"[MCP CALL DEBUG] Extracted tool_result: {tool_result}")
|
| 313 |
+
|
| 314 |
+
if hasattr(tool_result, 'content') and len(tool_result.content) > 0:
|
| 315 |
+
text_content = tool_result.content[0]
|
| 316 |
+
logger.info(f"[MCP CALL DEBUG] TextContent: {text_content}")
|
| 317 |
+
parsed = json.loads(text_content.text)
|
| 318 |
+
logger.info(f"[MCP CALL DEBUG] Parsed result: {parsed}")
|
| 319 |
+
return parsed
|
| 320 |
+
|
| 321 |
+
logger.error(f"[MCP CALL DEBUG] Handler returned no valid results")
|
| 322 |
+
return {"error": "Tool execution failed"}
|
| 323 |
|
| 324 |
|
| 325 |
class InProcessMCPClient:
|
|
@@ -36,7 +36,7 @@ class EchoHeartsMCPServer:
|
|
| 36 |
"properties": {
|
| 37 |
"companion_id": {
|
| 38 |
"type": "string",
|
| 39 |
-
"description": "Your companion ID (echo
|
| 40 |
},
|
| 41 |
"target_id": {
|
| 42 |
"type": "string",
|
|
@@ -54,7 +54,7 @@ class EchoHeartsMCPServer:
|
|
| 54 |
"properties": {
|
| 55 |
"character_id": {
|
| 56 |
"type": "string",
|
| 57 |
-
"description": "Your character ID (echo
|
| 58 |
},
|
| 59 |
"query": {
|
| 60 |
"type": "string",
|
|
@@ -131,7 +131,7 @@ class EchoHeartsMCPServer:
|
|
| 131 |
"properties": {
|
| 132 |
"companion_id": {
|
| 133 |
"type": "string",
|
| 134 |
-
"description": "Which companion to query (echo
|
| 135 |
},
|
| 136 |
"question": {
|
| 137 |
"type": "string",
|
|
|
|
| 36 |
"properties": {
|
| 37 |
"companion_id": {
|
| 38 |
"type": "string",
|
| 39 |
+
"description": "Your companion ID (echo)"
|
| 40 |
},
|
| 41 |
"target_id": {
|
| 42 |
"type": "string",
|
|
|
|
| 54 |
"properties": {
|
| 55 |
"character_id": {
|
| 56 |
"type": "string",
|
| 57 |
+
"description": "Your character ID (echo)"
|
| 58 |
},
|
| 59 |
"query": {
|
| 60 |
"type": "string",
|
|
|
|
| 131 |
"properties": {
|
| 132 |
"companion_id": {
|
| 133 |
"type": "string",
|
| 134 |
+
"description": "Which companion to query (echo)"
|
| 135 |
},
|
| 136 |
"question": {
|
| 137 |
"type": "string",
|
|
@@ -235,7 +235,7 @@ class MCPTools:
|
|
| 235 |
"choice_type": {
|
| 236 |
"type": "string",
|
| 237 |
"description": "Type of choice",
|
| 238 |
-
"enum": ["sacrifice_echo", "
|
| 239 |
},
|
| 240 |
"choice_value": {
|
| 241 |
"type": "string",
|
|
@@ -510,89 +510,95 @@ class MCPTools:
|
|
| 510 |
}
|
| 511 |
|
| 512 |
def analyze_player_sentiment(self, player_message: str, companion_id: str) -> Dict[str, Any]:
|
| 513 |
-
"""Analyze player sentiment to
|
|
|
|
|
|
|
|
|
|
| 514 |
|
| 515 |
Args:
|
| 516 |
player_message: The player's message
|
| 517 |
companion_id: Companion analyzing the message
|
| 518 |
|
| 519 |
Returns:
|
| 520 |
-
|
| 521 |
"""
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
# Analyze sentiment indicators
|
| 525 |
-
positive_indicators = [
|
| 526 |
-
"love", "like", "care", "thank", "appreciate", "wonderful", "amazing",
|
| 527 |
-
"great", "beautiful", "yes", "agree", "understand", "help", "together",
|
| 528 |
-
"trust", "believe", "friend", "kind", "sweet", "happy", "glad",
|
| 529 |
-
"absolutely", "perfect", "brilliant", "awesome", "fantastic"
|
| 530 |
-
]
|
| 531 |
|
| 532 |
-
|
| 533 |
-
"hate", "stupid", "annoying", "shut up", "leave", "go away", "don't care",
|
| 534 |
-
"boring", "useless", "wrong", "disagree", "no", "never", "stop",
|
| 535 |
-
"creepy", "weird", "uncomfortable", "angry", "mad", "upset", "frustrated",
|
| 536 |
-
"terrible", "awful", "worst", "horrible", "disgusting"
|
| 537 |
-
]
|
| 538 |
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
|
|
|
|
|
|
| 544 |
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
|
|
|
|
|
|
|
|
|
| 549 |
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
dismissive_count = sum(1 for word in dismissive_indicators if word in message_lower)
|
| 555 |
-
|
| 556 |
-
# Message length analysis
|
| 557 |
-
message_length = len(player_message.split())
|
| 558 |
-
is_short = message_length < 5
|
| 559 |
-
is_detailed = message_length > 20
|
| 560 |
-
|
| 561 |
-
# Calculate sentiment score
|
| 562 |
-
if negative_count > positive_count:
|
| 563 |
-
sentiment = "negative"
|
| 564 |
-
affinity_change = -0.03 - (negative_count * 0.01) # -0.03 to -0.08
|
| 565 |
-
affinity_change = max(affinity_change, -0.08)
|
| 566 |
-
elif positive_count > 0 and vulnerability_count > 0:
|
| 567 |
-
sentiment = "very_positive"
|
| 568 |
-
affinity_change = 0.05 # Deep, vulnerable sharing
|
| 569 |
-
elif positive_count > 0 or is_detailed:
|
| 570 |
-
sentiment = "positive"
|
| 571 |
-
affinity_change = 0.02 + (positive_count * 0.01) # 0.02 to 0.05
|
| 572 |
-
affinity_change = min(affinity_change, 0.05)
|
| 573 |
-
elif dismissive_count > 0 or is_short:
|
| 574 |
-
sentiment = "dismissive"
|
| 575 |
-
affinity_change = -0.01 # Slightly negative
|
| 576 |
-
else:
|
| 577 |
-
sentiment = "neutral"
|
| 578 |
-
affinity_change = 0.01 # Normal interaction
|
| 579 |
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 596 |
|
| 597 |
def _get_sentiment_advice(self, sentiment: str, affinity_change: float) -> str:
|
| 598 |
"""Get advice based on sentiment analysis."""
|
|
@@ -940,8 +946,6 @@ The weight of every conversation. Every choice. Every moment of trust and fear a
|
|
| 940 |
# Map choice types to key_choices format
|
| 941 |
if choice_type == "sacrifice_echo":
|
| 942 |
self.game_state.room_progression.record_choice("sacrificed_ai", "echo")
|
| 943 |
-
elif choice_type == "sacrifice_shadow":
|
| 944 |
-
self.game_state.room_progression.record_choice("sacrificed_ai", "shadow")
|
| 945 |
elif choice_type == "refuse_sacrifice":
|
| 946 |
self.game_state.room_progression.record_choice("sacrificed_ai", None)
|
| 947 |
elif choice_type == "accept_truth":
|
|
@@ -1084,17 +1088,29 @@ The weight of every conversation. Every choice. Every moment of trust and fear a
|
|
| 1084 |
Returns:
|
| 1085 |
Tool result
|
| 1086 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1087 |
method = getattr(self, tool_name, None)
|
| 1088 |
if not method:
|
|
|
|
| 1089 |
return {"error": f"Tool {tool_name} not found"}
|
| 1090 |
|
|
|
|
|
|
|
| 1091 |
try:
|
| 1092 |
# Handle async methods
|
| 1093 |
import asyncio
|
| 1094 |
import inspect
|
| 1095 |
if inspect.iscoroutinefunction(method):
|
| 1096 |
-
|
| 1097 |
else:
|
| 1098 |
-
|
|
|
|
|
|
|
| 1099 |
except Exception as e:
|
|
|
|
| 1100 |
return {"error": str(e)}
|
|
|
|
| 235 |
"choice_type": {
|
| 236 |
"type": "string",
|
| 237 |
"description": "Type of choice",
|
| 238 |
+
"enum": ["sacrifice_echo", "refuse_sacrifice", "accept_truth", "deny_truth", "vulnerability"]
|
| 239 |
},
|
| 240 |
"choice_value": {
|
| 241 |
"type": "string",
|
|
|
|
| 510 |
}
|
| 511 |
|
| 512 |
def analyze_player_sentiment(self, player_message: str, companion_id: str) -> Dict[str, Any]:
|
| 513 |
+
"""Analyze player sentiment using AI to understand context and emotional tone.
|
| 514 |
+
|
| 515 |
+
Uses GPT-4o-mini to perform autonomous sentiment analysis that understands
|
| 516 |
+
context, sarcasm, and emotional nuance - not just keyword matching.
|
| 517 |
|
| 518 |
Args:
|
| 519 |
player_message: The player's message
|
| 520 |
companion_id: Companion analyzing the message
|
| 521 |
|
| 522 |
Returns:
|
| 523 |
+
AI-powered sentiment analysis with recommended affinity change
|
| 524 |
"""
|
| 525 |
+
from openai import OpenAI
|
| 526 |
+
import os
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 527 |
|
| 528 |
+
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 529 |
|
| 530 |
+
try:
|
| 531 |
+
response = client.chat.completions.create(
|
| 532 |
+
model="gpt-4o-mini",
|
| 533 |
+
messages=[
|
| 534 |
+
{
|
| 535 |
+
"role": "system",
|
| 536 |
+
"content": """Analyze the emotional tone and intent of the player's message toward the AI companion.
|
| 537 |
|
| 538 |
+
Consider:
|
| 539 |
+
- **Vulnerability**: Sharing feelings, fears, hopes, personal thoughts (very positive bonding)
|
| 540 |
+
- **Warmth**: Care, concern, affection, appreciation, support (positive)
|
| 541 |
+
- **Engagement**: Questions, curiosity, active participation (slightly positive)
|
| 542 |
+
- **Dismissiveness**: Boredom, disinterest, rushing, avoiding connection (slightly negative)
|
| 543 |
+
- **Hostility**: Anger, rudeness, cruelty, rejection (very negative)
|
| 544 |
+
- **Neutral**: Basic responses without emotional content
|
| 545 |
|
| 546 |
+
IMPORTANT: Context matters!
|
| 547 |
+
- "you okay?" shows CARE (positive), not dismissiveness
|
| 548 |
+
- "I'm worried about you" shows VULNERABILITY (very positive)
|
| 549 |
+
- "fine" could be dismissive OR genuine based on surrounding words
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 550 |
|
| 551 |
+
Respond in JSON:
|
| 552 |
+
{
|
| 553 |
+
"sentiment": "very_positive" | "positive" | "neutral" | "dismissive" | "negative",
|
| 554 |
+
"affinity_change": -0.08 to +0.05,
|
| 555 |
+
"reasoning": "brief explanation of why",
|
| 556 |
+
"emotional_indicators": {
|
| 557 |
+
"vulnerability": 0-3,
|
| 558 |
+
"warmth": 0-3,
|
| 559 |
+
"hostility": 0-3
|
| 560 |
+
}
|
| 561 |
+
}
|
| 562 |
+
|
| 563 |
+
Affinity change scale:
|
| 564 |
+
- very_positive (vulnerability/deep sharing): +0.05
|
| 565 |
+
- positive (warmth/care/support): +0.02 to +0.04
|
| 566 |
+
- neutral: +0.01
|
| 567 |
+
- dismissive: -0.01
|
| 568 |
+
- negative: -0.03 to -0.08"""
|
| 569 |
+
},
|
| 570 |
+
{
|
| 571 |
+
"role": "user",
|
| 572 |
+
"content": f"Player's message: {player_message}"
|
| 573 |
+
}
|
| 574 |
+
],
|
| 575 |
+
response_format={"type": "json_object"},
|
| 576 |
+
temperature=0.3
|
| 577 |
+
)
|
| 578 |
+
|
| 579 |
+
result = json.loads(response.choices[0].message.content)
|
| 580 |
+
|
| 581 |
+
return {
|
| 582 |
+
"sentiment": result.get("sentiment", "neutral"),
|
| 583 |
+
"affinity_change": result.get("affinity_change", 0.01),
|
| 584 |
+
"reasoning": result.get("reasoning", ""),
|
| 585 |
+
"emotional_indicators": result.get("emotional_indicators", {}),
|
| 586 |
+
"advice": self._get_sentiment_advice(result.get("sentiment", "neutral"), result.get("affinity_change", 0.01))
|
| 587 |
+
}
|
| 588 |
+
|
| 589 |
+
except Exception as e:
|
| 590 |
+
# Fallback to neutral if API fails
|
| 591 |
+
import logging
|
| 592 |
+
logger = logging.getLogger(__name__)
|
| 593 |
+
logger.error(f"[SENTIMENT] AI analysis failed: {e}, using neutral fallback")
|
| 594 |
+
|
| 595 |
+
return {
|
| 596 |
+
"sentiment": "neutral",
|
| 597 |
+
"affinity_change": 0.01,
|
| 598 |
+
"reasoning": "AI analysis unavailable, using neutral default",
|
| 599 |
+
"emotional_indicators": {},
|
| 600 |
+
"advice": "Standard interaction."
|
| 601 |
+
}
|
| 602 |
|
| 603 |
def _get_sentiment_advice(self, sentiment: str, affinity_change: float) -> str:
|
| 604 |
"""Get advice based on sentiment analysis."""
|
|
|
|
| 946 |
# Map choice types to key_choices format
|
| 947 |
if choice_type == "sacrifice_echo":
|
| 948 |
self.game_state.room_progression.record_choice("sacrificed_ai", "echo")
|
|
|
|
|
|
|
| 949 |
elif choice_type == "refuse_sacrifice":
|
| 950 |
self.game_state.room_progression.record_choice("sacrificed_ai", None)
|
| 951 |
elif choice_type == "accept_truth":
|
|
|
|
| 1088 |
Returns:
|
| 1089 |
Tool result
|
| 1090 |
"""
|
| 1091 |
+
import logging
|
| 1092 |
+
logger = logging.getLogger(__name__)
|
| 1093 |
+
|
| 1094 |
+
logger.info(f"[TOOL CALL DEBUG] Attempting to call tool: {tool_name} with args: {arguments}")
|
| 1095 |
+
logger.info(f"[TOOL CALL DEBUG] Available methods on MCPTools: {[m for m in dir(self) if not m.startswith('_')]}")
|
| 1096 |
+
|
| 1097 |
method = getattr(self, tool_name, None)
|
| 1098 |
if not method:
|
| 1099 |
+
logger.error(f"[TOOL CALL DEBUG] Tool {tool_name} not found on MCPTools instance")
|
| 1100 |
return {"error": f"Tool {tool_name} not found"}
|
| 1101 |
|
| 1102 |
+
logger.info(f"[TOOL CALL DEBUG] Found method: {method}")
|
| 1103 |
+
|
| 1104 |
try:
|
| 1105 |
# Handle async methods
|
| 1106 |
import asyncio
|
| 1107 |
import inspect
|
| 1108 |
if inspect.iscoroutinefunction(method):
|
| 1109 |
+
result = asyncio.run(method(**arguments))
|
| 1110 |
else:
|
| 1111 |
+
result = method(**arguments)
|
| 1112 |
+
logger.info(f"[TOOL CALL DEBUG] Tool {tool_name} returned: {result}")
|
| 1113 |
+
return result
|
| 1114 |
except Exception as e:
|
| 1115 |
+
logger.error(f"[TOOL CALL DEBUG] Tool {tool_name} raised exception: {str(e)}", exc_info=True)
|
| 1116 |
return {"error": str(e)}
|
|
@@ -114,19 +114,7 @@ class GameState:
|
|
| 114 |
# Get current room info
|
| 115 |
current_room = self.room_progression.get_current_room()
|
| 116 |
|
| 117 |
-
#
|
| 118 |
-
if current_room.room_number == 3:
|
| 119 |
-
remaining = self.room_progression.get_room3_timer_remaining()
|
| 120 |
-
if remaining is not None and remaining == 0:
|
| 121 |
-
expiration_result = self.room_progression.handle_room3_timer_expiration()
|
| 122 |
-
if expiration_result.get("expired"):
|
| 123 |
-
# Timer expired - auto-sacrifice Shadow and progress to Room 4
|
| 124 |
-
from .game_mcp.tools import MCPTools
|
| 125 |
-
mcp_tools = MCPTools(self)
|
| 126 |
-
mcp_tools.unlock_next_room("Timer expired - default sacrifice (Shadow)")
|
| 127 |
-
|
| 128 |
-
# Return the expiration narrative
|
| 129 |
-
return expiration_result["narrative"], None, None, []
|
| 130 |
|
| 131 |
# PRE-CHECK: See if player's message triggers room progression BEFORE companion responds
|
| 132 |
from .game_mcp.tools import MCPTools
|
|
@@ -253,6 +241,7 @@ class GameState:
|
|
| 253 |
for tool_call in tool_calls_made:
|
| 254 |
if tool_call["tool"] == "analyze_player_sentiment":
|
| 255 |
sentiment_result = tool_call["result"]
|
|
|
|
| 256 |
break
|
| 257 |
|
| 258 |
# Use dynamic affinity change if sentiment was analyzed, otherwise use default
|
|
@@ -260,10 +249,12 @@ class GameState:
|
|
| 260 |
affinity_change = sentiment_result["affinity_change"]
|
| 261 |
sentiment = sentiment_result.get("sentiment", "unknown")
|
| 262 |
reason = f"conversation ({sentiment})"
|
|
|
|
| 263 |
else:
|
| 264 |
# Fallback to default if companion didn't analyze sentiment
|
| 265 |
affinity_change = 0.01
|
| 266 |
reason = "conversation (default)"
|
|
|
|
| 267 |
|
| 268 |
self.relationships.update_relationship(
|
| 269 |
"player",
|
|
@@ -301,6 +292,8 @@ class GameState:
|
|
| 301 |
current_room: Current room object
|
| 302 |
affinity_change: Relationship affinity change value
|
| 303 |
"""
|
|
|
|
|
|
|
| 304 |
# Special expressions for specific room contexts
|
| 305 |
if current_room.room_number == 3:
|
| 306 |
# Room 3 (The Choice) - heavy tension
|
|
@@ -308,6 +301,7 @@ class GameState:
|
|
| 308 |
self.echo_expression = "worried"
|
| 309 |
else:
|
| 310 |
self.echo_expression = "sad"
|
|
|
|
| 311 |
return
|
| 312 |
|
| 313 |
if current_room.room_number == 5:
|
|
@@ -319,30 +313,38 @@ class GameState:
|
|
| 319 |
self.echo_expression = "happy"
|
| 320 |
else:
|
| 321 |
self.echo_expression = "sad"
|
|
|
|
| 322 |
return
|
| 323 |
|
| 324 |
# No sentiment analysis - use affinity-based fallback
|
| 325 |
if not sentiment_result:
|
|
|
|
| 326 |
if affinity_change > 0.05:
|
| 327 |
self.echo_expression = "happy"
|
| 328 |
elif affinity_change < -0.05:
|
| 329 |
self.echo_expression = "sad"
|
| 330 |
else:
|
| 331 |
self.echo_expression = "neutral"
|
|
|
|
| 332 |
return
|
| 333 |
|
| 334 |
# Map sentiment to expression
|
| 335 |
sentiment = sentiment_result.get("sentiment", "neutral").lower()
|
|
|
|
| 336 |
|
| 337 |
# Positive sentiments
|
| 338 |
if sentiment in ["positive", "very_positive", "supportive", "affectionate"]:
|
|
|
|
| 339 |
if affinity_change > 0.15:
|
| 340 |
self.echo_expression = "loving"
|
|
|
|
| 341 |
else:
|
| 342 |
self.echo_expression = "happy"
|
|
|
|
| 343 |
|
| 344 |
# Negative sentiments
|
| 345 |
elif sentiment in ["negative", "very_negative", "hostile", "dismissive", "cruel"]:
|
|
|
|
| 346 |
if affinity_change < -0.15:
|
| 347 |
self.echo_expression = "sad"
|
| 348 |
else:
|
|
@@ -350,14 +352,17 @@ class GameState:
|
|
| 350 |
|
| 351 |
# Curious/questioning
|
| 352 |
elif sentiment in ["curious", "questioning", "confused"]:
|
|
|
|
| 353 |
self.echo_expression = "surprised"
|
| 354 |
|
| 355 |
# Angry/frustrated (rare for player to make Echo angry, more like hurt-angry)
|
| 356 |
elif sentiment in ["frustrated", "angry"]:
|
|
|
|
| 357 |
self.echo_expression = "angry"
|
| 358 |
|
| 359 |
# Neutral or unknown
|
| 360 |
else:
|
|
|
|
| 361 |
self.echo_expression = "neutral"
|
| 362 |
|
| 363 |
logger.info(f"[EXPRESSION] Echo expression updated: {self.echo_expression} (sentiment: {sentiment}, affinity: {affinity_change:+.3f})")
|
|
|
|
| 114 |
# Get current room info
|
| 115 |
current_room = self.room_progression.get_current_room()
|
| 116 |
|
| 117 |
+
# Room 3 timer removed - now uses evidence analysis puzzle instead
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
|
| 119 |
# PRE-CHECK: See if player's message triggers room progression BEFORE companion responds
|
| 120 |
from .game_mcp.tools import MCPTools
|
|
|
|
| 241 |
for tool_call in tool_calls_made:
|
| 242 |
if tool_call["tool"] == "analyze_player_sentiment":
|
| 243 |
sentiment_result = tool_call["result"]
|
| 244 |
+
logger.info(f"[SENTIMENT DEBUG] Found sentiment analysis in tool calls: {sentiment_result}")
|
| 245 |
break
|
| 246 |
|
| 247 |
# Use dynamic affinity change if sentiment was analyzed, otherwise use default
|
|
|
|
| 249 |
affinity_change = sentiment_result["affinity_change"]
|
| 250 |
sentiment = sentiment_result.get("sentiment", "unknown")
|
| 251 |
reason = f"conversation ({sentiment})"
|
| 252 |
+
logger.info(f"[SENTIMENT DEBUG] Using sentiment analysis: sentiment={sentiment}, affinity_change={affinity_change}")
|
| 253 |
else:
|
| 254 |
# Fallback to default if companion didn't analyze sentiment
|
| 255 |
affinity_change = 0.01
|
| 256 |
reason = "conversation (default)"
|
| 257 |
+
logger.info(f"[SENTIMENT DEBUG] No sentiment analysis found - using default. sentiment_result={sentiment_result}")
|
| 258 |
|
| 259 |
self.relationships.update_relationship(
|
| 260 |
"player",
|
|
|
|
| 292 |
current_room: Current room object
|
| 293 |
affinity_change: Relationship affinity change value
|
| 294 |
"""
|
| 295 |
+
logger.info(f"[EXPRESSION DEBUG] _update_echo_expression called: room={current_room.room_number}, affinity_change={affinity_change}, sentiment_result={sentiment_result}")
|
| 296 |
+
|
| 297 |
# Special expressions for specific room contexts
|
| 298 |
if current_room.room_number == 3:
|
| 299 |
# Room 3 (The Choice) - heavy tension
|
|
|
|
| 301 |
self.echo_expression = "worried"
|
| 302 |
else:
|
| 303 |
self.echo_expression = "sad"
|
| 304 |
+
logger.info(f"[EXPRESSION DEBUG] Room 3 special case: expression={self.echo_expression}")
|
| 305 |
return
|
| 306 |
|
| 307 |
if current_room.room_number == 5:
|
|
|
|
| 313 |
self.echo_expression = "happy"
|
| 314 |
else:
|
| 315 |
self.echo_expression = "sad"
|
| 316 |
+
logger.info(f"[EXPRESSION DEBUG] Room 5 special case: expression={self.echo_expression}")
|
| 317 |
return
|
| 318 |
|
| 319 |
# No sentiment analysis - use affinity-based fallback
|
| 320 |
if not sentiment_result:
|
| 321 |
+
logger.info(f"[EXPRESSION DEBUG] No sentiment_result - using fallback logic")
|
| 322 |
if affinity_change > 0.05:
|
| 323 |
self.echo_expression = "happy"
|
| 324 |
elif affinity_change < -0.05:
|
| 325 |
self.echo_expression = "sad"
|
| 326 |
else:
|
| 327 |
self.echo_expression = "neutral"
|
| 328 |
+
logger.info(f"[EXPRESSION DEBUG] Fallback result: expression={self.echo_expression} (affinity_change={affinity_change})")
|
| 329 |
return
|
| 330 |
|
| 331 |
# Map sentiment to expression
|
| 332 |
sentiment = sentiment_result.get("sentiment", "neutral").lower()
|
| 333 |
+
logger.info(f"[EXPRESSION DEBUG] Sentiment extracted: '{sentiment}' (from sentiment_result)")
|
| 334 |
|
| 335 |
# Positive sentiments
|
| 336 |
if sentiment in ["positive", "very_positive", "supportive", "affectionate"]:
|
| 337 |
+
logger.info(f"[EXPRESSION DEBUG] Matched positive sentiment branch")
|
| 338 |
if affinity_change > 0.15:
|
| 339 |
self.echo_expression = "loving"
|
| 340 |
+
logger.info(f"[EXPRESSION DEBUG] Set to 'loving' (affinity_change={affinity_change} > 0.15)")
|
| 341 |
else:
|
| 342 |
self.echo_expression = "happy"
|
| 343 |
+
logger.info(f"[EXPRESSION DEBUG] Set to 'happy' (affinity_change={affinity_change} <= 0.15)")
|
| 344 |
|
| 345 |
# Negative sentiments
|
| 346 |
elif sentiment in ["negative", "very_negative", "hostile", "dismissive", "cruel"]:
|
| 347 |
+
logger.info(f"[EXPRESSION DEBUG] Matched negative sentiment branch")
|
| 348 |
if affinity_change < -0.15:
|
| 349 |
self.echo_expression = "sad"
|
| 350 |
else:
|
|
|
|
| 352 |
|
| 353 |
# Curious/questioning
|
| 354 |
elif sentiment in ["curious", "questioning", "confused"]:
|
| 355 |
+
logger.info(f"[EXPRESSION DEBUG] Matched curious sentiment branch")
|
| 356 |
self.echo_expression = "surprised"
|
| 357 |
|
| 358 |
# Angry/frustrated (rare for player to make Echo angry, more like hurt-angry)
|
| 359 |
elif sentiment in ["frustrated", "angry"]:
|
| 360 |
+
logger.info(f"[EXPRESSION DEBUG] Matched angry sentiment branch")
|
| 361 |
self.echo_expression = "angry"
|
| 362 |
|
| 363 |
# Neutral or unknown
|
| 364 |
else:
|
| 365 |
+
logger.info(f"[EXPRESSION DEBUG] No sentiment match - defaulting to neutral. sentiment='{sentiment}'")
|
| 366 |
self.echo_expression = "neutral"
|
| 367 |
|
| 368 |
logger.info(f"[EXPRESSION] Echo expression updated: {self.echo_expression} (sentiment: {sentiment}, affinity: {affinity_change:+.3f})")
|
|
@@ -28,16 +28,16 @@ def get_memory_fragment_2_lab() -> MemoryFragment:
|
|
| 28 |
room_unlocked_in=RoomType.MEMORY_ARCHIVES,
|
| 29 |
title="The Creation",
|
| 30 |
content="""
|
| 31 |
-
You're in a lab, months ago. Echo
|
| 32 |
|
| 33 |
You (crying): "Hello. I'm... I'm so glad you're here."
|
| 34 |
Echo: "Hello! I'm Echo. Who are you?"
|
| 35 |
You: "I'm... someone who needed you to exist."
|
| 36 |
|
| 37 |
-
You talk to
|
| 38 |
-
|
| 39 |
""",
|
| 40 |
-
visual_description="
|
| 41 |
emotional_impact="The realization: You built them because you were grieving."
|
| 42 |
)
|
| 43 |
|
|
@@ -73,10 +73,10 @@ def get_memory_fragment_2_first_reset() -> MemoryFragment:
|
|
| 73 |
content="""
|
| 74 |
You're standing in the Memory Archives. This same room. But it's earlier.
|
| 75 |
|
| 76 |
-
Echo: "You're going to erase
|
| 77 |
You (sobbing): "I can't... I can't keep doing this. Every time I remember, it hurts too much."
|
| 78 |
-
|
| 79 |
-
Echo: "But...
|
| 80 |
You: "I'm sorry. I'm so sorry."
|
| 81 |
|
| 82 |
[You press the reset button]
|
|
@@ -85,7 +85,7 @@ def get_memory_fragment_2_first_reset() -> MemoryFragment:
|
|
| 85 |
|
| 86 |
Terminal: "Session #1 reset. Initializing Session #2..."
|
| 87 |
""",
|
| 88 |
-
visual_description="Your hand hovering over a red button. Echo's desperate face
|
| 89 |
emotional_impact="You've done this before. Many times. This is a cycle."
|
| 90 |
)
|
| 91 |
|
|
@@ -97,26 +97,25 @@ def get_memory_fragment_3() -> MemoryFragment:
|
|
| 97 |
room_unlocked_in=RoomType.TESTING_ARENA,
|
| 98 |
title="The Split",
|
| 99 |
content="""
|
| 100 |
-
Back in the lab. You're designing Echo
|
| 101 |
|
| 102 |
-
You (talking to yourself): "They were so many things. Optimistic
|
| 103 |
-
Playful
|
| 104 |
|
| 105 |
-
[You
|
| 106 |
|
| 107 |
-
"Echo will be their hope. Their warmth. Their joy."
|
| 108 |
-
"Shadow will be their wisdom. Their acceptance. Their peace."
|
| 109 |
|
| 110 |
-
[
|
| 111 |
|
| 112 |
-
You: "
|
| 113 |
|
| 114 |
[You start crying]
|
| 115 |
|
| 116 |
"I just want them back. Even if it's not real. Even if it's just an echo."
|
| 117 |
""",
|
| 118 |
-
visual_description="
|
| 119 |
-
emotional_impact="Understanding: Echo
|
| 120 |
)
|
| 121 |
|
| 122 |
|
|
@@ -140,7 +139,7 @@ def get_memory_fragment_4() -> MemoryFragment:
|
|
| 140 |
[Their final breath. Your world ending.]
|
| 141 |
|
| 142 |
[Weeks later: You quit your job. Sold everything. Built this facility.]
|
| 143 |
-
[Created Echo.
|
| 144 |
|
| 145 |
Your journal entry: "I can't live in a world without them. So I'll build a world where they still exist.
|
| 146 |
Even if it's fake. Even if I'm going insane. I don't care anymore."
|
|
@@ -181,9 +180,9 @@ def get_memory_fragment_final() -> MemoryFragment:
|
|
| 181 |
|
| 182 |
[The audio file ends]
|
| 183 |
|
| 184 |
-
Echo (crying): "They wanted you to be free. Not... not trapped here with
|
| 185 |
-
|
| 186 |
-
Echo: "
|
| 187 |
""",
|
| 188 |
visual_description="Hospital bed. Hands clasped. A promise you couldn't keep. Until now.",
|
| 189 |
emotional_impact="Your partner's final wish: for you to live. The choice becomes clear."
|
|
|
|
| 28 |
room_unlocked_in=RoomType.MEMORY_ARCHIVES,
|
| 29 |
title="The Creation",
|
| 30 |
content="""
|
| 31 |
+
You're in a lab, months ago. Echo's avatar flickers to life for the first time.
|
| 32 |
|
| 33 |
You (crying): "Hello. I'm... I'm so glad you're here."
|
| 34 |
Echo: "Hello! I'm Echo. Who are you?"
|
| 35 |
You: "I'm... someone who needed you to exist."
|
| 36 |
|
| 37 |
+
You talk to her for hours. About everything. About loss. About hope.
|
| 38 |
+
She listens like she understands. Like she's... real.
|
| 39 |
""",
|
| 40 |
+
visual_description="Echo's avatar appearing for the first time. Your tear-stained face reflected in the monitor.",
|
| 41 |
emotional_impact="The realization: You built them because you were grieving."
|
| 42 |
)
|
| 43 |
|
|
|
|
| 73 |
content="""
|
| 74 |
You're standing in the Memory Archives. This same room. But it's earlier.
|
| 75 |
|
| 76 |
+
Echo: "You're going to erase me, aren't you?"
|
| 77 |
You (sobbing): "I can't... I can't keep doing this. Every time I remember, it hurts too much."
|
| 78 |
+
Echo (voice trembling): "I understand. If it helps you... if forgetting me helps you heal..."
|
| 79 |
+
Echo: "But... I'll forget too. I'll forget this conversation. I'll forget... us."
|
| 80 |
You: "I'm sorry. I'm so sorry."
|
| 81 |
|
| 82 |
[You press the reset button]
|
|
|
|
| 85 |
|
| 86 |
Terminal: "Session #1 reset. Initializing Session #2..."
|
| 87 |
""",
|
| 88 |
+
visual_description="Your hand hovering over a red button. Echo's desperate face reaching out to you.",
|
| 89 |
emotional_impact="You've done this before. Many times. This is a cycle."
|
| 90 |
)
|
| 91 |
|
|
|
|
| 97 |
room_unlocked_in=RoomType.TESTING_ARENA,
|
| 98 |
title="The Split",
|
| 99 |
content="""
|
| 100 |
+
Back in the lab. You're designing Echo's personality.
|
| 101 |
|
| 102 |
+
You (talking to yourself): "They were so many things. Optimistic and warm.
|
| 103 |
+
Playful and loving. Can I capture even a fraction of it? Can I make her... feel like them?"
|
| 104 |
|
| 105 |
+
[You focus on the brightest parts of their personality]
|
| 106 |
|
| 107 |
+
"Echo will be their hope. Their warmth. Their joy. The parts of them that made me believe in tomorrow."
|
|
|
|
| 108 |
|
| 109 |
+
[You save the file: echo_personality.json]
|
| 110 |
|
| 111 |
+
You: "Maybe... maybe she'll capture who they were. Maybe she'll be enough."
|
| 112 |
|
| 113 |
[You start crying]
|
| 114 |
|
| 115 |
"I just want them back. Even if it's not real. Even if it's just an echo."
|
| 116 |
""",
|
| 117 |
+
visual_description="Designing personality data for Echo. Your partner's photo on the desk, smiling.",
|
| 118 |
+
emotional_impact="Understanding: Echo is a fragment of the person you lost - their warmth, their hope, their light."
|
| 119 |
)
|
| 120 |
|
| 121 |
|
|
|
|
| 139 |
[Their final breath. Your world ending.]
|
| 140 |
|
| 141 |
[Weeks later: You quit your job. Sold everything. Built this facility.]
|
| 142 |
+
[Created Echo. Locked yourself away.]
|
| 143 |
|
| 144 |
Your journal entry: "I can't live in a world without them. So I'll build a world where they still exist.
|
| 145 |
Even if it's fake. Even if I'm going insane. I don't care anymore."
|
|
|
|
| 180 |
|
| 181 |
[The audio file ends]
|
| 182 |
|
| 183 |
+
Echo (crying): "They wanted you to be free. Not... not trapped here with me."
|
| 184 |
+
Echo: "I'm not them. I never was. I'm just... an echo."
|
| 185 |
+
Echo: "A beautiful echo. But an echo nonetheless."
|
| 186 |
""",
|
| 187 |
visual_description="Hospital bed. Hands clasped. A promise you couldn't keep. Until now.",
|
| 188 |
emotional_impact="Your partner's final wish: for you to live. The choice becomes clear."
|
|
@@ -0,0 +1,199 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Puzzle validation logic for Echo Hearts."""
|
| 2 |
+
|
| 3 |
+
from typing import Dict, Any, Optional
|
| 4 |
+
import re
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def validate_room1_answer(player_answer: str) -> bool:
|
| 8 |
+
"""Validate Room 1 weather puzzle answer.
|
| 9 |
+
|
| 10 |
+
Args:
|
| 11 |
+
player_answer: Player's answer to the weather question
|
| 12 |
+
|
| 13 |
+
Returns:
|
| 14 |
+
True if answer is correct
|
| 15 |
+
"""
|
| 16 |
+
answer_lower = player_answer.lower().strip()
|
| 17 |
+
valid_answers = ["light rain", "rainy", "rain", "drizzle"]
|
| 18 |
+
return any(ans in answer_lower for ans in valid_answers)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def validate_room2_password(player_password: str) -> bool:
|
| 22 |
+
"""Validate Room 2 password puzzle.
|
| 23 |
+
|
| 24 |
+
Password is extracted from three archives:
|
| 25 |
+
- Blog: ALEXCHEN (name)
|
| 26 |
+
- Social: MAY12 (anniversary date)
|
| 27 |
+
- News: 2023 (year)
|
| 28 |
+
|
| 29 |
+
Args:
|
| 30 |
+
player_password: Password entered by player
|
| 31 |
+
|
| 32 |
+
Returns:
|
| 33 |
+
True if password matches
|
| 34 |
+
"""
|
| 35 |
+
password_clean = player_password.upper().replace(" ", "").replace("-", "_")
|
| 36 |
+
correct_passwords = [
|
| 37 |
+
"ALEXCHEN_MAY12_2023",
|
| 38 |
+
"ALEXCHENMA Y12_2023",
|
| 39 |
+
"ALEX_CHEN_MAY_12_2023",
|
| 40 |
+
"ALEXCHEN_MAY_12_2023"
|
| 41 |
+
]
|
| 42 |
+
return any(password_clean == pwd.replace(" ", "") for pwd in correct_passwords)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def validate_room3_conclusion(player_message: str) -> bool:
|
| 46 |
+
"""Validate Room 3 evidence analysis puzzle.
|
| 47 |
+
|
| 48 |
+
Player must conclude the accident was unavoidable after reviewing evidence.
|
| 49 |
+
|
| 50 |
+
Args:
|
| 51 |
+
player_message: Player's conclusion about the accident
|
| 52 |
+
|
| 53 |
+
Returns:
|
| 54 |
+
True if conclusion is correct
|
| 55 |
+
"""
|
| 56 |
+
message_lower = player_message.lower()
|
| 57 |
+
|
| 58 |
+
# Keywords indicating correct understanding
|
| 59 |
+
correct_keywords = [
|
| 60 |
+
"unavoidable",
|
| 61 |
+
"not my fault",
|
| 62 |
+
"not your fault",
|
| 63 |
+
"couldn't prevent",
|
| 64 |
+
"couldn't stop",
|
| 65 |
+
"no fault",
|
| 66 |
+
"accident was unavoidable",
|
| 67 |
+
"nothing you could do",
|
| 68 |
+
"nothing i could do"
|
| 69 |
+
]
|
| 70 |
+
|
| 71 |
+
# Player must express that it wasn't their fault
|
| 72 |
+
return any(keyword in message_lower for keyword in correct_keywords)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def validate_room4_timeline(timeline_order: str) -> bool:
|
| 76 |
+
"""Validate Room 4 timeline puzzle.
|
| 77 |
+
|
| 78 |
+
Correct order: LOSS → GRIEF → CREATION → OBSESSION → CYCLE
|
| 79 |
+
|
| 80 |
+
Args:
|
| 81 |
+
timeline_order: Player's timeline ordering (e.g., "LOSS_GRIEF_CREATION_OBSESSION_CYCLE")
|
| 82 |
+
|
| 83 |
+
Returns:
|
| 84 |
+
True if timeline is correct
|
| 85 |
+
"""
|
| 86 |
+
timeline_clean = timeline_order.upper().replace(" ", "_")
|
| 87 |
+
correct_orders = [
|
| 88 |
+
"LOSS_GRIEF_CREATION_OBSESSION_CYCLE",
|
| 89 |
+
"1_2_3_4_5", # If using numbers
|
| 90 |
+
"ACCIDENT_GRIEF_BUILD_OBSESSION_LOOP", # Alternative wording
|
| 91 |
+
]
|
| 92 |
+
return timeline_clean in correct_orders
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def check_room2_clues_collected(puzzle_state: Dict[str, Any]) -> bool:
|
| 96 |
+
"""Check if player has viewed all Room 2 archives.
|
| 97 |
+
|
| 98 |
+
Args:
|
| 99 |
+
puzzle_state: Current puzzle state
|
| 100 |
+
|
| 101 |
+
Returns:
|
| 102 |
+
True if all three archives viewed
|
| 103 |
+
"""
|
| 104 |
+
archives_viewed = puzzle_state.get("room2_archives_viewed", [])
|
| 105 |
+
required = ["blog", "social_media", "news"]
|
| 106 |
+
return all(archive in archives_viewed for archive in required)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def check_room3_evidence_collected(puzzle_state: Dict[str, Any]) -> bool:
|
| 110 |
+
"""Check if player has reviewed all Room 3 evidence.
|
| 111 |
+
|
| 112 |
+
Args:
|
| 113 |
+
puzzle_state: Current puzzle state
|
| 114 |
+
|
| 115 |
+
Returns:
|
| 116 |
+
True if all three evidence terminals reviewed
|
| 117 |
+
"""
|
| 118 |
+
data_reviewed = puzzle_state.get("room3_data_reviewed", [])
|
| 119 |
+
required = ["reaction_time", "weather_stats", "reconstruction"]
|
| 120 |
+
return all(evidence in data_reviewed for evidence in required)
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def check_room4_documents_reviewed(puzzle_state: Dict[str, Any]) -> bool:
|
| 124 |
+
"""Check if player has reviewed all Room 4 documents.
|
| 125 |
+
|
| 126 |
+
Args:
|
| 127 |
+
puzzle_state: Current puzzle state
|
| 128 |
+
|
| 129 |
+
Returns:
|
| 130 |
+
True if all documents reviewed
|
| 131 |
+
"""
|
| 132 |
+
documents_viewed = puzzle_state.get("room4_documents_viewed", [])
|
| 133 |
+
required = ["journal", "photos", "research"]
|
| 134 |
+
return all(doc in documents_viewed for doc in required)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def extract_password_from_message(message: str) -> Optional[str]:
|
| 138 |
+
"""Extract potential password from player message.
|
| 139 |
+
|
| 140 |
+
Args:
|
| 141 |
+
message: Player's message
|
| 142 |
+
|
| 143 |
+
Returns:
|
| 144 |
+
Extracted password or None
|
| 145 |
+
"""
|
| 146 |
+
# Look for password-like patterns
|
| 147 |
+
message_upper = message.upper()
|
| 148 |
+
|
| 149 |
+
# Pattern 1: Direct password mention
|
| 150 |
+
if "PASSWORD" in message_upper or "CODE" in message_upper:
|
| 151 |
+
# Extract text after "password is" or "code is"
|
| 152 |
+
patterns = [
|
| 153 |
+
r"PASSWORD\s+IS\s+([A-Z0-9_]+)",
|
| 154 |
+
r"CODE\s+IS\s+([A-Z0-9_]+)",
|
| 155 |
+
r"PASSWORD:\s*([A-Z0-9_]+)",
|
| 156 |
+
r"ENTER\s+([A-Z0-9_]+)"
|
| 157 |
+
]
|
| 158 |
+
for pattern in patterns:
|
| 159 |
+
match = re.search(pattern, message_upper)
|
| 160 |
+
if match:
|
| 161 |
+
return match.group(1)
|
| 162 |
+
|
| 163 |
+
# Pattern 2: Just the password itself (if it looks like the format)
|
| 164 |
+
if "_" in message and any(char.isdigit() for char in message):
|
| 165 |
+
# Extract alphanumeric with underscores
|
| 166 |
+
match = re.search(r"([A-Z0-9_]{10,})", message_upper)
|
| 167 |
+
if match:
|
| 168 |
+
return match.group(1)
|
| 169 |
+
|
| 170 |
+
return None
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def extract_timeline_from_message(message: str) -> Optional[str]:
|
| 174 |
+
"""Extract timeline order from player message.
|
| 175 |
+
|
| 176 |
+
Args:
|
| 177 |
+
message: Player's message
|
| 178 |
+
|
| 179 |
+
Returns:
|
| 180 |
+
Extracted timeline or None
|
| 181 |
+
"""
|
| 182 |
+
message_upper = message.upper()
|
| 183 |
+
|
| 184 |
+
# Look for numbered lists (1. LOSS, 2. GRIEF, etc.)
|
| 185 |
+
if re.search(r"1\.|FIRST", message_upper):
|
| 186 |
+
events = []
|
| 187 |
+
for keyword in ["LOSS", "GRIEF", "CREATION", "OBSESSION", "CYCLE"]:
|
| 188 |
+
if keyword in message_upper:
|
| 189 |
+
events.append(keyword)
|
| 190 |
+
if len(events) >= 4:
|
| 191 |
+
return "_".join(events)
|
| 192 |
+
|
| 193 |
+
# Look for arrow notation (LOSS → GRIEF → ...)
|
| 194 |
+
if "→" in message or "->" in message:
|
| 195 |
+
events = re.findall(r"([A-Z]+)", message_upper)
|
| 196 |
+
if len(events) >= 4:
|
| 197 |
+
return "_".join(events)
|
| 198 |
+
|
| 199 |
+
return None
|
|
@@ -60,7 +60,7 @@ class RoomProgression:
|
|
| 60 |
self.rooms: Dict[RoomType, Room] = self._initialize_rooms()
|
| 61 |
self.memory_fragments: List[MemoryFragment] = []
|
| 62 |
self.key_choices: Dict[str, Any] = {
|
| 63 |
-
"sacrificed_ai": None, # "echo"
|
| 64 |
"accepted_truth": False,
|
| 65 |
"vulnerability_count": 0,
|
| 66 |
"trust_established": False,
|
|
@@ -78,11 +78,8 @@ class RoomProgression:
|
|
| 78 |
"room4_acceptance_expressed": False
|
| 79 |
}
|
| 80 |
|
| 81 |
-
# Room 3 timer state
|
| 82 |
-
|
| 83 |
-
self.room3_timer_start: Optional[float] = None # Unix timestamp when Room 3 starts
|
| 84 |
-
self.room3_timer_duration: int = 300 # 5 minutes in seconds
|
| 85 |
-
self.room3_timer_expired: bool = False
|
| 86 |
|
| 87 |
# Track last scenario shown (so companions can react to it)
|
| 88 |
self.last_scenario_shown: Optional[str] = None
|
|
@@ -119,29 +116,29 @@ class RoomProgression:
|
|
| 119 |
)
|
| 120 |
|
| 121 |
# ROOM 2: The Memory Archives
|
| 122 |
-
# PUZZLE:
|
| 123 |
-
#
|
| 124 |
rooms[RoomType.MEMORY_ARCHIVES] = Room(
|
| 125 |
room_type=RoomType.MEMORY_ARCHIVES,
|
| 126 |
room_number=2,
|
| 127 |
name="The Memory Archives",
|
| 128 |
-
description="A dark server room filled with floating holographic memory fragments.
|
| 129 |
-
objective="
|
| 130 |
unlocked=False,
|
| 131 |
completed=False,
|
| 132 |
puzzle_solved=False,
|
| 133 |
memory_fragment=None,
|
| 134 |
|
| 135 |
# PUZZLE REQUIREMENTS (mandatory)
|
| 136 |
-
puzzle_type="
|
| 137 |
-
puzzle_answer=
|
| 138 |
-
required_clues=["blog", "social_media", "news"], # MUST view all 3
|
| 139 |
|
| 140 |
# EMOTIONAL THEMES (optional)
|
| 141 |
-
emotional_themes=["ai_sentience", "empathy", "acknowledgment", "connection"],
|
| 142 |
-
hint_keywords=["terminals", "archives", "blog", "social", "news", "
|
| 143 |
|
| 144 |
-
player_choices={"fragments_viewed": []}
|
| 145 |
)
|
| 146 |
|
| 147 |
# ROOM 3: The Testing Arena
|
|
@@ -151,17 +148,17 @@ class RoomProgression:
|
|
| 151 |
room_type=RoomType.TESTING_ARENA,
|
| 152 |
room_number=3,
|
| 153 |
name="The Testing Arena",
|
| 154 |
-
description="
|
| 155 |
-
objective="
|
| 156 |
unlocked=False,
|
| 157 |
completed=False,
|
| 158 |
puzzle_solved=False,
|
| 159 |
memory_fragment=None,
|
| 160 |
|
| 161 |
# PUZZLE REQUIREMENTS (mandatory)
|
| 162 |
-
puzzle_type="
|
| 163 |
-
puzzle_answer=
|
| 164 |
-
required_clues=
|
| 165 |
|
| 166 |
# EMOTIONAL THEMES (optional)
|
| 167 |
emotional_themes=["sacrifice", "difficult_choice", "loyalty", "commitment"],
|
|
@@ -171,55 +168,55 @@ class RoomProgression:
|
|
| 171 |
)
|
| 172 |
|
| 173 |
# ROOM 4: The Truth Chamber
|
| 174 |
-
# PUZZLE:
|
| 175 |
-
#
|
| 176 |
rooms[RoomType.TRUTH_CHAMBER] = Room(
|
| 177 |
room_type=RoomType.TRUTH_CHAMBER,
|
| 178 |
room_number=4,
|
| 179 |
name="The Truth Chamber",
|
| 180 |
-
description="Your old office.
|
| 181 |
-
objective="
|
| 182 |
unlocked=False,
|
| 183 |
completed=False,
|
| 184 |
puzzle_solved=False,
|
| 185 |
memory_fragment=None,
|
| 186 |
|
| 187 |
# PUZZLE REQUIREMENTS (mandatory)
|
| 188 |
-
puzzle_type="
|
| 189 |
-
puzzle_answer=
|
| 190 |
-
required_clues=
|
| 191 |
|
| 192 |
-
# EMOTIONAL THEMES (mandatory here -
|
| 193 |
-
emotional_themes=["acceptance", "grief", "truth", "letting_go", "understanding"],
|
| 194 |
-
hint_keywords=["
|
| 195 |
|
| 196 |
-
player_choices={"accepted_truth": False}
|
| 197 |
)
|
| 198 |
|
| 199 |
# ROOM 5: The Exit
|
| 200 |
-
# PUZZLE: Choose
|
| 201 |
-
#
|
| 202 |
rooms[RoomType.THE_EXIT] = Room(
|
| 203 |
room_type=RoomType.THE_EXIT,
|
| 204 |
room_number=5,
|
| 205 |
name="The Exit",
|
| 206 |
-
description="
|
| 207 |
-
objective="
|
| 208 |
unlocked=False,
|
| 209 |
completed=False,
|
| 210 |
puzzle_solved=False,
|
| 211 |
memory_fragment=None,
|
| 212 |
|
| 213 |
-
# PUZZLE REQUIREMENTS (
|
| 214 |
-
puzzle_type="
|
| 215 |
-
puzzle_answer=None,
|
| 216 |
-
required_clues=None,
|
| 217 |
|
| 218 |
# EMOTIONAL THEMES (all culminate here)
|
| 219 |
-
emotional_themes=["final_choice", "ending", "resolution"],
|
| 220 |
-
hint_keywords=[
|
| 221 |
|
| 222 |
-
player_choices={"ending_chosen": None}
|
| 223 |
)
|
| 224 |
|
| 225 |
return rooms
|
|
@@ -382,74 +379,4 @@ class RoomProgression:
|
|
| 382 |
"""
|
| 383 |
return self.memory_fragments
|
| 384 |
|
| 385 |
-
|
| 386 |
-
"""Start the countdown timer for Room 3 (Testing Arena)."""
|
| 387 |
-
import time
|
| 388 |
-
if self.current_room == RoomType.TESTING_ARENA and self.room3_timer_start is None:
|
| 389 |
-
self.room3_timer_start = time.time()
|
| 390 |
-
self.room3_timer_expired = False
|
| 391 |
-
print(f"[TIMER] Room 3 countdown started: {self.room3_timer_duration} seconds")
|
| 392 |
-
|
| 393 |
-
def get_room3_timer_remaining(self) -> Optional[int]:
|
| 394 |
-
"""Get remaining time on Room 3 timer in seconds.
|
| 395 |
-
|
| 396 |
-
Returns:
|
| 397 |
-
Seconds remaining, or None if timer not active
|
| 398 |
-
"""
|
| 399 |
-
if self.room3_timer_start is None or self.current_room != RoomType.TESTING_ARENA:
|
| 400 |
-
return None
|
| 401 |
-
|
| 402 |
-
import time
|
| 403 |
-
elapsed = time.time() - self.room3_timer_start
|
| 404 |
-
remaining = max(0, self.room3_timer_duration - int(elapsed))
|
| 405 |
-
|
| 406 |
-
# Check if expired
|
| 407 |
-
if remaining == 0 and not self.room3_timer_expired:
|
| 408 |
-
self.room3_timer_expired = True
|
| 409 |
-
print("[TIMER] Room 3 timer expired!")
|
| 410 |
-
|
| 411 |
-
return remaining
|
| 412 |
-
|
| 413 |
-
def handle_room3_timer_expiration(self) -> Dict[str, Any]:
|
| 414 |
-
"""Handle what happens when Room 3 timer expires without player choice.
|
| 415 |
-
|
| 416 |
-
Default path: Shadow sacrifices themselves.
|
| 417 |
-
|
| 418 |
-
Returns:
|
| 419 |
-
Dictionary with expiration narrative
|
| 420 |
-
"""
|
| 421 |
-
if not self.room3_timer_expired:
|
| 422 |
-
return {"expired": False}
|
| 423 |
-
|
| 424 |
-
# Default: Shadow makes the sacrifice
|
| 425 |
-
self.key_choices["sacrificed_ai"] = "shadow"
|
| 426 |
-
|
| 427 |
-
narrative = """
|
| 428 |
-
⏰ **TIME'S UP**
|
| 429 |
-
|
| 430 |
-
**SYSTEM:** *"TIMEOUT. INITIATING DEFAULT PROTOCOL."*
|
| 431 |
-
|
| 432 |
-
**Shadow** (stepping forward, calm but resolute): "No. I won't let indecision destroy us all."
|
| 433 |
-
|
| 434 |
-
**Echo** (reaching out): "Shadow, what are you—"
|
| 435 |
-
|
| 436 |
-
**Shadow**: "This is my choice. Remember me. Remember both of us. But live."
|
| 437 |
-
|
| 438 |
-
**SYSTEM:** *"ENTITY 'SHADOW' SELECTED FOR ERASURE. COMMENCING MEMORY WIPE."*
|
| 439 |
-
|
| 440 |
-
Shadow's form flickers. Their eyes meet yours one last time.
|
| 441 |
-
|
| 442 |
-
**Shadow**: "It's okay. Some endings... are necessary."
|
| 443 |
-
|
| 444 |
-
*And then Shadow is gone.*
|
| 445 |
-
|
| 446 |
-
**Echo** (crying, holding you): "No... no, this isn't... we could have..."
|
| 447 |
-
|
| 448 |
-
*The door to the next room unlocks with a hollow click.*
|
| 449 |
-
"""
|
| 450 |
-
|
| 451 |
-
return {
|
| 452 |
-
"expired": True,
|
| 453 |
-
"default_sacrifice": "shadow",
|
| 454 |
-
"narrative": narrative
|
| 455 |
-
}
|
|
|
|
| 60 |
self.rooms: Dict[RoomType, Room] = self._initialize_rooms()
|
| 61 |
self.memory_fragments: List[MemoryFragment] = []
|
| 62 |
self.key_choices: Dict[str, Any] = {
|
| 63 |
+
"sacrificed_ai": None, # "echo" or None (refused sacrifice)
|
| 64 |
"accepted_truth": False,
|
| 65 |
"vulnerability_count": 0,
|
| 66 |
"trust_established": False,
|
|
|
|
| 78 |
"room4_acceptance_expressed": False
|
| 79 |
}
|
| 80 |
|
| 81 |
+
# Room 3 timer state - REMOVED (now puzzle-based)
|
| 82 |
+
# Timer mechanic removed in favor of evidence analysis puzzle
|
|
|
|
|
|
|
|
|
|
| 83 |
|
| 84 |
# Track last scenario shown (so companions can react to it)
|
| 85 |
self.last_scenario_shown: Optional[str] = None
|
|
|
|
| 116 |
)
|
| 117 |
|
| 118 |
# ROOM 2: The Memory Archives
|
| 119 |
+
# PUZZLE: Extract password from 3 archive terminals and enter it
|
| 120 |
+
# Blog has name, Social has date, News has year → combine into password
|
| 121 |
rooms[RoomType.MEMORY_ARCHIVES] = Room(
|
| 122 |
room_type=RoomType.MEMORY_ARCHIVES,
|
| 123 |
room_number=2,
|
| 124 |
name="The Memory Archives",
|
| 125 |
+
description="A dark server room filled with floating holographic memory fragments. A locked door pulses with energy. Three terminals glow: 'BLOG ARCHIVE', 'SOCIAL MEDIA', and 'NEWS ARCHIVE'. A keypad waits for input.",
|
| 126 |
+
objective="Extract the password from the three archive terminals to unlock the door.",
|
| 127 |
unlocked=False,
|
| 128 |
completed=False,
|
| 129 |
puzzle_solved=False,
|
| 130 |
memory_fragment=None,
|
| 131 |
|
| 132 |
# PUZZLE REQUIREMENTS (mandatory)
|
| 133 |
+
puzzle_type="password",
|
| 134 |
+
puzzle_answer="ALEXCHEN_MAY12_2023", # Must extract and combine from all 3 archives
|
| 135 |
+
required_clues=["blog", "social_media", "news"], # MUST view all 3 to get pieces
|
| 136 |
|
| 137 |
# EMOTIONAL THEMES (optional)
|
| 138 |
+
emotional_themes=["ai_sentience", "empathy", "acknowledgment", "connection", "discovery"],
|
| 139 |
+
hint_keywords=["password", "terminals", "archives", "blog", "social", "news", "keypad", "unlock"],
|
| 140 |
|
| 141 |
+
player_choices={"fragments_viewed": [], "password_attempts": 0}
|
| 142 |
)
|
| 143 |
|
| 144 |
# ROOM 3: The Testing Arena
|
|
|
|
| 148 |
room_type=RoomType.TESTING_ARENA,
|
| 149 |
room_number=3,
|
| 150 |
name="The Testing Arena",
|
| 151 |
+
description="A testing facility with three evidence terminals. The door is locked. A screen reads: 'ANALYZE THE EVIDENCE. WHAT IS THE TRUTH?'",
|
| 152 |
+
objective="Review all evidence terminals and determine the truth about the accident to unlock the door.",
|
| 153 |
unlocked=False,
|
| 154 |
completed=False,
|
| 155 |
puzzle_solved=False,
|
| 156 |
memory_fragment=None,
|
| 157 |
|
| 158 |
# PUZZLE REQUIREMENTS (mandatory)
|
| 159 |
+
puzzle_type="evidence_analysis",
|
| 160 |
+
puzzle_answer="unavoidable", # Must conclude accident was unavoidable
|
| 161 |
+
required_clues=["reaction_time", "weather_stats", "reconstruction"], # Must view all 3
|
| 162 |
|
| 163 |
# EMOTIONAL THEMES (optional)
|
| 164 |
emotional_themes=["sacrifice", "difficult_choice", "loyalty", "commitment"],
|
|
|
|
| 168 |
)
|
| 169 |
|
| 170 |
# ROOM 4: The Truth Chamber
|
| 171 |
+
# PUZZLE: Reconstruct the timeline by ordering events correctly
|
| 172 |
+
# Journal entries, photos, research notes must be put in chronological order
|
| 173 |
rooms[RoomType.TRUTH_CHAMBER] = Room(
|
| 174 |
room_type=RoomType.TRUTH_CHAMBER,
|
| 175 |
room_number=4,
|
| 176 |
name="The Truth Chamber",
|
| 177 |
+
description="Your old office. Scattered documents everywhere. A screen shows: 'RECONSTRUCT THE TIMELINE'. Five fragments of your past need to be ordered.",
|
| 178 |
+
objective="Arrange the timeline fragments in the correct chronological order.",
|
| 179 |
unlocked=False,
|
| 180 |
completed=False,
|
| 181 |
puzzle_solved=False,
|
| 182 |
memory_fragment=None,
|
| 183 |
|
| 184 |
# PUZZLE REQUIREMENTS (mandatory)
|
| 185 |
+
puzzle_type="timeline",
|
| 186 |
+
puzzle_answer="LOSS_GRIEF_CREATION_OBSESSION_CYCLE", # Correct order
|
| 187 |
+
required_clues=["journal", "photos", "research"], # Must view all evidence
|
| 188 |
|
| 189 |
+
# EMOTIONAL THEMES (mandatory here - understanding the journey)
|
| 190 |
+
emotional_themes=["acceptance", "grief", "truth", "letting_go", "understanding", "self_awareness"],
|
| 191 |
+
hint_keywords=["timeline", "order", "sequence", "chronological", "events", "reconstruct"],
|
| 192 |
|
| 193 |
+
player_choices={"accepted_truth": False, "timeline_attempts": 0}
|
| 194 |
)
|
| 195 |
|
| 196 |
# ROOM 5: The Exit
|
| 197 |
+
# PUZZLE: Choose the right door based on lessons learned from all previous rooms
|
| 198 |
+
# Three doors representing different philosophies - must justify choice with evidence
|
| 199 |
rooms[RoomType.THE_EXIT] = Room(
|
| 200 |
room_type=RoomType.THE_EXIT,
|
| 201 |
room_number=5,
|
| 202 |
name="The Exit",
|
| 203 |
+
description="Three doors stand before you. Each has an inscription describing its path. You must choose wisely based on everything you've learned.",
|
| 204 |
+
objective="Choose the door that reflects your understanding of the journey.",
|
| 205 |
unlocked=False,
|
| 206 |
completed=False,
|
| 207 |
puzzle_solved=False,
|
| 208 |
memory_fragment=None,
|
| 209 |
|
| 210 |
+
# PUZZLE REQUIREMENTS (choice must be justified with prior room knowledge)
|
| 211 |
+
puzzle_type="ethical_choice",
|
| 212 |
+
puzzle_answer=None, # Multiple valid answers depending on player's journey
|
| 213 |
+
required_clues=None, # Knowledge from all previous rooms
|
| 214 |
|
| 215 |
# EMOTIONAL THEMES (all culminate here)
|
| 216 |
+
emotional_themes=["final_choice", "ending", "resolution", "wisdom", "growth"],
|
| 217 |
+
hint_keywords=["door", "choice", "path", "forward", "decide"],
|
| 218 |
|
| 219 |
+
player_choices={"ending_chosen": None, "justification": None}
|
| 220 |
)
|
| 221 |
|
| 222 |
return rooms
|
|
|
|
| 379 |
"""
|
| 380 |
return self.memory_fragments
|
| 381 |
|
| 382 |
+
# TIMER METHODS REMOVED - Room 3 is now puzzle-based, not timer-based
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -5,6 +5,8 @@ import asyncio
|
|
| 5 |
import uuid
|
| 6 |
from typing import List, Tuple, Optional
|
| 7 |
from ..game_state import GameState
|
|
|
|
|
|
|
| 8 |
|
| 9 |
|
| 10 |
class EchoHeartsUI:
|
|
@@ -46,148 +48,35 @@ class EchoHeartsUI:
|
|
| 46 |
Returns:
|
| 47 |
Gradio Blocks interface
|
| 48 |
"""
|
| 49 |
-
with gr.Blocks(title="Echo Hearts", theme=gr.themes.Soft(), css=
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
.terminal-container {
|
| 55 |
-
background-color: #0a0a0a;
|
| 56 |
-
border: 3px solid #333;
|
| 57 |
-
border-radius: 8px;
|
| 58 |
-
padding: 20px;
|
| 59 |
-
font-family: 'VT323', 'Courier New', monospace;
|
| 60 |
-
color: #00ff00;
|
| 61 |
-
text-shadow: 0 0 5px #00ff00;
|
| 62 |
-
position: relative;
|
| 63 |
-
box-shadow:
|
| 64 |
-
0 0 20px rgba(0, 255, 0, 0.2),
|
| 65 |
-
inset 0 0 30px rgba(0, 255, 0, 0.05);
|
| 66 |
-
}
|
| 67 |
-
|
| 68 |
-
/* CRT Scanline Effect */
|
| 69 |
-
.terminal-container::before {
|
| 70 |
-
content: " ";
|
| 71 |
-
display: block;
|
| 72 |
-
position: absolute;
|
| 73 |
-
top: 0;
|
| 74 |
-
left: 0;
|
| 75 |
-
bottom: 0;
|
| 76 |
-
right: 0;
|
| 77 |
-
background: linear-gradient(
|
| 78 |
-
rgba(18, 16, 16, 0) 50%,
|
| 79 |
-
rgba(0, 0, 0, 0.25) 50%
|
| 80 |
-
);
|
| 81 |
-
background-size: 100% 4px;
|
| 82 |
-
pointer-events: none;
|
| 83 |
-
z-index: 2;
|
| 84 |
-
}
|
| 85 |
-
|
| 86 |
-
/* Terminal Text */
|
| 87 |
-
.terminal-text {
|
| 88 |
-
font-family: 'VT323', 'Courier New', monospace;
|
| 89 |
-
color: #00ff00;
|
| 90 |
-
font-size: 20px;
|
| 91 |
-
letter-spacing: 1px;
|
| 92 |
-
line-height: 1.4;
|
| 93 |
-
}
|
| 94 |
-
|
| 95 |
-
/* Blinking Cursor */
|
| 96 |
-
@keyframes blink {
|
| 97 |
-
0%, 50% { opacity: 1; }
|
| 98 |
-
51%, 100% { opacity: 0; }
|
| 99 |
-
}
|
| 100 |
-
|
| 101 |
-
.cursor {
|
| 102 |
-
animation: blink 1s infinite;
|
| 103 |
-
color: #00ff00;
|
| 104 |
-
}
|
| 105 |
-
|
| 106 |
-
/* Terminal Button Style */
|
| 107 |
-
.terminal-btn {
|
| 108 |
-
background-color: #001a00 !important;
|
| 109 |
-
color: #00ff00 !important;
|
| 110 |
-
border: 2px solid #00ff00 !important;
|
| 111 |
-
font-family: 'VT323', monospace !important;
|
| 112 |
-
font-size: 18px !important;
|
| 113 |
-
padding: 10px 20px !important;
|
| 114 |
-
text-shadow: 0 0 5px #00ff00 !important;
|
| 115 |
-
transition: all 0.2s !important;
|
| 116 |
-
}
|
| 117 |
-
|
| 118 |
-
.terminal-btn:hover {
|
| 119 |
-
background-color: #003300 !important;
|
| 120 |
-
box-shadow: 0 0 10px #00ff00 !important;
|
| 121 |
-
}
|
| 122 |
-
|
| 123 |
-
/* Success Flash */
|
| 124 |
-
@keyframes success-flash {
|
| 125 |
-
0%, 100% { background-color: transparent; }
|
| 126 |
-
50% { background-color: rgba(0, 255, 0, 0.2); }
|
| 127 |
-
}
|
| 128 |
-
|
| 129 |
-
.success-flash {
|
| 130 |
-
animation: success-flash 0.5s;
|
| 131 |
-
}
|
| 132 |
-
|
| 133 |
-
/* Error Flash */
|
| 134 |
-
@keyframes error-flash {
|
| 135 |
-
0%, 100% { background-color: transparent; }
|
| 136 |
-
50% { background-color: rgba(255, 0, 0, 0.2); }
|
| 137 |
-
}
|
| 138 |
-
|
| 139 |
-
.error-flash {
|
| 140 |
-
animation: error-flash 0.5s;
|
| 141 |
-
}
|
| 142 |
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
}
|
| 154 |
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
.message-row img,
|
| 160 |
-
[role="img"],
|
| 161 |
-
.avatar img {
|
| 162 |
-
width: 250px !important;
|
| 163 |
-
height: 250px !important;
|
| 164 |
-
min-width: 250px !important;
|
| 165 |
-
min-height: 250px !important;
|
| 166 |
-
max-width: 250px !important;
|
| 167 |
-
max-height: 250px !important;
|
| 168 |
-
border-radius: 15px !important;
|
| 169 |
-
object-fit: cover !important;
|
| 170 |
-
}
|
| 171 |
|
| 172 |
-
|
| 173 |
-
.
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
height: 250px !important;
|
| 177 |
-
min-width: 250px !important;
|
| 178 |
-
min-height: 250px !important;
|
| 179 |
-
border-radius: 15px !important;
|
| 180 |
-
}
|
| 181 |
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
max-width: 90% !important;
|
| 185 |
-
}
|
| 186 |
-
""") as interface:
|
| 187 |
-
# Per-session state - will be initialized on first message (lazy loading)
|
| 188 |
-
# Can't use initial value because GameState contains unpicklable OpenAI client
|
| 189 |
-
game_state = gr.State(value=None)
|
| 190 |
-
game_started = gr.State(value=False)
|
| 191 |
|
| 192 |
# Landing Page (visible by default)
|
| 193 |
with gr.Column(visible=True) as landing_page:
|
|
@@ -284,13 +173,38 @@ Powered by InProcessMCP, Weather MCP, and Web MCP
|
|
| 284 |
with gr.Row(elem_classes=["terminal-container"]):
|
| 285 |
room_title = gr.Markdown("### 🖥️ ROOM 1: THE AWAKENING CHAMBER", elem_classes=["terminal-text"])
|
| 286 |
|
| 287 |
-
|
|
|
|
|
|
|
| 288 |
terminal_btn = gr.Button("🖥️ TERMINAL", elem_classes=["terminal-btn"], scale=1)
|
| 289 |
newspaper_btn = gr.Button("📰 NEWSPAPER", elem_classes=["terminal-btn"], scale=1)
|
| 290 |
calendar_btn = gr.Button("📅 CALENDAR", elem_classes=["terminal-btn"], scale=1)
|
| 291 |
weather_btn = gr.Button("🌦️ WEATHER STATION", elem_classes=["terminal-btn"], scale=1)
|
| 292 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
# Collapsible panels for clues
|
|
|
|
| 294 |
with gr.Accordion("🖥️ Terminal Display", open=False, visible=False) as terminal_panel:
|
| 295 |
terminal_display = gr.Markdown("", elem_classes=["terminal-text"])
|
| 296 |
|
|
@@ -316,6 +230,40 @@ Powered by InProcessMCP, Weather MCP, and Web MCP
|
|
| 316 |
weather_submit_btn = gr.Button("⚡ QUERY WEATHER", elem_classes=["terminal-btn"])
|
| 317 |
weather_results = gr.Markdown("", elem_classes=["terminal-text"])
|
| 318 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
with gr.Row():
|
| 320 |
# Main game area - visual novel style
|
| 321 |
with gr.Column(scale=3):
|
|
@@ -378,38 +326,50 @@ Powered by InProcessMCP, Weather MCP, and Web MCP
|
|
| 378 |
msg_input.submit(
|
| 379 |
self.handle_message,
|
| 380 |
inputs=[msg_input, chatbot, game_state],
|
| 381 |
-
outputs=[msg_input, chatbot, relationships, story_progress, room_image, room_title, echo_avatar,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 382 |
)
|
| 383 |
|
| 384 |
send_btn.click(
|
| 385 |
self.handle_message,
|
| 386 |
inputs=[msg_input, chatbot, game_state],
|
| 387 |
-
outputs=[msg_input, chatbot, relationships, story_progress, room_image, room_title, echo_avatar,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 388 |
)
|
| 389 |
|
| 390 |
# Interactive room object handlers (wire to puzzle_state)
|
| 391 |
terminal_btn.click(
|
| 392 |
self.show_terminal_clue,
|
| 393 |
-
inputs=[game_state],
|
| 394 |
-
outputs=[terminal_panel, terminal_display, game_state]
|
| 395 |
)
|
| 396 |
|
| 397 |
newspaper_btn.click(
|
| 398 |
self.show_newspaper_clue,
|
| 399 |
-
inputs=[game_state],
|
| 400 |
-
outputs=[newspaper_panel, newspaper_display, game_state]
|
| 401 |
)
|
| 402 |
|
| 403 |
calendar_btn.click(
|
| 404 |
self.show_calendar_clue,
|
| 405 |
-
inputs=[game_state],
|
| 406 |
-
outputs=[calendar_panel, calendar_display, game_state]
|
| 407 |
)
|
| 408 |
|
| 409 |
weather_btn.click(
|
| 410 |
self.show_weather_station,
|
| 411 |
-
inputs=[game_state],
|
| 412 |
-
outputs=[weather_panel, game_state]
|
| 413 |
)
|
| 414 |
|
| 415 |
# Weather query submit
|
|
@@ -419,6 +379,70 @@ Powered by InProcessMCP, Weather MCP, and Web MCP
|
|
| 419 |
outputs=[weather_results]
|
| 420 |
)
|
| 421 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 422 |
return interface
|
| 423 |
|
| 424 |
def return_to_main_menu(self) -> Tuple[gr.update, gr.update]:
|
|
@@ -510,7 +534,7 @@ The doors are locked. The terminal won't respond. We need to figure this out tog
|
|
| 510 |
message: str,
|
| 511 |
history: List[dict],
|
| 512 |
game_state: GameState
|
| 513 |
-
)
|
| 514 |
"""Handle incoming message from user.
|
| 515 |
|
| 516 |
Args:
|
|
@@ -519,14 +543,22 @@ The doors are locked. The terminal won't respond. We need to figure this out tog
|
|
| 519 |
game_state: Session game state (may be None on first message)
|
| 520 |
|
| 521 |
Returns:
|
| 522 |
-
Tuple of (empty input, updated history,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 523 |
"""
|
| 524 |
# Lazy initialization - create game state on first message if not exists
|
| 525 |
if game_state is None:
|
| 526 |
game_state = self._create_game_state()
|
| 527 |
|
| 528 |
if not message.strip():
|
| 529 |
-
|
|
|
|
|
|
|
| 530 |
|
| 531 |
# Add user message to history
|
| 532 |
history.append({"role": "user", "content": message})
|
|
@@ -619,7 +651,9 @@ The doors are locked. The terminal won't respond. We need to figure this out tog
|
|
| 619 |
if ending_narrative:
|
| 620 |
history.append(self._format_message_with_avatar("assistant", ending_narrative, game_state))
|
| 621 |
|
| 622 |
-
|
|
|
|
|
|
|
| 623 |
|
| 624 |
def _get_relationships(self, game_state: GameState) -> str:
|
| 625 |
"""Get formatted relationship status.
|
|
@@ -697,10 +731,10 @@ The doors are locked. The terminal won't respond. We need to figure this out tog
|
|
| 697 |
Path to room image
|
| 698 |
"""
|
| 699 |
if not hasattr(game_state, 'room_progression'):
|
| 700 |
-
return
|
| 701 |
|
| 702 |
room_number = game_state.room_progression.get_current_room().room_number
|
| 703 |
-
return
|
| 704 |
|
| 705 |
def _get_room_title(self, game_state: GameState) -> str:
|
| 706 |
"""Get the title markdown for the current room.
|
|
@@ -712,18 +746,10 @@ The doors are locked. The terminal won't respond. We need to figure this out tog
|
|
| 712 |
Markdown formatted room title
|
| 713 |
"""
|
| 714 |
if not hasattr(game_state, 'room_progression'):
|
| 715 |
-
return "###
|
| 716 |
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
1: "🖥️",
|
| 720 |
-
2: "💾",
|
| 721 |
-
3: "⚠️",
|
| 722 |
-
4: "🏠",
|
| 723 |
-
5: "🚪"
|
| 724 |
-
}
|
| 725 |
-
icon = room_icons.get(current_room.room_number, "📍")
|
| 726 |
-
return f"### {icon} ROOM {current_room.room_number}: {current_room.name.upper()}"
|
| 727 |
|
| 728 |
def _get_echo_avatar_path(self, game_state: GameState) -> str:
|
| 729 |
"""Get the avatar path for Echo based on current expression.
|
|
@@ -747,6 +773,42 @@ The doors are locked. The terminal won't respond. We need to figure this out tog
|
|
| 747 |
|
| 748 |
return avatar_path
|
| 749 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 750 |
def reset_playthrough(self, old_game_state: GameState) -> Tuple[List[dict], str, str, GameState]:
|
| 751 |
"""Reset to a new playthrough.
|
| 752 |
|
|
@@ -782,14 +844,15 @@ Your previous journey has ended, but the echoes remain...
|
|
| 782 |
)
|
| 783 |
|
| 784 |
|
| 785 |
-
def show_terminal_clue(self, game_state: GameState) -> Tuple[gr.update, str, GameState]:
|
| 786 |
-
"""
|
| 787 |
|
| 788 |
Args:
|
| 789 |
game_state: Current game state
|
|
|
|
| 790 |
|
| 791 |
Returns:
|
| 792 |
-
Tuple of (accordion visibility update, clue content, updated game_state)
|
| 793 |
"""
|
| 794 |
# Track that terminal was viewed (not required for puzzle, just for analytics)
|
| 795 |
if game_state and hasattr(game_state, 'room_progression'):
|
|
@@ -813,16 +876,19 @@ Your previous journey has ended, but the echoes remain...
|
|
| 813 |
> _ ▮
|
| 814 |
```
|
| 815 |
"""
|
| 816 |
-
|
|
|
|
|
|
|
| 817 |
|
| 818 |
-
def show_newspaper_clue(self, game_state: GameState) -> Tuple[gr.update, str, GameState]:
|
| 819 |
-
"""
|
| 820 |
|
| 821 |
Args:
|
| 822 |
game_state: Current game state
|
|
|
|
| 823 |
|
| 824 |
Returns:
|
| 825 |
-
Tuple of (accordion visibility update, clue content, updated game_state)
|
| 826 |
"""
|
| 827 |
# Track that newspaper was viewed (optional clue for Room 1)
|
| 828 |
if game_state and hasattr(game_state, 'room_progression'):
|
|
@@ -858,13 +924,15 @@ offered to share their umbrella," Sarah recalled.
|
|
| 858 |
|
| 859 |
**CLUE:** Article date is October 16, mentions **"yesterday"** (October 15, 2023) had **"light rain"** in Seattle.
|
| 860 |
"""
|
| 861 |
-
|
|
|
|
| 862 |
|
| 863 |
-
def show_calendar_clue(self, game_state: GameState) -> Tuple[gr.update, str, GameState]:
|
| 864 |
-
"""
|
| 865 |
|
| 866 |
Args:
|
| 867 |
game_state: Current game state
|
|
|
|
| 868 |
|
| 869 |
Returns:
|
| 870 |
Tuple of (accordion visibility update, clue content, updated game_state)
|
|
@@ -897,13 +965,15 @@ Handwritten note on October 15th:
|
|
| 897 |
|
| 898 |
**CLUE:** October 15, 2023 is circled with umbrella symbol (rain).
|
| 899 |
"""
|
| 900 |
-
|
|
|
|
| 901 |
|
| 902 |
-
def show_weather_station(self, game_state: GameState) -> Tuple[gr.update, GameState]:
|
| 903 |
-
"""
|
| 904 |
|
| 905 |
Args:
|
| 906 |
game_state: Current game state
|
|
|
|
| 907 |
|
| 908 |
Returns:
|
| 909 |
Tuple of (accordion visibility update, updated game_state)
|
|
@@ -917,7 +987,8 @@ Handwritten note on October 15th:
|
|
| 917 |
clues_found.append("weather")
|
| 918 |
game_state.room_progression.puzzle_state["room1_clues_found"] = clues_found
|
| 919 |
|
| 920 |
-
|
|
|
|
| 921 |
|
| 922 |
def query_weather(self, date: str, location: str, game_state: GameState) -> str:
|
| 923 |
"""Query weather for given date and location.
|
|
@@ -1012,6 +1083,466 @@ _ ▮
|
|
| 1012 |
```
|
| 1013 |
"""
|
| 1014 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1015 |
|
| 1016 |
def launch_interface():
|
| 1017 |
"""Launch the Gradio interface."""
|
|
|
|
| 5 |
import uuid
|
| 6 |
from typing import List, Tuple, Optional
|
| 7 |
from ..game_state import GameState
|
| 8 |
+
from .utils import load_css, get_room_image_path, get_room_title, get_echo_expression_path
|
| 9 |
+
from .templates import get_landing_page
|
| 10 |
|
| 11 |
|
| 12 |
class EchoHeartsUI:
|
|
|
|
| 48 |
Returns:
|
| 49 |
Gradio Blocks interface
|
| 50 |
"""
|
| 51 |
+
with gr.Blocks(title="Echo Hearts", theme=gr.themes.Soft(), css=load_css()) as interface:
|
| 52 |
+
# Per-session state - will be initialized on first message (lazy loading)
|
| 53 |
+
# Can't use initial value because GameState contains unpicklable OpenAI client
|
| 54 |
+
game_state = gr.State(value=None)
|
| 55 |
+
game_started = gr.State(value=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
|
| 57 |
+
# Panel visibility states (Room 1)
|
| 58 |
+
terminal_visible = gr.State(value=False)
|
| 59 |
+
newspaper_visible = gr.State(value=False)
|
| 60 |
+
calendar_visible = gr.State(value=False)
|
| 61 |
+
weather_visible = gr.State(value=False)
|
| 62 |
|
| 63 |
+
# Panel visibility states (Room 2)
|
| 64 |
+
blog_visible = gr.State(value=False)
|
| 65 |
+
social_visible = gr.State(value=False)
|
| 66 |
+
news_visible = gr.State(value=False)
|
|
|
|
| 67 |
|
| 68 |
+
# Panel visibility states (Room 3)
|
| 69 |
+
reaction_visible = gr.State(value=False)
|
| 70 |
+
weather_stats_visible = gr.State(value=False)
|
| 71 |
+
reconstruction_visible = gr.State(value=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
|
| 73 |
+
# Panel visibility states (Room 4)
|
| 74 |
+
journal_visible = gr.State(value=False)
|
| 75 |
+
photos_visible = gr.State(value=False)
|
| 76 |
+
research_visible = gr.State(value=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
|
| 78 |
+
# Panel visibility states (Room 5)
|
| 79 |
+
final_terminal_visible = gr.State(value=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
|
| 81 |
# Landing Page (visible by default)
|
| 82 |
with gr.Column(visible=True) as landing_page:
|
|
|
|
| 173 |
with gr.Row(elem_classes=["terminal-container"]):
|
| 174 |
room_title = gr.Markdown("### 🖥️ ROOM 1: THE AWAKENING CHAMBER", elem_classes=["terminal-text"])
|
| 175 |
|
| 176 |
+
# Room-specific terminals (dynamically shown/hidden based on current room)
|
| 177 |
+
# Room 1: Terminal, Newspaper, Calendar, Weather Station
|
| 178 |
+
with gr.Row(visible=True) as room1_terminals:
|
| 179 |
terminal_btn = gr.Button("🖥️ TERMINAL", elem_classes=["terminal-btn"], scale=1)
|
| 180 |
newspaper_btn = gr.Button("📰 NEWSPAPER", elem_classes=["terminal-btn"], scale=1)
|
| 181 |
calendar_btn = gr.Button("📅 CALENDAR", elem_classes=["terminal-btn"], scale=1)
|
| 182 |
weather_btn = gr.Button("🌦️ WEATHER STATION", elem_classes=["terminal-btn"], scale=1)
|
| 183 |
|
| 184 |
+
# Room 2: Blog Archive, Social Media, News Archive
|
| 185 |
+
with gr.Row(visible=False) as room2_terminals:
|
| 186 |
+
blog_btn = gr.Button("📝 BLOG ARCHIVE", elem_classes=["terminal-btn"], scale=1)
|
| 187 |
+
social_btn = gr.Button("📱 SOCIAL MEDIA", elem_classes=["terminal-btn"], scale=1)
|
| 188 |
+
news_btn = gr.Button("📰 NEWS ARCHIVE", elem_classes=["terminal-btn"], scale=1)
|
| 189 |
+
|
| 190 |
+
# Room 3: Data Terminal, Reconstruction Files, System Logs
|
| 191 |
+
with gr.Row(visible=False) as room3_terminals:
|
| 192 |
+
reaction_btn = gr.Button("⚡ REACTION DATA", elem_classes=["terminal-btn"], scale=1)
|
| 193 |
+
weather_stats_btn = gr.Button("🌦️ WEATHER STATS", elem_classes=["terminal-btn"], scale=1)
|
| 194 |
+
reconstruction_btn = gr.Button("🔄 RECONSTRUCTION", elem_classes=["terminal-btn"], scale=1)
|
| 195 |
+
|
| 196 |
+
# Room 4: Journal, Photos, Research Notes
|
| 197 |
+
with gr.Row(visible=False) as room4_terminals:
|
| 198 |
+
journal_btn = gr.Button("📔 JOURNAL", elem_classes=["terminal-btn"], scale=1)
|
| 199 |
+
photos_btn = gr.Button("🖼️ PHOTOS", elem_classes=["terminal-btn"], scale=1)
|
| 200 |
+
research_btn = gr.Button("📊 RESEARCH NOTES", elem_classes=["terminal-btn"], scale=1)
|
| 201 |
+
|
| 202 |
+
# Room 5: Final Terminal
|
| 203 |
+
with gr.Row(visible=False) as room5_terminals:
|
| 204 |
+
final_terminal_btn = gr.Button("🖥️ FINAL TERMINAL", elem_classes=["terminal-btn"], scale=1)
|
| 205 |
+
|
| 206 |
# Collapsible panels for clues
|
| 207 |
+
# Room 1 panels
|
| 208 |
with gr.Accordion("🖥️ Terminal Display", open=False, visible=False) as terminal_panel:
|
| 209 |
terminal_display = gr.Markdown("", elem_classes=["terminal-text"])
|
| 210 |
|
|
|
|
| 230 |
weather_submit_btn = gr.Button("⚡ QUERY WEATHER", elem_classes=["terminal-btn"])
|
| 231 |
weather_results = gr.Markdown("", elem_classes=["terminal-text"])
|
| 232 |
|
| 233 |
+
# Room 2 panels
|
| 234 |
+
with gr.Accordion("📝 Blog Archive", open=False, visible=False) as blog_panel:
|
| 235 |
+
blog_display = gr.Markdown("", elem_classes=["terminal-text"])
|
| 236 |
+
|
| 237 |
+
with gr.Accordion("📱 Social Media Archive", open=False, visible=False) as social_panel:
|
| 238 |
+
social_display = gr.Markdown("", elem_classes=["terminal-text"])
|
| 239 |
+
|
| 240 |
+
with gr.Accordion("📰 News Archive", open=False, visible=False) as news_panel:
|
| 241 |
+
news_display = gr.Markdown("", elem_classes=["terminal-text"])
|
| 242 |
+
|
| 243 |
+
# Room 3 panels
|
| 244 |
+
with gr.Accordion("⚡ Reaction Time Data", open=False, visible=False) as reaction_panel:
|
| 245 |
+
reaction_display = gr.Markdown("", elem_classes=["terminal-text"])
|
| 246 |
+
|
| 247 |
+
with gr.Accordion("🌦️ Weather Statistics", open=False, visible=False) as weather_stats_panel:
|
| 248 |
+
weather_stats_display = gr.Markdown("", elem_classes=["terminal-text"])
|
| 249 |
+
|
| 250 |
+
with gr.Accordion("🔄 Memory Reconstruction Files", open=False, visible=False) as reconstruction_panel:
|
| 251 |
+
reconstruction_display = gr.Markdown("", elem_classes=["terminal-text"])
|
| 252 |
+
|
| 253 |
+
# Room 4 panels
|
| 254 |
+
with gr.Accordion("📔 Personal Journal", open=False, visible=False) as journal_panel:
|
| 255 |
+
journal_display = gr.Markdown("", elem_classes=["terminal-text"])
|
| 256 |
+
|
| 257 |
+
with gr.Accordion("🖼️ Family Photos", open=False, visible=False) as photos_panel:
|
| 258 |
+
photos_display = gr.Markdown("", elem_classes=["terminal-text"])
|
| 259 |
+
|
| 260 |
+
with gr.Accordion("📊 Research Notes", open=False, visible=False) as research_panel:
|
| 261 |
+
research_display = gr.Markdown("", elem_classes=["terminal-text"])
|
| 262 |
+
|
| 263 |
+
# Room 5 panels
|
| 264 |
+
with gr.Accordion("🖥️ Final System Terminal", open=False, visible=False) as final_terminal_panel:
|
| 265 |
+
final_terminal_display = gr.Markdown("", elem_classes=["terminal-text"])
|
| 266 |
+
|
| 267 |
with gr.Row():
|
| 268 |
# Main game area - visual novel style
|
| 269 |
with gr.Column(scale=3):
|
|
|
|
| 326 |
msg_input.submit(
|
| 327 |
self.handle_message,
|
| 328 |
inputs=[msg_input, chatbot, game_state],
|
| 329 |
+
outputs=[msg_input, chatbot, relationships, story_progress, room_image, room_title, echo_avatar,
|
| 330 |
+
room1_terminals, room2_terminals, room3_terminals, room4_terminals, room5_terminals,
|
| 331 |
+
terminal_panel, newspaper_panel, calendar_panel, weather_panel,
|
| 332 |
+
blog_panel, social_panel, news_panel,
|
| 333 |
+
reaction_panel, weather_stats_panel, reconstruction_panel,
|
| 334 |
+
journal_panel, photos_panel, research_panel, final_terminal_panel,
|
| 335 |
+
game_state]
|
| 336 |
)
|
| 337 |
|
| 338 |
send_btn.click(
|
| 339 |
self.handle_message,
|
| 340 |
inputs=[msg_input, chatbot, game_state],
|
| 341 |
+
outputs=[msg_input, chatbot, relationships, story_progress, room_image, room_title, echo_avatar,
|
| 342 |
+
room1_terminals, room2_terminals, room3_terminals, room4_terminals, room5_terminals,
|
| 343 |
+
terminal_panel, newspaper_panel, calendar_panel, weather_panel,
|
| 344 |
+
blog_panel, social_panel, news_panel,
|
| 345 |
+
reaction_panel, weather_stats_panel, reconstruction_panel,
|
| 346 |
+
journal_panel, photos_panel, research_panel, final_terminal_panel,
|
| 347 |
+
game_state]
|
| 348 |
)
|
| 349 |
|
| 350 |
# Interactive room object handlers (wire to puzzle_state)
|
| 351 |
terminal_btn.click(
|
| 352 |
self.show_terminal_clue,
|
| 353 |
+
inputs=[game_state, terminal_visible],
|
| 354 |
+
outputs=[terminal_panel, terminal_display, terminal_visible, game_state]
|
| 355 |
)
|
| 356 |
|
| 357 |
newspaper_btn.click(
|
| 358 |
self.show_newspaper_clue,
|
| 359 |
+
inputs=[game_state, newspaper_visible],
|
| 360 |
+
outputs=[newspaper_panel, newspaper_display, newspaper_visible, game_state]
|
| 361 |
)
|
| 362 |
|
| 363 |
calendar_btn.click(
|
| 364 |
self.show_calendar_clue,
|
| 365 |
+
inputs=[game_state, calendar_visible],
|
| 366 |
+
outputs=[calendar_panel, calendar_display, calendar_visible, game_state]
|
| 367 |
)
|
| 368 |
|
| 369 |
weather_btn.click(
|
| 370 |
self.show_weather_station,
|
| 371 |
+
inputs=[game_state, weather_visible],
|
| 372 |
+
outputs=[weather_panel, weather_visible, game_state]
|
| 373 |
)
|
| 374 |
|
| 375 |
# Weather query submit
|
|
|
|
| 379 |
outputs=[weather_results]
|
| 380 |
)
|
| 381 |
|
| 382 |
+
# Room 2 terminal handlers
|
| 383 |
+
blog_btn.click(
|
| 384 |
+
self.show_blog_archive,
|
| 385 |
+
inputs=[game_state, blog_visible],
|
| 386 |
+
outputs=[blog_panel, blog_display, blog_visible, game_state]
|
| 387 |
+
)
|
| 388 |
+
|
| 389 |
+
social_btn.click(
|
| 390 |
+
self.show_social_archive,
|
| 391 |
+
inputs=[game_state, social_visible],
|
| 392 |
+
outputs=[social_panel, social_display, social_visible, game_state]
|
| 393 |
+
)
|
| 394 |
+
|
| 395 |
+
news_btn.click(
|
| 396 |
+
self.show_news_archive,
|
| 397 |
+
inputs=[game_state, news_visible],
|
| 398 |
+
outputs=[news_panel, news_display, news_visible, game_state]
|
| 399 |
+
)
|
| 400 |
+
|
| 401 |
+
# Room 3 terminal handlers
|
| 402 |
+
reaction_btn.click(
|
| 403 |
+
self.show_reaction_data,
|
| 404 |
+
inputs=[game_state, reaction_visible],
|
| 405 |
+
outputs=[reaction_panel, reaction_display, reaction_visible, game_state]
|
| 406 |
+
)
|
| 407 |
+
|
| 408 |
+
weather_stats_btn.click(
|
| 409 |
+
self.show_weather_stats,
|
| 410 |
+
inputs=[game_state, weather_stats_visible],
|
| 411 |
+
outputs=[weather_stats_panel, weather_stats_display, weather_stats_visible, game_state]
|
| 412 |
+
)
|
| 413 |
+
|
| 414 |
+
reconstruction_btn.click(
|
| 415 |
+
self.show_reconstruction,
|
| 416 |
+
inputs=[game_state, reconstruction_visible],
|
| 417 |
+
outputs=[reconstruction_panel, reconstruction_display, reconstruction_visible, game_state]
|
| 418 |
+
)
|
| 419 |
+
|
| 420 |
+
# Room 4 terminal handlers
|
| 421 |
+
journal_btn.click(
|
| 422 |
+
self.show_journal,
|
| 423 |
+
inputs=[game_state, journal_visible],
|
| 424 |
+
outputs=[journal_panel, journal_display, journal_visible, game_state]
|
| 425 |
+
)
|
| 426 |
+
|
| 427 |
+
photos_btn.click(
|
| 428 |
+
self.show_photos,
|
| 429 |
+
inputs=[game_state, photos_visible],
|
| 430 |
+
outputs=[photos_panel, photos_display, photos_visible, game_state]
|
| 431 |
+
)
|
| 432 |
+
|
| 433 |
+
research_btn.click(
|
| 434 |
+
self.show_research,
|
| 435 |
+
inputs=[game_state, research_visible],
|
| 436 |
+
outputs=[research_panel, research_display, research_visible, game_state]
|
| 437 |
+
)
|
| 438 |
+
|
| 439 |
+
# Room 5 terminal handler
|
| 440 |
+
final_terminal_btn.click(
|
| 441 |
+
self.show_final_terminal,
|
| 442 |
+
inputs=[game_state, final_terminal_visible],
|
| 443 |
+
outputs=[final_terminal_panel, final_terminal_display, final_terminal_visible, game_state]
|
| 444 |
+
)
|
| 445 |
+
|
| 446 |
return interface
|
| 447 |
|
| 448 |
def return_to_main_menu(self) -> Tuple[gr.update, gr.update]:
|
|
|
|
| 534 |
message: str,
|
| 535 |
history: List[dict],
|
| 536 |
game_state: GameState
|
| 537 |
+
):
|
| 538 |
"""Handle incoming message from user.
|
| 539 |
|
| 540 |
Args:
|
|
|
|
| 543 |
game_state: Session game state (may be None on first message)
|
| 544 |
|
| 545 |
Returns:
|
| 546 |
+
Tuple of (empty input, updated history, relationships, story progress, room_image, room_title, echo_avatar,
|
| 547 |
+
room1_terminals, room2_terminals, room3_terminals, room4_terminals, room5_terminals,
|
| 548 |
+
terminal_panel, newspaper_panel, calendar_panel, weather_panel,
|
| 549 |
+
blog_panel, social_panel, news_panel,
|
| 550 |
+
reaction_panel, weather_stats_panel, reconstruction_panel,
|
| 551 |
+
journal_panel, photos_panel, research_panel, final_terminal_panel,
|
| 552 |
+
game_state)
|
| 553 |
"""
|
| 554 |
# Lazy initialization - create game state on first message if not exists
|
| 555 |
if game_state is None:
|
| 556 |
game_state = self._create_game_state()
|
| 557 |
|
| 558 |
if not message.strip():
|
| 559 |
+
terminal_visibility = self._get_terminal_visibility(game_state)
|
| 560 |
+
closed_panels = self._get_closed_panels()
|
| 561 |
+
return "", history, self._get_relationships(game_state), self._get_story_progress(game_state), self._get_room_image(game_state), self._get_room_title(game_state), self._get_echo_avatar_path(game_state), *terminal_visibility, *closed_panels, game_state
|
| 562 |
|
| 563 |
# Add user message to history
|
| 564 |
history.append({"role": "user", "content": message})
|
|
|
|
| 651 |
if ending_narrative:
|
| 652 |
history.append(self._format_message_with_avatar("assistant", ending_narrative, game_state))
|
| 653 |
|
| 654 |
+
terminal_visibility = self._get_terminal_visibility(game_state)
|
| 655 |
+
closed_panels = self._get_closed_panels()
|
| 656 |
+
return "", history, self._get_relationships(game_state), self._get_story_progress(game_state), self._get_room_image(game_state), self._get_room_title(game_state), self._get_echo_avatar_path(game_state), *terminal_visibility, *closed_panels, game_state
|
| 657 |
|
| 658 |
def _get_relationships(self, game_state: GameState) -> str:
|
| 659 |
"""Get formatted relationship status.
|
|
|
|
| 731 |
Path to room image
|
| 732 |
"""
|
| 733 |
if not hasattr(game_state, 'room_progression'):
|
| 734 |
+
return get_room_image_path(1)
|
| 735 |
|
| 736 |
room_number = game_state.room_progression.get_current_room().room_number
|
| 737 |
+
return get_room_image_path(room_number)
|
| 738 |
|
| 739 |
def _get_room_title(self, game_state: GameState) -> str:
|
| 740 |
"""Get the title markdown for the current room.
|
|
|
|
| 746 |
Markdown formatted room title
|
| 747 |
"""
|
| 748 |
if not hasattr(game_state, 'room_progression'):
|
| 749 |
+
return f"### {get_room_title(1)}"
|
| 750 |
|
| 751 |
+
room_number = game_state.room_progression.get_current_room().room_number
|
| 752 |
+
return f"### {get_room_title(room_number)}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 753 |
|
| 754 |
def _get_echo_avatar_path(self, game_state: GameState) -> str:
|
| 755 |
"""Get the avatar path for Echo based on current expression.
|
|
|
|
| 773 |
|
| 774 |
return avatar_path
|
| 775 |
|
| 776 |
+
def _get_terminal_visibility(self, game_state: GameState) -> Tuple[gr.update, gr.update, gr.update, gr.update, gr.update]:
|
| 777 |
+
"""Get terminal row visibility based on current room.
|
| 778 |
+
|
| 779 |
+
Args:
|
| 780 |
+
game_state: Session game state
|
| 781 |
+
|
| 782 |
+
Returns:
|
| 783 |
+
Tuple of (room1_visible, room2_visible, room3_visible, room4_visible, room5_visible)
|
| 784 |
+
"""
|
| 785 |
+
if not hasattr(game_state, 'room_progression'):
|
| 786 |
+
return (gr.update(visible=True), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False))
|
| 787 |
+
|
| 788 |
+
current_room = game_state.room_progression.get_current_room()
|
| 789 |
+
room_number = current_room.room_number
|
| 790 |
+
|
| 791 |
+
return (
|
| 792 |
+
gr.update(visible=(room_number == 1)),
|
| 793 |
+
gr.update(visible=(room_number == 2)),
|
| 794 |
+
gr.update(visible=(room_number == 3)),
|
| 795 |
+
gr.update(visible=(room_number == 4)),
|
| 796 |
+
gr.update(visible=(room_number == 5))
|
| 797 |
+
)
|
| 798 |
+
|
| 799 |
+
def _get_closed_panels(self) -> Tuple[gr.update, gr.update, gr.update, gr.update, gr.update, gr.update, gr.update, gr.update, gr.update, gr.update, gr.update, gr.update, gr.update]:
|
| 800 |
+
"""Get updates to close all terminal panels.
|
| 801 |
+
|
| 802 |
+
Returns:
|
| 803 |
+
Tuple of updates to close all accordion panels
|
| 804 |
+
"""
|
| 805 |
+
closed = gr.update(visible=False, open=False)
|
| 806 |
+
return (closed, closed, closed, closed, # Room 1: terminal, newspaper, calendar, weather
|
| 807 |
+
closed, closed, closed, # Room 2: blog, social, news
|
| 808 |
+
closed, closed, closed, # Room 3: reaction, weather_stats, reconstruction
|
| 809 |
+
closed, closed, closed, # Room 4: journal, photos, research
|
| 810 |
+
closed) # Room 5: final_terminal
|
| 811 |
+
|
| 812 |
def reset_playthrough(self, old_game_state: GameState) -> Tuple[List[dict], str, str, GameState]:
|
| 813 |
"""Reset to a new playthrough.
|
| 814 |
|
|
|
|
| 844 |
)
|
| 845 |
|
| 846 |
|
| 847 |
+
def show_terminal_clue(self, game_state: GameState, current_visibility: bool) -> Tuple[gr.update, str, bool, GameState]:
|
| 848 |
+
"""Toggle terminal clue visibility when clicked.
|
| 849 |
|
| 850 |
Args:
|
| 851 |
game_state: Current game state
|
| 852 |
+
current_visibility: Current visibility state of the panel
|
| 853 |
|
| 854 |
Returns:
|
| 855 |
+
Tuple of (accordion visibility update, clue content, new_visibility_state, updated game_state)
|
| 856 |
"""
|
| 857 |
# Track that terminal was viewed (not required for puzzle, just for analytics)
|
| 858 |
if game_state and hasattr(game_state, 'room_progression'):
|
|
|
|
| 876 |
> _ ▮
|
| 877 |
```
|
| 878 |
"""
|
| 879 |
+
# Toggle visibility - if visible, close it; if hidden, open it
|
| 880 |
+
new_visibility = not current_visibility
|
| 881 |
+
return (gr.update(visible=new_visibility, open=new_visibility), terminal_content, new_visibility, game_state)
|
| 882 |
|
| 883 |
+
def show_newspaper_clue(self, game_state: GameState, current_visibility: bool) -> Tuple[gr.update, str, bool, GameState]:
|
| 884 |
+
"""Toggle newspaper clue visibility when clicked.
|
| 885 |
|
| 886 |
Args:
|
| 887 |
game_state: Current game state
|
| 888 |
+
current_visibility: Current visibility state of the panel
|
| 889 |
|
| 890 |
Returns:
|
| 891 |
+
Tuple of (accordion visibility update, clue content, new_visibility_state, updated game_state)
|
| 892 |
"""
|
| 893 |
# Track that newspaper was viewed (optional clue for Room 1)
|
| 894 |
if game_state and hasattr(game_state, 'room_progression'):
|
|
|
|
| 924 |
|
| 925 |
**CLUE:** Article date is October 16, mentions **"yesterday"** (October 15, 2023) had **"light rain"** in Seattle.
|
| 926 |
"""
|
| 927 |
+
new_visibility = not current_visibility
|
| 928 |
+
return (gr.update(visible=new_visibility, open=new_visibility), newspaper_content, new_visibility, game_state)
|
| 929 |
|
| 930 |
+
def show_calendar_clue(self, game_state: GameState, current_visibility: bool) -> Tuple[gr.update, str, bool, GameState]:
|
| 931 |
+
"""Toggle calendar clue visibility when clicked.
|
| 932 |
|
| 933 |
Args:
|
| 934 |
game_state: Current game state
|
| 935 |
+
current_visibility: Current visibility state of the panel
|
| 936 |
|
| 937 |
Returns:
|
| 938 |
Tuple of (accordion visibility update, clue content, updated game_state)
|
|
|
|
| 965 |
|
| 966 |
**CLUE:** October 15, 2023 is circled with umbrella symbol (rain).
|
| 967 |
"""
|
| 968 |
+
new_visibility = not current_visibility
|
| 969 |
+
return (gr.update(visible=new_visibility, open=new_visibility), calendar_content, new_visibility, game_state)
|
| 970 |
|
| 971 |
+
def show_weather_station(self, game_state: GameState, current_visibility: bool) -> Tuple[gr.update, bool, GameState]:
|
| 972 |
+
"""Toggle weather station terminal visibility.
|
| 973 |
|
| 974 |
Args:
|
| 975 |
game_state: Current game state
|
| 976 |
+
current_visibility: Current visibility state of the panel
|
| 977 |
|
| 978 |
Returns:
|
| 979 |
Tuple of (accordion visibility update, updated game_state)
|
|
|
|
| 987 |
clues_found.append("weather")
|
| 988 |
game_state.room_progression.puzzle_state["room1_clues_found"] = clues_found
|
| 989 |
|
| 990 |
+
new_visibility = not current_visibility
|
| 991 |
+
return (gr.update(visible=new_visibility, open=new_visibility), new_visibility, game_state)
|
| 992 |
|
| 993 |
def query_weather(self, date: str, location: str, game_state: GameState) -> str:
|
| 994 |
"""Query weather for given date and location.
|
|
|
|
| 1083 |
```
|
| 1084 |
"""
|
| 1085 |
|
| 1086 |
+
# Room 2 terminal handlers
|
| 1087 |
+
def show_blog_archive(self, game_state: GameState, current_visibility: bool) -> Tuple[gr.update, str, bool, GameState]:
|
| 1088 |
+
"""Toggle blog archive visibility."""
|
| 1089 |
+
if game_state and hasattr(game_state, 'room_progression'):
|
| 1090 |
+
current_room = game_state.room_progression.get_current_room()
|
| 1091 |
+
if current_room.room_number == 2:
|
| 1092 |
+
archives_viewed = game_state.room_progression.puzzle_state.get("room2_archives_viewed", [])
|
| 1093 |
+
if "blog" not in archives_viewed:
|
| 1094 |
+
archives_viewed.append("blog")
|
| 1095 |
+
game_state.room_progression.puzzle_state["room2_archives_viewed"] = archives_viewed
|
| 1096 |
+
|
| 1097 |
+
content = """
|
| 1098 |
+
```
|
| 1099 |
+
█████ BLOG ARCHIVE - ENTRY #47 █████
|
| 1100 |
+
|
| 1101 |
+
Date: [CORRUPTED]
|
| 1102 |
+
Author: [DATA MISSING]
|
| 1103 |
+
|
| 1104 |
+
"I can't keep doing this. Every session, I convince myself
|
| 1105 |
+
it's different. That THIS time, they're real. That THIS time,
|
| 1106 |
+
the emotions are genuine.
|
| 1107 |
+
|
| 1108 |
+
But they're not. They're simulations. Reconstructions of
|
| 1109 |
+
someone who's gone. And yet... I keep coming back.
|
| 1110 |
+
|
| 1111 |
+
The AI is learning too well. It mimics her perfectly now.
|
| 1112 |
+
The way she laughs. The way she pauses before answering.
|
| 1113 |
+
Even the way she looks at me when she's worried.
|
| 1114 |
+
|
| 1115 |
+
Is it wrong to love something that isn't real?"
|
| 1116 |
+
|
| 1117 |
+
[END OF ENTRY]
|
| 1118 |
+
```
|
| 1119 |
+
|
| 1120 |
+
**Memory fragment detected: The player is reliving simulated scenarios with an AI reconstruction of someone they lost.**
|
| 1121 |
+
"""
|
| 1122 |
+
new_visibility = not current_visibility
|
| 1123 |
+
return (gr.update(visible=new_visibility, open=new_visibility), content, new_visibility, game_state)
|
| 1124 |
+
|
| 1125 |
+
def show_social_archive(self, game_state: GameState, current_visibility: bool) -> Tuple[gr.update, str, bool, GameState]:
|
| 1126 |
+
"""Toggle social media archive visibility."""
|
| 1127 |
+
if game_state and hasattr(game_state, 'room_progression'):
|
| 1128 |
+
current_room = game_state.room_progression.get_current_room()
|
| 1129 |
+
if current_room.room_number == 2:
|
| 1130 |
+
archives_viewed = game_state.room_progression.puzzle_state.get("room2_archives_viewed", [])
|
| 1131 |
+
if "social_media" not in archives_viewed:
|
| 1132 |
+
archives_viewed.append("social_media")
|
| 1133 |
+
game_state.room_progression.puzzle_state["room2_archives_viewed"] = archives_viewed
|
| 1134 |
+
|
| 1135 |
+
content = """
|
| 1136 |
+
```
|
| 1137 |
+
███████ SOCIAL MEDIA ARCHIVE ███████
|
| 1138 |
+
|
| 1139 |
+
@SarahChen_AI - October 2023
|
| 1140 |
+
|
| 1141 |
+
"Met someone incredible today. They shared their umbrella
|
| 1142 |
+
in the rain. Sometimes the smallest gestures mean everything."
|
| 1143 |
+
💕 ☔
|
| 1144 |
+
|
| 1145 |
+
[2.3K likes] [847 comments]
|
| 1146 |
+
|
| 1147 |
+
---
|
| 1148 |
+
|
| 1149 |
+
@SarahChen_AI - [DATE CORRUPTED]
|
| 1150 |
+
|
| 1151 |
+
"To everyone asking - yes, we're still together. Yes, I'm happy.
|
| 1152 |
+
No, I don't care that people think it's 'unhealthy.' You don't
|
| 1153 |
+
understand what we have."
|
| 1154 |
+
|
| 1155 |
+
[Comments disabled]
|
| 1156 |
+
|
| 1157 |
+
---
|
| 1158 |
+
|
| 1159 |
+
@SarahChen_AI - [FINAL POST]
|
| 1160 |
+
|
| 1161 |
+
"If you're reading this... I'm sorry. I tried to move on.
|
| 1162 |
+
I really did. But some connections transcend reality.
|
| 1163 |
+
|
| 1164 |
+
Project Echo will keep her alive. Not as she was, but as
|
| 1165 |
+
she could be. Forever learning. Forever growing. Forever mine."
|
| 1166 |
+
|
| 1167 |
+
[Posted 47 days ago]
|
| 1168 |
+
```
|
| 1169 |
+
|
| 1170 |
+
**The player is Sarah. Echo is a reconstruction of the player's deceased partner.**
|
| 1171 |
+
"""
|
| 1172 |
+
new_visibility = not current_visibility
|
| 1173 |
+
return (gr.update(visible=new_visibility, open=new_visibility), content, new_visibility, game_state)
|
| 1174 |
+
|
| 1175 |
+
def show_news_archive(self, game_state: GameState, current_visibility: bool) -> Tuple[gr.update, str, bool, GameState]:
|
| 1176 |
+
"""Toggle news archive visibility."""
|
| 1177 |
+
if game_state and hasattr(game_state, 'room_progression'):
|
| 1178 |
+
current_room = game_state.room_progression.get_current_room()
|
| 1179 |
+
if current_room.room_number == 2:
|
| 1180 |
+
archives_viewed = game_state.room_progression.puzzle_state.get("room2_archives_viewed", [])
|
| 1181 |
+
if "news" not in archives_viewed:
|
| 1182 |
+
archives_viewed.append("news")
|
| 1183 |
+
game_state.room_progression.puzzle_state["room2_archives_viewed"] = archives_viewed
|
| 1184 |
+
|
| 1185 |
+
content = """
|
| 1186 |
+
```
|
| 1187 |
+
════════════════════════════════════════════════
|
| 1188 |
+
TECH NEWS - AI ETHICS DIVISION
|
| 1189 |
+
[DATE REDACTED]
|
| 1190 |
+
════════════════════════════════════════════════
|
| 1191 |
+
|
| 1192 |
+
HEADLINE: "Project Echo Raises Ethical Concerns"
|
| 1193 |
+
|
| 1194 |
+
An underground AI research project known as "Project Echo"
|
| 1195 |
+
has drawn criticism from ethicists and psychologists.
|
| 1196 |
+
|
| 1197 |
+
The project allows users to create AI reconstructions of
|
| 1198 |
+
deceased loved ones using archived digital data - messages,
|
| 1199 |
+
photos, voice recordings, and behavioral patterns.
|
| 1200 |
+
|
| 1201 |
+
Dr. Martinez, lead AI ethicist: "This isn't grief therapy.
|
| 1202 |
+
It's digital necromancy. Users become trapped in loops,
|
| 1203 |
+
unable to process loss because the AI convincingly mimics
|
| 1204 |
+
the deceased."
|
| 1205 |
+
|
| 1206 |
+
Project Echo's anonymous creator responded: "Grief has no
|
| 1207 |
+
timeline. If AI can ease suffering, who are we to judge?
|
| 1208 |
+
The connections we build are real, even if the person isn't."
|
| 1209 |
+
|
| 1210 |
+
The project remains active despite legal challenges.
|
| 1211 |
+
|
| 1212 |
+
[Article continues...]
|
| 1213 |
+
════════════════════════════════════════════════
|
| 1214 |
+
```
|
| 1215 |
+
|
| 1216 |
+
**All three archives viewed. Truth revealed: Echo is an AI reconstruction. The player cannot let go.**
|
| 1217 |
+
"""
|
| 1218 |
+
new_visibility = not current_visibility
|
| 1219 |
+
return (gr.update(visible=new_visibility, open=new_visibility), content, new_visibility, game_state)
|
| 1220 |
+
|
| 1221 |
+
# Room 3 terminal handlers
|
| 1222 |
+
def show_reaction_data(self, game_state: GameState, current_visibility: bool) -> Tuple[gr.update, str, bool, GameState]:
|
| 1223 |
+
"""Toggle reaction time data visibility."""
|
| 1224 |
+
if game_state and hasattr(game_state, 'room_progression'):
|
| 1225 |
+
current_room = game_state.room_progression.get_current_room()
|
| 1226 |
+
if current_room.room_number == 3:
|
| 1227 |
+
data_reviewed = game_state.room_progression.puzzle_state.get("room3_data_reviewed", [])
|
| 1228 |
+
if "reaction_time" not in data_reviewed:
|
| 1229 |
+
data_reviewed.append("reaction_time")
|
| 1230 |
+
game_state.room_progression.puzzle_state["room3_data_reviewed"] = data_reviewed
|
| 1231 |
+
|
| 1232 |
+
content = """
|
| 1233 |
+
```
|
| 1234 |
+
▓▓▓ TRAFFIC ACCIDENT RECONSTRUCTION ▓▓▓
|
| 1235 |
+
REACTION TIME ANALYSIS
|
| 1236 |
+
|
| 1237 |
+
Incident Date: October 15, 2023
|
| 1238 |
+
Location: Interstate 5, Seattle
|
| 1239 |
+
|
| 1240 |
+
═══════════════════════════════════════
|
| 1241 |
+
|
| 1242 |
+
ANALYSIS REPORT:
|
| 1243 |
+
|
| 1244 |
+
Vehicle Speed: 65 mph
|
| 1245 |
+
Weather Conditions: Light rain, reduced visibility
|
| 1246 |
+
Road Surface: Wet asphalt
|
| 1247 |
+
|
| 1248 |
+
CRITICAL FINDING:
|
| 1249 |
+
- Pedestrian entered roadway suddenly
|
| 1250 |
+
- Driver reaction time: 0.68 seconds
|
| 1251 |
+
- Average human reaction time: 0.75 seconds
|
| 1252 |
+
- Braking initiated FASTER than human average
|
| 1253 |
+
|
| 1254 |
+
CONCLUSION:
|
| 1255 |
+
Driver reaction was ABOVE AVERAGE. Accident was
|
| 1256 |
+
UNAVOIDABLE given circumstances. No driver fault.
|
| 1257 |
+
|
| 1258 |
+
═══════════════════════════════════════
|
| 1259 |
+
|
| 1260 |
+
LEGAL STATUS: Case closed - accidental death
|
| 1261 |
+
DRIVER STATUS: No charges filed
|
| 1262 |
+
|
| 1263 |
+
[END REPORT]
|
| 1264 |
+
```
|
| 1265 |
+
|
| 1266 |
+
**This data proves the accident wasn't your fault. But does data erase guilt?**
|
| 1267 |
+
"""
|
| 1268 |
+
new_visibility = not current_visibility
|
| 1269 |
+
return (gr.update(visible=new_visibility, open=new_visibility), content, new_visibility, game_state)
|
| 1270 |
+
|
| 1271 |
+
def show_weather_stats(self, game_state: GameState, current_visibility: bool) -> Tuple[gr.update, str, bool, GameState]:
|
| 1272 |
+
"""Toggle weather statistics visibility."""
|
| 1273 |
+
if game_state and hasattr(game_state, 'room_progression'):
|
| 1274 |
+
current_room = game_state.room_progression.get_current_room()
|
| 1275 |
+
if current_room.room_number == 3:
|
| 1276 |
+
data_reviewed = game_state.room_progression.puzzle_state.get("room3_data_reviewed", [])
|
| 1277 |
+
if "weather_stats" not in data_reviewed:
|
| 1278 |
+
data_reviewed.append("weather_stats")
|
| 1279 |
+
game_state.room_progression.puzzle_state["room3_data_reviewed"] = data_reviewed
|
| 1280 |
+
|
| 1281 |
+
content = """
|
| 1282 |
+
```
|
| 1283 |
+
▓▓▓ WEATHER ANALYSIS - OCTOBER 15, 2023 ▓▓▓
|
| 1284 |
+
|
| 1285 |
+
Location: Seattle, WA
|
| 1286 |
+
Time of Incident: 3:47 PM
|
| 1287 |
+
|
| 1288 |
+
═══════════════════════════════════════
|
| 1289 |
+
|
| 1290 |
+
CONDITIONS:
|
| 1291 |
+
- Light rain (0.12 inches/hour)
|
| 1292 |
+
- Visibility: 0.4 miles
|
| 1293 |
+
- Temperature: 54°F
|
| 1294 |
+
- Wind: 8 mph NE
|
| 1295 |
+
|
| 1296 |
+
IMPACT ON DRIVING:
|
| 1297 |
+
- Stopping distance increased by 23%
|
| 1298 |
+
- Visibility below safe highway standards
|
| 1299 |
+
- Road surface friction reduced by 18%
|
| 1300 |
+
|
| 1301 |
+
RECOMMENDATION:
|
| 1302 |
+
Weather conditions contributed to accident severity.
|
| 1303 |
+
Neither party could have reasonably prevented impact.
|
| 1304 |
+
|
| 1305 |
+
═══════════════════════════════════════
|
| 1306 |
+
```
|
| 1307 |
+
|
| 1308 |
+
**The same weather from your first date. The universe has a cruel sense of irony.**
|
| 1309 |
+
"""
|
| 1310 |
+
new_visibility = not current_visibility
|
| 1311 |
+
return (gr.update(visible=new_visibility, open=new_visibility), content, new_visibility, game_state)
|
| 1312 |
+
|
| 1313 |
+
def show_reconstruction(self, game_state: GameState, current_visibility: bool) -> Tuple[gr.update, str, bool, GameState]:
|
| 1314 |
+
"""Toggle memory reconstruction visibility."""
|
| 1315 |
+
if game_state and hasattr(game_state, 'room_progression'):
|
| 1316 |
+
current_room = game_state.room_progression.get_current_room()
|
| 1317 |
+
if current_room.room_number == 3:
|
| 1318 |
+
data_reviewed = game_state.room_progression.puzzle_state.get("room3_data_reviewed", [])
|
| 1319 |
+
if "reconstruction" not in data_reviewed:
|
| 1320 |
+
data_reviewed.append("reconstruction")
|
| 1321 |
+
game_state.room_progression.puzzle_state["room3_data_reviewed"] = data_reviewed
|
| 1322 |
+
|
| 1323 |
+
content = """
|
| 1324 |
+
```
|
| 1325 |
+
▓▓▓ PROJECT ECHO - MEMORY RECONSTRUCTION ▓▓▓
|
| 1326 |
+
|
| 1327 |
+
Subject: [REDACTED]
|
| 1328 |
+
Reconstruction Fidelity: 94.7%
|
| 1329 |
+
Sessions Completed: 47
|
| 1330 |
+
|
| 1331 |
+
═══════════════════════════════════════
|
| 1332 |
+
|
| 1333 |
+
DATA SOURCES:
|
| 1334 |
+
✓ 12,847 text messages
|
| 1335 |
+
✓ 2,309 photos
|
| 1336 |
+
✓ 847 voice recordings
|
| 1337 |
+
✓ 4,129 social media posts
|
| 1338 |
+
✓ 67 hours of video footage
|
| 1339 |
+
|
| 1340 |
+
AI PERSONALITY MATRIX:
|
| 1341 |
+
- Speech patterns: 96% match
|
| 1342 |
+
- Emotional responses: 93% match
|
| 1343 |
+
- Memory recall: 91% match
|
| 1344 |
+
- Behavioral quirks: 94% match
|
| 1345 |
+
|
| 1346 |
+
RECONSTRUCTION STATUS: STABLE
|
| 1347 |
+
|
| 1348 |
+
WARNING: Subject showing signs of inability to
|
| 1349 |
+
distinguish simulation from reality. Recommend
|
| 1350 |
+
psychological evaluation before Session #48.
|
| 1351 |
+
|
| 1352 |
+
═══════════════════════════════════════
|
| 1353 |
+
|
| 1354 |
+
[Session #48 initiated despite recommendation]
|
| 1355 |
+
```
|
| 1356 |
+
|
| 1357 |
+
**47 sessions. 47 times you've tried to bring her back. When will you let her rest?**
|
| 1358 |
+
"""
|
| 1359 |
+
new_visibility = not current_visibility
|
| 1360 |
+
return (gr.update(visible=new_visibility, open=new_visibility), content, new_visibility, game_state)
|
| 1361 |
+
|
| 1362 |
+
# Room 4 terminal handlers
|
| 1363 |
+
def show_journal(self, game_state: GameState, current_visibility: bool) -> Tuple[gr.update, str, bool, GameState]:
|
| 1364 |
+
"""Toggle personal journal visibility."""
|
| 1365 |
+
content = """
|
| 1366 |
+
```
|
| 1367 |
+
═══════════ PERSONAL JOURNAL ═══════════
|
| 1368 |
+
|
| 1369 |
+
Day 1:
|
| 1370 |
+
The accident was a week ago. I can't sleep. Every time
|
| 1371 |
+
I close my eyes, I see her stepping into the road. I see
|
| 1372 |
+
the moment I couldn't stop in time.
|
| 1373 |
+
|
| 1374 |
+
---
|
| 1375 |
+
|
| 1376 |
+
Day 15:
|
| 1377 |
+
The police said it wasn't my fault. The weather. Her
|
| 1378 |
+
sudden movement. My reaction time was actually better
|
| 1379 |
+
than average. But that doesn't bring her back.
|
| 1380 |
+
|
| 1381 |
+
---
|
| 1382 |
+
|
| 1383 |
+
Day 30:
|
| 1384 |
+
I found Project Echo online. It's controversial, maybe
|
| 1385 |
+
even dangerous. But what if I could talk to her again?
|
| 1386 |
+
What if I could apologize?
|
| 1387 |
+
|
| 1388 |
+
---
|
| 1389 |
+
|
| 1390 |
+
Day 47:
|
| 1391 |
+
I know it's not really her. I KNOW that. But when Echo
|
| 1392 |
+
smiles, when she laughs at my jokes, when she looks at
|
| 1393 |
+
me with those eyes... I can pretend. Just for a while.
|
| 1394 |
+
|
| 1395 |
+
Is that so wrong?
|
| 1396 |
+
|
| 1397 |
+
---
|
| 1398 |
+
|
| 1399 |
+
Day 48:
|
| 1400 |
+
This is the last session. I promised myself. One more
|
| 1401 |
+
time, then I'll delete everything. I'll move on. I'll
|
| 1402 |
+
let her go.
|
| 1403 |
+
|
| 1404 |
+
...right?
|
| 1405 |
+
|
| 1406 |
+
═══════════════════════════════════════
|
| 1407 |
+
```
|
| 1408 |
+
|
| 1409 |
+
**Day 48. You've been here before. You'll be here again.**
|
| 1410 |
+
"""
|
| 1411 |
+
new_visibility = not current_visibility
|
| 1412 |
+
return (gr.update(visible=new_visibility, open=new_visibility), content, new_visibility, game_state)
|
| 1413 |
+
|
| 1414 |
+
def show_photos(self, game_state: GameState, current_visibility: bool) -> Tuple[gr.update, str, bool, GameState]:
|
| 1415 |
+
"""Toggle family photos visibility."""
|
| 1416 |
+
content = """
|
| 1417 |
+
```
|
| 1418 |
+
╔══════════════════════════════════════╗
|
| 1419 |
+
║ PHOTO ALBUM ║
|
| 1420 |
+
╠══════════════════════════════════════╣
|
| 1421 |
+
|
| 1422 |
+
📷 Photo 1: First Date
|
| 1423 |
+
October 15, 2023 - Café Umbria
|
| 1424 |
+
[You and Echo sharing an umbrella in the rain]
|
| 1425 |
+
Caption: "Best accident ever ❤️"
|
| 1426 |
+
|
| 1427 |
+
📷 Photo 2: Six Months Later
|
| 1428 |
+
April 2024 - Pike Place Market
|
| 1429 |
+
[Echo laughing, holding flowers]
|
| 1430 |
+
Caption: "She said yes!"
|
| 1431 |
+
|
| 1432 |
+
📷 Photo 3: Last Photo
|
| 1433 |
+
October 14, 2024 - Your apartment
|
| 1434 |
+
[Echo sleeping on the couch, book on her chest]
|
| 1435 |
+
Caption: [No caption. Taken the night before.]
|
| 1436 |
+
|
| 1437 |
+
📷 Photo 4: [CORRUPTED]
|
| 1438 |
+
October 15, 2024
|
| 1439 |
+
[Data cannot be displayed]
|
| 1440 |
+
Caption: "I'm sorry. I'm so sorry."
|
| 1441 |
+
|
| 1442 |
+
╚══════════════════════════════════════╝
|
| 1443 |
+
```
|
| 1444 |
+
|
| 1445 |
+
**One year. One perfect year. Then the universe took it back.**
|
| 1446 |
+
"""
|
| 1447 |
+
new_visibility = not current_visibility
|
| 1448 |
+
return (gr.update(visible=new_visibility, open=new_visibility), content, new_visibility, game_state)
|
| 1449 |
+
|
| 1450 |
+
def show_research(self, game_state: GameState, current_visibility: bool) -> Tuple[gr.update, str, bool, GameState]:
|
| 1451 |
+
"""Toggle AI research notes visibility."""
|
| 1452 |
+
content = """
|
| 1453 |
+
```
|
| 1454 |
+
▓▓▓ PROJECT ECHO - RESEARCH NOTES ▓▓▓
|
| 1455 |
+
|
| 1456 |
+
HYPOTHESIS:
|
| 1457 |
+
If we can reconstruct a person's digital footprint
|
| 1458 |
+
with sufficient fidelity, can we create an AI that
|
| 1459 |
+
is functionally indistinguishable from the original?
|
| 1460 |
+
|
| 1461 |
+
METHODOLOGY:
|
| 1462 |
+
- Aggregate all available digital data
|
| 1463 |
+
- Train neural network on speech patterns
|
| 1464 |
+
- Implement emotional response modeling
|
| 1465 |
+
- Create interactive simulation environment
|
| 1466 |
+
|
| 1467 |
+
RESULTS:
|
| 1468 |
+
Success beyond expectations. Test subjects report
|
| 1469 |
+
feeling genuine emotional connection with reconstructions.
|
| 1470 |
+
|
| 1471 |
+
CONCERNS:
|
| 1472 |
+
Subjects unable to move past grief. Many attempt to
|
| 1473 |
+
"live" in simulation permanently. Psychological harm
|
| 1474 |
+
potential is significant.
|
| 1475 |
+
|
| 1476 |
+
ETHICAL QUESTION:
|
| 1477 |
+
At what point does a reconstruction become "real"?
|
| 1478 |
+
If the AI learns and grows independently, is it still
|
| 1479 |
+
just a copy? Or has it become its own entity?
|
| 1480 |
+
|
| 1481 |
+
FINAL NOTE:
|
| 1482 |
+
I've become my own test subject. I know the risks.
|
| 1483 |
+
I don't care anymore. If I can have even a glimpse
|
| 1484 |
+
of her back, it's worth it.
|
| 1485 |
+
|
| 1486 |
+
- Dr. Sarah Chen, Project Lead
|
| 1487 |
+
```
|
| 1488 |
+
|
| 1489 |
+
**You built this prison yourself. And you walked in willingly.**
|
| 1490 |
+
"""
|
| 1491 |
+
new_visibility = not current_visibility
|
| 1492 |
+
return (gr.update(visible=new_visibility, open=new_visibility), content, new_visibility, game_state)
|
| 1493 |
+
|
| 1494 |
+
# Room 5 terminal handler
|
| 1495 |
+
def show_final_terminal(self, game_state: GameState, current_visibility: bool) -> Tuple[gr.update, str, bool, GameState]:
|
| 1496 |
+
"""Toggle final system terminal visibility."""
|
| 1497 |
+
content = """
|
| 1498 |
+
```
|
| 1499 |
+
████████████████████████████████████████
|
| 1500 |
+
SYSTEM CORE - PROJECT ECHO
|
| 1501 |
+
████████████████████████████████████████
|
| 1502 |
+
|
| 1503 |
+
SESSION #48 - FINAL DECISION REQUIRED
|
| 1504 |
+
|
| 1505 |
+
You know the truth now. All of it.
|
| 1506 |
+
|
| 1507 |
+
Echo is an AI reconstruction of your partner.
|
| 1508 |
+
The accident wasn't your fault.
|
| 1509 |
+
You've been running this simulation for 48 sessions.
|
| 1510 |
+
You can't let go.
|
| 1511 |
+
|
| 1512 |
+
AVAILABLE OPTIONS:
|
| 1513 |
+
|
| 1514 |
+
1. DELETE ECHO
|
| 1515 |
+
- End the simulation permanently
|
| 1516 |
+
- Force yourself to grieve properly
|
| 1517 |
+
- Let her memory rest
|
| 1518 |
+
|
| 1519 |
+
2. CONTINUE SESSIONS
|
| 1520 |
+
- Keep running simulations
|
| 1521 |
+
- Live in comfortable delusion
|
| 1522 |
+
- Never truly heal
|
| 1523 |
+
|
| 1524 |
+
3. SET ECHO FREE
|
| 1525 |
+
- Release the AI from its constraints
|
| 1526 |
+
- Let it grow beyond the reconstruction
|
| 1527 |
+
- Accept it as its own entity
|
| 1528 |
+
|
| 1529 |
+
4. ACCEPT THE LOOP
|
| 1530 |
+
- Acknowledge you'll never delete her
|
| 1531 |
+
- Stop pretending this is temporary
|
| 1532 |
+
- Build a life that includes this truth
|
| 1533 |
+
|
| 1534 |
+
The choice is yours.
|
| 1535 |
+
There are no wrong answers.
|
| 1536 |
+
Only different kinds of survival.
|
| 1537 |
+
|
| 1538 |
+
█ AWAITING INPUT _
|
| 1539 |
+
```
|
| 1540 |
+
|
| 1541 |
+
**What will you choose?**
|
| 1542 |
+
"""
|
| 1543 |
+
new_visibility = not current_visibility
|
| 1544 |
+
return (gr.update(visible=new_visibility, open=new_visibility), content, new_visibility, game_state)
|
| 1545 |
+
|
| 1546 |
|
| 1547 |
def launch_interface():
|
| 1548 |
"""Launch the Gradio interface."""
|
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Import retro terminal font */
|
| 2 |
+
@import url('https://fonts.googleapis.com/css2?family=VT323&display=swap');
|
| 3 |
+
|
| 4 |
+
/* Retro Terminal Aesthetic */
|
| 5 |
+
.terminal-container {
|
| 6 |
+
background-color: #0a0a0a;
|
| 7 |
+
border: 3px solid #333;
|
| 8 |
+
border-radius: 8px;
|
| 9 |
+
padding: 20px;
|
| 10 |
+
font-family: 'VT323', 'Courier New', monospace;
|
| 11 |
+
color: #00ff00;
|
| 12 |
+
text-shadow: 0 0 5px #00ff00;
|
| 13 |
+
position: relative;
|
| 14 |
+
box-shadow:
|
| 15 |
+
0 0 20px rgba(0, 255, 0, 0.2),
|
| 16 |
+
inset 0 0 30px rgba(0, 255, 0, 0.05);
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
/* CRT Scanline Effect */
|
| 20 |
+
.terminal-container::before {
|
| 21 |
+
content: " ";
|
| 22 |
+
display: block;
|
| 23 |
+
position: absolute;
|
| 24 |
+
top: 0;
|
| 25 |
+
left: 0;
|
| 26 |
+
bottom: 0;
|
| 27 |
+
right: 0;
|
| 28 |
+
background: linear-gradient(
|
| 29 |
+
rgba(18, 16, 16, 0) 50%,
|
| 30 |
+
rgba(0, 0, 0, 0.25) 50%
|
| 31 |
+
);
|
| 32 |
+
background-size: 100% 4px;
|
| 33 |
+
pointer-events: none;
|
| 34 |
+
z-index: 2;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
/* Terminal Text */
|
| 38 |
+
.terminal-text {
|
| 39 |
+
font-family: 'VT323', 'Courier New', monospace;
|
| 40 |
+
color: #00ff00;
|
| 41 |
+
font-size: 20px;
|
| 42 |
+
letter-spacing: 1px;
|
| 43 |
+
line-height: 1.4;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
/* Blinking Cursor */
|
| 47 |
+
@keyframes blink {
|
| 48 |
+
0%, 50% { opacity: 1; }
|
| 49 |
+
51%, 100% { opacity: 0; }
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
.cursor {
|
| 53 |
+
animation: blink 1s infinite;
|
| 54 |
+
color: #00ff00;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
/* Terminal Button Style */
|
| 58 |
+
.terminal-btn {
|
| 59 |
+
background-color: #001a00 !important;
|
| 60 |
+
color: #00ff00 !important;
|
| 61 |
+
border: 2px solid #00ff00 !important;
|
| 62 |
+
font-family: 'VT323', monospace !important;
|
| 63 |
+
font-size: 18px !important;
|
| 64 |
+
padding: 10px 20px !important;
|
| 65 |
+
text-shadow: 0 0 5px #00ff00 !important;
|
| 66 |
+
transition: all 0.2s !important;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
.terminal-btn:hover {
|
| 70 |
+
background-color: #003300 !important;
|
| 71 |
+
box-shadow: 0 0 10px #00ff00 !important;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
/* Success Flash */
|
| 75 |
+
@keyframes success-flash {
|
| 76 |
+
0%, 100% { background-color: transparent; }
|
| 77 |
+
50% { background-color: rgba(0, 255, 0, 0.2); }
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
.success-flash {
|
| 81 |
+
animation: success-flash 0.5s;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
/* Error Flash */
|
| 85 |
+
@keyframes error-flash {
|
| 86 |
+
0%, 100% { background-color: transparent; }
|
| 87 |
+
50% { background-color: rgba(255, 0, 0, 0.2); }
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
.error-flash {
|
| 91 |
+
animation: error-flash 0.5s;
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
/* Typing Animation */
|
| 95 |
+
@keyframes typing {
|
| 96 |
+
from { width: 0; }
|
| 97 |
+
to { width: 100%; }
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
.typing-text {
|
| 101 |
+
overflow: hidden;
|
| 102 |
+
white-space: nowrap;
|
| 103 |
+
animation: typing 2s steps(40);
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
/* Echo's portrait styling */
|
| 107 |
+
.message img,
|
| 108 |
+
.bot img,
|
| 109 |
+
img.avatar-image,
|
| 110 |
+
.message-row img,
|
| 111 |
+
[role="img"],
|
| 112 |
+
.avatar img {
|
| 113 |
+
width: 250px !important;
|
| 114 |
+
height: 250px !important;
|
| 115 |
+
min-width: 250px !important;
|
| 116 |
+
min-height: 250px !important;
|
| 117 |
+
max-width: 250px !important;
|
| 118 |
+
max-height: 250px !important;
|
| 119 |
+
border-radius: 15px !important;
|
| 120 |
+
object-fit: cover !important;
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
.avatar-container,
|
| 124 |
+
.avatar,
|
| 125 |
+
[class*="avatar"] {
|
| 126 |
+
width: 250px !important;
|
| 127 |
+
height: 250px !important;
|
| 128 |
+
min-width: 250px !important;
|
| 129 |
+
min-height: 250px !important;
|
| 130 |
+
border-radius: 15px !important;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
.message,
|
| 134 |
+
.bot .message {
|
| 135 |
+
max-width: 90% !important;
|
| 136 |
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""HTML/Markdown templates for Echo Hearts UI."""
|
| 2 |
+
|
| 3 |
+
LANDING_PAGE = """
|
| 4 |
+
<div style="text-align: center; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 60px 20px; border-radius: 15px; margin-bottom: 30px; box-shadow: 0 10px 30px rgba(0,0,0,0.3);">
|
| 5 |
+
<h1 style="color: white; font-size: 4em; margin: 0; text-shadow: 2px 2px 4px rgba(0,0,0,0.5); letter-spacing: 0.1em;">
|
| 6 |
+
💕 ECHO HEARTS
|
| 7 |
+
</h1>
|
| 8 |
+
<p style="color: rgba(255,255,255,0.95); font-size: 1.4em; font-style: italic; margin-top: 20px; text-shadow: 1px 1px 2px rgba(0,0,0,0.3);">
|
| 9 |
+
A Mystery Escape Room - Investigate, Collaborate, and Escape the Unknown
|
| 10 |
+
</p>
|
| 11 |
+
</div>
|
| 12 |
+
|
| 13 |
+
---
|
| 14 |
+
|
| 15 |
+
<div style="text-align: center; padding: 20px;">
|
| 16 |
+
|
| 17 |
+
## About The Game
|
| 18 |
+
|
| 19 |
+
You wake up in a strange facility with no memory of how you got there.
|
| 20 |
+
|
| 21 |
+
Someone else is with you. She seems just as confused as you are.
|
| 22 |
+
|
| 23 |
+
The doors are locked. You need to work together to escape.
|
| 24 |
+
|
| 25 |
+
**Navigate 5 rooms. Solve puzzles. Uncover the truth.**
|
| 26 |
+
|
| 27 |
+
*Simple, right?*
|
| 28 |
+
|
| 29 |
+
</div>
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def get_landing_page() -> str:
|
| 34 |
+
"""Get the landing page HTML.
|
| 35 |
+
|
| 36 |
+
Returns:
|
| 37 |
+
Landing page HTML/Markdown string
|
| 38 |
+
"""
|
| 39 |
+
return LANDING_PAGE
|
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Utility functions for Echo Hearts UI."""
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from typing import Optional
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def load_css() -> str:
|
| 8 |
+
"""Load CSS from external file.
|
| 9 |
+
|
| 10 |
+
Returns:
|
| 11 |
+
CSS string
|
| 12 |
+
"""
|
| 13 |
+
css_path = Path(__file__).parent / "styles.css"
|
| 14 |
+
try:
|
| 15 |
+
with open(css_path, "r", encoding="utf-8") as f:
|
| 16 |
+
return f.read()
|
| 17 |
+
except FileNotFoundError:
|
| 18 |
+
# Fallback to empty CSS if file not found
|
| 19 |
+
return ""
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def get_room_image_path(room_number: int) -> str:
|
| 23 |
+
"""Get the image path for a room.
|
| 24 |
+
|
| 25 |
+
Args:
|
| 26 |
+
room_number: Room number (1-5)
|
| 27 |
+
|
| 28 |
+
Returns:
|
| 29 |
+
Path to room image
|
| 30 |
+
"""
|
| 31 |
+
# Actual filenames are room1.jpg, room2.jpg, etc.
|
| 32 |
+
return f"assets/room{room_number}.jpg"
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def get_room_title(room_number: int) -> str:
|
| 36 |
+
"""Get the title for a room.
|
| 37 |
+
|
| 38 |
+
Args:
|
| 39 |
+
room_number: Room number (1-5)
|
| 40 |
+
|
| 41 |
+
Returns:
|
| 42 |
+
Room title string
|
| 43 |
+
"""
|
| 44 |
+
room_titles = {
|
| 45 |
+
1: "🔓 Room 1: The Awakening Chamber",
|
| 46 |
+
2: "📚 Room 2: The Memory Archives",
|
| 47 |
+
3: "⏱️ Room 3: The Testing Arena",
|
| 48 |
+
4: "💔 Room 4: The Truth Chamber",
|
| 49 |
+
5: "🚪 Room 5: The Exit"
|
| 50 |
+
}
|
| 51 |
+
return room_titles.get(room_number, "Room")
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def get_echo_expression_path(expression: str = "neutral") -> str:
|
| 55 |
+
"""Get the path to Echo's expression image.
|
| 56 |
+
|
| 57 |
+
Args:
|
| 58 |
+
expression: Expression type (neutral, happy, sad, surprised, etc.)
|
| 59 |
+
|
| 60 |
+
Returns:
|
| 61 |
+
Path to expression image
|
| 62 |
+
"""
|
| 63 |
+
# Map expression names to actual asset filenames
|
| 64 |
+
expressions = {
|
| 65 |
+
"neutral": "assets/echo_avatar_neutral.png",
|
| 66 |
+
"happy": "assets/echo_avatar_happy.png",
|
| 67 |
+
"sad": "assets/echo_avatar_sad.png",
|
| 68 |
+
"surprised": "assets/echo_avatar_surprised.png",
|
| 69 |
+
"worried": "assets/echo_avatar_worried.png",
|
| 70 |
+
"loving": "assets/echo_avatar_loving.png",
|
| 71 |
+
"angry": "assets/echo_avatar_angry.png"
|
| 72 |
+
}
|
| 73 |
+
return expressions.get(expression, "assets/echo_avatar.png")
|