Numerous WebUI animations, improvements, AI fixes, opporitunity cost-based decision logic, etc.

This commit is contained in:
Aaron D. Lee
2026-01-25 17:37:01 -05:00
parent d9073f862c
commit f80bab3b4b
35 changed files with 5772 additions and 403 deletions

View File

@@ -1,18 +1,33 @@
"""FastAPI WebSocket server for Golf card game."""
import uuid
import asyncio
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
import logging
import os
import uuid
from typing import Optional
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Depends, Header
from fastapi.responses import FileResponse
from pydantic import BaseModel
from config import config
from room import RoomManager, Room
from game import GamePhase, GameOptions
from ai import GolfAI, process_cpu_turn, get_all_profiles
from game_log import get_logger
from auth import get_auth_manager, User, UserRole
app = FastAPI(title="Golf Card Game")
# Configure logging
logging.basicConfig(
level=getattr(logging, config.LOG_LEVEL.upper(), logging.INFO),
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)
app = FastAPI(
title="Golf Card Game",
debug=config.DEBUG,
version="0.1.0",
)
room_manager = RoomManager()
@@ -22,6 +37,374 @@ async def health_check():
return {"status": "ok"}
# =============================================================================
# Auth Models
# =============================================================================
class RegisterRequest(BaseModel):
username: str
password: str
email: Optional[str] = None
invite_code: str # Room code or explicit invite code
class LoginRequest(BaseModel):
username: str
password: str
class SetupPasswordRequest(BaseModel):
username: str
new_password: str
class UpdateUserRequest(BaseModel):
username: Optional[str] = None
email: Optional[str] = None
role: Optional[str] = None
is_active: Optional[bool] = None
class ChangePasswordRequest(BaseModel):
new_password: str
class CreateInviteRequest(BaseModel):
max_uses: int = 1
expires_in_days: Optional[int] = 7
# =============================================================================
# Auth Dependencies
# =============================================================================
async def get_current_user(authorization: Optional[str] = Header(None)) -> Optional[User]:
"""Get current user from Authorization header."""
if not authorization:
return None
# Expect "Bearer <token>"
parts = authorization.split()
if len(parts) != 2 or parts[0].lower() != "bearer":
return None
token = parts[1]
auth = get_auth_manager()
return auth.get_user_from_session(token)
async def require_user(user: Optional[User] = Depends(get_current_user)) -> User:
"""Require authenticated user."""
if not user:
raise HTTPException(status_code=401, detail="Not authenticated")
if not user.is_active:
raise HTTPException(status_code=403, detail="Account disabled")
return user
async def require_admin(user: User = Depends(require_user)) -> User:
"""Require admin user."""
if not user.is_admin():
raise HTTPException(status_code=403, detail="Admin access required")
return user
# =============================================================================
# Auth Endpoints
# =============================================================================
@app.post("/api/auth/register")
async def register(request: RegisterRequest):
"""Register a new user with an invite code."""
auth = get_auth_manager()
# Validate invite code
invite_valid = False
inviter_username = None
# Check if it's an explicit invite code
invite = auth.get_invite_code(request.invite_code)
if invite and invite.is_valid():
invite_valid = True
inviter = auth.get_user_by_id(invite.created_by)
inviter_username = inviter.username if inviter else None
# Check if it's a valid room code
if not invite_valid:
room = room_manager.get_room(request.invite_code.upper())
if room:
invite_valid = True
# Room codes are like open invites
if not invite_valid:
raise HTTPException(status_code=400, detail="Invalid invite code")
# Create user
user = auth.create_user(
username=request.username,
password=request.password,
email=request.email,
invited_by=inviter_username,
)
if not user:
raise HTTPException(status_code=400, detail="Username or email already taken")
# Mark invite code as used (if it was an explicit invite)
if invite:
auth.use_invite_code(request.invite_code)
# Create session
session = auth.create_session(user)
return {
"user": user.to_dict(),
"token": session.token,
"expires_at": session.expires_at.isoformat(),
}
@app.post("/api/auth/login")
async def login(request: LoginRequest):
"""Login with username and password."""
auth = get_auth_manager()
# Check if user needs password setup (first login)
if auth.needs_password_setup(request.username):
raise HTTPException(
status_code=428, # Precondition Required
detail="Password setup required. Use /api/auth/setup-password endpoint."
)
user = auth.authenticate(request.username, request.password)
if not user:
raise HTTPException(status_code=401, detail="Invalid credentials")
session = auth.create_session(user)
return {
"user": user.to_dict(),
"token": session.token,
"expires_at": session.expires_at.isoformat(),
}
@app.post("/api/auth/setup-password")
async def setup_password(request: SetupPasswordRequest):
"""Set password for first-time login (admin accounts created without password)."""
auth = get_auth_manager()
# Verify user exists and needs setup
if not auth.needs_password_setup(request.username):
raise HTTPException(
status_code=400,
detail="Password setup not available for this account"
)
# Set the password
user = auth.setup_password(request.username, request.new_password)
if not user:
raise HTTPException(status_code=400, detail="Setup failed")
# Create session
session = auth.create_session(user)
return {
"user": user.to_dict(),
"token": session.token,
"expires_at": session.expires_at.isoformat(),
}
@app.get("/api/auth/check-setup/{username}")
async def check_setup_needed(username: str):
"""Check if a username needs password setup."""
auth = get_auth_manager()
needs_setup = auth.needs_password_setup(username)
return {
"username": username,
"needs_password_setup": needs_setup,
}
@app.post("/api/auth/logout")
async def logout(authorization: Optional[str] = Header(None)):
"""Logout current session."""
if authorization:
parts = authorization.split()
if len(parts) == 2 and parts[0].lower() == "bearer":
auth = get_auth_manager()
auth.invalidate_session(parts[1])
return {"status": "ok"}
@app.get("/api/auth/me")
async def get_me(user: User = Depends(require_user)):
"""Get current user info."""
return {"user": user.to_dict()}
@app.put("/api/auth/password")
async def change_own_password(
request: ChangePasswordRequest,
user: User = Depends(require_user)
):
"""Change own password."""
auth = get_auth_manager()
auth.change_password(user.id, request.new_password)
# Invalidate all other sessions
auth.invalidate_user_sessions(user.id)
# Create new session
session = auth.create_session(user)
return {
"status": "ok",
"token": session.token,
"expires_at": session.expires_at.isoformat(),
}
# =============================================================================
# Admin Endpoints
# =============================================================================
@app.get("/api/admin/users")
async def list_users(
include_inactive: bool = False,
admin: User = Depends(require_admin)
):
"""List all users (admin only)."""
auth = get_auth_manager()
users = auth.list_users(include_inactive=include_inactive)
return {"users": [u.to_dict() for u in users]}
@app.get("/api/admin/users/{user_id}")
async def get_user(user_id: str, admin: User = Depends(require_admin)):
"""Get user by ID (admin only)."""
auth = get_auth_manager()
user = auth.get_user_by_id(user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return {"user": user.to_dict()}
@app.put("/api/admin/users/{user_id}")
async def update_user(
user_id: str,
request: UpdateUserRequest,
admin: User = Depends(require_admin)
):
"""Update user (admin only)."""
auth = get_auth_manager()
# Convert role string to enum if provided
role = UserRole(request.role) if request.role else None
user = auth.update_user(
user_id=user_id,
username=request.username,
email=request.email,
role=role,
is_active=request.is_active,
)
if not user:
raise HTTPException(status_code=400, detail="Update failed (duplicate username/email?)")
return {"user": user.to_dict()}
@app.put("/api/admin/users/{user_id}/password")
async def admin_change_password(
user_id: str,
request: ChangePasswordRequest,
admin: User = Depends(require_admin)
):
"""Change user password (admin only)."""
auth = get_auth_manager()
if not auth.change_password(user_id, request.new_password):
raise HTTPException(status_code=404, detail="User not found")
# Invalidate all user sessions
auth.invalidate_user_sessions(user_id)
return {"status": "ok"}
@app.delete("/api/admin/users/{user_id}")
async def delete_user(user_id: str, admin: User = Depends(require_admin)):
"""Deactivate user (admin only)."""
auth = get_auth_manager()
# Don't allow deleting yourself
if user_id == admin.id:
raise HTTPException(status_code=400, detail="Cannot delete yourself")
if not auth.delete_user(user_id):
raise HTTPException(status_code=404, detail="User not found")
return {"status": "ok"}
@app.post("/api/admin/invites")
async def create_invite(
request: CreateInviteRequest,
admin: User = Depends(require_admin)
):
"""Create an invite code (admin only)."""
auth = get_auth_manager()
invite = auth.create_invite_code(
created_by=admin.id,
max_uses=request.max_uses,
expires_in_days=request.expires_in_days,
)
return {
"code": invite.code,
"max_uses": invite.max_uses,
"expires_at": invite.expires_at.isoformat() if invite.expires_at else None,
}
@app.get("/api/admin/invites")
async def list_invites(admin: User = Depends(require_admin)):
"""List all invite codes (admin only)."""
auth = get_auth_manager()
invites = auth.list_invite_codes()
return {
"invites": [
{
"code": i.code,
"created_by": i.created_by,
"created_at": i.created_at.isoformat(),
"expires_at": i.expires_at.isoformat() if i.expires_at else None,
"max_uses": i.max_uses,
"use_count": i.use_count,
"is_active": i.is_active,
"is_valid": i.is_valid(),
}
for i in invites
]
}
@app.delete("/api/admin/invites/{code}")
async def deactivate_invite(code: str, admin: User = Depends(require_admin)):
"""Deactivate an invite code (admin only)."""
auth = get_auth_manager()
if not auth.deactivate_invite_code(code):
raise HTTPException(status_code=404, detail="Invite code not found")
return {"status": "ok"}
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
await websocket.accept()
@@ -485,3 +868,35 @@ if os.path.exists(client_path):
@app.get("/app.js")
async def serve_js():
return FileResponse(os.path.join(client_path, "app.js"), media_type="application/javascript")
@app.get("/card-manager.js")
async def serve_card_manager():
return FileResponse(os.path.join(client_path, "card-manager.js"), media_type="application/javascript")
@app.get("/state-differ.js")
async def serve_state_differ():
return FileResponse(os.path.join(client_path, "state-differ.js"), media_type="application/javascript")
@app.get("/animation-queue.js")
async def serve_animation_queue():
return FileResponse(os.path.join(client_path, "animation-queue.js"), media_type="application/javascript")
def run():
"""Run the server using uvicorn."""
import uvicorn
logger.info(f"Starting Golf server on {config.HOST}:{config.PORT}")
logger.info(f"Debug mode: {config.DEBUG}")
uvicorn.run(
"main:app",
host=config.HOST,
port=config.PORT,
reload=config.DEBUG,
log_level=config.LOG_LEVEL.lower(),
)
if __name__ == "__main__":
run()