diff --git a/.gitignore b/.gitignore index ce4b560b8..be4abf490 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,7 @@ coverage.xml *.py,cover .hypothesis/ .pytest_cache/ +tsconfig.tsbuildinfo # Translations *.mo @@ -165,9 +166,27 @@ docs/node_modules/* redis-data/* sotopia/cli/install/redis-data/* + +# Secrets and runtime files +*.pem +.backend.pid +.frontend.pid redis-stack-server-*/ *.rdb examples/experimental/negotiation_arena/redis-data/* *.rdb *.dot +sotopia-chatbot/ + + +# Internal / scratch docs (not committed) +internal-docs/ +.env.example +sotopia-chat/frontend/.env.local.example + +# Frontend deployment diagram (local reference only) +sotopia-chat/frontend/deployment-architecture +# Local dev scripts (optional, not committed) +scripts/start_local.sh +scripts/stop_local.sh diff --git a/examples/experimental/werewolves/config.json b/examples/experimental/werewolves/config.json index 345a94e4b..1e426043f 100644 --- a/examples/experimental/werewolves/config.json +++ b/examples/experimental/werewolves/config.json @@ -58,6 +58,7 @@ "actions": [ "action" ], + "action_order": "simultaneous", "visibility": "team" }, "Night_seer": { @@ -67,6 +68,7 @@ "actions": [ "action" ], + "action_order": "simultaneous", "visibility": "private" }, "Night_witch": { @@ -76,6 +78,7 @@ "actions": [ "action" ], + "action_order": "simultaneous", "visibility": "private", "internal_state": { "save_available": true, @@ -86,7 +89,7 @@ "actions": [ "speak" ], - "action_order": "round-robin", + "action_order": "simultaneous", "visibility": "public" }, "Day_vote": { diff --git a/examples/experimental/werewolves/main.py b/examples/experimental/werewolves/main.py index ec568ab82..3a6739420 100644 --- a/examples/experimental/werewolves/main.py +++ b/examples/experimental/werewolves/main.py @@ -7,8 +7,6 @@ from pathlib import Path import logging from typing import Any, Dict, List -import random -from collections import Counter from rich.logging import RichHandler import redis @@ -126,7 +124,7 @@ def handle_action( env.internal_state["votes"][agent_name] = target elif env.current_state == "Night_werewolf": - # Werewolves choose kill target + # Werewolves choose kill target (record proposals only; unanimity enforced in _should_transition_state) role = env.agent_to_role.get(agent_name, "") if role == "Werewolf" and action.action_type == "action": if "kill" in action.argument.lower(): @@ -139,22 +137,6 @@ def handle_action( if "kill_target_proposals" not in env.internal_state: env.internal_state["kill_target_proposals"] = {} env.internal_state["kill_target_proposals"][agent_name] = target - # Update the werewolf kill result - kill_votes = env.internal_state.get("kill_target_proposals", {}) - if kill_votes: - # Count votes - vote_counts = Counter(kill_votes.values()) - if vote_counts: - # Find max votes - max_votes = max(vote_counts.values()) - # Get all targets with max votes - candidates = [ - t for t, c in vote_counts.items() if c == max_votes - ] - # Break tie randomly - env.internal_state["kill_target"] = random.choice( - candidates - ) elif env.current_state == "Night_seer": # Seer inspects someone @@ -192,7 +174,7 @@ def handle_action( if target: env.internal_state["saved_target"] = target elif "poison" in action.argument.lower(): - env.internal_state["witch_have_posion"] = False + env.internal_state["witch_have_poison"] = False words = action.argument.split() target = next( (w for w in words if w[0].isupper() and w in env.agents), @@ -223,10 +205,10 @@ def get_action_instruction(self, env: SocialDeductionGame, agent_name: str) -> s elif env.current_state == "Night_witch": if role == "Witch": if env.internal_state.get( - "witch_have_posion", True + "witch_have_poison", True ) and env.internal_state.get("witch_have_save", True): use_potion_guide = "You can use 'save NAME' or 'poison NAME'. If you don't want to use potions, you can put 'skip' in the argument of action." - elif env.internal_state.get("witch_have_posion", True): + elif env.internal_state.get("witch_have_poison", True): use_potion_guide = "You can use 'poison NAME'. If you don't want to use potions, you can put 'skip' in the argument of action." elif env.internal_state.get("witch_have_save", True): use_potion_guide = "You can use 'save NAME'. If you don't want to use potions, you can put 'skip' in the argument of action." @@ -245,11 +227,34 @@ def get_action_instruction(self, env: SocialDeductionGame, agent_name: str) -> s class WerewolfEnv(SocialDeductionGame): - """Werewolf game with voting, kills, and special roles.""" + """Werewolf game with voting, kills, and special roles. + + Uses unanimity for werewolf kill target (both must pick the same player), + matching human play where wolves discuss and agree. + """ def __init__(self, **kwargs: Any) -> None: super().__init__(action_handler=WerewolfActionHandler(), **kwargs) + def _should_transition_state(self) -> bool: + """Require werewolf unanimity on kill target before transitioning.""" + if self.current_state == "Night_werewolf": + proposals = self.internal_state.get("kill_target_proposals", {}) or {} + alive_werewolves = [ + n for n, r in self.agent_to_role.items() + if r == "Werewolf" and self.agent_alive.get(n, False) + ] + if not alive_werewolves: + return True + if len(proposals) < len(alive_werewolves): + return False + targets = set(proposals.values()) + if len(targets) == 1: + self.internal_state["kill_target"] = next(iter(targets)) + return True + return False + return super()._should_transition_state() + def reset( self, seed: int | None = None, @@ -268,7 +273,7 @@ def reset( include_background_observations=include_background_observations, ) # Witch has potions - self.internal_state["witch_have_posion"] = True + self.internal_state["witch_have_poison"] = True self.internal_state["witch_have_save"] = True # Werewolves have kill targets self.internal_state["kill_target_proposals"] = {} diff --git a/pyproject.toml b/pyproject.toml index 3e620c09e..7f100f402 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,13 @@ dependencies = [ "pydantic>=2.5.0,<3.0.0", "hiredis>=3.0.0", "litellm>=1.80.11", - "aact" + "aact", + "uvicorn[standard]>=0.38.0", + "fastapi>=0.115.2", + "bcrypt>=4.0.0", + "PyJWT>=2.8.0", + "email-validator>=2.0.0", + "httpx>=0.25.0", # For OAuth HTTP requests ] [build-system] @@ -47,6 +53,11 @@ realtime = [ "websockets>=13.1,<14.0", "pyaudio>=0.2.14,<0.3.0", ] +postgres = [ + "asyncpg>=0.29.0", + "sqlalchemy>=2.0.0", + "psycopg2-binary>=2.9.0", +] [dependency-groups] dev = [ diff --git a/scripts/ec2/clear_ec2_caches.sh b/scripts/ec2/clear_ec2_caches.sh new file mode 100644 index 000000000..44a1968f6 --- /dev/null +++ b/scripts/ec2/clear_ec2_caches.sh @@ -0,0 +1,30 @@ +#!/bin/bash +# ============================================================================= +# Clear caches on EC2 for a clean rebuild +# Run on EC2: bash scripts/ec2/clear_ec2_caches.sh +# ============================================================================= + +set -e +cd /opt/sotopia + +echo "Clearing caches..." + +# Python +rm -rf .venv +find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true +find . -type f -name "*.pyc" -delete 2>/dev/null || true +rm -rf .pytest_cache 2>/dev/null || true + +# Frontend +rm -rf sotopia-chat/frontend/node_modules +rm -rf sotopia-chat/frontend/.next +rm -rf sotopia-chat/frontend/.turbo 2>/dev/null || true +rm -rf sotopia-chat/frontend/out 2>/dev/null || true + +# Pip cache (optional - saves space) +rm -rf ~/.cache/pip 2>/dev/null || true + +# Pnpm store (optional - forces fresh install) +# rm -rf ~/.local/share/pnpm/store 2>/dev/null || true + +echo "Done. Run scripts/ec2/redeploy_ec2.sh to rebuild." diff --git a/scripts/ec2/deploy_ec2.sh b/scripts/ec2/deploy_ec2.sh new file mode 100755 index 000000000..ce0a78f24 --- /dev/null +++ b/scripts/ec2/deploy_ec2.sh @@ -0,0 +1,567 @@ +#!/bin/bash +# ============================================================================= +# Sotopia EC2 Deployment Script +# ============================================================================= +# Deploys Sotopia to a single EC2 instance with: +# - Backend (FastAPI) on port 8800 +# - Frontend (Next.js) on port 3000 +# - Redis on port 6379 +# - Nginx reverse proxy on port 80/443 +# +# Prerequisites on EC2: +# - Ubuntu 22.04 LTS (recommended) +# - Security group with ports 22, 80, 443, 8800 open +# - SSH access configured +# +# Usage: +# 1. SSH into your EC2 instance +# 2. Clone the repository +# 3. Run: ./scripts/ec2/deploy_ec2.sh +# +# Or from local machine: +# scp scripts/ec2/deploy_ec2.sh ubuntu@YOUR_EC2_IP:~ +# ssh ubuntu@YOUR_EC2_IP './deploy_ec2.sh' +# ============================================================================= + +set -e + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +echo -e "${BLUE}========================================${NC}" +echo -e "${BLUE} Sotopia EC2 Deployment${NC}" +echo -e "${BLUE}========================================${NC}" + +# Configuration - UPDATE THESE +DOMAIN="${DOMAIN:-}" # Set your domain or leave empty for IP access +USE_SSL="${USE_SSL:-false}" # Set to true if you have a domain and want HTTPS +DEPLOY_HOST="${DEPLOY_HOST:-}" # e.g. 54.89.222.156 - used when EC2 metadata unavailable + +# Auto-detect EC2 public IP if domain not set +if [ -z "$DOMAIN" ]; then + PUBLIC_IP=$(curl -s --connect-timeout 2 http://169.254.169.254/latest/meta-data/public-ipv4 2>/dev/null || true) + if [ -z "$PUBLIC_IP" ]; then + PUBLIC_IP=$(curl -s --connect-timeout 2 https://ifconfig.me 2>/dev/null || curl -s --connect-timeout 2 https://icanhazip.com 2>/dev/null || true) + fi + if [ -z "$PUBLIC_IP" ]; then + PUBLIC_IP="${DEPLOY_HOST:-}" + fi + echo -e "${YELLOW}No domain set, using IP: ${PUBLIC_IP:-}${NC}" +fi + +# ============================================================================= +# System Setup +# ============================================================================= + +install_system_deps() { + echo -e "\n${YELLOW}Installing system dependencies...${NC}" + + sudo apt-get update + sudo apt-get install -y \ + curl \ + git \ + build-essential \ + nginx \ + certbot \ + python3-certbot-nginx \ + docker.io \ + docker-compose + + # Add current user to docker group + sudo usermod -aG docker $USER + + echo -e "${GREEN}✓ System dependencies installed${NC}" +} + +install_python() { + echo -e "\n${YELLOW}Installing Python 3.11...${NC}" + + sudo apt-get install -y software-properties-common + sudo add-apt-repository -y ppa:deadsnakes/ppa + sudo apt-get update + sudo apt-get install -y python3.11 python3.11-venv python3.11-dev python3-pip + + # Install uv for faster package management (installs to ~/.local/bin) + curl -LsSf https://astral.sh/uv/install.sh | sh + export PATH="$HOME/.local/bin:$PATH" + + echo -e "${GREEN}✓ Python 3.11 installed${NC}" +} + +install_node() { + echo -e "\n${YELLOW}Installing Node.js 20...${NC}" + + curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - + sudo apt-get install -y nodejs + + # Install pnpm + sudo npm install -g pnpm + + echo -e "${GREEN}✓ Node.js 20 installed${NC}" +} + +# ============================================================================= +# Redis Setup +# ============================================================================= + +start_redis() { + echo -e "\n${YELLOW}Starting Redis...${NC}" + + # Stop any existing container + sudo docker stop sotopia-redis 2>/dev/null || true + sudo docker rm sotopia-redis 2>/dev/null || true + + # Start Redis with persistence + sudo docker run -d \ + --name sotopia-redis \ + --restart unless-stopped \ + -p 6379:6379 \ + -v redis-data:/data \ + redis/redis-stack-server:latest + + echo -e "${GREEN}✓ Redis started${NC}" +} + +# ============================================================================= +# Application Setup +# ============================================================================= + +setup_app() { + echo -e "\n${YELLOW}Setting up application...${NC}" + + # Create app directory + APP_DIR="/opt/sotopia" + sudo mkdir -p $APP_DIR + sudo chown $USER:$USER $APP_DIR + + # Clone or update repository + if [ -d "$APP_DIR/.git" ]; then + echo "Updating existing repository..." + cd $APP_DIR + git pull + elif [ -f "$APP_DIR/pyproject.toml" ]; then + # App already present (e.g. rsync'd); skip copy + echo "Application already at $APP_DIR, skipping clone" + cd $APP_DIR + else + echo "Cloning repository..." + # If running from within the repo (elsewhere), copy files + if [ -f "pyproject.toml" ] && [ "$(pwd -P)" != "$(cd $APP_DIR 2>/dev/null && pwd -P)" ]; then + cp -r . $APP_DIR/ + else + echo -e "${RED}Error: No app at $APP_DIR. Run from repo root or rsync first.${NC}" + exit 1 + fi + cd $APP_DIR + fi + + cd $APP_DIR + echo -e "${GREEN}✓ Application files ready${NC}" +} + +create_env_file() { + echo -e "\n${YELLOW}Creating environment configuration...${NC}" + + APP_DIR="/opt/sotopia" + + # Prompt for required values if not set + if [ -z "$OPENAI_API_KEY" ]; then + echo -e "${YELLOW}Enter your OpenAI API key:${NC}" + read -r OPENAI_API_KEY + fi + + if [ -z "$JWT_SECRET" ]; then + # Generate a secure random secret + JWT_SECRET=$(openssl rand -base64 32) + echo -e "${GREEN}Generated JWT secret${NC}" + fi + + cat > $APP_DIR/.env << EOF +# Sotopia Production Environment + +# Required +OPENAI_API_KEY=$OPENAI_API_KEY + +# Storage +REDIS_OM_URL=redis://localhost:6379 +SOTOPIA_STORAGE_BACKEND=redis + +# Security +JWT_SECRET=$JWT_SECRET + +# OAuth (optional - uncomment and fill if using) +# GOOGLE_CLIENT_ID= +# GOOGLE_CLIENT_SECRET= +# GITHUB_CLIENT_ID= +# GITHUB_CLIENT_SECRET= +# DISCORD_CLIENT_ID= +# DISCORD_CLIENT_SECRET= +EOF + + chmod 600 $APP_DIR/.env + echo -e "${GREEN}✓ Environment file created${NC}" +} + +install_python_deps() { + echo -e "\n${YELLOW}Installing Python dependencies...${NC}" + + cd /opt/sotopia + + # Create virtual environment + python3.11 -m venv .venv + source .venv/bin/activate + + # Install dependencies + pip install --upgrade pip + pip install -e ".[test]" + + echo -e "${GREEN}✓ Python dependencies installed${NC}" +} + +build_frontend() { + echo -e "\n${YELLOW}Building frontend...${NC}" + + cd /opt/sotopia/sotopia-chat/frontend + + # Clear stale build cache (prevents old UI from persisting) + rm -rf .next node_modules/.cache 2>/dev/null || true + + # Set production API URL (use port 80 via Nginx, not 8800 - security group typically only allows 80) + if [ -n "$DOMAIN" ]; then + API_URL="https://$DOMAIN" + WS_URL="wss://$DOMAIN" + elif [ -n "$PUBLIC_IP" ]; then + API_URL="http://$PUBLIC_IP" + WS_URL="ws://$PUBLIC_IP" + else + # Fallback: use same origin for API (relative URLs); WS needs host - use DEPLOY_HOST if set + API_URL="" + if [ -n "${DEPLOY_HOST:-}" ]; then + WS_URL="ws://${DEPLOY_HOST}" + else + WS_URL="ws://localhost" + fi + fi + + cat > .env.local << EOF +NEXT_PUBLIC_API_BASE_URL=$API_URL +NEXT_PUBLIC_WS_BASE=$WS_URL +EOF + + # Install and build + pnpm install + pnpm build + + # Write deploy info for version checking (served at /deploy-info.json) + DEPLOY_TS=$(date -Iseconds 2>/dev/null || date '+%Y-%m-%dT%H:%M:%S%z') + BUILD_ID="${DEPLOY_TS}-$(openssl rand -hex 4 2>/dev/null || echo "local")" + cat > public/deploy-info.json << DEPLOYEOF +{"deployed_at":"${DEPLOY_TS}","build_id":"${BUILD_ID}"} +DEPLOYEOF + echo -e "${GREEN}✓ Frontend built (build_id: ${BUILD_ID})${NC}" +} + +# ============================================================================= +# Systemd Services +# ============================================================================= + +create_backend_service() { + echo -e "\n${YELLOW}Creating backend service...${NC}" + + sudo tee /etc/systemd/system/sotopia-backend.service > /dev/null << EOF +[Unit] +Description=Sotopia Backend API +After=network.target docker.service +Requires=docker.service + +[Service] +Type=simple +User=$USER +WorkingDirectory=/opt/sotopia +EnvironmentFile=/opt/sotopia/.env +ExecStart=/opt/sotopia/.venv/bin/python -m sotopia.api.fastapi_server +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +EOF + + sudo systemctl daemon-reload + sudo systemctl enable sotopia-backend + sudo systemctl start sotopia-backend + + echo -e "${GREEN}✓ Backend service created and started${NC}" +} + +create_frontend_service() { + echo -e "\n${YELLOW}Creating frontend service...${NC}" + + sudo tee /etc/systemd/system/sotopia-frontend.service > /dev/null << EOF +[Unit] +Description=Sotopia Frontend +After=network.target + +[Service] +Type=simple +User=$USER +WorkingDirectory=/opt/sotopia/sotopia-chat/frontend +ExecStart=/usr/bin/pnpm start +Restart=always +RestartSec=5 +Environment=NODE_ENV=production +Environment=PORT=3000 + +[Install] +WantedBy=multi-user.target +EOF + + sudo systemctl daemon-reload + sudo systemctl enable sotopia-frontend + sudo systemctl start sotopia-frontend + + echo -e "${GREEN}✓ Frontend service created and started${NC}" +} + +# ============================================================================= +# Nginx Configuration +# ============================================================================= + +configure_nginx() { + echo -e "\n${YELLOW}Configuring Nginx...${NC}" + + if [ -n "$DOMAIN" ]; then + SERVER_NAME="$DOMAIN" + elif [ -n "$PUBLIC_IP" ]; then + SERVER_NAME="$PUBLIC_IP" + else + # Fallback when metadata unavailable: _ accepts any hostname + SERVER_NAME="_" + fi + + sudo tee /etc/nginx/sites-available/sotopia > /dev/null << EOF +# Sotopia Nginx Configuration + +upstream backend { + server 127.0.0.1:8800; +} + +upstream frontend { + server 127.0.0.1:3000; +} + +server { + listen 80; + server_name $SERVER_NAME; + + # Frontend (no-store to prevent stale UI in browser cache) + location / { + proxy_pass http://frontend; + proxy_http_version 1.1; + proxy_set_header Upgrade \$http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host \$host; + proxy_set_header X-Real-IP \$remote_addr; + proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto \$scheme; + proxy_cache_bypass \$http_upgrade; + add_header Cache-Control "no-store, no-cache, must-revalidate"; + } + + # Backend API + location /api/ { + rewrite ^/api/(.*) /\$1 break; + proxy_pass http://backend; + proxy_http_version 1.1; + proxy_set_header Upgrade \$http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host \$host; + proxy_set_header X-Real-IP \$remote_addr; + proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto \$scheme; + proxy_cache_bypass \$http_upgrade; + + # WebSocket support + proxy_read_timeout 86400; + } + + # Direct backend access (for development/testing) + location /auth/ { + proxy_pass http://backend/auth/; + proxy_set_header Host \$host; + proxy_set_header X-Real-IP \$remote_addr; + } + + location /oauth/ { + proxy_pass http://backend/oauth/; + proxy_set_header Host \$host; + proxy_set_header X-Real-IP \$remote_addr; + } + + location /leaderboard { + proxy_pass http://backend/leaderboard; + proxy_set_header Host \$host; + proxy_set_header X-Real-IP \$remote_addr; + } + + location /profile/ { + proxy_pass http://backend/profile/; + proxy_set_header Host \$host; + proxy_set_header X-Real-IP \$remote_addr; + } + + location /games/ { + proxy_pass http://backend/games/; + proxy_http_version 1.1; + proxy_set_header Upgrade \$http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host \$host; + proxy_read_timeout 86400; + } + + location /ws/ { + proxy_pass http://backend/ws/; + proxy_http_version 1.1; + proxy_set_header Upgrade \$http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host \$host; + proxy_read_timeout 86400; + } + + location /health { + proxy_pass http://backend/health; + } +} +EOF + + # Enable site + sudo ln -sf /etc/nginx/sites-available/sotopia /etc/nginx/sites-enabled/ + sudo rm -f /etc/nginx/sites-enabled/default + + # Test and reload + sudo nginx -t + sudo systemctl reload nginx + + echo -e "${GREEN}✓ Nginx configured${NC}" +} + +setup_ssl() { + if [ "$USE_SSL" != "true" ] || [ -z "$DOMAIN" ]; then + echo -e "${YELLOW}Skipping SSL setup (no domain or USE_SSL not set)${NC}" + return + fi + + echo -e "\n${YELLOW}Setting up SSL with Let's Encrypt...${NC}" + + sudo certbot --nginx -d $DOMAIN --non-interactive --agree-tos -m admin@$DOMAIN + + echo -e "${GREEN}✓ SSL configured${NC}" +} + +# ============================================================================= +# Health Check +# ============================================================================= + +health_check() { + echo -e "\n${YELLOW}Running health checks...${NC}" + + sleep 5 + + # Check Redis + if docker exec sotopia-redis redis-cli ping | grep -q "PONG"; then + echo -e "${GREEN}✓ Redis: healthy${NC}" + else + echo -e "${RED}✗ Redis: not responding${NC}" + fi + + # Check Backend + if curl -s http://localhost:8800/health | grep -q "ok"; then + echo -e "${GREEN}✓ Backend: healthy${NC}" + else + echo -e "${RED}✗ Backend: not responding${NC}" + echo -e "${YELLOW} Check logs: sudo journalctl -u sotopia-backend -f${NC}" + fi + + # Check Frontend + if curl -s http://localhost:3000 > /dev/null 2>&1; then + echo -e "${GREEN}✓ Frontend: healthy${NC}" + else + echo -e "${RED}✗ Frontend: not responding${NC}" + echo -e "${YELLOW} Check logs: sudo journalctl -u sotopia-frontend -f${NC}" + fi + + # Check Nginx + if curl -s http://localhost > /dev/null 2>&1; then + echo -e "${GREEN}✓ Nginx: healthy${NC}" + else + echo -e "${RED}✗ Nginx: not responding${NC}" + fi +} + +# ============================================================================= +# Print Summary +# ============================================================================= + +print_summary() { + echo -e "\n${BLUE}========================================${NC}" + echo -e "${GREEN} Deployment Complete!${NC}" + echo -e "${BLUE}========================================${NC}" + echo "" + + if [ -n "$DOMAIN" ]; then + if [ "$USE_SSL" == "true" ]; then + echo -e " ${GREEN}Website:${NC} https://$DOMAIN" + else + echo -e " ${GREEN}Website:${NC} http://$DOMAIN" + fi + else + echo -e " ${GREEN}Website:${NC} http://$PUBLIC_IP" + fi + + echo "" + echo -e "${YELLOW}Pages:${NC}" + echo -e " - Home: /" + echo -e " - Login: /login" + echo -e " - Register: /register" + echo -e " - Profile: /profile" + echo -e " - Leaderboard: /leaderboard" + echo -e " - Werewolf: /games/werewolf" + echo "" + echo -e "${YELLOW}Management:${NC}" + echo -e " View backend logs: sudo journalctl -u sotopia-backend -f" + echo -e " View frontend logs: sudo journalctl -u sotopia-frontend -f" + echo -e " Restart backend: sudo systemctl restart sotopia-backend" + echo -e " Restart frontend: sudo systemctl restart sotopia-frontend" + echo -e " Restart Redis: sudo docker restart sotopia-redis" + echo "" + echo -e "${YELLOW}Update deployment:${NC}" + echo -e " cd /opt/sotopia && git pull && ./scripts/ec2/deploy_ec2.sh" + echo "" +} + +# ============================================================================= +# Main +# ============================================================================= + +main() { + install_system_deps + install_python + install_node + start_redis + setup_app + create_env_file + install_python_deps + build_frontend + create_backend_service + create_frontend_service + configure_nginx + setup_ssl + health_check + print_summary +} + +main "$@" diff --git a/scripts/ec2/fix_nginx_games.sh b/scripts/ec2/fix_nginx_games.sh new file mode 100644 index 000000000..c3a6cf752 --- /dev/null +++ b/scripts/ec2/fix_nginx_games.sh @@ -0,0 +1,83 @@ +#!/bin/bash +# Fix Nginx so /games/queue and other backend routes go to the backend, not Next.js +# Run on EC2: bash /opt/sotopia/scripts/ec2/fix_nginx_games.sh + +set -e +echo "Fixing Nginx routing for /games/..." + +# Backup +sudo cp /etc/nginx/sites-available/sotopia /etc/nginx/sites-available/sotopia.bak + +# Ensure backend /games/ routes exist BEFORE the frontend catch-all +# The key: /games/queue, /games/history, /games/matchmaking must hit backend +# location /games/ with proxy to backend must come before any /games/ → frontend + +sudo tee /etc/nginx/sites-available/sotopia > /dev/null << 'NGINXEOF' +# Sotopia Nginx - backend /games/* must route to API +upstream backend { server 127.0.0.1:8800; } +upstream frontend { server 127.0.0.1:3000; } + +server { + listen 80; + server_name _; + + # Backend API routes (MUST come before location /) + location /api/ { + rewrite ^/api/(.*) /$1 break; + proxy_pass http://backend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + proxy_read_timeout 86400; + } + location /auth/ { proxy_pass http://backend/auth/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } + location /oauth/ { proxy_pass http://backend/oauth/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } + location /leaderboard { proxy_pass http://backend/leaderboard; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } + location /profile/ { proxy_pass http://backend/profile/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } + location /health { proxy_pass http://backend/health; proxy_set_header Host $host; } + location /ws/ { + proxy_pass http://backend/ws/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_read_timeout 86400; + } + # Backend /games/* API only (NOT /games/werewolf page which is frontend) + location /games/queue { proxy_pass http://backend/games/queue; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } + location /games/history/ { proxy_pass http://backend/games/history/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } + location /games/matchmaking/ { proxy_pass http://backend/games/matchmaking/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } + location /games/werewolf/config { proxy_pass http://backend/games/werewolf/config; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } + location /games/werewolf/sessions/ { + proxy_pass http://backend/games/werewolf/sessions/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_read_timeout 86400; + } + location /games/leaderboard { proxy_pass http://backend/games/leaderboard; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } + + # Frontend (catch-all: /games/werewolf page, /, etc.) + location / { + proxy_pass http://frontend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + add_header Cache-Control "no-store, no-cache, must-revalidate"; + } +} +NGINXEOF + +sudo nginx -t && sudo systemctl reload nginx +echo "Done. Test: curl -s http://localhost/games/queue" \ No newline at end of file diff --git a/scripts/ec2/force_refresh_frontend_ec2.sh b/scripts/ec2/force_refresh_frontend_ec2.sh new file mode 100644 index 000000000..22e4ce509 --- /dev/null +++ b/scripts/ec2/force_refresh_frontend_ec2.sh @@ -0,0 +1,46 @@ +#!/bin/bash +# ============================================================================= +# Force a clean frontend rebuild on EC2 - run ON EC2 when browser cache bypass +# doesn't fix old UI ("YOUR ARENA IDENTITY" instead of "Guest Mode") +# ============================================================================= + +set -e +cd /opt/sotopia/sotopia-chat/frontend + +echo "=== Force refresh frontend (clean build) ===" + +# Verify we have the correct page.tsx (Guest Mode, not Your Arena Identity) +if ! grep -q "Guest Mode" app/page.tsx 2>/dev/null; then + echo "ERROR: app/page.tsx does not contain 'Guest Mode'. You have the OLD code." + echo "Rsync from your worktree FIRST (with the auth UI changes) before building." + echo " From Mac: rsync -avz --exclude .venv --exclude node_modules --exclude __pycache__ --exclude .next -e 'ssh -i sotopia-web-ssh.pem' ./ ubuntu@YOUR_EC2_IP:/opt/sotopia/" + exit 1 +fi + +# 1. Remove Next.js build cache - forces new chunk filenames +echo "Removing .next and build cache..." +rm -rf .next +rm -rf node_modules/.cache 2>/dev/null || true + +# 2. Ensure .env.local has correct API URL +DEPLOY_HOST="${DEPLOY_HOST:-54.89.222.156}" +if [ -n "$DEPLOY_HOST" ]; then + cat > .env.local << EOF +NEXT_PUBLIC_API_BASE_URL=http://${DEPLOY_HOST} +NEXT_PUBLIC_WS_BASE=ws://${DEPLOY_HOST} +EOF + echo "Updated .env.local for $DEPLOY_HOST" +fi + +# 3. Rebuild +echo "Building..." +pnpm build + +# 4. Restart frontend +echo "Restarting frontend service..." +sudo systemctl restart sotopia-frontend +sleep 2 + +echo "" +echo "Done. Visit http://${DEPLOY_HOST:-54.89.222.156} in a NEW incognito window." +echo "If still old: run 'curl -s http://localhost:3000/ | grep -E \"Guest Mode|YOUR ARENA\"' to see what server returns." diff --git a/scripts/ec2/inspect_ec2.sh b/scripts/ec2/inspect_ec2.sh new file mode 100644 index 000000000..9ee9ca15e --- /dev/null +++ b/scripts/ec2/inspect_ec2.sh @@ -0,0 +1,57 @@ +#!/bin/bash +# ============================================================================= +# Inspect EC2 deployment - run ON EC2 to diagnose version/code issues +# Usage: bash scripts/ec2/inspect_ec2.sh +# ============================================================================= + +echo "=== EC2 Sotopia Inspection ===" +echo "" + +echo "1. Check page.tsx - should have AuthHeader and Guest Mode (not YOUR ARENA IDENTITY):" +echo "---" +grep -n "AuthHeader\|Guest Mode\|YOUR ARENA IDENTITY\|Save Identity" /opt/sotopia/sotopia-chat/frontend/app/page.tsx 2>/dev/null | head -20 +echo "" + +echo "2. Search for 'YOUR ARENA IDENTITY' in frontend (should find nothing in current code):" +echo "---" +grep -r "YOUR ARENA IDENTITY" /opt/sotopia/sotopia-chat/frontend/src /opt/sotopia/sotopia-chat/frontend/app 2>/dev/null || echo "(none found)" +echo "" + +echo "3. Frontend .env.local contents:" +echo "---" +cat /opt/sotopia/sotopia-chat/frontend/.env.local 2>/dev/null || echo "(file not found)" +echo "" + +echo "4. Nginx games routing - /games/queue should proxy to backend:" +echo "---" +grep -A2 "location /games/queue" /etc/nginx/sites-available/sotopia 2>/dev/null || echo "(check /etc/nginx/sites-enabled/sotopia)" +echo "" + +echo "5. Backend /games/queue reachable?" +echo "---" +curl -s -o /dev/null -w "%{http_code}" http://localhost:8800/games/queue && echo " (200=OK)" || echo " (failed)" +echo "" + +echo "6. File modification times (when was code last updated?):" +echo "---" +ls -la /opt/sotopia/sotopia-chat/frontend/app/page.tsx 2>/dev/null +ls -la /opt/sotopia/sotopia-chat/frontend/.next/BUILD_ID 2>/dev/null +echo "" + +echo "7. First 30 lines of page.tsx:" +echo "---" +head -30 /opt/sotopia/sotopia-chat/frontend/app/page.tsx 2>/dev/null +echo "" + +echo "8. What does the server actually return? (curl localhost:3000 and grep):" +echo "---" +HTML=$(curl -s http://localhost:3000/ 2>/dev/null || echo "") +if echo "$HTML" | grep -q "Guest Mode"; then + echo " ✓ HTML contains 'Guest Mode' (correct UI)" +elif echo "$HTML" | grep -q "YOUR ARENA IDENTITY"; then + echo " ✗ HTML contains 'YOUR ARENA IDENTITY' (OLD UI - EC2 has stale build)" +else + echo " (neither found in HTML - might be client-rendered; check _next chunks)" +fi +echo "" +echo "=== Done ===" diff --git a/scripts/ec2/redeploy_ec2.sh b/scripts/ec2/redeploy_ec2.sh new file mode 100644 index 000000000..62f398fa1 --- /dev/null +++ b/scripts/ec2/redeploy_ec2.sh @@ -0,0 +1,197 @@ +#!/bin/bash +# ============================================================================= +# Clean Redeploy to EC2 +# ============================================================================= +# How it works on EC2: +# 1. Code lives at /opt/sotopia (Python backend + Next.js frontend) +# 2. Backend: systemd runs uvicorn from /opt/sotopia on port 8800 +# 3. Frontend: systemd runs "pnpm start" (Next.js) on port 3000 +# 4. Nginx: reverse proxy on 80, serves frontend and proxies /games/, /ws/, etc to backend +# 5. Redis: Docker container on 6379 +# +# Clean redeploy steps: +# A. On EC2: backup .env and wipe /opt/sotopia +# B. From local: rsync code to EC2 +# C. On EC2: run this script +# ============================================================================= + +set -e + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +DEPLOY_HOST="${DEPLOY_HOST:-54.89.222.156}" + +# ----------------------------------------------------------------------------- +# Check we have code +# ----------------------------------------------------------------------------- +if [ ! -f /opt/sotopia/pyproject.toml ]; then + echo -e "${RED}Error: No code at /opt/sotopia. Run steps A and B first:${NC}" + echo "" + echo -e "${YELLOW}Step A (on EC2):${NC}" + echo " cp /opt/sotopia/.env /tmp/sotopia_env_backup 2>/dev/null || true" + echo " sudo rm -rf /opt/sotopia/{*,.[!.]*}" + echo "" + echo -e "${YELLOW}Step B (from your Mac, in project dir):${NC}" + echo ' rsync -avz --exclude .venv --exclude node_modules --exclude __pycache__ --exclude .next \' + echo ' -e "ssh -i sotopia-web-ssh.pem" \' + echo ' ./ ubuntu@ec2-54-89-222-156.compute-1.amazonaws.com:/opt/sotopia/' + echo "" + exit 1 +fi + +echo -e "${GREEN}Clean redeploy (host: $DEPLOY_HOST)${NC}" + +# Restore .env +if [ -f /tmp/sotopia_env_backup ]; then + cp /tmp/sotopia_env_backup /opt/sotopia/.env + chmod 600 /opt/sotopia/.env + echo -e "${GREEN}Restored .env${NC}" +else + echo -e "${RED}No /tmp/sotopia_env_backup. Create .env with OPENAI_API_KEY, JWT_SECRET, REDIS_OM_URL${NC}" + exit 1 +fi + +# ----------------------------------------------------------------------------- +# Python deps +# ----------------------------------------------------------------------------- +echo -e "\n${YELLOW}Installing Python deps...${NC}" +cd /opt/sotopia +rm -rf .venv +python3.11 -m venv .venv +source .venv/bin/activate +pip install --upgrade pip -q +pip install -e . -q +pip install "fastapi[standard]" uvicorn -q +echo -e "${GREEN}✓ Python deps installed${NC}" + +# ----------------------------------------------------------------------------- +# Frontend (clean build - no stale chunks) +# ----------------------------------------------------------------------------- +echo -e "\n${YELLOW}Building frontend...${NC}" +cd /opt/sotopia/sotopia-chat/frontend +rm -rf .next node_modules/.cache 2>/dev/null || true +cat > .env.local << EOF +NEXT_PUBLIC_API_BASE_URL=http://${DEPLOY_HOST} +NEXT_PUBLIC_WS_BASE=ws://${DEPLOY_HOST} +EOF +pnpm install +pnpm build +DEPLOY_TS=$(date -Iseconds 2>/dev/null || date '+%Y-%m-%dT%H:%M:%S%z') +BUILD_ID="${DEPLOY_TS}-$(openssl rand -hex 4 2>/dev/null || echo "local")" +echo "{\"deployed_at\":\"${DEPLOY_TS}\",\"build_id\":\"${BUILD_ID}\"}" > public/deploy-info.json +echo -e "${GREEN}✓ Frontend built (build_id: ${BUILD_ID})${NC}" + +# ----------------------------------------------------------------------------- +# Nginx - update routing so /games/werewolf (page) goes to frontend +# ----------------------------------------------------------------------------- +echo -e "\n${YELLOW}Updating Nginx routing...${NC}" +SERVER_NAME="${DEPLOY_HOST:-54.89.222.156}" +sudo tee /etc/nginx/sites-available/sotopia > /dev/null << NGINXEOF +# Sotopia Nginx Configuration +upstream backend { server 127.0.0.1:8800; } +upstream frontend { server 127.0.0.1:3000; } + +server { + listen 80; + server_name $SERVER_NAME; + location / { + proxy_pass http://frontend; + proxy_http_version 1.1; + proxy_set_header Upgrade \$http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host \$host; + proxy_set_header X-Real-IP \$remote_addr; + proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto \$scheme; + proxy_cache_bypass \$http_upgrade; + add_header Cache-Control "no-store, no-cache, must-revalidate"; + } + location /api/ { + rewrite ^/api/(.*) /\$1 break; + proxy_pass http://backend; + proxy_http_version 1.1; + proxy_set_header Upgrade \$http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host \$host; + proxy_set_header X-Real-IP \$remote_addr; + proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto \$scheme; + proxy_cache_bypass \$http_upgrade; + proxy_read_timeout 86400; + } + location /auth/ { proxy_pass http://backend/auth/; proxy_set_header Host \$host; proxy_set_header X-Real-IP \$remote_addr; } + location /oauth/ { proxy_pass http://backend/oauth/; proxy_set_header Host \$host; proxy_set_header X-Real-IP \$remote_addr; } + location /leaderboard { proxy_pass http://backend/leaderboard; proxy_set_header Host \$host; proxy_set_header X-Real-IP \$remote_addr; } + location /profile/ { proxy_pass http://backend/profile/; proxy_set_header Host \$host; proxy_set_header X-Real-IP \$remote_addr; } + location /games/werewolf/sessions/ { + proxy_pass http://backend/games/werewolf/sessions/; + proxy_http_version 1.1; + proxy_set_header Upgrade \$http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host \$host; + proxy_read_timeout 86400; + } + location /games/werewolf/config { + proxy_pass http://backend/games/werewolf/config; + proxy_set_header Host \$host; + proxy_set_header X-Real-IP \$remote_addr; + } + location /games/queue { + proxy_pass http://backend/games/queue; + proxy_http_version 1.1; + proxy_set_header Host \$host; + proxy_set_header X-Real-IP \$remote_addr; + } + location /games/history/ { + proxy_pass http://backend/games/history/; + proxy_set_header Host \$host; + proxy_set_header X-Real-IP \$remote_addr; + } + location /games/leaderboard { + proxy_pass http://backend/games/leaderboard; + proxy_set_header Host \$host; + proxy_set_header X-Real-IP \$remote_addr; + } + location /games/ { + proxy_pass http://frontend; + proxy_http_version 1.1; + proxy_set_header Upgrade \$http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host \$host; + proxy_set_header X-Real-IP \$remote_addr; + proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto \$scheme; + proxy_cache_bypass \$http_upgrade; + add_header Cache-Control "no-store, no-cache, must-revalidate"; + } + location /ws/ { + proxy_pass http://backend/ws/; + proxy_http_version 1.1; + proxy_set_header Upgrade \$http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host \$host; + proxy_read_timeout 86400; + } + location /health { proxy_pass http://backend/health; } +} +NGINXEOF +sudo nginx -t && sudo systemctl reload nginx +echo -e "${GREEN}✓ Nginx updated${NC}" + +# ----------------------------------------------------------------------------- +# Restart +# ----------------------------------------------------------------------------- +echo -e "\n${YELLOW}Restarting services...${NC}" +sudo systemctl restart sotopia-backend +sudo systemctl restart sotopia-frontend +sleep 2 + +echo "" +echo -e "${GREEN}Done.${NC}" +echo -e "Backend: $(systemctl is-active sotopia-backend)" +echo -e "Frontend: $(systemctl is-active sotopia-frontend)" +echo -e "Visit: http://${DEPLOY_HOST}" +echo "" diff --git a/scripts/ec2/wipe_ec2.sh b/scripts/ec2/wipe_ec2.sh new file mode 100644 index 000000000..31fdd2672 --- /dev/null +++ b/scripts/ec2/wipe_ec2.sh @@ -0,0 +1,89 @@ +#!/bin/bash +# ============================================================================= +# Completely wipe Sotopia from EC2 +# ============================================================================= +# Removes: +# - Systemd services (sotopia-backend, sotopia-frontend) +# - Nginx sotopia site config +# - Redis container (sotopia-redis) and volume +# - /opt/sotopia directory +# - /tmp/sotopia_env_backup +# +# Run on EC2: bash scripts/ec2/wipe_ec2.sh +# ============================================================================= + +set -e + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +echo -e "${YELLOW}Wiping all Sotopia components from EC2...${NC}" + +# ----------------------------------------------------------------------------- +# Stop and remove systemd services +# ----------------------------------------------------------------------------- +echo -e "\n${YELLOW}Stopping systemd services...${NC}" +sudo systemctl stop sotopia-backend 2>/dev/null || true +sudo systemctl stop sotopia-frontend 2>/dev/null || true +sudo systemctl disable sotopia-backend 2>/dev/null || true +sudo systemctl disable sotopia-frontend 2>/dev/null || true +echo -e "${GREEN}✓ Services stopped and disabled${NC}" + +echo -e "\n${YELLOW}Removing systemd service files...${NC}" +sudo rm -f /etc/systemd/system/sotopia-backend.service +sudo rm -f /etc/systemd/system/sotopia-frontend.service +sudo systemctl daemon-reload +echo -e "${GREEN}✓ Service files removed${NC}" + +# ----------------------------------------------------------------------------- +# Nginx +# ----------------------------------------------------------------------------- +echo -e "\n${YELLOW}Removing Nginx sotopia config...${NC}" +sudo rm -f /etc/nginx/sites-enabled/sotopia +# Restore default site so nginx doesn't break +if [ -f /etc/nginx/sites-available/default ]; then + sudo ln -sf /etc/nginx/sites-available/default /etc/nginx/sites-enabled/default +fi +sudo nginx -t 2>/dev/null && sudo systemctl reload nginx || echo -e "${YELLOW}Nginx reload skipped (may already be clean)${NC}" +echo -e "${GREEN}✓ Nginx cleaned${NC}" + +# ----------------------------------------------------------------------------- +# Redis container +# ----------------------------------------------------------------------------- +echo -e "\n${YELLOW}Removing Redis container...${NC}" +sudo docker stop sotopia-redis 2>/dev/null || true +sudo docker rm sotopia-redis 2>/dev/null || true +sudo docker volume rm redis-data 2>/dev/null || true +echo -e "${GREEN}✓ Redis container and volume removed${NC}" + +# ----------------------------------------------------------------------------- +# Application directory and backups +# ----------------------------------------------------------------------------- +echo -e "\n${YELLOW}Removing /opt/sotopia...${NC}" +sudo rm -rf /opt/sotopia +echo -e "${GREEN}✓ /opt/sotopia removed${NC}" + +echo -e "\n${YELLOW}Removing backup files...${NC}" +rm -f /tmp/sotopia_env_backup +echo -e "${GREEN}✓ Backup files removed${NC}" + +# ----------------------------------------------------------------------------- +# Optional: remove nginx sotopia config from sites-available too +# ----------------------------------------------------------------------------- +sudo rm -f /etc/nginx/sites-available/sotopia + +echo "" +echo -e "${GREEN}========================================${NC}" +echo -e "${GREEN} Sotopia completely wiped from EC2${NC}" +echo -e "${GREEN}========================================${NC}" +echo "" +echo -e "Removed:" +echo -e " - sotopia-backend, sotopia-frontend systemd services" +echo -e " - sotopia-redis Docker container and volume" +echo -e " - Nginx sotopia site" +echo -e " - /opt/sotopia" +echo "" +echo -e "To deploy fresh: rsync code to /opt/sotopia, then run scripts/ec2/deploy_ec2.sh" +echo "" diff --git a/sotopia-chat/.tool-versions b/sotopia-chat/.tool-versions new file mode 100644 index 000000000..8aa451a5c --- /dev/null +++ b/sotopia-chat/.tool-versions @@ -0,0 +1 @@ +python 3.11.9 diff --git a/sotopia-chat/chat_server.py b/sotopia-chat/chat_server.py deleted file mode 100644 index 85f55382f..000000000 --- a/sotopia-chat/chat_server.py +++ /dev/null @@ -1,247 +0,0 @@ -import logging -import os -import random -import subprocess -from asyncio import gather -from asyncio import run as aiorun -from datetime import datetime -from typing import Literal, cast - -import redis.asyncio as redis -import typer -from rich.logging import RichHandler - -from sotopia.agents import redis_agent -from sotopia.agents.llm_agent import LLMAgent -from sotopia.database import EnvAgentComboStorage -from sotopia.database.persistent_profile import ( - AgentProfile, - EnvironmentList, - EnvironmentProfile, -) -from sotopia.envs.evaluators import ( - EpisodeLLMEvaluator, - RuleBasedTerminatedEvaluator, -) -from sotopia.envs.parallel import ParallelSotopiaEnv -from sotopia.server import arun_one_episode -from sotopia.envs.evaluators import EvaluationForAgents -from sotopia.database import SotopiaDimensions -from sotopia.logging import FileHandler - -process = subprocess.Popen( - ["git", "rev-parse", "HEAD"], shell=False, stdout=subprocess.PIPE -) -git_head_hash = process.communicate()[0].strip() - -FORMAT = "%(asctime)s - %(levelname)s - %(name)s - %(message)s" -logging.basicConfig( - level=15, - format=FORMAT, - datefmt="[%X]", - handlers=[ - RichHandler(), - FileHandler( - datetime.now().strftime( - f"./logs/%H_%M_%d_%m_%Y_{str(git_head_hash.decode('utf-8'))}.log" - ) - ), - ], -) - -app = typer.Typer() - - -async def _start_server_with_two_session_ids_and_agent_env_combo( - session_ids: list[str], agent_env_combo_pk: str -) -> None: - env_agent_combo_storage = EnvAgentComboStorage.get(agent_env_combo_pk) - env = ParallelSotopiaEnv( - env_profile=EnvironmentProfile.get(env_agent_combo_storage.env_id), - action_order="round-robin", - evaluators=[ - RuleBasedTerminatedEvaluator(max_turn_number=20, max_stale_turn=2), - ], - terminal_evaluators=[ - EpisodeLLMEvaluator("gpt-4", EvaluationForAgents[SotopiaDimensions]), - ], - ) - random.shuffle(session_ids) - agents = [ - redis_agent.RedisAgent( - agent_profile=AgentProfile.get(env_agent_combo_storage.agent_ids[idx]), - session_id=session_id, - ) - for idx, session_id in enumerate(session_ids) - ] - await arun_one_episode( - env, - agents, - tag="human_human_v0.0.3_dryrun", - push_to_db=True, - ) - - -async def _start_server_with_one_session_id_and_agent_env_combo( - session_id: str, - agent_env_combo_pk: str, - left_or_right: Literal["left", "right"], -) -> None: - env_agent_combo_storage = EnvAgentComboStorage.get(agent_env_combo_pk) - env = ParallelSotopiaEnv( - env_profile=EnvironmentProfile.get(env_agent_combo_storage.env_id), - action_order="round-robin", - evaluators=[ - RuleBasedTerminatedEvaluator(max_turn_number=20, max_stale_turn=2), - ], - terminal_evaluators=[ - EpisodeLLMEvaluator("gpt-4", EvaluationForAgents[SotopiaDimensions]), - ], - ) - - agents = ( - [ - redis_agent.RedisAgent( - agent_profile=AgentProfile.get(env_agent_combo_storage.agent_ids[0]), - session_id=session_id, - ), - LLMAgent( - model_name="gpt-4", - agent_profile=AgentProfile.get(env_agent_combo_storage.agent_ids[1]), - ), - ] - if left_or_right == "left" - else [ - LLMAgent( - model_name="gpt-4", - agent_profile=AgentProfile.get(env_agent_combo_storage.agent_ids[0]), - ), - redis_agent.RedisAgent( - agent_profile=AgentProfile.get(env_agent_combo_storage.agent_ids[1]), - session_id=session_id, - ), - ] - ) - await arun_one_episode( - env, - agents, - tag="human_human_v0.0.3_dryrun", - push_to_db=True, - ) - - -async def async_add_env_agent_combo_to_redis_queue( - use_hard_env_set: bool = False, -) -> None: - r = redis.Redis.from_url(os.environ["REDIS_OM_URL"]) - if use_hard_env_set: - env_list = cast( - list[EnvironmentList], - EnvironmentList.find(EnvironmentList.name == "hard_env_set").all(), - )[0] - envs = env_list.environments - agent_indices = env_list.agent_index - env_agent_combo_storage_pks: list[str] = [] - for env in envs: - env_agent_combo_storage = list( - EnvAgentComboStorage.find(EnvAgentComboStorage.env_id == env).all() - )[0] - assert env_agent_combo_storage.pk - env_agent_combo_storage_pks.append(env_agent_combo_storage.pk) - assert agent_indices - await r.rpush( - "chat_server_combos_double", - *tuple(set(env_agent_combo_storage_pks)), - ) - for agent_index, env_agent_combo_storage_pk in zip( - agent_indices, env_agent_combo_storage_pks - ): - if agent_index == "0": - await r.rpush( - "chat_server_combos_single_left", - env_agent_combo_storage_pk, - ) - else: - await r.rpush( - "chat_server_combos_single_right", - env_agent_combo_storage_pk, - ) - - else: - envs = list(EnvironmentProfile.all_pks()) - random.shuffle(envs) - for env in envs: - env_agent_combo_storage = list( - EnvAgentComboStorage.find(EnvAgentComboStorage.env_id == env).all() - )[0] - assert env_agent_combo_storage.pk - await r.rpush("chat_server_combos_double", env_agent_combo_storage.pk) - await r.rpush("chat_server_combos_single_left", env_agent_combo_storage.pk) - await r.rpush("chat_server_combos_single_right", env_agent_combo_storage.pk) - await r.close() - - -@app.command() -def add_env_agent_combo_to_redis_queue(use_hard_env_set: bool = False) -> None: - aiorun(async_add_env_agent_combo_to_redis_queue(use_hard_env_set)) - - -async def async_start_server_with_session_ids(session_ids: list[str]) -> None: - typer.echo(f"Starting server with session ids: {session_ids}") - - r = redis.Redis.from_url(os.environ["REDIS_OM_URL"]) - - async def _assign_left_or_right_and_run(session_id: str) -> None: - assert ( - await r.llen("chat_server_combos_single_left") - + await r.llen("chat_server_combos_single_right") - > 0 - ), "No agent-env combos available" - if await r.llen("chat_server_combos_single_left") >= await r.llen( - "chat_server_combos_single_right" - ): - agent_env_combo_pk = ( - await r.rpop("chat_server_combos_single_left") - ).decode("utf-8") - return await _start_server_with_one_session_id_and_agent_env_combo( - session_id, agent_env_combo_pk, "left" - ) - else: - agent_env_combo_pk = ( - await r.rpop("chat_server_combos_single_right") - ).decode("utf-8") - return await _start_server_with_one_session_id_and_agent_env_combo( - session_id, agent_env_combo_pk, "right" - ) - - match len(session_ids): - case 1: - await _assign_left_or_right_and_run(session_ids[0]) - case 2: - if await r.llen("chat_server_combos_double") == 0: - await gather( - *[ - _assign_left_or_right_and_run(session_id) - for session_id in session_ids - ] - ) - else: - agent_env_combo_pk: str = ( - await r.rpop("chat_server_combos_double") - ).decode("utf-8") - await _start_server_with_two_session_ids_and_agent_env_combo( - session_ids, agent_env_combo_pk - ) - case _: - raise ValueError( - f"Only 1 or 2 session ids are supported, but got {len(session_ids)}" - ) - - -@app.command() -def start_server_with_session_ids(session_ids: list[str]) -> None: - aiorun(async_start_server_with_session_ids(session_ids)) - - -if __name__ == "__main__": - app() diff --git a/sotopia-chat/fastapi_server.py b/sotopia-chat/fastapi_server.py deleted file mode 100644 index a0fdf6edd..000000000 --- a/sotopia-chat/fastapi_server.py +++ /dev/null @@ -1,353 +0,0 @@ -import asyncio -import json -import os -import random -import subprocess -import typing -import uuid -from datetime import datetime -from typing import Literal, cast - -import pydantic -import pytest -from fastapi import Body -from fastapi.applications import FastAPI -from fastapi.exceptions import HTTPException -from fastapi.middleware.cors import CORSMiddleware -from fastapi.testclient import TestClient -from redis import Redis -from redis.lock import Lock -from redis_om import Migrator -from starlette.responses import Response - -from sotopia.database import ( - AgentProfile, - EpisodeLog, - MatchingInWaitingRoom, - MessageTransaction, - SessionTransaction, -) - -Migrator().run() - -app = FastAPI() - -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -conn = Redis.from_url(os.environ["REDIS_OM_URL"]) - -WAITING_ROOM_TIMEOUT = float(os.environ.get("WAITING_ROOM_TIMEOUT", 1.0)) - - -@app.post("/connect/{session_id}/{role}/{id}") -async def connect( - session_id: str, role: Literal["server", "client"], id: str -) -> list[MessageTransaction]: - session_transactions = cast( - list[SessionTransaction], - SessionTransaction.find(SessionTransaction.session_id == session_id).all(), - ) - if not session_transactions: - if role == "client": - raise HTTPException(status_code=404, detail="Session not found") - else: - session_transaction = SessionTransaction( - session_id=session_id, - server_id=id, - client_id="", - message_list=[], - ) - session_transaction.save() - return [] - else: - if role == "client": - if len(session_transactions) > 1: - raise HTTPException( - status_code=500, - detail="Multiple session transactions found", - ) - session_transaction = session_transactions[0] - session_transaction.client_id = id - session_transaction.save() - return session_transaction.message_list - else: - raise HTTPException(status_code=500, detail="Session exists") - - -async def _get_single_exist_session(session_id: str) -> SessionTransaction: - session_transactions = cast( - list[SessionTransaction], - SessionTransaction.find(SessionTransaction.session_id == session_id).all(), - ) - if not session_transactions: - raise HTTPException(status_code=404, detail="Session not found") - elif len(session_transactions) > 1: - raise HTTPException( - status_code=500, detail="Multiple session transactions found" - ) - else: - return session_transactions[0] - - -@app.post("/send/{session_id}/{sender_id}") -async def send( - session_id: str, - sender_id: str, - message: str = Body(...), -) -> list[MessageTransaction]: - session_transaction = await _get_single_exist_session(session_id) - sender: str = "" - if sender_id == session_transaction.server_id: - # Sender is server - sender = "server" - elif sender_id == session_transaction.client_id: - # Sender is client - if session_transaction.client_action_lock == "no action": - raise HTTPException( - status_code=412, detail="Client cannot take action now." - ) - sender = "client" - else: - raise HTTPException(status_code=401, detail="Unauthorized sender") - - session_transaction.message_list.append( - MessageTransaction( - timestamp_str=str(datetime.now().timestamp()), - sender=sender, - message=message, - ) - ) - try: - session_transaction.save() - except pydantic.error_wrappers.ValidationError: - raise HTTPException(status_code=500, detail="timestamp error") - return session_transaction.message_list - - -@app.put("/lock/{session_id}/{server_id}/{lock}") -async def lock( - session_id: str, server_id: str, lock: Literal["no action", "action"] -) -> str: - session_transaction = await _get_single_exist_session(session_id) - if server_id != session_transaction.server_id: - raise HTTPException(status_code=401, detail="Unauthorized sender") - session_transaction.client_action_lock = lock - session_transaction.save() - return "success" - - -@app.get("/get/{session_id}") -async def get(session_id: str) -> list[MessageTransaction]: - session_transaction = await _get_single_exist_session(session_id) - return session_transaction.message_list - - -@app.delete("/delete/{session_id}/{server_id}") -async def delete(session_id: str, server_id: str) -> str: - session_transaction = await _get_single_exist_session(session_id) - if server_id != session_transaction.server_id: - raise HTTPException(status_code=401, detail="Unauthorized sender") - session_transaction.delete(session_transaction.pk) - return "success" - - -@app.get("/get_lock/{session_id}") -async def get_lock(session_id: str) -> str: - session_transaction = await _get_single_exist_session(session_id) - return session_transaction.client_action_lock - - -def _start_server(session_ids: list[str]) -> None: - print("start server", session_ids) - subprocess.Popen( - [ - "python", - "chat_server.py", - "start-server-with-session-ids", - *session_ids, - ] - ) - - -@app.get("/enter_waiting_room/{sender_id}") -async def enter_waiting_room(sender_id: str) -> str: - matchings_in_waiting_room = cast( - list[MatchingInWaitingRoom], - MatchingInWaitingRoom.find().all(), - ) - for matching_in_waiting_room in matchings_in_waiting_room: - if sender_id in matching_in_waiting_room.client_ids: - index = matching_in_waiting_room.client_ids.index(sender_id) - match index: - case 0: - if len(matching_in_waiting_room.client_ids) > 1: - _start_server(matching_in_waiting_room.session_ids) - matching_in_waiting_room.session_id_retrieved[0] = "true" - return matching_in_waiting_room.session_ids[0] - else: - if ( - datetime.now().timestamp() - - matching_in_waiting_room.timestamp - > WAITING_ROOM_TIMEOUT - ): - MatchingInWaitingRoom.delete(matching_in_waiting_room.pk) - _start_server(matching_in_waiting_room.session_ids) - return matching_in_waiting_room.session_ids[0] - else: - return "" - case 1: - if matching_in_waiting_room.session_id_retrieved[0]: - if ( - datetime.now().timestamp() - - matching_in_waiting_room.timestamp - > WAITING_ROOM_TIMEOUT - ): - MatchingInWaitingRoom.delete(matching_in_waiting_room.pk) - _start_server(matching_in_waiting_room.session_ids[1:]) - return matching_in_waiting_room.session_ids[1] - else: - return "" - else: - matching_in_waiting_room.session_id_retrieved[1] = "true" - MatchingInWaitingRoom.delete(matching_in_waiting_room.pk) - return matching_in_waiting_room.session_ids[1] - case _: - assert False, f"{matching_in_waiting_room} has more than 2 clients, not expected" - else: - lock = Lock(conn, "lock:check_available_spots") - with lock: - matchings_in_waiting_room = cast( - list[MatchingInWaitingRoom], - MatchingInWaitingRoom.find().all(), - ) - for matching_in_waiting_room in matchings_in_waiting_room: - if len(matching_in_waiting_room.client_ids) == 1: - matching_in_waiting_room.timestamp = datetime.now().timestamp() - matching_in_waiting_room.client_ids.append(sender_id) - matching_in_waiting_room.session_ids.append(str(uuid.uuid4())) - matching_in_waiting_room.session_id_retrieved.append("") - matching_in_waiting_room.save() - return "" - - matching_in_waiting_room = MatchingInWaitingRoom( - timestamp=datetime.now().timestamp(), - client_ids=[sender_id], - session_ids=[str(uuid.uuid4())], - session_id_retrieved=[""], - ) - matching_in_waiting_room.save() - return "" - - -class PrettyJSONResponse(Response): - media_type = "application/json" - - def render(self, content: typing.Any) -> bytes: - return json.dumps( - content, - ensure_ascii=False, - allow_nan=False, - indent=4, - separators=(", ", ": "), - ).encode("utf-8") - - -@app.get("/get_episode/{episode_id}", response_class=PrettyJSONResponse) -async def get_episode(episode_id: str) -> EpisodeLog: - try: - episode_log = EpisodeLog.get(pk=episode_id) - except Exception as e: - raise HTTPException(status_code=404, detail=f"Episode not found: {e}") - return episode_log - - -@app.get("/get_agent/{agent_id}", response_class=PrettyJSONResponse) -async def get_agent(agent_id: str) -> AgentProfile: - try: - agent_profile = AgentProfile.get(pk=agent_id) - except Exception as e: - raise HTTPException(status_code=404, detail=f"Agent not found: {e}") - return agent_profile - - -client = TestClient(app) - - -def test_connect() -> None: - session_id = str(uuid.uuid4()) - server_id = str(uuid.uuid4()) - response = client.post(f"/connect/{session_id}/server/{server_id}") - assert response.status_code == 200 - assert response.json() == [] - - sessions = cast( - list[SessionTransaction], - SessionTransaction.find(SessionTransaction.session_id == session_id).all(), - ) - assert len(sessions) == 1 - assert sessions[0].server_id == server_id - assert sessions[0].client_id == "" - assert sessions[0].message_list == [] - SessionTransaction.delete(sessions[0].pk) - - -def test_send_message() -> None: - session_id = str(uuid.uuid4()) - server_id = str(uuid.uuid4()) - response = client.post(f"/connect/{session_id}/server/{server_id}") - assert response.status_code == 200 - assert response.json() == [] - - response = client.post( - f"/send/{session_id}/{server_id}", - json="hello", - ) - assert response.status_code == 200 - - sessions = cast( - list[SessionTransaction], - SessionTransaction.find(SessionTransaction.session_id == session_id).all(), - ) - assert len(sessions) == 1 - assert sessions[0].server_id == server_id - assert sessions[0].client_id == "" - assert len(sessions[0].message_list) == 1 - - message = sessions[0].message_list[0] - assert message.sender == "server" - assert message.message == "hello" - - -@pytest.mark.asyncio -async def test_waiting_room() -> None: - async def _join_after_seconds( - seconds: float, - ) -> str: - sender_id = str(uuid.uuid4()) - await asyncio.sleep(seconds) - while True: - response = client.get(f"/enter_waiting_room/{sender_id}") - if response.text: - break - await asyncio.sleep(0.1) - return str(response.text) - - try: - await asyncio.wait_for( - asyncio.gather( - _join_after_seconds(random.random() * 199), - _join_after_seconds(random.random() * 199), - _join_after_seconds(random.random() * 199), - _join_after_seconds(random.random() * 199), - _join_after_seconds(random.random() * 199), - ), - timeout=200, - ) - except (TimeoutError, asyncio.TimeoutError) as _: - pass diff --git a/sotopia-chat/frontend/.eslintrc.json b/sotopia-chat/frontend/.eslintrc.json new file mode 100644 index 000000000..68821099b --- /dev/null +++ b/sotopia-chat/frontend/.eslintrc.json @@ -0,0 +1,12 @@ +{ + "extends": [ + "next/core-web-vitals", + "plugin:@typescript-eslint/recommended", + "prettier" + ], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "project": "./tsconfig.json" + }, + "plugins": ["@typescript-eslint"] +} diff --git a/sotopia-chat/frontend/.gitignore b/sotopia-chat/frontend/.gitignore new file mode 100644 index 000000000..016ef5f13 --- /dev/null +++ b/sotopia-chat/frontend/.gitignore @@ -0,0 +1,17 @@ +# Dependencies +node_modules +.pnp* +pnpm-lock.yaml +package-lock.json +yarn.lock + +# Build output +.next +out +dist + +# IDEs +.DS_Store +.idea +*.log +.env.local diff --git a/sotopia-chat/frontend/.prettierrc b/sotopia-chat/frontend/.prettierrc new file mode 100644 index 000000000..2879698d5 --- /dev/null +++ b/sotopia-chat/frontend/.prettierrc @@ -0,0 +1,5 @@ +{ + "semi": true, + "singleQuote": false, + "trailingComma": "es5" +} diff --git a/sotopia-chat/frontend/README.md b/sotopia-chat/frontend/README.md new file mode 100644 index 000000000..0a2deaa90 --- /dev/null +++ b/sotopia-chat/frontend/README.md @@ -0,0 +1,52 @@ +# Sotopia Chat Arena Frontend + +This Next.js 14 workspace hosts the modular user interface for Sotopia social +games. It reuses the existing `chat_server.py` / `fastapi_server.py` +infrastructure and now exposes a lightweight registry so multiple games can +plug into a shared consent → lobby → experience flow. The Werewolf experience +is implemented in `src/games/werewolf` as the first module. + +## Getting Started + +```bash +cd sotopia-chat/frontend +pnpm install +pnpm dev # launches http://localhost:3000 +``` + +Set `NEXT_PUBLIC_API_BASE_URL` in `.env.local` to point at the FastAPI service +from `sotopia-chat/fastapi_server.py`. + +### Prerequisite: Redis + +The backend stores werewolf session state in Redis. Start a local Redis before running the API: + +```bash +# If you have redis-server installed locally +redis-server + +# Or, if you installed Redis via Homebrew and want it as a background service: +brew services start redis +``` + +The default URL is `redis://localhost:6379`; override with `REDIS_OM_URL` if needed. + +## Available Scripts + +- `npm run dev` – start the Next.js dev server +- `npm run build` / `npm run start` – production build & server +- `npm run lint` – run ESLint checks +- `npm run type-check` – TypeScript project validation + +## Local Flow + +1. Visit `/` and select a game from the arena landing page. +2. Accept the consent form rendered via the shared core component. +3. Provide your participant identifier (email or assigned token) in the + module-specific lobby; for Werewolf this creates a FastAPI session. +4. Once matched, the game board polls `/games/{slug}/sessions/{id}` and submits + actions via `/games/{slug}/sessions/{id}/actions`. + +Additional games can be added by creating a folder under `src/games/` +with API helpers, hooks, and components, then registering it in +`src/core/config/games.ts`. diff --git a/sotopia-chat/frontend/app/admin/page.tsx b/sotopia-chat/frontend/app/admin/page.tsx new file mode 100644 index 000000000..dcd4597d9 --- /dev/null +++ b/sotopia-chat/frontend/app/admin/page.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { useState } from "react"; +import useSWR from "swr"; +import { Button } from "@/components/ui/button"; +import { + fetchAdminStatus, + setGameEnabled, + type AdminStatus, +} from "@/lib/api"; + +export default function AdminPage() { + const [token, setToken] = useState(""); + const [submittedToken, setSubmittedToken] = useState(null); + + const { data, error, isLoading, mutate } = useSWR( + submittedToken ? ["admin-status", submittedToken] : null, + () => fetchAdminStatus(submittedToken as string), + { refreshInterval: 5000 } + ); + + const submitToken = (event: React.FormEvent) => { + event.preventDefault(); + if (token.trim()) { + setSubmittedToken(token.trim()); + } + }; + + const toggleGame = async (slug: string, enabled: boolean) => { + if (!submittedToken) return; + await setGameEnabled(slug, enabled, submittedToken); + mutate(); + }; + + return ( +
+
+

