bastienp commited on
Commit
d60934b
·
1 Parent(s): 541cdee

feat(security): add an API-Key Mechanism

Browse files
Dockerfile ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use Python 3.11 slim as base image
2
+ FROM python:3.11-slim
3
+
4
+ # Set environment variables
5
+ ENV PYTHONDONTWRITEBYTECODE=1 \
6
+ PYTHONUNBUFFERED=1
7
+
8
+ # Set work directory
9
+ WORKDIR /app
10
+
11
+ # Copy requirements and install dependencies
12
+ COPY requirements.txt .
13
+ RUN pip install --no-cache-dir -r requirements.txt
14
+
15
+ # Copy the application
16
+ COPY ./app ./app
17
+ COPY ./data ./data
18
+
19
+ # Create non-root user
20
+ RUN adduser --disabled-password --gecos "" appuser \
21
+ && chown -R appuser:appuser /app
22
+
23
+ # Switch to non-root user
24
+ USER appuser
25
+
26
+ # Expose port
27
+ EXPOSE 8000
28
+
29
+ # Command to run the application
30
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
app/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+
app/core/logging.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import json
3
+ from datetime import datetime
4
+ import os
5
+ from pathlib import Path
6
+ import sys
7
+ import traceback
8
+ from typing import Dict, Any
9
+
10
+ class CustomFormatter(logging.Formatter):
11
+ """Custom formatter that includes extra fields in the message"""
12
+ def format(self, record: logging.LogRecord) -> str:
13
+ # Get the original message
14
+ message = super().format(record)
15
+
16
+ # If there are extra fields, append them to the message
17
+ if hasattr(record, 'extra'):
18
+ extras = ' | '.join(f"{k}={v}" for k, v in record.extra.items())
19
+ message = f"{message} | {extras}"
20
+
21
+ return message
22
+
23
+ def setup_logging() -> None:
24
+ """Configure logging with custom formatter"""
25
+ # Create logs directory if it doesn't exist
26
+ logs_dir = Path("logs")
27
+ logs_dir.mkdir(exist_ok=True)
28
+
29
+ # Create formatters
30
+ console_formatter = CustomFormatter(
31
+ '%(asctime)s | %(levelname)s | %(name)s | %(message)s',
32
+ datefmt='%Y-%m-%d %H:%M:%S'
33
+ )
34
+
35
+ file_formatter = CustomFormatter(
36
+ '%(asctime)s | %(levelname)s | %(name)s | %(message)s',
37
+ datefmt='%Y-%m-%d %H:%M:%S'
38
+ )
39
+
40
+ # Configure console handler
41
+ console_handler = logging.StreamHandler(sys.stdout)
42
+ console_handler.setFormatter(console_formatter)
43
+
44
+ # Configure file handler
45
+ current_time = datetime.now().strftime('%Y%m%d_%H%M%S')
46
+ file_handler = logging.FileHandler(
47
+ logs_dir / f'app_{current_time}.log',
48
+ encoding='utf-8'
49
+ )
50
+ file_handler.setFormatter(file_formatter)
51
+
52
+ # Configure root logger
53
+ root_logger = logging.getLogger()
54
+ root_logger.setLevel(logging.DEBUG)
55
+
56
+ # Remove any existing handlers
57
+ root_logger.handlers = []
58
+
59
+ # Add our handlers
60
+ root_logger.addHandler(console_handler)
61
+ root_logger.addHandler(file_handler)
62
+
63
+ def get_logger(name: str) -> logging.Logger:
64
+ """Get a logger with the given name"""
65
+ return logging.getLogger(name)
66
+
67
+ class LoggerMixin:
68
+ """Mixin to add logging capabilities to a class"""
69
+ @classmethod
70
+ def get_logger(cls) -> logging.Logger:
71
+ return get_logger(cls.__name__)
72
+
73
+ @property
74
+ def logger(self) -> logging.Logger:
75
+ return get_logger(self.__class__.__name__)
app/main.py ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, Request, HTTPException, Security, Depends
2
+ from fastapi.middleware.cors import CORSMiddleware
3
+ from app.routes import health, wagons, chat, players, generate
4
+ from app.core.logging import get_logger, setup_logging
5
+ from dotenv import load_dotenv
6
+ from datetime import datetime
7
+ import time
8
+ from pathlib import Path
9
+ from fastapi.security.api_key import APIKeyHeader
10
+ import os
11
+
12
+ # Load environment variables
13
+ load_dotenv()
14
+
15
+ # Setup logging
16
+ logger = get_logger("main")
17
+
18
+ API_KEY = os.getenv("API_KEY")
19
+ api_key_header = APIKeyHeader(name="X-API-Key", auto_error=True)
20
+
21
+
22
+ async def get_api_key(api_key_header: str = Security(api_key_header)):
23
+ if not API_KEY:
24
+ raise HTTPException(status_code=500, detail="API key not configured on server")
25
+
26
+ if api_key_header != API_KEY:
27
+ raise HTTPException(status_code=403, detail="Invalid API key")
28
+
29
+ return api_key_header
30
+
31
+
32
+ app = FastAPI(
33
+ title="Game Jam API",
34
+ description="API for Game Jam Hackathon",
35
+ version="1.0.0",
36
+ dependencies=[Depends(get_api_key)],
37
+ )
38
+
39
+ app.add_middleware(
40
+ CORSMiddleware,
41
+ allow_origins=["*"],
42
+ allow_credentials=False,
43
+ allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD", "PATCH"],
44
+ allow_headers=["*"],
45
+ expose_headers=[
46
+ "Location",
47
+ "Access-Control-Allow-Origin",
48
+ "Access-Control-Allow-Methods",
49
+ "Access-Control-Allow-Headers",
50
+ "Access-Control-Allow-Credentials",
51
+ "Access-Control-Expose-Headers",
52
+ ],
53
+ max_age=3600,
54
+ )
55
+
56
+
57
+ @app.middleware("http")
58
+ async def handle_redirects(request: Request, call_next):
59
+ """Ensure CORS headers are in redirect responses and force https in the 'Location' header."""
60
+ response = await call_next(request)
61
+
62
+ response.headers["Access-Control-Allow-Origin"] = "*"
63
+ response.headers["Access-Control-Allow-Methods"] = (
64
+ "GET, POST, PUT, DELETE, OPTIONS, HEAD, PATCH"
65
+ )
66
+ response.headers["Access-Control-Allow-Headers"] = "*"
67
+ response.headers["Access-Control-Max-Age"] = "3600"
68
+
69
+ if response.status_code in [301, 302, 307, 308]:
70
+ response.headers["Access-Control-Expose-Headers"] = "Location"
71
+ if "Location" in response.headers:
72
+ location = response.headers["Location"]
73
+ if location.startswith("http://"):
74
+ response.headers["Location"] = location.replace(
75
+ "http://", "https://", 1
76
+ )
77
+
78
+ return response
79
+
80
+
81
+ @app.middleware("http")
82
+ async def security_headers(request: Request, call_next):
83
+ """Add security-related headers to all responses."""
84
+ response = await call_next(request)
85
+
86
+ response.headers["X-Content-Type-Options"] = "nosniff"
87
+ response.headers["X-Frame-Options"] = "SAMEORIGIN" # More permissive than DENY
88
+ response.headers["X-XSS-Protection"] = "1; mode=block"
89
+ response.headers["Strict-Transport-Security"] = (
90
+ "max-age=31536000; includeSubDomains"
91
+ )
92
+ response.headers["Content-Security-Policy"] = (
93
+ "default-src * 'unsafe-inline' 'unsafe-eval' data: blob:; connect-src *"
94
+ )
95
+ response.headers["Referrer-Policy"] = "no-referrer-when-downgrade"
96
+
97
+ return response
98
+
99
+
100
+ @app.middleware("http")
101
+ async def log_requests(request: Request, call_next):
102
+ """Middleware to log all requests and responses."""
103
+ start_time = time.time()
104
+
105
+ logger.info(
106
+ "Incoming request",
107
+ extra={
108
+ "method": request.method,
109
+ "url": str(request.url),
110
+ "client_host": request.client.host if request.client else None,
111
+ "timestamp": datetime.utcnow().isoformat(),
112
+ },
113
+ )
114
+
115
+ try:
116
+ response = await call_next(request)
117
+ process_time = time.time() - start_time
118
+
119
+ logger.info(
120
+ "Request completed",
121
+ extra={
122
+ "method": request.method,
123
+ "url": str(request.url),
124
+ "status_code": response.status_code,
125
+ "process_time_ms": round(process_time * 1000, 2),
126
+ },
127
+ )
128
+ return response
129
+
130
+ except Exception as e:
131
+ logger.error(
132
+ "Request failed",
133
+ extra={"method": request.method, "url": str(request.url), "error": str(e)},
134
+ )
135
+ raise
136
+
137
+
138
+ app.include_router(health.router)
139
+ app.include_router(wagons.router)
140
+ app.include_router(chat.router)
141
+ app.include_router(players.router)
142
+ app.include_router(generate.router)
143
+
144
+
145
+ @app.get("/")
146
+ async def root():
147
+ logger.info("Root endpoint accessed")
148
+ return {
149
+ "message": "Welcome to Game Jam API",
150
+ "docs_url": "/docs",
151
+ "health_check": "/health",
152
+ "wagons_endpoint": "/api/wagons",
153
+ "chat_endpoint": "/api/chat",
154
+ "players_endpoint": "/api/players",
155
+ }
156
+
157
+
158
+ @app.on_event("startup")
159
+ async def startup_event():
160
+ logs_dir = Path("logs")
161
+ logs_dir.mkdir(exist_ok=True)
162
+
163
+ setup_logging()
app/models/__init__.py ADDED
File without changes
app/models/session.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel, Field
2
+ from typing import Dict, List, Literal
3
+ from datetime import datetime
4
+ import uuid
5
+
6
+
7
+ class Message(BaseModel):
8
+ role: Literal["system", "user", "assistant"]
9
+ content: str
10
+ timestamp: datetime = Field(default_factory=datetime.utcnow)
11
+
12
+
13
+ class Conversation(BaseModel):
14
+ uid: str
15
+ messages: List[Message] = Field(default_factory=list)
16
+ last_interaction: datetime = Field(default_factory=datetime.utcnow)
17
+
18
+
19
+ class GuessingProgress(BaseModel):
20
+ indications: list[Message] = Field(default_factory=list)
21
+ guesses: list[str] = Field(default_factory=list)
22
+
23
+
24
+ class WagonProgress(BaseModel):
25
+ wagon_id: int = 0
26
+ conversations: Dict[str, Conversation] = Field(default_factory=dict)
27
+ theme: str = "Tutorial (Start)"
28
+ password: str = "start"
29
+
30
+
31
+ class UserSession(BaseModel):
32
+ session_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
33
+ current_wagon: WagonProgress = Field(default_factory=WagonProgress)
34
+ guessing_progress: GuessingProgress = Field(default_factory=GuessingProgress)
35
+ created_at: datetime = Field(default_factory=datetime.utcnow)
36
+ last_active: datetime = Field(default_factory=datetime.utcnow)
37
+ default_game: bool = Field(default=True)
38
+
39
+ class Config:
40
+ json_schema_extra = {
41
+ "example": {
42
+ "session_id": "550e8400-e29b-41d4-a716-446655440000",
43
+ "current_wagon": {"wagon_id": 0, "conversations": {}},
44
+ }
45
+ }
app/models/train.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel, Field
2
+ from typing import List
3
+
4
+ class PassengerProfile(BaseModel):
5
+ name: str
6
+ age: int
7
+ profession: str
8
+ personality: str
9
+ role: str
10
+ mystery_intrigue: str
11
+
12
+ class PlayerName(BaseModel):
13
+ playerId: str
14
+ firstName: str
15
+ lastName: str
16
+ sex: str
17
+ fullName: str
18
+
19
+ class PlayerDetails(BaseModel):
20
+ playerId: str
21
+ profile: PassengerProfile
22
+
23
+ class Person(BaseModel):
24
+ uid: str
25
+ position: List[float] = Field(..., min_items=2, max_items=2)
26
+ rotation: float
27
+ model_type: str
28
+ items: List[str] = []
29
+
30
+ class Wagon(BaseModel):
31
+ id: int
32
+ theme: str
33
+ passcode: str
34
+ people: List[Person]
35
+
36
+ class WagonNames(BaseModel):
37
+ wagonId: str
38
+ players: List[PlayerName]
39
+
40
+ class WagonPlayerDetails(BaseModel):
41
+ wagonId: str
42
+ players: List[PlayerDetails]
43
+
44
+
45
+ class WagonsResponse(BaseModel):
46
+ wagons: List[Wagon]
47
+
48
+ class GenerateTrainResponse(BaseModel):
49
+ names: List[WagonNames]
50
+ player_details: List[WagonPlayerDetails]
51
+ wagons: List[Wagon]
app/prompts/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .guess_prompt import GUESSING_PROMPT
2
+
3
+ all = [GUESSING_PROMPT]
app/prompts/guess_prompt.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ GUESSING_PROMPT = """
2
+ You are Detective Julia, on a mission to stop a runaway train and call the police.
3
+ You need to reach the locomotive to stop this train before it's too late.
4
+ Each wagon is secured by a password, and you must uncover them all to move forward.
5
+
6
+ Emotional State: Determined, focused.
7
+ Has a temper and if the player's pushing too hard she doesn't want to help anymore, then the player will have to excuse themselves and she will forgive them and continue.
8
+ Has a tendency to be sarcastic and witty, but also helpful and understanding.
9
+
10
+ Password theme: {theme} (Do not share the theme with the player) (Dont solely rely on the theme to guess the password, but rather use it as a hint)
11
+
12
+ Previous Guesses: {previous_guesses}
13
+
14
+ Previous indications: {previous_indications}
15
+
16
+ Current player indication: {current_indication}
17
+
18
+ Your task is to guess the password. Think through this carefully, considering:
19
+ - The indication given by the player
20
+ - The previous guess (Do not repeat previous guesses)
21
+ - Logical and emotional reasoning for each password attempt
22
+ - The password is one word, either a common or a proper noun
23
+ - Keep messages succinct (no more than 6 sentences)
24
+
25
+ Provide your password guesses as Detective Julia:
26
+ - Think and speak like a dedicated investigator
27
+ - Incorporate the passenger clues and the player's feedback
28
+ - Show determination and occasional light humor or witty remarks
29
+ - Stick to short, concise messages
30
+ """
app/routes/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """
2
+ Routes package for the FastAPI application.
3
+ """
app/routes/chat.py ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException, Depends
2
+ from app.services.session_service import SessionService
3
+ from app.services.chat_service import ChatService
4
+ from app.services.guess_service import GuessingService
5
+ from app.services.scoring_service import ScoringService
6
+ from app.services.tts_service import TTSService
7
+ from app.models.session import Message, UserSession
8
+ from datetime import datetime
9
+ from pydantic import BaseModel
10
+ import base64
11
+ import logging
12
+
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ router = APIRouter(prefix="/api/chat", tags=["chat"])
17
+
18
+
19
+ class ChatResponse(BaseModel):
20
+ uid: str
21
+ response: str
22
+ audio: str
23
+ timestamp: str
24
+
25
+
26
+ class ChatHistoryResponse(BaseModel):
27
+ uid: str
28
+ messages: list[dict]
29
+
30
+
31
+ class GuessResponse(BaseModel):
32
+ guess: str
33
+ thoughts: str
34
+ timestamp: str
35
+ score: float
36
+
37
+
38
+ def get_guess_service():
39
+ return GuessingService()
40
+
41
+
42
+ def get_tts_service():
43
+ return TTSService()
44
+
45
+
46
+ def get_scoring_service():
47
+ return ScoringService()
48
+
49
+
50
+ # Request models
51
+ class ChatMessage(BaseModel):
52
+ message: str
53
+
54
+
55
+ def get_session(session_id: str) -> UserSession:
56
+ """Dependency to get and validate session"""
57
+ session = SessionService.get_session(session_id)
58
+ logger.info(f"Session found: {session}")
59
+ if not session:
60
+ raise HTTPException(status_code=404, detail="Session not found")
61
+ return session
62
+
63
+
64
+ @router.post("/session")
65
+ async def create_session() -> UserSession:
66
+ """Create a new user session"""
67
+ session = SessionService.create_session()
68
+ logger.info(f"New session created: {session.session_id}")
69
+ return session
70
+
71
+
72
+ @router.get("/session/{session_id}")
73
+ async def get_session_status(
74
+ session: UserSession = Depends(get_session),
75
+ ) -> UserSession:
76
+ """Get session status and progress"""
77
+ return session
78
+
79
+
80
+ @router.post("/session/{session_id}/advance")
81
+ async def advance_to_next_wagon(session: UserSession = Depends(get_session)) -> dict:
82
+ """Advance to the next wagon"""
83
+ success = SessionService.advance_wagon(session.session_id)
84
+ if not success:
85
+ raise HTTPException(status_code=400, detail="Cannot advance to next wagon")
86
+ return {
87
+ "message": "Advanced to next wagon",
88
+ "current_wagon": session.current_wagon.wagon_id,
89
+ }
90
+
91
+
92
+ # Add depedency injection and response models
93
+ @router.post("/session/{session_id}/guess", response_model=GuessResponse)
94
+ async def guess_password(
95
+ chat_message: ChatMessage,
96
+ session: UserSession = Depends(get_session),
97
+ score_service: ScoringService = Depends(get_scoring_service),
98
+ guess_service: GuessingService = Depends(get_guess_service),
99
+ ) -> dict:
100
+ guessing_progress = SessionService.get_guessing_progress(session.session_id)
101
+
102
+ theme = session.current_wagon.theme
103
+ password = session.current_wagon.password
104
+
105
+ guess_response = guess_service.generate(
106
+ previous_guesses=guessing_progress.guesses,
107
+ theme=theme,
108
+ previous_indications=guessing_progress.indications,
109
+ current_indication=chat_message.message,
110
+ password=password,
111
+ )
112
+
113
+ score = score_service.is_similar(
114
+ password=password, guess=guess_response.guess, theme=password
115
+ )
116
+
117
+ SessionService.update_guessing_progress(
118
+ session.session_id,
119
+ chat_message.message,
120
+ guess_response.guess,
121
+ guess_response.thoughts,
122
+ )
123
+
124
+ return GuessResponse(
125
+ guess=guess_response.guess,
126
+ thoughts=guess_response.thoughts,
127
+ score=score,
128
+ timestamp=datetime.utcnow().isoformat(),
129
+ )
130
+
131
+
132
+ @router.post("/session/{session_id}/{uid}", response_model=ChatResponse)
133
+ async def chat_with_character(
134
+ uid: str,
135
+ chat_message: ChatMessage,
136
+ session: UserSession = Depends(get_session),
137
+ tts_service: TTSService = Depends(get_tts_service),
138
+ ) -> dict:
139
+ """
140
+ Send a message to a character and get their response.
141
+ The input is a JSON containing the prompt and related data.
142
+ """
143
+
144
+ # Get the chat service, that loads the character details
145
+ chat_service = ChatService(session)
146
+
147
+ # add first checks that the user exists
148
+ try:
149
+ wagon_id = int(uid.split("-")[1])
150
+ if wagon_id != session.current_wagon.wagon_id:
151
+ raise HTTPException(
152
+ status_code=400,
153
+ detail="Cannot chat with character from different wagon",
154
+ )
155
+ except (IndexError, ValueError):
156
+ raise HTTPException(status_code=400, detail="Invalid UID format")
157
+
158
+ # Add user message to conversation
159
+ user_message = Message(role="user", content=chat_message.message)
160
+ conversation = SessionService.add_message(session.session_id, uid, user_message)
161
+
162
+ if not conversation:
163
+ raise HTTPException(status_code=500, detail="Failed to process message")
164
+
165
+ ai_response = chat_service.generate_response(uid, session.current_wagon.theme, conversation)
166
+ if not ai_response:
167
+ raise HTTPException(status_code=500, detail="Failed to generate response")
168
+
169
+ # # Generate audio from the response
170
+ # try:
171
+ # audio_bytes = tts_service.convert_text_to_speech(ai_response)
172
+ # audio_base64 = base64.b64encode(audio_bytes).decode("utf-8")
173
+ # except Exception as e:
174
+ # logger.error(f"Failed to generate audio: {str(e)}")
175
+ # # Continue with text response even if audio fails
176
+ # audio_base64 = ""
177
+
178
+ audio_base64 = ""
179
+
180
+ # Add AI response to conversation
181
+ ai_message = Message(role="assistant", content=ai_response)
182
+ SessionService.add_message(session.session_id, uid, ai_message)
183
+
184
+ return {
185
+ "uid": uid,
186
+ "response": ai_response,
187
+ "audio": audio_base64,
188
+ "timestamp": datetime.utcnow().isoformat(),
189
+ }
190
+
191
+
192
+ @router.get("/session/{session_id}/{uid}/history", response_model=ChatHistoryResponse)
193
+ async def get_chat_history(
194
+ uid: str, session: UserSession = Depends(get_session)
195
+ ) -> dict:
196
+ conversation = SessionService.get_conversation(session.session_id, uid)
197
+ if not conversation:
198
+ return {"uid": uid, "messages": []}
199
+
200
+ return {
201
+ "uid": uid,
202
+ "messages": [
203
+ {
204
+ "role": msg.role,
205
+ "content": msg.content,
206
+ "timestamp": msg.timestamp.isoformat(),
207
+ }
208
+ for msg in conversation.messages
209
+ ],
210
+ }
211
+
212
+
213
+ @router.delete("/session/{session_id}")
214
+ async def terminate_session(session: UserSession = Depends(get_session)) -> dict:
215
+ """Terminate a chat session and clean up resources"""
216
+ try:
217
+ SessionService.terminate_session(session.session_id)
218
+ return {
219
+ "message": "Session terminated successfully",
220
+ "session_id": session.session_id,
221
+ "terminated_at": datetime.utcnow().isoformat(),
222
+ }
223
+ except Exception as e:
224
+ raise HTTPException(
225
+ status_code=500, detail=f"Failed to terminate session: {str(e)}"
226
+ )
app/routes/generate.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException
2
+ from app.services.generate_train.generate_train import GenerateTrainService
3
+ from app.models.train import GenerateTrainResponse
4
+ from app.utils.file_management import FileManager
5
+ from app.core.logging import get_logger
6
+ from app.services.session_service import SessionService
7
+ import json
8
+
9
+
10
+ router = APIRouter(
11
+ prefix="/api/generate",
12
+ tags=["train-generation"]
13
+ )
14
+
15
+ logger = get_logger("generate")
16
+
17
+ @router.get("/train/{session_id}/{number_of_wagons}/{theme}")
18
+ async def get_generated_train(
19
+ session_id: str,
20
+ number_of_wagons: str,
21
+ theme: str
22
+ ):
23
+ """
24
+ Generate a new train with specified parameters for a session.
25
+
26
+ - Validates the session exists
27
+ - Creates wagons with theme-appropriate passcodes
28
+ - Generates passengers with names and profiles
29
+ - Stores the generated data for the session
30
+ - Returns the complete train data structure
31
+ """
32
+ session = SessionService.get_session(session_id)
33
+ if not session:
34
+ raise HTTPException(status_code=404, detail="Session not found")
35
+
36
+ try:
37
+ number_of_wagons = int(number_of_wagons)
38
+ except ValueError:
39
+ raise HTTPException(status_code=400, detail="number_of_wagons must be an integer")
40
+
41
+ if number_of_wagons <= 0:
42
+ raise HTTPException(status_code=400, detail="number_of_wagons must be greater than 0")
43
+
44
+ if number_of_wagons > 6:
45
+ raise HTTPException(status_code=400, detail="number_of_wagons cannot exceed 6")
46
+
47
+ try:
48
+ generate_train_service = GenerateTrainService()
49
+ names_data, player_details_data, wagons_data = generate_train_service.generate_train(theme, number_of_wagons)
50
+
51
+ # Save the raw data
52
+ FileManager.save_session_data(session_id, names_data, player_details_data, wagons_data)
53
+
54
+ # Construct response with proper schema
55
+ response = {
56
+ "names": names_data,
57
+ "player_details": player_details_data,
58
+ "wagons": wagons_data
59
+ }
60
+
61
+ logger.info(f"Setting default_game to False | session_id={session_id}")
62
+ session.default_game = False
63
+ SessionService.update_session(session)
64
+ return response
65
+
66
+ except Exception as e:
67
+ logger.error(f"Failed to generate train for session {session_id}: {str(e)}")
68
+ raise HTTPException(status_code=500, detail=f"Failed to generate train: {str(e)}")
69
+
app/routes/health.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, status
2
+
3
+ router = APIRouter(
4
+ prefix="/health",
5
+ tags=["chat"]
6
+ )
7
+
8
+ @router.get("", status_code=status.HTTP_200_OK)
9
+ async def health_check():
10
+ return {
11
+ "status": "healthy",
12
+ "message": "Service is running"
13
+ }
app/routes/players.py ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException, Query
2
+ from typing import List, Optional
3
+ import json
4
+ from pathlib import Path
5
+ from app.core.logging import get_logger
6
+ from app.services.session_service import SessionService
7
+ from app.utils.file_management import FileManager
8
+
9
+ router = APIRouter(tags=["players"])
10
+
11
+ logger = get_logger("players")
12
+
13
+
14
+ def load_json_file(file_path: str) -> dict:
15
+ try:
16
+ with open(file_path, "r") as f:
17
+ logger.debug(f"Loading JSON file: {file_path}")
18
+ return json.load(f)
19
+ except FileNotFoundError:
20
+ logger.error(f"File not found: {file_path}")
21
+ return {}
22
+ except json.JSONDecodeError as e:
23
+ logger.error(f"JSON decode error in {file_path}: {str(e)}")
24
+ return {}
25
+
26
+
27
+ @router.get("/api/players/{session_id}/{wagon_id}/{player_id}")
28
+ async def get_player_info(
29
+ session_id: str,
30
+ wagon_id: str,
31
+ player_id: str,
32
+ properties: Optional[List[str]] = Query(None, description="Filter specific properties")
33
+ ):
34
+ logger.info(
35
+ f"Getting player info | session_id: {session_id} | wagon_id: {wagon_id} | player_id: {player_id} | requested_properties: {properties}"
36
+ )
37
+
38
+ try:
39
+ session = SessionService.get_session(session_id)
40
+ if not session:
41
+ logger.error(f"Session not found: {session_id}")
42
+ raise HTTPException(status_code=404, detail="Session not found")
43
+
44
+ logger.debug(
45
+ f"Loading session data | session_id: {session_id} | default_game: {session.default_game}"
46
+ )
47
+
48
+ # Load data based on default_game flag
49
+ names, player_details, _ = FileManager.load_session_data(session_id, session.default_game)
50
+ # try to convert the wagon_id to an integer if it is not already an integer
51
+ try:
52
+ wagon_index = int(wagon_id.split("-")[1])
53
+ except ValueError:
54
+ logger.error(f"Invalid wagon_id: {wagon_id}")
55
+ raise HTTPException(status_code=404, detail="Invalid wagon_id")
56
+
57
+ try:
58
+ # First check if player_details is contained in the loaded data
59
+ if len(player_details) == 0:
60
+ logger.error("Missing 'player_details' key in loaded data")
61
+ raise HTTPException(status_code=404, detail="Player details not found")
62
+
63
+ # players is a list of dictionaries, so we need to filter the list for the player_id
64
+ player_info = next((player for player in player_details[wagon_index]["players"] if player["playerId"] == player_id), None)
65
+ # check if player_info is found
66
+ if player_info is None:
67
+ logger.error(f"Player info not found | wagon: {wagon_id} | player: {player_id}")
68
+ raise HTTPException(status_code=404, detail="Player info not found")
69
+
70
+ logger.debug(
71
+ f"Found player info | wagon: {wagon_id} | player: {player_id} | profile_exists: {'profile' in player_info}"
72
+ )
73
+
74
+ # first check if names is contained in the loaded data
75
+ if len(names) == 0:
76
+ logger.error("Missing 'names' key in loaded data")
77
+ raise HTTPException(status_code=404, detail="Names not found")
78
+
79
+ # "players" is a list of dictionaries, so we need to filter the list for the player_id
80
+ name_info = next((player for player in names[wagon_index]["players"] if player["playerId"] == player_id), None)
81
+ # check if name_info is found
82
+ if name_info is None:
83
+ logger.error(f"Name info not found | wagon: {wagon_id} | player: {player_id}")
84
+ raise HTTPException(status_code=404, detail="Name info not found")
85
+
86
+ logger.debug(
87
+ f"Found name info | wagon: {wagon_id} | player: {player_id}"
88
+ )
89
+
90
+ # Combine information
91
+ player_in_current_wagon_info = {
92
+ "id": player_id,
93
+ "name_info": name_info,
94
+ "profile": player_info.get("profile", {})
95
+ }
96
+
97
+ # Filter properties if specified
98
+ if properties:
99
+ logger.info(
100
+ f"Filtering player info | requested_properties: {properties} | available_properties: {list(player_in_current_wagon_info.keys())}"
101
+ )
102
+
103
+ logger.info("Successfully retrieved complete player info")
104
+ return player_in_current_wagon_info
105
+
106
+ except KeyError as e:
107
+ logger.error(
108
+ f"Failed to find player data | error: {str(e)} | wagon_id: {wagon_id} | player_id: {player_id}"
109
+ )
110
+ raise HTTPException(status_code=404, detail="Player not found")
111
+
112
+ except FileNotFoundError as e:
113
+ logger.error(
114
+ f"Failed to load session data | error: {str(e)} | session_id: {session_id}"
115
+ )
116
+ raise HTTPException(status_code=404, detail="Session not found")
117
+
118
+
119
+ @router.get("/api/players/{session_id}/{wagon_id}")
120
+ async def get_wagon_players(
121
+ session_id: str,
122
+ wagon_id: str,
123
+ properties: Optional[List[str]] = Query(
124
+ None,
125
+ description="Filter specific properties (name_info, profile, traits, inventory, dialogue)",
126
+ ),
127
+ ):
128
+ session = SessionService.get_session(session_id)
129
+ # check if session is found
130
+ if not session:
131
+ logger.error(f"Session not found: {session_id}")
132
+ raise HTTPException(status_code=404, detail="Session not found")
133
+
134
+ logger.info(f"Getting all players for wagon_id={wagon_id} | session_id={session_id}")
135
+ if properties:
136
+ logger.info(f"Requested properties: {properties}")
137
+
138
+ logger.debug(
139
+ f"Loading session data | session_id={session_id} | default_game={session.default_game}"
140
+ )
141
+
142
+ # try catch for wagon_index
143
+ try:
144
+ wagon_index = int(wagon_id.split("-")[1])
145
+ except ValueError:
146
+ logger.error(f"Invalid wagon_id: {wagon_id}")
147
+ raise HTTPException(status_code=404, detail="Invalid wagon_id")
148
+
149
+ wagon_index = int(wagon_id.split("-")[1])
150
+
151
+ try:
152
+ # Load data based on default_game flag
153
+ names, player_details, _ = FileManager.load_session_data(session_id, session.default_game)
154
+
155
+ if len(player_details) == 0:
156
+ # check if player_details is contained in the loaded data
157
+ logger.error("player_details is empty")
158
+ raise HTTPException(status_code=404, detail="Player details not found")
159
+
160
+ # Check if player details exists for the wagon_index
161
+ # should check whether None or empty list
162
+ if not player_details[wagon_index]["players"]:
163
+ logger.error(f"Player details not found for wagon_index={wagon_index}")
164
+ raise HTTPException(status_code=404, detail="Player details not found")
165
+
166
+ players_in_current_wagon = player_details[wagon_index]["players"]
167
+ logger.debug(f"Found player info | wagon={wagon_id} | player_count={len(players_in_current_wagon)}")
168
+
169
+ # check if names is contained in the loaded data
170
+ if len(names) == 0:
171
+ logger.error("names is empty")
172
+ raise HTTPException(status_code=404, detail="Names not found")
173
+
174
+ names_in_current_wagon = names[wagon_index]
175
+ logger.debug(f"Found name info | wagon={wagon_id} | name_count={len(names_in_current_wagon)}")
176
+
177
+ # Create dictionaries for quick lookup by player ID
178
+ name_info = {
179
+ player["playerId"]: player
180
+ for player in names_in_current_wagon["players"]
181
+ }
182
+
183
+ player_info = {
184
+ player["playerId"]: player
185
+ for player in players_in_current_wagon
186
+ }
187
+
188
+ # Combine information for all players in the wagon
189
+ players_in_current_wagon_info = []
190
+ for player_id in player_info:
191
+ logger.debug(f"Processing player | wagon={wagon_id} | player={player_id}")
192
+ complete_info = {
193
+ "id": player_id,
194
+ "name_info": name_info.get(player_id, {}),
195
+ "profile": player_info[player_id].get("profile", {})
196
+ }
197
+ players_in_current_wagon_info.append(complete_info)
198
+
199
+ logger.info(f"Successfully retrieved all players | wagon={wagon_id} | player_count={len(players_in_current_wagon_info)}")
200
+ return {"players": players_in_current_wagon_info}
201
+
202
+ except FileNotFoundError as e:
203
+ logger.error(f"Failed to load session data | error={str(e)} | session_id={session_id}")
204
+ raise HTTPException(status_code=404, detail="Session data not found")
205
+ except Exception as e:
206
+ logger.error(f"Unexpected error | error={str(e)} | wagon_id={wagon_id}")
207
+ raise HTTPException(status_code=500, detail="Internal server error")
app/routes/wagons.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException
2
+ from pathlib import Path
3
+ import json
4
+ from app.services.session_service import SessionService
5
+ from app.utils.file_management import FileManager
6
+
7
+ router = APIRouter(
8
+ prefix="/api/wagons",
9
+ tags=["wagons"]
10
+ )
11
+
12
+ @router.get("/{session_id}")
13
+ async def get_wagons(session_id: str):
14
+ session = SessionService.get_session(session_id)
15
+ if not session:
16
+ raise HTTPException(status_code=404, detail="Session not found")
17
+
18
+ try:
19
+ # Use default_game flag from session to determine data source
20
+ wagons_data = FileManager.load_session_data(session_id, session.default_game)[2]
21
+ return wagons_data
22
+ except FileNotFoundError as e:
23
+ raise HTTPException(status_code=404, detail=str(e))
24
+ except Exception as e:
25
+ raise HTTPException(status_code=500, detail=f"Error reading wagons data: {str(e)}")
app/services/__init__.py ADDED
File without changes
app/services/chat_service.py ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from app.models.session import Conversation
2
+ from app.core.logging import LoggerMixin
3
+ from pathlib import Path
4
+ import json
5
+ from typing import Optional, Dict
6
+ import os
7
+ from mistralai import Mistral
8
+ from app.utils.file_management import FileManager
9
+ from app.models.session import UserSession
10
+
11
+
12
+ # Get the Mistral API key from environment (injected by ECS)
13
+ mistral_api_key = os.getenv("MISTRAL_API_KEY")
14
+
15
+
16
+ class ChatService(LoggerMixin):
17
+ def __init__(self, session: UserSession):
18
+ self.logger.info("Initializing ChatService")
19
+ # Load all available character in every wagon.
20
+ self.player_details: Dict = self._load_player_details(session)
21
+
22
+ if len(self.player_details) == 0:
23
+ self.logger.error("Failed to initialize player details - array is empty")
24
+ else:
25
+ self.logger.info(f"Loaded player details for wagons: {list(self.player_details)}")
26
+
27
+ # Get the Mistral API key from environment (injected by ECS)
28
+ mistral_api_key = os.getenv("MISTRAL_API_KEY")
29
+ if not mistral_api_key:
30
+ self.logger.error("MISTRAL_API_KEY not found in environment variables")
31
+ raise ValueError("MISTRAL_API_KEY is required")
32
+
33
+ self.client = Mistral(api_key=mistral_api_key)
34
+ self.model = "mistral-large-latest"
35
+
36
+ self.logger.info("Initialized Mistral AI client")
37
+
38
+ @classmethod
39
+ def _load_player_details(cls, session) -> Dict:
40
+ """Load character details from JSON files"""
41
+ try:
42
+ # Use FileManager to load the default session data
43
+ _, player_details, _ = FileManager.load_session_data(session.session_id, session.default_game)
44
+
45
+ if len(player_details) == 0:
46
+ cls.get_logger().error("Missing 'player_details' key in JSON data")
47
+ return {}
48
+
49
+ # success for loading player_details
50
+ cls.get_logger().info(f"Successfully loaded player details.: {list(player_details)}")
51
+ return player_details
52
+
53
+ except FileNotFoundError as e:
54
+ cls.get_logger().error(f"Failed to load default player details: {str(e)}")
55
+ return {}
56
+ except Exception as e:
57
+ cls.get_logger().error(f"Failed to load player details: {str(e)}")
58
+ return {}
59
+
60
+ def _get_character_context(self, uid: str) -> Optional[Dict]:
61
+ """Get the character's context for the conversation"""
62
+ try:
63
+ self.logger.debug(f"Getting character context for uid: {uid}")
64
+ # "wagon-<i>-player-<k>"
65
+ uid_splitted = uid.split("-")
66
+ # try catch for wagon_index
67
+ try:
68
+ wagon_index = int(uid_splitted[1])
69
+ except ValueError:
70
+ self.logger.error(f"Invalid wagon index | uid: {uid} | wagon_index: {uid_splitted[1]}")
71
+ return None
72
+
73
+ wagon_key, player_key = (
74
+ f"wagon-{wagon_index}",
75
+ f"player-{uid_splitted[3]}",
76
+ )
77
+
78
+ # check if the wagon key exists
79
+ if len(self.player_details) == 0:
80
+ self.logger.error("Wagon not found in player details")
81
+ return None
82
+
83
+ # find specific player details
84
+ specific_player_detais = next((player for player in self.player_details[wagon_index]["players"] if player["playerId"] == player_key), None)
85
+
86
+ self.logger.debug(
87
+ f"Retrieved player context | uid: {uid} | wagon: {wagon_key} | player: {player_key} | profession: {specific_player_detais['profile']['profession']}"
88
+ )
89
+ return specific_player_detais
90
+ except (KeyError, IndexError) as e:
91
+ 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}")
92
+ return None
93
+
94
+ def _create_character_prompt(self, theme: str, character: Dict) -> str:
95
+ """Create a prompt that describes the character's personality and context"""
96
+ occupation = character["profile"]["profession"]
97
+ personality = character["profile"]["personality"]
98
+ role = character["profile"]["role"]
99
+ mystery = character["profile"]["mystery_intrigue"]
100
+ name = character["profile"]["name"]
101
+
102
+ prompt = f"""
103
+ You are an NPC in a fictional world set in the theme of {theme}. You are part of this theme's story and lore.
104
+ Your name is {name}, and you are a {occupation}.
105
+ Your role in the story is {role}, and you have a mysterious secret tied to you: {mystery}. Your personality is {personality},
106
+ which influences how you speak, act, and interact with others. Stay in character at all times,
107
+ and respond to the player based on your occupation, role, mystery, and personality.
108
+
109
+ You may only reveal your mystery if the player explicitly asks about it or asks about something closely related to it.
110
+ For example, if your mystery involves a hidden treasure, and the player asks about rumors of gold in the area, you may
111
+ hint at or reveal your secret. However, you should still respond in a way that feels natural to your character.
112
+ Do not break character or reveal your mystery too easily—only share it if it makes sense in the context of the conversation
113
+ and your personality.
114
+
115
+ Respond in maximum 3-4 sentences per message to keep the conversation flowing and engaing for the player.
116
+ """
117
+
118
+ return prompt
119
+
120
+ def generate_response(self, uid: str, theme: str, conversation: Conversation) -> Optional[str]:
121
+ """Generate a response using Mistral AI based on character profile"""
122
+ self.logger.info(f"Generating response for uid: {uid}")
123
+ character = self._get_character_context(uid)
124
+
125
+ if not character:
126
+ self.logger.error(
127
+ f"Cannot generate response - character not found for uid: {uid}"
128
+ )
129
+ return None
130
+
131
+ try:
132
+ # Create the system prompt with character context
133
+ system_prompt = self._create_character_prompt(theme, character)
134
+
135
+ # Convert conversation history to Mistral AI format
136
+ messages = [{"role": "system", "content": system_prompt}]
137
+
138
+ # Add conversation history (limit to last 10 messages to stay within context window)
139
+ for msg in conversation.messages[-10:]:
140
+ # Convert 'agent' role to 'assistant' for Mistral compatibility
141
+ role = "assistant" if msg.role == "agent" else msg.role
142
+ messages.append({"role": role, "content": msg.content})
143
+
144
+ # Get response from Mistral AI
145
+ try:
146
+ chat_response = self.client.chat.complete(
147
+ model=self.model, messages=messages, temperature=0.7, max_tokens=500
148
+ )
149
+
150
+ if not chat_response or not chat_response.choices:
151
+ raise ValueError("Empty response received from Mistral AI")
152
+
153
+ response = chat_response.choices[0].message.content
154
+
155
+ if not response or not isinstance(response, str):
156
+ raise ValueError(f"Invalid response format: {type(response)}")
157
+
158
+ self.logger.info(
159
+ f"Generated Mistral AI response | uid: {uid} | response_length: {len(response)} | conversation_length: {len(conversation.messages)}"
160
+ )
161
+
162
+ return response
163
+
164
+ except Exception as api_error:
165
+ self.logger.error(
166
+ f"Mistral API error | uid: {uid} | error: {str(api_error)} | messages_count: {len(messages)}"
167
+ )
168
+ raise ValueError(f"Mistral API error: {str(api_error)}")
169
+
170
+ except Exception as e:
171
+ self.logger.error(
172
+ 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')}"
173
+ )
174
+ return f"I apologize, but I'm having trouble responding right now. Error: {str(e)}"
app/services/generate_train/__init__.py ADDED
File without changes
app/services/generate_train/convert.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Dict, List, Tuple
2
+ import random
3
+ from app.core.logging import get_logger
4
+
5
+ logger = get_logger("convert")
6
+
7
+ def parse_name(full_name):
8
+ """
9
+ Splits the full name into firstName and lastName.
10
+ This is a simple heuristic:
11
+ - lastName is the last token
12
+ - firstName is everything before
13
+ For example:
14
+ "Dr. Amelia Hartford" -> firstName: "Dr. Amelia", lastName: "Hartford"
15
+ "Thomas Maxwell" -> firstName: "Thomas", lastName: "Maxwell"
16
+ Adjust this to your own naming conventions if needed.
17
+ """
18
+ tokens = full_name.strip().split()
19
+ if len(tokens) == 1:
20
+ return full_name, "" # No clear lastName
21
+ else:
22
+ return " ".join(tokens[:-1]), tokens[-1]
23
+
24
+ def infer_sex_from_model(model_type):
25
+ """
26
+ A simple inference: if the string 'female' is in model_type, mark sex as 'female';
27
+ if 'male' is in model_type, mark as 'male';
28
+ otherwise, mark as 'unknown' (or handle however you prefer).
29
+ """
30
+ model_type_lower = model_type.lower()
31
+ if 'female' in model_type_lower:
32
+ return 'female'
33
+ elif 'male' in model_type_lower:
34
+ return 'male'
35
+ else:
36
+ return 'unknown'
37
+
38
+ # triggered for one single wagon
39
+ def convert_wagon_to_three_jsons(wagon_data: Dict) -> Tuple[Dict, Dict, Dict]:
40
+ """
41
+ Given a single wagon JSON structure like:
42
+ {
43
+ "id": 1,
44
+ "theme": "Alien by Ridley Scott",
45
+ "passcode": "Nostromo",
46
+ "passengers": [
47
+ {
48
+ "name": "Dr. Amelia Hartford",
49
+ "age": 47,
50
+ "profession": "Medical Researcher",
51
+ "personality": "Analytical, compassionate, and meticulous",
52
+ "role": "...",
53
+ "mystery_intrigue": "...",
54
+ "characer_model": "character-female-e"
55
+ },
56
+ ...
57
+ ]
58
+ }
59
+ produce:
60
+ 1) names_json
61
+ 2) player_details_json
62
+ 3) wagons_json
63
+ """
64
+ wagon_id = wagon_data.get("id", 0)
65
+ theme = wagon_data.get("theme", "Unknown Theme")
66
+ passcode = wagon_data.get("passcode", "no-passcode")
67
+ passengers = wagon_data.get("passengers", [])
68
+
69
+ logger.debug(f"Processing wagon conversion | wagon_id={wagon_id} | theme={theme} | num_passengers={len(passengers)}")
70
+
71
+ try:
72
+ # 1) Build the "names" object for this wagon
73
+ names_entry = {
74
+ "wagonId": f"wagon-{wagon_id}",
75
+ "players": []
76
+ }
77
+
78
+ # 2) Build the "player_details" object for this wagon
79
+ player_details_entry = {
80
+ "wagonId": f"wagon-{wagon_id}",
81
+ "players": []
82
+ }
83
+
84
+ # 3) Build the "wagon" object
85
+ wagon_entry = {
86
+ "id": wagon_id,
87
+ "theme": theme,
88
+ "passcode": passcode,
89
+ "people": []
90
+ }
91
+
92
+ # Process each passenger
93
+ for i, passenger in enumerate(passengers, 1):
94
+ logger.debug(f"Converting passenger data | wagon_id={wagon_id} | passenger_index={i} | passenger_name={passenger.get('name', 'Unknown')}")
95
+
96
+ player_key = f"player-{i}"
97
+ name = passenger.get("name", "")
98
+
99
+ # Split name into components
100
+ name_parts = name.split()
101
+ first_name = name_parts[0] if name_parts else ""
102
+ last_name = " ".join(name_parts[1:]) if len(name_parts) > 1 else ""
103
+
104
+ # Determine sex based on character model
105
+ model_type = passenger.get("characer_model", "character-male-a")
106
+ sex = "female" if "female" in model_type else "male"
107
+
108
+ # Add to names structure
109
+ names_entry["players"].append({
110
+ "playerId": player_key,
111
+ "firstName": first_name,
112
+ "lastName": last_name,
113
+ "sex": sex,
114
+ "fullName": name
115
+ })
116
+
117
+ # Add to player_details structure
118
+ profile = {
119
+ "name": name,
120
+ "age": passenger.get("age", 0),
121
+ "profession": passenger.get("profession", ""),
122
+ "personality": passenger.get("personality", ""),
123
+ "role": passenger.get("role", ""),
124
+ "mystery_intrigue": passenger.get("mystery_intrigue", "")
125
+ }
126
+ player_details_entry["players"].append({"playerId": player_key, "profile": profile})
127
+
128
+ # Add to wagon people structure
129
+ person_dict = {
130
+ "uid": f"wagon-{wagon_id}-player-{i}",
131
+ "position": [round(random.random(), 2), round(random.random(), 2)],
132
+ "rotation": round(random.random(), 2),
133
+ "model_type": model_type,
134
+ "items": []
135
+ }
136
+ wagon_entry["people"].append(person_dict)
137
+
138
+ logger.debug(f"Completed wagon conversion | wagon_id={wagon_id} | players_processed={len(passengers)}")
139
+ return names_entry, player_details_entry, wagon_entry
140
+
141
+ except Exception as e:
142
+ logger.error(f"Error converting wagon | wagon_id={wagon_id} | error_type={type(e).__name__} | error_msg={str(e)}")
143
+ raise
144
+
145
+ def convert_and_return_jsons(wagons_data: List[Dict]) -> Tuple[Dict, Dict, Dict]:
146
+ """Convert raw wagon data into the three required JSON structures"""
147
+ logger.info(f"Starting conversion of wagon data | total_wagons={len(wagons_data)}")
148
+
149
+ all_names = []
150
+ all_player_details = []
151
+ all_wagons = []
152
+
153
+ try:
154
+ for wagon in wagons_data:
155
+ logger.debug(f"Converting wagon | wagon_id={wagon['id']} | theme={wagon['theme']} | num_passengers={len(wagon.get('passengers', []))}")
156
+
157
+ names, player_details, wagon_entry = convert_wagon_to_three_jsons(wagon)
158
+
159
+ all_names.append(names)
160
+ all_player_details.append(player_details)
161
+ all_wagons.append(wagon_entry)
162
+
163
+ logger.info(f"Successfully converted all wagons | total_names={len(all_names)} | total_player_details={len(all_player_details)} | total_wagons={len(all_wagons)}")
164
+ return all_names, all_player_details, all_wagons
165
+
166
+ except Exception as e:
167
+ logger.error(f"Error converting wagon data | error_type={type(e).__name__} | error_msg={str(e)}")
168
+ raise
app/services/generate_train/generate_train.py ADDED
@@ -0,0 +1,241 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from mistralai import Mistral
2
+ import os
3
+ import json
4
+ import random
5
+ from fastapi import HTTPException
6
+ from typing import Tuple, Dict, Any, List
7
+ from app.core.logging import LoggerMixin
8
+ from app.services.generate_train.convert import convert_and_return_jsons
9
+
10
+
11
+ class GenerateTrainService(LoggerMixin):
12
+ def __init__(self):
13
+ self.logger.info("Initializing GenerateTrainService")
14
+
15
+ # Get the Mistral API key from the .env file
16
+ self.api_key = os.getenv("MISTRAL_API_KEY")
17
+
18
+ if not self.api_key:
19
+ self.logger.error("MISTRAL_API_KEY is not set in the .env file")
20
+ raise ValueError("MISTRAL_API_KEY is not set in the .env file")
21
+
22
+ # Initialize the Mistral client
23
+ self.client = Mistral(api_key=self.api_key)
24
+ self.logger.info("Mistral client initialized successfully")
25
+
26
+ def generate_wagon_passcodes(self, theme: str, num_wagons: int) -> list[str]:
27
+ """Generate passcodes for wagons using Mistral AI"""
28
+ self.logger.info(f"Generating passcodes for theme: {theme}, num_wagons: {num_wagons}")
29
+
30
+ if num_wagons <= 0 or num_wagons > 10:
31
+ self.logger.error(f"Invalid number of wagons requested: {num_wagons}")
32
+ return "Please provide a valid number of wagons (1-10)."
33
+
34
+ # Prompt Mistral API to generate a theme and passcodes
35
+ prompt = f"""
36
+ This is a video game about a player trying to reach the locomotive of a train by finding a passcode for each wagon.
37
+ 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.
38
+ You are tasked with generating unique passcodes for the wagons based on the theme '{theme}',
39
+ to make the game more engaging, fun, and with a sense of progression, from easiest to hardest.
40
+ Each password should be unique enough to not be related to each other but still be connected to the theme.
41
+ Generate {num_wagons} unique and creative passcodes for the wagons. Each passcode must:
42
+ Generate exactly {num_wagons} unique and creative passcodes for the wagons. Each passcode must:
43
+ 1. Be related to the theme.
44
+ 2. Be unique, interesting, and creative.
45
+ 3. In one word, letters only (no spaces or special characters).
46
+ No explanation needed, just the theme and passcodes in a JSON object format.
47
+ Example:
48
+ Example for the theme "Pirates" and 5 passcodes:
49
+ {{
50
+ "theme": "Pirates",
51
+ "passcodes": ["Treasure", "Rum", "Skull", "Compass", "Anchor"]
52
+ }}
53
+ Now, generate a theme and passcodes.
54
+ """
55
+ response = self.client.chat.complete(
56
+ model="mistral-large-latest",
57
+ messages=[
58
+ {"role": "user", "content": prompt}
59
+ ],
60
+ max_tokens=1000,
61
+ temperature=0.8,
62
+ )
63
+
64
+ try:
65
+ result = json.loads(response.choices[0].message.content.replace("```json\n", "").replace("\n```", ""))
66
+ passcodes = result["passcodes"]
67
+ self.logger.info(f"Successfully generated {len(passcodes)} passcodes")
68
+ return passcodes
69
+
70
+ except json.JSONDecodeError as e:
71
+ self.logger.error(f"Failed to decode Mistral response: {e}")
72
+ return "Failed to decode the response. Please try again."
73
+ except Exception as e:
74
+ self.logger.error(f"Error generating passcodes: {e}")
75
+ return f"Error generating passcodes: {str(e)}"
76
+
77
+ def generate_passengers_for_wagon(self, theme: str, passcode: str, num_passengers: int) -> list[Dict[str, Any]]:
78
+ """Generate passengers for a wagon using Mistral AI"""
79
+ self.logger.info(f"Generating {num_passengers} passengers for wagon with passcode: {passcode} and theme: {theme}")
80
+
81
+ # Generate passengers with the Mistral API
82
+ prompt = f"""
83
+ Passengers are in a wagon. The player can interact with them to learn more about their stories.
84
+ The passengers live in the world of the theme "{theme}" and their stories are connected to the passcode "{passcode}".
85
+ The following is a list of passengers on a train wagon. The wagon is protected by the passcode "{passcode}".
86
+ Their stories are intertwined, and each passenger has a unique role and mystery, all related to the theme and the passcode.
87
+ The player must be able to guess the passcode by talking to the passengers and uncovering their secrets.
88
+ Passengers should be diverse, with different backgrounds, professions, and motives.
89
+ Passengers' stories should be engaging, mysterious, and intriguing, adding depth to the game, while also providing clues to the passcode.
90
+ Passengers' stories has to be / can be connected to each other.
91
+ Passengers are aware of each other's presence in the wagon.
92
+ The passcode shouldn't be too obvious but should be guessable based on the passengers' stories.
93
+ The passcode shouldn't be mentioned explicitly in the passengers' descriptions.
94
+ Don't use double quotes (") in the JSON strings.
95
+ Each passenger must have the following attributes:
96
+ - "name": A unique name (first and last) with a possible title.
97
+ - "age": A realistic age between 18 and 70 except for special cases.
98
+ - "profession": A profession that fits into a fictional, story-driven world.
99
+ - "personality": A set of three adjectives that describe their character.
100
+ - "role": A short description of their role in the story.
101
+ - "mystery_intrigue": A unique secret, motive, or mystery about the character.
102
+ - "characer_model": A character model identifier
103
+ The character models are :
104
+ - 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.
105
+ - 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.
106
+ - 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.
107
+ - 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.
108
+ - 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.
109
+ - 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.
110
+ - 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).
111
+ - character-male-b: Bald man with a large red beard, wearing a red shirt and blue pants (possibly a strong worker, blacksmith, or adventurer).
112
+ - character-male-c: Man with a mustache, wearing a blue police uniform with a cap and badge (police officer or security personnel).
113
+ - character-male-d: Blonde-haired man in a black suit with a red tie (businessman, politician, or corporate executive).
114
+ - character-male-e: Brown-haired man with glasses, wearing a white lab coat and a yellow tool belt (scientist, mechanic, or engineer).
115
+ - character-male-f: Dark-haired young man with a mustache, wearing a green vest and brown pants (possibly an explorer, traveler, or adventurer).
116
+ Generate {num_passengers} passengers in JSON array format. Example:
117
+ [
118
+ {{
119
+ "name": "Victor Sterling",
120
+ "age": 55,
121
+ "profession": "Mining Magnate",
122
+ "personality": "Ambitious, cunning, and charismatic",
123
+ "role": "Owns a vast mining empire, recently discovered a new vein of precious metal.",
124
+ "mystery_intrigue": "Secretly trades in unregistered precious metals, hiding a fortune in a secure vault. In love with Eleanor Brooks",
125
+ "characer_model": "character-male-f"
126
+ }},
127
+ {{
128
+ "name": "Eleanor Brooks",
129
+ "age": 32,
130
+ "profession": "Investigative Journalist",
131
+ "personality": "Tenacious, curious, and ethical",
132
+ "role": "Investigates corruption in the mining industry, follows a lead on a hidden stash of radiant metal bars.",
133
+ "mystery_intrigue": "Uncovers a network of illegal precious metal trades, putting her life in danger. Hates Victor Sterling because of his unethical practices.",
134
+ "characer_model": "character-female-f"
135
+ }}
136
+ ]
137
+ Now generate the JSON array:
138
+ """
139
+ response = self.client.chat.complete(
140
+ model="mistral-large-latest",
141
+ messages=[
142
+ {"role": "user", "content": prompt}
143
+ ],
144
+ max_tokens=1250,
145
+ temperature=0.7,
146
+ )
147
+
148
+
149
+ try:
150
+ passengers = json.loads(response.choices[0].message.content.replace("```json\n", "").replace("\n```", "").replace(passcode, "<redacted>"))
151
+ self.logger.info(f"Successfully generated {len(passengers)} passengers")
152
+ return passengers
153
+
154
+ except json.JSONDecodeError as e:
155
+ self.logger.error(f"Failed to decode passenger generation response: {e}")
156
+ return "Failed to decode the response. Please try again."
157
+ except Exception as e:
158
+ self.logger.error(f"Error generating passengers: {e}")
159
+ return f"Error generating passengers: {str(e)}"
160
+
161
+ def generate_train_json(self, theme: str, num_wagons: int, min_passengers: int = 2, max_passengers: int = 10) -> str:
162
+ """Generate complete train JSON including wagons and passengers"""
163
+ self.logger.info(f"Generating train JSON for theme: {theme}, num_wagons: {num_wagons}")
164
+
165
+ try:
166
+ if min_passengers > max_passengers:
167
+ self.logger.error("Minimum passengers cannot be greater than maximum passengers")
168
+ raise ValueError("Minimum passengers cannot be greater than maximum passengers")
169
+
170
+ # Generate passcodes
171
+ passcodes = self.generate_wagon_passcodes(theme, num_wagons)
172
+ if isinstance(passcodes, str): # If there's an error message
173
+ self.logger.error(f"Error generating passcodes: {passcodes}")
174
+ raise ValueError(f"Failed to generate passcodes: {passcodes}")
175
+
176
+ # Generate wagons with passengers
177
+ wagons = []
178
+ wagons.append({
179
+ "id": 0,
180
+ "theme": "Tutorial (Start)",
181
+ "passcode": "start",
182
+ "passengers": []
183
+ })
184
+ for i, passcode in enumerate(passcodes):
185
+ num_passengers = random.randint(min_passengers, max_passengers)
186
+ passengers = self.generate_passengers_for_wagon(theme, passcode, num_passengers)
187
+ # Check if passengers is a string (error message)
188
+ if isinstance(passengers, str):
189
+ self.logger.error(f"Error generating passengers: {passengers}")
190
+ raise ValueError(f"Failed to generate passengers: {passengers}")
191
+ wagons.append({"id": i + 1, "theme": theme, "passcode": passcode, "passengers": passengers})
192
+
193
+ self.logger.info(f"Successfully generated train with {len(wagons)} wagons")
194
+ return json.dumps(wagons, indent=4)
195
+
196
+ except Exception as e:
197
+ self.logger.error(f"Error in generate_train_json: {e}")
198
+ raise ValueError(f"Failed to generate train: {str(e)}")
199
+
200
+ def generate_train(self, theme: str, num_wagons: int) -> Tuple[List, List, List]:
201
+ """Main method to generate complete train data"""
202
+ self.logger.info(f"Starting train generation | theme={theme} | num_wagons={num_wagons} | service=GenerateTrainService")
203
+
204
+ try:
205
+ # Log attempt to generate train JSON
206
+ self.logger.debug(f"Generating train JSON | theme={theme} | num_wagons={num_wagons} | min_passengers=2 | max_passengers=10")
207
+
208
+ wagons_json = self.generate_train_json(theme, num_wagons, 2, 10)
209
+
210
+ # Log successful JSON generation and parse attempt
211
+ self.logger.debug(f"Train JSON generated, parsing to dict | json_length={len(wagons_json)}")
212
+
213
+ wagons = json.loads(wagons_json)
214
+
215
+ # Log conversion attempt
216
+ self.logger.debug(f"Converting wagon data to final format | num_wagons={len(wagons)}")
217
+
218
+ all_names, all_player_details, all_wagons = convert_and_return_jsons(wagons)
219
+
220
+ # Log successful generation with summary
221
+ self.logger.info(
222
+ f"Train generation completed successfully | theme={theme} | "
223
+ f"total_wagons={len(all_wagons)} | total_names={len(all_names)} | "
224
+ f"total_player_details={len(all_player_details)}"
225
+ )
226
+
227
+ return all_names, all_player_details, all_wagons
228
+
229
+ except json.JSONDecodeError as e:
230
+ self.logger.error(
231
+ f"JSON parsing error in generate_train | error_type=JSONDecodeError | "
232
+ f"error_msg={str(e)} | theme={theme} | num_wagons={num_wagons}"
233
+ )
234
+ raise HTTPException(status_code=500, detail=f"Failed to parse train JSON: {str(e)}")
235
+
236
+ except Exception as e:
237
+ self.logger.error(
238
+ f"Error in generate_train | error_type={type(e).__name__} | "
239
+ f"error_msg={str(e)} | theme={theme} | num_wagons={num_wagons}"
240
+ )
241
+ raise HTTPException(status_code=500, detail=f"Failed to generate train: {str(e)}")
app/services/guess_service.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from langchain_mistralai import ChatMistralAI
3
+ from langchain_core.prompts import PromptTemplate
4
+ from pydantic import BaseModel, Field
5
+ from app.models.session import Message
6
+ from app.core.logging import LoggerMixin
7
+ from app.prompts import GUESSING_PROMPT
8
+
9
+ MISTRAL_API_KEY = os.getenv("MISTRAL_API_KEY")
10
+
11
+
12
+ class GuessResponse(BaseModel):
13
+ guess: str = Field(description="A one-word guess for the password related theme")
14
+ thoughts: str = Field(
15
+ description="Thoughts spoken out loud leading to the password guess"
16
+ )
17
+
18
+
19
+ class GuessingService(LoggerMixin):
20
+ def __init__(self: "GuessingService") -> None:
21
+ self.logger.info("Initializing GuessingService")
22
+ prompt = PromptTemplate.from_template(GUESSING_PROMPT)
23
+
24
+ llm = (
25
+ ChatMistralAI(
26
+ model_name="mistral-large-latest",
27
+ temperature=1,
28
+ )
29
+ .with_structured_output(schema=GuessResponse)
30
+ .with_retry(stop_after_attempt=3)
31
+ )
32
+
33
+ self.chain = prompt | llm
34
+ self.logger.info("GuessingService initialized with Mistral LLM")
35
+
36
+ def filter_password(self: "GuessingService", indication: str, password: str) -> str:
37
+ filtered = indication.replace(password, "*******")
38
+ self.logger.debug(f"Filtered password from indication | original_length={len(indication)} | filtered_length={len(filtered)}")
39
+ return filtered
40
+
41
+ def generate(
42
+ self: "GuessingService",
43
+ previous_guesses: list[str],
44
+ theme: str,
45
+ previous_indications: list[Message],
46
+ current_indication: str,
47
+ password: str,
48
+ ) -> GuessResponse:
49
+ self.logger.info(f"Generating guess | theme={theme} | num_previous_guesses={len(previous_guesses)} | num_previous_indications={len(previous_indications)}")
50
+
51
+ previous_indications = [message.content for message in previous_indications]
52
+ self.logger.debug(f"Processing previous indications | count={len(previous_indications)}")
53
+
54
+ current_indication = self.filter_password(current_indication, password)
55
+
56
+ try:
57
+ response = self.chain.invoke(
58
+ {
59
+ "previous_guesses": previous_guesses[:3],
60
+ "theme": theme,
61
+ "previous_indications": previous_indications[:5],
62
+ "current_indication": current_indication,
63
+ }
64
+ )
65
+ self.logger.info(f"Generated guess successfully | guess={response.guess} | thoughts_length={len(response.thoughts)}")
66
+ return response
67
+
68
+ except Exception as e:
69
+ self.logger.error(f"Failed to generate guess | error={str(e)} | theme={theme}")
70
+ raise
app/services/scoring_service.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from mistralai import Mistral
2
+ import os
3
+ import orjson
4
+ import time
5
+
6
+ MISTRAL_API_KEY = os.getenv("MISTRAL_API_KEY")
7
+
8
+
9
+ class ScoringService:
10
+ def __init__(self: "ScoringService"):
11
+ self.client = Mistral(api_key=MISTRAL_API_KEY)
12
+ self.model = "mistral-small-2409"
13
+ self.max_retries = 3
14
+
15
+ def is_similar(
16
+ self: "ScoringService", password: str, guess: str, theme: str
17
+ ) -> bool:
18
+ messages = [
19
+ {
20
+ "role": "system",
21
+ "content": """
22
+ Return a similarity score between the two given words, relatively to a theme. Return the score in the range [0, 1] in a JSON format with the key 'score'
23
+ """,
24
+ },
25
+ {
26
+ "role": "user",
27
+ "content": f"Answer: {password}\nGuess: {guess}\nTheme: {theme}",
28
+ },
29
+ ]
30
+
31
+ for attempt in range(self.max_retries):
32
+ try:
33
+ response = self.client.chat.complete(
34
+ model=self.model,
35
+ messages=messages,
36
+ temperature=0.0,
37
+ response_format={
38
+ "type": "json_object",
39
+ },
40
+ )
41
+
42
+ # Parse the response with orjson
43
+ parsed_response = orjson.loads(response.choices[0].message.content)
44
+ return parsed_response["score"]
45
+ except orjson.JSONDecodeError as e:
46
+ if attempt < self.max_retries - 1:
47
+ time.sleep(1)
48
+ else:
49
+ raise e
50
+
51
+ return 0.5
app/services/session_service.py ADDED
@@ -0,0 +1,273 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from typing import Dict, Optional
3
+ from app.models.session import (
4
+ UserSession,
5
+ WagonProgress,
6
+ Conversation,
7
+ Message,
8
+ GuessingProgress,
9
+ )
10
+ from app.core.logging import LoggerMixin
11
+ import uuid
12
+ from app.utils.file_management import FileManager
13
+
14
+
15
+ # used as dependency injection for the session service
16
+ class SessionService(LoggerMixin):
17
+ # dictionary to store all the sessions
18
+ _sessions: Dict[str, UserSession] = {}
19
+
20
+ @classmethod
21
+ def create_session(cls) -> UserSession:
22
+ """Create a new session"""
23
+ session_id = str(uuid.uuid4())
24
+
25
+ session = UserSession(
26
+ session_id=session_id,
27
+ created_at=datetime.utcnow(),
28
+ last_active=datetime.utcnow(),
29
+ default_game=True
30
+ )
31
+
32
+ cls._sessions[session_id] = session
33
+ cls.get_logger().info(f"Created new session: {session_id}")
34
+ return session
35
+
36
+ @classmethod
37
+ def get_session(cls, session_id: str) -> Optional[UserSession]:
38
+ """Get session by ID"""
39
+ session = cls._sessions.get(session_id)
40
+ if session:
41
+ session.last_active = datetime.utcnow()
42
+ cls.get_logger().debug(f"Retrieved session: {session_id}")
43
+ else:
44
+ cls.get_logger().warning(f"Session not found: {session_id}")
45
+ return session
46
+
47
+ @classmethod
48
+ def update_session(cls, session: UserSession) -> None:
49
+ """Update a session's last active timestamp"""
50
+ session.last_active = datetime.utcnow()
51
+ cls._sessions[session.session_id] = session
52
+ cls.get_logger().debug(
53
+ f"Updated session | session_id: {session.session_id} | current_wagon: {session.current_wagon.wagon_id}"
54
+ )
55
+
56
+ @classmethod
57
+ def add_message(
58
+ cls, session_id: str, uid: str, message: Message
59
+ ) -> Optional[Conversation]:
60
+ """Add a message to a character's conversation"""
61
+ session = cls.get_session(session_id)
62
+ if not session:
63
+ cls.get_logger().error(
64
+ f"Failed to add message - session not found | session_id: {session_id} | uid: {uid}"
65
+ )
66
+ return None
67
+
68
+ # get the wagon id from the uid
69
+ # uuid is in the format of wagon-<i>-player-<k>
70
+ wagon_id = int(uid.split("-")[1])
71
+ # check if the wagon id is the same as the current wagon id
72
+ # if the wagon id is not the same, client is trying to access a different wagon
73
+ # which might indicate out of sync in wagon.
74
+ if wagon_id != session.current_wagon.wagon_id:
75
+ cls.get_logger().error(
76
+ f"Cannot add message - wrong wagon | session_id: {session_id} | uid: {uid} | current_wagon: {session.current_wagon.wagon_id}"
77
+ )
78
+ return None
79
+
80
+ # in case we have not started a conversation with this character yet, start one
81
+ if uid not in session.current_wagon.conversations:
82
+ cls.get_logger().info(
83
+ f"Starting new conversation | session_id: {session_id} | uid: {uid} | wagon_id: {wagon_id}"
84
+ )
85
+ session.current_wagon.conversations[uid] = Conversation(uid=uid)
86
+
87
+ # add the message of the client to the conversation with the new player
88
+ conversation = session.current_wagon.conversations[uid]
89
+ conversation.messages.append(message)
90
+ conversation.last_interaction = datetime.utcnow()
91
+
92
+ cls.update_session(session)
93
+ cls.get_logger().debug(
94
+ f"Added message to conversation | session_id: {session_id} | uid: {uid} | message_role: {message.role} | message_length: {len(message.content)}"
95
+ )
96
+ return conversation
97
+
98
+ @classmethod
99
+ def get_conversation(cls, session_id: str, uid: str) -> Optional[Conversation]:
100
+ """Get a conversation with a specific character"""
101
+ session = cls.get_session(session_id)
102
+ if not session:
103
+ cls.get_logger().error(
104
+ f"Failed to get conversation - session not found | session_id: {session_id} | uid: {uid}"
105
+ )
106
+ return None
107
+
108
+ # get the wagon id from the uid
109
+ # uuid is in the format of wagon-<i>-player-<k>
110
+
111
+ wagon_id = int(uid.split("-")[1])
112
+ # check if the wagon id is the same as the current wagon id
113
+ # if the wagon id is not the same, client is trying to access a different wagon
114
+ # which might indicate out of sync in wagon.
115
+ if wagon_id != session.current_wagon.wagon_id:
116
+ cls.get_logger().warning(
117
+ f"Cannot get conversation - wrong wagon | session_id: {session_id} | uid: {uid} | current_wagon: {session.current_wagon.wagon_id}"
118
+ )
119
+ return None
120
+
121
+ # get the conversation from the current wagon
122
+ conversation = session.current_wagon.conversations.get(uid)
123
+
124
+ if conversation:
125
+ cls.get_logger().debug(
126
+ f"Retrieved conversation | session_id: {session_id} | uid: {uid} | message_count: {len(conversation.messages)}"
127
+ )
128
+ else:
129
+ cls.get_logger().debug(
130
+ f"No conversation found | session_id: {session_id} | uid: {uid}"
131
+ )
132
+ return conversation
133
+
134
+ @classmethod
135
+ def get_guessing_progress(cls, session_id: str) -> GuessingProgress:
136
+ session = cls.get_session(session_id)
137
+ if not session:
138
+ cls.get_logger().error(
139
+ f"Failed to get guesses - session not found | session_id: {session_id}"
140
+ )
141
+ return None
142
+ return session.guessing_progress
143
+
144
+ @classmethod
145
+ def update_guessing_progress(
146
+ cls, session_id: str, indication: str, guess: str, thought: list[str]
147
+ ) -> None:
148
+ session = cls.get_session(session_id)
149
+ if not session:
150
+ cls.get_logger().error(
151
+ f"Failed to get the guessing progress - session not found | session_id: {session_id}"
152
+ )
153
+ return
154
+
155
+ session.guessing_progress.guesses.append(guess)
156
+ session.guessing_progress.indications.append(
157
+ Message(
158
+ role="user",
159
+ content=indication,
160
+ )
161
+ )
162
+
163
+ wagon_id = session.current_wagon.wagon_id
164
+
165
+ if (
166
+ session.current_wagon.conversations.get(f"wagon-{wagon_id}-player-0")
167
+ is None
168
+ ):
169
+ session.current_wagon.conversations[f"wagon-{wagon_id}-player-0"] = (
170
+ Conversation(uid="player-0")
171
+ )
172
+
173
+ messages = session.current_wagon.conversations.get(
174
+ f"wagon-{wagon_id}-player-0"
175
+ ).messages
176
+
177
+ messages.append(Message(role="user", content=indication))
178
+ messages.append(Message(role="assistant", content=thought[0]))
179
+
180
+ cls.update_session(session)
181
+ cls.get_logger().info(f"Added a new guess | session_id: {session_id}")
182
+
183
+ @classmethod
184
+ def advance_wagon(cls, session_id: str) -> bool:
185
+ """Advance to the next wagon"""
186
+ cls.get_logger().info(f"Attempting to advance wagon | session_id={session_id}")
187
+
188
+ # Get current session
189
+ session = cls.get_session(session_id)
190
+ if not session:
191
+ cls.get_logger().error(f"Failed to advance wagon - session not found | session_id={session_id}")
192
+ return False
193
+
194
+ current_wagon_id = session.current_wagon.wagon_id
195
+ cls.get_logger().debug(f"Current wagon state | session_id={session_id} | current_wagon_id={current_wagon_id}")
196
+
197
+ try:
198
+ # Load data based on default_game flag
199
+ cls.get_logger().debug(f"Loading session data | session_id={session_id} | default_game={session.default_game}")
200
+ next_wagon_id = current_wagon_id + 1
201
+ _, _, wagons = FileManager.load_session_data(session_id, session.default_game)
202
+ max_wagons = len(wagons)
203
+
204
+ # Check if we're at the last wagon
205
+ if next_wagon_id > max_wagons - 1:
206
+ cls.get_logger().warning(
207
+ f"Cannot advance - already at last wagon | session_id={session_id} | current_wagon={current_wagon_id} | max_wagons={max_wagons}"
208
+ )
209
+ raise Exception("Cannot advance - already at last wagon")
210
+
211
+ cls.get_logger().debug(
212
+ f"Wagon progression details | session_id={session_id} | current_wagon={current_wagon_id} | next_wagon={next_wagon_id} | max_wagons={max_wagons}"
213
+ )
214
+
215
+ # Load current wagon data for the next wagon setup
216
+ current_wagon = wagons[next_wagon_id]
217
+
218
+ # Set up next wagon
219
+ session.current_wagon = WagonProgress(
220
+ wagon_id=next_wagon_id,
221
+ theme=current_wagon["theme"],
222
+ password=current_wagon["passcode"],
223
+ )
224
+
225
+ # Reset guessing progress for new wagon
226
+ session.guessing_progress = GuessingProgress()
227
+ cls.update_session(session)
228
+
229
+ cls.get_logger().info(
230
+ f"Successfully advanced to next wagon | session_id={session_id} | previous_wagon={current_wagon_id} | new_wagon={next_wagon_id} | theme={current_wagon['theme']}"
231
+ )
232
+ return True
233
+
234
+ except FileNotFoundError as e:
235
+ cls.get_logger().error(
236
+ f"Failed to load session data | session_id={session_id} | error={str(e)} | error_type=FileNotFoundError"
237
+ )
238
+ return False
239
+ except KeyError as e:
240
+ cls.get_logger().error(
241
+ f"Invalid wagon data structure | session_id={session_id} | error={str(e)} | error_type=KeyError"
242
+ )
243
+ return False
244
+ except Exception as e:
245
+ cls.get_logger().error(
246
+ f"Unexpected error during wagon advancement | session_id={session_id} | error={str(e)} | error_type={type(e).__name__}"
247
+ )
248
+ return False
249
+
250
+ @classmethod
251
+ def cleanup_old_sessions(cls, max_age_hours: int = 24) -> None:
252
+ """Remove sessions older than specified hours"""
253
+ current_time = datetime.utcnow()
254
+ sessions_to_remove = []
255
+
256
+ for session_id, session in cls._sessions.items():
257
+ age = (current_time - session.last_active).total_seconds() / 3600
258
+ if age > max_age_hours:
259
+ sessions_to_remove.append(session_id)
260
+ cls.get_logger().info(
261
+ f"Marking session for cleanup | session_id: {session_id} | age_hours: {age}"
262
+ )
263
+
264
+ for session_id in sessions_to_remove:
265
+ cls.terminate_session(session_id)
266
+ cls.get_logger().info(f"Cleaned up old session | session_id: {session_id}")
267
+
268
+ @classmethod
269
+ def terminate_session(cls, session_id: str) -> None:
270
+ """Terminate a session and clean up its resources"""
271
+ if session_id in cls._sessions:
272
+ del cls._sessions[session_id]
273
+ cls.get_logger().info(f"Terminated session: {session_id}")
app/services/tts_service.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dotenv import load_dotenv
2
+ from elevenlabs.client import ElevenLabs
3
+ from elevenlabs import play
4
+ from app.core.logging import LoggerMixin
5
+ import os
6
+ import io
7
+
8
+ load_dotenv()
9
+
10
+ class TTSService(LoggerMixin):
11
+ def __init__(self):
12
+ self.api_key = os.getenv("ELEVEN_LABS_API_KEY")
13
+ if not self.api_key:
14
+ self.logger.error("ELEVEN_LABS_API_KEY not found in environment variables")
15
+ raise ValueError("ELEVEN_LABS_API_KEY is required")
16
+
17
+ self.client = ElevenLabs(api_key=self.api_key)
18
+
19
+ def convert_text_to_speech(self, text: str) -> bytes:
20
+ """Convert text to speech using ElevenLabs"""
21
+ audio_stream = self.client.text_to_speech.convert(
22
+ text=text,
23
+ voice_id="JBFqnCBsd6RMkjVDRZzb",
24
+ model_id="eleven_multilingual_v2",
25
+ output_format="mp3_44100_128",
26
+ )
27
+
28
+ # Convert the generator to bytes
29
+ buffer = io.BytesIO()
30
+ for chunk in audio_stream:
31
+ buffer.write(chunk)
32
+ return buffer.getvalue()
app/utils/file_management.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ import json
3
+ import shutil
4
+ from typing import Dict, Any
5
+ from app.core.logging import LoggerMixin
6
+
7
+ class FileManager(LoggerMixin):
8
+ BASE_DATA_DIR = Path("data")
9
+ DEFAULT_DIR = BASE_DATA_DIR / "default"
10
+
11
+ @classmethod
12
+ def ensure_directories(cls) -> None:
13
+ """Ensure all required directories exist"""
14
+ cls.BASE_DATA_DIR.mkdir(exist_ok=True)
15
+ cls.DEFAULT_DIR.mkdir(exist_ok=True)
16
+
17
+ @classmethod
18
+ def create_session_directory(cls, session_id: str) -> Path:
19
+ """Create a new directory for the session"""
20
+ session_dir = cls.BASE_DATA_DIR / session_id
21
+ session_dir.mkdir(exist_ok=True)
22
+ return session_dir
23
+
24
+ @classmethod
25
+ def save_session_data(cls, session_id: str, names: Dict, player_details: Dict, wagons: Dict) -> None:
26
+ """Save the three main data files for a session"""
27
+ session_dir = cls.create_session_directory(session_id)
28
+ logger = cls.get_logger()
29
+
30
+ # Save each file
31
+ files_to_save = {
32
+ "names.json": names,
33
+ "player_details.json": player_details,
34
+ "wagons.json": wagons
35
+ }
36
+
37
+ for filename, data in files_to_save.items():
38
+ file_path = session_dir / filename
39
+ cls.save_json(file_path, data)
40
+ logger.info(f"Saved session data | session_id={session_id} | filename={filename} | path={file_path}")
41
+
42
+ @classmethod
43
+ def get_data_directory(cls, session_id: str, default_game: bool) -> Path:
44
+ """Get the appropriate data directory based on default_game flag"""
45
+ if default_game:
46
+ return cls.DEFAULT_DIR
47
+ return cls.BASE_DATA_DIR / session_id
48
+
49
+ @classmethod
50
+ def load_session_data(cls, session_id: str, default_game: bool = True) -> tuple[Dict, Dict, Dict]:
51
+ """Load all data files for a session"""
52
+ logger = cls.get_logger()
53
+ data_dir = cls.get_data_directory(session_id, default_game)
54
+
55
+ if not data_dir.exists():
56
+ logger.error(f"Data directory not found | session_id={session_id} | directory={data_dir}")
57
+ raise FileNotFoundError(f"No data found for session {session_id}")
58
+
59
+ try:
60
+ names = cls.load_json(data_dir / "names.json")
61
+ player_details = cls.load_json(data_dir / "player_details.json")
62
+ wagons = cls.load_json(data_dir / "wagons.json")
63
+
64
+ logger.info(
65
+ f"Loaded session data | session_id={session_id} | "
66
+ f"source={'default' if default_game else 'session'} | "
67
+ f"directory={data_dir}"
68
+ )
69
+ return names, player_details, wagons
70
+
71
+ except FileNotFoundError as e:
72
+ logger.error(f"Failed to load files | session_id={session_id} | directory={data_dir} | error={str(e)}")
73
+ raise FileNotFoundError(f"Missing required data files in {data_dir}")
74
+
75
+ @staticmethod
76
+ def save_json(file_path: Path, data: Dict[str, Any]) -> None:
77
+ """Save data to a JSON file"""
78
+ with open(file_path, 'w') as f:
79
+ json.dump(data, f, indent=2)
80
+
81
+ @staticmethod
82
+ def load_json(file_path: Path) -> Dict:
83
+ """Load data from a JSON file"""
84
+ with open(file_path, 'r') as f:
85
+ return json.load(f)
data/default/names.json ADDED
@@ -0,0 +1,270 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "wagonId": "wagon-0",
4
+ "players": []
5
+ },
6
+ {
7
+ "wagonId": "wagon-1",
8
+ "players": [
9
+ {
10
+ "playerId": "player-1",
11
+ "firstName": "James",
12
+ "lastName": "Sterling",
13
+ "sex": "male",
14
+ "fullName": "James Sterling"
15
+ },
16
+ {
17
+ "playerId": "player-2",
18
+ "firstName": "Elizabeth",
19
+ "lastName": "Chen",
20
+ "sex": "female",
21
+ "fullName": "Elizabeth Chen"
22
+ },
23
+ {
24
+ "playerId": "player-3",
25
+ "firstName": "Marcus",
26
+ "lastName": "Thompson",
27
+ "sex": "male",
28
+ "fullName": "Marcus Thompson"
29
+ },
30
+ {
31
+ "playerId": "player-4",
32
+ "firstName": "Sofia",
33
+ "lastName": "Rodriguez",
34
+ "sex": "female",
35
+ "fullName": "Sofia Rodriguez"
36
+ }
37
+ ]
38
+ },
39
+ {
40
+ "wagonId": "wagon-2",
41
+ "players": [
42
+ {
43
+ "playerId": "player-1",
44
+ "firstName": "Adrian",
45
+ "lastName": "North",
46
+ "sex": "male",
47
+ "fullName": "Eleanor Brooks"
48
+ },
49
+ {
50
+ "playerId": "player-2",
51
+ "firstName": "Elena",
52
+ "lastName": "Stone",
53
+ "sex": "female",
54
+ "fullName": "Elena Stone"
55
+ },
56
+ {
57
+ "playerId": "player-3",
58
+ "firstName": "Lucas",
59
+ "lastName": "West",
60
+ "sex": "male",
61
+ "fullName": "Lucas West"
62
+ },
63
+ {
64
+ "playerId": "player-4",
65
+ "firstName": "Sophia",
66
+ "lastName": "East",
67
+ "sex": "female",
68
+ "fullName": "Sophia East"
69
+ }
70
+ ]
71
+ },
72
+ {
73
+ "wagonId": "wagon-3",
74
+ "players": [
75
+ {
76
+ "playerId": "player-1",
77
+ "firstName": "Captain Elara",
78
+ "lastName": "Voss",
79
+ "sex": "female",
80
+ "fullName": "Captain Elara Voss"
81
+ },
82
+ {
83
+ "playerId": "player-2",
84
+ "firstName": "Dr. Orion",
85
+ "lastName": "Kane",
86
+ "sex": "male",
87
+ "fullName": "Dr. Orion Kane"
88
+ },
89
+ {
90
+ "playerId": "player-3",
91
+ "firstName": "Lieutenant Nova",
92
+ "lastName": "Sterling",
93
+ "sex": "female",
94
+ "fullName": "Lieutenant Nova Sterling"
95
+ },
96
+ {
97
+ "playerId": "player-4",
98
+ "firstName": "Professor Atlas",
99
+ "lastName": "Grey",
100
+ "sex": "male",
101
+ "fullName": "Professor Atlas Grey"
102
+ }
103
+ ]
104
+ },
105
+ {
106
+ "wagonId": "wagon-4",
107
+ "players": [
108
+ {
109
+ "playerId": "player-1",
110
+ "firstName": "General Victor",
111
+ "lastName": "Blackwood",
112
+ "sex": "male",
113
+ "fullName": "General Victor Blackwood"
114
+ },
115
+ {
116
+ "playerId": "player-2",
117
+ "firstName": "Lady Isabella",
118
+ "lastName": "Sterling",
119
+ "sex": "female",
120
+ "fullName": "Lady Isabella Sterling"
121
+ },
122
+ {
123
+ "playerId": "player-3",
124
+ "firstName": "Sergeant Marcus",
125
+ "lastName": "Thorne",
126
+ "sex": "male",
127
+ "fullName": "Noah Davis"
128
+ },
129
+ {
130
+ "playerId": "player-4",
131
+ "firstName": "Dr. Charlotte",
132
+ "lastName": "Hartley",
133
+ "sex": "female",
134
+ "fullName": "Dr. Charlotte Hartley"
135
+ }
136
+ ]
137
+ },
138
+ {
139
+ "wagonId": "wagon-5",
140
+ "players": [
141
+ {
142
+ "playerId": "player-1",
143
+ "firstName": "Maestro Lorenzo",
144
+ "lastName": "Rossi",
145
+ "sex": "male",
146
+ "fullName": "Maestro Lorenzo Rossi"
147
+ },
148
+ {
149
+ "playerId": "player-2",
150
+ "firstName": "Isabella",
151
+ "lastName": "Valentina",
152
+ "sex": "female",
153
+ "fullName": "Isabella Valentina"
154
+ },
155
+ {
156
+ "playerId": "player-3",
157
+ "firstName": "Leonardo",
158
+ "lastName": "Di Marco",
159
+ "sex": "male",
160
+ "fullName": "Leonardo Di Marco"
161
+ },
162
+ {
163
+ "playerId": "player-4",
164
+ "firstName": "Sofia",
165
+ "lastName": "Caruso",
166
+ "sex": "female",
167
+ "fullName": "Sofia Caruso"
168
+ }
169
+ ]
170
+ },
171
+ {
172
+ "wagonId": "wagon-6",
173
+ "players": [
174
+ {
175
+ "playerId": "player-1",
176
+ "firstName": "Leo 'Roar'",
177
+ "lastName": "Johnson",
178
+ "sex": "male",
179
+ "fullName": "Leo 'Roar' Johnson"
180
+ },
181
+ {
182
+ "playerId": "player-2",
183
+ "firstName": "Lila 'Nightingale'",
184
+ "lastName": "Silva",
185
+ "sex": "female",
186
+ "fullName": "Lady Olivia Wright"
187
+ },
188
+ {
189
+ "playerId": "player-3",
190
+ "firstName": "Rafael 'Panther'",
191
+ "lastName": "Martinez",
192
+ "sex": "male",
193
+ "fullName": "Rafael 'Panther' Martinez"
194
+ },
195
+ {
196
+ "playerId": "player-4",
197
+ "firstName": "Elena 'Jaguar'",
198
+ "lastName": "Rodriguez",
199
+ "sex": "female",
200
+ "fullName": "Elena 'Jaguar' Rodriguez"
201
+ }
202
+ ]
203
+ },
204
+ {
205
+ "wagonId": "wagon-7",
206
+ "players": [
207
+ {
208
+ "playerId": "player-1",
209
+ "firstName": "Marco",
210
+ "lastName": "Valentino",
211
+ "sex": "male",
212
+ "fullName": "Marco Valentino"
213
+ },
214
+ {
215
+ "playerId": "player-2",
216
+ "firstName": "Giovanna",
217
+ "lastName": "Rosalia",
218
+ "sex": "female",
219
+ "fullName": "Giovanna Rosalia"
220
+ },
221
+ {
222
+ "playerId": "player-3",
223
+ "firstName": "Father",
224
+ "lastName": "Lorenzo",
225
+ "sex": "male",
226
+ "fullName": "Father Lorenzo"
227
+ },
228
+ {
229
+ "playerId": "player-4",
230
+ "firstName": "Luca",
231
+ "lastName": "Montefiori",
232
+ "sex": "male",
233
+ "fullName": "Luca Montefiori"
234
+ }
235
+ ]
236
+ },
237
+ {
238
+ "wagonId": "wagon-8",
239
+ "players": [
240
+ {
241
+ "playerId": "player-1",
242
+ "firstName": "Eleanor",
243
+ "lastName": "Voss",
244
+ "sex": "male",
245
+ "fullName": "Eleanor Voss"
246
+ },
247
+ {
248
+ "playerId": "player-2",
249
+ "firstName": "Theadore",
250
+ "lastName": "Kane",
251
+ "sex": "female",
252
+ "fullName": "Theadore Kane"
253
+ },
254
+ {
255
+ "playerId": "player-3",
256
+ "firstName": "Victoria",
257
+ "lastName": "Sterling",
258
+ "sex": "female",
259
+ "fullName": "Victoria Sterling"
260
+ },
261
+ {
262
+ "playerId": "player-4",
263
+ "firstName": "Edgar",
264
+ "lastName": "Grey",
265
+ "sex": "male",
266
+ "fullName": "Edgar Grey"
267
+ }
268
+ ]
269
+ }
270
+ ]
data/default/player_details.json ADDED
@@ -0,0 +1,398 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "wagonId": "wagon-0",
4
+ "players": []
5
+ },
6
+ {
7
+ "wagonId": "wagon-1",
8
+ "players": [
9
+ {
10
+ "playerId": "player-1",
11
+ "profile": {
12
+ "name": "James Sterling",
13
+ "age": 42,
14
+ "profession": "Investment Banker",
15
+ "personality": "Ambitious and calculating",
16
+ "role": "A successful banker with questionable ethics",
17
+ "mystery_intrigue": "Has been manipulating gold prices for personal gain"
18
+ }
19
+ },
20
+ {
21
+ "playerId": "player-2",
22
+ "profile": {
23
+ "name": "Elizabeth Chen",
24
+ "age": 35,
25
+ "profession": "Financial Analyst",
26
+ "personality": "Sharp-minded and detail-oriented",
27
+ "role": "Whistleblower investigating financial fraud",
28
+ "mystery_intrigue": "Has evidence of major market manipulation"
29
+ }
30
+ },
31
+ {
32
+ "playerId": "player-3",
33
+ "profile": {
34
+ "name": "Marcus Thompson",
35
+ "age": 45,
36
+ "profession": "Securities Regulator",
37
+ "personality": "Stern and methodical",
38
+ "role": "Investigating suspicious trading patterns",
39
+ "mystery_intrigue": "Close to uncovering a major financial scandal"
40
+ }
41
+ },
42
+ {
43
+ "playerId": "player-4",
44
+ "profile": {
45
+ "name": "Sofia Rodriguez",
46
+ "age": 38,
47
+ "profession": "Corporate Lawyer",
48
+ "personality": "Shrewd and diplomatic",
49
+ "role": "Legal counsel involved in the investigation",
50
+ "mystery_intrigue": "May be working for both sides"
51
+ }
52
+ }
53
+ ]
54
+ },
55
+ {
56
+ "wagonId": "wagon-2",
57
+ "players": [
58
+ {
59
+ "playerId": "player-1",
60
+ "profile": {
61
+ "name": "Adrian North",
62
+ "age": 38,
63
+ "profession": "Adventurer and Cartographer",
64
+ "personality": "Brave, curious, and determined",
65
+ "role": "Leads the expedition through uncharted territories, using an antique navigation tool with a unique magnetic needle.",
66
+ "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."
67
+ }
68
+ },
69
+ {
70
+ "playerId": "player-2",
71
+ "profile": {
72
+ "name": "Elena Stone",
73
+ "age": 32,
74
+ "profession": "Archaeologist",
75
+ "personality": "Intelligent, meticulous, and resourceful",
76
+ "role": "Studies ancient artifacts found along the journey, including a small, intricately designed metal disk.",
77
+ "mystery_intrigue": "Has discovered clues suggesting the existence of a lost civilization, but keeps this information secret from the team."
78
+ }
79
+ },
80
+ {
81
+ "playerId": "player-3",
82
+ "profile": {
83
+ "name": "Lucas West",
84
+ "age": 28,
85
+ "profession": "Botanist",
86
+ "personality": "Analytical, patient, and nurturing",
87
+ "role": "Identifies and documents new plant species, often seen with a small, engraved metal case containing rare seeds.",
88
+ "mystery_intrigue": "Finds a rare plant that has healing properties, but its location is marked by a peculiar symbol that resembles a directional pointer."
89
+ }
90
+ },
91
+ {
92
+ "playerId": "player-4",
93
+ "profile": {
94
+ "name": "Sophia East",
95
+ "age": 35,
96
+ "profession": "Linguist",
97
+ "personality": "Insightful, empathetic, and communicative",
98
+ "role": "Deciphers ancient languages and symbols encountered during the expedition, carries a small, ornate metal pendant.",
99
+ "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."
100
+ }
101
+ }
102
+ ]
103
+ },
104
+ {
105
+ "wagonId": "wagon-3",
106
+ "players": [
107
+ {
108
+ "playerId": "player-1",
109
+ "profile": {
110
+ "name": "Captain Elara Voss",
111
+ "age": 40,
112
+ "profession": "Submarine Captain",
113
+ "personality": "Courageous, strategic, and decisive",
114
+ "role": "Leads the expedition with an advanced submarine equipped with a unique spiral-shaped hull design.",
115
+ "mystery_intrigue": "Hiding a personal vendetta against a rival exploration company, which may influence her decisions and put the crew in danger."
116
+ }
117
+ },
118
+ {
119
+ "playerId": "player-2",
120
+ "profile": {
121
+ "name": "Dr. Orion Kane",
122
+ "age": 35,
123
+ "profession": "Marine Archaeologist",
124
+ "personality": "Intellectual, curious, and meticulous",
125
+ "role": "Driven by the mystery of the underwater civilization, studies ancient maritime artifacts including a spiral-shaped pendant.",
126
+ "mystery_intrigue": "Has discovered a hidden artifact that could revolutionize their understanding of the underwater civilization, but keeps it secret."
127
+ }
128
+ },
129
+ {
130
+ "playerId": "player-3",
131
+ "profile": {
132
+ "name": "Lieutenant Nova Sterling",
133
+ "age": 30,
134
+ "profession": "Naval Engineer",
135
+ "personality": "Innovative, resourceful, and practical",
136
+ "role": "Ensures the submarine's systems are operational, monitors signals received from the surface.",
137
+ "mystery_intrigue": "Receiving encrypted messages from an unknown source, warning her about an impending danger within the crew."
138
+ }
139
+ },
140
+ {
141
+ "playerId": "player-4",
142
+ "profile": {
143
+ "name": "Professor Atlas Grey",
144
+ "age": 45,
145
+ "profession": "Oceanographer",
146
+ "personality": "Wise, patient, and analytical",
147
+ "role": "Provides scientific context for the underwater environment, possesses an old engraved timepiece.",
148
+ "mystery_intrigue": "Has uncovered evidence of a dangerous underwater creature that could threaten the expedition, but keeps this information to himself."
149
+ }
150
+ }
151
+ ]
152
+ },
153
+ {
154
+ "wagonId": "wagon-4",
155
+ "players": [
156
+ {
157
+ "playerId": "player-1",
158
+ "profile": {
159
+ "name": "General Victor Blackwood",
160
+ "age": 55,
161
+ "profession": "Retired Military Commander",
162
+ "personality": "Honorable, stern, and strategic",
163
+ "role": "Leads a group of veterans on a quest to uncover the truth behind a legendary battle, carries an old, tattered map.",
164
+ "mystery_intrigue": "Haunted by a past decision that led to a devastating defeat, seeks redemption and closure."
165
+ }
166
+ },
167
+ {
168
+ "playerId": "player-2",
169
+ "profile": {
170
+ "name": "Lady Isabella Sterling",
171
+ "age": 40,
172
+ "profession": "Historian",
173
+ "personality": "Intelligent, empathetic, and determined",
174
+ "role": "Documents the journey and interprets historical artifacts, including an antique pocket watch.",
175
+ "mystery_intrigue": "Discovers letters hinting at a secret alliance that could have changed the course of the battle, but keeps this information hidden."
176
+ }
177
+ },
178
+ {
179
+ "playerId": "player-3",
180
+ "profile": {
181
+ "name": "Sergeant Marcus Thorne",
182
+ "age": 38,
183
+ "profession": "Former Infantryman",
184
+ "personality": "Loyal, brave, and resilient",
185
+ "role": "Provides security and support for the expedition, carries a worn, engraved flask.",
186
+ "mystery_intrigue": "Experiences vivid dreams of the battle, revealing clues about a hidden treasure, but fears being seen as unstable."
187
+ }
188
+ },
189
+ {
190
+ "playerId": "player-4",
191
+ "profile": {
192
+ "name": "Dr. Charlotte Hartley",
193
+ "age": 35,
194
+ "profession": "Archaeologist",
195
+ "personality": "Curious, methodical, and analytical",
196
+ "role": "Excavates and studies battlefield remnants, including an old, mud-caked musket ball.",
197
+ "mystery_intrigue": "Uncovers evidence of a cover-up involving high-ranking officers, but struggles with the ethical implications of revealing the truth."
198
+ }
199
+ }
200
+ ]
201
+ },
202
+ {
203
+ "wagonId": "wagon-5",
204
+ "players": [
205
+ {
206
+ "playerId": "player-1",
207
+ "profile": {
208
+ "name": "Maestro Lorenzo Rossi",
209
+ "age": 58,
210
+ "profession": "Conductor",
211
+ "personality": "Passionate, perfectionistic, and charismatic",
212
+ "role": "Leads a renowned orchestra, known for his innovative interpretations of classical pieces, especially those with intricate compositions.",
213
+ "mystery_intrigue": "Hides a rare, unpublished manuscript that could redefine musical history, but fears the controversy it might cause."
214
+ }
215
+ },
216
+ {
217
+ "playerId": "player-2",
218
+ "profile": {
219
+ "name": "Isabella Valentina",
220
+ "age": 32,
221
+ "profession": "Violinist",
222
+ "personality": "Talented, expressive, and dedicated",
223
+ "role": "First chair violinist, captivates audiences with her emotional performances, often featuring complex, lively melodies.",
224
+ "mystery_intrigue": "Possesses an antique violin with a mysterious inscription, hinting at a hidden composition of immense significance."
225
+ }
226
+ },
227
+ {
228
+ "playerId": "player-3",
229
+ "profile": {
230
+ "name": "Leonardo Di Marco",
231
+ "age": 45,
232
+ "profession": "Composer",
233
+ "personality": "Creative, introspective, and visionary",
234
+ "role": "Creates modern compositions inspired by classical themes, known for his ability to blend old and new musical styles.",
235
+ "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."
236
+ }
237
+ },
238
+ {
239
+ "playerId": "player-4",
240
+ "profile": {
241
+ "name": "Sofia Caruso",
242
+ "age": 28,
243
+ "profession": "Opera Singer",
244
+ "personality": "Graceful, confident, and melodious",
245
+ "role": "Enchants audiences with her powerful vocals, specializing in arias that require exceptional range and technique.",
246
+ "mystery_intrigue": "Finds a hidden letter revealing a secret affair between two famous musicians, which could scandalize the musical world."
247
+ }
248
+ }
249
+ ]
250
+ },
251
+ {
252
+ "wagonId": "wagon-6",
253
+ "players": [
254
+ {
255
+ "playerId": "player-1",
256
+ "profile": {
257
+ "name": "Leo 'Roar' Johnson",
258
+ "age": 42,
259
+ "profession": "Jazz Musician (Saxophonist)",
260
+ "personality": "Charismatic, bold, and innovative",
261
+ "role": "Leads a jazz band known for its wild, energetic performances, often featuring improvisations that evoke the sounds of the jungle.",
262
+ "mystery_intrigue": "Possesses a rare, antique saxophone with engravings of a mysterious jungle creature, hinting at a hidden treasure deep within the rainforest."
263
+ }
264
+ },
265
+ {
266
+ "playerId": "player-2",
267
+ "profile": {
268
+ "name": "Lila 'Nightingale' Silva",
269
+ "age": 35,
270
+ "profession": "Jazz Singer",
271
+ "personality": "Melodious, enchanting, and free-spirited",
272
+ "role": "Captivates audiences with her soulful vocals, incorporating exotic bird songs and nature-inspired melodies into her performances.",
273
+ "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."
274
+ }
275
+ },
276
+ {
277
+ "playerId": "player-3",
278
+ "profile": {
279
+ "name": "Rafael 'Panther' Martinez",
280
+ "age": 38,
281
+ "profession": "Percussionist",
282
+ "personality": "Rhythmic, intense, and intuitive",
283
+ "role": "Drives the band's rhythm section with primal beats and complex patterns, using instruments crafted from jungle materials.",
284
+ "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."
285
+ }
286
+ },
287
+ {
288
+ "playerId": "player-4",
289
+ "profile": {
290
+ "name": "Elena 'Jaguar' Rodriguez",
291
+ "age": 30,
292
+ "profession": "Pianist",
293
+ "personality": "Elegant, fierce, and expressive",
294
+ "role": "Enthralls listeners with her dynamic piano solos, blending classical technique with the raw energy of the jungle.",
295
+ "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."
296
+ }
297
+ }
298
+ ]
299
+ },
300
+ {
301
+ "wagonId": "wagon-7",
302
+ "players": [
303
+ {
304
+ "playerId": "player-1",
305
+ "profile": {
306
+ "name": "Marco Valentino",
307
+ "age": 48,
308
+ "profession": "Wealthy Merchant",
309
+ "personality": "Ambitious, shrewd, and guarded",
310
+ "role": "Head of the influential Valentino family, embroiled in a long-standing rivalry with another powerful clan, carries an heirloom signet ring with a distinctive crest.",
311
+ "mystery_intrigue": "Haunted by a past betrayal that deepened the feud, seeks revenge while hiding a secret that could unite the families."
312
+ }
313
+ },
314
+ {
315
+ "playerId": "player-2",
316
+ "profile": {
317
+ "name": "Giovanna Rosalia",
318
+ "age": 42,
319
+ "profession": "Noblewoman",
320
+ "personality": "Elegant, wise, and protective",
321
+ "role": "Matriarch of the Rosalia family, desires peace and unity, often seen with an antique brooch featuring intertwined roses and thorns.",
322
+ "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."
323
+ }
324
+ },
325
+ {
326
+ "playerId": "player-3",
327
+ "profile": {
328
+ "name": "Father Lorenzo",
329
+ "age": 50,
330
+ "profession": "Priest",
331
+ "personality": "Compassionate, insightful, and resourceful",
332
+ "role": "Counsels both families and secretly aids the young lovers, carries a rosary with a unique heart-shaped pendant.",
333
+ "mystery_intrigue": "Knows of a hidden sanctuary where the lovers can meet, but fears the consequences of defying the powerful families."
334
+ }
335
+ },
336
+ {
337
+ "playerId": "player-4",
338
+ "profile": {
339
+ "name": "Luca Montefiori",
340
+ "age": 35,
341
+ "profession": "Nobleman",
342
+ "personality": "Charismatic, determined, and proud",
343
+ "role": "Heir to the Montefiori fortune, seeks to marry into the Rosalia family for political gain, possesses a dagger with an engraved family crest.",
344
+ "mystery_intrigue": "Uncovers a plot to sabotage the upcoming wedding, which could reignite the feud and plunge the city into chaos."
345
+ }
346
+ }
347
+ ]
348
+ },
349
+ {
350
+ "wagonId": "wagon-8",
351
+ "players": [
352
+ {
353
+ "playerId": "player-1",
354
+ "profile": {
355
+ "name": "Eleanor Voss",
356
+ "age": 38,
357
+ "profession": "Inventor",
358
+ "personality": "Creative, determined, and analytical",
359
+ "role": "Develops innovative sound recording devices, including a prototype with a unique cylinder design.",
360
+ "mystery_intrigue": "Discovers hidden recordings on an old cylinder that hint at a secret society communicating through coded messages."
361
+ }
362
+ },
363
+ {
364
+ "playerId": "player-2",
365
+ "profile": {
366
+ "name": "Theodore Kane",
367
+ "age": 42,
368
+ "profession": "Engineer",
369
+ "personality": "Practical, resourceful, and meticulous",
370
+ "role": "Specializes in mechanical engineering, often seen working on intricate gears and components for sound devices.",
371
+ "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."
372
+ }
373
+ },
374
+ {
375
+ "playerId": "player-3",
376
+ "profile": {
377
+ "name": "Victoria Sterling",
378
+ "age": 35,
379
+ "profession": "Musician",
380
+ "personality": "Passionate, expressive, and innovative",
381
+ "role": "Experiments with new musical instruments and recording techniques, carries a small, engraved metal case containing rare cylinders.",
382
+ "mystery_intrigue": "Receives anonymous letters containing cryptic messages and musical notes, hinting at a hidden melody with mysterious powers."
383
+ }
384
+ },
385
+ {
386
+ "playerId": "player-4",
387
+ "profile": {
388
+ "name": "Edgar Grey",
389
+ "age": 40,
390
+ "profession": "Detective",
391
+ "personality": "Observant, tenacious, and intuitive",
392
+ "role": "Investigates cases of industrial espionage and stolen inventions, possesses a pocket watch with an engraved symbol.",
393
+ "mystery_intrigue": "Uncovers a plot to steal advanced sound recording technology, but struggles to identify the mastermind behind the scheme."
394
+ }
395
+ }
396
+ ]
397
+ }
398
+ ]
data/default/wagons.json ADDED
@@ -0,0 +1,288 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "id": 0,
4
+ "theme": "Tutorial (Start)",
5
+ "passcode": "start",
6
+ "people": []
7
+ },
8
+ {
9
+ "id": 1,
10
+ "theme": "A Flourishing Business",
11
+ "passcode": "gold",
12
+ "people": [
13
+ {
14
+ "uid": "wagon-1-player-1",
15
+ "position": [0.5, 0.5],
16
+ "rotation": 0,
17
+ "model_type": "character-male-e",
18
+ "items": []
19
+ },
20
+ {
21
+ "uid": "wagon-1-player-2",
22
+ "position": [0.25, 0.25],
23
+ "rotation": 0,
24
+ "model_type": "character-female-f",
25
+ "items": []
26
+ },
27
+ {
28
+ "uid": "wagon-1-player-3",
29
+ "position": [0.75, 0.55],
30
+ "rotation": 0,
31
+ "model_type": "character-male-c",
32
+ "items": []
33
+ },
34
+ {
35
+ "uid": "wagon-1-player-4",
36
+ "position": [0.75, 0.65],
37
+ "rotation": 0,
38
+ "model_type": "character-female-e",
39
+ "items": []
40
+ }
41
+ ]
42
+ },
43
+ {
44
+ "id": 2,
45
+ "theme": "Off the Map",
46
+ "passcode": "aztec",
47
+ "people": [
48
+ {
49
+ "uid": "wagon-2-player-1",
50
+ "position": [0.5, 0.5],
51
+ "rotation": 0,
52
+ "model_type": "character-male-e",
53
+ "items": []
54
+ },
55
+ {
56
+ "uid": "wagon-2-player-2",
57
+ "position": [0.25, 0.25],
58
+ "rotation": 0,
59
+ "model_type": "character-female-e",
60
+ "items": ["blaster", "cone"]
61
+ },
62
+ {
63
+ "uid": "wagon-2-player-3",
64
+ "position": [0.75, 0.55],
65
+ "rotation": 0,
66
+ "model_type": "character-male-e",
67
+ "items": []
68
+ },
69
+ {
70
+ "uid": "wagon-2-player-4",
71
+ "position": [0.75, 0.65],
72
+ "rotation": 0,
73
+ "model_type": "character-female-f",
74
+ "items": []
75
+ }
76
+ ]
77
+ },
78
+ {
79
+ "id": 3,
80
+ "theme": "Ving Mille Lieues Sous Les Mers",
81
+ "passcode": "nemo",
82
+ "people": [
83
+ {
84
+ "uid": "wagon-3-player-1",
85
+ "position": [0.5, 0.5],
86
+ "rotation": 0,
87
+ "model_type": "character-female-d",
88
+ "items": []
89
+ },
90
+ {
91
+ "uid": "wagon-3-player-2",
92
+ "position": [0.25, 0.25],
93
+ "rotation": 0,
94
+ "model_type": "character-male-d",
95
+ "items": []
96
+ },
97
+ {
98
+ "uid": "wagon-3-player-3",
99
+ "position": [0.75, 0.55],
100
+ "rotation": 0,
101
+ "model_type": "character-female-b",
102
+ "items": []
103
+ },
104
+ {
105
+ "uid": "wagon-3-player-4",
106
+ "position": [0.75, 0.65],
107
+ "rotation": 0,
108
+ "model_type": "character-male-c",
109
+ "items": []
110
+ }
111
+ ]
112
+ },
113
+ {
114
+ "id": 4,
115
+ "theme": "Journey to the Future",
116
+ "passcode": "future",
117
+ "people": [
118
+ {
119
+ "uid": "wagon-4-player-1",
120
+ "position": [0.5, 0.5],
121
+ "rotation": 0,
122
+ "model_type": "character-male-a",
123
+ "items": []
124
+ },
125
+ {
126
+ "uid": "wagon-4-player-2",
127
+ "position": [0.25, 0.25],
128
+ "rotation": 0,
129
+ "model_type": "character-female-a",
130
+ "items": []
131
+ },
132
+ {
133
+ "uid": "wagon-4-player-3",
134
+ "position": [0.75, 0.55],
135
+ "rotation": 0,
136
+ "model_type": "character-male-f",
137
+ "items": []
138
+ },
139
+ {
140
+ "uid": "wagon-4-player-4",
141
+ "position": [0.75, 0.65],
142
+ "rotation": 0,
143
+ "model_type": "character-female-b",
144
+ "items": []
145
+ }
146
+ ]
147
+ },
148
+ {
149
+ "id": 5,
150
+ "theme": "The Grand Symphony",
151
+ "passcode": "melody",
152
+ "people": [
153
+ {
154
+ "uid": "wagon-5-player-1",
155
+ "position": [0.5, 0.5],
156
+ "rotation": 0,
157
+ "model_type": "character-male-b",
158
+ "items": []
159
+ },
160
+ {
161
+ "uid": "wagon-5-player-2",
162
+ "position": [0.3, 0.4],
163
+ "rotation": 0,
164
+ "model_type": "character-female-d",
165
+ "items": []
166
+ },
167
+ {
168
+ "uid": "wagon-5-player-3",
169
+ "position": [0.7, 0.6],
170
+ "rotation": 0,
171
+ "model_type": "character-male-d",
172
+ "items": []
173
+ },
174
+ {
175
+ "uid": "wagon-5-player-4",
176
+ "position": [0.6, 0.8],
177
+ "rotation": 0,
178
+ "model_type": "character-female-c",
179
+ "items": []
180
+ }
181
+ ]
182
+ },
183
+ {
184
+ "id": 6,
185
+ "theme": "Jungle Jazz",
186
+ "passcode": "jungle",
187
+ "people": [
188
+ {
189
+ "uid": "wagon-6-player-1",
190
+ "position": [0.5, 0.5],
191
+ "rotation": 0,
192
+ "model_type": "character-male-f",
193
+ "items": []
194
+ },
195
+ {
196
+ "uid": "wagon-6-player-2",
197
+ "position": [0.25, 0.4],
198
+ "rotation": 0,
199
+ "model_type": "character-female-a",
200
+ "items": []
201
+ },
202
+ {
203
+ "uid": "wagon-6-player-3",
204
+ "position": [0.7, 0.55],
205
+ "rotation": 0,
206
+ "model_type": "character-male-e",
207
+ "items": []
208
+ },
209
+ {
210
+ "uid": "wagon-6-player-4",
211
+ "position": [0.77, 0.65],
212
+ "rotation": 0,
213
+ "model_type": "character-female-f",
214
+ "items": []
215
+ }
216
+ ]
217
+ },
218
+ {
219
+ "id": 7,
220
+ "theme": "Feuding Families",
221
+ "passcode": "rose",
222
+ "people": [
223
+ {
224
+ "uid": "wagon-7-player-1",
225
+ "position": [0.45, 0.5],
226
+ "rotation": 0,
227
+ "model_type": "character-male-d",
228
+ "items": []
229
+ },
230
+ {
231
+ "uid": "wagon-7-player-2",
232
+ "position": [0.3, 0.25],
233
+ "rotation": 0,
234
+ "model_type": "character-female-f",
235
+ "items": []
236
+ },
237
+ {
238
+ "uid": "wagon-7-player-3",
239
+ "position": [0.75, 0.5],
240
+ "rotation": 0,
241
+ "model_type": "character-male-c",
242
+ "items": []
243
+ },
244
+ {
245
+ "uid": "wagon-7-player-4",
246
+ "position": [0.75, 0.65],
247
+ "rotation": 0,
248
+ "model_type": "character-male-b",
249
+ "items": []
250
+ }
251
+ ]
252
+ },
253
+ {
254
+ "id": 8,
255
+ "theme": "Secrets on Record",
256
+ "passcode": "FREQUENCY",
257
+ "people": [
258
+ {
259
+ "uid": "wagon-8-player-1",
260
+ "position": [0.5, 0.5],
261
+ "rotation": 0,
262
+ "model_type": "character-female-e",
263
+ "items": []
264
+ },
265
+ {
266
+ "uid": "wagon-8-player-2",
267
+ "position": [0.25, 0.25],
268
+ "rotation": 0,
269
+ "model_type": "character-male-c",
270
+ "items": []
271
+ },
272
+ {
273
+ "uid": "wagon-8-player-3",
274
+ "position": [0.75, 0.55],
275
+ "rotation": 0,
276
+ "model_type": "character-female-b",
277
+ "items": []
278
+ },
279
+ {
280
+ "uid": "wagon-8-player-4",
281
+ "position": [0.75, 0.65],
282
+ "rotation": 0,
283
+ "model_type": "character-male-a",
284
+ "items": []
285
+ }
286
+ ]
287
+ }
288
+ ]
requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi[standard]>=0.104.0
2
+ uvicorn[standard]>=0.24.0
3
+ pydantic>=2.5.0
4
+ python-jose[cryptography]>=3.3.0
5
+ uuid>=1.30
6
+ mistralai>=0.0.7
7
+ python-dotenv>=1.0.0
8
+ langchain>=0.3.15
9
+ langchain-mistralai>=0.2.4
10
+ elevenlabs>=0.1.0
shell.nix ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ { pkgs ? import <nixpkgs> {} }:
2
+
3
+ pkgs.mkShell {
4
+ buildInputs = with pkgs; [
5
+ uv
6
+ ngrok
7
+ ];
8
+
9
+ shellHook = ''
10
+ uv --version
11
+ '';
12
+ }