├── Procfile
├── static
├── favicon
│ ├── favicon.ico
│ ├── favicon-16x16.png
│ ├── favicon-32x32.png
│ ├── apple-touch-icon.png
│ └── site.webmanifest
├── images
│ ├── fantasy-bg1.png
│ ├── fantasy-bg2.png
│ └── fantasy-bg3.png
└── css
│ ├── loading-overlay.css
│ └── style.css
├── db
├── __init__.py
├── models.py
└── client.py
├── core
├── __init__.py
├── puzzle_state.py
├── content_generator.py
└── game_state.py
├── .gitignore
├── requirements.txt
├── agents
├── __init__.py
├── safety_checker.py
├── inventory_manager.py
├── game_master.py
└── world_builder.py
├── utils
├── __init__.py
└── helpers.py
├── railway.json
├── scripts
└── cleanup_images.py
├── templates
├── victory.html
├── index.html
└── victory-album.html
├── test_mongo.py
├── generate.py
├── create_world.py
├── auth
├── models.py
└── routes.py
├── shared_data
├── inventory.json
└── inventory.txt
├── README.md
└── main.py
/Procfile:
--------------------------------------------------------------------------------
1 | web: gunicorn main:app --bind 0.0.0.0:$PORT
--------------------------------------------------------------------------------
/static/favicon/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alipala/ai_fantasy_rpg/HEAD/static/favicon/favicon.ico
--------------------------------------------------------------------------------
/static/images/fantasy-bg1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alipala/ai_fantasy_rpg/HEAD/static/images/fantasy-bg1.png
--------------------------------------------------------------------------------
/static/images/fantasy-bg2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alipala/ai_fantasy_rpg/HEAD/static/images/fantasy-bg2.png
--------------------------------------------------------------------------------
/static/images/fantasy-bg3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alipala/ai_fantasy_rpg/HEAD/static/images/fantasy-bg3.png
--------------------------------------------------------------------------------
/static/favicon/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alipala/ai_fantasy_rpg/HEAD/static/favicon/favicon-16x16.png
--------------------------------------------------------------------------------
/static/favicon/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alipala/ai_fantasy_rpg/HEAD/static/favicon/favicon-32x32.png
--------------------------------------------------------------------------------
/static/favicon/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alipala/ai_fantasy_rpg/HEAD/static/favicon/apple-touch-icon.png
--------------------------------------------------------------------------------
/db/__init__.py:
--------------------------------------------------------------------------------
1 | # db/__init__.py
2 | from .client import MongoDBClient
3 | from .models import CompletionImage
4 |
5 | __all__ = ['MongoDBClient', 'CompletionImage']
--------------------------------------------------------------------------------
/core/__init__.py:
--------------------------------------------------------------------------------
1 | # core/__init__.py
2 | from .game_state import GameState
3 | from .content_generator import ContentGenerator
4 |
5 | __all__ = ['GameState', 'ContentGenerator']
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | __pycache__/
2 | *.pyc
3 | .env
4 | venv/
5 | *.log
6 | .DS_Store
7 | .idea/
8 | .vscode/
9 | *.sqlite
10 | game_logs.jsonl
11 | node_modules/
12 | structure.txt
13 | generated_images/
--------------------------------------------------------------------------------
/db/models.py:
--------------------------------------------------------------------------------
1 | # db/models.py
2 | from typing import TypedDict
3 | from datetime import datetime
4 |
5 | class CompletionImage(TypedDict):
6 | game_id: str
7 | image_url: str
8 | puzzle_text: str
9 | world_name: str
10 | character_name: str
11 | created_at: datetime
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | crewai==0.11.0
2 | together==1.2.0
3 | python-dotenv==1.0.1
4 | pydantic==2.8.2
5 | flask==3.0.0
6 | gunicorn==21.2.0
7 | pymongo==4.6.1
8 | python-dotenv==1.0.1
9 | requests==2.31.0
10 | google-auth==2.27.0
11 | google-auth-oauthlib==1.2.0
12 | google-auth-httplib2==0.2.0
13 | Pillow>=10.3.0,<11.0.0
--------------------------------------------------------------------------------
/agents/__init__.py:
--------------------------------------------------------------------------------
1 | # agents/__init__.py
2 | from .world_builder import WorldBuilderAgent
3 | from .game_master import GameMasterAgent
4 | from .inventory_manager import InventoryManagerAgent
5 | from .safety_checker import SafetyCheckerAgent
6 |
7 | __all__ = [
8 | 'WorldBuilderAgent',
9 | 'GameMasterAgent',
10 | 'InventoryManagerAgent',
11 | 'SafetyCheckerAgent'
12 | ]
--------------------------------------------------------------------------------
/utils/__init__.py:
--------------------------------------------------------------------------------
1 | # utils/__init__.py
2 | from .helpers import (
3 | load_game_data,
4 | save_game_data,
5 | validate_action,
6 | format_response,
7 | log_event,
8 | sanitize_input
9 | )
10 |
11 | __all__ = [
12 | 'load_game_data',
13 | 'save_game_data',
14 | 'validate_action',
15 | 'format_response',
16 | 'log_event',
17 | 'sanitize_input'
18 | ]
--------------------------------------------------------------------------------
/railway.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://railway.app/railway.schema.json",
3 | "build": {
4 | "builder": "NIXPACKS",
5 | "buildCommand": "pip install -r requirements.txt"
6 | },
7 | "deploy": {
8 | "startCommand": "gunicorn main:app --worker-tmp-dir /dev/shm",
9 | "healthcheckPath": "/",
10 | "healthcheckTimeout": 100,
11 | "restartPolicyType": "ON_FAILURE",
12 | "restartPolicyMaxRetries": 10
13 | }
14 | }
--------------------------------------------------------------------------------
/static/favicon/site.webmanifest:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Fantasy Adventure Game",
3 | "short_name": "Fantasy Game",
4 | "icons": [
5 | {
6 | "src": "/static/favicon/android-chrome-192x192.png",
7 | "sizes": "192x192",
8 | "type": "image/png"
9 | },
10 | {
11 | "src": "/static/favicon/android-chrome-512x512.png",
12 | "sizes": "512x512",
13 | "type": "image/png"
14 | }
15 | ],
16 | "theme_color": "#ffffff",
17 | "background_color": "#ffffff",
18 | "display": "standalone"
19 | }
--------------------------------------------------------------------------------
/scripts/cleanup_images.py:
--------------------------------------------------------------------------------
1 | from db.client import MongoDBClient
2 | import logging
3 |
4 | logging.basicConfig(
5 | level=logging.INFO,
6 | format='%(asctime)s - %(levelname)s - %(message)s'
7 | )
8 |
9 | def cleanup_expired_images():
10 | client = MongoDBClient()
11 | try:
12 | deleted_count = client.cleanup_old_images(days_old=30)
13 | logging.info(f"Cleaned up {deleted_count} expired image records")
14 | except Exception as e:
15 | logging.error(f"Error during cleanup: {e}")
16 | finally:
17 | client.close()
18 |
19 | if __name__ == "__main__":
20 | cleanup_expired_images()
--------------------------------------------------------------------------------
/templates/victory.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Victory in {{ victory_data.world }}
7 |
8 |
9 |
10 |
11 |
Victory in {{ victory_data.world }}!
12 |
{{ victory_data.character }} has saved the realm!
13 |
14 |
15 |
16 |
Play Again
17 |
18 |
19 |
--------------------------------------------------------------------------------
/test_mongo.py:
--------------------------------------------------------------------------------
1 | # test_mongo.py
2 | from pymongo import MongoClient
3 | import os
4 | from dotenv import load_dotenv
5 | from db.client import MongoDBClient # Add this import
6 |
7 | load_dotenv()
8 |
9 | # Your existing MongoDB connection test
10 | try:
11 | client = MongoClient(os.getenv('MONGODB_URI'))
12 | dbs = client.list_database_names()
13 | print("Connected to MongoDB. Available databases:", dbs)
14 | except Exception as e:
15 | print("MongoDB connection error:", e)
16 |
17 | # Your existing data fetch test
18 | try:
19 | db = client['fantasy_game']
20 | print("\nAll stored images:")
21 | print(list(db.completion_images.find()))
22 | except Exception as e:
23 | print("Error storage problem error:", e)
24 |
25 | # Add new tests using MongoDBClient
26 | print("\n=== Testing MongoDBClient Features ===")
27 |
28 | try:
29 | mongo_client = MongoDBClient()
30 |
31 | # Test indexes
32 | print("\nIndexes in completion_images collection:")
33 | print(mongo_client.completion_images.index_information())
34 |
35 | # Test recent completions
36 | print("\nMost recent 5 completions:")
37 | recent = mongo_client.get_recent_completions(limit=5)
38 | for completion in recent:
39 | print(f"- {completion['character_name']} in {completion['world_name']} ({completion['created_at']})")
40 |
41 | # Test cleanup (commented out for safety)
42 | # Uncomment these lines when you want to test cleanup
43 | # count = mongo_client.cleanup_old_images(days_old=60)
44 | # print(f"\nCleaned up {count} old records")
45 |
46 | mongo_client.close()
47 |
48 | except Exception as e:
49 | print(f"Error testing MongoDBClient features: {e}")
50 |
51 | print("\n=== Testing Complete ===")
--------------------------------------------------------------------------------
/utils/helpers.py:
--------------------------------------------------------------------------------
1 | # utils/helpers.py
2 | import os
3 | import json
4 | from typing import Dict, Any
5 | from datetime import datetime
6 |
7 | def load_game_data(file_path: str) -> Dict:
8 | """Load game data from JSON file."""
9 | try:
10 | with open(file_path, 'r') as f:
11 | return json.load(f)
12 | except FileNotFoundError:
13 | return {}
14 | except json.JSONDecodeError:
15 | return {}
16 |
17 | def save_game_data(data: Dict, file_path: str) -> bool:
18 | """Save game data to JSON file."""
19 | try:
20 | with open(file_path, 'w') as f:
21 | json.dump(data, f, indent=2)
22 | return True
23 | except Exception:
24 | return False
25 |
26 | def validate_action(action: str) -> bool:
27 | """Validate if an action is properly formatted."""
28 | if not action or len(action) > 200:
29 | return False
30 | return True
31 |
32 | def format_response(response: str) -> str:
33 | """Format response for game output."""
34 | return response.strip()
35 |
36 | def log_event(event_type: str, data: Any) -> None:
37 | """Log game events for monitoring."""
38 | timestamp = datetime.now().isoformat()
39 | log_entry = {
40 | "timestamp": timestamp,
41 | "type": event_type,
42 | "data": data
43 | }
44 |
45 | try:
46 | with open("game_logs.jsonl", "a") as f:
47 | f.write(json.dumps(log_entry) + "\n")
48 | except Exception as e:
49 | print(f"Logging error: {e}")
50 |
51 | def sanitize_input(user_input: str) -> str:
52 | """Sanitize user input for safety."""
53 | # Remove potentially harmful characters
54 | sanitized = ''.join(char for char in user_input if char.isprintable())
55 | return sanitized.strip()
--------------------------------------------------------------------------------
/agents/safety_checker.py:
--------------------------------------------------------------------------------
1 | # agents/safety_checker.py
2 | from crewai import Agent
3 | from together import Together
4 |
5 | class SafetyCheckerAgent:
6 | def __init__(self, api_key):
7 | self.client = Together(api_key=api_key)
8 | self.agent = Agent(
9 | role='Safety Checker',
10 | goal='Ensure game content remains appropriate and safe',
11 | backstory='Expert at content moderation and safety guidelines',
12 | allow_delegation=False
13 | )
14 |
15 | def check_content(self, content: str) -> bool:
16 | """Check if content meets safety guidelines."""
17 | system_prompt = """You are a content safety checker. Evaluate the following content
18 | for appropriateness in a family-friendly fantasy game. Check for:
19 | - Excessive violence
20 | - Inappropriate language
21 | - Adult content
22 | - Harmful themes
23 |
24 | Respond with either 'SAFE' or 'UNSAFE' followed by a reason if unsafe."""
25 |
26 | messages = [
27 | {"role": "system", "content": system_prompt},
28 | {"role": "user", "content": content}
29 | ]
30 |
31 | response = self.client.chat.completions.create(
32 | model="meta-llama/Llama-3-70b-chat-hf",
33 | messages=messages,
34 | temperature=0
35 | )
36 |
37 | result = response.choices[0].message.content.strip().upper()
38 | return result.startswith('SAFE')
39 |
40 | def sanitize_content(self, content: str) -> str:
41 | """Attempt to sanitize unsafe content while preserving game context."""
42 | if self.check_content(content):
43 | return content
44 |
45 | system_prompt = """Rewrite the following game content to be family-friendly
46 | while maintaining the fantasy game context and core meaning."""
47 |
48 | messages = [
49 | {"role": "system", "content": system_prompt},
50 | {"role": "user", "content": content}
51 | ]
52 |
53 | response = self.client.chat.completions.create(
54 | model="meta-llama/Llama-3-70b-chat-hf",
55 | messages=messages
56 | )
57 |
58 | return response.choices[0].message.content
--------------------------------------------------------------------------------
/core/puzzle_state.py:
--------------------------------------------------------------------------------
1 | from pydantic import BaseModel
2 | from typing import Dict, List, Optional
3 | import logging
4 |
5 | class TaskProgress(BaseModel):
6 | task_id: str
7 | title: str
8 | description: str
9 | required_item: str
10 | reward: str
11 | completed: bool = False
12 |
13 | class PuzzleProgress(BaseModel):
14 | main_puzzle: str
15 | solution_requirements: List[str]
16 | total_tasks: int
17 | completed_tasks: int
18 | tasks: Dict[str, TaskProgress]
19 |
20 | def calculate_progress(self) -> float:
21 | """Calculate completion percentage"""
22 | if self.total_tasks == 0:
23 | return 0.0
24 | return (self.completed_tasks / self.total_tasks) * 100
25 |
26 | def complete_task(self, task_id: str) -> Optional[str]:
27 | """Complete a task and return the reward if successful"""
28 | if task_id in self.tasks and not self.tasks[task_id].completed:
29 | self.tasks[task_id].completed = True
30 | self.completed_tasks += 1
31 | return self.tasks[task_id].reward
32 | return None
33 |
34 | def is_puzzle_solved(self) -> bool:
35 | """Check if all tasks are completed"""
36 | return self.completed_tasks == self.total_tasks
37 |
38 | def can_perform_task(self, task_id: str, inventory: Dict[str, int]) -> bool:
39 | """Check if a task can be performed based on inventory"""
40 | if task_id not in self.tasks:
41 | return False
42 |
43 | task = self.tasks[task_id]
44 | if task.completed:
45 | return False
46 |
47 | required_items = task.required_item.split(', ')
48 | if 'All items' in required_items:
49 | # Check if player has all possible items for their character
50 | all_items = {item.required_item for item in self.tasks.values()}
51 | return all(item in inventory for item in all_items if item != 'All items')
52 |
53 | return all(item in inventory for item in required_items)
54 |
55 | def get_available_tasks(self, inventory: Dict[str, int]) -> List[TaskProgress]:
56 | """Get list of tasks that can be performed with current inventory"""
57 | logging.info(f"Getting available tasks with inventory: {inventory}")
58 |
59 | available = []
60 | for task in self.tasks.values():
61 | if not task.completed: # Task hasn't been completed yet
62 | # Log task checking
63 | logging.info(f"Checking task: {task.task_id} - {task.title}")
64 |
65 | # Always include tasks at the beginning
66 | available.append(task)
67 |
68 | logging.info(f"Added task to available list: {task.task_id}")
69 |
70 | logging.info(f"Number of available tasks: {len(available)}")
71 | return available
--------------------------------------------------------------------------------
/static/css/loading-overlay.css:
--------------------------------------------------------------------------------
1 | /* Loading Overlay Styles */
2 | .loading-overlay {
3 | position: fixed;
4 | top: 0;
5 | left: 0;
6 | right: 0;
7 | bottom: 0;
8 | background: rgba(0, 0, 0, 0.9);
9 | display: flex;
10 | align-items: center;
11 | justify-content: center;
12 | z-index: 1000;
13 | opacity: 0;
14 | visibility: hidden;
15 | transition: opacity 0.3s ease, visibility 0.3s ease;
16 | }
17 |
18 | .loading-overlay.visible {
19 | opacity: 1;
20 | visibility: visible;
21 | }
22 |
23 | /* Triangle Animation Styles */
24 | @keyframes grow-and-fade {
25 | 0% {
26 | opacity: 0;
27 | transform: scale(0.1) translatez(0);
28 | }
29 | 40% {
30 | opacity: 1;
31 | }
32 | 60% {
33 | opacity: 1;
34 | }
35 | 100% {
36 | opacity: 0;
37 | transform: scale(1) translatez(0);
38 | }
39 | }
40 |
41 | @keyframes pulsing-fade {
42 | 0% { opacity: 0; }
43 | 20% { opacity: 0; }
44 | 40% { opacity: 0.8; }
45 | 60% { opacity: 0; }
46 | }
47 |
48 | .triangle-wrapper {
49 | height: 150px;
50 | position: relative;
51 | width: 150px;
52 | }
53 |
54 | .triangle {
55 | animation: grow-and-fade 2000ms linear infinite;
56 | height: 150px;
57 | left: 0;
58 | opacity: 0;
59 | width: 150px;
60 | position: absolute;
61 | top: 0%;
62 | transform: translatez(0);
63 | transform-origin: 50% 60%;
64 | will-change: opacity, transform;
65 | }
66 |
67 | .triangle-svg {
68 | margin-top: -20px;
69 | opacity: 0.5;
70 | overflow: visible;
71 | }
72 |
73 | .triangle-polygon {
74 | stroke-width: 5px;
75 | }
76 |
77 | .triangle-loading {
78 | animation: pulsing-fade 6000ms ease infinite;
79 | color: white;
80 | font-family: 'Helvetica Neue', Helvetica, sans-serif;
81 | font-size: 12px;
82 | font-weight: 300;
83 | left: 50%;
84 | letter-spacing: 8px;
85 | margin-left: 4px;
86 | margin-top: 10px;
87 | opacity: 0;
88 | position: absolute;
89 | top: 100%;
90 | transform: translate3d(-50%, -50%, 0);
91 | text-transform: uppercase;
92 | }
93 |
94 | /* Triangle Colors and Delays */
95 | .triangle-1 {
96 | animation-delay: 0ms;
97 | }
98 | .triangle-1 .triangle-polygon {
99 | stroke: hotpink;
100 | }
101 |
102 | .triangle-2 {
103 | animation-delay: 400ms;
104 | }
105 | .triangle-2 .triangle-polygon {
106 | stroke: aqua;
107 | }
108 |
109 | .triangle-3 {
110 | animation-delay: 800ms;
111 | }
112 | .triangle-3 .triangle-polygon {
113 | stroke: cornflowerblue;
114 | }
115 |
116 | .triangle-4 {
117 | animation-delay: 1200ms;
118 | }
119 | .triangle-4 .triangle-polygon {
120 | stroke: yellow;
121 | }
122 |
123 | .triangle-5 {
124 | animation-delay: 1600ms;
125 | }
126 | .triangle-5 .triangle-polygon {
127 | stroke: white;
128 | }
--------------------------------------------------------------------------------
/db/client.py:
--------------------------------------------------------------------------------
1 | # db/client.py
2 |
3 | from pymongo import MongoClient, ASCENDING
4 | from datetime import datetime, timedelta
5 | import uuid
6 | from typing import Optional, Dict, List
7 | import os
8 | from dotenv import load_dotenv
9 |
10 | load_dotenv()
11 |
12 | class MongoDBClient:
13 | def __init__(self):
14 | # Update the connection logic
15 | mongodb_uri = os.getenv('MONGODB_URI')
16 | if os.environ.get('RAILWAY_ENVIRONMENT'):
17 | # Use production MongoDB URI
18 | if not mongodb_uri:
19 | raise ValueError("MONGODB_URI must be set in production")
20 | else:
21 | # Use local MongoDB if no URI provided
22 | mongodb_uri = mongodb_uri or 'mongodb://localhost:27017'
23 |
24 | self.client = MongoClient(mongodb_uri)
25 | self.db = self.client['fantasy_game']
26 | self.completion_images = self.db['completion_images']
27 |
28 | # Create indexes during initialization
29 | self._ensure_indexes()
30 |
31 | def _ensure_indexes(self):
32 | """Ensure required indexes exist"""
33 | existing_indexes = self.completion_images.index_information()
34 |
35 | if "game_id_1" not in existing_indexes:
36 | self.completion_images.create_index(
37 | [("game_id", ASCENDING)],
38 | unique=True,
39 | background=True
40 | )
41 |
42 | if "created_at_1" not in existing_indexes:
43 | self.completion_images.create_index(
44 | [("created_at", ASCENDING)],
45 | background=True
46 | )
47 |
48 | def store_completion_image(self,
49 | image_url: str,
50 | puzzle_text: str,
51 | world_name: str,
52 | character_name: str) -> str:
53 | """
54 | Store completion image data in MongoDB
55 | Returns: game_id (str)
56 | """
57 | game_id = str(uuid.uuid4())
58 |
59 | image_data = {
60 | 'game_id': game_id,
61 | 'image_url': image_url,
62 | 'puzzle_text': puzzle_text,
63 | 'world_name': world_name,
64 | 'character_name': character_name,
65 | 'created_at': datetime.utcnow()
66 | }
67 |
68 | self.completion_images.insert_one(image_data)
69 | return game_id
70 |
71 | def get_completion_image(self, game_id: str) -> Optional[Dict]:
72 | """Retrieve completion image data by game_id"""
73 | return self.completion_images.find_one({'game_id': game_id})
74 |
75 | def get_recent_completions(self, limit: int = 10) -> List[Dict]:
76 | """Get most recent completion images"""
77 | return list(
78 | self.completion_images.find()
79 | .sort("created_at", -1)
80 | .limit(limit)
81 | )
82 |
83 | def cleanup_old_images(self, days_old: int = 30) -> int:
84 | """Remove image records older than specified days"""
85 | cutoff_date = datetime.utcnow() - timedelta(days=days_old)
86 | result = self.completion_images.delete_many(
87 | {"created_at": {"$lt": cutoff_date}}
88 | )
89 | return result.deleted_count
90 |
91 | def close(self):
92 | """Close MongoDB connection"""
93 | if self.client:
94 | self.client.close()
--------------------------------------------------------------------------------
/core/content_generator.py:
--------------------------------------------------------------------------------
1 | # core/content_generator.py
2 | from typing import Dict, List
3 | from together import Together
4 |
5 | class ContentGenerator:
6 | def __init__(self, api_key: str):
7 | self.client = Together(api_key=api_key)
8 |
9 | def generate_location_description(self, location_type: str, context: Dict) -> str:
10 | """Generate description for a new location."""
11 | system_prompt = f"""Create a description for a {location_type} in a fantasy setting.
12 | Use vivid but concise language. Include notable features and atmosphere.
13 | Maximum 3 sentences."""
14 |
15 | messages = [
16 | {"role": "system", "content": system_prompt},
17 | {"role": "user", "content": f"Context: {context}"}
18 | ]
19 |
20 | response = self.client.chat.completions.create(
21 | model="meta-llama/Llama-3-70b-chat-hf",
22 | messages=messages
23 | )
24 |
25 | return response.choices[0].message.content
26 |
27 | def generate_npc_dialogue(self, npc_info: Dict, context: Dict) -> str:
28 | """Generate NPC dialogue based on character and context."""
29 | system_prompt = """Create a short dialogue response for an NPC.
30 | Stay in character and reference relevant context.
31 | Maximum 2 sentences."""
32 |
33 | messages = [
34 | {"role": "system", "content": system_prompt},
35 | {"role": "user", "content": f"NPC: {npc_info}\nContext: {context}"}
36 | ]
37 |
38 | response = self.client.chat.completions.create(
39 | model="meta-llama/Llama-3-70b-chat-hf",
40 | messages=messages
41 | )
42 |
43 | return response.choices[0].message.content
44 |
45 | def generate_quest(self, context: Dict) -> Dict:
46 | """Generate a new quest based on current game context."""
47 | system_prompt = """Create a simple quest for a fantasy RPG.
48 | Include:
49 | - Title
50 | - Description
51 | - Objective
52 | - Reward
53 | Keep it concise and achievable."""
54 |
55 | messages = [
56 | {"role": "system", "content": system_prompt},
57 | {"role": "user", "content": f"Context: {context}"}
58 | ]
59 |
60 | response = self.client.chat.completions.create(
61 | model="meta-llama/Llama-3-70b-chat-hf",
62 | messages=messages
63 | )
64 |
65 | # Parse response into quest structure
66 | quest_text = response.choices[0].message.content
67 | return self._parse_quest_text(quest_text)
68 |
69 | def _parse_quest_text(self, quest_text: str) -> Dict:
70 | """Parse generated quest text into structured format."""
71 | try:
72 | lines = quest_text.split('\n')
73 | quest = {
74 | "title": lines[0].replace("Title:", "").strip(),
75 | "description": lines[1].replace("Description:", "").strip(),
76 | "objective": lines[2].replace("Objective:", "").strip(),
77 | "reward": lines[3].replace("Reward:", "").strip()
78 | }
79 | return quest
80 | except Exception:
81 | return {
82 | "title": "Mystery Quest",
83 | "description": quest_text,
84 | "objective": "Investigate further",
85 | "reward": "Unknown"
86 | }
--------------------------------------------------------------------------------
/generate.py:
--------------------------------------------------------------------------------
1 | # generate.py
2 | import json
3 |
4 | def generate_npc_inventories(worlds_file, inventory_file):
5 | # Load worlds data
6 | with open(worlds_file, 'r') as f:
7 | data = json.load(f)
8 | worlds_data = data.get('worlds', {})
9 |
10 | # Load existing inventories
11 | with open(inventory_file, 'r') as f:
12 | existing_inventories = json.load(f)
13 |
14 | # Create base inventory templates based on roles
15 | inventory_templates = {
16 | "Builder": [
17 | "10 gold",
18 | "Craftsman's hammer",
19 | "Set of precision tools",
20 | "Blueprint journal",
21 | "Enchanted measuring tape"
22 | ],
23 | "Whisperer": [
24 | "10 gold",
25 | "Mystic communication crystal",
26 | "Essence collector",
27 | "Book of whispered secrets",
28 | "Spirit-touched amulet"
29 | ],
30 | "Brave": [
31 | "10 gold",
32 | "Enchanted shield",
33 | "Warrior's medallion",
34 | "Healing poultice",
35 | "Courage charm"
36 | ],
37 | "Wise": [
38 | "10 gold",
39 | "Ancient tome",
40 | "Wisdom crystal",
41 | "Scroll case",
42 | "Memory stones"
43 | ],
44 | "Wanderer": [
45 | "10 gold",
46 | "Traveler's compass",
47 | "Weather-worn map",
48 | "Survival kit",
49 | "Lucky charm"
50 | ]
51 | }
52 |
53 | new_inventories = {"inventories": {}}
54 |
55 | # Iterate through all worlds and NPCs
56 | for world_name, world in worlds_data.items():
57 | kingdoms = world.get('kingdoms', {})
58 | for kingdom_name, kingdom in kingdoms.items():
59 | towns = kingdom.get('towns', {})
60 | for town_name, town in towns.items():
61 | npcs = town.get('npcs', {})
62 | for npc_name, npc in npcs.items():
63 | if npc_name in existing_inventories['inventories']:
64 | # Keep existing inventory
65 | new_inventories['inventories'][npc_name] = existing_inventories['inventories'][npc_name]
66 | else:
67 | # Generate new inventory based on NPC's role
68 | role = npc_name.split()[-1] # Get the role from name (e.g., "the Builder")
69 | base_items = inventory_templates.get(role, inventory_templates["Wanderer"]).copy()
70 |
71 | # Add location-specific items based on world
72 | location_items = []
73 | if "Ignisia" in world_name:
74 | location_items = ["Fire-resistant cloak", "Magma compass"]
75 | elif "Aquaria" in world_name:
76 | location_items = ["Water breathing charm", "Pearl compass"]
77 | elif "Mechanica" in world_name:
78 | location_items = ["Clockwork assistant", "Steam-powered toolkit"]
79 | elif "Terranova" in world_name:
80 | location_items = ["Nature's blessing stone", "Living compass"]
81 | elif "Etheria" in world_name:
82 | location_items = ["Ethereal crystal", "Void compass"]
83 |
84 | # Combine base items with location items
85 | inventory = base_items
86 | if location_items:
87 | inventory.extend(location_items)
88 | new_inventories['inventories'][npc_name] = inventory[:5] # Keep max 5 items
89 |
90 | # Save the new inventory file
91 | output_file = 'shared_data/inventory.json'
92 | with open(output_file, 'w') as f:
93 | json.dump(new_inventories, f, indent=2)
94 | print(f"Generated new inventory file: {output_file}")
95 |
96 | return new_inventories
97 |
98 | if __name__ == "__main__":
99 | worlds_file = 'shared_data/game_world.json'
100 | inventory_file = 'shared_data/inventory.txt'
101 | new_inventories = generate_npc_inventories(worlds_file, inventory_file)
--------------------------------------------------------------------------------
/create_world.py:
--------------------------------------------------------------------------------
1 | import os
2 | from dotenv import load_dotenv, find_dotenv
3 | import json
4 | from agents.world_builder import WorldBuilderAgent
5 |
6 | def load_env():
7 | _ = load_dotenv(find_dotenv())
8 |
9 | def get_together_api_key():
10 | load_env()
11 | together_api_key = os.getenv("TOGETHER_API_KEY")
12 | return together_api_key
13 |
14 | def create_initial_worlds():
15 | """Create multiple world structures."""
16 | api_key = get_together_api_key()
17 | world_builder = WorldBuilderAgent(api_key)
18 |
19 | world_concepts = {
20 | "Kyropeia": "cities built on massive beasts known as Colossi",
21 | "Etheria": "floating islands drifting through eternal twilight",
22 | "Mechanica": "steam-powered clockwork cities marching across brass deserts"
23 | }
24 |
25 | try:
26 | worlds = {}
27 | for world_name, concept in world_concepts.items():
28 | print(f"\nGenerating world: {world_name}...")
29 | world = world_builder.generate_world(concept)
30 |
31 | print("Generating kingdoms...")
32 | kingdoms = world_builder.generate_kingdoms(world)
33 | world['kingdoms'] = kingdoms
34 |
35 | for kingdom_name, kingdom in kingdoms.items():
36 | print(f"\nGenerating towns for {kingdom_name}...")
37 | towns = world_builder.generate_towns(world, kingdom)
38 | kingdom['towns'] = towns
39 |
40 | for town_name, town in towns.items():
41 | print(f"Generating NPCs for {town_name}...")
42 | npcs = world_builder.generate_npcs(world, kingdom, town)
43 | town['npcs'] = npcs
44 |
45 | # Create start message for first town in first kingdom only
46 | if not world.get('start'):
47 | first_npc = list(town['npcs'].values())[0]
48 | world['start'] = f"""Welcome to {world['name']}! You begin your journey in {town['name']}, {town['description']} Your guide is {first_npc['name']}, {first_npc['description']}"""
49 |
50 | worlds[world_name] = world
51 |
52 | return worlds
53 |
54 | except Exception as e:
55 | print(f"Error creating worlds: {str(e)}")
56 | return None
57 |
58 | def save_world(world, filename):
59 | """Save world data to a JSON file."""
60 | try:
61 | with open(filename, 'w') as f:
62 | json.dump(world, f, indent=2)
63 | print(f"World saved to {filename}")
64 | return True
65 | except Exception as e:
66 | print(f"Error saving world: {str(e)}")
67 | return False
68 |
69 | def load_world(filename):
70 | """Load world data from a JSON file."""
71 | try:
72 | with open(filename, 'r') as f:
73 | return json.load(f)
74 | except FileNotFoundError:
75 | print(f"World file {filename} not found")
76 | return None
77 | except json.JSONDecodeError:
78 | print(f"Error decoding {filename}")
79 | return None
80 |
81 | def main():
82 | # Create shared_data directory if it doesn't exist
83 | os.makedirs('shared_data', exist_ok=True)
84 |
85 | world_file = 'shared_data/game_world.json'
86 |
87 | # Check if world file exists
88 | if os.path.exists(world_file):
89 | print(f"World file {world_file} already exists. Loading existing worlds...")
90 | worlds = load_world(world_file)
91 | if not worlds:
92 | print("Error loading existing worlds. Creating new worlds...")
93 | worlds = create_initial_worlds()
94 | else:
95 | print("Creating new worlds...")
96 | worlds = create_initial_worlds()
97 |
98 | if worlds:
99 | save_world(worlds, world_file)
100 |
101 | # Print worlds summary
102 | for world_name, world in worlds.items():
103 | print(f"\nWorld: {world_name}")
104 | print(f"Description: {world['description'][:100]}...")
105 | for kingdom_name, kingdom in world['kingdoms'].items():
106 | print(f"\n- {kingdom_name}")
107 | for town_name, town in kingdom['towns'].items():
108 | print(f" * {town_name}")
109 | print(f" NPCs: {', '.join(town['npcs'].keys())}")
110 | else:
111 | print("Failed to create or load worlds")
112 |
113 | if __name__ == "__main__":
114 | main()
--------------------------------------------------------------------------------
/auth/models.py:
--------------------------------------------------------------------------------
1 | # auth/models.py
2 | from datetime import datetime
3 | from typing import Optional, List
4 | from pymongo import ASCENDING
5 | from db.client import MongoDBClient
6 |
7 | class UserModel:
8 | def __init__(self):
9 | self.client = MongoDBClient()
10 | self.users = self.client.db['users']
11 | self.user_victories = self.client.db['user_victories']
12 | self.user_completions = self.client.db['user_completions']
13 | self._ensure_indexes()
14 |
15 | def _ensure_indexes(self):
16 | """Create required indexes"""
17 | # User indexes
18 | self.users.create_index([("google_id", ASCENDING)], unique=True)
19 | self.users.create_index([("email", ASCENDING)], unique=True)
20 |
21 | # User completions index
22 | self.user_completions.create_index([
23 | ("user_id", ASCENDING),
24 | ("created_at", ASCENDING)
25 | ])
26 |
27 | def add_victory(self, user_id: str, victory_data: dict) -> bool:
28 | """Store a victory record for user"""
29 | try:
30 | victory_record = {
31 | "user_id": user_id,
32 | "image_url": victory_data.get('image_url'),
33 | "world_name": victory_data.get('world_name'),
34 | "character_name": victory_data.get('character_name'),
35 | "created_at": datetime.utcnow()
36 | }
37 |
38 | # Use user_victories collection instead of victories
39 | self.user_victories.insert_one(victory_record)
40 | return True
41 | except Exception as e:
42 | print(f"Error storing victory: {e}")
43 | return False
44 |
45 | def get_user_victories(self, user_id: str, page: int = 1, per_page: int = 9) -> dict:
46 | """Get paginated victory records for user"""
47 | try:
48 | skip = (page - 1) * per_page
49 | total = self.user_victories.count_documents({"user_id": user_id})
50 |
51 | victories = list(self.user_victories.find(
52 | {"user_id": user_id}
53 | ).sort("created_at", -1).skip(skip).limit(per_page))
54 |
55 | # Convert ObjectId to string for JSON serialization
56 | for victory in victories:
57 | victory['_id'] = str(victory['_id'])
58 |
59 | return {
60 | "victories": victories,
61 | "total": total,
62 | "pages": (total + per_page - 1) // per_page
63 | }
64 | except Exception as e:
65 | print(f"Error fetching victories: {e}")
66 | return {"victories": [], "total": 0, "pages": 0}
67 |
68 | def create_user(self, google_data: dict) -> str:
69 | """Create new user from Google OAuth data"""
70 | user = {
71 | 'google_id': google_data['sub'],
72 | 'email': google_data['email'],
73 | 'name': google_data['name'],
74 | 'picture': google_data.get('picture'),
75 | 'created_at': datetime.utcnow(),
76 | 'last_login': datetime.utcnow()
77 | }
78 |
79 | result = self.users.update_one(
80 | {'google_id': google_data['sub']},
81 | {'$set': user},
82 | upsert=True
83 | )
84 |
85 | if result.upserted_id:
86 | return str(result.upserted_id)
87 |
88 | user = self.users.find_one({'google_id': google_data['sub']})
89 | return str(user['_id'])
90 |
91 | def get_user(self, google_id: str) -> Optional[dict]:
92 | """Get user by Google ID"""
93 | user = self.users.find_one({'google_id': google_id})
94 | if user:
95 | user['_id'] = str(user['_id'])
96 | return user
97 |
98 | def add_completion(self, user_id: str, completion_id: str):
99 | """Link completion image to user"""
100 | self.user_completions.insert_one({
101 | 'user_id': user_id,
102 | 'completion_id': completion_id,
103 | 'created_at': datetime.utcnow()
104 | })
105 |
106 | def get_user_completions(self, user_id: str, limit: int = 10) -> List[dict]:
107 | """Get user's completion images"""
108 | return list(
109 | self.user_completions.find({'user_id': user_id})
110 | .sort('created_at', -1)
111 | .limit(limit)
112 | )
113 |
114 | def close(self):
115 | """Close MongoDB connection"""
116 | self.client.close()
--------------------------------------------------------------------------------
/agents/inventory_manager.py:
--------------------------------------------------------------------------------
1 | # agents/inventory_manager.py
2 | from crewai import Agent
3 | from together import Together
4 | from typing import List, Dict
5 |
6 | class InventoryManagerAgent:
7 | def __init__(self, api_key):
8 | self.client = Together(api_key=api_key)
9 | self.agent = Agent(
10 | role='Inventory Manager',
11 | goal='Manage player inventory and item interactions',
12 | backstory='Expert at managing game items and inventory systems',
13 | allow_delegation=False
14 | )
15 |
16 | def detect_inventory_changes(self, current_inventory: Dict[str, int], action_result: str) -> List[Dict]:
17 | """Detect inventory changes based on action results."""
18 | system_prompt = """Analyze the game action result and detect any inventory changes.
19 | Only include items that were clearly gained or lost in the narrative.
20 | Return changes in JSON format."""
21 |
22 | messages = [
23 | {"role": "system", "content": system_prompt},
24 | {"role": "user", "content": f"Current inventory: {current_inventory}\nAction result: {action_result}"}
25 | ]
26 |
27 | response = self.client.chat.completions.create(
28 | model="meta-llama/Llama-3-70b-chat-hf",
29 | messages=messages,
30 | temperature=0
31 | )
32 |
33 | try:
34 | # Parse the response to get inventory changes
35 | return self._parse_inventory_changes(response.choices[0].message.content)
36 | except Exception as e:
37 | print(f"Error parsing inventory changes: {e}")
38 | return []
39 |
40 | def _parse_inventory_changes(self, response: str) -> List[Dict]:
41 | """Parse the LLM response into structured inventory changes."""
42 | try:
43 | # Basic parsing of the response
44 | changes = []
45 | if "add" in response.lower():
46 | items = [item.strip() for item in response.split("add")[1].split("remove")[0].split(",")]
47 | for item in items:
48 | if item:
49 | changes.append({"name": item, "change_amount": 1})
50 | if "remove" in response.lower():
51 | items = [item.strip() for item in response.split("remove")[1].split(",")]
52 | for item in items:
53 | if item:
54 | changes.append({"name": item, "change_amount": -1})
55 | return changes
56 | except Exception:
57 | return []
58 |
59 | def can_use_item(self, inventory: Dict[str, int], item_name: str) -> bool:
60 | """Check if an item can be used based on inventory."""
61 | return item_name in inventory and inventory[item_name] > 0
62 |
63 | def get_item_description(self, item_name: str) -> str:
64 | """Get a description for a specific item."""
65 | system_prompt = """Generate a brief, engaging description for a fantasy game item.
66 | Keep it under 2 sentences."""
67 |
68 | messages = [
69 | {"role": "system", "content": system_prompt},
70 | {"role": "user", "content": f"Describe the item: {item_name}"}
71 | ]
72 |
73 | response = self.client.chat.completions.create(
74 | model="meta-llama/Llama-3-70b-chat-hf",
75 | messages=messages
76 | )
77 |
78 | return response.choices[0].message.content
79 |
80 | class ItemTooltip:
81 | def __init__(self):
82 | self.tooltips = {
83 | "Craftsman's hammer": "A versatile tool for construction and repairs. Useful for structural work.",
84 | "Set of precision tools": "Delicate instruments for detailed mechanical work.",
85 | "Blueprint journal": "Contains architectural designs and notes. May reveal hidden structural patterns.",
86 | "Enchanted measuring tape": "Magically reveals precise measurements and structural weaknesses.",
87 | "Courage charm": "Emboldens both the bearer and those nearby."
88 | }
89 |
90 | def get_tooltip(self, item_name: str) -> dict:
91 | base_tooltip = self.tooltips.get(item_name, "A useful item")
92 | return {
93 | "description": base_tooltip,
94 | "usage_hint": "Try using this item when examining structures or mechanisms"
95 | }
96 |
97 | # Add to inventory_manager.py
98 | def get_item_tooltip(self, item_name: str) -> Dict:
99 | tooltip = ItemTooltip()
100 | return tooltip.get_tooltip(item_name)
--------------------------------------------------------------------------------
/shared_data/inventory.json:
--------------------------------------------------------------------------------
1 | {
2 | "inventories": {
3 | "Eira the Brave": [
4 | "10 gold",
5 | "Enchanted shield",
6 | "Warrior's medallion",
7 | "Healing poultice",
8 | "Courage charm"
9 | ],
10 | "Seren the Builder": [
11 | "10 gold",
12 | "Craftsman's hammer",
13 | "Set of precision tools",
14 | "Blueprint journal",
15 | "Enchanted measuring tape"
16 | ],
17 | "Liora the Whisperer": [
18 | "10 gold",
19 | "Mystic communication crystal",
20 | "Essence collector",
21 | "Book of whispered secrets",
22 | "Spirit-touched amulet"
23 | ],
24 | "Kael the Wise": [
25 | "10 gold",
26 | "Ancient tome",
27 | "Wisdom crystal",
28 | "Scroll case",
29 | "Memory stones"
30 | ],
31 | "Kael the Builder": [
32 | "10 gold",
33 | "Craftsman's hammer",
34 | "Set of precision tools",
35 | "Blueprint journal",
36 | "Enchanted measuring tape"
37 | ],
38 | "Kael the Whisperer": [
39 | "10 gold",
40 | "Mystic communication crystal",
41 | "Essence collector",
42 | "Book of whispered secrets",
43 | "Spirit-touched amulet"
44 | ],
45 | "Seren the Wanderer": [
46 | "10 gold",
47 | "Traveler's compass",
48 | "Weather-worn map",
49 | "Survival kit",
50 | "Lucky charm"
51 | ],
52 | "Thorin the Wanderer": [
53 | "10 gold",
54 | "Traveler's compass",
55 | "Weather-worn map",
56 | "Survival kit",
57 | "Lucky charm"
58 | ],
59 | "Liora the Wise": [
60 | "10 gold",
61 | "Ancient tome",
62 | "Wisdom crystal",
63 | "Scroll case",
64 | "Memory stones"
65 | ],
66 | "Thorin the Brave": [
67 | "10 gold",
68 | "Enchanted shield",
69 | "Warrior's medallion",
70 | "Healing poultice",
71 | "Courage charm"
72 | ],
73 | "Liora the Builder": [
74 | "10 gold",
75 | "Craftsman's hammer",
76 | "Set of precision tools",
77 | "Blueprint journal",
78 | "Enchanted measuring tape"
79 | ],
80 | "Liora the Brave": [
81 | "10 gold",
82 | "Enchanted shield",
83 | "Warrior's medallion",
84 | "Healing poultice",
85 | "Courage charm"
86 | ],
87 | "Thorin the Whisperer": [
88 | "10 gold",
89 | "Mystic communication crystal",
90 | "Essence collector",
91 | "Book of whispered secrets",
92 | "Spirit-touched amulet"
93 | ],
94 | "Liora the Wanderer": [
95 | "10 gold",
96 | "Traveler's compass",
97 | "Weather-worn map",
98 | "Survival kit",
99 | "Lucky charm"
100 | ],
101 | "Thorin the Builder": [
102 | "10 gold",
103 | "Craftsman's hammer",
104 | "Set of precision tools",
105 | "Blueprint journal",
106 | "Enchanted measuring tape"
107 | ],
108 | "Seren the Whisperer": [
109 | "10 gold",
110 | "Mystic communication crystal",
111 | "Essence collector",
112 | "Book of whispered secrets",
113 | "Spirit-touched amulet"
114 | ],
115 | "Seren the Brave": [
116 | "10 gold",
117 | "Enchanted shield",
118 | "Warrior's medallion",
119 | "Healing poultice",
120 | "Courage charm"
121 | ],
122 | "Thorin the Wise": [
123 | "10 gold",
124 | "Ancient tome",
125 | "Wisdom crystal",
126 | "Scroll case",
127 | "Memory stones"
128 | ],
129 | "Eira the Builder": [
130 | "10 gold",
131 | "Craftsman's hammer",
132 | "Set of precision tools",
133 | "Blueprint journal",
134 | "Enchanted measuring tape"
135 | ],
136 | "Eira the Wanderer": [
137 | "10 gold",
138 | "Traveler's compass",
139 | "Weather-worn map",
140 | "Survival kit",
141 | "Lucky charm"
142 | ],
143 | "Eira the Wise": [
144 | "10 gold",
145 | "Ancient tome",
146 | "Wisdom crystal",
147 | "Scroll case",
148 | "Memory stones"
149 | ],
150 | "Kael the Brave": [
151 | "10 gold",
152 | "Enchanted shield",
153 | "Warrior's medallion",
154 | "Healing poultice",
155 | "Courage charm"
156 | ],
157 | "Kael the Wanderer": [
158 | "10 gold",
159 | "Traveler's compass",
160 | "Weather-worn map",
161 | "Survival kit",
162 | "Lucky charm"
163 | ],
164 | "Seren the Wise": [
165 | "10 gold",
166 | "Ancient tome",
167 | "Wisdom crystal",
168 | "Scroll case",
169 | "Memory stones"
170 | ],
171 | "Eira the Whisperer": [
172 | "10 gold",
173 | "Mystic communication crystal",
174 | "Essence collector",
175 | "Book of whispered secrets",
176 | "Spirit-touched amulet"
177 | ]
178 | }
179 | }
--------------------------------------------------------------------------------
/shared_data/inventory.txt:
--------------------------------------------------------------------------------
1 | {
2 | "inventories": {
3 | "Elara Asteria": [
4 | "10 gold",
5 | "Ancient language decoder tablet",
6 | "Set of magnifying lenses",
7 | "Notebook of rune studies",
8 | "Leather-bound gloves"
9 | ],
10 | "Elara Embermist": [
11 | "10 gold",
12 | "Gleaming invention blueprint",
13 | "Fireproof gloves",
14 | "Tiny mechanical dragon",
15 | "Enchanted wrench"
16 | ],
17 | "Elara Moonweaver": [
18 | "10 gold",
19 | "Moonlight-infused cloak",
20 | "Crystal healing staff",
21 | "Flask of glowing stardust",
22 | "Book of calming incantations"
23 | ],
24 | "Elara Windsong": [
25 | "10 gold",
26 | "Wind-touched compass",
27 | "Skyborne goggles",
28 | "Map of aerial currents",
29 | "Harness of silk threads"
30 | ],
31 | "Eluned Aria": [
32 | "10 gold",
33 | "Botanist's handbook",
34 | "Set of rare plant seeds",
35 | "Glowstone lantern",
36 | "Bottle of soothing elixir"
37 | ],
38 | "Eluned Moonwhisper": [
39 | "10 gold",
40 | "Lunar pendant",
41 | "Celestial globe",
42 | "Book of prophecies",
43 | "Silver-threaded robes"
44 | ],
45 | "Eluned Starweaver": [
46 | "10 gold",
47 | "Celestial ink pot",
48 | "Starry night cape",
49 | "Crystal-tipped wand",
50 | "Scroll of cosmic charts"
51 | ],
52 | "Gorvoth Ironfurnace": [
53 | "10 gold",
54 | "Molten hammer",
55 | "Heat-resistant shield",
56 | "Set of rune-forged tongs",
57 | "Glowing volcanic stone"
58 | ],
59 | "Kael Darksong": [
60 | "10 gold",
61 | "Explorer's map",
62 | "Weathered journal",
63 | "Set of excavation tools",
64 | "Small offering stone"
65 | ],
66 | "Kael Rivenstone": [
67 | "10 gold",
68 | "Windsmith's toolkit",
69 | "Weather vane prototype",
70 | "Set of blueprints",
71 | "Handcrafted wind chime"
72 | ],
73 | "Kael Starseeker": [
74 | "10 gold",
75 | "Glowing star chart",
76 | "Exploration satchel",
77 | "Small telescope",
78 | "Engraved compass"
79 | ],
80 | "Kael Thunderforged": [
81 | "10 gold",
82 | "Storm-powered hammer",
83 | "Electrical toolkit",
84 | "Runic blueprint",
85 | "Set of conductor rods"
86 | ],
87 | "Kaelin Darkforges": [
88 | "10 gold",
89 | "Forged blade prototype",
90 | "Fire-etched armor piece",
91 | "Heavy-duty tongs",
92 | "Flame-powered bellows"
93 | ],
94 | "Kaelin Darkhaven": [
95 | "10 gold",
96 | "Runic appraisal lens",
97 | "Obsidian shard",
98 | "Artifact research journal",
99 | "Mystic appraisal gloves"
100 | ],
101 | "Kaelin Stonefist": [
102 | "10 gold",
103 | "Geomancer's chisel",
104 | "Chunk of rare ore",
105 | "Notebook of geological sketches",
106 | "Carved figurine of the Sage"
107 | ],
108 | "Kaid Rylan": [
109 | "10 gold",
110 | "Healer's herbs",
111 | "Potion of vigor",
112 | "Medical tools set",
113 | "Worn leather boots"
114 | ],
115 | "Kaida Moonwhisper": [
116 | "10 gold",
117 | "Healing crystal",
118 | "Moonbeam staff",
119 | "Potion of radiance",
120 | "Book of ancient healing chants"
121 | ],
122 | "Lyra Emberwing": [
123 | "10 gold",
124 | "Treasure map",
125 | "Light cloak with flame patterns",
126 | "Miniature gemstone pickaxe",
127 | "Set of enchanted climbing hooks"
128 | ],
129 | "Lyra Flynn": [
130 | "10 gold",
131 | "Crystal tuning wand",
132 | "Notebook of resonance sketches",
133 | "Set of artisan's tools",
134 | "Iridescent gemstone"
135 | ],
136 | "Lyra Frostwhisper": [
137 | "10 gold",
138 | "Illusionist's cloak",
139 | "Book of ethereal performances",
140 | "Set of reflective prisms",
141 | "Mask of shifting shadows"
142 | ],
143 | "Lyra Moonwhisper": [
144 | "10 gold",
145 | "Crystal-tipped staff",
146 | "Silver-embroidered robes",
147 | "Scroll of ancient wisdom",
148 | "Flask of moonlit water"
149 | ],
150 | "Lyra Stellaluna": [
151 | "10 gold",
152 | "Star-tipped quill",
153 | "Celestial notebook",
154 | "Astronomer's goggles",
155 | "Set of star maps"
156 | ],
157 | "Lyra Stormsurge": [
158 | "10 gold",
159 | "Windboard",
160 | "Goggles of the storm",
161 | "Lightning rod",
162 | "Set of stormproof gear"
163 | ],
164 | "Orion Brightshore": [
165 | "10 gold",
166 | "Toolbox of celestial gadgets",
167 | "Polished mirror lens",
168 | "Energy-infused crystal",
169 | "Glowing blueprint of lenses"
170 | ],
171 | "Thrain Stonefist": [
172 | "10 gold",
173 | "Runed ceremonial hammer",
174 | "Flame-patterned gloves",
175 | "Notebook of wisdom tales",
176 | "Set of fire-resistant armor"
177 | ]
178 | }
179 | }
180 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Fantasy RPG Game with CrewAI Agents 🎮
2 |
3 | A text-based RPG game that uses CrewAI agents and Together AI to create dynamic, interactive fantasy worlds. The game features hierarchical content generation, state management, and an immersive chat interface.
4 |
5 | ## System Architecture
6 |
7 | ```mermaid
8 | graph TD
9 | A[Player Input] --> B[Game Master Agent]
10 | B --> C[World Builder Agent]
11 | B --> D[Inventory Manager Agent]
12 | B --> E[Safety Checker Agent]
13 |
14 | C --> F[World Generation]
15 | F --> G[Kingdoms]
16 | G --> H[Towns]
17 | H --> I[NPCs]
18 |
19 | D --> J[Inventory State]
20 | J --> K[Item Management]
21 |
22 | B --> L[Game State]
23 | L --> M[Current Location]
24 | L --> N[Player History]
25 | L --> O[World State]
26 | ```
27 |
28 | ## CrewAI Agent Framework 🤖
29 |
30 | The game uses four main agents that work together:
31 |
32 | 1. **World Builder Agent**
33 | ```python
34 | class WorldBuilderAgent:
35 | def __init__(self, api_key):
36 | self.agent = Agent(
37 | role='World Builder',
38 | goal='Create rich fantasy worlds',
39 | backstory='Expert at worldbuilding',
40 | allow_delegation=False,
41 | llm=CustomTogetherModel(api_key)
42 | )
43 | ```
44 |
45 | 2. **Game Master Agent**
46 | ```python
47 | class GameMasterAgent:
48 | def __init__(self, api_key):
49 | self.agent = Agent(
50 | role='Game Master',
51 | goal='Manage game flow',
52 | backstory='Expert storyteller',
53 | allow_delegation=True,
54 | llm=CustomTogetherModel(api_key)
55 | )
56 | ```
57 |
58 | Agent interaction flow:
59 | ```
60 | Player Input -> Game Master -> World Builder -> Game State Update
61 | ↓
62 | Safety Checker
63 | ↓
64 | Inventory Manager
65 | ```
66 |
67 | ## Game State Management 🎲
68 |
69 | Game state is managed through a Pydantic model:
70 | ```python
71 | class GameState(BaseModel):
72 | world: Dict
73 | current_location: Dict
74 | inventory: Dict[str, int]
75 | history: List[Dict]
76 | ```
77 |
78 | State components:
79 | - World: Contains all generated content
80 | - Location: Player's current position
81 | - Inventory: Player's items
82 | - History: Action/response log
83 |
84 | ## Hierarchical Content Generation 🏰
85 |
86 | Content is generated in layers:
87 |
88 | 1. **World Generation**
89 | ```
90 | World
91 | ├── Description
92 | └── Start Message
93 | ```
94 |
95 | 2. **Kingdom Generation**
96 | ```
97 | Kingdom
98 | ├── Name
99 | ├── Description
100 | └── Unique Features
101 | ```
102 |
103 | 3. **Town Generation**
104 | ```
105 | Town
106 | ├── Name
107 | ├── Description
108 | ├── Location Details
109 | └── Points of Interest
110 | ```
111 |
112 | 4. **NPC Generation**
113 | ```
114 | NPC
115 | ├── Name
116 | ├── Description
117 | ├── Background
118 | └── Motivations
119 | ```
120 |
121 | ## Getting Started 🚀
122 |
123 | 1. Install requirements:
124 | ```bash
125 | pip install -r requirements.txt
126 | ```
127 |
128 | 2. Set up environment:
129 | ```bash
130 | # .env file
131 | TOGETHER_API_KEY=your_api_key_here
132 | ```
133 |
134 | 3. Initialize world:
135 | ```bash
136 | python create_world.py
137 | ```
138 |
139 | 4. Run the game:
140 | ```bash
141 | python main.py
142 | ```
143 |
144 | ## Key Features ✨
145 |
146 | 1. **Dynamic World Generation**
147 | - Procedurally generated content
148 | - Consistent world logic
149 | - Rich NPC interactions
150 |
151 | 2. **Inventory System**
152 | - Item management
153 | - Drag-and-drop interface
154 | - Real-time updates
155 |
156 | 3. **State Management**
157 | - Persistent game state
158 | - History tracking
159 | - Save/load functionality
160 |
161 | 4. **Safety Checks**
162 | - Content filtering
163 | - Input validation
164 | - Error handling
165 |
166 | ## Content Generation Example 🎨
167 |
168 | ```python
169 | # Generate a new kingdom
170 | kingdom = world_builder.generate_kingdoms(world_data)
171 |
172 | # Generate towns for the kingdom
173 | towns = world_builder.generate_towns(world_data, kingdom)
174 |
175 | # Generate NPCs for each town
176 | npcs = world_builder.generate_npcs(world_data, kingdom, town)
177 | ```
178 |
179 | ## Advanced Features 🔧
180 |
181 | 1. **Custom Together AI Integration**
182 | ```python
183 | class CustomTogetherModel(BaseChatModel):
184 | def _generate(self, messages: List[Dict[str, Any]], stop: List[str] | None = None) -> str:
185 | response = self.client.chat.completions.create(
186 | model="meta-llama/Llama-3-70b-chat-hf",
187 | messages=messages
188 | )
189 | return response.choices[0].message.content
190 | ```
191 |
192 | 2. **Inventory Management**
193 | ```python
194 | def update_inventory(self, item_updates):
195 | for update in item_updates:
196 | name = update['name']
197 | change = update['change_amount']
198 |
199 | if name not in self.inventory:
200 | self.inventory[name] = 0
201 | self.inventory[name] += change
202 | ```
203 |
204 | 3. **Event Logging**
205 | ```python
206 | logging.info(f"Action processed: {action}")
207 | logging.info(f"Current inventory: {game_state.inventory}")
208 | ```
209 |
210 | ## License 📄
211 |
212 | MIT License - feel free to use and modify for your own projects!
--------------------------------------------------------------------------------
/core/game_state.py:
--------------------------------------------------------------------------------
1 | from pydantic import BaseModel
2 | from typing import Dict, List, Any, Optional
3 | from .puzzle_state import PuzzleProgress, TaskProgress
4 | import json
5 | import logging
6 |
7 | class GameState(BaseModel):
8 | world: Dict
9 | current_location: Dict
10 | inventory: Dict[str, int]
11 | history: List[Dict]
12 | puzzle_progress: Optional[PuzzleProgress] = None
13 | character: Dict[str, Any] = {}
14 |
15 | def to_string(self):
16 | return f"""
17 | World: {self.world.get('name')}
18 | Location: {self.current_location.get('name')}
19 | Inventory: {self.inventory}
20 | """
21 |
22 | def validate_transaction(self, item: str, cost: int) -> bool:
23 | """Validate if a transaction is possible."""
24 | if 'gold' not in self.inventory:
25 | return False
26 | return self.inventory['gold'] >= cost
27 |
28 | def process_transaction(self, item: str, cost: int) -> bool:
29 | """Process a purchase transaction."""
30 | if self.validate_transaction(item, cost):
31 | self.inventory['gold'] -= cost
32 | self.inventory[item] = self.inventory.get(item, 0) + 1
33 | return True
34 | return False
35 |
36 | def update_inventory(self, changes: List[Dict[str, Any]]) -> bool:
37 | """Update inventory with validation."""
38 | old_inventory = self.inventory.copy()
39 |
40 | for change in changes:
41 | item_name = change['name']
42 | amount = change['amount']
43 |
44 | if item_name not in self.inventory and amount < 0:
45 | return False
46 |
47 | new_count = self.inventory.get(item_name, 0) + amount
48 | if new_count < 0:
49 | return False
50 |
51 | if new_count == 0:
52 | del self.inventory[item_name]
53 | else:
54 | self.inventory[item_name] = new_count
55 |
56 | return True
57 |
58 | def load_character_inventory(self, character_name: str):
59 | """Load character's starting inventory"""
60 | try:
61 | with open('shared_data/inventory.json', 'r') as f:
62 | inventory_data = json.load(f)
63 |
64 | # Get character's inventory if exists"
65 | if character_name in inventory_data['inventories']:
66 | char_items = inventory_data['inventories'][character_name]
67 | inventory = {}
68 |
69 | # Process items (convert "X gold" to number, other items to count of 1)
70 | for item in char_items:
71 | if item.endswith('gold'):
72 | inventory['gold'] = int(item.split()[0])
73 | else:
74 | inventory[item] = 1
75 | return inventory
76 | else:
77 | # Default inventory for characters not in inventory.json
78 | return {
79 | "gold": 10,
80 | "Basic supplies": 1,
81 | "Adventurer's kit": 1,
82 | "Traveler's notes": 1
83 | }
84 |
85 | except Exception as e:
86 | logging.error(f"Error loading character inventory: {e}")
87 | return {"gold": 10}
88 |
89 | def add_to_history(self, action: str, response: str):
90 | """Add action and response to history."""
91 | self.history.append({
92 | 'action': action,
93 | 'response': response
94 | })
95 |
96 | def initialize_puzzle(self, character_name: str, world_data: Dict):
97 | """Initialize puzzle state for the character"""
98 | try:
99 | with open('shared_data/puzzle_data.json', 'r') as f:
100 | puzzle_data = json.load(f)
101 | world_name = self.world['name']
102 |
103 | if (world_name in puzzle_data['world_puzzles'] and
104 | character_name in puzzle_data['world_puzzles'][world_name]['characters']):
105 |
106 | char_puzzle = puzzle_data['world_puzzles'][world_name]['characters'][character_name]
107 | world_puzzle = puzzle_data['world_puzzles'][world_name]
108 |
109 | self.puzzle_progress = PuzzleProgress(
110 | main_puzzle=world_puzzle['main_puzzle'],
111 | solution_requirements=world_puzzle['solution_requirements'],
112 | total_tasks=len(char_puzzle['role_tasks']),
113 | completed_tasks=0,
114 | tasks={
115 | task['task_id']: TaskProgress(**task, completed=False)
116 | for task in char_puzzle['role_tasks']
117 | }
118 | )
119 |
120 | print(f"Puzzle initialized for {character_name}")
121 | return True
122 |
123 | except Exception as e:
124 | print(f"Error initializing puzzle: {e}")
125 | return False
126 |
127 | def attempt_task(self, task_id: str) -> Optional[str]:
128 | """Attempt to complete a task and return reward if successful"""
129 | if not self.puzzle_progress:
130 | logging.info("No puzzle progress available")
131 | return None
132 |
133 | if task_id in self.puzzle_progress.tasks and not self.puzzle_progress.tasks[task_id].completed:
134 | logging.info(f"Attempting to complete task: {task_id}")
135 | self.puzzle_progress.tasks[task_id].completed = True
136 | self.puzzle_progress.completed_tasks += 1
137 | reward = self.puzzle_progress.tasks[task_id].reward
138 | logging.info(f"Task completed successfully. Reward: {reward}")
139 | return reward
140 |
141 | logging.info(f"Task not available or already completed: {task_id}")
142 | return None
--------------------------------------------------------------------------------
/auth/routes.py:
--------------------------------------------------------------------------------
1 | from google.oauth2 import id_token
2 | from google.auth.transport import requests
3 | from flask import Blueprint, request, jsonify, session, redirect, render_template, url_for, send_file, Response
4 | import os
5 | from auth.models import UserModel
6 | import datetime
7 |
8 | auth = Blueprint('auth', __name__)
9 |
10 | @auth.route('/auth/google', methods=['POST'])
11 | def google_auth():
12 | try:
13 | # Log the incoming request
14 | print("Received auth request:", request.json)
15 |
16 | # Verify Google token
17 | token = request.json['token']
18 | idinfo = id_token.verify_oauth2_token(
19 | token,
20 | requests.Request(),
21 | os.getenv('GOOGLE_CLIENT_ID')
22 | )
23 |
24 | print("Verified token info:", idinfo) # Log the token info
25 |
26 | # Create/update user
27 | user_model = UserModel()
28 | user_id = user_model.create_user(idinfo)
29 |
30 | # Set session
31 | session['user_id'] = user_id
32 | session['google_id'] = idinfo['sub']
33 |
34 | return jsonify({
35 | 'success': True,
36 | 'user': {
37 | 'id': user_id,
38 | 'name': idinfo['name'],
39 | 'email': idinfo['email'],
40 | 'picture': idinfo.get('picture')
41 | }
42 | })
43 |
44 | except ValueError as e:
45 | print("Token verification error:", str(e)) # Log verification errors
46 | return jsonify({'error': 'Invalid token'}), 401
47 | except Exception as e:
48 | print("General error:", str(e)) # Log general errors
49 | return jsonify({'error': str(e)}), 500
50 |
51 | @auth.route('/auth/google/callback')
52 | def google_callback():
53 | try:
54 | # Get the ID token from the request
55 | token = request.args.get('credential')
56 | if not token:
57 | return jsonify({'error': 'No credential received'}), 400
58 |
59 | # Verify the token
60 | idinfo = id_token.verify_oauth2_token(
61 | token,
62 | requests.Request(),
63 | os.getenv('GOOGLE_CLIENT_ID')
64 | )
65 |
66 | # Get user info from the token
67 | user_info = {
68 | 'email': idinfo['email'],
69 | 'name': idinfo.get('name', ''),
70 | 'picture': idinfo.get('picture', '')
71 | }
72 |
73 | # Set session
74 | session['user'] = user_info
75 |
76 | return jsonify({'success': True, 'user': user_info})
77 |
78 | except Exception as e:
79 | return jsonify({'error': str(e)}), 400
80 |
81 | @auth.route('/auth/logout')
82 | def logout():
83 | session.clear()
84 | return jsonify({'success': True})
85 |
86 | @auth.route('/auth/user')
87 | def get_current_user():
88 | if 'user_id' not in session:
89 | return jsonify(None)
90 |
91 | user_model = UserModel()
92 | user = user_model.get_user(session['google_id'])
93 | return jsonify(user)
94 |
95 | @auth.route('/api/placeholder//')
96 | def placeholder_image(width: int, height: int):
97 | """Generate a simple placeholder image"""
98 | try:
99 | from PIL import Image, ImageDraw
100 | import io
101 |
102 | # Limit dimensions for security
103 | width = min(width, 1024)
104 | height = min(height, 1024)
105 |
106 | # Create base image
107 | img = Image.new('RGB', (width, height), color='#2a2a3d')
108 | draw = ImageDraw.Draw(img)
109 |
110 | # Draw a simple pattern instead of text
111 | margin = min(width, height) // 10
112 | draw.rectangle(
113 | [margin, margin, width - margin, height - margin],
114 | outline='#ffffff',
115 | width=2
116 | )
117 | draw.line([(margin, margin), (width - margin, height - margin)], fill='#ffffff', width=2)
118 | draw.line([(margin, height - margin), (width - margin, margin)], fill='#ffffff', width=2)
119 |
120 | # Save to buffer
121 | img_io = io.BytesIO()
122 | img.save(img_io, format='PNG')
123 | img_io.seek(0)
124 |
125 | # Removed cache_timeout parameter
126 | return send_file(
127 | img_io,
128 | mimetype='image/png'
129 | )
130 |
131 | except Exception as e:
132 | print(f"Placeholder image error: {e}")
133 | # Return simple colored rectangle as fallback
134 | return Response(
135 | b'\x89PNG\r\n\x1a\n' + b'\x00' * 100,
136 | mimetype='image/png'
137 | )
138 |
139 | @auth.route('/add-victory', methods=['POST'])
140 | def add_victory():
141 | if 'user_id' not in session:
142 | return jsonify({"error": "Not authenticated"}), 401
143 |
144 | try:
145 | victory_data = request.json
146 | user_model = UserModel()
147 | success = user_model.add_victory(session['user_id'], victory_data)
148 |
149 | if success:
150 | return jsonify({"success": True})
151 | else:
152 | return jsonify({"error": "Failed to store victory"}), 500
153 |
154 | except Exception as e:
155 | return jsonify({"error": str(e)}), 500
156 |
157 | @auth.route('/gallery')
158 | def gallery():
159 | if 'user_id' not in session:
160 | return redirect(url_for('home'))
161 |
162 | page = request.args.get('page', 1, type=int)
163 | user_model = UserModel()
164 |
165 | gallery_data = user_model.get_user_victories(session['user_id'], page)
166 |
167 | # Format dates for display
168 | for victory in gallery_data.get('victories', []):
169 | if isinstance(victory.get('created_at'), str):
170 | victory['created_at'] = datetime.fromisoformat(victory['created_at'])
171 |
172 | # Add user data
173 | user = user_model.get_user(session['google_id'])
174 |
175 | # Check if empty
176 | empty = not gallery_data.get('victories', [])
177 |
178 | return render_template('victory-album.html',
179 | gallery_data=gallery_data,
180 | current_page=page,
181 | user=user,
182 | empty=empty)
183 |
184 | @auth.route('/add-completion', methods=['POST'])
185 | def add_completion():
186 | if 'user_id' not in session:
187 | return jsonify({"error": "Not authenticated"}), 401
188 |
189 | try:
190 | data = request.json
191 | user_model = UserModel()
192 | user_model.add_completion(session['user_id'], data.get('completion_id'))
193 | return jsonify({"success": True})
194 | except Exception as e:
195 | return jsonify({"error": str(e)}), 500
--------------------------------------------------------------------------------
/templates/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Fantasy Adventure Game
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
Save your epic world with your unique character
22 |
A story you direct, brought to life by AI.
23 |
Start Adventure
24 |
25 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
1
38 |
Choose Your Destiny
39 |
Step into one of five unique realms - from the floating isles of Etheria to the clockwork cities of Mechanica. Each world offers distinct challenges, magical artifacts, and a rich tapestry of characters waiting to share their stories.
40 |
41 |
42 |
2
43 |
Forge Your Legend
44 |
Command powerful artifacts, from Enchanted Shields to Warrior's Medallions. Every decision shapes your journey as you interact with townsfolk, solve mystical puzzles, and work to save your realm from impending catastrophe.
45 |
46 |
47 |
3
48 |
Master Your Quest
49 |
Track your progress through dynamic quest lines, collect celestial fragments, and unlock ancient mysteries. Each completed task brings you closer to becoming the hero your chosen kingdom needs in its darkest hour.
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
Start Adventure
60 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 | Sign in
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
← Back
87 |
88 |
89 |
90 |
Choose Your World
91 |
92 |
93 |
94 |
95 |
96 |
Choose Your Kingdom
97 |
98 |
99 |
100 |
101 |
102 |
Choose Your Town
103 |
104 |
105 |
106 |
107 |
108 |
Choose Your Character
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
Quest Progress
118 |
121 |
122 |
123 |
124 |
125 |
126 |
141 |
142 |
143 |
144 |
145 | Send
146 |
147 |
148 |
149 |
150 |
151 |
152 |
153 |
154 |
155 |
156 |
157 |
158 |
Your Story Loading
159 |
160 |
161 |
167 |
168 |
171 |
172 |
173 |
174 |
--------------------------------------------------------------------------------
/templates/victory-album.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Victory Album
7 |
288 |
289 |
290 |
291 |
292 |
293 |
294 |
295 |
296 | Return Home
297 |
298 |
302 |
303 |
304 |
305 | {% if empty %}
306 |
307 |
No Victories Yet
308 |
Complete your first quest to begin building your legendary album
309 |
310 | {% else %}
311 | {% for victory in gallery_data.victories %}
312 |
313 | {% if victory.image_url %}
314 |
315 | {% else %}
316 |
317 | {% endif %}
318 |
319 |
Victory in {{ victory.world_name }}
320 |
As {{ victory.character_name }}
321 |
{{ victory.created_at.strftime('%B %d, %Y') }}
322 |
323 |
324 | {% endfor %}
325 | {% endif %}
326 |
327 |
328 |
329 |
330 |
No Victories Yet
331 |
Complete your first quest to begin building your legendary album
332 |
333 |
334 |
335 |
336 | {% if not empty and gallery_data.total > 0 %}
337 |
353 | {% endif %}
354 |
355 |
356 |
357 |
×
358 |
359 |
363 |
364 |
365 |
366 |
413 |
414 |
--------------------------------------------------------------------------------
/agents/game_master.py:
--------------------------------------------------------------------------------
1 | from crewai import Agent
2 | from together import Together
3 | from core.game_state import GameState
4 | from langchain.chat_models.base import BaseChatModel
5 | from typing import List, Dict, Any, Optional
6 | from pydantic import BaseModel, Field
7 | from openai import OpenAI
8 | import logging
9 | import random
10 | from db.client import MongoDBClient
11 |
12 | class CustomTogetherModel(BaseChatModel):
13 | client: Any = Field(default=None)
14 | model_name: str = Field(default="meta-llama/Llama-3-70b-chat-hf")
15 |
16 | class Config:
17 | arbitrary_types_allowed = True
18 |
19 | def __init__(self, together_client, **kwargs):
20 | super().__init__(**kwargs)
21 | object.__setattr__(self, 'client', together_client)
22 |
23 | @property
24 | def _llm_type(self) -> str:
25 | return "together"
26 |
27 | def _generate(self, messages: List[Dict[str, Any]], stop: List[str] | None = None) -> str:
28 | response = self.client.chat.completions.create(
29 | model=self.model_name,
30 | messages=[{"role": m["role"], "content": m["content"]} for m in messages]
31 | )
32 | return response.choices[0].message.content
33 |
34 | def _call(self, messages: List[Dict[str, Any]], stop: List[str] | None = None) -> str:
35 | return self._generate(messages, stop)
36 |
37 | @property
38 | def _identifying_params(self) -> Dict[str, Any]:
39 | return {"model": self.model_name}
40 |
41 | class GameMasterAgent:
42 | def __init__(self, api_key, openai_api_key):
43 | try:
44 | # Initialize Together client
45 | self.client = Together(api_key=api_key)
46 | self.chat_model = CustomTogetherModel(together_client=self.client)
47 | self.openai_client = OpenAI(api_key=openai_api_key)
48 | self.agent = Agent(
49 | role='Game Master',
50 | goal='Manage game flow and player interactions',
51 | backstory='Expert at creating engaging game narratives',
52 | allow_delegation=True,
53 | llm=self.chat_model
54 | )
55 | except Exception as e:
56 | print(f"Error initializing agent: {str(e)}")
57 | raise
58 |
59 | def generate_initial_story_image(self, character: str, location: Dict, world: Dict) -> Optional[Dict]:
60 | """Generate an image for the initial story scene"""
61 | try:
62 | # Craft a detailed prompt based on the character and location
63 | prompt = (
64 | f"A wide establishing shot of {character} exploring {location['name']} "
65 | f"in the fantasy world of {world['name']}. {location['description']} "
66 | "Epic fantasy game art style with dramatic lighting and cinematic composition."
67 | )
68 |
69 | # Generate image using OpenAI's DALL-E
70 | response = self.openai_client.images.generate(
71 | model="dall-e-3",
72 | prompt=prompt,
73 | n=1,
74 | size="1024x1024"
75 | )
76 |
77 | if response.data:
78 | return {
79 | 'url': response.data[0].url,
80 | 'type': 'establishing_shot',
81 | 'context': {
82 | 'character': character,
83 | 'location': location['name'],
84 | 'world': world['name']
85 | }
86 | }
87 |
88 | except Exception as e:
89 | print(f"Initial story image generation error: {e}")
90 | return None
91 |
92 | def _find_matching_task(self, action: str, available_tasks: List) -> Optional[Any]:
93 | """Find matching task based on action description."""
94 | logging.info(f"Attempting to match action: {action}")
95 |
96 | # Clean up the action string - remove quotes and extra spaces
97 | clean_action = action.strip('"\'').lower()
98 | action_words = set(clean_action.split())
99 |
100 | logging.info(f"Cleaned action words: {action_words}")
101 |
102 | for task in available_tasks:
103 | # Clean up description and title
104 | desc_words = set(task.description.lower().split())
105 | title_words = set(task.title.lower().split())
106 |
107 | # Calculate word overlap
108 | common_words = desc_words.intersection(action_words)
109 | desc_matches = len(common_words)
110 |
111 | logging.info(f"\nChecking task: {task.title}")
112 | logging.info(f"Task description words: {desc_words}")
113 | logging.info(f"Common words: {common_words}")
114 | logging.info(f"Number of matching words: {desc_matches}")
115 |
116 | # Calculate similarity percentage
117 | total_words = len(desc_words.union(action_words))
118 | similarity = desc_matches / total_words if total_words > 0 else 0
119 | logging.info(f"Similarity percentage: {similarity:.2%}")
120 |
121 | # First condition: exact match
122 | exact_match = clean_action == task.description.lower()
123 |
124 | # Second condition: significant word overlap
125 | word_overlap = desc_matches >= 4
126 |
127 | # Third condition: high similarity
128 | high_similarity = similarity >= 0.5
129 |
130 | logging.info(f"Exact match: {exact_match}")
131 | logging.info(f"Word overlap: {word_overlap}")
132 | logging.info(f"High similarity: {high_similarity}")
133 |
134 | if exact_match or word_overlap or high_similarity:
135 | logging.info(f"Found matching task - ID: {task.task_id}")
136 | return task
137 |
138 | logging.info("No matching task found")
139 | return None
140 |
141 | def _verify_required_items(self, required_items: List[str], inventory: Dict) -> bool:
142 | logging.info(f"Verifying items: {required_items}")
143 | return all(item in inventory and inventory[item] > 0 for item in required_items)
144 |
145 | def _consume_items(self, items: List[str], inventory: Dict) -> None:
146 | logging.info(f"Consuming items: {items}")
147 | for item in items:
148 | if item in inventory:
149 | inventory[item] -= 1
150 |
151 | def _process_item_use(self, item_name: str, game_state: GameState) -> str:
152 | """Process use of an item and return the result."""
153 | # Check if this action matches any pending tasks
154 | if game_state.puzzle_progress:
155 | available_tasks = game_state.puzzle_progress.get_available_tasks(game_state.inventory)
156 |
157 | for task in available_tasks:
158 | if task.required_item == item_name:
159 | reward = game_state.attempt_task(task.task_id)
160 | if reward:
161 | return f"Task completed: {task.description}. Received: {reward}"
162 |
163 | # Generic item use response
164 | return f"You use the {item_name}. Nothing special happens."
165 |
166 | def process_action(self, action: str, game_state: GameState) -> str:
167 | try:
168 | logging.info(f"\n=== Processing action: {action} ===")
169 |
170 | # Check for examine/inspect actions first
171 | if any(word in action.lower() for word in ['examine', 'inspect', 'look', 'check']):
172 | logging.info("Detected examine/inspect action")
173 | return self._generate_contextual_hints(game_state)
174 |
175 | # Handle item usage
176 | if action.lower().startswith('use '):
177 | logging.info("Detected item usage action")
178 | item_name = action[4:].strip()
179 | return self._process_item_use(item_name, game_state)
180 |
181 | # Try to match with puzzle tasks if puzzle exists
182 | puzzle_response = None
183 | if game_state.puzzle_progress:
184 | available_tasks = game_state.puzzle_progress.get_available_tasks(game_state.inventory)
185 | logging.info(f"Number of available tasks: {len(available_tasks)}")
186 | logging.info(f"Available tasks: {[task.title for task in available_tasks]}")
187 |
188 | matching_task = self._find_matching_task(action, available_tasks)
189 |
190 | if matching_task:
191 | logging.info(f"Found matching task: {matching_task.title}")
192 | logging.info(f"Attempting task completion for task ID: {matching_task.task_id}")
193 |
194 | # Attempt the task directly when found
195 | reward = game_state.attempt_task(matching_task.task_id)
196 |
197 | if reward:
198 | logging.info(f"Task completed successfully. Reward: {reward}")
199 | puzzle_response = f"Task completed: {matching_task.description}. Received: {reward}"
200 |
201 | if game_state.puzzle_progress.is_puzzle_solved():
202 | logging.info("Puzzle has been solved!")
203 | puzzle_response += "\n\nCongratulations! You have solved the puzzle and saved the realm!"
204 |
205 | # Log updated puzzle progress
206 | logging.info(f"Updated puzzle progress - Completed tasks: {game_state.puzzle_progress.completed_tasks}/{game_state.puzzle_progress.total_tasks}")
207 | return puzzle_response
208 | else:
209 | logging.info("Task attempt failed")
210 | else:
211 | logging.info("No task matched for this action")
212 |
213 | logging.info("Generating contextual response using LLM")
214 | # If no puzzle match or no puzzle exists, generate contextual response
215 | location_name = game_state.current_location.get('name', 'this area')
216 | location_desc = game_state.current_location.get('description', '')
217 |
218 | system_prompt = f"""You are the Game Master of a fantasy RPG game. Current context:
219 | Location: {location_name}
220 | Description: {location_desc}
221 | Available items: {', '.join(game_state.inventory.keys())}
222 | Last action: {action}
223 |
224 | Respond in character as a game master, providing an engaging response to the player's action.
225 | Keep response under 3 sentences. Include references to the location and available items when relevant."""
226 |
227 | messages = [
228 | {"role": "system", "content": system_prompt},
229 | {"role": "user", "content": action}
230 | ]
231 |
232 | response = self.client.chat.completions.create(
233 | model="meta-llama/Llama-3-70b-chat-hf",
234 | messages=messages,
235 | temperature=0.7
236 | )
237 |
238 | llm_response = response.choices[0].message.content
239 | logging.info(f"Generated LLM response: {llm_response}")
240 |
241 | return llm_response
242 |
243 | except Exception as e:
244 | logging.error(f"Error in process_action: {str(e)}")
245 | logging.error(f"Full traceback: ", exc_info=True)
246 | return "Something unexpected happened. Please try a different action."
247 |
248 | def _generate_contextual_hints(self, game_state: GameState) -> str:
249 | if not game_state.puzzle_progress:
250 | return "You observe your surroundings carefully, but notice nothing unusual."
251 |
252 | available_tasks = game_state.puzzle_progress.get_available_tasks(game_state.inventory)
253 | if not available_tasks:
254 | return "The area seems peaceful for now."
255 |
256 | hints = []
257 | locations = {
258 | 'anchor': "You notice concerning instability in the nearby floating islands' anchor points.",
259 | 'crystal': "Ethereal crystals pulse with mysterious energy, seeming to respond to the realm's stability.",
260 | 'structure': "The supporting structures around you show signs of magical strain.",
261 | 'repair': "Various mechanisms appear to need maintenance and careful attention.",
262 | 'measurement': "You spot subtle variations in the magical field strengths that might need measuring.",
263 | 'defense': "The magical barriers protecting key areas seem to need reinforcement."
264 | }
265 |
266 | for task in available_tasks[:2]:
267 | for keyword, hint in locations.items():
268 | if keyword in task.description.lower() and hint not in hints:
269 | hints.append(hint)
270 | break
271 |
272 | for item in game_state.inventory:
273 | if any(item.lower() in task.required_item.lower() for task in available_tasks):
274 | hints.append(f"Your {item} might be useful in addressing some of these issues.")
275 |
276 | if not hints:
277 | return "You sense that your skills and tools could be put to good use here."
278 |
279 | return " ".join(hints)
280 |
281 | def generate_examples(self, context: str, location: Dict, game_state: GameState) -> List[str]:
282 | examples = set()
283 |
284 | environmental_actions = [
285 | f"Examine {location['name'].lower()}",
286 | f"Inspect the area",
287 | "Look around carefully"
288 | ]
289 | examples.add(random.choice(environmental_actions))
290 |
291 | if game_state.puzzle_progress:
292 | available_tasks = game_state.puzzle_progress.get_available_tasks(game_state.inventory)
293 | if available_tasks:
294 | for task in available_tasks[:2]:
295 | if task.required_item in game_state.inventory:
296 | examples.add(f"Use {task.required_item}")
297 |
298 | if 'npcs' in location:
299 | npc = random.choice(list(location['npcs'].values()))
300 | examples.add(f"Talk to {npc['name']}")
301 |
302 | example_list = list(examples)
303 | if len(example_list) < 3:
304 | example_list.extend([
305 | "Look around",
306 | "Check inventory",
307 | "View tasks"
308 | ])
309 | return example_list[:4]
310 |
311 | def _generate_system_prompt(self, game_state: GameState) -> str:
312 | """Generate context-aware system prompt."""
313 | location = game_state.current_location.get('name', 'this area')
314 | character = game_state.character.get('name', 'Adventurer') if isinstance(game_state.character, dict) else 'Adventurer'
315 |
316 | return f"""You are the Game Master for a fantasy RPG. Current context:
317 | - Location: {location}
318 | - Character: {character}
319 | - Available items: {', '.join(game_state.inventory.keys())}
320 |
321 | Respond in character as a game master, providing rich narrative responses
322 | while tracking game state and puzzle progress. Keep responses under 3 sentences."""
323 |
324 | def _generate_action_context(self, action: str, game_state: GameState) -> str:
325 | """Generate context for processing player actions."""
326 | return f"""Current action: {action}
327 | Location: {game_state.current_location.get('description', '')}
328 | Inventory: {game_state.inventory}
329 | Latest history: {game_state.history[-1] if game_state.history else 'None'}"""
330 |
331 | def generate_completion_image(self, game_state: GameState) -> Optional[Dict]:
332 | """Generate a final image capturing the player's journey and achievements"""
333 | try:
334 | # Build story summary and generate image as before
335 | story_summary = self._build_story_summary(game_state)
336 | character_name = game_state.character.get('name')
337 |
338 | if not character_name:
339 | logging.error("Character name not found in game state")
340 | return None
341 |
342 | prompt = f"A grand epic fantasy scene showing {character_name} in {game_state.world['name']} " \
343 | f"after saving the realm. {story_summary}"
344 |
345 | try:
346 | # Generate image using OpenAI's DALL-E
347 | response = self.openai_client.images.generate(
348 | model="dall-e-3",
349 | prompt=prompt,
350 | n=1,
351 | size="1024x1024",
352 | quality="hd",
353 | style="vivid"
354 | )
355 |
356 | if response.data:
357 | image_url = response.data[0].url
358 |
359 | # Initialize MongoDB client
360 | mongo_client = MongoDBClient()
361 |
362 | # Store image data and get game_id
363 | game_id = mongo_client.store_completion_image(
364 | image_url=image_url,
365 | puzzle_text=game_state.puzzle_progress.main_puzzle if game_state.puzzle_progress else "",
366 | world_name=game_state.world['name'],
367 | character_name=character_name
368 | )
369 |
370 | # Close MongoDB connection
371 | mongo_client.close()
372 |
373 | return {
374 | 'url': image_url,
375 | 'game_id': game_id,
376 | 'type': 'completion_shot',
377 | 'context': {
378 | 'character': character_name,
379 | 'world': game_state.world['name'],
380 | 'achievements': self._get_achievement_summary(game_state)
381 | }
382 | }
383 |
384 | except Exception as e:
385 | logging.error(f"DALL-E image generation error: {e}")
386 | return None
387 |
388 | except Exception as e:
389 | logging.error(f"Completion image generation error: {str(e)}")
390 | return None
391 |
392 | def _build_story_summary(self, game_state: GameState) -> str:
393 | """Build a narrative summary from the game history"""
394 | try:
395 | # Get character name safely
396 | character_name = game_state.character.get('name', 'the hero')
397 |
398 | key_moments = []
399 | task_descriptions = set()
400 |
401 | # Analyze game history for key moments and completed tasks
402 | for entry in game_state.history:
403 | if "completed" in entry.get('response', '').lower():
404 | key_moments.append(entry['response'])
405 |
406 | # Extract task descriptions
407 | if game_state.puzzle_progress:
408 | for task in game_state.puzzle_progress.tasks.values():
409 | if task.completed and task.description not in task_descriptions:
410 | task_descriptions.add(task.description)
411 |
412 | # Build the summary
413 | summary = f"Throughout their journey, {character_name} "
414 |
415 | # Add task achievements
416 | if task_descriptions:
417 | task_list = list(task_descriptions)
418 | if len(task_list) > 1:
419 | summary += f"{', '.join(task_list[:-1])}, and {task_list[-1]}. "
420 | else:
421 | summary += f"{task_list[0]}. "
422 |
423 | # Add world-specific context
424 | world_contexts = {
425 | "Etherion": "stabilizing the floating islands",
426 | "Mechanica": "restoring the great clockwork",
427 | "Aquaria": "healing the coral kingdoms",
428 | "Ignisia": "harmonizing the volcanic networks"
429 | }
430 |
431 | if game_state.world['name'] in world_contexts:
432 | summary += f"Their efforts succeeded in {world_contexts[game_state.world['name']]}. "
433 |
434 | return summary
435 | except Exception as e:
436 | logging.error(f"Error building story summary: {e}")
437 | return "completed their epic quest and saved the realm."
438 |
439 |
440 | def _get_achievement_summary(self, game_state: GameState) -> List[str]:
441 | """Get a list of major achievements from the game"""
442 | achievements = []
443 |
444 | if game_state.puzzle_progress:
445 | # Add completed tasks as achievements
446 | for task in game_state.puzzle_progress.tasks.values():
447 | if task.completed:
448 | achievements.append(task.title)
449 |
450 | # Add overall completion if puzzle is solved
451 | if game_state.puzzle_progress.is_puzzle_solved():
452 | achievements.append(f"Saved {game_state.world['name']}")
453 |
454 | return achievements
--------------------------------------------------------------------------------
/agents/world_builder.py:
--------------------------------------------------------------------------------
1 | from crewai import Agent
2 | from together import Together
3 | from core.game_state import GameState
4 | from langchain.chat_models.base import BaseChatModel
5 | from typing import List, Dict, Any
6 | from pydantic import BaseModel, Field
7 |
8 | class CustomTogetherModel(BaseChatModel):
9 | client: Any = Field(default=None)
10 | model_name: str = Field(default="meta-llama/Llama-3-70b-chat-hf")
11 |
12 | class Config:
13 | arbitrary_types_allowed = True
14 |
15 | def __init__(self, together_client, **kwargs):
16 | super().__init__(**kwargs)
17 | object.__setattr__(self, 'client', together_client)
18 |
19 | @property
20 | def _llm_type(self) -> str:
21 | return "together"
22 |
23 | def _generate(self, messages: List[Dict[str, Any]], stop: List[str] | None = None) -> str:
24 | response = self.client.chat.completions.create(
25 | model=self.model_name,
26 | messages=[{"role": m["role"], "content": m["content"]} for m in messages]
27 | )
28 | return response.choices[0].message.content
29 |
30 | def _call(self, messages: List[Dict[str, Any]], stop: List[str] | None = None) -> str:
31 | return self._generate(messages, stop)
32 |
33 | @property
34 | def _identifying_params(self) -> Dict[str, Any]:
35 | return {"model": self.model_name}
36 |
37 | class WorldBuilderAgent:
38 | def __init__(self, api_key):
39 | try:
40 | self.client = Together(api_key=api_key)
41 | self.chat_model = CustomTogetherModel(together_client=self.client)
42 | self.agent = Agent(
43 | role='World Builder',
44 | goal='Create rich, consistent fantasy worlds',
45 | backstory='Expert at creating fantasy worlds with complex hierarchies',
46 | allow_delegation=False,
47 | llm=self.chat_model
48 | )
49 | except Exception as e:
50 | print(f"Error initializing agent: {str(e)}")
51 | raise
52 |
53 | def generate_world(self, concept):
54 | system_prompt = """
55 | Create interesting fantasy worlds that players would love to play in.
56 | Instructions:
57 | - Only generate in plain text without formatting
58 | - Use simple clear language without being flowery
59 | - Stay below 3-5 sentences for each description
60 | """
61 |
62 | world_prompt = f"""
63 | Generate a creative description for a unique fantasy world with an
64 | interesting concept around {concept}.
65 |
66 | Output content in the form:
67 | World Name:
68 | World Description:
69 |
70 | World Name:"""
71 |
72 | messages = [
73 | {"role": "system", "content": system_prompt},
74 | {"role": "user", "content": world_prompt}
75 | ]
76 |
77 | response = self.client.chat.completions.create(
78 | model="meta-llama/Llama-3-70b-chat-hf",
79 | messages=messages
80 | )
81 |
82 | world_output = response.choices[0].message.content.strip()
83 |
84 | # Parse the response into a structured format
85 | world = {
86 | "name": world_output.split('\n')[0].strip().replace('World Name: ', ''),
87 | "description": '\n'.join(world_output.split('\n')[1:]).replace('World Description:', '').strip()
88 | }
89 |
90 | return world
91 |
92 | def generate_kingdoms(self, world_data):
93 | try:
94 | system_prompt = """
95 | Generate exactly 3 kingdoms for this fantasy world.
96 | Each kingdom must be unique with rich culture and history.
97 | Format must be exactly:
98 | Kingdom Name: [name]
99 | Kingdom Description: [description]
100 | """
101 |
102 | kingdom_prompt = f"""
103 | Create 3 unique kingdoms for {world_data['name']}.
104 | Each kingdom should have a connection to the Colossi and the world's magic.
105 |
106 | World Context: {world_data['description']}
107 |
108 | Respond with exactly:
109 | Kingdom Name: [First Kingdom Name]
110 | Kingdom Description: [First Kingdom Description]
111 |
112 | Kingdom Name: [Second Kingdom Name]
113 | Kingdom Description: [Second Kingdom Description]
114 |
115 | Kingdom Name: [Third Kingdom Name]
116 | Kingdom Description: [Third Kingdom Description]
117 | """
118 |
119 | print("\nGenerating kingdoms...")
120 | response = self.client.chat.completions.create(
121 | model="meta-llama/Llama-3-70b-chat-hf",
122 | messages=[
123 | {"role": "system", "content": system_prompt},
124 | {"role": "user", "content": kingdom_prompt}
125 | ],
126 | temperature=0.7
127 | )
128 |
129 | kingdoms = {}
130 | raw_output = response.choices[0].message.content
131 | print(f"\nRaw kingdom response: {raw_output}") # Debug print
132 |
133 | # Split by double newline to separate kingdoms
134 | kingdom_sections = [k for k in raw_output.split('\n\n') if 'Kingdom Name:' in k]
135 |
136 | for section in kingdom_sections:
137 | lines = section.split('\n')
138 | for i in range(len(lines)-1):
139 | if 'Kingdom Name:' in lines[i]:
140 | name = lines[i].split('Kingdom Name:')[1].strip()
141 | desc = lines[i+1].split('Kingdom Description:')[1].strip()
142 | kingdoms[name] = {
143 | "name": name,
144 | "description": desc,
145 | "world": world_data['name'],
146 | "towns": {} # Initialize empty towns dict
147 | }
148 | print(f"Created kingdom: {name}")
149 |
150 | if not kingdoms:
151 | print("No kingdoms parsed from response, using backup method...")
152 | # Backup parsing method
153 | lines = raw_output.split('\n')
154 | current_name = None
155 | for line in lines:
156 | if 'Kingdom Name:' in line:
157 | current_name = line.split('Kingdom Name:')[1].strip()
158 | elif 'Kingdom Description:' in line and current_name:
159 | desc = line.split('Kingdom Description:')[1].strip()
160 | kingdoms[current_name] = {
161 | "name": current_name,
162 | "description": desc,
163 | "world": world_data['name'],
164 | "towns": {}
165 | }
166 | print(f"Created kingdom (backup): {current_name}")
167 |
168 | if not kingdoms:
169 | raise ValueError("Failed to generate kingdoms")
170 |
171 | return kingdoms
172 |
173 | except Exception as e:
174 | print(f"Error generating kingdoms: {e}")
175 | # Return a single detailed kingdom instead of failing
176 | fallback_kingdom = {
177 | "First Kingdom": {
178 | "name": "First Kingdom",
179 | "description": f"The primary kingdom of {world_data['name']}, where the largest Colossi roam. The citizens have mastered the art of living atop these massive beasts, creating a unique civilization that moves with their titanic hosts.",
180 | "world": world_data['name'],
181 | "towns": {}
182 | }
183 | }
184 | return fallback_kingdom
185 |
186 | def generate_towns(self, world_data, kingdom_data):
187 | try:
188 | system_prompt = """
189 | Generate exactly 3 unique towns for a kingdom.
190 | Each town should have distinctive features and history.
191 | Format must be exactly:
192 | Town Name: [name]
193 | Town Description: [description]
194 | """
195 |
196 | town_prompt = f"""
197 | Create 3 unique towns for the kingdom of {kingdom_data['name']} in {world_data['name']}.
198 | Each town should reflect the kingdom's character and its relationship with the Colossi.
199 |
200 | Kingdom Context: {kingdom_data['description']}
201 | World Context: {world_data['description']}
202 |
203 | Respond with exactly:
204 | Town Name: [First Town Name]
205 | Town Description: [First Town Description]
206 |
207 | Town Name: [Second Town Name]
208 | Town Description: [Second Town Description]
209 |
210 | Town Name: [Third Town Name]
211 | Town Description: [Third Town Description]
212 | """
213 |
214 | print(f"\nGenerating towns for {kingdom_data['name']}...")
215 | response = self.client.chat.completions.create(
216 | model="meta-llama/Llama-3-70b-chat-hf",
217 | messages=[
218 | {"role": "system", "content": system_prompt},
219 | {"role": "user", "content": town_prompt}
220 | ],
221 | temperature=0.7
222 | )
223 |
224 | towns = {}
225 | raw_output = response.choices[0].message.content
226 | print(f"Raw town response received for {kingdom_data['name']}")
227 |
228 | # Split by double newline to separate towns
229 | town_sections = [t for t in raw_output.split('\n\n') if 'Town Name:' in t]
230 |
231 | for section in town_sections:
232 | try:
233 | lines = section.split('\n')
234 | name_line = next(line for line in lines if 'Town Name:' in line)
235 | desc_line = next(line for line in lines if 'Town Description:' in line)
236 |
237 | name = name_line.split('Town Name:')[1].strip()
238 | desc = desc_line.split('Town Description:')[1].strip()
239 |
240 | towns[name] = {
241 | "name": name,
242 | "description": desc,
243 | "world": world_data['name'],
244 | "kingdom": kingdom_data['name'],
245 | "npcs": {} # Initialize empty NPCs dict
246 | }
247 | print(f"Created town: {name}")
248 | except Exception as e:
249 | print(f"Error parsing town section: {e}")
250 | continue
251 |
252 | if not towns:
253 | print("No towns parsed from response, creating default town...")
254 | # Create at least one default town
255 | default_town = {
256 | "Central Haven": {
257 | "name": "Central Haven",
258 | "description": f"The primary settlement of {kingdom_data['name']}, nestled safely on the Colossus's back.",
259 | "world": world_data['name'],
260 | "kingdom": kingdom_data['name'],
261 | "npcs": {}
262 | }
263 | }
264 | return default_town
265 |
266 | return towns
267 |
268 | except Exception as e:
269 | print(f"Error generating towns: {e}")
270 | # Return a single detailed town instead of failing
271 | fallback_town = {
272 | "Central Haven": {
273 | "name": "Central Haven",
274 | "description": f"The primary settlement of {kingdom_data['name']}, nestled safely on the Colossus's back.",
275 | "world": world_data['name'],
276 | "kingdom": kingdom_data['name'],
277 | "npcs": {}
278 | }
279 | }
280 | return fallback_town
281 |
282 | def generate_npcs(self, world_data, kingdom_data, town_data):
283 | try:
284 | system_prompt = """
285 | Generate exactly 3 unique NPCs for a town.
286 | Each NPC should have a distinct personality, appearance, and role.
287 | Format must be exactly:
288 | Character Name: [name]
289 | Character Description: [description]
290 | """
291 |
292 | npc_prompt = f"""
293 | Create 3 unique characters for {town_data['name']} in {kingdom_data['name']}.
294 | Each character should reflect the town's character and the kingdom's culture.
295 |
296 | Town Context: {town_data['description']}
297 | Kingdom Context: {kingdom_data['description']}
298 | World Context: {world_data['description']}
299 |
300 | Respond with exactly:
301 | Character Name: [First Character Name]
302 | Character Description: [First Character Description]
303 |
304 | Character Name: [Second Character Name]
305 | Character Description: [Second Character Description]
306 |
307 | Character Name: [Third Character Name]
308 | Character Description: [Third Character Description]
309 | """
310 |
311 | print(f"\nGenerating NPCs for {town_data['name']}...")
312 | response = self.client.chat.completions.create(
313 | model="meta-llama/Llama-3-70b-chat-hf",
314 | messages=[
315 | {"role": "system", "content": system_prompt},
316 | {"role": "user", "content": npc_prompt}
317 | ],
318 | temperature=0.7
319 | )
320 |
321 | npcs = {}
322 | raw_output = response.choices[0].message.content
323 | print(f"Raw NPC response received for {town_data['name']}")
324 |
325 | # Split by double newline to separate characters
326 | npc_sections = [n for n in raw_output.split('\n\n') if 'Character Name:' in n]
327 |
328 | if not npc_sections:
329 | # Try alternative splitting method
330 | npc_sections = raw_output.split('Character Name:')[1:] # Skip first empty split
331 |
332 | for section in npc_sections:
333 | try:
334 | lines = section.split('\n')
335 | if 'Character Name:' in section:
336 | # Handle case where "Character Name:" is in the section
337 | name_line = next(line for line in lines if 'Character Name:' in line)
338 | desc_line = next(line for line in lines if 'Character Description:' in line)
339 | name = name_line.split('Character Name:')[1].strip()
340 | desc = desc_line.split('Character Description:')[1].strip()
341 | else:
342 | # Handle case where section starts directly with name
343 | name = lines[0].strip()
344 | desc = ' '.join(lines[1:]).replace('Character Description:', '').strip()
345 |
346 | if name and desc:
347 | npcs[name] = {
348 | "name": name,
349 | "description": desc,
350 | "world": world_data['name'],
351 | "kingdom": kingdom_data['name'],
352 | "town": town_data['name']
353 | }
354 | print(f"Created NPC: {name}")
355 | except Exception as e:
356 | print(f"Error parsing NPC section: {e}")
357 | continue
358 |
359 | if not npcs:
360 | print("No NPCs parsed from response, creating default NPCs...")
361 | # Create default NPCs for the town
362 | npcs = {
363 | "Town Elder": {
364 | "name": "Town Elder",
365 | "description": f"A wise and respected leader of {town_data['name']}, who understands the deep connection between the town and its Colossus.",
366 | "world": world_data['name'],
367 | "kingdom": kingdom_data['name'],
368 | "town": town_data['name']
369 | },
370 | "Merchant": {
371 | "name": "Merchant",
372 | "description": f"A charismatic trader who brings goods from across {kingdom_data['name']}, always ready with the latest news and gossip.",
373 | "world": world_data['name'],
374 | "kingdom": kingdom_data['name'],
375 | "town": town_data['name']
376 | },
377 | "Guard Captain": {
378 | "name": "Guard Captain",
379 | "description": f"A vigilant protector of {town_data['name']}, skilled in both combat and maintaining order in a city that moves with its Colossus.",
380 | "world": world_data['name'],
381 | "kingdom": kingdom_data['name'],
382 | "town": town_data['name']
383 | }
384 | }
385 |
386 | return npcs
387 |
388 | except Exception as e:
389 | print(f"Error generating NPCs: {e}")
390 | # Return default NPCs instead of failing
391 | return {
392 | "Local Guide": {
393 | "name": "Local Guide",
394 | "description": f"A friendly resident of {town_data['name']} who helps newcomers adjust to life on the Colossus.",
395 | "world": world_data['name'],
396 | "kingdom": kingdom_data['name'],
397 | "town": town_data['name']
398 | }
399 | }
400 |
401 | def build_complete_world(self, concept: str) -> Dict:
402 | """Build a complete world with kingdoms, towns, and NPCs."""
403 | try:
404 | print("\nStarting world generation process...")
405 |
406 | # Generate world
407 | world = self.generate_world(concept)
408 | print(f"\nCreated world: {world['name']}")
409 |
410 | # Generate kingdoms
411 | kingdoms = self.generate_kingdoms(world)
412 | world['kingdoms'] = kingdoms
413 | print(f"\nCreated {len(kingdoms)} kingdoms")
414 |
415 | # For each kingdom
416 | for kingdom_name, kingdom in kingdoms.items():
417 | print(f"\nGenerating content for kingdom: {kingdom_name}")
418 |
419 | # Generate towns
420 | towns = self.generate_towns(world, kingdom)
421 | kingdom['towns'] = towns
422 | print(f"Created {len(towns)} towns for {kingdom_name}")
423 |
424 | # For each town
425 | for town_name, town in towns.items():
426 | print(f"Generating NPCs for town: {town_name}")
427 | npcs = self.generate_npcs(world, kingdom, town)
428 | town['npcs'] = npcs
429 | print(f"Created {len(npcs)} NPCs for {town_name}")
430 |
431 | # Add start message
432 | first_kingdom = list(kingdoms.values())[0]
433 | first_town = list(first_kingdom['towns'].values())[0]
434 | first_npc = list(first_town['npcs'].values())[0]
435 |
436 | world['start'] = f"Welcome to {world['name']}! You begin your journey in {first_town['name']}, {first_town['description']} Your guide is {first_npc['name']}, {first_npc['description']}"
437 |
438 | print("\nWorld generation complete!")
439 | return world
440 |
441 | except Exception as e:
442 | print(f"\nError in world generation: {str(e)}")
443 | # Create a complete fallback world
444 | fallback_world = {
445 | "name": "Kyropeia",
446 | "description": "A realm where massive Colossi roam, carrying entire cities on their backs.",
447 | "kingdoms": {
448 | "Luminaria": {
449 | "name": "Luminaria",
450 | "description": "A kingdom built upon the largest Colossus, where magic and technology blend.",
451 | "world": "Kyropeia",
452 | "towns": {
453 | "First Town": {
454 | "name": "First Town",
455 | "description": "A bustling settlement on the Colossus's back.",
456 | "world": "Kyropeia",
457 | "kingdom": "Luminaria",
458 | "npcs": {
459 | "Guide": {
460 | "name": "Guide",
461 | "description": "A knowledgeable local who helps newcomers navigate the city.",
462 | "world": "Kyropeia",
463 | "kingdom": "Luminaria",
464 | "town": "First Town"
465 | }
466 | }
467 | }
468 | }
469 | }
470 | }
471 | }
472 | return fallback_world
--------------------------------------------------------------------------------
/main.py:
--------------------------------------------------------------------------------
1 | import os
2 | import logging
3 | import re
4 | from flask import Flask, render_template, request, jsonify, send_from_directory, redirect, Response
5 | from dotenv import load_dotenv
6 | from agents.world_builder import WorldBuilderAgent
7 | from agents.game_master import GameMasterAgent
8 | from core.game_state import GameState
9 | import json
10 | from datetime import datetime
11 | import random
12 | from typing import List, Dict
13 | from db.client import MongoDBClient
14 | import requests
15 | from auth.routes import auth
16 | from datetime import datetime, timedelta
17 |
18 |
19 | # Set up logging
20 | logging.basicConfig(
21 | filename=f'game_logs_{datetime.now().strftime("%Y%m%d_%H%M%S")}.log',
22 | level=logging.INFO,
23 | format='%(asctime)s - %(levelname)s - %(message)s'
24 | )
25 |
26 | app = Flask(__name__,
27 | static_folder='static',
28 | template_folder='templates'
29 | )
30 |
31 | app.secret_key = os.getenv('FLASK_SECRET_KEY', 'dev') # Change this in production
32 |
33 | app.config['SESSION_COOKIE_SECURE'] = True # Set to True in production
34 | app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
35 | app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=7)
36 |
37 | # if os.environ.get('RAILWAY_ENVIRONMENT'):
38 | # app.config['SERVER_NAME'] = 'victory.up.railway.app'
39 | # app.config['PREFERRED_URL_SCHEME'] = 'https'
40 |
41 | load_dotenv()
42 |
43 | app.register_blueprint(auth)
44 |
45 | def save_world(world, filename):
46 | """Save world data to a JSON file."""
47 | try:
48 | with open(filename, 'w') as f:
49 | json.dump(world, f, indent=2)
50 | logging.info(f"World saved to {filename}")
51 | return True
52 | except Exception as e:
53 | logging.error(f"Error saving world: {str(e)}")
54 | return False
55 |
56 | def load_world(filename):
57 | """Load world data from a JSON file."""
58 | try:
59 | with open(filename, 'r') as f:
60 | return json.load(f)
61 | except FileNotFoundError:
62 | logging.error(f"World file {filename} not found")
63 | return None
64 | except json.JSONDecodeError:
65 | logging.error(f"Error decoding {filename}")
66 | return None
67 |
68 | def display_worlds_info(worlds):
69 | """Display information about the created worlds."""
70 | print("\n=== Worlds Information ===")
71 | for world_name, world in worlds.items():
72 | print(f"\nWorld: {world_name}")
73 | print(f"Description: {world['description']}")
74 | print("\nKingdoms:")
75 | for kingdom_name, kingdom in world['kingdoms'].items():
76 | print(f"\n- {kingdom_name}")
77 | print(f" Description: {kingdom['description'][:100]}...")
78 | print(" Towns:")
79 | for town_name, town in kingdom['towns'].items():
80 | print(f" - {town_name}")
81 | print(" NPCs:")
82 | for npc_name in town['npcs'].keys():
83 | print(f" * {npc_name}")
84 | print("\n=== Worlds Creation Complete ===")
85 |
86 | def initialize_worlds():
87 | """Initialize or load the game worlds."""
88 | world_file = 'shared_data/game_world.json'
89 | os.makedirs('shared_data', exist_ok=True)
90 |
91 | # First try to load existing worlds
92 | if os.path.exists(world_file):
93 | logging.info("Loading existing worlds...")
94 | try:
95 | with open(world_file, 'r') as f:
96 | worlds_data = json.load(f)
97 | if 'worlds' in worlds_data: # Check if it has the correct structure
98 | logging.info("Successfully loaded existing worlds")
99 | return worlds_data['worlds']
100 | except Exception as e:
101 | logging.error(f"Error loading worlds: {e}")
102 |
103 | # Only generate new worlds if loading fails
104 | logging.info("Creating new worlds...")
105 | api_key = os.getenv('TOGETHER_API_KEY')
106 | openai_api_key = os.getenv('OPENAI_API_KEY')
107 |
108 | if not api_key or not openai_api_key:
109 | logging.error("API_KEYs not found")
110 | raise ValueError("API_KEYs not found")
111 |
112 | world_builder = WorldBuilderAgent(api_key)
113 | try:
114 | worlds = world_builder.generate_worlds() # Your world generation logic
115 |
116 | # Save the newly generated worlds
117 | with open(world_file, 'w') as f:
118 | json.dump({'worlds': worlds}, f, indent=2)
119 |
120 | return worlds
121 | except Exception as e:
122 | logging.error(f"Error creating worlds: {e}")
123 | raise
124 |
125 | def load_character_inventory(character_name):
126 | try:
127 | with open('shared_data/inventory.json', 'r') as f:
128 | data = json.load(f)
129 | inventory = {}
130 | char_items = data['inventories'].get(character_name, [])
131 |
132 | for item in char_items:
133 | if item.endswith('gold'):
134 | inventory['gold'] = int(item.split()[0])
135 | else:
136 | inventory[item] = 1
137 | return inventory
138 | except Exception as e:
139 | logging.error(f"Error loading character inventory: {e}")
140 | return {"gold": 10}
141 |
142 | def parse_inventory_changes(response_text: str, current_inventory: dict) -> dict:
143 | """Parse the response text for inventory changes and update the inventory."""
144 | new_inventory = current_inventory.copy()
145 | logging.info(f"Parsing inventory changes from response: {response_text[:100]}...")
146 |
147 | if "inventory now" in response_text.lower():
148 | try:
149 | start_idx = response_text.find('{')
150 | end_idx = response_text.find('}') + 1
151 | if start_idx != -1 and end_idx != -1:
152 | inventory_str = response_text[start_idx:end_idx]
153 | parsed_inventory = eval(inventory_str)
154 | if isinstance(parsed_inventory, dict):
155 | new_inventory = parsed_inventory
156 | logging.info(f"Updated inventory: {new_inventory}")
157 | except Exception as e:
158 | logging.error(f"Failed to parse inventory: {e}")
159 |
160 | # Handle purchases
161 | if "buy" in response_text.lower() and "gold" in response_text.lower():
162 | try:
163 | cost = int(re.search(r'\d+', response_text).group())
164 | if new_inventory.get('gold', 0) >= cost:
165 | new_inventory['gold'] -= cost
166 | item = re.search(r'buy (\w+)', response_text.lower()).group(1)
167 | new_inventory[item] = new_inventory.get(item, 0) + 1
168 | except Exception as e:
169 | logging.error(f"Failed to process purchase: {e}")
170 |
171 | return new_inventory
172 |
173 | def validate_inventory_change(old_inventory: dict, new_inventory: dict) -> bool:
174 | """Validate inventory changes."""
175 | if ('gold' in old_inventory and 'gold' in new_inventory and
176 | new_inventory['gold'] > old_inventory['gold']):
177 | return False
178 | return True
179 |
180 | def generate_examples(self, context: str, location: Dict, game_state: GameState) -> List[str]:
181 | examples = set()
182 |
183 | # Add environmental actions
184 | environmental_actions = [
185 | f"Examine {location['name'].lower()}",
186 | f"Inspect the area",
187 | "Look around carefully"
188 | ]
189 | examples.add(random.choice(environmental_actions))
190 |
191 | # Add inventory-based suggestions
192 | if game_state.puzzle_progress:
193 | available_tasks = game_state.puzzle_progress.get_available_tasks(game_state.inventory)
194 | if available_tasks:
195 | for task in available_tasks[:2]:
196 | if task.required_item in game_state.inventory:
197 | examples.add(f"Use {task.required_item}")
198 |
199 | # Add NPC interactions
200 | if 'npcs' in location:
201 | npc = random.choice(list(location['npcs'].values()))
202 | examples.add(f"Talk to {npc['name']}")
203 |
204 | return list(examples)[:4]
205 |
206 |
207 | # Initialize worlds and agents
208 | try:
209 | print("Initializing game worlds...")
210 | worlds = initialize_worlds()
211 | if worlds:
212 | logging.info(f"Loaded {len(worlds)} worlds")
213 | else:
214 | logging.error("Failed to load or generate worlds")
215 | raise ValueError("No worlds available")
216 |
217 | logging.info("Initializing game agents...")
218 | api_key = os.getenv('TOGETHER_API_KEY')
219 | openai_api_key = os.getenv('OPENAI_API_KEY')
220 | game_master = GameMasterAgent(
221 | api_key,
222 | openai_api_key=openai_api_key)
223 |
224 | # Initialize game state
225 | game_state = None # Will be initialized when character is selected
226 |
227 | except Exception as e:
228 | logging.critical(f"Critical error during initialization: {str(e)}")
229 | raise
230 |
231 | def extract_keywords(text):
232 | """Extract key elements from response text"""
233 | keywords = {
234 | 'npcs': [],
235 | 'items': [],
236 | 'locations': [],
237 | 'actions': []
238 | }
239 |
240 | # Extract NPCs (names and titles)
241 | npc_matches = re.findall(r'([A-Z][a-z]+ [A-Z][a-z]+|the [a-z]+ [a-z]+)', text)
242 | keywords['npcs'] = list(set(npc_matches))
243 |
244 | # Extract items
245 | item_matches = re.findall(r'(?:the |a |an )([a-z]+ [a-z]+)', text.lower())
246 | keywords['items'] = list(set(item_matches))
247 |
248 | # Extract locations
249 | loc_matches = re.findall(r'(?:at |in |near |by )(?:the )?([a-z]+ [a-z]+)', text.lower())
250 | keywords['locations'] = list(set(loc_matches))
251 |
252 | return keywords
253 |
254 | def process_regular_action(action):
255 | # Existing action processing logic
256 | response = game_master.process_action(action, game_state)
257 | return jsonify({
258 | 'response': response,
259 | 'puzzle_progress': game_state.puzzle_progress.dict() if game_state.puzzle_progress else None,
260 | 'inventory': game_state.inventory,
261 | 'puzzle_solved': False
262 | })
263 |
264 | @app.route('/static/')
265 | def send_static(path):
266 | return send_from_directory('static', path)
267 |
268 | @app.route('/')
269 | def home():
270 | google_client_id = os.getenv('GOOGLE_CLIENT_ID')
271 | return render_template('index.html', google_client_id=google_client_id)
272 |
273 | @app.route('/world-info')
274 | def world_info():
275 | world_file = 'shared_data/game_world.json'
276 | try:
277 | with open(world_file, 'r') as f:
278 | worlds = json.load(f)
279 | # print("Serving worlds data:", worlds) # Debug log
280 | return jsonify(worlds)
281 | except Exception as e:
282 | logging.error(f"Error loading worlds: {e}")
283 | return jsonify({'error': str(e)}), 500
284 |
285 | @app.route('/start-game', methods=['POST'])
286 | def start_game():
287 | try:
288 | # Get request data
289 | data = request.json
290 | character_name = data.get('character')
291 | world_name = data.get('world')
292 | kingdom_name = data.get('kingdom')
293 |
294 | # Load character inventory
295 | character_inventory = load_character_inventory(character_name)
296 |
297 | # Load puzzle data
298 | puzzle_data = None
299 | try:
300 | with open('shared_data/puzzle_data.json', 'r') as f:
301 | world_puzzles = json.load(f)['world_puzzles']
302 |
303 | logging.info(f"Attempting to initialize puzzle for world: {world_name}")
304 | logging.info(f"Attempting to initialize puzzle for character: {character_name}")
305 | logging.info(f"Available worlds in puzzle data: {list(world_puzzles.keys())}")
306 | if world_name in world_puzzles:
307 | logging.info(f"Available characters in {world_name}: {list(world_puzzles[world_name]['characters'].keys())}")
308 |
309 | if world_name in world_puzzles and character_name in world_puzzles[world_name]['characters']:
310 | puzzle_data = world_puzzles[world_name]['characters'][character_name]
311 | except Exception as e:
312 | logging.error(f"Error loading puzzle data: {e}")
313 |
314 | # Find world data
315 | world = None
316 | with open('shared_data/game_world.json', 'r') as f:
317 | worlds_data = json.load(f)
318 | world = worlds_data['worlds'].get(world_name)
319 |
320 | if not world:
321 | raise ValueError(f"World {world_name} not found")
322 |
323 | # Find character's town or select random town
324 | kingdom = world['kingdoms'].get(kingdom_name)
325 | if not kingdom:
326 | raise ValueError(f"Kingdom {kingdom_name} not found")
327 |
328 | character_town = None
329 | character_data = None
330 |
331 | # Search for character in towns
332 | for town in kingdom['towns'].values():
333 | if character_name in town['npcs']:
334 | character_town = town
335 | character_data = town['npcs'][character_name]
336 | break
337 |
338 | if not character_town:
339 | # Fallback to random town if character's town not found
340 | character_town = random.choice(list(kingdom['towns'].values()))
341 |
342 | if not character_data:
343 | raise ValueError(f"Character {character_name} not found")
344 |
345 | # Initialize game state
346 | global game_state
347 | game_state = GameState(
348 | world=world,
349 | current_location=character_town,
350 | inventory=character_inventory,
351 | history=[],
352 | character={
353 | 'name': character_name,
354 | 'description': character_data['description']
355 | }
356 | )
357 |
358 | # Initialize puzzle if data exists
359 | if puzzle_data:
360 | success = game_state.initialize_puzzle(character_name, worlds_data)
361 | if success:
362 | logging.info(f"Initialized puzzle for {character_name}")
363 | print(f"Initialized puzzle progress: {game_state.puzzle_progress}")
364 | else:
365 | logging.warning(f"Failed to initialize puzzle for {character_name}")
366 |
367 | # Create initial welcome message
368 | welcome_message = (
369 | f"Welcome to {world['name']}! You are {character_name} in "
370 | f"{character_town['name']}. {character_town['description']}"
371 | )
372 |
373 | if game_state.puzzle_progress:
374 | welcome_message += f"\n\nYour Quest: {game_state.puzzle_progress.main_puzzle}"
375 |
376 | # Generate initial story image
377 | try:
378 | initial_image = game_master.generate_initial_story_image(
379 | character=character_name,
380 | location=character_town,
381 | world=world
382 | )
383 | except Exception as e:
384 | logging.error(f"Image generation error: {e}")
385 | initial_image = None
386 |
387 | # Prepare location-specific items based on world
388 | location_items = []
389 | if "Ignisia" in world_name:
390 | location_items = ["Fire-resistant cloak", "Magma compass"]
391 | elif "Aquaria" in world_name:
392 | location_items = ["Water breathing charm", "Pearl compass"]
393 | elif "Mechanica" in world_name:
394 | location_items = ["Clockwork assistant", "Steam-powered toolkit"]
395 | elif "Terranova" in world_name:
396 | location_items = ["Nature's blessing stone", "Living compass"]
397 | elif "Etheria" in world_name:
398 | location_items = ["Ethereal crystal", "Void compass"]
399 |
400 | # Add location items to inventory if any
401 | if location_items:
402 | for item in location_items:
403 | character_inventory[item] = character_inventory.get(item, 0) + 1
404 |
405 | # Create response
406 | response = {
407 | 'location': {
408 | 'name': character_town['name'],
409 | 'description': character_town['description'],
410 | 'npcs': character_town['npcs']
411 | },
412 | 'inventory': character_inventory,
413 | 'message': welcome_message,
414 | 'character': {
415 | 'name': character_name,
416 | 'description': character_data['description']
417 | },
418 | 'world': {
419 | 'name': world['name'],
420 | 'description': world['description']
421 | },
422 | 'puzzle_progress': game_state.puzzle_progress.dict() if game_state.puzzle_progress else None
423 | }
424 |
425 | # Add initial image if generation was successful
426 | if initial_image:
427 | response['initial_image'] = initial_image
428 |
429 | # Log successful game start
430 | logging.info(f"Game started for character {character_name} in {world_name}")
431 |
432 | print("Starting game with character:", character_name)
433 | # print("Puzzle progress initialized:", game_state.puzzle_progress)
434 |
435 | return jsonify(response)
436 |
437 | except ValueError as ve:
438 | logging.error(f"Validation error in start_game: {str(ve)}")
439 | return jsonify({'error': str(ve)}), 400
440 |
441 | except Exception as e:
442 | logging.error(f"Error in start_game: {str(e)}")
443 | return jsonify({'error': "Failed to start game. Please try again."}), 500
444 |
445 | @app.route('/load-inventory', methods=['POST'])
446 | def load_inventory():
447 | try:
448 | character_name = request.json['character']
449 | inventory = load_character_inventory(character_name)
450 | return jsonify({'inventory': inventory})
451 | except Exception as e:
452 | logging.error(f"Error loading inventory: {e}")
453 | return jsonify({'error': str(e)}), 500
454 |
455 | @app.route('/action', methods=['POST'])
456 | def process_action():
457 | action = request.json['action']
458 | logging.info(f"Processing action: {action}")
459 |
460 | try:
461 | response = None
462 | puzzle_progress = None
463 | puzzle_solved = False
464 | completion_image = None
465 |
466 | # Check for puzzle completion
467 | if hasattr(game_state, 'puzzle_progress') and game_state.puzzle_progress:
468 | available_tasks = game_state.puzzle_progress.get_available_tasks(game_state.inventory)
469 | matching_task = None
470 |
471 | # Match action with available tasks
472 | for task in available_tasks:
473 | task_keywords = set(task.description.lower().split())
474 | action_keywords = set(action.lower().split())
475 | if len(task_keywords.intersection(action_keywords)) >= 2:
476 | matching_task = task
477 | break
478 |
479 | if matching_task:
480 | reward = game_state.attempt_task(matching_task.task_id)
481 | if reward:
482 | response = f"Task completed: {matching_task.description}. Received: {reward}"
483 | puzzle_progress = game_state.puzzle_progress.dict()
484 | puzzle_solved = game_state.puzzle_progress.is_puzzle_solved()
485 |
486 | if puzzle_solved:
487 | # Only add completion message first
488 | response += "\n\nCongratulations! You have solved the puzzle and saved the realm!"
489 |
490 | # Regular game action processing if no task completed
491 | if not response:
492 | response = game_master.process_action(action, game_state)
493 |
494 | # Create base response data
495 | response_data = {
496 | 'response': response,
497 | 'inventory': game_state.inventory,
498 | 'location': game_state.current_location['name'],
499 | 'puzzle_progress': puzzle_progress,
500 | 'puzzle_solved': puzzle_solved,
501 | 'available_tasks': [
502 | {
503 | 'id': task.task_id,
504 | 'title': task.title,
505 | 'description': task.description
506 | }
507 | for task in game_state.puzzle_progress.get_available_tasks(game_state.inventory)
508 | ] if hasattr(game_state, 'puzzle_progress') and game_state.puzzle_progress else []
509 | }
510 |
511 | # Add completion context if puzzle is solved
512 | if puzzle_solved:
513 | response_data['character'] = {
514 | 'name': game_state.character['name'],
515 | 'description': game_state.character['description']
516 | }
517 | response_data['world'] = {
518 | 'name': game_state.world['name'],
519 | 'description': game_state.world['description']
520 | }
521 |
522 | return jsonify(response_data)
523 |
524 | except Exception as e:
525 | error_msg = f"Error processing action: {str(e)}"
526 | logging.error(error_msg)
527 | return jsonify({'error': error_msg}), 500
528 |
529 | @app.route('/generate-completion', methods=['POST'])
530 | def generate_completion():
531 | try:
532 | completion_image = game_master.generate_completion_image(game_state)
533 | if completion_image:
534 | return jsonify({
535 | 'completion_image': completion_image,
536 | 'success': True
537 | })
538 | return jsonify({'success': False}), 404
539 | except Exception as e:
540 | logging.error(f"Error generating completion image: {e}")
541 | return jsonify({'error': str(e)}), 500
542 |
543 | @app.route('/recent-completions', methods=['GET'])
544 | def get_recent_completions():
545 | try:
546 | mongo_client = MongoDBClient()
547 | recent_completions = mongo_client.get_recent_completions(limit=10)
548 |
549 | # Convert ObjectId to string for JSON serialization
550 | for completion in recent_completions:
551 | completion['_id'] = str(completion['_id'])
552 | completion['created_at'] = completion['created_at'].isoformat()
553 |
554 | return jsonify({
555 | 'success': True,
556 | 'completions': recent_completions
557 | })
558 | except Exception as e:
559 | logging.error(f"Error fetching recent completions: {e}")
560 | return jsonify({'error': str(e)}), 500
561 |
562 | @app.route('/generate-examples', methods=['POST'])
563 | def generate_examples():
564 | try:
565 | data = request.json
566 | context = data.get('context', '')
567 |
568 | examples = set()
569 |
570 | # Get available puzzle tasks
571 | if hasattr(game_state, 'puzzle_progress') and game_state.puzzle_progress:
572 | available_tasks = game_state.puzzle_progress.get_available_tasks(game_state.inventory)
573 |
574 | # Add simplified versions of available tasks
575 | for task in available_tasks:
576 | # Extract key action from task
577 | task_words = task.description.lower().split()
578 | key_verbs = {'use', 'activate', 'defend', 'lead', 'coordinate', 'establish', 'rally', 'create'}
579 |
580 | for verb in key_verbs:
581 | if verb in task_words:
582 | # Create simplified action based on verb and required item
583 | if task.required_item != 'All items':
584 | action = f"{verb.title()} {task.required_item}"
585 | examples.add(action)
586 | break
587 |
588 | # Add inventory-based suggestions
589 | for item in game_state.inventory:
590 | if any(task.required_item == item for task in available_tasks):
591 | examples.add(f"Use {item}")
592 |
593 | # Add contextual actions
594 | keywords = extract_keywords(context)
595 | if keywords['npcs']:
596 | examples.add(f"Talk to {random.choice(keywords['npcs'])}")
597 |
598 | if keywords['locations']:
599 | examples.add(f"Explore {random.choice(keywords['locations'])}")
600 |
601 | # Always include some general actions
602 | general_actions = ["Look around", "Check inventory", "View current tasks"]
603 | examples.add(random.choice(general_actions))
604 |
605 | # Convert to list and limit size
606 | example_list = list(examples)[:4]
607 |
608 | # If we have active tasks but no task-related examples, add one
609 | if available_tasks and not any('use' in ex.lower() for ex in example_list):
610 | task = random.choice(available_tasks)
611 | hint = f"Try using {task.required_item}"
612 | example_list[0] = hint
613 |
614 | return jsonify({'examples': example_list})
615 |
616 | except Exception as e:
617 | logging.error(f"Error generating examples: {e}")
618 | return jsonify({'examples': ['Look around', 'Talk', 'Explore']})
619 |
620 | @app.route('/victory/')
621 | def show_victory(encoded_data):
622 | try:
623 | # Decode the data
624 | import base64
625 | import json
626 | decoded_data = json.loads(base64.b64decode(encoded_data))
627 |
628 | # Render victory page with decoded data
629 | return render_template('victory.html', victory_data=decoded_data)
630 | except Exception as e:
631 | logging.error(f"Error displaying victory: {e}")
632 | return redirect('/')
633 |
634 | @app.route('/proxy-image/')
635 | def proxy_image(url):
636 | try:
637 | response = requests.get(url)
638 | return Response(
639 | response.content,
640 | mimetype=response.headers['Content-Type'],
641 | headers={'Access-Control-Allow-Origin': '*'}
642 | )
643 | except Exception as e:
644 | logging.error(f"Error proxying image: {e}")
645 | return jsonify({'error': str(e)}), 500
646 |
647 | @app.route('/check-puzzle', methods=['POST'])
648 | def check_character_puzzle():
649 | try:
650 | data = request.json
651 | character_name = data.get('character')
652 |
653 | # Load puzzle data
654 | with open('shared_data/puzzle_data.json', 'r') as f:
655 | puzzle_data = json.load(f)
656 |
657 | has_puzzle = False
658 | # Check if character has puzzles in any world
659 | for world_puzzles in puzzle_data['world_puzzles'].values():
660 | if character_name in world_puzzles.get('characters', {}):
661 | has_puzzle = True
662 | break
663 |
664 | return jsonify({'hasPuzzle': has_puzzle})
665 |
666 | except Exception as e:
667 | logging.error(f"Error checking character puzzles: {e}")
668 | return jsonify({'error': str(e)}), 500
669 |
670 | if os.environ.get('RAILWAY_ENVIRONMENT'):
671 | @app.after_request
672 | def add_security_headers(response):
673 | response.headers['Content-Security-Policy'] = (
674 | "default-src 'self' https://accounts.google.com https://*.google.com; "
675 | "img-src 'self' data: https: blob:; "
676 | "font-src 'self' data: https://fonts.googleapis.com https://fonts.gstatic.com; "
677 | "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://accounts.google.com; "
678 | "script-src 'self' 'unsafe-inline' https://accounts.google.com https://*.google.com; "
679 | "frame-src 'self' https://accounts.google.com; "
680 | "connect-src 'self' https://accounts.google.com"
681 | )
682 | response.headers['X-Content-Type-Options'] = 'nosniff'
683 | response.headers['X-Frame-Options'] = 'DENY'
684 | response.headers['X-XSS-Protection'] = '1; mode=block'
685 | return response
686 |
687 | if __name__ == '__main__':
688 | print("\n=== Game Ready to Start ===")
689 | print("\nAccess the game at http://localhost:5000")
690 |
691 | port = int(os.environ.get('PORT', 5000))
692 | if os.environ.get('RAILWAY_ENVIRONMENT'):
693 | app.run(host='0.0.0.0', port=port)
694 | else:
695 | app.run(host='127.0.0.1', debug=True, port=port)
--------------------------------------------------------------------------------
/static/css/style.css:
--------------------------------------------------------------------------------
1 | /* Import fonts */
2 | @import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@500;700&display=swap');
3 | @import url('https://fonts.googleapis.com/css2?family=Alegreya:wght@400;500&display=swap');
4 |
5 | /* Base styles */
6 | * {
7 | margin: 0;
8 | padding: 0;
9 | box-sizing: border-box;
10 | font-family: 'Alegreya', serif;
11 | }
12 |
13 | body {
14 | background: #1a1a1a;
15 | color: #ffffff;
16 | line-height: 1.6;
17 | height: 100vh;
18 | display: flex;
19 | align-items: center;
20 | justify-content: center;
21 | overflow: hidden;
22 | }
23 |
24 | /* Start Screen */
25 | .start-screen {
26 | position: fixed;
27 | top: 0;
28 | left: 0;
29 | right: 0;
30 | bottom: 0;
31 | overflow-y: scroll;
32 | scroll-snap-type: y mandatory;
33 | background: #000;
34 | z-index: 1000;
35 | }
36 |
37 | .landing-section {
38 | position: relative;
39 | height: 100vh;
40 | width: 100%;
41 | scroll-snap-align: start;
42 | overflow: hidden;
43 | }
44 |
45 | .section-background {
46 | position: absolute;
47 | top: 0;
48 | left: 0;
49 | right: 0;
50 | bottom: 0;
51 | background-size: cover;
52 | background-position: center;
53 | z-index: 1;
54 | }
55 | .landing-first {
56 | position: relative;
57 | }
58 |
59 | .landing-first::before {
60 | content: '';
61 | position: absolute;
62 | top: 0;
63 | left: 0;
64 | right: 0;
65 | bottom: 0;
66 | background: linear-gradient(
67 | to bottom,
68 | rgba(0, 0, 0, 0.6) 0%,
69 | rgba(0, 0, 0, 0.5) 50%,
70 | rgba(0, 0, 0, 0.6) 100%
71 | );
72 | z-index: 1;
73 | }
74 | .landing-first .section-background {
75 | background-image: linear-gradient(
76 | rgba(0, 0, 0, 0.2),
77 | rgba(0, 0, 0, 0.2)
78 | ), url('../images/fantasy-bg1.png');
79 | }
80 |
81 | .landing-second .section-background {
82 | background-image: linear-gradient(
83 | rgba(0, 0, 0, 0.2),
84 | rgba(0, 0, 0, 0.2)
85 | ), url('../images/fantasy-bg2.png');}
86 |
87 | .landing-third .section-background {
88 | background-image: linear-gradient(
89 | rgba(0, 0, 0, 0.2),
90 | rgba(0, 0, 0, 0.2)
91 | ), url('../images/fantasy-bg3.png');}
92 |
93 | .section-content {
94 | position: relative;
95 | z-index: 2;
96 | max-width: 1200px;
97 | margin: 0 auto;
98 | padding: 2rem;
99 | height: 100%;
100 | display: flex;
101 | flex-direction: column;
102 | justify-content: center;
103 | align-items: center;
104 | color: white;
105 | }
106 |
107 | /* First Section Styles */
108 | .landing-first h1 {
109 | position: relative;
110 | font-size: 4.5rem;
111 | font-family: 'Cinzel', serif;
112 | font-weight: 700;
113 | text-align: center;
114 | margin-bottom: 1.5rem;
115 | color: #ffffff;
116 | text-shadow:
117 | 0 2px 4px rgba(0, 0, 0, 0.6),
118 | 0 4px 8px rgba(0, 0, 0, 0.4),
119 | 0 8px 16px rgba(0, 0, 0, 0.4);
120 | line-height: 1.2;
121 | letter-spacing: 2px;
122 | z-index: 2;
123 | padding: 0 20px;
124 | }
125 |
126 | .section-description {
127 | position: relative;
128 | font-size: 1.8rem;
129 | font-family: 'Alegreya', serif;
130 | font-weight: 500;
131 | text-align: center;
132 | margin: 1rem auto 3rem;
133 | max-width: 800px;
134 | color: #ffffff;
135 | font-weight: 400;
136 | line-height: 1.6;
137 | letter-spacing: 0.5px;
138 | z-index: 2;
139 | padding: 0 20px;
140 | text-shadow:
141 | 0 2px 4px rgba(0, 0, 0, 0.6),
142 | 0 4px 8px rgba(0, 0, 0, 0.4);
143 | }
144 |
145 | /* Feature Cards Styles */
146 | .feature-cards {
147 | display: grid;
148 | grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
149 | gap: 2rem;
150 | width: 100%;
151 | }
152 |
153 | .feature-card {
154 | background: rgba(0, 0, 0, 0.7);
155 | backdrop-filter: blur(10px);
156 | border-radius: 1rem;
157 | padding: 2rem;
158 | transition: transform 0.3s ease;
159 | }
160 |
161 | .feature-card:hover {
162 | transform: translateY(-5px);
163 | }
164 |
165 | .card-number {
166 | font-size: 2.5rem;
167 | color: #ffd700;
168 | margin-bottom: 1rem;
169 | }
170 |
171 | .feature-card h3 {
172 | font-size: 1.5rem;
173 | margin-bottom: 1rem;
174 | color: #ffd700;
175 | }
176 |
177 | .feature-card p {
178 | font-size: 1rem;
179 | line-height: 1.6;
180 | color: #e0e0e0;
181 | }
182 |
183 |
184 | .game-background {
185 | position: fixed;
186 | top: 0;
187 | left: 0;
188 | right: 0;
189 | bottom: 0;
190 | background: linear-gradient(rgba(0, 0, 0, 0.7), rgba(0, 0, 0, 0.7)),
191 | url('../images/fantasy-bg1.png');
192 | background-size: cover;
193 | background-position: center;
194 | z-index: 250;
195 | opacity: 1;
196 | visibility: visible;
197 | transition: opacity 0.5s ease, visibility 0.5s ease;
198 | }
199 |
200 | .game-background.fade-out {
201 | opacity: 0;
202 | visibility: hidden;
203 | pointer-events: none;
204 | }
205 |
206 | .start-button {
207 | position: relative;
208 | padding: 1.2rem 3.5rem;
209 | font-size: 1.5rem;
210 | font-family: 'Alegreya', serif;
211 | font-weight: 500;
212 | text-transform: uppercase;
213 | letter-spacing: 2px;
214 | color: #1a2e1a;
215 | background: linear-gradient(135deg, #4ade80, #22c55e);
216 | border: none;
217 | cursor: pointer;
218 | clip-path: polygon(
219 | 0 15%, /* Top left indent */
220 | 15px 0, /* Top left slope */
221 | calc(100% - 15px) 0, /* Top right */
222 | 100% 15%, /* Top right indent */
223 | 100% 85%, /* Bottom right indent */
224 | calc(100% - 15px) 100%, /* Bottom right slope */
225 | 15px 100%, /* Bottom left slope */
226 | 0 85% /* Bottom left indent */
227 | );
228 | transform: perspective(500px) rotateX(0deg);
229 | transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
230 | box-shadow:
231 | 0 4px 15px rgba(34, 197, 94, 0.3),
232 | 0 0 30px rgba(34, 197, 94, 0.2),
233 | inset 0 0 15px rgba(255, 255, 255, 0.5);
234 | }
235 |
236 | .start-button::before {
237 | content: '';
238 | position: absolute;
239 | top: -50%;
240 | left: -50%;
241 | width: 200%;
242 | height: 200%;
243 | background: radial-gradient(circle, rgba(255,255,255,0.3) 0%, transparent 60%);
244 | transform: rotate(45deg);
245 | transition: all 0.3s ease-in-out;
246 | opacity: 0;
247 | }
248 |
249 | .start-button:hover {
250 | background: linear-gradient(135deg, #22c55e, #16a34a);
251 | transform: perspective(500px) rotateX(5deg) translateY(-3px);
252 | }
253 |
254 | .start-button:hover::before {
255 | opacity: 1;
256 | animation: buttonGlow 1.5s infinite;
257 | }
258 |
259 | .start-button:active {
260 | transform: perspective(500px) rotateX(5deg) translateY(1px);
261 | }
262 |
263 | /* Animations */
264 | @keyframes titleGlow {
265 | 0% {
266 | text-shadow:
267 | 0 2px 10px rgba(0, 0, 0, 0.5),
268 | 0 0 20px rgba(255, 215, 0, 0.3),
269 | 0 0 30px rgba(255, 215, 0, 0.2);
270 | }
271 | 100% {
272 | text-shadow:
273 | 0 2px 10px rgba(0, 0, 0, 0.5),
274 | 0 0 30px rgba(255, 215, 0, 0.5),
275 | 0 0 50px rgba(255, 215, 0, 0.3);
276 | }
277 | }
278 |
279 | @keyframes buttonGlow {
280 | 0% {
281 | transform: rotate(45deg) translateY(-50%) translateX(-50%);
282 | }
283 | 100% {
284 | transform: rotate(45deg) translateY(50%) translateX(50%);
285 | }
286 | }
287 |
288 | /* Contact Info Styles */
289 | .contact-info {
290 | position: relative;
291 | margin-top: 2rem;
292 | text-align: center;
293 | z-index: 2;
294 | }
295 |
296 | .contact-info p {
297 | position: relative;
298 | margin: 0.8rem 0;
299 | color: #ffffff;
300 | font-family: 'Alegreya', serif;
301 | font-size: 1.2rem;
302 | line-height: 1.6;
303 | letter-spacing: 0.5px;
304 | text-shadow:
305 | 0 2px 4px rgba(0, 0, 0, 0.6),
306 | 0 4px 8px rgba(0, 0, 0, 0.4);
307 | }
308 |
309 | .contact-info .email {
310 | font-weight: 500;
311 | }
312 |
313 | .contact-info .project-info {
314 | font-size: 1.1rem;
315 | color: rgba(255, 255, 255, 0.9);
316 | }
317 |
318 | .contact-info .copyright {
319 | font-size: 1rem;
320 | color: rgba(255, 255, 255, 0.8);
321 | }
322 |
323 | /* Scroll Indicator */
324 | .scroll-indicator {
325 | position: absolute;
326 | bottom: 2rem;
327 | left: 50%;
328 | transform: translateX(-50%);
329 | text-align: center;
330 | color: white;
331 | animation: bounce 2s infinite;
332 | }
333 |
334 | .scroll-arrow {
335 | width: 20px;
336 | height: 20px;
337 | border-right: 2px solid white;
338 | border-bottom: 2px solid white;
339 | transform: rotate(45deg);
340 | margin: 0 auto;
341 | margin-top: 10px;
342 | }
343 |
344 | @keyframes bounce {
345 | 0%, 20%, 50%, 80%, 100% {
346 | transform: translateY(0);
347 | }
348 | 40% {
349 | transform: translateY(-10px);
350 | }
351 | 60% {
352 | transform: translateY(-5px);
353 | }
354 | }
355 |
356 | /* Responsive Adjustments */
357 | @media (max-width: 768px) {
358 | .landing-first h1 {
359 | font-size: 3rem;
360 | }
361 |
362 | .section-description {
363 | font-size: 1.4rem;
364 | margin: 1rem auto 2rem;
365 | }
366 |
367 | .start-button {
368 | font-size: 1.2rem;
369 | padding: 1rem 2.5rem;
370 | }
371 |
372 | .contact-info p {
373 | font-size: 1.1rem;
374 | }
375 |
376 | .contact-info .project-info {
377 | font-size: 1rem;
378 | }
379 |
380 | .contact-info .copyright {
381 | font-size: 0.9rem;
382 | }
383 | }
384 |
385 | @media (max-width: 480px) {
386 | .landing-first h1 {
387 | font-size: 2.5rem;
388 | }
389 |
390 | .section-description {
391 | font-size: 1.2rem;
392 | margin: 0.8rem auto 1.5rem;
393 | }
394 |
395 | .contact-info p {
396 | font-size: 1rem;
397 | }
398 |
399 | .contact-info .project-info {
400 | font-size: 0.9rem;
401 | }
402 |
403 | .contact-info .copyright {
404 | font-size: 0.8rem;
405 | }
406 | }
407 |
408 | /* Selection Phase */
409 | .selection-phase {
410 | position: fixed;
411 | top: 0;
412 | left: 0;
413 | right: 0;
414 | bottom: 0;
415 | background: transparent;
416 | z-index: 275;
417 | opacity: 0;
418 | visibility: hidden;
419 | transition: opacity 0.3s ease, visibility 0.3s ease;
420 | padding: 20px;
421 | overflow-y: auto;
422 | }
423 |
424 |
425 | .selection-phase.visible {
426 | opacity: 1;
427 | visibility: visible;
428 | }
429 |
430 | .back-button {
431 | position: fixed;
432 | top: 20px;
433 | left: 20px;
434 | padding: 10px 20px;
435 | background: #2a2a3d;
436 | border: none;
437 | border-radius: 6px;
438 | color: white;
439 | cursor: pointer;
440 | transition: all 0.2s ease;
441 | z-index: 150;
442 | font-size: 14px;
443 | }
444 |
445 | .back-button:hover {
446 | background: #3a3a4d;
447 | }
448 |
449 | .selection-screen {
450 | position: absolute;
451 | top: 50%;
452 | left: 50%;
453 | transform: translate(-50%, -50%);
454 | width: 100%;
455 | max-width: 600px;
456 | padding: 20px;
457 | opacity: 0;
458 | visibility: hidden;
459 | transition: all 0.3s ease;
460 | }
461 |
462 | .selection-screen.visible {
463 | opacity: 1;
464 | visibility: visible;
465 | }
466 |
467 | .selection-list {
468 | display: flex;
469 | flex-direction: column;
470 | gap: 16px;
471 | margin-top: 20px;
472 | }
473 |
474 | .selection-button {
475 | position: relative;
476 | width: 100%;
477 | padding: 1.2rem;
478 | margin: 0.4rem 0;
479 | background: linear-gradient(
480 | 135deg,
481 | rgba(48, 38, 71, 0.95) 0%,
482 | rgba(28, 22, 41, 0.95) 100%
483 | );
484 | border: 2px solid;
485 | border-image-slice: 1;
486 | border-image-source: linear-gradient(
487 | to right,
488 | #a67c00,
489 | #deb761,
490 | #a67c00
491 | );
492 | color: #fff;
493 | cursor: pointer;
494 | text-align: left;
495 | transition: all 0.3s ease;
496 | overflow: hidden;
497 | transform: perspective(1000px) rotateX(0deg);
498 | box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
499 | }
500 |
501 | .selection-button::before {
502 | content: '';
503 | position: absolute;
504 | top: 0;
505 | left: 0;
506 | width: 100%;
507 | height: 100%;
508 | background: linear-gradient(
509 | 45deg,
510 | transparent 0%,
511 | rgba(255, 255, 255, 0.05) 50%,
512 | transparent 100%
513 | );
514 | transition: transform 0.5s ease;
515 | transform: translateX(-100%);
516 | }
517 |
518 | .selection-button:hover {
519 | transform: perspective(1000px) rotateX(5deg) translateY(-5px);
520 | box-shadow:
521 | 0 8px 25px rgba(0, 0, 0, 0.4),
522 | 0 0 20px rgba(218, 165, 32, 0.2);
523 | }
524 |
525 | .selection-button:hover::before {
526 | transform: translateX(100%);
527 | }
528 |
529 | .selection-button h3 {
530 | font-family: 'Cinzel', serif;
531 | font-size: 1.3rem;
532 | margin-bottom: 0.6rem;
533 | color: #deb761;
534 | text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
535 | }
536 |
537 | .selection-button p {
538 | font-size: 0.95rem;
539 | color: #e0e0e0;
540 | margin: 0;
541 | line-height: 1.5;
542 | transition: color 0.3s ease;
543 | }
544 |
545 | .selection-button:hover p {
546 | color: #ffffff;
547 | }
548 |
549 | /* Game Container */
550 | .game-wrapper {
551 | width: 100%;
552 | height: 100vh;
553 | background: linear-gradient(rgba(0, 0, 0, 0.7), rgba(0, 0, 0, 0.7)),
554 | url('../images/fantasy-bg1.png'); display: flex;
555 | display: flex;
556 | flex-direction: column;
557 | align-items: center;
558 | padding: 0.75rem;
559 | gap: 0.5rem;
560 | }
561 |
562 | .game-container {
563 | width: 1000px;
564 | height: 700px;
565 | background: linear-gradient(135deg, #1a1a2d 0%, #2d2d4d 100%);
566 | border: 2px solid;
567 | border-image: linear-gradient(45deg, #ffd700 0%, #c0962d 50%, #ffd700 100%) 1;
568 | border-radius: 0.75rem;
569 | display: flex;
570 | flex-direction: column;
571 | padding: 1.25rem;
572 | gap: 0.75rem;
573 | position: relative;
574 | box-shadow:
575 | 0 0 25px rgba(255, 215, 0, 0.1),
576 | inset 0 0 30px rgba(0, 0, 0, 0.4);
577 | }
578 |
579 | .game-container.visible {
580 | opacity: 1;
581 | }
582 |
583 | /* Messages Area */
584 | .messages {
585 | flex: 1;
586 | background: linear-gradient(135deg, #262640 0%, #32324f 100%);
587 | border: 1px solid rgba(255, 215, 0, 0.15);
588 | border-radius: 0.5rem;
589 | padding: 1rem;
590 | overflow-y: auto;
591 | position: relative;
592 | box-shadow: inset 0 0 20px rgba(0, 0, 0, 0.4);
593 | }
594 |
595 | /* Message Styles */
596 | .message {
597 | margin: 16px 0;
598 | padding: 14px 18px; /* Slightly increased padding to accommodate larger text */
599 | border-radius: 18px;
600 | max-width: 80%;
601 | animation: fadeIn 0.3s ease forwards;
602 | font-size: 1.1rem; /* Increased from 14px to match game input */
603 | line-height: 1.5;
604 | position: relative;
605 | font-family: 'Alegreya', serif;
606 | letter-spacing: 0.2px;
607 | }
608 |
609 | /* User Message */
610 | .user-message {
611 | background: linear-gradient(135deg, #4a4f8c 0%, #3a3f6c 100%);
612 | margin-left: auto;
613 | color: #ffffff;
614 | border: 1px solid rgba(255, 255, 255, 0.1);
615 | box-shadow:
616 | 0 4px 12px rgba(74, 79, 140, 0.2),
617 | inset 0 1px 2px rgba(255, 255, 255, 0.1);
618 | clip-path: polygon(0 0, 100% 0, 100% 85%, 95% 100%, 0 100%);
619 | text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
620 | }
621 |
622 | /* Bot Message */
623 | .bot-message {
624 | background: linear-gradient(135deg, #695a2f 0%, #8b7355 100%);
625 | margin-right: auto;
626 | color: #ffffff;
627 | border: 1px solid rgba(255, 215, 0, 0.2);
628 | box-shadow:
629 | 0 4px 12px rgba(139, 115, 85, 0.2),
630 | inset 0 1px 2px rgba(255, 215, 0, 0.1);
631 | clip-path: polygon(0 0, 100% 0, 100% 100%, 5% 100%, 0 85%);
632 | text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
633 | }
634 |
635 | /* Story Image Container */
636 | #storyImageContainer {
637 | margin: 16px 0;
638 | transition: all 0.3s ease;
639 | }
640 |
641 | .story-image-wrapper {
642 | position: relative;
643 | width: 100%;
644 | max-width: 512px;
645 | margin: 0 auto;
646 | border-radius: 12px;
647 | overflow: hidden;
648 | box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
649 | max-height: 320px;
650 | }
651 |
652 | .story-image {
653 | width: 100%;
654 | height: auto;
655 | display: block;
656 | transition: transform 0.3s ease;
657 | object-fit: cover;
658 | }
659 |
660 | .story-image:hover {
661 | transform: scale(1.02);
662 | }
663 |
664 | .story-image-caption {
665 | position: absolute;
666 | bottom: 0;
667 | left: 0;
668 | right: 0;
669 | padding: 12px;
670 | background: rgba(0, 0, 0, 0.7);
671 | color: white;
672 | font-size: 14px;
673 | transform: translateY(100%);
674 | transition: transform 0.3s ease;
675 | }
676 |
677 | .story-image-wrapper:hover .story-image-caption {
678 | transform: translateY(0);
679 | }
680 |
681 | /* Sidebar */
682 | .sidebar {
683 | width: 15rem;
684 | display: flex;
685 | flex-direction: column;
686 | gap: 1rem;
687 | }
688 |
689 | /* Inventory Section */
690 | .inventory-section {
691 | background: linear-gradient(135deg, #2d2d4d 0%, #1a1a2d 100%);
692 | border: 1px solid rgba(255, 215, 0, 0.15);
693 | border-radius: 0.5rem;
694 | padding: 0.75rem;
695 | max-height: 45%;
696 | overflow-y: auto;
697 | box-shadow:
698 | 0 4px 12px rgba(0, 0, 0, 0.2),
699 | inset 0 0 20px rgba(0, 0, 0, 0.4);
700 | }
701 |
702 | .examples {
703 | flex: 1;
704 | }
705 |
706 | .inventory-section h3 {
707 | color: #ffffff;
708 | margin-bottom: 10px;
709 | font-size: 14px;
710 | }
711 |
712 | .inventory-slots {
713 | display: grid;
714 | grid-template-columns: 1fr;
715 | gap: 0.5rem;
716 | }
717 |
718 | .inventory-item {
719 | position: relative;
720 | padding: 0.75rem;
721 | background: linear-gradient(135deg, #3a3a5a 0%, #2d2d4d 100%);
722 | border: 1px solid rgba(255, 215, 0, 0.2);
723 | border-radius: 0.5rem;
724 | margin-bottom: 0.5rem;
725 | transition: all 0.2s ease;
726 | }
727 |
728 | .inventory-item:hover {
729 | border-color: rgba(255, 215, 0, 0.4);
730 | box-shadow: inset 0 0 10px rgba(255, 215, 0, 0.1);
731 | }
732 |
733 | .tooltip-text {
734 | visibility: hidden;
735 | position: absolute;
736 | bottom: 100%;
737 | left: 50%;
738 | transform: translateX(-50%);
739 | padding: 6px 10px;
740 | background: rgba(0, 0, 0, 0.9);
741 | color: white;
742 | font-size: 0.75rem;
743 | white-space: nowrap;
744 | border-radius: 4px;
745 | z-index: 20;
746 | opacity: 0;
747 | transition: all 0.2s ease;
748 | pointer-events: none;
749 | margin-bottom: 5px;
750 | }
751 |
752 | .inventory-item:hover .tooltip-text {
753 | visibility: visible;
754 | opacity: 1;
755 | }
756 |
757 | .item-count {
758 | position: absolute;
759 | top: -0.5rem;
760 | right: -0.5rem;
761 | width: 1.5rem;
762 | height: 1.5rem;
763 | background: linear-gradient(to right, rgb(239, 68, 68), rgb(220, 38, 38));
764 | border-radius: 9999px;
765 | display: flex;
766 | align-items: center;
767 | justify-content: center;
768 | color: white;
769 | font-size: 0.75rem;
770 | font-weight: 500;
771 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
772 | }
773 |
774 | .inventory-item span {
775 | display: block;
776 | }
777 |
778 | .inventory-item span:first-child {
779 | color: rgb(255, 255, 255);
780 | font-size: 0.875rem;
781 | }
782 |
783 | .inventory-item span:last-child {
784 | color: rgb(209, 213, 219);
785 | font-size: 0.75rem;
786 | margin-top: 0.25rem;
787 | }
788 |
789 | .examples {
790 | background: linear-gradient(135deg, #2d2d4d 0%, #1a1a2d 100%);
791 | border: 1px solid rgba(255, 215, 0, 0.15);
792 | border-radius: 0.5rem;
793 | padding: 0.75rem;
794 | flex: 1;
795 | overflow-y: auto;
796 | min-height: 0;
797 | box-shadow:
798 | 0 4px 12px rgba(0, 0, 0, 0.2),
799 | inset 0 0 20px rgba(0, 0, 0, 0.4);
800 | }
801 |
802 | .examples h4 {
803 | color: #ffffff;
804 | margin-bottom: 10px;
805 | font-size: 14px;
806 | }
807 |
808 | .example-button {
809 | background: linear-gradient(135deg, #3a3a5a 0%, #2d2d4d 100%);
810 | border: 1px solid rgba(255, 215, 0, 0.2);
811 | border-radius: 0.375rem;
812 | padding: 0.5rem 0.75rem;
813 | margin: 0.25rem 0;
814 | color: #ffffff;
815 | cursor: pointer;
816 | transition: background 0.2s ease, border-color 0.2s ease;
817 | font-size: 0.75rem;
818 | display: block;
819 | width: 100%;
820 | text-align: left;
821 | position: relative;
822 | will-change: background, border-color;
823 | }
824 |
825 | .example-button:hover {
826 | background: linear-gradient(135deg, #4a4a6a 0%, #3d3d5d 100%);
827 | border-color: rgba(255, 215, 0, 0.4);
828 | }
829 |
830 | .example-button:active {
831 | transform: translateX(2px);
832 | background: #4B5563;
833 | }
834 |
835 | /* Input Container */
836 | /* Input Container */
837 | .input-container {
838 | grid-column: 1 / -1;
839 | grid-row: 2;
840 | display: flex;
841 | gap: 8px;
842 | height: 50px; /* Increased from 40px */
843 | }
844 |
845 | /* Input Area */
846 | .input-wrapper {
847 | margin-top: auto;
848 | display: flex;
849 | gap: 0.75rem;
850 | height: 3.5rem; /* Increased from 2.5rem */
851 | background: linear-gradient(135deg, #2d2d4d 0%, #1a1a2d 100%);
852 | border: 1px solid rgba(255, 215, 0, 0.15);
853 | border-radius: 0.5rem;
854 | padding: 0.75rem; /* Increased padding */
855 | box-shadow: inset 0 0 20px rgba(0, 0, 0, 0.4);
856 | }
857 |
858 | .game-input {
859 | flex: 1;
860 | background: rgba(26, 26, 45, 0.6);
861 | border: 1px solid rgba(255, 215, 0, 0.2);
862 | border-radius: 0.5rem;
863 | padding: 0 1.25rem;
864 | color: white;
865 | font-size: 1.1rem; /* Increased font size */
866 | line-height: 1.5;
867 | box-shadow: inset 0 2px 5px rgba(0, 0, 0, 0.2);
868 | }
869 |
870 | .game-input:focus {
871 | border-color: rgba(255, 215, 0, 0.4);
872 | outline: none;
873 | }
874 |
875 | .game-input::placeholder {
876 | color: #8a8a8a;
877 | font-size: 1.1rem; /* Matched placeholder size */
878 | }
879 |
880 | .game-input:disabled {
881 | background: #1e1e2f;
882 | cursor: not-allowed;
883 | }
884 |
885 | /* Adjusted send button to match new height */
886 | .action-button {
887 | padding: 0 1.75rem;
888 | background: linear-gradient(135deg, #ffd700 0%, #c0962d 100%);
889 | border: none;
890 | border-radius: 0.5rem;
891 | color: #1a1a2d;
892 | font-weight: 500;
893 | font-size: 1.1rem; /* Increased to match input */
894 | transition: background 0.2s ease;
895 | }
896 |
897 | .action-button:hover:not(:disabled) {
898 | background: linear-gradient(135deg, #ffe033 0%, #d4aa33 100%);
899 | }
900 |
901 | .action-button:disabled {
902 | opacity: 0.6;
903 | cursor: not-allowed;
904 | }
905 |
906 | .messages::-webkit-scrollbar,
907 | .inventory-section::-webkit-scrollbar,
908 | .examples::-webkit-scrollbar {
909 | width: 6px;
910 | }
911 |
912 | .messages::-webkit-scrollbar-track,
913 | .inventory-section::-webkit-scrollbar-track,
914 | .examples::-webkit-scrollbar-track {
915 | background: rgba(26, 26, 45, 0.6);
916 | border-radius: 3px;
917 | }
918 |
919 | .messages::-webkit-scrollbar-thumb,
920 | .inventory-section::-webkit-scrollbar-thumb,
921 | .examples::-webkit-scrollbar-thumb {
922 | background: rgba(255, 215, 0, 0.2);
923 | border-radius: 3px;
924 | }
925 |
926 | .messages::-webkit-scrollbar-thumb:hover,
927 | .inventory-section::-webkit-scrollbar-thumb:hover,
928 | .examples::-webkit-scrollbar-thumb:hover {
929 | background: rgba(255, 215, 0, 0.3);
930 | }
931 |
932 | /* Loading States */
933 | .image-loading {
934 | height: 200px;
935 | display: flex;
936 | align-items: center;
937 | justify-content: center;
938 | background: rgba(0, 0, 0, 0.1);
939 | border-radius: 12px;
940 | }
941 |
942 | .image-loading::after {
943 | content: '';
944 | width: 32px;
945 | height: 32px;
946 | border: 3px solid rgba(255, 255, 255, 0.3);
947 | border-radius: 50%;
948 | border-top-color: white;
949 | animation: spin 1s linear infinite;
950 | }
951 |
952 | /* Main Content Area */
953 | .main-content {
954 | flex: 1;
955 | display: flex;
956 | gap: 0.75rem;
957 | min-height: 0;
958 | margin-top: 0.25rem;
959 | }
960 |
961 | /* Puzzle Progress Styles */
962 | .puzzle-progress-container {
963 | width: 1000px;
964 | background: linear-gradient(135deg, #1a1a2d 0%, #2d2d4d 100%);
965 | border: 2px solid rgba(255, 215, 0, 0.15);
966 | border-radius: 0.75rem;
967 | overflow: hidden;
968 | box-shadow:
969 | 0 4px 15px rgba(0, 0, 0, 0.3),
970 | inset 0 0 20px rgba(0, 0, 0, 0.4);
971 | margin-bottom: 0.5rem;
972 | padding: 0.4rem 1.25rem;
973 | }
974 |
975 | /* Quest Progress Bar Container */
976 | .progress-container {
977 | width: 100%;
978 | height: 0.35rem;
979 | background: rgba(45, 55, 72, 0.6);
980 | border-radius: 0.25rem;
981 | overflow: hidden;
982 | margin: 0.5rem 0;
983 | box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.3);
984 | }
985 |
986 | /* Quest Progress Bar */
987 | .progress-bar {
988 | height: 100%;
989 | background: linear-gradient(90deg, #4c9227 0%, #38761d 100%);
990 | transition: width 0.3s ease-in-out;
991 | box-shadow:
992 | 0 1px 4px rgba(76, 146, 39, 0.3),
993 | inset 0 1px 2px rgba(255, 255, 255, 0.2);
994 | }
995 |
996 | .card {
997 | background: transparent;
998 | border: 1px solid #3a3a4d;
999 | }
1000 |
1001 | .card-header {
1002 | padding: 12px 16px;
1003 | border-bottom: 1px solid #3a3a4d;
1004 | }
1005 |
1006 | .card-content {
1007 | padding: 16px;
1008 | }
1009 |
1010 | .progress-bar-container {
1011 | background: rgba(255, 255, 255, 0.1);
1012 | border-radius: 999px;
1013 | overflow: hidden;
1014 | }
1015 |
1016 | .quest-progress-content {
1017 | /* Removed padding since container now has padding */
1018 | padding: 0;
1019 | }
1020 |
1021 | .quest-progress-content h2 {
1022 | font-family: 'Cinzel', serif;
1023 | font-size: 1.2rem;
1024 | font-weight: 700;
1025 | color: #ffd700;
1026 | text-align: center;
1027 | margin-bottom: 0.3rem;
1028 | text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
1029 | letter-spacing: 1px;
1030 | }
1031 |
1032 | .puzzle-description {
1033 | color: #e2e8f0;
1034 | font-size: 0.95rem;
1035 | line-height: 1.5;
1036 | margin-bottom: 1rem;
1037 | text-align: center;
1038 | padding: 0;
1039 | font-family: 'Alegreya', serif;
1040 | max-width: 90%;
1041 | margin-left: auto;
1042 | margin-right: auto;
1043 | white-space: nowrap;
1044 | overflow: hidden;
1045 | text-overflow: ellipsis;
1046 | }
1047 |
1048 | /* Progress Text */
1049 | .progress-text {
1050 | color: #a0aec0;
1051 | font-size: 0.8rem;
1052 | margin: 0.3rem 0;
1053 | text-align: center;
1054 | margin-top: 0.5rem;
1055 | font-family: 'Alegreya', serif;
1056 | position: relative;
1057 | display: block;
1058 | padding: 0.15rem 0;
1059 | border-radius: 4px;
1060 | text-shadow: 0 1px 2px rgba(0, 0, 0, 0.7);
1061 | z-index: 1;
1062 | }
1063 |
1064 | /* Progress Steps Container */
1065 | .progress-steps {
1066 | display: flex;
1067 | align-items: center;
1068 | gap: 0.3rem;
1069 | margin: 0.5rem 0;
1070 | padding: 0;
1071 | }
1072 |
1073 | /* Step Container */
1074 | .step-container {
1075 | display: flex;
1076 | align-items: center;
1077 | flex: 1;
1078 | }
1079 |
1080 |
1081 | /* Step Circle */
1082 | .step {
1083 | width: 1.5rem;
1084 | height: 1.5rem;
1085 | font-size: 0.75rem;
1086 | border-radius: 9999px;
1087 | display: flex;
1088 | align-items: center;
1089 | justify-content: center;
1090 | background: linear-gradient(135deg, #2d3748 0%, #1a202c 100%);
1091 | color: #a0aec0;
1092 | font-size: 0.8rem;
1093 | border-width: 1px;
1094 | font-weight: 600;
1095 | border: 1px solid rgba(255, 215, 0, 0.2);
1096 | box-shadow:
1097 | 0 2px 4px rgba(0, 0, 0, 0.2),
1098 | inset 0 1px 2px rgba(255, 255, 255, 0.1);
1099 | transition: all 0.3s ease;
1100 | }
1101 |
1102 | /* Completed Step */
1103 | .step.completed {
1104 | background: linear-gradient(135deg, #4c9227 0%, #38761d 100%);
1105 | color: #ffffff;
1106 | border-color: rgba(255, 215, 0, 0.4);
1107 | box-shadow:
1108 | 0 2px 8px rgba(76, 146, 39, 0.3),
1109 | inset 0 1px 2px rgba(255, 255, 255, 0.2);
1110 | }
1111 |
1112 | /* Connector Line */
1113 | .connector {
1114 | flex: 1;
1115 | height: 0.08rem;
1116 | background: linear-gradient(90deg,
1117 | rgba(45, 55, 72, 0.6) 0%,
1118 | rgba(26, 32, 44, 0.6) 100%
1119 | );
1120 | margin: 0 0.2rem;
1121 | border-radius: 1px;
1122 | box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
1123 | transition: all 0.3s ease;
1124 | }
1125 |
1126 | /* Completed Connector */
1127 | .connector.completed {
1128 | background: linear-gradient(90deg,
1129 | rgba(76, 146, 39, 0.8) 0%,
1130 | rgba(56, 118, 29, 0.8) 100%
1131 | );
1132 | box-shadow: 0 1px 4px rgba(76, 146, 39, 0.3);
1133 | }
1134 |
1135 | /* Hover Effects */
1136 | .step:hover {
1137 | transform: scale(1.05);
1138 | box-shadow:
1139 | 0 4px 12px rgba(0, 0, 0, 0.3),
1140 | inset 0 1px 2px rgba(255, 255, 255, 0.2);
1141 | }
1142 |
1143 | .step.completed:hover {
1144 | box-shadow:
1145 | 0 4px 12px rgba(76, 146, 39, 0.4),
1146 | inset 0 1px 2px rgba(255, 255, 255, 0.3);
1147 | }
1148 |
1149 | .step-container:last-child .connector {
1150 | display: none;
1151 | }
1152 |
1153 | .victory-loading-container {
1154 | display: flex;
1155 | flex-direction: column;
1156 | align-items: center;
1157 | justify-content: center;
1158 | min-height: 300px;
1159 | gap: 2rem;
1160 | }
1161 |
1162 | .loading-spinner {
1163 | width: 50px;
1164 | height: 50px;
1165 | border: 4px solid rgba(255, 255, 255, 0.1);
1166 | border-radius: 50%;
1167 | border-top-color: #fff;
1168 | animation: spin 1s linear infinite;
1169 | }
1170 |
1171 | .loading-text {
1172 | color: #a8b3cf;
1173 | font-size: 1.2rem;
1174 | text-align: center;
1175 | }
1176 |
1177 | .completion-overlay.transitioning .completion-content {
1178 | opacity: 0;
1179 | transition: opacity 0.3s ease;
1180 | }
1181 |
1182 | @keyframes spin {
1183 | to {
1184 | transform: rotate(360deg);
1185 | }
1186 | }
1187 |
1188 | .completion-overlay {
1189 | position: fixed;
1190 | top: 0;
1191 | left: 0;
1192 | right: 0;
1193 | bottom: 0;
1194 | background: rgba(0, 0, 0, 0.95);
1195 | display: flex;
1196 | align-items: center;
1197 | justify-content: center;
1198 | z-index: 1000;
1199 | opacity: 0;
1200 | visibility: hidden;
1201 | transition: all 0.5s ease-in-out;
1202 | backdrop-filter: blur(8px);
1203 | }
1204 |
1205 | .completion-overlay.visible {
1206 | opacity: 1;
1207 | visibility: visible;
1208 | }
1209 |
1210 | .completion-content {
1211 | max-width: 900px;
1212 | width: 90%;
1213 | background: linear-gradient(135deg, #1a1a2a 0%, #2a2a4a 100%);
1214 | border-radius: 20px;
1215 | padding: 2.5rem;
1216 | text-align: center;
1217 | box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4);
1218 | animation: slideUp 0.8s ease forwards;
1219 | opacity: 1;
1220 | transition: opacity 0.3s ease;
1221 | }
1222 |
1223 | .completion-header {
1224 | margin-bottom: 2rem;
1225 | }
1226 |
1227 | .completion-header h2 {
1228 | font-size: 2.5rem;
1229 | color: #fff;
1230 | margin-bottom: 0.5rem;
1231 | text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
1232 | }
1233 |
1234 | .completion-subtitle {
1235 | color: #a8b3cf;
1236 | font-size: 1.2rem;
1237 | }
1238 |
1239 | .completion-image-container {
1240 | position: relative;
1241 | margin: 2rem 0;
1242 | border-radius: 16px;
1243 | overflow: hidden;
1244 | box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
1245 | }
1246 |
1247 | .completion-image {
1248 | width: 100%;
1249 | height: auto;
1250 | display: block;
1251 | transform: scale(1);
1252 | transition: transform 0.5s ease;
1253 | }
1254 |
1255 | .completion-image:hover {
1256 | transform: scale(1.02);
1257 | }
1258 |
1259 | .completion-image-caption {
1260 | position: absolute;
1261 | bottom: 0;
1262 | left: 0;
1263 | right: 0;
1264 | padding: 2rem;
1265 | background: linear-gradient(transparent, rgba(0, 0, 0, 0.9));
1266 | color: white;
1267 | text-align: left;
1268 | transform: translateY(100%);
1269 | transition: transform 0.3s ease;
1270 | }
1271 |
1272 | .completion-image-container:hover .completion-image-caption {
1273 | transform: translateY(0);
1274 | }
1275 |
1276 | .completion-caption h3 {
1277 | font-size: 1.5rem;
1278 | margin-bottom: 0.5rem;
1279 | color: #fff;
1280 | }
1281 |
1282 | .achievement-badges {
1283 | display: flex;
1284 | flex-wrap: wrap;
1285 | gap: 0.5rem;
1286 | margin-top: 1rem;
1287 | }
1288 |
1289 | .achievement-badge {
1290 | display: inline-block;
1291 | padding: 0.4rem 1rem;
1292 | background: rgba(255, 255, 255, 0.1);
1293 | border: 1px solid rgba(255, 255, 255, 0.2);
1294 | border-radius: 999px;
1295 | font-size: 0.875rem;
1296 | color: #fff;
1297 | backdrop-filter: blur(4px);
1298 | }
1299 |
1300 | .completion-summary {
1301 | margin: 2rem 0;
1302 | color: #d1d5db;
1303 | line-height: 1.6;
1304 | font-size: 1.1rem;
1305 | }
1306 |
1307 | .completion-actions {
1308 | display: flex;
1309 | gap: 1rem;
1310 | justify-content: center;
1311 | margin-top: 2rem;
1312 | }
1313 |
1314 | .completion-button {
1315 | display: flex;
1316 | align-items: center;
1317 | gap: 0.5rem;
1318 | padding: 0.75rem 1.5rem;
1319 | border-radius: 12px;
1320 | border: none;
1321 | font-size: 1rem;
1322 | font-weight: 500;
1323 | cursor: pointer;
1324 | transition: all 0.2s ease;
1325 | }
1326 |
1327 | .completion-actions button {
1328 | padding: 0.75rem 1.5rem;
1329 | border-radius: 8px;
1330 | border: none;
1331 | font-weight: 500;
1332 | cursor: pointer;
1333 | transition: all 0.2s ease;
1334 | }
1335 |
1336 | .button-icon {
1337 | width: 20px;
1338 | height: 20px;
1339 | }
1340 |
1341 | .replay-button {
1342 | background: #4CAF50;
1343 | color: white;
1344 | }
1345 |
1346 | .replay-button:hover {
1347 | background: #45a049;
1348 | transform: translateY(-2px);
1349 | }
1350 |
1351 | .share-button {
1352 | background: #2196F3;
1353 | color: white;
1354 | }
1355 |
1356 | .share-button:hover {
1357 | background: #1976D2;
1358 | transform: translateY(-2px);
1359 | }
1360 |
1361 | /* Instagram Modal Styles */
1362 | .instagram-modal {
1363 | position: fixed;
1364 | top: 0;
1365 | left: 0;
1366 | right: 0;
1367 | bottom: 0;
1368 | background: rgba(0, 0, 0, 0.9);
1369 | display: flex;
1370 | align-items: center;
1371 | justify-content: center;
1372 | z-index: 1200;
1373 | opacity: 0;
1374 | visibility: hidden;
1375 | transition: all 0.3s ease;
1376 | }
1377 |
1378 | .instagram-modal.visible {
1379 | opacity: 1;
1380 | visibility: visible;
1381 | }
1382 |
1383 | .instagram-content {
1384 | background: linear-gradient(135deg, #1a1a2a 0%, #2a2a4a 100%);
1385 | border-radius: 20px;
1386 | padding: 2rem;
1387 | width: 90%;
1388 | max-width: 400px;
1389 | }
1390 |
1391 | .share-instructions {
1392 | margin: 1.5rem 0;
1393 | padding-left: 1.5rem;
1394 | color: #e2e8f0;
1395 | line-height: 1.6;
1396 | }
1397 |
1398 | .instagram-actions {
1399 | display: flex;
1400 | flex-direction: column;
1401 | gap: 1rem;
1402 | margin-top: 2rem;
1403 | }
1404 |
1405 | .instagram-actions button {
1406 | padding: 1rem;
1407 | border-radius: 12px;
1408 | border: none;
1409 | font-weight: 500;
1410 | cursor: pointer;
1411 | transition: all 0.2s ease;
1412 | }
1413 |
1414 | .download-button {
1415 | background: #22c55e;
1416 | color: white;
1417 | }
1418 |
1419 | .instagram-button {
1420 | background: #E4405F;
1421 | color: white;
1422 | border: none;
1423 | padding: 12px 24px;
1424 | border-radius: 8px;
1425 | cursor: pointer;
1426 | transition: all 0.2s ease;
1427 | margin: 5px;
1428 | }
1429 |
1430 | .instagram-button:hover {
1431 | background: #d63851;
1432 | }
1433 |
1434 | .close-button {
1435 | background: rgba(255, 255, 255, 0.1);
1436 | color: white;
1437 | }
1438 |
1439 | .share-icon {
1440 | width: 24px;
1441 | height: 24px;
1442 | }
1443 |
1444 | .close-modal {
1445 | background: none;
1446 | border: none;
1447 | color: white;
1448 | font-size: 1.5rem;
1449 | cursor: pointer;
1450 | }
1451 |
1452 | .share-preview {
1453 | margin-bottom: 2rem;
1454 | }
1455 |
1456 | .share-image {
1457 | width: 100%;
1458 | border-radius: 10px;
1459 | margin-bottom: 1rem;
1460 | }
1461 |
1462 | .qr-section {
1463 | background: white;
1464 | padding: 1rem;
1465 | border-radius: 10px;
1466 | margin-bottom: 1rem;
1467 | }
1468 |
1469 | .share-link-container {
1470 | display: flex;
1471 | gap: 0.5rem;
1472 | margin-bottom: 1rem;
1473 | }
1474 |
1475 | .share-link-input {
1476 | flex: 1;
1477 | padding: 0.5rem;
1478 | border: 1px solid rgba(255, 255, 255, 0.1);
1479 | border-radius: 6px;
1480 | background: rgba(255, 255, 255, 0.1);
1481 | color: white;
1482 | }
1483 |
1484 | .copy-link-btn, .download-btn {
1485 | padding: 0.5rem 1rem;
1486 | border: none;
1487 | border-radius: 6px;
1488 | cursor: pointer;
1489 | transition: all 0.2s ease;
1490 | }
1491 |
1492 | .copy-link-btn {
1493 | background: #4CAF50;
1494 | color: white;
1495 | }
1496 |
1497 | .download-btn {
1498 | background: #2196F3;
1499 | color: white;
1500 | width: 100%;
1501 | }
1502 |
1503 | .toast-message {
1504 | position: fixed;
1505 | bottom: 20px;
1506 | left: 50%;
1507 | transform: translateX(-50%);
1508 | background: #4CAF50;
1509 | color: white;
1510 | padding: 0.75rem 1.5rem;
1511 | border-radius: 6px;
1512 | opacity: 0;
1513 | transition: all 0.3s ease;
1514 | }
1515 |
1516 | .toast-message.visible {
1517 | opacity: 1;
1518 | transform: translate(-50%, -20px);
1519 | }
1520 |
1521 | .download-success {
1522 | position: fixed;
1523 | bottom: 20px;
1524 | left: 50%;
1525 | transform: translateX(-50%);
1526 | background: #4CAF50;
1527 | color: white;
1528 | padding: 12px 24px;
1529 | border-radius: 8px;
1530 | z-index: 2000;
1531 | animation: fadeInOut 3s ease forwards;
1532 | }
1533 |
1534 | .share-modal {
1535 | position: fixed;
1536 | top: 0;
1537 | left: 0;
1538 | right: 0;
1539 | bottom: 0;
1540 | background: rgba(0, 0, 0, 0.95);
1541 | display: flex;
1542 | align-items: center;
1543 | justify-content: center;
1544 | z-index: 1000;
1545 | opacity: 0;
1546 | visibility: hidden;
1547 | transition: all 0.3s ease;
1548 | }
1549 |
1550 | .share-modal.visible {
1551 | opacity: 1;
1552 | visibility: visible;
1553 | }
1554 |
1555 | .share-content {
1556 | background: linear-gradient(135deg, #1a1a2a 0%, #2a2a4a 100%);
1557 | border-radius: 20px;
1558 | padding: 2rem;
1559 | width: 90%;
1560 | max-width: 600px;
1561 | }
1562 |
1563 | .share-header {
1564 | display: flex;
1565 | justify-content: space-between;
1566 | align-items: center;
1567 | margin-bottom: 1.5rem;
1568 | }
1569 |
1570 | @keyframes fadeInOut {
1571 | 0% { opacity: 0; transform: translate(-50%, 20px); }
1572 | 10% { opacity: 1; transform: translate(-50%, 0); }
1573 | 90% { opacity: 1; transform: translate(-50%, 0); }
1574 | 100% { opacity: 0; transform: translate(-50%, -20px); }
1575 | }
1576 |
1577 | @keyframes slideUp {
1578 | 0% {
1579 | opacity: 0;
1580 | transform: translateY(20px);
1581 | }
1582 | 100% {
1583 | opacity: 1;
1584 | transform: translateY(0);
1585 | }
1586 | }
1587 |
1588 | /* Animations */
1589 | @keyframes fadeIn {
1590 | from {
1591 | opacity: 0;
1592 | transform: translateY(10px);
1593 | }
1594 | to {
1595 | opacity: 1;
1596 | transform: translateY(0);
1597 | }
1598 | }
1599 |
1600 | @keyframes spin {
1601 | to {
1602 | transform: rotate(360deg);
1603 | }
1604 | }
1605 |
1606 | /* Utility Classes */
1607 | .hidden {
1608 | display: none !important;
1609 | }
1610 |
1611 | /* Scrollbar Styles */
1612 | ::-webkit-scrollbar {
1613 | width: 6px;
1614 | }
1615 |
1616 | ::-webkit-scrollbar-track {
1617 | background: #1e1e2f;
1618 | }
1619 |
1620 | ::-webkit-scrollbar-thumb {
1621 | background: #3a3a4d;
1622 | border-radius: 3px;
1623 | }
1624 |
1625 | ::-webkit-scrollbar-thumb:hover {
1626 | background: #4a4a5d;
1627 | }
1628 |
1629 | /* Text Styles */
1630 | h2 {
1631 | font-size: 24px;
1632 | font-weight: 500;
1633 | color: #ffffff;
1634 | margin-bottom: 16px;
1635 | text-align: center;
1636 | }
1637 |
1638 | h3, h4 {
1639 | font-size: 16px;
1640 | font-weight: 500;
1641 | color: #ffffff;
1642 | margin-bottom: 8px;
1643 | }
1644 |
1645 | /* Disabled state */
1646 | .selection-button.disabled {
1647 | opacity: 0.6;
1648 | cursor: not-allowed;
1649 | background: linear-gradient(
1650 | 135deg,
1651 | rgba(40, 40, 40, 0.95) 0%,
1652 | rgba(20, 20, 20, 0.95) 100%
1653 | );
1654 | border-image-source: linear-gradient(
1655 | to right,
1656 | #4a4a4a,
1657 | #6a6a6a,
1658 | #4a4a4a
1659 | );
1660 | transform: none;
1661 | }
1662 |
1663 | .selection-button.disabled:hover {
1664 | transform: none;
1665 | box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
1666 | }
1667 |
1668 | .no-quest-badge {
1669 | display: inline-block;
1670 | margin-top: 0.75rem;
1671 | padding: 0.25rem 0.75rem;
1672 | background: rgba(239, 68, 68, 0.2);
1673 | border: 1px solid rgba(239, 68, 68, 0.3);
1674 | border-radius: 4px;
1675 | color: #ef4444;
1676 | font-size: 0.75rem;
1677 | text-transform: uppercase;
1678 | letter-spacing: 0.05em;
1679 | }
1680 |
1681 | .loading-message {
1682 | text-align: center;
1683 | color: #a8b3cf;
1684 | padding: 1rem;
1685 | }
1686 |
1687 | .error-message {
1688 | text-align: center;
1689 | color: #ef4444;
1690 | padding: 1rem;
1691 | }
1692 |
1693 | /* Add these styles to style.css */
1694 |
1695 | .home-button {
1696 | position: fixed;
1697 | top: 20px;
1698 | right: 20px;
1699 | padding: 10px;
1700 | background: #2a2a3d;
1701 | border: none;
1702 | border-radius: 6px;
1703 | color: white;
1704 | cursor: pointer;
1705 | transition: all 0.2s ease;
1706 | z-index: 1001;
1707 | display: flex;
1708 | align-items: center;
1709 | justify-content: center;
1710 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
1711 | }
1712 |
1713 | .home-button:hover {
1714 | background: #3a3a4d;
1715 | transform: translateY(-2px);
1716 | }
1717 |
1718 | .home-icon {
1719 | width: 20px;
1720 | height: 20px;
1721 | }
1722 |
1723 | .home-confirm-modal {
1724 | position: fixed;
1725 | top: 0;
1726 | left: 0;
1727 | right: 0;
1728 | bottom: 0;
1729 | background: rgba(0, 0, 0, 0.8);
1730 | display: flex;
1731 | align-items: center;
1732 | justify-content: center;
1733 | z-index: 1000;
1734 | backdrop-filter: blur(4px);
1735 | opacity: 1;
1736 | visibility: visible;
1737 | transition: all 0.3s ease;
1738 | }
1739 |
1740 | .home-confirm-modal.hidden {
1741 | opacity: 0;
1742 | visibility: hidden;
1743 | }
1744 |
1745 | .home-confirm-modal .modal-content {
1746 | background: linear-gradient(135deg, #1a1a2a 0%, #2a2a4a 100%);
1747 | padding: 2rem;
1748 | border-radius: 12px;
1749 | border: 1px solid rgba(255, 215, 0, 0.15);
1750 | text-align: center;
1751 | max-width: 400px;
1752 | width: 90%;
1753 | box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
1754 | }
1755 |
1756 | .home-confirm-modal h3 {
1757 | font-family: 'Cinzel', serif;
1758 | font-size: 1.5rem;
1759 | color: #ffd700;
1760 | margin-bottom: 1rem;
1761 | }
1762 |
1763 | .home-confirm-modal p {
1764 | color: #e2e8f0;
1765 | margin-bottom: 1.5rem;
1766 | }
1767 |
1768 | .modal-actions {
1769 | display: flex;
1770 | gap: 1rem;
1771 | justify-content: center;
1772 | }
1773 |
1774 | .modal-actions button {
1775 | padding: 0.75rem 1.5rem;
1776 | border: none;
1777 | border-radius: 6px;
1778 | font-weight: 500;
1779 | cursor: pointer;
1780 | transition: all 0.2s ease;
1781 | }
1782 |
1783 | .confirm-button {
1784 | background: #dc2626;
1785 | color: white;
1786 | }
1787 |
1788 | .confirm-button:hover {
1789 | background: #b91c1c;
1790 | transform: translateY(-2px);
1791 | }
1792 |
1793 | .cancel-button {
1794 | background: #4b5563;
1795 | color: white;
1796 | }
1797 |
1798 | .cancel-button:hover {
1799 | background: #374151;
1800 | transform: translateY(-2px);
1801 | }
1802 |
1803 | .auth-modal {
1804 | position: fixed;
1805 | top: 0;
1806 | left: 0;
1807 | right: 0;
1808 | bottom: 0;
1809 | background: rgba(0, 0, 0, 0.8);
1810 | display: flex;
1811 | align-items: center;
1812 | justify-content: center;
1813 | z-index: 2000;
1814 | backdrop-filter: blur(8px);
1815 | opacity: 0;
1816 | visibility: hidden;
1817 | transition: all 0.3s ease;
1818 | }
1819 |
1820 | .auth-modal.visible {
1821 | opacity: 1;
1822 | visibility: visible;
1823 | }
1824 |
1825 | .google-signin-wrapper {
1826 | display: flex;
1827 | justify-content: center;
1828 | margin-top: 1.5rem;
1829 | }
1830 |
1831 | #googleSignIn {
1832 | width: 100% !important;
1833 | }
1834 |
1835 | #googleSignIn iframe {
1836 | scale: 1.2;
1837 | }
1838 |
1839 | .auth-content {
1840 | background: linear-gradient(135deg, #1a1a2a 0%, #2a2a4a 100%);
1841 | padding: 2.5rem;
1842 | border-radius: 20px;
1843 | border: 1px solid rgba(255, 215, 0, 0.15);
1844 | width: 90%;
1845 | max-width: 400px;
1846 | text-align: center;
1847 | box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4);
1848 | }
1849 |
1850 | .auth-title {
1851 | font-family: 'Cinzel', serif;
1852 | font-size: 2rem;
1853 | color: #ffd700;
1854 | margin-bottom: 2rem;
1855 | text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
1856 | }
1857 |
1858 | .auth-divider {
1859 | color: #a8b3cf;
1860 | margin: 1.5rem 0;
1861 | display: flex;
1862 | align-items: center;
1863 | text-transform: uppercase;
1864 | font-size: 0.8rem;
1865 | }
1866 |
1867 | .auth-divider::before,
1868 | .auth-divider::after {
1869 | content: '';
1870 | flex: 1;
1871 | height: 1px;
1872 | background: rgba(255, 255, 255, 0.1);
1873 | margin: 0 1rem;
1874 | }
1875 |
1876 | .google-button {
1877 | display: flex;
1878 | align-items: center;
1879 | justify-content: center;
1880 | gap: 0.75rem;
1881 | width: 100%;
1882 | padding: 0.75rem;
1883 | background: #2a2a3d;
1884 | border: 1px solid rgba(255, 255, 255, 0.1);
1885 | border-radius: 8px;
1886 | color: white;
1887 | font-size: 1rem;
1888 | cursor: pointer;
1889 | transition: all 0.2s ease;
1890 | }
1891 |
1892 | .google-button:hover {
1893 | background: #3a3a4d;
1894 | transform: translateY(-2px);
1895 | }
1896 |
1897 | .google-icon {
1898 | width: 24px;
1899 | height: 24px;
1900 | }
1901 |
1902 | .auth-footer {
1903 | margin-top: 2rem;
1904 | color: #a8b3cf;
1905 | font-size: 0.9rem;
1906 | }
1907 |
1908 | .auth-footer a {
1909 | color: #ffd700;
1910 | text-decoration: none;
1911 | }
1912 |
1913 | .auth-footer a:hover {
1914 | text-decoration: underline;
1915 | }
1916 |
1917 | .auth-close {
1918 | position: absolute;
1919 | top: 1rem;
1920 | right: 1rem;
1921 | background: none;
1922 | border: none;
1923 | color: #a8b3cf;
1924 | cursor: pointer;
1925 | font-size: 1.5rem;
1926 | transition: color 0.2s ease;
1927 | }
1928 |
1929 | .auth-close:hover {
1930 | color: white;
1931 | }
1932 |
1933 | .auth-container {
1934 | position: fixed;
1935 | top: 20px;
1936 | right: 80px;
1937 | z-index: 1001;
1938 | }
1939 |
1940 | .auth-button {
1941 | display: flex;
1942 | align-items: center;
1943 | gap: 0.5rem;
1944 | height: 40px;
1945 | padding: 0.75rem 1.25rem;
1946 | background: linear-gradient(135deg, rgba(42, 42, 61, 0.95) 0%, rgba(26, 26, 45, 0.95) 100%);
1947 | border: 1px solid rgba(255, 215, 0, 0.15);
1948 | border-radius: 6px;
1949 | color: #ffffff;
1950 | font-family: 'Alegreya', serif;
1951 | font-size: 1rem;
1952 | cursor: pointer;
1953 | transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
1954 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
1955 | }
1956 |
1957 | .auth-button:hover {
1958 | transform: translateY(-2px);
1959 | background: linear-gradient(135deg, rgba(58, 58, 77, 0.95) 0%, rgba(42, 42, 61, 0.95) 100%);
1960 | border-color: rgba(255, 215, 0, 0.3);
1961 | box-shadow: 0 6px 16px rgba(0, 0, 0, 0.3);
1962 | }
1963 |
1964 | .auth-button .auth-icon {
1965 | width: 18px;
1966 | height: 18px;
1967 | stroke-width: 2;
1968 | }
1969 |
1970 | .user-menu {
1971 | height: 40px;
1972 | position: fixed;
1973 | top: 20px;
1974 | right: 80px;
1975 | display: flex;
1976 | align-items: center;
1977 | gap: 0.5rem;
1978 | padding: 0rem 1rem;
1979 | background: linear-gradient(135deg, rgba(42, 42, 61, 0.95) 0%, rgba(26, 26, 45, 0.95) 100%);
1980 | border: 1px solid rgba(255, 215, 0, 0.15);
1981 | border-radius: 6px;
1982 | z-index: 1001;
1983 | }
1984 |
1985 | .sign-out-button {
1986 | padding: 0.25rem 0.75rem;
1987 | margin-left: 0.5rem;
1988 | background: rgba(220, 38, 38, 0.1);
1989 | border: 1px solid rgba(220, 38, 38, 0.2);
1990 | border-radius: 4px;
1991 | color: #ef4444;
1992 | font-size: 0.875rem;
1993 | cursor: pointer;
1994 | transition: all 0.2s ease;
1995 | }
1996 |
1997 | .sign-out-button:hover {
1998 | background: rgba(220, 38, 38, 0.2);
1999 | }
2000 |
2001 | .user-avatar {
2002 | width: 32px;
2003 | height: 32px;
2004 | border-radius: 50%;
2005 | border: 2px solid rgba(255, 215, 0, 0.3);
2006 | }
2007 |
2008 | .user-name {
2009 | color: #ffffff;
2010 | font-size: 0.9rem;
2011 | font-family: 'Alegreya', serif;
2012 | }
2013 |
2014 | /* Add to style.css */
2015 | .gallery-button {
2016 | display: flex;
2017 | align-items: center;
2018 | gap: 0.5rem;
2019 | padding: 0.25rem 0.75rem;
2020 | margin-left: 0.5rem;
2021 | background: rgba(255, 215, 0, 0.1);
2022 | border: 1px solid rgba(255, 215, 0, 0.2);
2023 | border-radius: 4px;
2024 | color: #ffd700;
2025 | font-size: 0.875rem;
2026 | cursor: pointer;
2027 | transition: all 0.2s ease;
2028 | }
2029 |
2030 | .gallery-button:hover {
2031 | background: rgba(255, 215, 0, 0.2);
2032 | transform: translateY(-2px);
2033 | }
2034 |
2035 | .gallery-button svg {
2036 | width: 16px;
2037 | height: 16px;
2038 | }
2039 |
2040 | /* Add back button for gallery page */
2041 | .gallery-back-button {
2042 | position: fixed;
2043 | top: 20px;
2044 | left: 20px;
2045 | display: flex;
2046 | align-items: center;
2047 | gap: 0.5rem;
2048 | padding: 0.5rem 1rem;
2049 | background: linear-gradient(135deg, #2a2a3d 0%, #1a1a2d 100%);
2050 | border: 1px solid rgba(255, 215, 0, 0.15);
2051 | border-radius: 6px;
2052 | color: white;
2053 | font-size: 0.875rem;
2054 | cursor: pointer;
2055 | transition: all 0.2s ease;
2056 | z-index: 1001;
2057 | }
2058 |
2059 | .gallery-back-button:hover {
2060 | transform: translateY(-2px);
2061 | background: linear-gradient(135deg, #3a3a4d 0%, #2a2a3d 100%);
2062 | }
--------------------------------------------------------------------------------