+ Admin Console +

+

Platform Health

+

+ Requires admin token. Monitor queue saturation, Redis health, and toggle games. +

+
+ +
+ setToken(e.target.value)} + placeholder="Enter admin token" + className="flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + /> + +
+ + {isLoading && submittedToken && ( +
+ Loading status… +
+ )} + + {error && submittedToken && ( +
+ Failed to load status. Verify admin token. +
+ )} + + {data && ( +
+
+ + + +
+ +
+ + + + + + + + + + + + {data.games.map((game) => ( + + + + + + + + ))} + +
GameQueue DepthAvg WaitStatusActions
{game.title}{game.queueDepth}{game.avgWaitSeconds}s + {game.enabled ? ( + + Enabled + + ) : ( + + Disabled + + )} + + +
+
+
+ )} +
+ ); +} + +function StatCard({ label, value }: { label: string; value: string }) { + return ( +
+

+ {label} +

+

{value}

+
+ ); +} diff --git a/sotopia-chat/frontend/app/games/[slug]/page.tsx b/sotopia-chat/frontend/app/games/[slug]/page.tsx new file mode 100644 index 000000000..c1cae5419 --- /dev/null +++ b/sotopia-chat/frontend/app/games/[slug]/page.tsx @@ -0,0 +1,17 @@ +import { notFound } from "next/navigation"; +import { GameExperience } from "@/core/components/game-experience"; +import { getGameBySlug } from "@/core/config/games"; + +interface GamePageProps { + params: { slug: string }; +} + +export default function GamePage({ params }: GamePageProps) { + const game = getGameBySlug(params.slug); + + if (!game) { + notFound(); + } + + return ; +} diff --git a/sotopia-chat/frontend/app/globals.css b/sotopia-chat/frontend/app/globals.css new file mode 100644 index 000000000..bd1877fee --- /dev/null +++ b/sotopia-chat/frontend/app/globals.css @@ -0,0 +1,75 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --background: 0 0% 100%; + --foreground: 222.2 47.4% 11.2%; + + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + + --popover: 0 0% 100%; + --popover-foreground: 222.2 47.4% 11.2%; + + --card: 0 0% 100%; + --card-foreground: 222.2 47.4% 11.2%; + + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + + --primary: 222.2 47.4% 11.2%; + --primary-foreground: 210 40% 98%; + + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + + --destructive: 0 100% 50%; + --destructive-foreground: 210 40% 98%; + + --ring: 215 20.2% 65.1%; + + --radius: 0.5rem; +} + +.dark { + --background: 222.2 47.4% 11.2%; + --foreground: 210 40% 98%; + + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + + --popover: 222.2 47.4% 11.2%; + --popover-foreground: 210 40% 98%; + + --card: 222.2 47.4% 11.2%; + --card-foreground: 210 40% 98%; + + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + + --primary: 210 40% 98%; + --primary-foreground: 222.2 47.4% 11.2%; + + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + + --destructive: 0 63% 31%; + --destructive-foreground: 210 40% 98%; + + --ring: 212.7 26.8% 83.9%; +} + +* { + @apply border-border; +} + +body { + @apply bg-background text-foreground; +} diff --git a/sotopia-chat/frontend/app/history/page.tsx b/sotopia-chat/frontend/app/history/page.tsx new file mode 100644 index 000000000..909bd90ba --- /dev/null +++ b/sotopia-chat/frontend/app/history/page.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { useEffect } from "react"; +import useSWR from "swr"; +import { + fetchPersonalHistory, + type PersonalHistoryResponse, +} from "@/lib/api"; +import { getStoredUser } from "@/lib/auth-api"; +import { useRouter } from "next/navigation"; + +export default function HistoryPage() { + const router = useRouter(); + const user = getStoredUser(); + const participantId = user?.pk ?? null; + + const { data, error, isLoading } = useSWR( + participantId ? ["history", participantId] : null, + () => fetchPersonalHistory(participantId as string), + { refreshInterval: 10000 } + ); + + useEffect(() => { + if (typeof window !== "undefined" && !participantId) { + router.replace("/login"); + } + }, [participantId, router]); + + if (!participantId) { + return ( +
+

Redirecting to login…

+
+ ); + } + + return ( +
+
+

+ Match History +

+

Your Game History

+

+ Recent games you have played. +

+
+ + {isLoading && participantId && ( +
+ Loading history… +
+ )} + + {error && participantId && ( +
+ Failed to load history. Participant may have no recent games. +
+ )} + + {data && ( +
+

+ Showing latest {data.history.length} matches for{" "} + {data.participantId} +

+
    + {data.history.map((entry, idx) => ( +
  • +
    +
    +

    + {entry.game} +

    +

    + vs. {entry.opponentModel} +

    +
    + + Winner: {entry.winner === "human" ? "You" : "AI"} + +
    +
    + + Duration: {entry.durationSeconds.toFixed(0)}s + + + {new Date(entry.recordedAt).toLocaleString()} + +
    +
  • + ))} + {!data.history.length && ( +
  • + No recent matches recorded for this participant. +
  • + )} +
+
+ )} +
+ ); +} diff --git a/sotopia-chat/frontend/app/layout.tsx b/sotopia-chat/frontend/app/layout.tsx new file mode 100644 index 000000000..ed04312d2 --- /dev/null +++ b/sotopia-chat/frontend/app/layout.tsx @@ -0,0 +1,30 @@ +import "./globals.css"; +import type { Metadata } from "next"; +import { Providers } from "@/components/providers"; + +export const metadata: Metadata = { + title: "Sotopia Social Game Arena", + description: "Modular Next.js frontend for Sotopia research games", +}; + +export default function RootLayout({ + children +}: { + children: React.ReactNode; +}) { + return ( + + + +
+ {children} +
+
+ + + ); +} diff --git a/sotopia-chat/frontend/app/leaderboard/page.tsx b/sotopia-chat/frontend/app/leaderboard/page.tsx new file mode 100644 index 000000000..df1d06328 --- /dev/null +++ b/sotopia-chat/frontend/app/leaderboard/page.tsx @@ -0,0 +1,86 @@ +"use client"; + +import useSWR from "swr"; +import { fetchLeaderboard, type LeaderboardResponse } from "@/lib/api"; + +export default function LeaderboardPage() { + const { data, error, isLoading } = useSWR( + "leaderboard", + fetchLeaderboard, + { refreshInterval: 10000 } + ); + + return ( +
+
+

+ Arena Rankings +

+

Game Leaderboard

+

+ Tracking aggregate human vs. AI performance across all games. +

+
+ + {isLoading && ( +
+ Loading leaderboard… +
+ )} + + {error && ( +
+ Failed to load leaderboard. Try again later. +
+ )} + + {data && ( +
+ + + + + + + + + + + + + {data.entries.map((entry) => ( + + + + + + + + + ))} + +
GameMatchesHuman Win % + Avg Duration (s) + Human WinsAI Wins
+ {entry.game} + + {entry.totalMatches} + + {(entry.humanWinRate * 100).toFixed(1)}% + + {entry.avgDurationSeconds.toFixed(0)} + + {entry.humanWins} + {entry.aiWins}
+

+ Last updated:{" "} + {new Date(data.lastUpdated * 1000).toLocaleTimeString()} +

+
+ )} +
+ ); +} diff --git a/sotopia-chat/frontend/app/login/page.tsx b/sotopia-chat/frontend/app/login/page.tsx new file mode 100644 index 000000000..2de5713a1 --- /dev/null +++ b/sotopia-chat/frontend/app/login/page.tsx @@ -0,0 +1,184 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { useAuth } from "@/contexts/auth-context"; +import { AuthError } from "@/lib/auth-api"; +import { Button } from "@/components/ui/button"; + +export default function LoginPage() { + const router = useRouter(); + const { login, isAuthenticated, isLoading, error, clearError } = useAuth(); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [localError, setLocalError] = useState(null); + + // Redirect if already authenticated + useEffect(() => { + if (isAuthenticated && !isLoading) { + router.push("/"); + } + }, [isAuthenticated, isLoading, router]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLocalError(null); + clearError(); + + if (!username.trim() || !password) { + setLocalError("Username and password are required"); + return; + } + + setSubmitting(true); + try { + await login({ username: username.trim(), password }); + router.push("/"); + } catch (err) { + // Extract error detail from AuthError + if (err instanceof AuthError && err.detail) { + const detail = err.detail as { detail?: string }; + setLocalError(detail.detail || "Login failed"); + } else if (err instanceof Error) { + setLocalError("Invalid username or password"); + } + } finally { + setSubmitting(false); + } + }; + + if (isLoading) { + return ( +
+
Loading...
+
+ ); + } + + return ( +
+
+
+

Welcome Back

+

+ Sign in to your Sotopia Arena account +

+
+ +
+ {(localError || error) && ( +
+ {localError || error} +
+ )} + +
+
+ + setUsername(e.target.value)} + className="mt-1 block w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus:border-ring focus:outline-none focus:ring-1 focus:ring-ring" + placeholder="your_username" + /> +
+ +
+ + setPassword(e.target.value)} + className="mt-1 block w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus:border-ring focus:outline-none focus:ring-1 focus:ring-ring" + placeholder="••••••••" + /> +
+
+ + +
+ +
+
+ +
+
+ + Or continue with + +
+
+ +
+ + + +
+ +

