feat(security): add an API-Key Mechanism
Browse files- Dockerfile +30 -0
- app/__init__.py +1 -0
- app/core/logging.py +75 -0
- app/main.py +163 -0
- app/models/__init__.py +0 -0
- app/models/session.py +45 -0
- app/models/train.py +51 -0
- app/prompts/__init__.py +3 -0
- app/prompts/guess_prompt.py +30 -0
- app/routes/__init__.py +3 -0
- app/routes/chat.py +226 -0
- app/routes/generate.py +69 -0
- app/routes/health.py +13 -0
- app/routes/players.py +207 -0
- app/routes/wagons.py +25 -0
- app/services/__init__.py +0 -0
- app/services/chat_service.py +174 -0
- app/services/generate_train/__init__.py +0 -0
- app/services/generate_train/convert.py +168 -0
- app/services/generate_train/generate_train.py +241 -0
- app/services/guess_service.py +70 -0
- app/services/scoring_service.py +51 -0
- app/services/session_service.py +273 -0
- app/services/tts_service.py +32 -0
- app/utils/file_management.py +85 -0
- data/default/names.json +270 -0
- data/default/player_details.json +398 -0
- data/default/wagons.json +288 -0
- requirements.txt +10 -0
- shell.nix +12 -0
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 |
+
}
|