diff --git a/.gitignore b/.gitignore index 3dd3569..e6c29c8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ +# Ignore all files in data folder except default folder +data/* +!data/default/* + # Python __pycache__/ *.py[cod] diff --git a/app/core/logging.py b/app/core/logging.py index c58a44a..978ab54 100644 --- a/app/core/logging.py +++ b/app/core/logging.py @@ -2,64 +2,74 @@ import json from datetime import datetime import os +from pathlib import Path +import sys +import traceback +from typing import Dict, Any -class JSONFormatter(logging.Formatter): - """Custom JSON formatter for structured logging""" +class CustomFormatter(logging.Formatter): + """Custom formatter that includes extra fields in the message""" def format(self, record: logging.LogRecord) -> str: - log_data = { - "timestamp": datetime.utcnow().isoformat(), - "level": record.levelname, - "message": record.getMessage(), - "module": record.module, - "function": record.funcName, - "line": record.lineno - } - - # Add extra fields if they exist - if hasattr(record, "extra_data"): - log_data.update(record.extra_data) - - # Add exception info if present - if record.exc_info: - log_data["exception"] = self.formatException(record.exc_info) - - return json.dumps(log_data) + # Get the original message + message = super().format(record) + + # If there are extra fields, append them to the message + if hasattr(record, 'extra'): + extras = ' | '.join(f"{k}={v}" for k, v in record.extra.items()) + message = f"{message} | {extras}" + + return message def setup_logging() -> None: - """Configure logging for the application""" + """Configure logging with custom formatter""" # Create logs directory if it doesn't exist - os.makedirs("logs", exist_ok=True) + logs_dir = Path("logs") + logs_dir.mkdir(exist_ok=True) - logger = logging.getLogger("game_jam") - logger.setLevel(logging.INFO) - - # Console handler with JSON formatting - console_handler = logging.StreamHandler() - console_handler.setFormatter(JSONFormatter()) - logger.addHandler(console_handler) - - # File handler for persistent logs - file_handler = logging.FileHandler("logs/game_jam.log") - file_handler.setFormatter(JSONFormatter()) - logger.addHandler(file_handler) - - # Prevent propagation to root logger - logger.propagate = False + # Create formatters + console_formatter = CustomFormatter( + '%(asctime)s | %(levelname)s | %(name)s | %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' + ) + + file_formatter = CustomFormatter( + '%(asctime)s | %(levelname)s | %(name)s | %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' + ) + + # Configure console handler + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setFormatter(console_formatter) + + # Configure file handler + current_time = datetime.now().strftime('%Y%m%d_%H%M%S') + file_handler = logging.FileHandler( + logs_dir / f'app_{current_time}.log', + encoding='utf-8' + ) + file_handler.setFormatter(file_formatter) + + # Configure root logger + root_logger = logging.getLogger() + root_logger.setLevel(logging.DEBUG) + + # Remove any existing handlers + root_logger.handlers = [] + + # Add our handlers + root_logger.addHandler(console_handler) + root_logger.addHandler(file_handler) def get_logger(name: str) -> logging.Logger: - """Get a logger instance with the given name""" - return logging.getLogger(f"game_jam.{name}") + """Get a logger with the given name""" + return logging.getLogger(name) class LoggerMixin: """Mixin to add logging capabilities to a class""" @classmethod def get_logger(cls) -> logging.Logger: - """Get a logger for the class""" - if not hasattr(cls, "_logger"): - cls._logger = get_logger(cls.__name__) - return cls._logger - + return get_logger(cls.__name__) + @property def logger(self) -> logging.Logger: - """Get a logger for the instance""" - return self.get_logger() \ No newline at end of file + return get_logger(self.__class__.__name__) \ No newline at end of file diff --git a/app/main.py b/app/main.py index 96ca86d..250ebd8 100644 --- a/app/main.py +++ b/app/main.py @@ -1,11 +1,11 @@ from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import Response -from app.routes import health, wagons, chat, players -from app.core.logging import get_logger +from app.routes import health, wagons, chat, players, generate +from app.core.logging import get_logger, setup_logging from dotenv import load_dotenv from datetime import datetime import time +from pathlib import Path # Load environment variables load_dotenv() @@ -26,7 +26,7 @@ # 1. Configure CORS for Unity Web Requests # ----------------------------------------------------------------------------- # -# Unity’s documentation highlights the need for the following CORS headers: +# Unity's documentation highlights the need for the following CORS headers: # "Access-Control-Allow-Origin": "*", # "Access-Control-Allow-Methods": "GET, POST, OPTIONS", # "Access-Control-Allow-Headers": "Accept, X-Access-Token, X-Application-Name, X-Request-Sent-Time", @@ -36,11 +36,11 @@ # This is the simplest approach. If you need cookies or authentication headers, # switch `allow_origins` to an explicit domain and set `allow_credentials=True`. # -# Note: We also allow PUT, DELETE, etc., but that’s up to your application needs. +# Note: We also allow PUT, DELETE, etc., but that's up to your application needs. # app.add_middleware( CORSMiddleware, - allow_origins=["*"], # "https://your-custom-domain.com" if credentials are needed + allow_origins=["*"], allow_credentials=False, # Must be False if allow_origins=["*"] in modern browsers allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD", "PATCH"], allow_headers=["*"], # You could limit this to ["Accept", "X-Access-Token", ...] if desired @@ -155,6 +155,7 @@ async def log_requests(request: Request, call_next): app.include_router(wagons.router) app.include_router(chat.router) app.include_router(players.router) +app.include_router(generate.router) # ----------------------------------------------------------------------------- # 6. Basic root endpoint @@ -171,3 +172,13 @@ async def root(): "players_endpoint": "/api/players" } +# Ensure logs directory exists and setup logging at startup +@app.on_event("startup") +async def startup_event(): + # Create logs directory if it doesn't exist + logs_dir = Path("logs") + logs_dir.mkdir(exist_ok=True) + + # Initialize logging + setup_logging() + diff --git a/app/models/session.py b/app/models/session.py index 4742c0e..400e5ce 100644 --- a/app/models/session.py +++ b/app/models/session.py @@ -34,6 +34,7 @@ class UserSession(BaseModel): guessing_progress: GuessingProgress = Field(default_factory=GuessingProgress) created_at: datetime = Field(default_factory=datetime.utcnow) last_active: datetime = Field(default_factory=datetime.utcnow) + default_game: bool = Field(default=True) class Config: json_schema_extra = { diff --git a/app/models/train.py b/app/models/train.py new file mode 100644 index 0000000..85bc475 --- /dev/null +++ b/app/models/train.py @@ -0,0 +1,51 @@ +from pydantic import BaseModel, Field +from typing import List + +class PassengerProfile(BaseModel): + name: str + age: int + profession: str + personality: str + role: str + mystery_intrigue: str + +class PlayerName(BaseModel): + playerId: str + firstName: str + lastName: str + sex: str + fullName: str + +class PlayerDetails(BaseModel): + playerId: str + profile: PassengerProfile + +class Person(BaseModel): + uid: str + position: List[float] = Field(..., min_items=2, max_items=2) + rotation: float + model_type: str + items: List[str] = [] + +class Wagon(BaseModel): + id: int + theme: str + passcode: str + people: List[Person] + +class WagonNames(BaseModel): + wagonId: str + players: List[PlayerName] + +class WagonPlayerDetails(BaseModel): + wagonId: str + players: List[PlayerDetails] + + +class WagonsResponse(BaseModel): + wagons: List[Wagon] + +class GenerateTrainResponse(BaseModel): + names: List[WagonNames] + player_details: List[WagonPlayerDetails] + wagons: List[Wagon] \ No newline at end of file diff --git a/app/routes/chat.py b/app/routes/chat.py index 2e32485..f45adf9 100644 --- a/app/routes/chat.py +++ b/app/routes/chat.py @@ -35,10 +35,6 @@ class GuessResponse(BaseModel): score: float -def get_chat_service(): - return ChatService() - - def get_guess_service(): return GuessingService() @@ -59,6 +55,7 @@ class ChatMessage(BaseModel): def get_session(session_id: str) -> UserSession: """Dependency to get and validate session""" session = SessionService.get_session(session_id) + logger.info(f"Session found: {session}") if not session: raise HTTPException(status_code=404, detail="Session not found") return session @@ -137,13 +134,17 @@ async def chat_with_character( uid: str, chat_message: ChatMessage, session: UserSession = Depends(get_session), - chat_service: ChatService = Depends(get_chat_service), tts_service: TTSService = Depends(get_tts_service), ) -> dict: """ Send a message to a character and get their response. The input is a JSON containing the prompt and related data. """ + + # Get the chat service, that loads the character details + chat_service = ChatService(session) + + # add first checks that the user exists try: wagon_id = int(uid.split("-")[1]) if wagon_id != session.current_wagon.wagon_id: @@ -161,7 +162,7 @@ async def chat_with_character( if not conversation: raise HTTPException(status_code=500, detail="Failed to process message") - ai_response = chat_service.generate_response(uid, conversation) + ai_response = chat_service.generate_response(uid, session.current_wagon.theme, conversation) if not ai_response: raise HTTPException(status_code=500, detail="Failed to generate response") diff --git a/app/routes/generate.py b/app/routes/generate.py new file mode 100644 index 0000000..0a6666e --- /dev/null +++ b/app/routes/generate.py @@ -0,0 +1,69 @@ +from fastapi import APIRouter, HTTPException +from app.services.generate_train.generate_train import GenerateTrainService +from app.models.train import GenerateTrainResponse +from app.utils.file_management import FileManager +from app.core.logging import get_logger +from app.services.session_service import SessionService +import json + + +router = APIRouter( + prefix="/api/generate", + tags=["train-generation"] +) + +logger = get_logger("generate") + +@router.get("/train/{session_id}/{number_of_wagons}/{theme}") +async def get_generated_train( + session_id: str, + number_of_wagons: str, + theme: str +): + """ + Generate a new train with specified parameters for a session. + + - Validates the session exists + - Creates wagons with theme-appropriate passcodes + - Generates passengers with names and profiles + - Stores the generated data for the session + - Returns the complete train data structure + """ + session = SessionService.get_session(session_id) + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + try: + number_of_wagons = int(number_of_wagons) + except ValueError: + raise HTTPException(status_code=400, detail="number_of_wagons must be an integer") + + if number_of_wagons <= 0: + raise HTTPException(status_code=400, detail="number_of_wagons must be greater than 0") + + if number_of_wagons > 6: + raise HTTPException(status_code=400, detail="number_of_wagons cannot exceed 6") + + try: + generate_train_service = GenerateTrainService() + names_data, player_details_data, wagons_data = generate_train_service.generate_train(theme, number_of_wagons) + + # Save the raw data + FileManager.save_session_data(session_id, names_data, player_details_data, wagons_data) + + # Construct response with proper schema + response = { + "names": names_data, + "player_details": player_details_data, + "wagons": wagons_data + } + + logger.info(f"Setting default_game to False | session_id={session_id}") + session.default_game = False + SessionService.update_session(session) + return response + + except Exception as e: + logger.error(f"Failed to generate train for session {session_id}: {str(e)}") + raise HTTPException(status_code=500, detail=f"Failed to generate train: {str(e)}") + \ No newline at end of file diff --git a/app/routes/players.py b/app/routes/players.py index 693e414..0925288 100644 --- a/app/routes/players.py +++ b/app/routes/players.py @@ -3,8 +3,10 @@ import json from pathlib import Path from app.core.logging import get_logger +from app.services.session_service import SessionService +from app.utils.file_management import FileManager -router = APIRouter() +router = APIRouter(tags=["players"]) logger = get_logger("players") @@ -22,123 +24,184 @@ def load_json_file(file_path: str) -> dict: return {} -def filter_player_info(complete_info: dict, properties: List[str] = None) -> dict: - if not properties: - logger.debug("No properties filter applied") - return complete_info - - filtered_info = {} - # Add the id to the filtered info - filtered_info["id"] = complete_info["id"] - valid_properties = {"profile", "name_info"} - - logger.info(f"Filtering properties: {properties}") - for prop in properties: - if prop in valid_properties and prop in complete_info: - filtered_info[prop] = complete_info[prop] - else: - logger.warning(f"Invalid or missing property requested: {prop}") - - return filtered_info - - -@router.get("/api/players/{wagon_id}/{player_id}") +@router.get("/api/players/{session_id}/{wagon_id}/{player_id}") async def get_player_info( + session_id: str, wagon_id: str, player_id: str, - properties: Optional[List[str]] = Query( - None, description="Filter specific properties" - ), + properties: Optional[List[str]] = Query(None, description="Filter specific properties") ): - logger.info(f"Getting player info for wagon_id={wagon_id}, player_id={player_id}") - if properties: - logger.info(f"Requested properties: {properties}") - - # Define paths to JSON files - data_dir = Path("data") - player_details_path = data_dir / "player_details.json" - names_path = data_dir / "names.json" - - # Load data from JSON files - player_details = load_json_file(player_details_path) - names_data = load_json_file(names_path) - - # Get player details + logger.info( + f"Getting player info | session_id: {session_id} | wagon_id: {wagon_id} | player_id: {player_id} | requested_properties: {properties}" + ) + try: - player_info = player_details["player_details"][wagon_id][player_id] - logger.debug(f"Found player details for {player_id} in wagon {wagon_id}") - except KeyError: - logger.error(f"Player not found: wagon_id={wagon_id}, player_id={player_id}") - raise HTTPException( - status_code=404, detail="Player not found in player details" + session = SessionService.get_session(session_id) + if not session: + logger.error(f"Session not found: {session_id}") + raise HTTPException(status_code=404, detail="Session not found") + + logger.debug( + f"Loading session data | session_id: {session_id} | default_game: {session.default_game}" ) - - # Get player name information - try: - name_info = names_data["names"][wagon_id][player_id] - logger.debug(f"Found name information for {player_id} in wagon {wagon_id}") - except KeyError: - logger.warning( - f"Name information not found for player {player_id} in wagon {wagon_id}" + + # Load data based on default_game flag + names, player_details, _ = FileManager.load_session_data(session_id, session.default_game) + # try to convert the wagon_id to an integer if it is not already an integer + try: + wagon_index = int(wagon_id.split("-")[1]) + except ValueError: + logger.error(f"Invalid wagon_id: {wagon_id}") + raise HTTPException(status_code=404, detail="Invalid wagon_id") + + try: + # First check if player_details is contained in the loaded data + if len(player_details) == 0: + logger.error("Missing 'player_details' key in loaded data") + raise HTTPException(status_code=404, detail="Player details not found") + + # players is a list of dictionaries, so we need to filter the list for the player_id + player_info = next((player for player in player_details[wagon_index]["players"] if player["playerId"] == player_id), None) + # check if player_info is found + if player_info is None: + logger.error(f"Player info not found | wagon: {wagon_id} | player: {player_id}") + raise HTTPException(status_code=404, detail="Player info not found") + + logger.debug( + f"Found player info | wagon: {wagon_id} | player: {player_id} | profile_exists: {'profile' in player_info}" + ) + + # first check if names is contained in the loaded data + if len(names) == 0: + logger.error("Missing 'names' key in loaded data") + raise HTTPException(status_code=404, detail="Names not found") + + # "players" is a list of dictionaries, so we need to filter the list for the player_id + name_info = next((player for player in names[wagon_index]["players"] if player["playerId"] == player_id), None) + # check if name_info is found + if name_info is None: + logger.error(f"Name info not found | wagon: {wagon_id} | player: {player_id}") + raise HTTPException(status_code=404, detail="Name info not found") + + logger.debug( + f"Found name info | wagon: {wagon_id} | player: {player_id}" + ) + + # Combine information + player_in_current_wagon_info = { + "id": player_id, + "name_info": name_info, + "profile": player_info.get("profile", {}) + } + + # Filter properties if specified + if properties: + logger.info( + f"Filtering player info | requested_properties: {properties} | available_properties: {list(player_in_current_wagon_info.keys())}" + ) + + logger.info("Successfully retrieved complete player info") + return player_in_current_wagon_info + + except KeyError as e: + logger.error( + f"Failed to find player data | error: {str(e)} | wagon_id: {wagon_id} | player_id: {player_id}" + ) + raise HTTPException(status_code=404, detail="Player not found") + + except FileNotFoundError as e: + logger.error( + f"Failed to load session data | error: {str(e)} | session_id: {session_id}" ) - name_info = {} + raise HTTPException(status_code=404, detail="Session not found") - # Combine all information - complete_player_info = { - "id": player_id, - "name_info": name_info, - "profile": player_info.get("profile", {}), - } - # Filter properties if specified - result = filter_player_info(complete_player_info, properties) - logger.info( - f"Successfully retrieved player info for {player_id} in wagon {wagon_id}" - ) - return result - - -@router.get("/api/players/{wagon_id}") +@router.get("/api/players/{session_id}/{wagon_id}") async def get_wagon_players( + session_id: str, wagon_id: str, properties: Optional[List[str]] = Query( None, description="Filter specific properties (name_info, profile, traits, inventory, dialogue)", ), ): - logger.info(f"Getting all players for wagon_id={wagon_id}") + session = SessionService.get_session(session_id) + # check if session is found + if not session: + logger.error(f"Session not found: {session_id}") + raise HTTPException(status_code=404, detail="Session not found") + + logger.info(f"Getting all players for wagon_id={wagon_id} | session_id={session_id}") if properties: logger.info(f"Requested properties: {properties}") - # Define paths to JSON files - data_dir = Path("data") - player_details_path = data_dir / "player_details.json" - names_path = data_dir / "names.json" + logger.debug( + f"Loading session data | session_id={session_id} | default_game={session.default_game}" + ) - # Load data from JSON files - player_details = load_json_file(player_details_path) - names_data = load_json_file(names_path) + # try catch for wagon_index + try: + wagon_index = int(wagon_id.split("-")[1]) + except ValueError: + logger.error(f"Invalid wagon_id: {wagon_id}") + raise HTTPException(status_code=404, detail="Invalid wagon_id") + wagon_index = int(wagon_id.split("-")[1]) + try: - wagon_players = player_details["player_details"][wagon_id] - wagon_names = names_data["names"][wagon_id] - logger.debug(f"Found {len(wagon_players)} players in wagon {wagon_id}") + # Load data based on default_game flag + names, player_details, _ = FileManager.load_session_data(session_id, session.default_game) + + if len(player_details) == 0: + # check if player_details is contained in the loaded data + logger.error("player_details is empty") + raise HTTPException(status_code=404, detail="Player details not found") + + # Check if player details exists for the wagon_index + # should check whether None or empty list + if not player_details[wagon_index]["players"]: + logger.error(f"Player details not found for wagon_index={wagon_index}") + raise HTTPException(status_code=404, detail="Player details not found") + + players_in_current_wagon = player_details[wagon_index]["players"] + logger.debug(f"Found player info | wagon={wagon_id} | player_count={len(players_in_current_wagon)}") + + # check if names is contained in the loaded data + if len(names) == 0: + logger.error("names is empty") + raise HTTPException(status_code=404, detail="Names not found") + + names_in_current_wagon = names[wagon_index] + logger.debug(f"Found name info | wagon={wagon_id} | name_count={len(names_in_current_wagon)}") + + # Create dictionaries for quick lookup by player ID + name_info = { + player["playerId"]: player + for player in names_in_current_wagon["players"] + } + + player_info = { + player["playerId"]: player + for player in players_in_current_wagon + } # Combine information for all players in the wagon - players_info = [] - - for player_id in wagon_players: - logger.debug(f"Processing player {player_id} in wagon {wagon_id}") + players_in_current_wagon_info = [] + for player_id in player_info: + logger.debug(f"Processing player | wagon={wagon_id} | player={player_id}") complete_info = { "id": player_id, - "name_info": wagon_names.get(player_id, {}), - "profile": wagon_players[player_id].get("profile", {}), + "name_info": name_info.get(player_id, {}), + "profile": player_info[player_id].get("profile", {}) } - filtered_info = filter_player_info(complete_info, properties) - players_info.append(filtered_info) - - logger.info(f"Successfully retrieved all players for wagon {wagon_id}") - return {"players": players_info} - except KeyError: - logger.error(f"Wagon not found: wagon_id={wagon_id}") - raise HTTPException(status_code=404, detail="Wagon not found") + players_in_current_wagon_info.append(complete_info) + + logger.info(f"Successfully retrieved all players | wagon={wagon_id} | player_count={len(players_in_current_wagon_info)}") + return {"players": players_in_current_wagon_info} + + except FileNotFoundError as e: + logger.error(f"Failed to load session data | error={str(e)} | session_id={session_id}") + raise HTTPException(status_code=404, detail="Session data not found") + except Exception as e: + logger.error(f"Unexpected error | error={str(e)} | wagon_id={wagon_id}") + raise HTTPException(status_code=500, detail="Internal server error") diff --git a/app/routes/wagons.py b/app/routes/wagons.py index 6379335..ea4aacf 100644 --- a/app/routes/wagons.py +++ b/app/routes/wagons.py @@ -1,22 +1,25 @@ from fastapi import APIRouter, HTTPException from pathlib import Path import json +from app.services.session_service import SessionService +from app.utils.file_management import FileManager router = APIRouter( prefix="/api/wagons", tags=["wagons"] ) -@router.get("/") -async def get_wagons(): - """Get all wagons data""" +@router.get("/{session_id}") +async def get_wagons(session_id: str): + session = SessionService.get_session(session_id) + if not session: + raise HTTPException(status_code=404, detail="Session not found") + try: - file_path = Path("data/wagons.json") - if not file_path.exists(): - raise HTTPException(status_code=404, detail="Wagons data not found") - - with open(file_path, "r") as f: - wagons_data = json.load(f) + # Use default_game flag from session to determine data source + wagons_data = FileManager.load_session_data(session_id, session.default_game)[2] return wagons_data - except json.JSONDecodeError: - raise HTTPException(status_code=500, detail="Error reading wagons data") \ No newline at end of file + except FileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error reading wagons data: {str(e)}") \ No newline at end of file diff --git a/app/services/chat_service.py b/app/services/chat_service.py index db0d1b1..78900c8 100644 --- a/app/services/chat_service.py +++ b/app/services/chat_service.py @@ -5,6 +5,8 @@ from typing import Optional, Dict import os from mistralai import Mistral +from app.utils.file_management import FileManager +from app.models.session import UserSession # Get the Mistral API key from environment (injected by ECS) @@ -12,18 +14,15 @@ class ChatService(LoggerMixin): - def __init__(self): + def __init__(self, session: UserSession): self.logger.info("Initializing ChatService") - self.character_details: Dict = self._load_character_details() + # Load all available character in every wagon. + self.player_details: Dict = self._load_player_details(session) - if not self.character_details: - self.logger.error( - "Failed to initialize character details - dictionary is empty" - ) + if len(self.player_details) == 0: + self.logger.error("Failed to initialize player details - array is empty") else: - self.logger.info( - f"Loaded character details for wagons: {list(self.character_details.keys())}" - ) + self.logger.info(f"Loaded player details for wagons: {list(self.player_details)}") # Get the Mistral API key from environment (injected by ECS) mistral_api_key = os.getenv("MISTRAL_API_KEY") @@ -37,43 +36,25 @@ def __init__(self): self.logger.info("Initialized Mistral AI client") @classmethod - def _load_character_details(cls) -> Dict: + def _load_player_details(cls, session) -> Dict: """Load character details from JSON files""" try: - # Get the absolute path to the data directory - current_dir = Path(__file__).resolve().parent - app_dir = current_dir.parent - backend_dir = app_dir.parent - player_details_path = backend_dir / "data" / "player_details.json" - - # use cls because it's a class method - cls.get_logger().info( - f"Attempting to load character details from {player_details_path}" - ) - - # check if the file exists - if not player_details_path.exists(): - cls.get_logger().error(f"File not found: {player_details_path}") + # Use FileManager to load the default session data + _, player_details, _ = FileManager.load_session_data(session.session_id, session.default_game) + + if len(player_details) == 0: + cls.get_logger().error("Missing 'player_details' key in JSON data") return {} - - # read the contents of the file. - with open(player_details_path, "r") as f: - data = json.load(f) - # check if the key exists - if "player_details" not in data: - cls.get_logger().error("Missing 'player_details' key in JSON data") - return {} - # load the details about the players in the wagons - details = data["player_details"] - cls.get_logger().info( - f"Successfully loaded character details. Available wagons: {list(details.keys())}" - ) - return details - except json.JSONDecodeError as e: - cls.get_logger().error(f"JSON parsing error: {str(e)}") + + # success for loading player_details + cls.get_logger().info(f"Successfully loaded player details.: {list(player_details)}") + return player_details + + except FileNotFoundError as e: + cls.get_logger().error(f"Failed to load default player details: {str(e)}") return {} except Exception as e: - cls.get_logger().error(f"Failed to load character details: {str(e)}") + cls.get_logger().error(f"Failed to load player details: {str(e)}") return {} def _get_character_context(self, uid: str) -> Optional[Dict]: @@ -82,51 +63,35 @@ def _get_character_context(self, uid: str) -> Optional[Dict]: self.logger.debug(f"Getting character context for uid: {uid}") # "wagon--player-" uid_splitted = uid.split("-") + # try catch for wagon_index + try: + wagon_index = int(uid_splitted[1]) + except ValueError: + self.logger.error(f"Invalid wagon index | uid: {uid} | wagon_index: {uid_splitted[1]}") + return None + wagon_key, player_key = ( - f"wagon-{uid_splitted[1]}", + f"wagon-{wagon_index}", f"player-{uid_splitted[3]}", ) # check if the wagon key exists - if wagon_key not in self.character_details: - self.logger.error( - f"Wagon {wagon_key} not found in character details. Available wagons: {list(self.character_details.keys())}" - ) + if len(self.player_details) == 0: + self.logger.error("Wagon not found in player details") return None - # check if the player key exists - if player_key not in self.character_details[wagon_key]: - self.logger.error( - f"Player {player_key} not found in wagon {wagon_key}. Available players: {list(self.character_details[wagon_key].keys())}" - ) - return None + # find specific player details + specific_player_detais = next((player for player in self.player_details[wagon_index]["players"] if player["playerId"] == player_key), None) - # get the details of the player that belongs to the wagon - character = self.character_details[wagon_key][player_key] self.logger.debug( - "Retrieved character context", - extra={ - "uid": uid, - "wagon": wagon_key, - "player": player_key, - "profession": character["profile"]["profession"], - }, + f"Retrieved player context | uid: {uid} | wagon: {wagon_key} | player: {player_key} | profession: {specific_player_detais['profile']['profession']}" ) - return character + return specific_player_detais except (KeyError, IndexError) as e: - self.logger.error( - f"Failed to get character context: {str(e)}", - extra={ - "uid": uid, - "error": str(e), - "character_details_keys": list(self.character_details.keys()) - if self.character_details - else None, - }, - ) + self.logger.error(f"Failed to get character context: {str(e)} | uid: {uid} | error: {str(e)} | player_details_keys: {list(self.player_details) if self.player_details else None}") return None - def _create_character_prompt(self, character: Dict) -> str: + def _create_character_prompt(self, theme: str, character: Dict) -> str: """Create a prompt that describes the character's personality and context""" occupation = character["profile"]["profession"] personality = character["profile"]["personality"] @@ -135,7 +100,8 @@ def _create_character_prompt(self, character: Dict) -> str: name = character["profile"]["name"] prompt = f""" - You are an NPC in a fictional world. Your name is {name}, and you are a {occupation} by trade. + You are an NPC in a fictional world set in the theme of {theme}. You are part of this theme's story and lore. + Your name is {name}, and you are a {occupation}. Your role in the story is {role}, and you have a mysterious secret tied to you: {mystery}. Your personality is {personality}, which influences how you speak, act, and interact with others. Stay in character at all times, and respond to the player based on your occupation, role, mystery, and personality. @@ -151,7 +117,7 @@ def _create_character_prompt(self, character: Dict) -> str: return prompt - def generate_response(self, uid: str, conversation: Conversation) -> Optional[str]: + def generate_response(self, uid: str, theme: str, conversation: Conversation) -> Optional[str]: """Generate a response using Mistral AI based on character profile""" self.logger.info(f"Generating response for uid: {uid}") character = self._get_character_context(uid) @@ -164,7 +130,7 @@ def generate_response(self, uid: str, conversation: Conversation) -> Optional[st try: # Create the system prompt with character context - system_prompt = self._create_character_prompt(character) + system_prompt = self._create_character_prompt(theme, character) # Convert conversation history to Mistral AI format messages = [{"role": "system", "content": system_prompt}] @@ -176,26 +142,33 @@ def generate_response(self, uid: str, conversation: Conversation) -> Optional[st messages.append({"role": role, "content": msg.content}) # Get response from Mistral AI - chat_response = self.client.chat.complete( - model=self.model, messages=messages, temperature=0.7, max_tokens=500 - ) + try: + chat_response = self.client.chat.complete( + model=self.model, messages=messages, temperature=0.7, max_tokens=500 + ) - response = chat_response.choices[0].message.content + if not chat_response or not chat_response.choices: + raise ValueError("Empty response received from Mistral AI") - self.logger.info( - "Generated Mistral AI response", - extra={ - "uid": uid, - "response_length": len(response), - "conversation_length": len(conversation.messages), - }, - ) + response = chat_response.choices[0].message.content - return response + if not response or not isinstance(response, str): + raise ValueError(f"Invalid response format: {type(response)}") + + self.logger.info( + f"Generated Mistral AI response | uid: {uid} | response_length: {len(response)} | conversation_length: {len(conversation.messages)}" + ) + + return response + + except Exception as api_error: + self.logger.error( + f"Mistral API error | uid: {uid} | error: {str(api_error)} | messages_count: {len(messages)}" + ) + raise ValueError(f"Mistral API error: {str(api_error)}") except Exception as e: self.logger.error( - f"Failed to generate Mistral AI response: {str(e)}", - extra={"uid": uid, "error": str(e)}, + f"Failed to generate Mistral AI response | uid: {uid} | error: {str(e)} | error_type: {type(e).__name__} | character_name: {character.get('profile', {}).get('name', 'unknown')}" ) - return "I apologize, but I'm having trouble responding right now. Please try again later." + return f"I apologize, but I'm having trouble responding right now. Error: {str(e)}" diff --git a/app/services/generate_train/__init__.py b/app/services/generate_train/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/generate_train/convert.py b/app/services/generate_train/convert.py new file mode 100644 index 0000000..9e71dc1 --- /dev/null +++ b/app/services/generate_train/convert.py @@ -0,0 +1,168 @@ +from typing import Dict, List, Tuple +import random +from app.core.logging import get_logger + +logger = get_logger("convert") + +def parse_name(full_name): + """ + Splits the full name into firstName and lastName. + This is a simple heuristic: + - lastName is the last token + - firstName is everything before + For example: + "Dr. Amelia Hartford" -> firstName: "Dr. Amelia", lastName: "Hartford" + "Thomas Maxwell" -> firstName: "Thomas", lastName: "Maxwell" + Adjust this to your own naming conventions if needed. + """ + tokens = full_name.strip().split() + if len(tokens) == 1: + return full_name, "" # No clear lastName + else: + return " ".join(tokens[:-1]), tokens[-1] + +def infer_sex_from_model(model_type): + """ + A simple inference: if the string 'female' is in model_type, mark sex as 'female'; + if 'male' is in model_type, mark as 'male'; + otherwise, mark as 'unknown' (or handle however you prefer). + """ + model_type_lower = model_type.lower() + if 'female' in model_type_lower: + return 'female' + elif 'male' in model_type_lower: + return 'male' + else: + return 'unknown' + +# triggered for one single wagon +def convert_wagon_to_three_jsons(wagon_data: Dict) -> Tuple[Dict, Dict, Dict]: + """ + Given a single wagon JSON structure like: + { + "id": 1, + "theme": "Alien by Ridley Scott", + "passcode": "Nostromo", + "passengers": [ + { + "name": "Dr. Amelia Hartford", + "age": 47, + "profession": "Medical Researcher", + "personality": "Analytical, compassionate, and meticulous", + "role": "...", + "mystery_intrigue": "...", + "characer_model": "character-female-e" + }, + ... + ] + } + produce: + 1) names_json + 2) player_details_json + 3) wagons_json + """ + wagon_id = wagon_data.get("id", 0) + theme = wagon_data.get("theme", "Unknown Theme") + passcode = wagon_data.get("passcode", "no-passcode") + passengers = wagon_data.get("passengers", []) + + logger.debug(f"Processing wagon conversion | wagon_id={wagon_id} | theme={theme} | num_passengers={len(passengers)}") + + try: + # 1) Build the "names" object for this wagon + names_entry = { + "wagonId": f"wagon-{wagon_id}", + "players": [] + } + + # 2) Build the "player_details" object for this wagon + player_details_entry = { + "wagonId": f"wagon-{wagon_id}", + "players": [] + } + + # 3) Build the "wagon" object + wagon_entry = { + "id": wagon_id, + "theme": theme, + "passcode": passcode, + "people": [] + } + + # Process each passenger + for i, passenger in enumerate(passengers, 1): + logger.debug(f"Converting passenger data | wagon_id={wagon_id} | passenger_index={i} | passenger_name={passenger.get('name', 'Unknown')}") + + player_key = f"player-{i}" + name = passenger.get("name", "") + + # Split name into components + name_parts = name.split() + first_name = name_parts[0] if name_parts else "" + last_name = " ".join(name_parts[1:]) if len(name_parts) > 1 else "" + + # Determine sex based on character model + model_type = passenger.get("characer_model", "character-male-a") + sex = "female" if "female" in model_type else "male" + + # Add to names structure + names_entry["players"].append({ + "playerId": player_key, + "firstName": first_name, + "lastName": last_name, + "sex": sex, + "fullName": name + }) + + # Add to player_details structure + profile = { + "name": name, + "age": passenger.get("age", 0), + "profession": passenger.get("profession", ""), + "personality": passenger.get("personality", ""), + "role": passenger.get("role", ""), + "mystery_intrigue": passenger.get("mystery_intrigue", "") + } + player_details_entry["players"].append({"playerId": player_key, "profile": profile}) + + # Add to wagon people structure + person_dict = { + "uid": f"wagon-{wagon_id}-player-{i}", + "position": [round(random.random(), 2), round(random.random(), 2)], + "rotation": round(random.random(), 2), + "model_type": model_type, + "items": [] + } + wagon_entry["people"].append(person_dict) + + logger.debug(f"Completed wagon conversion | wagon_id={wagon_id} | players_processed={len(passengers)}") + return names_entry, player_details_entry, wagon_entry + + except Exception as e: + logger.error(f"Error converting wagon | wagon_id={wagon_id} | error_type={type(e).__name__} | error_msg={str(e)}") + raise + +def convert_and_return_jsons(wagons_data: List[Dict]) -> Tuple[Dict, Dict, Dict]: + """Convert raw wagon data into the three required JSON structures""" + logger.info(f"Starting conversion of wagon data | total_wagons={len(wagons_data)}") + + all_names = [] + all_player_details = [] + all_wagons = [] + + try: + for wagon in wagons_data: + logger.debug(f"Converting wagon | wagon_id={wagon['id']} | theme={wagon['theme']} | num_passengers={len(wagon.get('passengers', []))}") + + names, player_details, wagon_entry = convert_wagon_to_three_jsons(wagon) + + all_names.append(names) + all_player_details.append(player_details) + all_wagons.append(wagon_entry) + + logger.info(f"Successfully converted all wagons | total_names={len(all_names)} | total_player_details={len(all_player_details)} | total_wagons={len(all_wagons)}") + return all_names, all_player_details, all_wagons + + except Exception as e: + logger.error(f"Error converting wagon data | error_type={type(e).__name__} | error_msg={str(e)}") + raise \ No newline at end of file diff --git a/app/services/generate_train/generate_train.py b/app/services/generate_train/generate_train.py new file mode 100644 index 0000000..2cacaa8 --- /dev/null +++ b/app/services/generate_train/generate_train.py @@ -0,0 +1,241 @@ +from mistralai import Mistral +import os +import json +import random +from fastapi import HTTPException +from typing import Tuple, Dict, Any, List +from app.core.logging import LoggerMixin +from app.services.generate_train.convert import convert_and_return_jsons + + +class GenerateTrainService(LoggerMixin): + def __init__(self): + self.logger.info("Initializing GenerateTrainService") + + # Get the Mistral API key from the .env file + self.api_key = os.getenv("MISTRAL_API_KEY") + + if not self.api_key: + self.logger.error("MISTRAL_API_KEY is not set in the .env file") + raise ValueError("MISTRAL_API_KEY is not set in the .env file") + + # Initialize the Mistral client + self.client = Mistral(api_key=self.api_key) + self.logger.info("Mistral client initialized successfully") + + def generate_wagon_passcodes(self, theme: str, num_wagons: int) -> list[str]: + """Generate passcodes for wagons using Mistral AI""" + self.logger.info(f"Generating passcodes for theme: {theme}, num_wagons: {num_wagons}") + + if num_wagons <= 0 or num_wagons > 10: + self.logger.error(f"Invalid number of wagons requested: {num_wagons}") + return "Please provide a valid number of wagons (1-10)." + + # Prompt Mistral API to generate a theme and passcodes + prompt = f""" + This is a video game about a player trying to reach the locomotive of a train by finding a passcode for each wagon. + You are tasked with generating unique passcodes for the wagons based on the theme '{theme}', to make the game more engaging, fun, and with a sense of progression. + You are tasked with generating unique passcodes for the wagons based on the theme '{theme}', + to make the game more engaging, fun, and with a sense of progression, from easiest to hardest. + Each password should be unique enough to not be related to each other but still be connected to the theme. + Generate {num_wagons} unique and creative passcodes for the wagons. Each passcode must: + Generate exactly {num_wagons} unique and creative passcodes for the wagons. Each passcode must: + 1. Be related to the theme. + 2. Be unique, interesting, and creative. + 3. In one word, letters only (no spaces or special characters). + No explanation needed, just the theme and passcodes in a JSON object format. + Example: + Example for the theme "Pirates" and 5 passcodes: + {{ + "theme": "Pirates", + "passcodes": ["Treasure", "Rum", "Skull", "Compass", "Anchor"] + }} + Now, generate a theme and passcodes. + """ + response = self.client.chat.complete( + model="mistral-large-latest", + messages=[ + {"role": "user", "content": prompt} + ], + max_tokens=1000, + temperature=0.8, + ) + + try: + result = json.loads(response.choices[0].message.content.replace("```json\n", "").replace("\n```", "")) + passcodes = result["passcodes"] + self.logger.info(f"Successfully generated {len(passcodes)} passcodes") + return passcodes + + except json.JSONDecodeError as e: + self.logger.error(f"Failed to decode Mistral response: {e}") + return "Failed to decode the response. Please try again." + except Exception as e: + self.logger.error(f"Error generating passcodes: {e}") + return f"Error generating passcodes: {str(e)}" + + def generate_passengers_for_wagon(self, theme: str, passcode: str, num_passengers: int) -> list[Dict[str, Any]]: + """Generate passengers for a wagon using Mistral AI""" + self.logger.info(f"Generating {num_passengers} passengers for wagon with passcode: {passcode} and theme: {theme}") + + # Generate passengers with the Mistral API + prompt = f""" + Passengers are in a wagon. The player can interact with them to learn more about their stories. + The passengers live in the world of the theme "{theme}" and their stories are connected to the passcode "{passcode}". + The following is a list of passengers on a train wagon. The wagon is protected by the passcode "{passcode}". + Their stories are intertwined, and each passenger has a unique role and mystery, all related to the theme and the passcode. + The player must be able to guess the passcode by talking to the passengers and uncovering their secrets. + Passengers should be diverse, with different backgrounds, professions, and motives. + Passengers' stories should be engaging, mysterious, and intriguing, adding depth to the game, while also providing clues to the passcode. + Passengers' stories has to be / can be connected to each other. + Passengers are aware of each other's presence in the wagon. + The passcode shouldn't be too obvious but should be guessable based on the passengers' stories. + The passcode shouldn't be mentioned explicitly in the passengers' descriptions. + Don't use double quotes (") in the JSON strings. + Each passenger must have the following attributes: + - "name": A unique name (first and last) with a possible title. + - "age": A realistic age between 18 and 70 except for special cases. + - "profession": A profession that fits into a fictional, story-driven world. + - "personality": A set of three adjectives that describe their character. + - "role": A short description of their role in the story. + - "mystery_intrigue": A unique secret, motive, or mystery about the character. + - "characer_model": A character model identifier + The character models are : + - character-female-a: A dark-skinned woman with a high bun hairstyle, wearing a purple and orange outfit. She is holding two blue weapons or tools, possibly a warrior or fighter. + - character-female-b: A young girl with orange hair tied into two pigtails, wearing a yellow and purple sporty outfit. She looks energetic, possibly an athlete or fitness enthusiast. + - character-female-c: An elderly woman with gray hair in a bun, wearing a blue and red dress. She has a warm and wise appearance, resembling a grandmotherly figure. + - character-female-d: A woman with blonde hair styled in a tight bun, wearing a gray business suit. She appears professional, possibly a corporate worker or manager. + - character-female-e: A woman with dark hair in a ponytail, dressed in a white lab coat with blue gloves. She likely represents a doctor or scientist. + - character-female-f: A red-haired woman with long, wavy hair, wearing a black and yellow vest with purple pants. She looks adventurous, possibly an engineer, explorer, or worker. + - character-male-a: Dark-skinned man with glasses and a beaded hairstyle, wearing a green shirt with orange and white stripes, along with yellow sneakers (casual or scholarly figure). + - character-male-b: Bald man with a large red beard, wearing a red shirt and blue pants (possibly a strong worker, blacksmith, or adventurer). + - character-male-c: Man with a mustache, wearing a blue police uniform with a cap and badge (police officer or security personnel). + - character-male-d: Blonde-haired man in a black suit with a red tie (businessman, politician, or corporate executive). + - character-male-e: Brown-haired man with glasses, wearing a white lab coat and a yellow tool belt (scientist, mechanic, or engineer). + - character-male-f: Dark-haired young man with a mustache, wearing a green vest and brown pants (possibly an explorer, traveler, or adventurer). + Generate {num_passengers} passengers in JSON array format. Example: + [ + {{ + "name": "Victor Sterling", + "age": 55, + "profession": "Mining Magnate", + "personality": "Ambitious, cunning, and charismatic", + "role": "Owns a vast mining empire, recently discovered a new vein of precious metal.", + "mystery_intrigue": "Secretly trades in unregistered precious metals, hiding a fortune in a secure vault. In love with Eleanor Brooks", + "characer_model": "character-male-f" + }}, + {{ + "name": "Eleanor Brooks", + "age": 32, + "profession": "Investigative Journalist", + "personality": "Tenacious, curious, and ethical", + "role": "Investigates corruption in the mining industry, follows a lead on a hidden stash of radiant metal bars.", + "mystery_intrigue": "Uncovers a network of illegal precious metal trades, putting her life in danger. Hates Victor Sterling because of his unethical practices.", + "characer_model": "character-female-f" + }} + ] + Now generate the JSON array: + """ + response = self.client.chat.complete( + model="mistral-large-latest", + messages=[ + {"role": "user", "content": prompt} + ], + max_tokens=1250, + temperature=0.7, + ) + + + try: + passengers = json.loads(response.choices[0].message.content.replace("```json\n", "").replace("\n```", "").replace(passcode, "")) + self.logger.info(f"Successfully generated {len(passengers)} passengers") + return passengers + + except json.JSONDecodeError as e: + self.logger.error(f"Failed to decode passenger generation response: {e}") + return "Failed to decode the response. Please try again." + except Exception as e: + self.logger.error(f"Error generating passengers: {e}") + return f"Error generating passengers: {str(e)}" + + def generate_train_json(self, theme: str, num_wagons: int, min_passengers: int = 2, max_passengers: int = 10) -> str: + """Generate complete train JSON including wagons and passengers""" + self.logger.info(f"Generating train JSON for theme: {theme}, num_wagons: {num_wagons}") + + try: + if min_passengers > max_passengers: + self.logger.error("Minimum passengers cannot be greater than maximum passengers") + raise ValueError("Minimum passengers cannot be greater than maximum passengers") + + # Generate passcodes + passcodes = self.generate_wagon_passcodes(theme, num_wagons) + if isinstance(passcodes, str): # If there's an error message + self.logger.error(f"Error generating passcodes: {passcodes}") + raise ValueError(f"Failed to generate passcodes: {passcodes}") + + # Generate wagons with passengers + wagons = [] + wagons.append({ + "id": 0, + "theme": "Tutorial (Start)", + "passcode": "start", + "passengers": [] + }) + for i, passcode in enumerate(passcodes): + num_passengers = random.randint(min_passengers, max_passengers) + passengers = self.generate_passengers_for_wagon(theme, passcode, num_passengers) + # Check if passengers is a string (error message) + if isinstance(passengers, str): + self.logger.error(f"Error generating passengers: {passengers}") + raise ValueError(f"Failed to generate passengers: {passengers}") + wagons.append({"id": i + 1, "theme": theme, "passcode": passcode, "passengers": passengers}) + + self.logger.info(f"Successfully generated train with {len(wagons)} wagons") + return json.dumps(wagons, indent=4) + + except Exception as e: + self.logger.error(f"Error in generate_train_json: {e}") + raise ValueError(f"Failed to generate train: {str(e)}") + + def generate_train(self, theme: str, num_wagons: int) -> Tuple[List, List, List]: + """Main method to generate complete train data""" + self.logger.info(f"Starting train generation | theme={theme} | num_wagons={num_wagons} | service=GenerateTrainService") + + try: + # Log attempt to generate train JSON + self.logger.debug(f"Generating train JSON | theme={theme} | num_wagons={num_wagons} | min_passengers=2 | max_passengers=10") + + wagons_json = self.generate_train_json(theme, num_wagons, 2, 10) + + # Log successful JSON generation and parse attempt + self.logger.debug(f"Train JSON generated, parsing to dict | json_length={len(wagons_json)}") + + wagons = json.loads(wagons_json) + + # Log conversion attempt + self.logger.debug(f"Converting wagon data to final format | num_wagons={len(wagons)}") + + all_names, all_player_details, all_wagons = convert_and_return_jsons(wagons) + + # Log successful generation with summary + self.logger.info( + f"Train generation completed successfully | theme={theme} | " + f"total_wagons={len(all_wagons)} | total_names={len(all_names)} | " + f"total_player_details={len(all_player_details)}" + ) + + return all_names, all_player_details, all_wagons + + except json.JSONDecodeError as e: + self.logger.error( + f"JSON parsing error in generate_train | error_type=JSONDecodeError | " + f"error_msg={str(e)} | theme={theme} | num_wagons={num_wagons}" + ) + raise HTTPException(status_code=500, detail=f"Failed to parse train JSON: {str(e)}") + + except Exception as e: + self.logger.error( + f"Error in generate_train | error_type={type(e).__name__} | " + f"error_msg={str(e)} | theme={theme} | num_wagons={num_wagons}" + ) + raise HTTPException(status_code=500, detail=f"Failed to generate train: {str(e)}") \ No newline at end of file diff --git a/app/services/guess_service.py b/app/services/guess_service.py index 17a5873..75fc480 100644 --- a/app/services/guess_service.py +++ b/app/services/guess_service.py @@ -3,7 +3,7 @@ from langchain_core.prompts import PromptTemplate from pydantic import BaseModel, Field from app.models.session import Message -from pydantic import BaseModel, Field +from app.core.logging import LoggerMixin from app.prompts import GUESSING_PROMPT MISTRAL_API_KEY = os.getenv("MISTRAL_API_KEY") @@ -16,8 +16,9 @@ class GuessResponse(BaseModel): ) -class GuessingService: +class GuessingService(LoggerMixin): def __init__(self: "GuessingService") -> None: + self.logger.info("Initializing GuessingService") prompt = PromptTemplate.from_template(GUESSING_PROMPT) llm = ( @@ -30,9 +31,12 @@ def __init__(self: "GuessingService") -> None: ) self.chain = prompt | llm + self.logger.info("GuessingService initialized with Mistral LLM") def filter_password(self: "GuessingService", indication: str, password: str) -> str: - return indication.replace(password, "*******") + filtered = indication.replace(password, "*******") + self.logger.debug(f"Filtered password from indication | original_length={len(indication)} | filtered_length={len(filtered)}") + return filtered def generate( self: "GuessingService", @@ -42,15 +46,25 @@ def generate( current_indication: str, password: str, ) -> GuessResponse: + self.logger.info(f"Generating guess | theme={theme} | num_previous_guesses={len(previous_guesses)} | num_previous_indications={len(previous_indications)}") + previous_indications = [message.content for message in previous_indications] + self.logger.debug(f"Processing previous indications | count={len(previous_indications)}") current_indication = self.filter_password(current_indication, password) - - return self.chain.invoke( - { - "previous_guesses": previous_guesses[:3], - "theme": theme, - "previous_indications": previous_indications[:5], - "current_indication": current_indication, - } - ) + + try: + response = self.chain.invoke( + { + "previous_guesses": previous_guesses[:3], + "theme": theme, + "previous_indications": previous_indications[:5], + "current_indication": current_indication, + } + ) + self.logger.info(f"Generated guess successfully | guess={response.guess} | thoughts_length={len(response.thoughts)}") + return response + + except Exception as e: + self.logger.error(f"Failed to generate guess | error={str(e)} | theme={theme}") + raise diff --git a/app/services/session_service.py b/app/services/session_service.py index 4a104f0..6a820df 100644 --- a/app/services/session_service.py +++ b/app/services/session_service.py @@ -8,8 +8,8 @@ GuessingProgress, ) from app.core.logging import LoggerMixin -import json -from pathlib import Path +import uuid +from app.utils.file_management import FileManager # used as dependency injection for the session service @@ -19,44 +19,38 @@ class SessionService(LoggerMixin): @classmethod def create_session(cls) -> UserSession: - """Create a new user session""" - # create a new session and store it in the dictionary - session = UserSession() - cls._sessions[session.session_id] = session - cls.get_logger().info( - "Created new session", extra={"session_id": session.session_id} + """Create a new session""" + session_id = str(uuid.uuid4()) + + session = UserSession( + session_id=session_id, + created_at=datetime.utcnow(), + last_active=datetime.utcnow(), + default_game=True ) + + cls._sessions[session_id] = session + cls.get_logger().info(f"Created new session: {session_id}") return session @classmethod def get_session(cls, session_id: str) -> Optional[UserSession]: - """Get an existing session by ID""" - # get the session from the dictionary + """Get session by ID""" session = cls._sessions.get(session_id) - if session: - cls.get_logger().debug( - "Retrieved session", extra={"session_id": session_id} - ) + session.last_active = datetime.utcnow() + cls.get_logger().debug(f"Retrieved session: {session_id}") else: - cls.get_logger().warning( - "Session not found", extra={"session_id": session_id} - ) + cls.get_logger().warning(f"Session not found: {session_id}") return session @classmethod def update_session(cls, session: UserSession) -> None: """Update a session's last active timestamp""" - # update the last active timestamp session.last_active = datetime.utcnow() - # update the session in the dictionary by overriding the existing session cls._sessions[session.session_id] = session cls.get_logger().debug( - "Updated session", - extra={ - "session_id": session.session_id, - "current_wagon": session.current_wagon.wagon_id, - }, + f"Updated session | session_id: {session.session_id} | current_wagon: {session.current_wagon.wagon_id}" ) @classmethod @@ -64,13 +58,10 @@ def add_message( cls, session_id: str, uid: str, message: Message ) -> Optional[Conversation]: """Add a message to a character's conversation""" - # get the session from the dictionary session = cls.get_session(session_id) - # check if the session exists if not session: cls.get_logger().error( - "Failed to add message - session not found", - extra={"session_id": session_id, "uid": uid}, + f"Failed to add message - session not found | session_id: {session_id} | uid: {uid}" ) return None @@ -82,20 +73,14 @@ def add_message( # which might indicate out of sync in wagon. if wagon_id != session.current_wagon.wagon_id: cls.get_logger().error( - "Cannot add message - wrong wagon", - extra={ - "session_id": session_id, - "uid": uid, - "current_wagon": session.current_wagon.wagon_id, - }, + f"Cannot add message - wrong wagon | session_id: {session_id} | uid: {uid} | current_wagon: {session.current_wagon.wagon_id}" ) return None # in case we have not started a conversation with this character yet, start one if uid not in session.current_wagon.conversations: cls.get_logger().info( - "Starting new conversation", - extra={"session_id": session_id, "uid": uid, "wagon_id": wagon_id}, + f"Starting new conversation | session_id: {session_id} | uid: {uid} | wagon_id: {wagon_id}" ) session.current_wagon.conversations[uid] = Conversation(uid=uid) @@ -106,13 +91,7 @@ def add_message( cls.update_session(session) cls.get_logger().debug( - "Added message to conversation", - extra={ - "session_id": session_id, - "uid": uid, - "message_role": message.role, - "message_length": len(message.content), - }, + f"Added message to conversation | session_id: {session_id} | uid: {uid} | message_role: {message.role} | message_length: {len(message.content)}" ) return conversation @@ -120,11 +99,9 @@ def add_message( def get_conversation(cls, session_id: str, uid: str) -> Optional[Conversation]: """Get a conversation with a specific character""" session = cls.get_session(session_id) - # check if the session exists if not session: cls.get_logger().error( - "Failed to get conversation - session not found", - extra={"session_id": session_id, "uid": uid}, + f"Failed to get conversation - session not found | session_id: {session_id} | uid: {uid}" ) return None @@ -137,12 +114,7 @@ def get_conversation(cls, session_id: str, uid: str) -> Optional[Conversation]: # which might indicate out of sync in wagon. if wagon_id != session.current_wagon.wagon_id: cls.get_logger().warning( - "Cannot get conversation - wrong wagon", - extra={ - "session_id": session_id, - "uid": uid, - "current_wagon": session.current_wagon.wagon_id, - }, + f"Cannot get conversation - wrong wagon | session_id: {session_id} | uid: {uid} | current_wagon: {session.current_wagon.wagon_id}" ) return None @@ -151,30 +123,22 @@ def get_conversation(cls, session_id: str, uid: str) -> Optional[Conversation]: if conversation: cls.get_logger().debug( - "Retrieved conversation", - extra={ - "session_id": session_id, - "uid": uid, - "message_count": len(conversation.messages), - }, + f"Retrieved conversation | session_id: {session_id} | uid: {uid} | message_count: {len(conversation.messages)}" ) else: cls.get_logger().debug( - "No conversation found", extra={"session_id": session_id, "uid": uid} + f"No conversation found | session_id: {session_id} | uid: {uid}" ) return conversation @classmethod def get_guessing_progress(cls, session_id: str) -> GuessingProgress: session = cls.get_session(session_id) - if not session: cls.get_logger().error( - "Failed to get guesses - session not found", - extra={"session_id": session_id}, + f"Failed to get guesses - session not found | session_id: {session_id}" ) - return - + return None return session.guessing_progress @classmethod @@ -182,11 +146,9 @@ def update_guessing_progress( cls, session_id: str, indication: str, guess: str, thought: list[str] ) -> None: session = cls.get_session(session_id) - if not session: cls.get_logger().error( - "Failed to get the guessing progress - session not found", - extra={"session_id": session_id}, + f"Failed to get the guessing progress - session not found | session_id: {session_id}" ) return @@ -216,64 +178,74 @@ def update_guessing_progress( messages.append(Message(role="assistant", content=thought[0])) cls.update_session(session) - cls.get_logger().info( - "Added a new guess", - extra={"session_id": session_id}, - ) + cls.get_logger().info(f"Added a new guess | session_id: {session_id}") @classmethod def advance_wagon(cls, session_id: str) -> bool: """Advance to the next wagon""" + cls.get_logger().info(f"Attempting to advance wagon | session_id={session_id}") + + # Get current session session = cls.get_session(session_id) if not session: - cls.get_logger().error( - "Failed to advance wagon - session not found", - extra={"session_id": session_id}, - ) + cls.get_logger().error(f"Failed to advance wagon - session not found | session_id={session_id}") return False - wagons_file = Path("data/wagons.json") - try: - with open(wagons_file, "r") as f: - wagons_data = json.load(f) - # we start counting from 0, max_wagon_id is the last wagon id - max_wagon_id = len(wagons_data["wagons"]) - 1 - except Exception as e: - cls.get_logger().error( - "Failed to read wagons data", - extra={"session_id": session_id, "error": str(e)}, + current_wagon_id = session.current_wagon.wagon_id + cls.get_logger().debug(f"Current wagon state | session_id={session_id} | current_wagon_id={current_wagon_id}") + + try: + # Load data based on default_game flag + cls.get_logger().debug(f"Loading session data | session_id={session_id} | default_game={session.default_game}") + next_wagon_id = current_wagon_id + 1 + _, _, wagons = FileManager.load_session_data(session_id, session.default_game) + max_wagons = len(wagons) + + # Check if we're at the last wagon + if next_wagon_id > max_wagons - 1: + cls.get_logger().warning( + f"Cannot advance - already at last wagon | session_id={session_id} | current_wagon={current_wagon_id} | max_wagons={max_wagons}" + ) + raise Exception("Cannot advance - already at last wagon") + + cls.get_logger().debug( + f"Wagon progression details | session_id={session_id} | current_wagon={current_wagon_id} | next_wagon={next_wagon_id} | max_wagons={max_wagons}" ) - return False - # advance to the next wagon - next_wagon_id = session.current_wagon.wagon_id + 1 - # check if we are out of bounds - if next_wagon_id > max_wagon_id: - cls.get_logger().warning( - "Cannot advance - already at last wagon", - extra={ - "session_id": session_id, - "current_wagon": session.current_wagon.wagon_id, - }, + # Load current wagon data for the next wagon setup + current_wagon = wagons[next_wagon_id] + + # Set up next wagon + session.current_wagon = WagonProgress( + wagon_id=next_wagon_id, + theme=current_wagon["theme"], + password=current_wagon["passcode"], ) - return False - # Set up next wagon - session.current_wagon = WagonProgress( - wagon_id=next_wagon_id, - theme=wagons_data["wagons"][next_wagon_id]["theme"], - password=wagons_data["wagons"][next_wagon_id]["passcode"], - ) + # Reset guessing progress for new wagon + session.guessing_progress = GuessingProgress() + cls.update_session(session) - # Clean up the previous guesses - session.guessing_progress = GuessingProgress() - cls.update_session(session) + cls.get_logger().info( + f"Successfully advanced to next wagon | session_id={session_id} | previous_wagon={current_wagon_id} | new_wagon={next_wagon_id} | theme={current_wagon['theme']}" + ) + return True - cls.get_logger().info( - "Advanced to next wagon", - extra={"session_id": session_id, "new_wagon": next_wagon_id}, - ) - return True + except FileNotFoundError as e: + cls.get_logger().error( + f"Failed to load session data | session_id={session_id} | error={str(e)} | error_type=FileNotFoundError" + ) + return False + except KeyError as e: + cls.get_logger().error( + f"Invalid wagon data structure | session_id={session_id} | error={str(e)} | error_type=KeyError" + ) + return False + except Exception as e: + cls.get_logger().error( + f"Unexpected error during wagon advancement | session_id={session_id} | error={str(e)} | error_type={type(e).__name__}" + ) + return False @classmethod def cleanup_old_sessions(cls, max_age_hours: int = 24) -> None: @@ -286,35 +258,16 @@ def cleanup_old_sessions(cls, max_age_hours: int = 24) -> None: if age > max_age_hours: sessions_to_remove.append(session_id) cls.get_logger().info( - "Marking session for cleanup", - extra={"session_id": session_id, "age_hours": age}, + f"Marking session for cleanup | session_id: {session_id} | age_hours: {age}" ) for session_id in sessions_to_remove: - del cls._sessions[session_id] - cls.get_logger().info( - "Cleaned up old session", extra={"session_id": session_id} - ) + cls.terminate_session(session_id) + cls.get_logger().info(f"Cleaned up old session | session_id: {session_id}") @classmethod def terminate_session(cls, session_id: str) -> None: """Terminate a session and clean up its resources""" - session = cls.get_session(session_id) - if not session: - cls.get_logger().warning( - "Attempted to terminate non-existent session", - extra={"session_id": session_id}, - ) - return - - # Clean up session data if session_id in cls._sessions: del cls._sessions[session_id] - cls.get_logger().info( - "Session terminated", - extra={ - "session_id": session_id, - "terminated_at": datetime.utcnow().isoformat(), - "final_wagon": session.current_wagon.wagon_id, - }, - ) + cls.get_logger().info(f"Terminated session: {session_id}") diff --git a/app/utils/file_management.py b/app/utils/file_management.py new file mode 100644 index 0000000..d46ba91 --- /dev/null +++ b/app/utils/file_management.py @@ -0,0 +1,85 @@ +from pathlib import Path +import json +import shutil +from typing import Dict, Any +from app.core.logging import LoggerMixin + +class FileManager(LoggerMixin): + BASE_DATA_DIR = Path("data") + DEFAULT_DIR = BASE_DATA_DIR / "default" + + @classmethod + def ensure_directories(cls) -> None: + """Ensure all required directories exist""" + cls.BASE_DATA_DIR.mkdir(exist_ok=True) + cls.DEFAULT_DIR.mkdir(exist_ok=True) + + @classmethod + def create_session_directory(cls, session_id: str) -> Path: + """Create a new directory for the session""" + session_dir = cls.BASE_DATA_DIR / session_id + session_dir.mkdir(exist_ok=True) + return session_dir + + @classmethod + def save_session_data(cls, session_id: str, names: Dict, player_details: Dict, wagons: Dict) -> None: + """Save the three main data files for a session""" + session_dir = cls.create_session_directory(session_id) + logger = cls.get_logger() + + # Save each file + files_to_save = { + "names.json": names, + "player_details.json": player_details, + "wagons.json": wagons + } + + for filename, data in files_to_save.items(): + file_path = session_dir / filename + cls.save_json(file_path, data) + logger.info(f"Saved session data | session_id={session_id} | filename={filename} | path={file_path}") + + @classmethod + def get_data_directory(cls, session_id: str, default_game: bool) -> Path: + """Get the appropriate data directory based on default_game flag""" + if default_game: + return cls.DEFAULT_DIR + return cls.BASE_DATA_DIR / session_id + + @classmethod + def load_session_data(cls, session_id: str, default_game: bool = True) -> tuple[Dict, Dict, Dict]: + """Load all data files for a session""" + logger = cls.get_logger() + data_dir = cls.get_data_directory(session_id, default_game) + + if not data_dir.exists(): + logger.error(f"Data directory not found | session_id={session_id} | directory={data_dir}") + raise FileNotFoundError(f"No data found for session {session_id}") + + try: + names = cls.load_json(data_dir / "names.json") + player_details = cls.load_json(data_dir / "player_details.json") + wagons = cls.load_json(data_dir / "wagons.json") + + logger.info( + f"Loaded session data | session_id={session_id} | " + f"source={'default' if default_game else 'session'} | " + f"directory={data_dir}" + ) + return names, player_details, wagons + + except FileNotFoundError as e: + logger.error(f"Failed to load files | session_id={session_id} | directory={data_dir} | error={str(e)}") + raise FileNotFoundError(f"Missing required data files in {data_dir}") + + @staticmethod + def save_json(file_path: Path, data: Dict[str, Any]) -> None: + """Save data to a JSON file""" + with open(file_path, 'w') as f: + json.dump(data, f, indent=2) + + @staticmethod + def load_json(file_path: Path) -> Dict: + """Load data from a JSON file""" + with open(file_path, 'r') as f: + return json.load(f) \ No newline at end of file diff --git a/data/names.json b/data/default/names.json similarity index 64% rename from data/names.json rename to data/default/names.json index 2ccbf7e..183ce48 100644 --- a/data/names.json +++ b/data/default/names.json @@ -1,215 +1,270 @@ -{ - "names": { - "wagon-0" : { - - }, - "wagon-1": { - "player-1": { - "firstName": "Victor", +[ + { + "wagonId": "wagon-0", + "players": [] + }, + { + "wagonId": "wagon-1", + "players": [ + { + "playerId": "player-1", + "firstName": "James", "lastName": "Sterling", "sex": "male", - "fullName": "Victor Sterling" + "fullName": "James Sterling" }, - "player-2": { - "firstName": "Isabella", - "lastName": "Hart", + { + "playerId": "player-2", + "firstName": "Elizabeth", + "lastName": "Chen", "sex": "female", - "fullName": "Isabella Hart" + "fullName": "Elizabeth Chen" }, - "player-3": { - "firstName": "Lucas", - "lastName": "Ford", + { + "playerId": "player-3", + "firstName": "Marcus", + "lastName": "Thompson", "sex": "male", - "fullName": "Lucas Ford" + "fullName": "Marcus Thompson" }, - "player-4": { - "firstName": "Eleanor", - "lastName": "Brooks", + { + "playerId": "player-4", + "firstName": "Sofia", + "lastName": "Rodriguez", "sex": "female", - "fullName": "Eleanor Brooks" + "fullName": "Sofia Rodriguez" } - }, - "wagon-2": { - "player-1": { + ] + }, + { + "wagonId": "wagon-2", + "players": [ + { + "playerId": "player-1", "firstName": "Adrian", "lastName": "North", "sex": "male", "fullName": "Eleanor Brooks" }, - "player-2": { + { + "playerId": "player-2", "firstName": "Elena", "lastName": "Stone", "sex": "female", "fullName": "Elena Stone" }, - "player-3": { + { + "playerId": "player-3", "firstName": "Lucas", "lastName": "West", "sex": "male", "fullName": "Lucas West" }, - "player-4": { + { + "playerId": "player-4", "firstName": "Sophia", "lastName": "East", "sex": "female", "fullName": "Sophia East" } - }, - "wagon-3": { - "player-1": { + ] + }, + { + "wagonId": "wagon-3", + "players": [ + { + "playerId": "player-1", "firstName": "Captain Elara", "lastName": "Voss", "sex": "female", "fullName": "Captain Elara Voss" }, - "player-2": { + { + "playerId": "player-2", "firstName": "Dr. Orion", "lastName": "Kane", "sex": "male", "fullName": "Dr. Orion Kane" }, - "player-3": { + { + "playerId": "player-3", "firstName": "Lieutenant Nova", "lastName": "Sterling", "sex": "female", "fullName": "Lieutenant Nova Sterling" }, - "player-4": { + { + "playerId": "player-4", "firstName": "Professor Atlas", "lastName": "Grey", "sex": "male", "fullName": "Professor Atlas Grey" } - }, - "wagon-4": { - "player-1": { + ] + }, + { + "wagonId": "wagon-4", + "players": [ + { + "playerId": "player-1", "firstName": "General Victor", "lastName": "Blackwood", "sex": "male", "fullName": "General Victor Blackwood" }, - "player-2": { + { + "playerId": "player-2", "firstName": "Lady Isabella", "lastName": "Sterling", "sex": "female", "fullName": "Lady Isabella Sterling" }, - "player-3": { + { + "playerId": "player-3", "firstName": "Sergeant Marcus", "lastName": "Thorne", "sex": "male", "fullName": "Noah Davis" }, - "player-4": { + { + "playerId": "player-4", "firstName": "Dr. Charlotte", "lastName": "Hartley", "sex": "female", "fullName": "Dr. Charlotte Hartley" } - }, - "wagon-5": { - "player-1": { + ] + }, + { + "wagonId": "wagon-5", + "players": [ + { + "playerId": "player-1", "firstName": "Maestro Lorenzo", "lastName": "Rossi", "sex": "male", "fullName": "Maestro Lorenzo Rossi" }, - "player-2": { + { + "playerId": "player-2", "firstName": "Isabella", "lastName": "Valentina", "sex": "female", "fullName": "Isabella Valentina" }, - "player-3": { + { + "playerId": "player-3", "firstName": "Leonardo", "lastName": "Di Marco", "sex": "male", "fullName": "Leonardo Di Marco" }, - "player-4": { + { + "playerId": "player-4", "firstName": "Sofia", "lastName": "Caruso", "sex": "female", "fullName": "Sofia Caruso" } - }, - "wagon-6": { - "player-1": { + ] + }, + { + "wagonId": "wagon-6", + "players": [ + { + "playerId": "player-1", "firstName": "Leo 'Roar'", "lastName": "Johnson", "sex": "male", "fullName": "Leo 'Roar' Johnson" }, - "player-2": { + { + "playerId": "player-2", "firstName": "Lila 'Nightingale'", "lastName": "Silva", "sex": "female", "fullName": "Lady Olivia Wright" }, - "player-3": { + { + "playerId": "player-3", "firstName": "Rafael 'Panther'", "lastName": "Martinez", "sex": "male", "fullName": "Rafael 'Panther' Martinez" }, - "player-4": { + { + "playerId": "player-4", "firstName": "Elena 'Jaguar'", "lastName": "Rodriguez", "sex": "female", "fullName": "Elena 'Jaguar' Rodriguez" } - }, - "wagon-7": { - "player-1": { + ] + }, + { + "wagonId": "wagon-7", + "players": [ + { + "playerId": "player-1", "firstName": "Marco", "lastName": "Valentino", "sex": "male", "fullName": "Marco Valentino" }, - "player-2": { + { + "playerId": "player-2", "firstName": "Giovanna", "lastName": "Rosalia", "sex": "female", "fullName": "Giovanna Rosalia" }, - "player-3": { + { + "playerId": "player-3", "firstName": "Father", "lastName": "Lorenzo", "sex": "male", "fullName": "Father Lorenzo" }, - "player-4": { + { + "playerId": "player-4", "firstName": "Luca", "lastName": "Montefiori", "sex": "male", "fullName": "Luca Montefiori" } - }, - "wagon-8": { - "player-1": { + ] + }, + { + "wagonId": "wagon-8", + "players": [ + { + "playerId": "player-1", "firstName": "Eleanor", "lastName": "Voss", "sex": "male", "fullName": "Eleanor Voss" }, - "player-2": { + { + "playerId": "player-2", "firstName": "Theadore", "lastName": "Kane", "sex": "female", "fullName": "Theadore Kane" }, - "player-3": { + { + "playerId": "player-3", "firstName": "Victoria", "lastName": "Sterling", "sex": "female", "fullName": "Victoria Sterling" }, - "player-4": { + { + "playerId": "player-4", "firstName": "Edgar", "lastName": "Grey", "sex": "male", "fullName": "Edgar Grey" } - } + ] } -} \ No newline at end of file +] diff --git a/data/player_details.json b/data/default/player_details.json similarity index 82% rename from data/player_details.json rename to data/default/player_details.json index 9947265..6a3965d 100644 --- a/data/player_details.json +++ b/data/default/player_details.json @@ -1,52 +1,62 @@ -{ - "player_details": { - "wagon-0": {}, - - "wagon-1": { - "player-1": { - "profile": { - "name": "Victor Sterling", - "age": 55, - "profession": "Mining Magnate", - "personality": "Ambitious, cunning, and charismatic", - "role": "Owns a vast mining empire, recently discovered a new vein of precious metal with a peculiar luster.", - "mystery_intrigue": "Secretly trades in unregistered precious metals, hiding a fortune in a secure vault." +[ + { + "wagonId": "wagon-0", + "players": [] + }, + { + "wagonId": "wagon-1", + "players": [ + { + "playerId": "player-1", + "profile": { + "name": "James Sterling", + "age": 42, + "profession": "Investment Banker", + "personality": "Ambitious and calculating", + "role": "A successful banker with questionable ethics", + "mystery_intrigue": "Has been manipulating gold prices for personal gain" } }, - "player-2": { + { + "playerId": "player-2", "profile": { - "name": "Isabella Hart", - "age": 38, - "profession": "Jewelry Designer", - "personality": "Creative, passionate, and meticulous", - "role": "Designs exquisite jewelry for the elite, recently commissioned to create pieces from a rare, gleaming metal.", - "mystery_intrigue": "Receives anonymous shipments of raw, lustrous metal, unaware of its true origin." + "name": "Elizabeth Chen", + "age": 35, + "profession": "Financial Analyst", + "personality": "Sharp-minded and detail-oriented", + "role": "Whistleblower investigating financial fraud", + "mystery_intrigue": "Has evidence of major market manipulation" } }, - "player-3": { + { + "playerId": "player-3", "profile": { - "name": "Lucas Ford", - "age": 42, - "profession": "Financial Advisor", - "personality": "Analytical, cautious, and strategic", - "role": "Manages the investments of wealthy clients, notices unusual transactions involving large amounts of gleaming bullion.", - "mystery_intrigue": "Suspects a client of illegal trading in unregistered precious metals, but lacks concrete evidence." + "name": "Marcus Thompson", + "age": 45, + "profession": "Securities Regulator", + "personality": "Stern and methodical", + "role": "Investigating suspicious trading patterns", + "mystery_intrigue": "Close to uncovering a major financial scandal" } }, - "player-4": { + { + "playerId": "player-4", "profile": { - "name": "Eleanor Brooks", - "age": 32, - "profession": "Investigative Journalist", - "personality": "Tenacious, curious, and ethical", - "role": "Investigates corruption in the mining industry, follows a lead on a hidden stash of radiant metal bars.", - "mystery_intrigue": "Uncovers a network of illegal precious metal trades, putting her life in danger." + "name": "Sofia Rodriguez", + "age": 38, + "profession": "Corporate Lawyer", + "personality": "Shrewd and diplomatic", + "role": "Legal counsel involved in the investigation", + "mystery_intrigue": "May be working for both sides" } } - }, - - "wagon-2": { - "player-1": { + ] + }, + { + "wagonId": "wagon-2", + "players": [ + { + "playerId": "player-1", "profile": { "name": "Adrian North", "age": 38, @@ -56,7 +66,8 @@ "mystery_intrigue": "Carries a mysterious map that seems to guide them towards a hidden treasure, but the map has strange symbols that no one can decipher." } }, - "player-2": { + { + "playerId": "player-2", "profile": { "name": "Elena Stone", "age": 32, @@ -66,7 +77,8 @@ "mystery_intrigue": "Has discovered clues suggesting the existence of a lost civilization, but keeps this information secret from the team." } }, - "player-3": { + { + "playerId": "player-3", "profile": { "name": "Lucas West", "age": 28, @@ -76,7 +88,8 @@ "mystery_intrigue": "Finds a rare plant that has healing properties, but its location is marked by a peculiar symbol that resembles a directional pointer." } }, - "player-4": { + { + "playerId": "player-4", "profile": { "name": "Sophia East", "age": 35, @@ -86,10 +99,13 @@ "mystery_intrigue": "Uncovers a hidden language that seems to provide directions to a mysterious artifact, but the language is unlike anything she has ever seen." } } - }, - - "wagon-3": { - "player-1": { + ] + }, + { + "wagonId": "wagon-3", + "players": [ + { + "playerId": "player-1", "profile": { "name": "Captain Elara Voss", "age": 40, @@ -99,7 +115,8 @@ "mystery_intrigue": "Hiding a personal vendetta against a rival exploration company, which may influence her decisions and put the crew in danger." } }, - "player-2": { + { + "playerId": "player-2", "profile": { "name": "Dr. Orion Kane", "age": 35, @@ -109,7 +126,8 @@ "mystery_intrigue": "Has discovered a hidden artifact that could revolutionize their understanding of the underwater civilization, but keeps it secret." } }, - "player-3": { + { + "playerId": "player-3", "profile": { "name": "Lieutenant Nova Sterling", "age": 30, @@ -119,7 +137,8 @@ "mystery_intrigue": "Receiving encrypted messages from an unknown source, warning her about an impending danger within the crew." } }, - "player-4": { + { + "playerId": "player-4", "profile": { "name": "Professor Atlas Grey", "age": 45, @@ -129,10 +148,13 @@ "mystery_intrigue": "Has uncovered evidence of a dangerous underwater creature that could threaten the expedition, but keeps this information to himself." } } - }, - - "wagon-4": { - "player-1": { + ] + }, + { + "wagonId": "wagon-4", + "players": [ + { + "playerId": "player-1", "profile": { "name": "General Victor Blackwood", "age": 55, @@ -142,7 +164,8 @@ "mystery_intrigue": "Haunted by a past decision that led to a devastating defeat, seeks redemption and closure." } }, - "player-2": { + { + "playerId": "player-2", "profile": { "name": "Lady Isabella Sterling", "age": 40, @@ -152,7 +175,8 @@ "mystery_intrigue": "Discovers letters hinting at a secret alliance that could have changed the course of the battle, but keeps this information hidden." } }, - "player-3": { + { + "playerId": "player-3", "profile": { "name": "Sergeant Marcus Thorne", "age": 38, @@ -162,7 +186,8 @@ "mystery_intrigue": "Experiences vivid dreams of the battle, revealing clues about a hidden treasure, but fears being seen as unstable." } }, - "player-4": { + { + "playerId": "player-4", "profile": { "name": "Dr. Charlotte Hartley", "age": 35, @@ -172,10 +197,13 @@ "mystery_intrigue": "Uncovers evidence of a cover-up involving high-ranking officers, but struggles with the ethical implications of revealing the truth." } } - }, - - "wagon-5": { - "player-1": { + ] + }, + { + "wagonId": "wagon-5", + "players": [ + { + "playerId": "player-1", "profile": { "name": "Maestro Lorenzo Rossi", "age": 58, @@ -185,7 +213,8 @@ "mystery_intrigue": "Hides a rare, unpublished manuscript that could redefine musical history, but fears the controversy it might cause." } }, - "player-2": { + { + "playerId": "player-2", "profile": { "name": "Isabella Valentina", "age": 32, @@ -195,7 +224,8 @@ "mystery_intrigue": "Possesses an antique violin with a mysterious inscription, hinting at a hidden composition of immense significance." } }, - "player-3": { + { + "playerId": "player-3", "profile": { "name": "Leonardo Di Marco", "age": 45, @@ -205,7 +235,8 @@ "mystery_intrigue": "Discovers a lost symphony that echoes the genius of past masters, but grapples with the ethical dilemma of claiming it as his own." } }, - "player-4": { + { + "playerId": "player-4", "profile": { "name": "Sofia Caruso", "age": 28, @@ -215,10 +246,13 @@ "mystery_intrigue": "Finds a hidden letter revealing a secret affair between two famous musicians, which could scandalize the musical world." } } - }, - - "wagon-6": { - "player-1": { + ] + }, + { + "wagonId": "wagon-6", + "players": [ + { + "playerId": "player-1", "profile": { "name": "Leo 'Roar' Johnson", "age": 42, @@ -228,7 +262,8 @@ "mystery_intrigue": "Possesses a rare, antique saxophone with engravings of a mysterious jungle creature, hinting at a hidden treasure deep within the rainforest." } }, - "player-2": { + { + "playerId": "player-2", "profile": { "name": "Lila 'Nightingale' Silva", "age": 35, @@ -238,7 +273,8 @@ "mystery_intrigue": "Discovers an old map leading to a legendary jungle temple, guarded by a mythical beast, but keeps the map's existence a secret." } }, - "player-3": { + { + "playerId": "player-3", "profile": { "name": "Rafael 'Panther' Martinez", "age": 38, @@ -248,7 +284,8 @@ "mystery_intrigue": "Receives mysterious drum patterns in his dreams, which he believes are clues to an ancient jungle ritual, but fears the consequences of unleashing its power." } }, - "player-4": { + { + "playerId": "player-4", "profile": { "name": "Elena 'Jaguar' Rodriguez", "age": 30, @@ -258,10 +295,13 @@ "mystery_intrigue": "Finds a hidden journal detailing a legendary jungle expedition, but the journal's warnings of a dangerous predator make her hesitant to reveal its contents." } } - }, - - "wagon-7": { - "player-1": { + ] + }, + { + "wagonId": "wagon-7", + "players": [ + { + "playerId": "player-1", "profile": { "name": "Marco Valentino", "age": 48, @@ -271,7 +311,8 @@ "mystery_intrigue": "Haunted by a past betrayal that deepened the feud, seeks revenge while hiding a secret that could unite the families." } }, - "player-2": { + { + "playerId": "player-2", "profile": { "name": "Giovanna Rosalia", "age": 42, @@ -281,7 +322,8 @@ "mystery_intrigue": "Discovers an old love letter hinting at a forbidden romance between members of the rival families, but struggles with the implications of revealing it." } }, - "player-3": { + { + "playerId": "player-3", "profile": { "name": "Father Lorenzo", "age": 50, @@ -291,7 +333,8 @@ "mystery_intrigue": "Knows of a hidden sanctuary where the lovers can meet, but fears the consequences of defying the powerful families." } }, - "player-4": { + { + "playerId": "player-4", "profile": { "name": "Luca Montefiori", "age": 35, @@ -301,10 +344,13 @@ "mystery_intrigue": "Uncovers a plot to sabotage the upcoming wedding, which could reignite the feud and plunge the city into chaos." } } - }, - - "wagon-8": { - "player-1": { + ] + }, + { + "wagonId": "wagon-8", + "players": [ + { + "playerId": "player-1", "profile": { "name": "Eleanor Voss", "age": 38, @@ -314,7 +360,8 @@ "mystery_intrigue": "Discovers hidden recordings on an old cylinder that hint at a secret society communicating through coded messages." } }, - "player-2": { + { + "playerId": "player-2", "profile": { "name": "Theodore Kane", "age": 42, @@ -324,7 +371,8 @@ "mystery_intrigue": "Finds blueprints for an advanced device that can record and play back voices with unprecedented clarity, but the inventor's identity remains a mystery." } }, - "player-3": { + { + "playerId": "player-3", "profile": { "name": "Victoria Sterling", "age": 35, @@ -334,7 +382,8 @@ "mystery_intrigue": "Receives anonymous letters containing cryptic messages and musical notes, hinting at a hidden melody with mysterious powers." } }, - "player-4": { + { + "playerId": "player-4", "profile": { "name": "Edgar Grey", "age": 40, @@ -344,6 +393,6 @@ "mystery_intrigue": "Uncovers a plot to steal advanced sound recording technology, but struggles to identify the mastermind behind the scheme." } } - } + ] } -} +] diff --git a/data/wagons.json b/data/default/wagons.json similarity index 62% rename from data/wagons.json rename to data/default/wagons.json index 4d8535e..8c65d8c 100644 --- a/data/wagons.json +++ b/data/default/wagons.json @@ -1,109 +1,80 @@ -{ - "wagons": [ - { - "id": 0, - "theme": "Tutorial (Start)", - "passcode": "start", - "people": [] - }, - { - "id": 1, - "theme": "A Flourishing Business", - "passcode": "gold", - "people": [ - { - "uid": "wagon-1-player-1", - "position": [ - 0.5, - 0.5 - ], - "rotation": 0, - "model_type": "character-male-e", - "items": [] - }, - { - "uid": "wagon-1-player-2", - "position": [ - 0.25, - 0.25 - ], - "rotation": 0, - "model_type": "character-female-f", - "items": [] - }, - { - "uid": "wagon-1-player-3", - "position": [ - 0.75, - 0.55 - ], - "rotation": 0, - "model_type": "character-male-c", - "items": [] - }, - { - "uid": "wagon-1-player-4", - "position": [ - 0.75, - 0.65 - ], - "rotation": 0, - "model_type": "character-female-e", - "items": [] - } - ] - }, - { - "id": 2, - "theme": "Off the Map", - "passcode": "aztec", - "people": [ - { - "uid": "wagon-2-player-1", - "position": [ - 0.5, - 0.5 - ], - "rotation": 0, - "model_type": "character-male-e", - "items": [] - }, - { - "uid": "wagon-2-player-2", - "position": [ - 0.25, - 0.25 - ], - "rotation": 0, - "model_type": "character-female-e", - "items": [ - "blaster", - "cone" - ] - }, - { - "uid": "wagon-2-player-3", - "position": [ - 0.75, - 0.55 - ], - "rotation": 0, - "model_type": "character-male-e", - "items": [] - }, - { - "uid": "wagon-2-player-4", - "position": [ - 0.75, - 0.65 - ], - "rotation": 0, - "model_type": "character-female-f", - "items": [] - } - ] - } - , +[ + { + "id": 0, + "theme": "Tutorial (Start)", + "passcode": "start", + "people": [] + }, + { + "id": 1, + "theme": "A Flourishing Business", + "passcode": "gold", + "people": [ + { + "uid": "wagon-1-player-1", + "position": [0.5, 0.5], + "rotation": 0, + "model_type": "character-male-e", + "items": [] + }, + { + "uid": "wagon-1-player-2", + "position": [0.25, 0.25], + "rotation": 0, + "model_type": "character-female-f", + "items": [] + }, + { + "uid": "wagon-1-player-3", + "position": [0.75, 0.55], + "rotation": 0, + "model_type": "character-male-c", + "items": [] + }, + { + "uid": "wagon-1-player-4", + "position": [0.75, 0.65], + "rotation": 0, + "model_type": "character-female-e", + "items": [] + } + ] + }, + { + "id": 2, + "theme": "Off the Map", + "passcode": "aztec", + "people": [ + { + "uid": "wagon-2-player-1", + "position": [0.5, 0.5], + "rotation": 0, + "model_type": "character-male-e", + "items": [] + }, + { + "uid": "wagon-2-player-2", + "position": [0.25, 0.25], + "rotation": 0, + "model_type": "character-female-e", + "items": ["blaster", "cone"] + }, + { + "uid": "wagon-2-player-3", + "position": [0.75, 0.55], + "rotation": 0, + "model_type": "character-male-e", + "items": [] + }, + { + "uid": "wagon-2-player-4", + "position": [0.75, 0.65], + "rotation": 0, + "model_type": "character-female-f", + "items": [] + } + ] + }, { "id": 3, "theme": "Ving Mille Lieues Sous Les Mers", @@ -111,40 +82,28 @@ "people": [ { "uid": "wagon-3-player-1", - "position": [ - 0.5, - 0.5 - ], + "position": [0.5, 0.5], "rotation": 0, "model_type": "character-female-d", "items": [] }, { "uid": "wagon-3-player-2", - "position": [ - 0.25, - 0.25 - ], + "position": [0.25, 0.25], "rotation": 0, "model_type": "character-male-d", "items": [] }, { "uid": "wagon-3-player-3", - "position": [ - 0.75, - 0.55 - ], + "position": [0.75, 0.55], "rotation": 0, "model_type": "character-female-b", "items": [] }, { "uid": "wagon-3-player-4", - "position": [ - 0.75, - 0.65 - ], + "position": [0.75, 0.65], "rotation": 0, "model_type": "character-male-c", "items": [] @@ -158,40 +117,28 @@ "people": [ { "uid": "wagon-4-player-1", - "position": [ - 0.5, - 0.5 - ], + "position": [0.5, 0.5], "rotation": 0, "model_type": "character-male-a", "items": [] }, { "uid": "wagon-4-player-2", - "position": [ - 0.25, - 0.25 - ], + "position": [0.25, 0.25], "rotation": 0, "model_type": "character-female-a", "items": [] }, { "uid": "wagon-4-player-3", - "position": [ - 0.75, - 0.55 - ], + "position": [0.75, 0.55], "rotation": 0, "model_type": "character-male-f", "items": [] }, { "uid": "wagon-4-player-4", - "position": [ - 0.75, - 0.65 - ], + "position": [0.75, 0.65], "rotation": 0, "model_type": "character-female-b", "items": [] @@ -338,5 +285,4 @@ } ] } - ] -} \ No newline at end of file +]