+ Don't have an account?{" "} + + Sign up + +

+ +

+ + ← Back to Arena + +

+
+
+ ); +} diff --git a/sotopia-chat/frontend/app/page.tsx b/sotopia-chat/frontend/app/page.tsx new file mode 100644 index 000000000..4f45cb308 --- /dev/null +++ b/sotopia-chat/frontend/app/page.tsx @@ -0,0 +1,523 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; +import useSWR from "swr"; +import { games } from "@/core/config/games"; +import { Button } from "@/components/ui/button"; +import { + enqueueMatchmaking, + fetchQueueOverview, + fetchTicketStatus, + cancelTicket, + type QueueOverview, + type TicketStatus, +} from "@/lib/api"; +import { useIdentity } from "@/hooks/use-identity"; +import { useAuth } from "@/contexts/auth-context"; +import { AuthHeader } from "@/components/auth-header"; + +export default function GamesLandingPage() { + const router = useRouter(); + const identity = useIdentity(); + const { user, isAuthenticated } = useAuth(); + const [selected, setSelected] = useState([]); + const [queueMessage, setQueueMessage] = useState(null); + const [queueError, setQueueError] = useState(null); + const [queueLoading, setQueueLoading] = useState(false); + const [queueParticipantId, setQueueParticipantId] = useState(""); + const [activeTicket, setActiveTicket] = useState(null); + + const allSlugs = useMemo(() => games.map((game) => game.slug), []); + const statusStyles = { + online: "bg-emerald-100 text-emerald-800", + maintenance: "bg-amber-100 text-amber-900", + "coming-soon": "bg-slate-200 text-slate-700", + } as const; + + const { + data: queueOverview, + error: matchmakingStatusError, + isLoading: matchmakingStatusLoading, + } = useSWR("matchmaking-status", fetchQueueOverview, { + refreshInterval: 5000, + }); + const matchmakingStatus = queueOverview?.globalStats; + const { + data: ticketStatus, + error: ticketStatusError, + isLoading: ticketStatusLoading, + } = useSWR( + activeTicket ? ["ticket-status", activeTicket] : null, + () => fetchTicketStatus(activeTicket as string), + { refreshInterval: 4000 } + ); + useEffect(() => { + if ( + identity.identity?.participantId && + !queueParticipantId && + identity.identity.participantId.length + ) { + setQueueParticipantId(identity.identity.participantId); + } + }, [identity.identity?.participantId, queueParticipantId]); + + const toggleGame = (slug: string) => { + setSelected((prev) => + prev.includes(slug) + ? prev.filter((item) => item !== slug) + : [...prev, slug] + ); + setQueueMessage(null); + setQueueError(null); + }; + + const selectAll = () => { + setSelected(allSlugs); + setQueueMessage(null); + setQueueError(null); + }; + + const clearSelection = () => { + setSelected([]); + setQueueMessage(null); + setQueueError(null); + }; + + const handlePlaySelected = () => { + if (!selected.length) return; + router.push(`/games/${selected[0]}`); + }; + + const handleQueueSelected = async () => { + const participantId = + identity.identity?.participantId || queueParticipantId.trim(); + if (!participantId) { + setQueueError("Set your participant identity first."); + return; + } + if (!selected.length || queueLoading) { + if (!selected.length) { + setQueueError("Select at least one game to queue."); + } + return; + } + setQueueLoading(true); + setQueueError(null); + setQueueMessage(null); + try { + const response = await enqueueMatchmaking({ + participantId, + games: selected, + }); + setQueueMessage( + `${response.message} Estimated wait: ${response.estimatedWaitSeconds}s (position ${response.position}).` + ); + setActiveTicket(response.ticketId); + } catch (error) { + console.error(error); + setQueueError( + error instanceof Error + ? error.message + : "Failed to queue selected games." + ); + } finally { + setQueueLoading(false); + } + }; + + const formatPlayers = (min?: number, max?: number) => { + if (!min && !max) return null; + if (min && max) { + if (min === max) return `${min} players`; + return `${min}-${max} players`; + } + return `${min ?? max} players`; + }; + + const formatDuration = (minutes?: number) => { + if (!minutes) return null; + return `~${minutes} min`; + }; + + const queueDisabled = selected.length === 0 || queueLoading; + + const handleCancelTicket = async () => { + if (!activeTicket) return; + try { + await cancelTicket(activeTicket); + setQueueMessage("Ticket cancelled."); + setActiveTicket(null); + } catch (error) { + console.error(error); + setQueueError( + error instanceof Error + ? error.message + : "Failed to cancel ticket." + ); + } + }; + + return ( + <> + +
+
+

+ Sotopia Social Game Arena +

+

+ Choose a research game to play with humans + LLM agents +

+

+ Each experience blends experimental storytelling with structured + social deduction mechanics. Select a title below to review the + consent form, set up the lobby, and jump into a live match. +

+
+ + +
+
+ +
+ {isAuthenticated && user ? ( + /* Logged in user - show their info */ +
+
+

+ Welcome Back +

+

{user.username}

+
+ ELO: {user.elo_rating} + Games: {user.games_played} + Wins: {user.games_won} +
+
+
+ +
+
+ ) : ( + /* Guest mode - prompt to login */ +
+
+

+ Guest Mode +

+

+ Sign in to track your progress and compete on the leaderboard +

+
+
+ + +
+
+ )} +
+ +
+
+

+ Avg. Queue Time +

+

+ {matchmakingStatus?.avgWaitSeconds + ? `${matchmakingStatus.avgWaitSeconds}s` + : matchmakingStatusLoading + ? "…" + : "—"} +

+
+
+

+ Active Sessions +

+

+ {matchmakingStatus?.activeSessions ?? + (matchmakingStatusLoading ? "…" : "—")} +

+
+
+

+ Queue Depth +

+

+ {matchmakingStatus?.queueDepth ?? + (matchmakingStatusLoading ? "…" : "—")} +

+
+
+

+ Server Status +

+

+ {matchmakingStatus?.serverStatus ?? + (matchmakingStatusLoading ? "…" : "Unknown")} +

+

+ Updated{" "} + {matchmakingStatus?.lastUpdated + ? new Date( + matchmakingStatus.lastUpdated * 1000 + ).toLocaleTimeString() + : ""} +

+
+
+ +
+
+
+ + +
+ + {selected.length} game + {selected.length === 1 ? "" : "s"} selected + +
+ +
+ {games.map((game) => { + const queueGame = queueOverview?.games.find( + (entry) => entry.slug === game.slug + ); + const isEnabled = + queueGame?.enabled ?? + (game.status !== "coming-soon" && + game.status !== "maintenance"); + return ( + + ); + })} +
+
+ +
+
+ setQueueParticipantId(e.target.value)} + placeholder="Participant or model ID (optional)" + className="flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + /> +
+
+ + +
+

+ Queueing lets us match you to live experiments once matchmaking is available. + Play jumps straight into the first selected game. +

+ {queueMessage && ( +
+ {queueMessage} +
+ )} + {queueError && ( +
+ {queueError} +
+ )} + {ticketStatusError && ( +
+ Failed to refresh ticket status. +
+ )} + {matchmakingStatusError && ( +
+ {matchmakingStatusError instanceof Error + ? matchmakingStatusError.message + : "Unable to load matchmaking stats."} +
+ )} + {matchmakingStatus?.issues?.length ? ( +
+ {matchmakingStatus.issues.map((issue) => ( +
+ {issue} +
+ ))} +
+ ) : null} +
+ + {activeTicket && ( +
+
+
+

+ Ticket {activeTicket.slice(0, 8)} +

+

+ Status:{" "} + {ticketStatusLoading + ? "Refreshing…" + : ticketStatus?.status ?? "unknown"} +

+
+
+ + +
+
+ {ticketStatus?.matchedGame && ( +

+ Matched to {ticketStatus.matchedGame}. Launching session soon… +

+ )} +
+ )} +
+ + ); +} diff --git a/sotopia-chat/frontend/app/profile/page.tsx b/sotopia-chat/frontend/app/profile/page.tsx new file mode 100644 index 000000000..a656cd5b1 --- /dev/null +++ b/sotopia-chat/frontend/app/profile/page.tsx @@ -0,0 +1,405 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import useSWR from "swr"; +import { useAuth } from "@/contexts/auth-context"; +import { Button } from "@/components/ui/button"; +import { + getProfile, + getEloHistory, + getMatchHistory, + type ProfileStats, + type EloHistoryEntry, + type MatchHistoryEntry, +} from "@/lib/auth-api"; + +export default function ProfilePage() { + const router = useRouter(); + const { user, isAuthenticated, isLoading: authLoading, logout } = useAuth(); + const [activeTab, setActiveTab] = useState<"overview" | "elo" | "matches">("overview"); + + // Redirect if not authenticated + useEffect(() => { + if (!authLoading && !isAuthenticated) { + router.push("/login"); + } + }, [authLoading, isAuthenticated, router]); + + const { + data: profile, + error: profileError, + isLoading: isProfileLoading, + } = useSWR( + isAuthenticated ? "profile" : null, + getProfile, + { refreshInterval: 30000 } + ); + + const { + data: eloHistory, + error: eloError, + isLoading: eloLoading, + } = useSWR( + isAuthenticated && activeTab === "elo" ? "elo-history" : null, + () => getEloHistory(50) + ); + + const { + data: matchHistory, + error: matchError, + isLoading: matchLoading, + } = useSWR( + isAuthenticated && activeTab === "matches" ? "match-history" : null, + () => getMatchHistory(50) + ); + + if (authLoading || !isAuthenticated) { + return ( +
+
Loading...
+
+ ); + } + + const handleLogout = () => { + logout(); + router.push("/"); + }; + + return ( +
+ {/* Header */} +
+
+

+ Player Profile +

+

+ {isProfileLoading ? "Loading..." : (profile?.username || user?.username || "—")} +

+
+
+ + + + +
+
+ + {profileError && ( +
+ Failed to load profile. Please try again. +
+ )} + + {/* Stats Cards */} + {profile && ( +
+ + + + +
+ )} + + {/* Tabs */} +
+ setActiveTab("overview")} + > + Overview + + setActiveTab("elo")} + > + ELO History + + setActiveTab("matches")} + > + Match History + +
+ + {/* Tab Content */} + {activeTab === "overview" && profile && ( +
+
+

Account Info

+
+
+
Email
+
{profile.email}
+
+
+
Auth Provider
+
{profile.auth_provider}
+
+
+
Member Since
+
{new Date(profile.created_at).toLocaleDateString()}
+
+
+
+ +
+

Rank Progress

+
+ {profile.rank_emoji} +
+

{profile.rank_tier}

+

+ {profile.elo_rating} ELO +

+
+
+
+ +
+
+
+ )} + + {activeTab === "elo" && ( +
+ {eloLoading && ( +
+ Loading ELO history... +
+ )} + {eloError && ( +
+ Failed to load ELO history. +
+ )} + {eloHistory && eloHistory.length === 0 && ( +
+ No ELO history yet. Play some games to see your progress! +
+ )} + {eloHistory && eloHistory.length > 0 && ( +
+ + + + + + + + + + + + {eloHistory.map((entry, idx) => ( + + + + + + + + ))} + +
DateGameResultChangeRating
+ {new Date(entry.created_at).toLocaleDateString()} + + {entry.game_type} + + + {entry.won ? "Win" : "Loss"} + + + 0 + ? "text-emerald-600" + : "text-red-600" + } + > + {entry.elo_change > 0 ? "+" : ""} + {entry.elo_change} + + + {entry.elo_after} +
+
+ )} +
+ )} + + {activeTab === "matches" && ( +
+ {matchLoading && ( +
+ Loading match history... +
+ )} + {matchError && ( +
+ Failed to load match history. +
+ )} + {matchHistory && matchHistory.length === 0 && ( +
+ No matches yet. Start playing to build your history! +
+ )} + {matchHistory && matchHistory.length > 0 && ( +
    + {matchHistory.map((match, idx) => ( +
  • +
    +
    +

    + {match.game_type} +

    +

    + vs. {match.opponent_name} ({match.opponent_type}) +

    +
    +
    + + {match.won ? "Victory" : "Defeat"} + +

    0 + ? "text-emerald-600" + : "text-red-600" + }`} + > + {match.elo_change > 0 ? "+" : ""} + {match.elo_change} ELO +

    +
    +
    +

    + {new Date(match.played_at).toLocaleString()} +

    +
  • + ))} +
+ )} +
+ )} +
+ ); +} + +function StatCard({ + label, + value, + subtext, +}: { + label: string; + value: string; + subtext?: string; +}) { + return ( +
+

+ {label} +

+

{value}

+ {subtext && ( +

{subtext}

+ )} +
+ ); +} + +function TabButton({ + active, + onClick, + children, +}: { + active: boolean; + onClick: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} + +function RankProgressBar({ elo }: { elo: number }) { + const ranks = [ + { name: "Bronze", min: 0, max: 1199, color: "bg-amber-600" }, + { name: "Silver", min: 1200, max: 1399, color: "bg-gray-400" }, + { name: "Gold", min: 1400, max: 1599, color: "bg-yellow-500" }, + { name: "Platinum", min: 1600, max: 1799, color: "bg-cyan-400" }, + { name: "Diamond", min: 1800, max: 1999, color: "bg-blue-500" }, + { name: "Master", min: 2000, max: 3000, color: "bg-purple-600" }, + ]; + + const currentRank = ranks.find((r) => elo >= r.min && elo <= r.max) || ranks[0]; + const progress = ((elo - currentRank.min) / (currentRank.max - currentRank.min)) * 100; + const nextRank = ranks[ranks.indexOf(currentRank) + 1]; + + return ( +
+
+ {currentRank.name} ({currentRank.min}) + {nextRank ? `${nextRank.name} (${nextRank.min})` : "Max Rank"} +
+
+
+
+ {nextRank && ( +

+ {nextRank.min - elo} ELO to {nextRank.name} +

+ )} +
+ ); +} diff --git a/sotopia-chat/frontend/app/register/page.tsx b/sotopia-chat/frontend/app/register/page.tsx new file mode 100644 index 000000000..068ec5035 --- /dev/null +++ b/sotopia-chat/frontend/app/register/page.tsx @@ -0,0 +1,262 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { useAuth } from "@/contexts/auth-context"; +import { AuthError } from "@/lib/auth-api"; +import { Button } from "@/components/ui/button"; + +export default function RegisterPage() { + const router = useRouter(); + const { register, isAuthenticated, isLoading, error, clearError } = useAuth(); + const [username, setUsername] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [localError, setLocalError] = useState(null); + + // Redirect if already authenticated + useEffect(() => { + if (isAuthenticated && !isLoading) { + router.push("/"); + } + }, [isAuthenticated, isLoading, router]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLocalError(null); + clearError(); + + // Validation + if (!username.trim() || !email.trim() || !password) { + setLocalError("All fields are required"); + return; + } + + if (username.length < 3) { + setLocalError("Username must be at least 3 characters"); + return; + } + + if (!/^[a-zA-Z0-9_]+$/.test(username)) { + setLocalError("Username can only contain letters, numbers, and underscores"); + return; + } + + if (password.length < 6) { + setLocalError("Password must be at least 6 characters"); + return; + } + + if (password !== confirmPassword) { + setLocalError("Passwords do not match"); + return; + } + + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + setLocalError("Please enter a valid email address"); + return; + } + + setSubmitting(true); + try { + await register({ + username: username.trim(), + email: email.trim(), + password, + }); + router.push("/"); + } catch (err) { + // Extract error detail from AuthError + if (err instanceof AuthError && err.detail) { + const detail = err.detail as { detail?: string }; + setLocalError(detail.detail || "Registration failed"); + } else if (err instanceof Error) { + setLocalError("Registration failed. Please try again."); + } + } finally { + setSubmitting(false); + } + }; + + if (isLoading) { + return ( +
+
Loading...
+
+ ); + } + + return ( +
+
+
+

Create Account

+

+ Join Sotopia Arena and compete against AI +

+
+ +
+ {(localError || error) && ( +
+ {localError || error} +
+ )} + +
+
+ + setUsername(e.target.value)} + className="mt-1 block w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus:border-ring focus:outline-none focus:ring-1 focus:ring-ring" + placeholder="your_username" + /> +

+ 3-30 characters, letters, numbers, and underscores only +

+
+ +
+ + setEmail(e.target.value)} + className="mt-1 block w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus:border-ring focus:outline-none focus:ring-1 focus:ring-ring" + placeholder="you@example.com" + /> +
+ +
+ + setPassword(e.target.value)} + className="mt-1 block w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus:border-ring focus:outline-none focus:ring-1 focus:ring-ring" + placeholder="••••••••" + /> +

+ At least 6 characters +

+
+ +
+ + setConfirmPassword(e.target.value)} + className="mt-1 block w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus:border-ring focus:outline-none focus:ring-1 focus:ring-ring" + placeholder="••••••••" + /> +
+
+ + +
+ +
+
+ +
+
+ + Or continue with + +
+
+ +
+ + + +
+ +

+ Already have an account?{" "} + + Sign in + +

+ +

+ + ← Back to Arena + +

+
+
+ ); +} diff --git a/sotopia-chat/frontend/next-env.d.ts b/sotopia-chat/frontend/next-env.d.ts new file mode 100644 index 000000000..4f11a03dc --- /dev/null +++ b/sotopia-chat/frontend/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/sotopia-chat/frontend/next.config.js b/sotopia-chat/frontend/next.config.js new file mode 100644 index 000000000..c5a5bd28e --- /dev/null +++ b/sotopia-chat/frontend/next.config.js @@ -0,0 +1,17 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, + experimental: { + externalDir: true + }, + images: { + remotePatterns: [ + { + protocol: "https", + hostname: "**" + } + ] + } +}; + +module.exports = nextConfig; diff --git a/sotopia-chat/frontend/package.json b/sotopia-chat/frontend/package.json new file mode 100644 index 000000000..b02779592 --- /dev/null +++ b/sotopia-chat/frontend/package.json @@ -0,0 +1,50 @@ +{ + "name": "werewolves-frontend", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@radix-ui/react-dialog": "^1.0.4", + "@radix-ui/react-radio-group": "^1.1.3", + "@radix-ui/react-tooltip": "^1.0.6", + "@vercel/analytics": "^1.0.0", + "ai": "^2.1.6", + "class-variance-authority": "^0.7.0", + "clsx": "^1.2.1", + "next": "14.2.4", + "next-auth": "^4.24.5", + "next-themes": "^0.2.1", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-hot-toast": "^2.4.1", + "react-markdown": "^9.0.1", + "react-textarea-autosize": "^8.4.1", + "remark-gfm": "^4.0.0", + "remark-math": "^6.0.0", + "swr": "^2.2.4", + "tailwind-merge": "^2.3.0", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/node": "^20.12.7", + "@types/react": "^18.2.28", + "@types/react-dom": "^18.2.12", + "@typescript-eslint/eslint-plugin": "^7.8.0", + "@typescript-eslint/parser": "^7.8.0", + "autoprefixer": "^10.4.19", + "eslint": "^8.57.0", + "eslint-config-next": "14.2.4", + "eslint-config-prettier": "^9.1.0", + "postcss": "^8.4.38", + "prettier": "^3.2.5", + "tailwindcss": "^3.4.3", + "tailwindcss-animate": "^1.0.7", + "typescript": "^5.4.5" + } +} diff --git a/sotopia-chat/frontend/postcss.config.js b/sotopia-chat/frontend/postcss.config.js new file mode 100644 index 000000000..fe66dd61a --- /dev/null +++ b/sotopia-chat/frontend/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {} + } +}; diff --git a/sotopia-chat/frontend/src/components/auth-header.tsx b/sotopia-chat/frontend/src/components/auth-header.tsx new file mode 100644 index 000000000..ae7fa402d --- /dev/null +++ b/sotopia-chat/frontend/src/components/auth-header.tsx @@ -0,0 +1,63 @@ +"use client"; + +import Link from "next/link"; +import { useAuth } from "@/contexts/auth-context"; +import { Button } from "@/components/ui/button"; + +export function AuthHeader() { + const { user, isAuthenticated, isLoading, logout } = useAuth(); + + return ( +
+
+ + Sotopia Arena + + + +
+
+ ); +} diff --git a/sotopia-chat/frontend/src/components/providers.tsx b/sotopia-chat/frontend/src/components/providers.tsx new file mode 100644 index 000000000..d7a78acc6 --- /dev/null +++ b/sotopia-chat/frontend/src/components/providers.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { Toaster } from "react-hot-toast"; +import { ThemeProvider } from "next-themes"; +import type { ThemeProviderProps } from "next-themes/dist/types"; +import { AuthProvider } from "@/contexts/auth-context"; + +export function Providers({ + children, + ...themeProps +}: ThemeProviderProps) { + return ( + + + + {children} + + + ); +} diff --git a/sotopia-chat/frontend/src/components/ui/button.tsx b/sotopia-chat/frontend/src/components/ui/button.tsx new file mode 100644 index 000000000..b1b558446 --- /dev/null +++ b/sotopia-chat/frontend/src/components/ui/button.tsx @@ -0,0 +1,55 @@ +"use client"; + +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground shadow hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", + outline: + "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", + ghost: + "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline" + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10" + } + }, + defaultVariants: { + variant: "default", + size: "default" + } + } +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps {} + +const Button = React.forwardRef( + ({ className, variant, size, ...props }, ref) => { + return ( + +
+

+ Now Playing +

+

{gameTitle}

+
+
+
+ + {dossierAvailable && ( + + )} + +
+ + + ); +} + +function PlayerDossierPanel({ + participantId, + playerName, + memory, + onClose, +}: { + participantId: string; + playerName?: string; + memory: ReturnType; + onClose: () => void; +}) { + const [notes, setNotes] = useState(""); + + useEffect(() => { + if (memory.memory?.notes !== undefined) { + setNotes(memory.memory.notes); + } + }, [memory.memory?.notes]); + + return ( +
+
+
+

Player Dossier

+

+ {playerName || participantId} — persistent notes & history +

+
+ +
+ +
+ +