diff --git a/.github/workflows/backend-test.yaml b/.github/workflows/backend-test.yaml new file mode 100644 index 0000000..84fd3a6 --- /dev/null +++ b/.github/workflows/backend-test.yaml @@ -0,0 +1,45 @@ +name: Backend Test + +on: + push: + branches: [main] + paths: + - "backend/**" + - "src/fall_in/core/**" + - "src/fall_in/net/**" + pull_request: + branches: [main] + paths: + - "backend/**" + - "src/fall_in/core/**" + - "src/fall_in/net/**" + +defaults: + run: + shell: bash + working-directory: backend + +jobs: + test: + name: Lint & Test + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + + - name: Set up Python 3.12 + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Install uv + uses: astral-sh/setup-uv@v7 + + - name: Install dependencies + run: uv sync --extra dev + + - name: Lint (ruff) + run: uv run ruff check app/ tests/ + + - name: Test (pytest) + run: uv run pytest --cov=app --cov-report=term-missing diff --git a/.github/workflows/deploy-backend.yaml b/.github/workflows/deploy-backend.yaml new file mode 100644 index 0000000..6e0b3a6 --- /dev/null +++ b/.github/workflows/deploy-backend.yaml @@ -0,0 +1,120 @@ +name: Deploy Backend + +on: + workflow_dispatch: + push: + branches: [main] + paths: + - "backend/**" + - "src/fall_in/core/**" + - "src/fall_in/ai/**" + - "src/fall_in/net/**" + - "src/fall_in/multiplayer/models.py" + +# Only one deployment at a time. +concurrency: + group: deploy-backend + cancel-in-progress: true + +jobs: + test: + name: Pre-deploy Test + runs-on: ubuntu-latest + defaults: + run: + shell: bash + working-directory: backend + + steps: + - uses: actions/checkout@v6 + + - name: Set up Python 3.12 + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Install uv + uses: astral-sh/setup-uv@v7 + + - name: Install dependencies + run: uv sync --extra dev + + - name: Lint + run: uv run ruff check app/ tests/ + + - name: Test + run: uv run pytest -x -q + + deploy: + name: Deploy to OCI + needs: test + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + + - name: Set up SSH + run: | + mkdir -p ~/.ssh + echo "${{ secrets.OCI_SSH_KEY }}" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan -H "${{ secrets.OCI_HOST }}" >> ~/.ssh/known_hosts + + - name: Sync source to server + run: | + rsync -azP --delete \ + --include='backend/***' \ + --include='src/fall_in/core/***' \ + --include='src/fall_in/ai/***' \ + --include='src/fall_in/net/***' \ + --include='src/fall_in/multiplayer/models.py' \ + --include='src/fall_in/__init__.py' \ + --include='src/fall_in/multiplayer/__init__.py' \ + --include='src/' \ + --include='src/fall_in/' \ + --include='src/fall_in/multiplayer/' \ + --include='pyproject.toml' \ + --include='uv.lock' \ + --include='data/***' \ + --exclude='*' \ + ./ "${{ secrets.OCI_USER }}@${{ secrets.OCI_HOST }}:~/fall-in/" + + - name: Build & restart on server + run: | + ssh "${{ secrets.OCI_USER }}@${{ secrets.OCI_HOST }}" << 'DEPLOY_SCRIPT' + set -e + cd ~/fall-in + + # Build Docker image (ARM-native on Ampere A1). + docker build -t fall-in-backend -f backend/Dockerfile . + + # Stop existing container (if any) and start fresh. + docker stop fall-in-backend 2>/dev/null || true + docker rm fall-in-backend 2>/dev/null || true + + docker run -d \ + --name fall-in-backend \ + --restart unless-stopped \ + --env-file ~/fall-in/backend/.env \ + --network host \ + fall-in-backend + + # Wait for health check. + echo "Waiting for health check..." + for i in $(seq 1 15); do + if curl -sf http://localhost:8000/healthz > /dev/null 2>&1; then + echo "Health check passed." + exit 0 + fi + sleep 2 + done + echo "Health check failed after 30s" + docker logs fall-in-backend --tail 30 + exit 1 + DEPLOY_SCRIPT + + - name: Clean up old Docker images + if: success() + run: | + ssh "${{ secrets.OCI_USER }}@${{ secrets.OCI_HOST }}" \ + 'docker image prune -f' diff --git a/.gitignore b/.gitignore index bd50441..d5b7c70 100644 --- a/.gitignore +++ b/.gitignore @@ -45,4 +45,10 @@ Thumbs.db ingame_base.png # Logs -*.log \ No newline at end of file +*.log + +# DB +*.db + +# env +.env diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..039a4bb --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,8 @@ +__pycache__ +*.pyc +.venv +.env +*.db +.ruff_cache +.pytest_cache +tests/ diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..e66e3a4 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,43 @@ +# ============================================================ +# Fall-In Backend — local development environment +# Copy to .env and fill in real values before running. +# ============================================================ + +# --- Database --- +# SQLite for local dev (no Docker needed): +DATABASE_URL=sqlite:///./fall_in_dev.db + +# PostgreSQL example (for staging/prod): +# DATABASE_URL=postgresql://fall_in:secret@localhost:5432/fall_in + +# --- JWT --- +# Generate a strong random key: python -c "import secrets; print(secrets.token_hex(32))" +SECRET_KEY=dev-secret-key-change-in-production +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=15 +REFRESH_TOKEN_EXPIRE_DAYS=7 +GUEST_TOKEN_EXPIRE_HOURS=24 + +# --- Startup behaviour --- +# Set true to auto-create tables on startup (dev only). +# Production should use: uv run alembic upgrade head +CREATE_TABLES_ON_STARTUP=true + +# --- Redis (optional) --- +# Leave unset for local dev — in-memory fallback is used automatically. +# Only covers the quick-match queue and reconnect token TTLs. +# NOTE: Redis does NOT make the stack multi-worker-safe. Room, match, and +# connection state are still in-process singletons. Run a single uvicorn +# worker until a future PR moves those stores to a shared backend. +# REDIS_URL=redis://localhost:6379/0 + +# --- Logging --- +# INFO for normal beta use; DEBUG for step-through local debugging. +LOG_LEVEL=INFO + +# --- Admin API --- +# Static bearer token that gates /admin/* endpoints. +# Leave empty to disable admin endpoints (safe default for local dev). +# Set to a strong random string before sharing with moderators: +# python -c "import secrets; print(secrets.token_hex(32))" +ADMIN_TOKEN= diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..e883fff --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,54 @@ +# --- Fall-In Backend --- +# Multi-stage build optimised for ARM64 (Oracle Cloud Ampere A1). +# Build (from repo root): +# docker build -t fall-in-backend -f backend/Dockerfile . +# Run: +# docker run -p 8000:8000 --env-file backend/.env fall-in-backend + +FROM python:3.12-slim AS base + +# Prevent Python from writing .pyc files and enable unbuffered output. +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +# --- Dependencies stage --- +FROM base AS deps + +# Install uv for fast, reproducible dependency resolution. +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +# Copy only dependency metadata first (cache-friendly layer). +COPY backend/pyproject.toml backend/uv.lock ./backend/ +COPY pyproject.toml uv.lock ./ + +# Install backend deps (prod extras include redis). +# The client package (fall_in.core, fall_in.ai, etc.) is needed at runtime. +RUN cd backend && uv sync --no-dev --extra prod --no-install-project + +# --- Runtime stage --- +FROM base AS runtime + +# Copy the virtual environment from deps stage. +COPY --from=deps /app/backend/.venv /app/backend/.venv + +# Copy source code. +COPY backend/app ./backend/app +COPY backend/migrations ./backend/migrations +COPY backend/alembic.ini ./backend/ +COPY src/fall_in ./src/fall_in +COPY data ./data + +# Make fall_in package importable (backend pythonpath includes ../src). +ENV PYTHONPATH="/app/src:/app/backend" +ENV PATH="/app/backend/.venv/bin:$PATH" + +WORKDIR /app/backend + +EXPOSE 8000 + +# Run migrations then start uvicorn. +# --host 0.0.0.0 binds to all interfaces inside the container. +# --workers 1 is correct for the in-memory singleton architecture. +CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..d4f7d27 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,42 @@ +# Alembic configuration for Fall-In backend. +# The DATABASE_URL is loaded from app.config (via .env) in migrations/env.py. +# Do NOT hard-code credentials here. + +[alembic] +script_location = migrations +prepend_sys_path = . + +# Logging +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..5c78708 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +# Fall-In Multiplayer API diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py new file mode 100644 index 0000000..77d7f7c --- /dev/null +++ b/backend/app/api/admin.py @@ -0,0 +1,211 @@ +""" +Minimal admin query API (PR-08). + +Intentionally narrow scope: read-only access to reports for beta moderation. +No admin panel, no user management, no stats dashboards. + +Authentication: static Bearer token from settings.ADMIN_TOKEN. +Set ADMIN_TOKEN to a strong random string in .env before beta deployment. +Leave it empty (default) to disable the admin endpoints entirely. + +Endpoints +--------- +GET /admin/reports — list reports with optional filters +GET /admin/reports/{id} — get a single report by ID +PATCH /admin/reports/{id} — update report status (reviewed / dismissed) +""" + +from __future__ import annotations + +import secrets +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from app.config import settings +from app.database import get_db +from app.models.db import ReportReasonCode, ReportStatus +from app.repositories import report_repo + +router = APIRouter(prefix="/admin", tags=["admin"]) + +_bearer = HTTPBearer(auto_error=False) + + +def _require_admin( + credentials: Optional[HTTPAuthorizationCredentials] = Depends(_bearer), +) -> None: + """Dependency that verifies the static admin token.""" + token = settings.ADMIN_TOKEN + if not token: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Admin endpoints are disabled (ADMIN_TOKEN not set)", + ) + if credentials is None or not secrets.compare_digest(credentials.credentials, token): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or missing admin token", + ) + + +# --------------------------------------------------------------------------- +# Schemas +# --------------------------------------------------------------------------- + + +class ReportDetail(BaseModel): + id: str + reporter_user_id: Optional[str] + reported_user_id: Optional[str] + reported_connection_id: Optional[str] + reason_code: str + details: Optional[str] + room_code: Optional[str] + match_id: Optional[str] + status: str + created_at: str + + model_config = {"from_attributes": True} + + +class ReportListResponse(BaseModel): + items: list[ReportDetail] + total: int + limit: int + offset: int + + +class UpdateStatusRequest(BaseModel): + status: str + + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- + + +@router.get( + "/reports", + response_model=ReportListResponse, + dependencies=[Depends(_require_admin)], +) +def list_reports( + status: Optional[str] = Query(default=None), + reason_code: Optional[str] = Query(default=None), + limit: int = Query(default=50, ge=1, le=200), + offset: int = Query(default=0, ge=0), + db: Session = Depends(get_db), +) -> ReportListResponse: + """ + Return a paginated list of reports. + + Optional query parameters: + - status: open | reviewed | dismissed + - reason_code: emote_spam | abusive_language | cheating | + nickname_violation | other + - limit / offset for pagination + """ + rs: Optional[ReportStatus] = None + if status: + try: + rs = ReportStatus(status) + except ValueError: + valid = ", ".join(s.value for s in ReportStatus) + raise HTTPException(400, detail=f"유효하지 않은 status. 허용 값: {valid}") + + rc: Optional[ReportReasonCode] = None + if reason_code: + try: + rc = ReportReasonCode(reason_code) + except ValueError: + valid = ", ".join(r.value for r in ReportReasonCode) + raise HTTPException(400, detail=f"유효하지 않은 reason_code. 허용 값: {valid}") + + # Count matching rows before applying limit/offset so `total` reflects the + # true number of matching reports, not just the current page size. + total = report_repo.count_reports(db, status=rs, reason_code=rc) + reports = report_repo.list_reports(db, status=rs, reason_code=rc, limit=limit, offset=offset) + items = [ + ReportDetail( + id=r.id, + reporter_user_id=r.reporter_user_id, + reported_user_id=r.reported_user_id, + reported_connection_id=r.reported_connection_id, + reason_code=r.reason_code.value, + details=r.details, + room_code=r.room_code, + match_id=r.match_id, + status=r.status.value, + created_at=r.created_at.isoformat(), + ) + for r in reports + ] + return ReportListResponse( + items=items, + total=total, + limit=limit, + offset=offset, + ) + + +@router.get( + "/reports/{report_id}", + response_model=ReportDetail, + dependencies=[Depends(_require_admin)], +) +def get_report(report_id: str, db: Session = Depends(get_db)) -> ReportDetail: + report = report_repo.get_by_id(db, report_id) + if report is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Report not found") + return ReportDetail( + id=report.id, + reporter_user_id=report.reporter_user_id, + reported_user_id=report.reported_user_id, + reported_connection_id=report.reported_connection_id, + reason_code=report.reason_code.value, + details=report.details, + room_code=report.room_code, + match_id=report.match_id, + status=report.status.value, + created_at=report.created_at.isoformat(), + ) + + +@router.patch( + "/reports/{report_id}", + response_model=ReportDetail, + dependencies=[Depends(_require_admin)], +) +def update_report_status( + report_id: str, + req: UpdateStatusRequest, + db: Session = Depends(get_db), +) -> ReportDetail: + """Update report status to reviewed or dismissed.""" + report = report_repo.get_by_id(db, report_id) + if report is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Report not found") + + try: + new_status = ReportStatus(req.status) + except ValueError: + valid = ", ".join(s.value for s in ReportStatus) + raise HTTPException(400, detail=f"유효하지 않은 status. 허용 값: {valid}") + + updated = report_repo.update_status(db, report, new_status) + return ReportDetail( + id=updated.id, + reporter_user_id=updated.reporter_user_id, + reported_user_id=updated.reported_user_id, + reported_connection_id=updated.reported_connection_id, + reason_code=updated.reason_code.value, + details=updated.details, + room_code=updated.room_code, + match_id=updated.match_id, + status=updated.status.value, + created_at=updated.created_at.isoformat(), + ) diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py new file mode 100644 index 0000000..7ad51c0 --- /dev/null +++ b/backend/app/api/auth.py @@ -0,0 +1,162 @@ +""" +Authentication API routes. + +POST /auth/register — create account, return access + refresh tokens +POST /auth/login — email+password login, return access + refresh tokens +POST /auth/guest — ephemeral guest identity, return short-lived access token only +POST /auth/refresh — exchange refresh token for new access token +POST /auth/logout — client-side logout (token blacklist deferred to PR-08) +""" + +import uuid + +from fastapi import APIRouter, Depends, HTTPException, status +from jose import JWTError +from sqlalchemy.orm import Session + +from app.auth.jwt import ( + create_access_token, + create_guest_token, + create_refresh_token, + decode_token, +) +from app.auth.password import hash_password +from app.database import get_db +from app.models.db import UserStatus +from app.repositories import user_repo +from app.schemas.auth import ( + AuthResponse, + GuestLoginRequest, + LoginRequest, + RefreshRequest, + RegisterRequest, + TokenResponse, +) +from app.services.auth_service import AuthenticationError, authenticate + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +@router.post("/register", response_model=AuthResponse, status_code=status.HTTP_201_CREATED) +def register(req: RegisterRequest, db: Session = Depends(get_db)) -> AuthResponse: + """ + Create a new registered account. + + Returns 400 if the email is already registered. + """ + if user_repo.get_by_email(db, req.email): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email already registered", + ) + + user = user_repo.create_registered( + db, + email=req.email, + password_hash=hash_password(req.password), + nickname=req.nickname, + ) + + return AuthResponse( + access_token=create_access_token(user.id, "registered"), + refresh_token=create_refresh_token(user.id), + account_type="registered", + ) + + +@router.post("/login", response_model=AuthResponse) +def login(req: LoginRequest, db: Session = Depends(get_db)) -> AuthResponse: + """Authenticate with email + password.""" + try: + user = authenticate(db, req.email, req.password) + except AuthenticationError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid credentials", + ) + + user_repo.touch_last_login(db, user) + + return AuthResponse( + access_token=create_access_token(user.id, "registered"), + refresh_token=create_refresh_token(user.id), + account_type="registered", + ) + + +@router.post("/guest", response_model=AuthResponse) +def guest_login(req: GuestLoginRequest, db: Session = Depends(get_db)) -> AuthResponse: + """ + Create a temporary guest identity. + + No refresh token is issued. Guest data (profile, session identity, + report log linkage) exists only for the token's lifetime. + Persistent rewards, MMR, and collection writes are blocked at the + API level (see require_registered dependency). + """ + nickname = req.nickname or f"Guest_{uuid.uuid4().hex[:6].upper()}" + user = user_repo.create_guest(db, nickname=nickname) + + return AuthResponse( + access_token=create_guest_token(user.id), + refresh_token=None, + account_type="guest", + ) + + +@router.post("/refresh", response_model=TokenResponse) +def refresh(req: RefreshRequest, db: Session = Depends(get_db)) -> TokenResponse: + """ + Exchange a valid refresh token for a new access token. + + Only registered users receive refresh tokens, so this endpoint + implicitly only serves registered accounts. + """ + try: + payload = decode_token(req.refresh_token) + except JWTError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired refresh token", + ) + + if payload.get("type") != "refresh": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not a refresh token", + ) + + user_id: str | None = payload.get("sub") + if not user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token has no subject", + ) + + user = user_repo.get_by_id(db, user_id) + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found", + ) + + if user.status != UserStatus.ACTIVE: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Account is not active", + ) + + return TokenResponse( + access_token=create_access_token(user.id, user.account_type.value), + ) + + +@router.post("/logout") +def logout() -> dict: + """ + Signal logout intent from the client. + + Token invalidation (blacklist) is deferred to PR-08 (beta hardening). + Until then, clients must discard their tokens locally. + """ + return {"detail": "Logged out"} diff --git a/backend/app/api/me.py b/backend/app/api/me.py new file mode 100644 index 0000000..92996f9 --- /dev/null +++ b/backend/app/api/me.py @@ -0,0 +1,164 @@ +""" +/me — authenticated user's own profile and collection. + +GET /me/profile — available to registered and guest users +GET /me/collection — registered users only (guests have no persistent collection) +""" + +import time + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.config import settings +from app.database import get_db +from app.dependencies import get_current_user, require_registered +from app.models.db import User +from app.repositories import collection_repo, profile_repo +from app.schemas.profile import CollectionEntry, CollectionResponse, ProfileResponse +from app.schemas.progress import ( + CollectionUnlockRequest, + CollectionUnlockResponse, + ProgressMergeRequest, + ProgressMergeResponse, + RewardClaimRequest, + RewardClaimResponse, +) + +router = APIRouter(prefix="/me", tags=["me"]) + + +@router.get("/profile", response_model=ProfileResponse) +def get_profile(user: User = Depends(get_current_user)) -> ProfileResponse: + """ + Return the authenticated user's public profile. + + Available to both registered and guest users. + Note: hidden_mmr is intentionally NOT included in the response. + """ + return ProfileResponse( + user_id=user.id, + nickname=user.profile.nickname, + avatar_id=user.profile.avatar_id, + currency=user.profile.currency, + total_games=user.profile.total_games, + total_wins=user.profile.total_wins, + account_type=user.account_type.value, + ) + + +@router.get("/collection", response_model=CollectionResponse) +def get_collection( + user: User = Depends(require_registered), + db: Session = Depends(get_db), +) -> CollectionResponse: + """ + Return the authenticated user's soldier collection. + + Restricted to registered accounts — guests receive 403. + The collection is keyed exclusively by user_id, so one user's + collection is never visible to another. + """ + rows = collection_repo.get_for_user(db, user.id) + items = [ + CollectionEntry( + soldier_id=row.soldier_id, + unlocked_at=row.unlocked_at, + source=row.source, + ) + for row in rows + ] + return CollectionResponse(items=items, total=len(items)) + + +@router.post("/progress/merge", response_model=ProgressMergeResponse) +def merge_progress( + req: ProgressMergeRequest, + user: User = Depends(require_registered), + db: Session = Depends(get_db), +) -> ProgressMergeResponse: + """ + Merge local single-player progress into the registered server account. + + Merge strategy is intentionally conservative for beta: + - currency keeps the larger of {server, local} + - collection becomes the union of {server, local} + + This protects the common "old local save + new online account" case + without forcing the client to reconcile multiple sources first. + """ + current_rows = collection_repo.get_for_user(db, user.id) + current_ids = {row.soldier_id for row in current_rows} + merged_ids = current_ids | set(req.collected_soldier_ids) + + new_ids = sorted(merged_ids - current_ids) + if new_ids: + collection_repo.add_soldiers_batch(db, user.id, new_ids, source="client_merge") + + merged_currency = max(user.profile.currency, req.currency) + if user.profile.currency != merged_currency: + profile_repo.set_currency(db, user.profile, merged_currency) + + return ProgressMergeResponse( + currency=merged_currency, + collected_soldier_ids=sorted(merged_ids), + total_collected=len(merged_ids), + ) + + +# Per-user rate-limit state for single-play reward claims. +# Key: user_id, Value: last claim timestamp. +_reward_last_claim: dict[str, float] = {} + + +@router.post("/reward", response_model=RewardClaimResponse) +def claim_reward( + req: RewardClaimRequest, + user: User = Depends(require_registered), + db: Session = Depends(get_db), +) -> RewardClaimResponse: + """ + Claim a delta-based currency reward from a single-player game. + + The server validates: + - amount does not exceed REWARD_SINGLE_PLAY_MAX + - minimum cooldown between claims (rate-limit) + """ + if req.amount > settings.REWARD_SINGLE_PLAY_MAX: + raise HTTPException(status_code=422, detail="Reward amount exceeds maximum") + + now = time.monotonic() + last = _reward_last_claim.get(user.id, 0.0) + if now - last < settings.REWARD_SINGLE_PLAY_COOLDOWN_SECONDS: + raise HTTPException(status_code=429, detail="Reward claim too frequent") + _reward_last_claim[user.id] = now + + profile_repo.add_currency(db, user.profile, req.amount) + return RewardClaimResponse(granted=req.amount, currency=user.profile.currency) + + +@router.post("/collection/unlock", response_model=CollectionUnlockResponse) +def unlock_collection_entry( + req: CollectionUnlockRequest, + user: User = Depends(require_registered), + db: Session = Depends(get_db), +) -> CollectionUnlockResponse: + """ + Idempotently unlock a collected soldier for the authenticated user. + """ + added = False + if not collection_repo.has_soldier(db, user.id, req.soldier_id): + collection_repo.add_soldier( + db, + user.id, + req.soldier_id, + source="client_unlock", + ) + added = True + + total = len(collection_repo.get_for_user(db, user.id)) + return CollectionUnlockResponse( + soldier_id=req.soldier_id, + added=added, + total_collected=total, + ) diff --git a/backend/app/api/report.py b/backend/app/api/report.py new file mode 100644 index 0000000..fbefaf6 --- /dev/null +++ b/backend/app/api/report.py @@ -0,0 +1,75 @@ +""" +Report submission API (PR-08). + +POST /report — authenticated players (registered or guest) submit a report + against another player. + +The reporter is identified from the Bearer token so clients cannot spoof it. +The reported player is identified by user_id and/or connection_id from the +game context the client supplies. + +Rate-limiting note: a single report per reporter+reported+reason per hour is +not enforced here — the volume for beta is low enough that manual review is +sufficient. Add a time-based dedup query in report_repo if needed later. +""" + +from __future__ import annotations + +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from app.database import get_db +from app.dependencies import get_current_user +from app.models.db import User +from app.services.report_service import ReportError, submit_report + +router = APIRouter(prefix="/report", tags=["report"]) + + +class ReportRequest(BaseModel): + reported_user_id: Optional[str] = None + reported_connection_id: Optional[str] = None + reason_code: str + details: Optional[str] = Field(default=None, max_length=280) + room_code: Optional[str] = Field(default=None, max_length=10) + match_id: Optional[str] = Field(default=None, max_length=36) + + +class ReportResponse(BaseModel): + report_id: str + status: str + + +@router.post("", response_model=ReportResponse, status_code=status.HTTP_201_CREATED) +def submit( + req: ReportRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +) -> ReportResponse: + """ + Submit a report against another player. + + The caller's identity is derived from the Bearer token — clients cannot + spoof the reporter_user_id. + + Returns 400 if the reason_code is unknown or the target is missing. + Returns 201 with the new report_id on success. + """ + try: + report = submit_report( + db, + reporter_user_id=current_user.id, + reported_user_id=req.reported_user_id, + reported_connection_id=req.reported_connection_id, + reason_code=req.reason_code, + details=req.details, + room_code=req.room_code, + match_id=req.match_id, + ) + except ReportError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) + + return ReportResponse(report_id=report.id, status=report.status.value) diff --git a/backend/app/auth/__init__.py b/backend/app/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/auth/jwt.py b/backend/app/auth/jwt.py new file mode 100644 index 0000000..b3d2382 --- /dev/null +++ b/backend/app/auth/jwt.py @@ -0,0 +1,70 @@ +""" +JWT creation and verification. + +Token types: + access — short-lived (15 min for registered, 24 h for guest). + Contains: sub (user_id), account_type, type="access". + refresh — long-lived (7 days), registered users only. + Contains: sub (user_id), type="refresh". + No account_type — caller must look up from DB if needed. + +Token invalidation (blacklist) is deferred to PR-08 (beta hardening). +For now, logout is client-side only. +""" + +from datetime import datetime, timedelta, timezone + +from jose import jwt + +from app.config import settings + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def create_access_token(user_id: str, account_type: str) -> str: + expire = _now() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + payload = { + "sub": user_id, + "account_type": account_type, + "type": "access", + "exp": expire, + "iat": _now(), + } + return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + + +def create_refresh_token(user_id: str) -> str: + """Refresh tokens are issued only to registered users.""" + expire = _now() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS) + payload = { + "sub": user_id, + "type": "refresh", + "exp": expire, + "iat": _now(), + } + return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + + +def create_guest_token(user_id: str) -> str: + """Guest access tokens have a longer TTL but no refresh counterpart.""" + expire = _now() + timedelta(hours=settings.GUEST_TOKEN_EXPIRE_HOURS) + payload = { + "sub": user_id, + "account_type": "guest", + "type": "access", + "exp": expire, + "iat": _now(), + } + return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + + +def decode_token(token: str) -> dict: + """ + Decode and verify a JWT. + + Raises jose.JWTError on invalid signature, expired token, etc. + Callers must handle JWTError explicitly. + """ + return jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) diff --git a/backend/app/auth/password.py b/backend/app/auth/password.py new file mode 100644 index 0000000..d389e08 --- /dev/null +++ b/backend/app/auth/password.py @@ -0,0 +1,22 @@ +""" +Password hashing and verification using bcrypt. + +Uses the bcrypt library directly (no passlib wrapper) to avoid the +bcrypt 4.x / passlib compatibility issue present in Python 3.12. +""" + +import bcrypt + + +def hash_password(plain: str) -> str: + """Hash a plain-text password. Returns a UTF-8 bcrypt hash string.""" + salt = bcrypt.gensalt() + return bcrypt.hashpw(plain.encode("utf-8"), salt).decode("utf-8") + + +def verify_password(plain: str, hashed: str) -> bool: + """Return True if plain matches the bcrypt hash.""" + try: + return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8")) + except Exception: + return False diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..44ca5cc --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,102 @@ +""" +Application settings loaded from environment variables / .env file. + +All tuneable values live here. No secrets should be hard-coded anywhere else. +""" + +from typing import Optional + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + # Database + DATABASE_URL: str = "sqlite:///./fall_in_dev.db" + + # JWT + SECRET_KEY: str = "dev-secret-key-change-in-production" + ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 15 + REFRESH_TOKEN_EXPIRE_DAYS: int = 7 + GUEST_TOKEN_EXPIRE_HOURS: int = 24 + + # Startup behaviour + # Set true only for local dev; production uses Alembic migrations instead. + CREATE_TABLES_ON_STARTUP: bool = False + + # Redis (optional). + # Leave unset for local dev — the in-memory fallback is used automatically. + # Set to e.g. "redis://localhost:6379/0" in production to use Redis-backed + # reconnect token storage with automatic TTL expiry. + REDIS_URL: Optional[str] = None + + # WebSocket heartbeat. + # Server sends PING every HEARTBEAT_INTERVAL_SECONDS. + # If session.awaiting_pong is still True when the next PING is due, the + # previous PING went unanswered — the connection is closed with code 1001. + # Effective dead-connection timeout = HEARTBEAT_INTERVAL_SECONDS. + HEARTBEAT_INTERVAL_SECONDS: int = 15 + + # Reconnect & timeout windows. + # RECONNECT_GRACE_SECONDS: how long a disconnected human seat is preserved + # before being permanently converted to bot control. + # CARD_SELECTION_TIMEOUT_SECONDS: how long the server waits for a human to + # select a card before auto-playing with bot logic. + RECONNECT_GRACE_SECONDS: int = 45 + CARD_SELECTION_TIMEOUT_SECONDS: int = 30 + ROUND_SETTLEMENT_TIMEOUT_SECONDS: int = 8 + + # Quick-match matchmaking (PR-06). + # QUICK_MATCH_FILL_SECONDS: how long to wait for a full 4-player lobby + # before filling remaining seats with AI bots. + # DEFAULT_MMR: starting hidden MMR for new registered users. + # Guests always use DEFAULT_MMR for bucket assignment; it is never stored. + # MMR_K_FACTOR: maximum MMR delta for a single match (full 4-human game). + # Halved for 3-human matches; no update for ≤2 human seats. + QUICK_MATCH_FILL_SECONDS: int = 20 + DEFAULT_MMR: int = 1000 + MMR_K_FACTOR: int = 32 + + # Emote system (PR-07). + # EMOTE_COOLDOWN_SECONDS: minimum gap between any two emotes from one + # connection. Attempts within this window are rejected with RATE_LIMITED. + # EMOTE_BURST_CAP: maximum emotes allowed within EMOTE_BURST_WINDOW_SECONDS. + # EMOTE_BURST_WINDOW_SECONDS: sliding window size for burst-cap enforcement. + # EMOTE_SAME_REPEAT_CAP: max consecutive sends of the identical emote_id + # before a soft block kicks in (same-emote spam prevention). + EMOTE_COOLDOWN_SECONDS: float = 1.5 + EMOTE_BURST_CAP: int = 3 + EMOTE_BURST_WINDOW_SECONDS: int = 10 + EMOTE_SAME_REPEAT_CAP: int = 2 + + # Beta ops (PR-08). + # LOG_LEVEL: root log level — INFO for beta, DEBUG for local stepping. + # ADMIN_TOKEN: static bearer token that gates /admin/* endpoints. + # Leave empty to disable admin endpoints (safe default for local dev). + # Set to a strong random string before beta deployment. + LOG_LEVEL: str = "INFO" + ADMIN_TOKEN: str = "" + + # Reward constants (must match client-side values for single-player validation). + REWARD_VICTORY_BASE: int = 100 + REWARD_VICTORY_PER_ROUND: int = 10 + REWARD_DEFEAT_BASE: int = 30 + REWARD_DEFEAT_PER_ROUND: int = 5 + # Maximum reward the server will accept from a single-play claim. + # Victory at round 10 = 100 + 10*10 = 200; generous cap at 500. + REWARD_SINGLE_PLAY_MAX: int = 500 + # Rate-limit: minimum seconds between single-play reward claims. + REWARD_SINGLE_PLAY_COOLDOWN_SECONDS: int = 30 + + # CORS — comma-separated origins, or ["*"] for wide-open dev. + # Production example: "https://fallin.example.com,https://web.fallin.example.com" + CORS_ORIGINS: list[str] = ["*"] + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + +settings = Settings() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..d93cb25 --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,54 @@ +""" +Database engine, session factory, and declarative base. + +Session lifecycle: + - get_db() yields a sync SQLAlchemy Session for use in FastAPI dependencies. + - Each request gets its own session; it is closed (and rolled back on error) + at the end of the request. + +For tests, get_db is overridden via app.dependency_overrides to inject an +in-memory SQLite session — see backend/tests/conftest.py. +""" + +from typing import Generator + +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker + +from app.config import settings + + +def _make_engine(url: str): + kwargs: dict = {} + if url.startswith("sqlite"): + kwargs["connect_args"] = {"check_same_thread": False} + else: + # Production DB (PostgreSQL): detect stale connections after DB restart + # and set a reasonable connection timeout. + kwargs["pool_pre_ping"] = True + kwargs["pool_size"] = 5 + kwargs["max_overflow"] = 10 + kwargs["pool_timeout"] = 10 + return create_engine(url, **kwargs) + + +engine = _make_engine(settings.DATABASE_URL) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +class Base(DeclarativeBase): + """Shared declarative base for all ORM models.""" + + pass + + +def get_db() -> Generator[Session, None, None]: + """FastAPI dependency that provides a database session per request.""" + db = SessionLocal() + try: + yield db + except Exception: + db.rollback() + raise + finally: + db.close() diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py new file mode 100644 index 0000000..7107b8b --- /dev/null +++ b/backend/app/dependencies.py @@ -0,0 +1,81 @@ +""" +Shared FastAPI dependency functions. + +get_current_user — decode Bearer token and return the owning User row. +require_registered — same as above but rejects guest accounts. +""" + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from jose import JWTError +from sqlalchemy.orm import Session + +from app.auth.jwt import decode_token +from app.database import get_db +from app.models.db import AccountType, User, UserStatus +from app.repositories import user_repo + +_bearer = HTTPBearer() + + +def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(_bearer), + db: Session = Depends(get_db), +) -> User: + """ + Decode the Bearer JWT and return the corresponding User. + + Raises 401 if the token is missing, expired, or malformed. + The token must have type == "access". + """ + token = credentials.credentials + try: + payload = decode_token(token) + except JWTError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired token", + ) + + if payload.get("type") != "access": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token type", + ) + + user_id: str | None = payload.get("sub") + if not user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token has no subject", + ) + + user = user_repo.get_by_id(db, user_id) + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found", + ) + + if user.status != UserStatus.ACTIVE: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Account is not active", + ) + + return user + + +def require_registered(user: User = Depends(get_current_user)) -> User: + """ + Dependency that rejects guest accounts. + + Guest tokens are valid access tokens, but certain resources (collection, + MMR, persistent rewards) are only available to registered users. + """ + if user.account_type != AccountType.REGISTERED: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="A registered account is required for this action", + ) + return user diff --git a/backend/app/logging_config.py b/backend/app/logging_config.py new file mode 100644 index 0000000..806f44a --- /dev/null +++ b/backend/app/logging_config.py @@ -0,0 +1,117 @@ +""" +Structured logging configuration for the Fall-In backend (PR-08). + +Uses Python's built-in logging module with a JSON-style formatter so +that log lines are easily grep-able during beta and parsable by log +aggregators (e.g. Loki, CloudWatch) in production. + +Call ``configure_logging()`` once at application startup (in main.py +lifespan) before any other module emits log records. + +Log levels +---------- +DEBUG internal state useful for stepping through WS flows locally +INFO normal lifecycle events (connect, auth, match start/end, reports) +WARNING unexpected but recoverable situations (rate-limit hits, bad msgs) +ERROR unhandled exceptions that degrade a connection or request + +Root logger level defaults to INFO. Set LOG_LEVEL=DEBUG in .env for +local debugging. + +Log format (one JSON object per line):: + + {"ts": "2026-04-06T12:00:00.123Z", "level": "INFO", + "logger": "fall_in.ws", "msg": "ws_connect", "conn_id": "..."} +""" + +from __future__ import annotations + +import json +import logging +import sys +from datetime import datetime, timezone +from typing import Any + + +class _JsonFormatter(logging.Formatter): + """ + Format log records as single-line JSON objects. + + Extra fields attached via ``logger.info(msg, extra={...})`` are merged + into the top-level JSON object for easy querying. + """ + + # Fields present on every LogRecord that we do NOT want to repeat. + _SKIP = frozenset( + { + "args", + "created", + "exc_info", + "exc_text", + "filename", + "funcName", + "levelno", + "lineno", + "message", + "module", + "msecs", + "msg", + "name", + "pathname", + "process", + "processName", + "relativeCreated", + "stack_info", + "taskName", + "thread", + "threadName", + } + ) + + def format(self, record: logging.LogRecord) -> str: + record.message = record.getMessage() + ts = ( + datetime.fromtimestamp(record.created, tz=timezone.utc).strftime( + "%Y-%m-%dT%H:%M:%S.%f" + )[:-3] + + "Z" + ) + + payload: dict[str, Any] = { + "ts": ts, + "level": record.levelname, + "logger": record.name, + "msg": record.message, + } + + # Merge any extra fields supplied by the caller. + for key, value in record.__dict__.items(): + if key not in self._SKIP and not key.startswith("_"): + payload[key] = value + + if record.exc_info: + payload["exc"] = self.formatException(record.exc_info) + + return json.dumps(payload, ensure_ascii=False, default=str) + + +def configure_logging(level: str = "INFO") -> None: + """ + Set up the root logger and the fall_in namespace logger. + + Safe to call multiple times (idempotent — handlers are added only once). + """ + root = logging.getLogger() + if root.handlers: + # Already configured — skip to avoid duplicate output. + return + + handler = logging.StreamHandler(sys.stdout) + handler.setFormatter(_JsonFormatter()) + + root.addHandler(handler) + root.setLevel(getattr(logging, level.upper(), logging.INFO)) + + # Suppress noisy third-party loggers at WARNING+ only. + for noisy in ("uvicorn.access", "sqlalchemy.engine"): + logging.getLogger(noisy).setLevel(logging.WARNING) diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..5aed1ba --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,79 @@ +""" +Fall-In Multiplayer API — FastAPI application entry point. + +Local dev quickstart (from backend/ directory): + cp .env.example .env # fill in your values + uv run alembic upgrade head # apply migrations + uv run uvicorn app.main:app --reload + +Or set CREATE_TABLES_ON_STARTUP=true in .env to skip Alembic for local dev. +See docs/local-beta-setup.md for the full beta smoke-test walkthrough. +""" + +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from sqlalchemy import text + +from app.api import admin as admin_router +from app.api import auth as auth_router +from app.api import me as me_router +from app.api import report as report_router +from app.config import settings +from app.database import Base, SessionLocal, engine +from app.logging_config import configure_logging +from app.ws import endpoint as ws_router + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Initialise structured logging before anything else logs. + configure_logging(level=settings.LOG_LEVEL) + + # Dev convenience: auto-create tables if enabled. + # Production deployments run `alembic upgrade head` instead. + if settings.CREATE_TABLES_ON_STARTUP: + Base.metadata.create_all(bind=engine) + yield + + +app = FastAPI( + title="Fall-In Multiplayer API", + version="0.1.0", + description="Backend service for Fall-In (헤쳐 모여!) real-time multiplayer.", + lifespan=lifespan, +) + +# CORS — allow game clients (desktop, web) and local dev. +app.add_middleware( + CORSMiddleware, + allow_origins=settings.CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(auth_router.router) +app.include_router(me_router.router) +app.include_router(report_router.router) +app.include_router(admin_router.router) +app.include_router(ws_router.router) + + +@app.get("/healthz", tags=["ops"]) +def health_check(): + """Liveness probe. Verifies the DB connection pool is healthy.""" + db = SessionLocal() + try: + db.execute(text("SELECT 1")) + except Exception: + return {"status": "degraded", "db": "unreachable"} + finally: + db.close() + return {"status": "ok"} + + +@app.get("/version", tags=["ops"]) +def version(): + return {"version": "0.1.0"} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/models/db.py b/backend/app/models/db.py new file mode 100644 index 0000000..0739e54 --- /dev/null +++ b/backend/app/models/db.py @@ -0,0 +1,181 @@ +""" +SQLAlchemy ORM models. + +Table layout matches the schema defined in fall_in_multiplayer_plan_v1.md §15. + +Design notes: + - UUIDs are stored as VARCHAR(36) for SQLite compatibility. + PostgreSQL deployments can use a native UUID column type via Alembic migration + without changing application code. + - hidden_mmr is stored here but never exposed via the profile API. + It is only updated by the match-result service (PR-06+). + - UserCollection rows are keyed by (user_id, soldier_id). + A guest user_id must never appear here — enforced at the service layer. +""" + +import enum +import uuid +from datetime import datetime, timezone + +from sqlalchemy import ( + DateTime, + ForeignKey, + Integer, + String, + Text, +) +from sqlalchemy import ( + Enum as SAEnum, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc).replace(tzinfo=None) # naive UTC for SQLite compat + + +def _new_uuid() -> str: + return str(uuid.uuid4()) + + +# --------------------------------------------------------------------------- +# Enumerations +# --------------------------------------------------------------------------- + + +class AccountType(str, enum.Enum): + REGISTERED = "registered" + GUEST = "guest" + + +class UserStatus(str, enum.Enum): + ACTIVE = "active" + SUSPENDED = "suspended" + DELETED = "deleted" + + +class ReportReasonCode(str, enum.Enum): + EMOTE_SPAM = "emote_spam" + ABUSIVE_LANGUAGE = "abusive_language" + CHEATING = "cheating" + NICKNAME_VIOLATION = "nickname_violation" + OTHER = "other" + + +class ReportStatus(str, enum.Enum): + OPEN = "open" + REVIEWED = "reviewed" + DISMISSED = "dismissed" + + +# --------------------------------------------------------------------------- +# ORM models +# --------------------------------------------------------------------------- + + +class User(Base): + __tablename__ = "users" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_uuid) + account_type: Mapped[AccountType] = mapped_column( + SAEnum(AccountType, values_callable=lambda e: [m.value for m in e]), + nullable=False, + ) + email: Mapped[str | None] = mapped_column(String(255), unique=True, nullable=True, index=True) + password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True) + status: Mapped[UserStatus] = mapped_column( + SAEnum(UserStatus, values_callable=lambda e: [m.value for m in e]), + nullable=False, + default=UserStatus.ACTIVE, + ) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=_utcnow) + last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + + profile: Mapped["Profile"] = relationship( + "Profile", back_populates="user", uselist=False, cascade="all, delete-orphan" + ) + collection: Mapped[list["UserCollection"]] = relationship( + "UserCollection", back_populates="user", cascade="all, delete-orphan" + ) + + +class Profile(Base): + __tablename__ = "profiles" + + user_id: Mapped[str] = mapped_column( + String(36), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True + ) + nickname: Mapped[str] = mapped_column(String(50), nullable=False) + avatar_id: Mapped[str | None] = mapped_column(String(50), nullable=True) + currency: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + + # Never exposed via API; updated only by match-result service (PR-06+). + hidden_mmr: Mapped[int | None] = mapped_column(Integer, nullable=True) + + total_games: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + total_wins: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + emote_loadout_json: Mapped[str | None] = mapped_column(String, nullable=True) + updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=_utcnow) + + user: Mapped["User"] = relationship("User", back_populates="profile") + + +class UserCollection(Base): + """ + Persistent soldier collection for registered users only. + + Invariant: guest user_ids must never appear in this table. + The collection API enforces this via require_registered(). + """ + + __tablename__ = "user_collection" + + user_id: Mapped[str] = mapped_column( + String(36), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True + ) + soldier_id: Mapped[int] = mapped_column(Integer, primary_key=True) + unlocked_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=_utcnow) + source: Mapped[str | None] = mapped_column(String(50), nullable=True) + + user: Mapped["User"] = relationship("User", back_populates="collection") + + +class Report(Base): + """ + Player-submitted reports for beta moderation. + + reporter_user_id is nullable so that both registered and guest users + can submit reports (guest user_id is stored). + reported_user_id may be None if the reported player is a guest whose + account no longer exists — reported_connection_id preserves the session + context for log correlation. + """ + + __tablename__ = "reports" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_uuid) + reporter_user_id: Mapped[str | None] = mapped_column( + String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True + ) + reported_user_id: Mapped[str | None] = mapped_column( + String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True + ) + # Preserve WS session context for log correlation even if the user is gone. + reported_connection_id: Mapped[str | None] = mapped_column(String(36), nullable=True) + reason_code: Mapped[ReportReasonCode] = mapped_column( + SAEnum(ReportReasonCode, values_callable=lambda e: [m.value for m in e]), + nullable=False, + ) + # Optional free-form note (max 280 chars; sanitised at service layer). + details: Mapped[str | None] = mapped_column(Text, nullable=True) + # Game context for admin review. + room_code: Mapped[str | None] = mapped_column(String(10), nullable=True) + match_id: Mapped[str | None] = mapped_column(String(36), nullable=True) + status: Mapped[ReportStatus] = mapped_column( + SAEnum(ReportStatus, values_callable=lambda e: [m.value for m in e]), + nullable=False, + default=ReportStatus.OPEN, + ) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=_utcnow) diff --git a/backend/app/models/match.py b/backend/app/models/match.py new file mode 100644 index 0000000..3d9fe8e --- /dev/null +++ b/backend/app/models/match.py @@ -0,0 +1,117 @@ +""" +Server-side match state models. + +ActiveMatch owns the authoritative GameRules instance for a running match. +All state mutations go through MatchService. + +Design rules (from multiplayer plan): + - player_id == seat_index for all seats. + - board_row_owners tracks per-card seat ownership for PublicMatchState serialisation. + - Starter cards (dealt to rows at round start) have owner_seat = -1. + - last_turn_steps holds the most recently resolved turn's placements; + it is cleared at the start of each new round. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: + pass + +from app.models.room import SeatControllerType + + +@dataclass +class MatchSeat: + """Per-seat match identity: maps a room participant to a GameRules Player.""" + + seat_index: int + # fall_in.core.player.Player — typed as object to avoid circular import at module load + player: object + connection_id: Optional[str] # None for bots; cleared on disconnect + user_id: Optional[str] # None for guests and bots + display_name: str + controller_type: SeatControllerType + # "registered" | "guest" | "bot" — used by MmrService to determine whether + # MMR should be persisted for this seat after the match ends. + account_type: str = "guest" + ai_controller: Optional[object] = None # fall_in.ai.ai_player.AIPlayer; None for human seats + + # Presence / reconnect state (PR-05). + # is_disconnected: True between WebSocketDisconnect and successful reconnect. + # disconnected_at: time.time() value recorded when is_disconnected becomes True. + # took_over_by_bot: True once the grace period has expired and the seat has + # been permanently converted to bot control. Cannot be reversed. + is_disconnected: bool = False + disconnected_at: Optional[float] = None + took_over_by_bot: bool = False + + +@dataclass +class TurnStep: + """Result of a single card placement during turn resolution.""" + + seat_index: int + card_number: int + card_danger: int + row_index: int + penalty_score: int + had_to_take_row: bool + order: int # 1-based placement order + penalty_card_count: int = 0 # number of penalty cards taken in this placement + + +@dataclass +class RoundSummary: + """Scores and elimination info produced by MatchService.finalize_round().""" + + round_number: int + round_danger: dict[int, int] # seat_index -> danger this round + total_scores: dict[int, int] # seat_index -> cumulative danger + eliminated_seats: list[int] + game_over: bool + winner_seat: Optional[int] + + +@dataclass +class ActiveMatch: + """Live match owned by MatchService.""" + + match_id: str + room_code: str + rules: object # fall_in.core.rules.GameRules + seats: dict[int, MatchSeat] # seat_index -> MatchSeat + player_to_seat: dict[int, int] # player_id -> seat_index + + # Parallel ownership tracking for board rows. + # board_row_owners[row_idx][pos] = seat_index (-1 for starter cards). + board_row_owners: list[list[int]] = field(default_factory=lambda: [[], [], [], []]) + + # Most recently resolved turn's TurnStep list (cleared at round start). + last_turn_steps: list[TurnStep] = field(default_factory=list) + + # Timestamp (time.time()) set when the SELECTING phase begins. + # Used by the card-selection timeout task to know when the timer started. + selection_started_at: Optional[float] = None + + # True when this match was created from the quick-match queue (PR-06). + # Determines whether MMR updates are applied after the game ends. + # Custom-room matches always have is_ranked=False. + is_ranked: bool = False + + # Round-settlement coordination (client result screen before continuing). + round_settlement_pending: bool = False + round_settlement_ready_seats: set[int] = field(default_factory=set) + round_summary_pending: Optional[RoundSummary] = None + + # Seats that voluntarily left mid-match (MATCH_LEAVE). + # They are converted to bot immediately but marked as eliminated + # at the end of the current round in finalize_round(). + voluntarily_left_seats: set[int] = field(default_factory=set) + + # Per-seat elimination round tracking for reward calculation. + # Recorded in finalize_round() when a seat is first eliminated. + # Winner (or still-active seats) will not appear here. + seat_eliminated_round: dict[int, int] = field(default_factory=dict) diff --git a/backend/app/models/room.py b/backend/app/models/room.py new file mode 100644 index 0000000..607c3c4 --- /dev/null +++ b/backend/app/models/room.py @@ -0,0 +1,86 @@ +""" +In-memory room/lobby models. + +These are not persisted to the database — rooms live only for the duration +of the lobby phase. Once a match starts (PR-04+), a match record is created. + +Design rules: + - RoomParticipant tracks seat_index (position at table), not user_id identity. + - seat_index 0..3 maps to game seats; bots fill unoccupied seats on start. + - connection_id is None for bot seats (they have no WS connection). +""" + +from dataclasses import dataclass, field +from enum import Enum +from typing import Optional + + +class RoomPhase(str, Enum): + WAITING = "waiting" # accepting joins, host hasn't started + STARTING = "starting" # host pressed start, bots filled, match engine pending + + +class SeatControllerType(str, Enum): + REMOTE = "remote" # human connected via WebSocket + BOT = "bot" # AI-controlled seat + + +@dataclass +class RoomParticipant: + seat_index: int + display_name: str + controller_type: SeatControllerType + is_ready: bool = False + account_type: str = "guest" # "registered" | "guest" | "bot" + user_id: Optional[str] = None # None for guests and bots + connection_id: Optional[str] = None # None for bots + + +@dataclass +class Room: + room_code: str + host_seat_index: int + phase: RoomPhase = RoomPhase.WAITING + # Keyed by seat_index (0..3). Gaps are empty seats. + participants: dict = field(default_factory=dict) + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def next_available_seat(self) -> Optional[int]: + for i in range(4): + if i not in self.participants: + return i + return None + + def is_full(self) -> bool: + return len(self.participants) >= 4 + + def human_count(self) -> int: + return sum( + 1 for p in self.participants.values() if p.controller_type == SeatControllerType.REMOTE + ) + + def to_dict(self) -> dict: + """ + Return a JSON-serialisable representation for ROOM_STATE messages. + + user_id is deliberately excluded — it is a stable account identifier + that other clients have no need for. display_name is sufficient for + the lobby UI. + """ + return { + "room_code": self.room_code, + "phase": self.phase, + "host_seat_index": self.host_seat_index, + "participants": [ + { + "seat_index": p.seat_index, + "display_name": p.display_name, + "controller_type": p.controller_type, + "is_ready": p.is_ready, + } + for p in sorted(self.participants.values(), key=lambda x: x.seat_index) + ], + } diff --git a/backend/app/repositories/__init__.py b/backend/app/repositories/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/repositories/collection_repo.py b/backend/app/repositories/collection_repo.py new file mode 100644 index 0000000..1a555f1 --- /dev/null +++ b/backend/app/repositories/collection_repo.py @@ -0,0 +1,89 @@ +""" +Database operations for user_collection. + +Invariant enforced here: + - add_soldier must only be called with a registered user_id. + - The service layer (or API dependency require_registered) is responsible + for rejecting guest user_ids before reaching this function. + +These functions do NOT validate account_type — that boundary belongs to the +service / API layers. Repositories are intentionally thin. +""" + +from datetime import datetime, timezone + +from sqlalchemy.orm import Session + +from app.models.db import UserCollection + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc).replace(tzinfo=None) + + +def get_for_user(db: Session, user_id: str) -> list[UserCollection]: + """Return all collection entries for a user, ordered by unlock time.""" + return ( + db.query(UserCollection) + .filter(UserCollection.user_id == user_id) + .order_by(UserCollection.unlocked_at) + .all() + ) + + +def has_soldier(db: Session, user_id: str, soldier_id: int) -> bool: + return ( + db.query(UserCollection) + .filter( + UserCollection.user_id == user_id, + UserCollection.soldier_id == soldier_id, + ) + .first() + is not None + ) + + +def add_soldier( + db: Session, + user_id: str, + soldier_id: int, + source: str = "interview", +) -> UserCollection: + """ + Add a soldier to a user's collection. + + Caller must ensure user_id belongs to a registered user. + Does not check for duplicates — caller should use has_soldier() first + if idempotency is required. + """ + entry = UserCollection( + user_id=user_id, + soldier_id=soldier_id, + source=source, + unlocked_at=_utcnow(), + ) + db.add(entry) + db.commit() + db.refresh(entry) + return entry + + +def add_soldiers_batch( + db: Session, + user_id: str, + soldier_ids: list[int], + source: str = "client_merge", +) -> int: + """Add multiple soldiers in a single transaction. Returns count added.""" + now = _utcnow() + for sid in soldier_ids: + db.add( + UserCollection( + user_id=user_id, + soldier_id=sid, + source=source, + unlocked_at=now, + ) + ) + db.commit() + return len(soldier_ids) diff --git a/backend/app/repositories/match_repo.py b/backend/app/repositories/match_repo.py new file mode 100644 index 0000000..c1d2dcb --- /dev/null +++ b/backend/app/repositories/match_repo.py @@ -0,0 +1,36 @@ +""" +In-memory repository for active matches. + +Keyed by match_id; secondary index by room_code for O(1) lookup. +Replace with a Redis-backed implementation in PR-06 for multi-worker deployments. +""" + +from typing import Optional + +from app.models.match import ActiveMatch + + +class InMemoryMatchRepo: + def __init__(self) -> None: + self._matches: dict[str, ActiveMatch] = {} + self._by_room: dict[str, str] = {} # room_code -> match_id + + def create(self, match: ActiveMatch) -> None: + self._matches[match.match_id] = match + self._by_room[match.room_code] = match.match_id + + def get(self, match_id: str) -> Optional[ActiveMatch]: + return self._matches.get(match_id) + + def get_by_room(self, room_code: str) -> Optional[ActiveMatch]: + mid = self._by_room.get(room_code) + return self._matches.get(mid) if mid else None + + def delete(self, match_id: str) -> None: + match = self._matches.pop(match_id, None) + if match: + self._by_room.pop(match.room_code, None) + + def clear(self) -> None: + self._matches.clear() + self._by_room.clear() diff --git a/backend/app/repositories/profile_repo.py b/backend/app/repositories/profile_repo.py new file mode 100644 index 0000000..9f1e7b8 --- /dev/null +++ b/backend/app/repositories/profile_repo.py @@ -0,0 +1,31 @@ +""" +Database operations for Profile rows. +""" + +from datetime import datetime, timezone + +from sqlalchemy.orm import Session + +from app.models.db import Profile + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc).replace(tzinfo=None) + + +def set_currency(db: Session, profile: Profile, currency: int) -> Profile: + """Persist the exact wallet amount for the profile.""" + profile.currency = currency + profile.updated_at = _utcnow() + db.commit() + db.refresh(profile) + return profile + + +def add_currency(db: Session, profile: Profile, delta: int) -> Profile: + """Atomically add *delta* to the profile's wallet (can be negative).""" + profile.currency = max(0, profile.currency + delta) + profile.updated_at = _utcnow() + db.commit() + db.refresh(profile) + return profile diff --git a/backend/app/repositories/queue_repo.py b/backend/app/repositories/queue_repo.py new file mode 100644 index 0000000..34f48cf --- /dev/null +++ b/backend/app/repositories/queue_repo.py @@ -0,0 +1,228 @@ +""" +Quick-match queue storage. + +Two implementations are provided: + + InMemoryQueueRepo — default, single-process, no external deps. + Used automatically when REDIS_URL is not set. + + RedisQueueRepo — production-grade, survives worker restarts. + Requires ``redis[hiredis]`` package. + Activated when REDIS_URL is set in config. + +The public factory ``make_queue_repo(redis_url)`` selects the right +implementation and falls back to in-memory if Redis is unreachable. + +Queue entry lifecycle: + 1. Added when a player sends QUICK_MATCH_JOIN. + 2. Removed when 4 entries accumulate (match formed), the fill timer fires, + or the player sends QUICK_MATCH_LEAVE / disconnects. + +Entries are ordered by ``joined_at`` so that the longest-waiting players +are matched first. +""" + +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class QueueEntry: + """All fields needed to seat a queued player in a quick-match lobby.""" + + connection_id: str + user_id: Optional[str] # None for guests + display_name: str + account_type: str # "registered" | "guest" + mmr: int # hidden MMR (DEFAULT_MMR for guests) + bucket: str # MMR bucket name used for grouping + joined_at: float # time.time() when the player entered the queue + + +# --------------------------------------------------------------------------- +# In-memory implementation +# --------------------------------------------------------------------------- + + +class InMemoryQueueRepo: + def __init__(self) -> None: + # bucket -> list[QueueEntry], insertion-ordered + self._buckets: dict[str, list[QueueEntry]] = {} + # connection_id -> bucket (reverse index for O(1) removal) + self._conn_bucket: dict[str, str] = {} + + def add(self, entry: QueueEntry) -> None: + bucket = entry.bucket + if bucket not in self._buckets: + self._buckets[bucket] = [] + self._buckets[bucket].append(entry) + self._conn_bucket[entry.connection_id] = bucket + + def remove(self, connection_id: str) -> Optional[QueueEntry]: + """Remove and return the entry for *connection_id*, or None if absent.""" + bucket = self._conn_bucket.pop(connection_id, None) + if bucket is None: + return None + entries = self._buckets.get(bucket, []) + for i, e in enumerate(entries): + if e.connection_id == connection_id: + entries.pop(i) + if not entries: + self._buckets.pop(bucket, None) + return e + return None + + def get_bucket(self, bucket: str) -> list[QueueEntry]: + """Return all entries in *bucket* sorted by joined_at (oldest first).""" + return sorted(self._buckets.get(bucket, []), key=lambda e: e.joined_at) + + def bucket_size(self, bucket: str) -> int: + return len(self._buckets.get(bucket, [])) + + def get_entry(self, connection_id: str) -> Optional[QueueEntry]: + bucket = self._conn_bucket.get(connection_id) + if bucket is None: + return None + for e in self._buckets.get(bucket, []): + if e.connection_id == connection_id: + return e + return None + + def clear(self) -> None: + self._buckets.clear() + self._conn_bucket.clear() + + +# --------------------------------------------------------------------------- +# Redis implementation (optional) +# --------------------------------------------------------------------------- + + +class RedisQueueRepo: + """ + Redis-backed queue store. Requires ``redis[hiredis]``. + + Per-bucket sorted set: ``fall_in:queue:{bucket}`` (score = joined_at) + Per-entry hash: ``fall_in:queue:entry:{connection_id}`` + + SINGLE-WORKER ONLY + ------------------ + This implementation shares the queue across processes, but the rest of the + match-formation stack (ConnectionManager, InMemoryRoomRepo, InMemoryMatchRepo) + is process-local. Running multiple workers with REDIS_URL set will cause + workers to dequeue connections they do not own, so those players will never + receive MATCH_FOUND. + + Do NOT use REDIS_URL in a multi-worker deployment until ConnectionManager, + room_repo, and match_repo are also backed by a shared store with atomic + match-formation operations. Single-worker deployments (single uvicorn + process or single Gunicorn worker) are safe. + """ + + _PREFIX = "fall_in:queue:" + # Entries expire automatically after 10 min so stale data does not + # accumulate when a worker crashes mid-queue. + _ENTRY_TTL = 600 + + def __init__(self, redis_url: str) -> None: + import redis as _redis + + self._r = _redis.from_url(redis_url, decode_responses=True) + + def ping(self) -> None: + self._r.ping() + + def _bucket_key(self, bucket: str) -> str: + return f"{self._PREFIX}{bucket}" + + def _entry_key(self, connection_id: str) -> str: + return f"{self._PREFIX}entry:{connection_id}" + + def add(self, entry: QueueEntry) -> None: + pipe = self._r.pipeline() + pipe.zadd(self._bucket_key(entry.bucket), {entry.connection_id: entry.joined_at}) + mapping = { + "connection_id": entry.connection_id, + "user_id": entry.user_id or "", + "display_name": entry.display_name, + "account_type": entry.account_type, + "mmr": str(entry.mmr), + "bucket": entry.bucket, + "joined_at": str(entry.joined_at), + } + pipe.hset(self._entry_key(entry.connection_id), mapping=mapping) + pipe.expire(self._entry_key(entry.connection_id), self._ENTRY_TTL) + pipe.execute() + + def remove(self, connection_id: str) -> Optional[QueueEntry]: + entry = self.get_entry(connection_id) + if entry is None: + return None + pipe = self._r.pipeline() + pipe.zrem(self._bucket_key(entry.bucket), connection_id) + pipe.delete(self._entry_key(connection_id)) + pipe.execute() + return entry + + def get_bucket(self, bucket: str) -> list[QueueEntry]: + # ZRANGEBYSCORE -inf +inf WITHSCORES; sorted by score (joined_at) + members = self._r.zrange(self._bucket_key(bucket), 0, -1) + entries = [] + for conn_id in members: + e = self.get_entry(conn_id) + if e is not None: + entries.append(e) + return sorted(entries, key=lambda e: e.joined_at) + + def bucket_size(self, bucket: str) -> int: + return self._r.zcard(self._bucket_key(bucket)) + + def get_entry(self, connection_id: str) -> Optional[QueueEntry]: + data = self._r.hgetall(self._entry_key(connection_id)) + if not data: + return None + return QueueEntry( + connection_id=data["connection_id"], + user_id=data["user_id"] or None, + display_name=data["display_name"], + account_type=data["account_type"], + mmr=int(data["mmr"]), + bucket=data["bucket"], + joined_at=float(data["joined_at"]), + ) + + def clear(self) -> None: + """Flush all queue keys. Use only in tests.""" + keys = self._r.keys(f"{self._PREFIX}*") + if keys: + self._r.delete(*keys) + + +# --------------------------------------------------------------------------- +# Factory +# --------------------------------------------------------------------------- + + +def make_queue_repo( + redis_url: Optional[str] = None, +) -> InMemoryQueueRepo: + """ + Return a Redis-backed repo if redis_url is set and reachable, + otherwise fall back to the in-memory implementation. + + WARNING: RedisQueueRepo is only safe with a *single* uvicorn/Gunicorn + worker process. See the RedisQueueRepo docstring for details. + """ + if redis_url: + try: + repo = RedisQueueRepo(redis_url) + repo.ping() + return repo # type: ignore[return-value] + except Exception as exc: + import warnings + + warnings.warn( + f"Redis connection failed ({exc}); falling back to in-memory quick-match queue.", + stacklevel=2, + ) + return InMemoryQueueRepo() diff --git a/backend/app/repositories/reconnect_repo.py b/backend/app/repositories/reconnect_repo.py new file mode 100644 index 0000000..ac16699 --- /dev/null +++ b/backend/app/repositories/reconnect_repo.py @@ -0,0 +1,231 @@ +""" +Reconnect token storage. + +Two implementations are provided: + + InMemoryReconnectRepo — default, single-process, no external deps. + Used automatically when REDIS_URL is not set. + + RedisReconnectRepo — production-grade, TTL expiry handled by Redis. + Requires ``redis[hiredis]`` package. + Activated when REDIS_URL is set in config. + +The public factory ``make_reconnect_repo(redis_url)`` selects the right +implementation and falls back to the in-memory version if the Redis +connection fails, so the server remains functional without Redis in local +development. + +Token lifecycle: + 1. Created at match start (one per human seat). + 2. Looked up on RECONNECT; expired/missing tokens are rejected. + 3. Revoked on: bot takeover, match end, or explicit invalidation. + +TTL = RECONNECT_GRACE_SECONDS * 4 so the token outlives the grace window +even if the client takes a few reconnect attempts. +""" + +import time +import uuid +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class ReconnectEntry: + """All fields needed to restore a WsSession on reconnect.""" + + token: str + match_id: str + room_code: str + seat_index: int + user_id: Optional[str] + display_name: str + account_type: str # "registered" | "guest" + expires_at: float # absolute time.time() deadline + + +# --------------------------------------------------------------------------- +# In-memory implementation +# --------------------------------------------------------------------------- + + +class InMemoryReconnectRepo: + def __init__(self) -> None: + self._tokens: dict[str, ReconnectEntry] = {} + + def create( + self, + match_id: str, + room_code: str, + seat_index: int, + user_id: Optional[str], + display_name: str, + account_type: str, + ttl_seconds: int, + ) -> str: + # Invalidate any existing token for this (match, seat) pair first. + self.revoke_by_match_seat(match_id, seat_index) + token = str(uuid.uuid4()) + self._tokens[token] = ReconnectEntry( + token=token, + match_id=match_id, + room_code=room_code, + seat_index=seat_index, + user_id=user_id, + display_name=display_name, + account_type=account_type, + expires_at=time.time() + ttl_seconds, + ) + return token + + def get(self, token: str) -> Optional[ReconnectEntry]: + entry = self._tokens.get(token) + if entry is None: + return None + if time.time() > entry.expires_at: + self._tokens.pop(token, None) + return None + return entry + + def revoke_by_match_seat(self, match_id: str, seat_index: int) -> None: + to_delete = [ + t + for t, e in self._tokens.items() + if e.match_id == match_id and e.seat_index == seat_index + ] + for t in to_delete: + self._tokens.pop(t, None) + + def revoke_by_match(self, match_id: str) -> None: + to_delete = [t for t, e in self._tokens.items() if e.match_id == match_id] + for t in to_delete: + self._tokens.pop(t, None) + + def clear(self) -> None: + self._tokens.clear() + + +# --------------------------------------------------------------------------- +# Redis implementation (optional) +# --------------------------------------------------------------------------- + + +class RedisReconnectRepo: + """ + Redis-backed token store. Requires ``redis[hiredis]``. + + Tokens are stored as Redis hashes under ``fall_in:reconnect:``. + A reverse index at ``fall_in:reconnect:seat::`` maps a + seat back to its current token so revoke_by_match_seat is O(1). + """ + + _PREFIX = "fall_in:reconnect:" + + def __init__(self, redis_url: str) -> None: + import redis as _redis # optional dependency + + self._r = _redis.from_url(redis_url, decode_responses=True) + + def ping(self) -> None: + """Raise if Redis is unreachable (used by factory for health check).""" + self._r.ping() + + def _token_key(self, token: str) -> str: + return f"{self._PREFIX}{token}" + + def _seat_key(self, match_id: str, seat_index: int) -> str: + return f"{self._PREFIX}seat:{match_id}:{seat_index}" + + def create( + self, + match_id: str, + room_code: str, + seat_index: int, + user_id: Optional[str], + display_name: str, + account_type: str, + ttl_seconds: int, + ) -> str: + self.revoke_by_match_seat(match_id, seat_index) + token = str(uuid.uuid4()) + expires_at = time.time() + ttl_seconds + mapping = { + "token": token, + "match_id": match_id, + "room_code": room_code, + "seat_index": str(seat_index), + "user_id": user_id or "", + "display_name": display_name, + "account_type": account_type, + "expires_at": str(expires_at), + } + pipe = self._r.pipeline() + pipe.hset(self._token_key(token), mapping=mapping) + pipe.expire(self._token_key(token), ttl_seconds + 60) + pipe.setex(self._seat_key(match_id, seat_index), ttl_seconds + 60, token) + pipe.execute() + return token + + def get(self, token: str) -> Optional[ReconnectEntry]: + data = self._r.hgetall(self._token_key(token)) + if not data: + return None + expires_at = float(data["expires_at"]) + if time.time() > expires_at: + self._r.delete(self._token_key(token)) + return None + return ReconnectEntry( + token=token, + match_id=data["match_id"], + room_code=data["room_code"], + seat_index=int(data["seat_index"]), + user_id=data["user_id"] or None, + display_name=data["display_name"], + account_type=data["account_type"], + expires_at=expires_at, + ) + + def revoke_by_match_seat(self, match_id: str, seat_index: int) -> None: + seat_key = self._seat_key(match_id, seat_index) + old_token = self._r.get(seat_key) + if old_token: + self._r.delete(self._token_key(old_token)) + self._r.delete(seat_key) + + def revoke_by_match(self, match_id: str) -> None: + for seat_idx in range(4): + self.revoke_by_match_seat(match_id, seat_idx) + + def clear(self) -> None: + """Flush all keys with our prefix. Use only in tests.""" + keys = self._r.keys(f"{self._PREFIX}*") + if keys: + self._r.delete(*keys) + + +# --------------------------------------------------------------------------- +# Factory +# --------------------------------------------------------------------------- + + +def make_reconnect_repo( + redis_url: Optional[str] = None, +) -> InMemoryReconnectRepo: # return type is the base duck-type + """ + Return a Redis-backed repo if redis_url is set and reachable, + otherwise fall back to the in-memory implementation. + """ + if redis_url: + try: + repo = RedisReconnectRepo(redis_url) + repo.ping() + return repo # type: ignore[return-value] + except Exception as exc: + import warnings + + warnings.warn( + f"Redis connection failed ({exc}); " + "falling back to in-memory reconnect token store.", + stacklevel=2, + ) + return InMemoryReconnectRepo() diff --git a/backend/app/repositories/report_repo.py b/backend/app/repositories/report_repo.py new file mode 100644 index 0000000..19d2917 --- /dev/null +++ b/backend/app/repositories/report_repo.py @@ -0,0 +1,117 @@ +""" +Report repository — CRUD for the reports table. + +All writes go through the service layer; this module is purely data access. +""" + +from __future__ import annotations + +from typing import Optional + +from sqlalchemy.orm import Session + +from app.models.db import Report, ReportReasonCode, ReportStatus + + +def create( + db: Session, + *, + reporter_user_id: Optional[str], + reported_user_id: Optional[str], + reported_connection_id: Optional[str], + reason_code: ReportReasonCode, + details: Optional[str], + room_code: Optional[str], + match_id: Optional[str], +) -> Report: + """Persist a new report and return the committed object.""" + report = Report( + reporter_user_id=reporter_user_id, + reported_user_id=reported_user_id, + reported_connection_id=reported_connection_id, + reason_code=reason_code, + details=details, + room_code=room_code, + match_id=match_id, + status=ReportStatus.OPEN, + ) + db.add(report) + db.commit() + db.refresh(report) + return report + + +def get_by_id(db: Session, report_id: str) -> Optional[Report]: + return db.get(Report, report_id) + + +def list_reports( + db: Session, + *, + status: Optional[ReportStatus] = None, + reason_code: Optional[ReportReasonCode] = None, + limit: int = 50, + offset: int = 0, +) -> list[Report]: + """Return reports with optional filtering for admin review.""" + query = db.query(Report) + if status is not None: + query = query.filter(Report.status == status) + if reason_code is not None: + query = query.filter(Report.reason_code == reason_code) + query = query.order_by(Report.created_at.desc()) + return query.offset(offset).limit(limit).all() + + +def count_reports( + db: Session, + *, + status: Optional[ReportStatus] = None, + reason_code: Optional[ReportReasonCode] = None, +) -> int: + """Return the total number of reports matching the given filters.""" + query = db.query(Report) + if status is not None: + query = query.filter(Report.status == status) + if reason_code is not None: + query = query.filter(Report.reason_code == reason_code) + return query.count() + + +def find_duplicate( + db: Session, + *, + reporter_user_id: Optional[str], + reported_user_id: Optional[str], + reported_connection_id: Optional[str], + reason_code: ReportReasonCode, + match_id: Optional[str], +) -> Optional[Report]: + """ + Return an existing open report if the same reporter has already filed + the same reason against the same target in the same match/session. + + A duplicate is defined as: identical (reporter, reported target, reason, + match_id) where the existing report is still OPEN. If match_id is None + the match is not considered (connection-ID-only reports). + """ + query = db.query(Report).filter( + Report.reason_code == reason_code, + Report.status == ReportStatus.OPEN, + ) + if reporter_user_id is not None: + query = query.filter(Report.reporter_user_id == reporter_user_id) + if reported_user_id is not None: + query = query.filter(Report.reported_user_id == reported_user_id) + elif reported_connection_id is not None: + query = query.filter(Report.reported_connection_id == reported_connection_id) + if match_id is not None: + query = query.filter(Report.match_id == match_id) + return query.first() + + +def update_status(db: Session, report: Report, new_status: ReportStatus) -> Report: + report.status = new_status + db.commit() + db.refresh(report) + return report diff --git a/backend/app/repositories/room_repo.py b/backend/app/repositories/room_repo.py new file mode 100644 index 0000000..010b0c0 --- /dev/null +++ b/backend/app/repositories/room_repo.py @@ -0,0 +1,68 @@ +""" +In-memory room repository. + +Designed so the storage backend can be swapped for Redis later (PR-06). +The interface contract callers must depend on: + - generate_room_code() -> str + - create(room) -> Room + - get(room_code) -> Optional[Room] + - update(room) -> None + - delete(room_code) -> None + - find_by_connection(conn_id) -> Optional[tuple[str, int]] +""" + +import random +import string +from typing import Optional + +from app.models.room import Room + + +class InMemoryRoomRepo: + def __init__(self) -> None: + self._rooms: dict[str, Room] = {} + + # ------------------------------------------------------------------ + # Code generation + # ------------------------------------------------------------------ + + def generate_room_code(self) -> str: + chars = string.ascii_uppercase + string.digits + for _ in range(200): + code = "".join(random.choices(chars, k=6)) + if code not in self._rooms: + return code + raise RuntimeError("Could not generate a unique room code") + + # ------------------------------------------------------------------ + # CRUD + # ------------------------------------------------------------------ + + def create(self, room: Room) -> Room: + self._rooms[room.room_code] = room + return room + + def get(self, room_code: str) -> Optional[Room]: + return self._rooms.get(room_code.upper()) + + def update(self, room: Room) -> None: + self._rooms[room.room_code] = room + + def delete(self, room_code: str) -> None: + self._rooms.pop(room_code, None) + + # ------------------------------------------------------------------ + # Lookups + # ------------------------------------------------------------------ + + def find_by_connection(self, conn_id: str) -> Optional[tuple[str, int]]: + """Return (room_code, seat_index) for a connection, or None.""" + for room_code, room in self._rooms.items(): + for seat_idx, participant in room.participants.items(): + if participant.connection_id == conn_id: + return room_code, seat_idx + return None + + def clear(self) -> None: + """Reset all state. Used in tests between test runs.""" + self._rooms.clear() diff --git a/backend/app/repositories/user_repo.py b/backend/app/repositories/user_repo.py new file mode 100644 index 0000000..a7f505b --- /dev/null +++ b/backend/app/repositories/user_repo.py @@ -0,0 +1,89 @@ +""" +Database operations for User and Profile rows. + +All functions accept an explicit Session — no global state. +Callers (services / routes) own transaction boundaries. +""" + +from datetime import datetime, timezone +from typing import Optional + +from sqlalchemy.orm import Session + +from app.models.db import AccountType, Profile, User + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc).replace(tzinfo=None) + + +# --------------------------------------------------------------------------- +# Read +# --------------------------------------------------------------------------- + + +def get_by_id(db: Session, user_id: str) -> Optional[User]: + return db.get(User, user_id) + + +def get_by_email(db: Session, email: str) -> Optional[User]: + return db.query(User).filter(User.email == email).first() + + +# --------------------------------------------------------------------------- +# Write +# --------------------------------------------------------------------------- + + +def create_registered( + db: Session, + *, + email: str, + password_hash: str, + nickname: str, +) -> User: + """Create a registered user + profile in a single transaction.""" + user = User( + account_type=AccountType.REGISTERED, + email=email, + password_hash=password_hash, + ) + db.add(user) + db.flush() # populate user.id before creating Profile + + profile = Profile( + user_id=user.id, + nickname=nickname, + updated_at=_utcnow(), + ) + db.add(profile) + db.commit() + db.refresh(user) + return user + + +def create_guest(db: Session, *, nickname: str) -> User: + """ + Create a guest user + profile. + + Guest rows are ephemeral: they have no email/password and + should never accumulate collection entries or MMR. + """ + user = User(account_type=AccountType.GUEST) + db.add(user) + db.flush() + + profile = Profile( + user_id=user.id, + nickname=nickname, + updated_at=_utcnow(), + ) + db.add(profile) + db.commit() + db.refresh(user) + return user + + +def touch_last_login(db: Session, user: User) -> None: + user.last_login_at = _utcnow() + db.commit() diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 0000000..556b972 --- /dev/null +++ b/backend/app/schemas/auth.py @@ -0,0 +1,64 @@ +""" +Pydantic schemas for authentication endpoints. +""" + +from typing import Optional + +from pydantic import BaseModel, EmailStr, Field, field_validator + +from app.services.nickname_service import NicknameError, validate_nickname + + +def _validate_nick(v: str) -> str: + """Pydantic field validator that runs the nickname service check.""" + try: + return validate_nickname(v) + except NicknameError as exc: + raise ValueError(str(exc)) from exc + + +class RegisterRequest(BaseModel): + email: EmailStr + password: str = Field(min_length=8, max_length=128) + nickname: str = Field(min_length=1, max_length=50) + + @field_validator("nickname") + @classmethod + def nickname_policy(cls, v: str) -> str: + return _validate_nick(v) + + +class LoginRequest(BaseModel): + email: EmailStr + password: str + + +class GuestLoginRequest(BaseModel): + nickname: Optional[str] = Field(default=None, min_length=1, max_length=50) + + @field_validator("nickname") + @classmethod + def nickname_policy(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return v + return _validate_nick(v) + + +class RefreshRequest(BaseModel): + refresh_token: str + + +class TokenResponse(BaseModel): + """Minimal token response (used for refresh endpoint).""" + + access_token: str + token_type: str = "bearer" + + +class AuthResponse(BaseModel): + """Full auth response (register / login / guest).""" + + access_token: str + token_type: str = "bearer" + refresh_token: Optional[str] = None # None for guest accounts + account_type: str diff --git a/backend/app/schemas/profile.py b/backend/app/schemas/profile.py new file mode 100644 index 0000000..bfaa85b --- /dev/null +++ b/backend/app/schemas/profile.py @@ -0,0 +1,32 @@ +""" +Pydantic schemas for profile and collection endpoints. + +Note: hidden_mmr is intentionally absent from ProfileResponse. +It is stored in the DB but never exposed to clients in the beta build. +""" + +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel + + +class ProfileResponse(BaseModel): + user_id: str + nickname: str + avatar_id: Optional[str] + currency: int + total_games: int + total_wins: int + account_type: str + + +class CollectionEntry(BaseModel): + soldier_id: int + unlocked_at: datetime + source: Optional[str] + + +class CollectionResponse(BaseModel): + items: list[CollectionEntry] + total: int diff --git a/backend/app/schemas/progress.py b/backend/app/schemas/progress.py new file mode 100644 index 0000000..f6ab7d2 --- /dev/null +++ b/backend/app/schemas/progress.py @@ -0,0 +1,48 @@ +""" +Schemas for client progress synchronisation. + +These endpoints are intentionally narrow: + - initial merge of local single-player progress into a registered account + - delta-based reward claims with server-side validation + - idempotent soldier unlock writes +""" + +from typing import Literal + +from pydantic import BaseModel, Field + + +class ProgressMergeRequest(BaseModel): + currency: int = Field(ge=0) + collected_soldier_ids: list[int] = Field(default_factory=list) + + +class ProgressMergeResponse(BaseModel): + currency: int + collected_soldier_ids: list[int] + total_collected: int + + +class RewardClaimRequest(BaseModel): + """Delta-based reward claim with reason for server validation.""" + + amount: int = Field(ge=0) + reason: Literal[ + "single_play_victory", + "single_play_defeat", + ] + + +class RewardClaimResponse(BaseModel): + granted: int + currency: int + + +class CollectionUnlockRequest(BaseModel): + soldier_id: int = Field(ge=1) + + +class CollectionUnlockResponse(BaseModel): + soldier_id: int + added: bool + total_collected: int diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py new file mode 100644 index 0000000..bf08dde --- /dev/null +++ b/backend/app/services/auth_service.py @@ -0,0 +1,43 @@ +""" +Auth service — business logic for authentication flows. + +Keeps route handlers thin by centralising credential validation here. +""" + +from sqlalchemy.orm import Session + +from app.auth.password import verify_password +from app.models.db import AccountType, User, UserStatus +from app.repositories import user_repo + + +class AuthenticationError(Exception): + """Raised when credentials are invalid.""" + + pass + + +def authenticate(db: Session, email: str, password: str) -> User: + """ + Validate email + password and return the matching User. + + Raises AuthenticationError on any failure (user not found, + wrong password, account is a guest, or account is not ACTIVE). + Using the same exception type for all cases avoids leaking + which part of the check failed (timing-safe). + """ + user = user_repo.get_by_email(db, email) + + if user is None or user.account_type != AccountType.REGISTERED: + raise AuthenticationError("Invalid credentials") + + if not user.password_hash: + raise AuthenticationError("Invalid credentials") + + if not verify_password(password, user.password_hash): + raise AuthenticationError("Invalid credentials") + + if user.status != UserStatus.ACTIVE: + raise AuthenticationError("Invalid credentials") + + return user diff --git a/backend/app/services/emote_service.py b/backend/app/services/emote_service.py new file mode 100644 index 0000000..1e90a05 --- /dev/null +++ b/backend/app/services/emote_service.py @@ -0,0 +1,191 @@ +""" +Emote broadcast service (PR-07). + +Responsibilities +---------------- +* Validate that an emote_id belongs to the fixed permitted catalog. +* Enforce per-connection rate limits (all values from settings): + - Cooldown: EMOTE_COOLDOWN_SECONDS (1.5 s) between any two emotes. + - Burst cap: at most EMOTE_BURST_CAP (3) emotes within + EMOTE_BURST_WINDOW_SECONDS (10 s) sliding window. + - Same-emote soft block: more than EMOTE_SAME_REPEAT_CAP (2) consecutive + sends of the identical slug are denied until a different emote is sent. +* Provide reset() for test isolation. + +No broadcast or session logic lives here — the WS handler is responsible +for fanout and mute filtering. + +Emote catalog +------------- +Emotes are identified by a short ASCII slug. The client maps these to +emoji / images. The server only validates the slug — it never stores or +renders it. + +Permitted slugs: + thumbsup 👍 좋아요 + thumbsdown 👎 별로 + smile 😄 웃음 + sweat 😅 땀 + thinking 🤔 생각 + fire 🔥 열정 + cry 😭 눈물 + clap 👏 박수 +""" + +from __future__ import annotations + +import time + +from app.config import settings + +# --------------------------------------------------------------------------- +# Catalog +# --------------------------------------------------------------------------- + +VALID_EMOTE_IDS: frozenset[str] = frozenset( + { + "thumbsup", + "thumbsdown", + "smile", + "sweat", + "thinking", + "fire", + "cry", + "clap", + } +) + + +# --------------------------------------------------------------------------- +# Service +# --------------------------------------------------------------------------- + + +class EmoteService: + """ + Stateful rate-limit tracker for emote broadcasts. + + One instance is shared for the entire server process. All state is + keyed by connection_id so there is no per-user persistence. + State is cleared between tests via reset(). + """ + + def __init__(self) -> None: + # connection_id -> timestamp of the most recent allowed emote + self._last_sent: dict[str, float] = {} + # connection_id -> list of timestamps within the current burst window + self._window_history: dict[str, list[float]] = {} + # connection_id -> (last_emote_id, consecutive_count) + # Tracks same-emote repetitions for soft-block enforcement. + self._same_emote: dict[str, tuple[str, int]] = {} + # connection_id -> reason string for the most recent denial + # One of: "cooldown", "burst", "same_emote" + self._last_deny_reason: dict[str, str] = {} + + # ------------------------------------------------------------------ + # Validation + # ------------------------------------------------------------------ + + def is_valid_emote(self, emote_id: str) -> bool: + """Return True if emote_id is in the permitted catalog.""" + return emote_id in VALID_EMOTE_IDS + + # ------------------------------------------------------------------ + # Rate limiting + # ------------------------------------------------------------------ + + def check_and_record(self, conn_id: str, emote_id: str) -> bool: + """ + Check whether conn_id may send *emote_id* right now. + + Returns True and records the emission if allowed. + Returns False (without recording) if any limit would be violated. + + Rules applied in order: + 1. Cooldown: now − last_sent < EMOTE_COOLDOWN_SECONDS → deny. + 2. Burst cap: entries_in_window >= EMOTE_BURST_CAP → deny. + 3. Same-emote soft block: consecutive_same > EMOTE_SAME_REPEAT_CAP + → deny until a different emote is used. + """ + now = time.time() + + # 1. Cooldown check + last = self._last_sent.get(conn_id, 0.0) + if now - last < settings.EMOTE_COOLDOWN_SECONDS: + self._last_deny_reason[conn_id] = "cooldown" + return False + + # 2. Burst window — prune expired entries then count + window = self._window_history.get(conn_id, []) + window = [t for t in window if now - t < settings.EMOTE_BURST_WINDOW_SECONDS] + if len(window) >= settings.EMOTE_BURST_CAP: + self._last_deny_reason[conn_id] = "burst" + return False + + # 3. Same-emote soft block + prev_id, streak = self._same_emote.get(conn_id, ("", 0)) + if emote_id == prev_id and streak >= settings.EMOTE_SAME_REPEAT_CAP: + self._last_deny_reason[conn_id] = "same_emote" + return False + + # Allowed — record + window.append(now) + self._last_sent[conn_id] = now + self._window_history[conn_id] = window + + # Update same-emote streak counter + if emote_id == prev_id: + self._same_emote[conn_id] = (emote_id, streak + 1) + else: + self._same_emote[conn_id] = (emote_id, 1) + + return True + + def last_deny_reason(self, conn_id: str) -> str: + """ + Return the reason for the most recent rate-limit denial for conn_id. + + Returns one of: ``"cooldown"``, ``"burst"``, ``"same_emote"``. + Returns ``""`` if conn_id has never been denied. + """ + return self._last_deny_reason.get(conn_id, "") + + def remaining_cooldown(self, conn_id: str) -> float: + """ + Return seconds until the cooldown expires for conn_id. + + Returns 0.0 if the connection is not rate-limited. + Useful for generating informative error messages. + """ + last = self._last_sent.get(conn_id, 0.0) + remaining = settings.EMOTE_COOLDOWN_SECONDS - (time.time() - last) + return max(0.0, remaining) + + # ------------------------------------------------------------------ + # Connection cleanup + # ------------------------------------------------------------------ + + def cleanup_connection(self, conn_id: str) -> None: + """Remove all rate-limit state for a disconnected connection.""" + self._last_sent.pop(conn_id, None) + self._window_history.pop(conn_id, None) + self._same_emote.pop(conn_id, None) + self._last_deny_reason.pop(conn_id, None) + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def reset(self) -> None: + """Clear all rate-limit state. Called between tests.""" + self._last_sent.clear() + self._window_history.clear() + self._same_emote.clear() + self._last_deny_reason.clear() + + +# --------------------------------------------------------------------------- +# Module-level singleton +# --------------------------------------------------------------------------- + +emote_service = EmoteService() diff --git a/backend/app/services/match_service.py b/backend/app/services/match_service.py new file mode 100644 index 0000000..85d8d22 --- /dev/null +++ b/backend/app/services/match_service.py @@ -0,0 +1,625 @@ +""" +Match business logic: authoritative server-side game engine. + +Wraps fall_in.core.GameRules to run multiplayer matches. All methods are +synchronous; async broadcasting is done by the WS handler that calls these. + +Key invariants: + - player_id == seat_index for every seat (simplifies reverse lookup). + - GameRules requires exactly 4 players; start_room() guarantees this. + - Bots auto-select their card immediately at the start of each round. + - board_row_owners[row][pos] = seat_index (-1 for starter cards). + - last_turn_steps tracks the most recent turn for PublicMatchState serialisation. +""" + +from __future__ import annotations + +import uuid +from typing import Optional + +from fall_in.ai.ai_player import AIPlayer +from fall_in.core.player import Player, PlayerType +from fall_in.core.rules import GameRules, RoundPhase +from fall_in.multiplayer.models import ( + ControllerType, + MatchCardPublic, + PrivatePlayerState, + PublicMatchState, + SeatIdentity, +) + +from app.models.match import ActiveMatch, MatchSeat, RoundSummary, TurnStep +from app.models.room import Room, SeatControllerType +from app.repositories.match_repo import InMemoryMatchRepo + + +class MatchError(Exception): + pass + + +class MatchService: + def __init__(self, repo: InMemoryMatchRepo) -> None: + self.repo = repo + + # ------------------------------------------------------------------ + # Create + # ------------------------------------------------------------------ + + def create_match(self, room: Room) -> ActiveMatch: + """ + Create an ActiveMatch from a STARTING room. + + Builds Player objects for each seat, runs start_new_round(), and + auto-selects cards for all bot seats. Raises MatchError if the + room does not have exactly 4 participants. + """ + match_id = str(uuid.uuid4()) + + players: list[Player] = [] + seats: dict[int, MatchSeat] = {} + player_to_seat: dict[int, int] = {} + + for seat_idx in range(4): + participant = room.participants.get(seat_idx) + if participant is None: + raise MatchError( + f"Room {room.room_code} is missing seat {seat_idx}. " + "All 4 seats must be filled before starting a match." + ) + + is_bot = participant.controller_type == SeatControllerType.BOT + ptype = PlayerType.AI if is_bot else PlayerType.HUMAN + player = Player( + name=participant.display_name, + player_type=ptype, + player_id=seat_idx, # player_id == seat_index + ) + players.append(player) + player_to_seat[seat_idx] = seat_idx + + ai_ctrl: Optional[AIPlayer] = AIPlayer(player) if is_bot else None + + account_type = participant.account_type if not is_bot else "bot" + + seats[seat_idx] = MatchSeat( + seat_index=seat_idx, + player=player, + connection_id=participant.connection_id, + user_id=participant.user_id, + display_name=participant.display_name, + controller_type=participant.controller_type, + account_type=account_type, + ai_controller=ai_ctrl, + ) + + # GameRules.__init__ asserts len(players) == NUM_PLAYERS (4). + # players are already in seat_index order (0-3). + # human_seat=None: multiplayer — game ends only when 1 survivor remains, + # not when any specific seat is eliminated. + rules = GameRules(players, human_seat=None) + + match = ActiveMatch( + match_id=match_id, + room_code=room.room_code, + rules=rules, + seats=seats, + player_to_seat=player_to_seat, + board_row_owners=[[], [], [], []], + last_turn_steps=[], + ) + + self.repo.create(match) + self._start_round(match) + return match + + # ------------------------------------------------------------------ + # Round management + # ------------------------------------------------------------------ + + def _start_round(self, match: ActiveMatch) -> None: + """ + Deal cards for a new round and immediately auto-select for bot seats. + + Called by create_match() for the first round, and by start_next_round() + for subsequent rounds. + """ + rules: GameRules = match.rules # type: ignore[assignment] + rules.start_new_round() + + # Starter cards placed on rows at round start have no owner. + match.board_row_owners = [[-1] for _ in range(4)] + match.last_turn_steps = [] + + # Bots select immediately; humans will submit CARD_SELECT over WS. + for seat in match.seats.values(): + if seat.ai_controller is not None and not seat.player.is_eliminated: # type: ignore[union-attr] + seat.ai_controller.select_card(rules.board) # type: ignore[union-attr] + + def start_next_round(self, match: ActiveMatch) -> None: + """Start the next round (used after ROUND_RESULT is broadcast).""" + self._start_round(match) + + def begin_round_settlement(self, match: ActiveMatch, summary: RoundSummary) -> None: + """Mark the match as waiting on the round-settlement screen.""" + match.round_settlement_pending = True + match.round_settlement_ready_seats.clear() + match.round_summary_pending = summary + + def has_round_settlement_pending(self, match: ActiveMatch) -> bool: + return match.round_settlement_pending and match.round_summary_pending is not None + + def mark_round_ready(self, match: ActiveMatch, seat_index: int) -> bool: + """ + Record one human seat's acknowledgement of the round-settlement screen. + + Returns True once every human seat still controlled by a REMOTE player + has acknowledged. + """ + if not self.has_round_settlement_pending(match): + raise MatchError("Round settlement is not active") + + seat = match.seats.get(seat_index) + if seat is None: + raise MatchError(f"Seat {seat_index} not found in match") + if seat.controller_type != SeatControllerType.REMOTE: + raise MatchError("Only human (REMOTE) seats can acknowledge settlement") + + match.round_settlement_ready_seats.add(seat_index) + required = { + s.seat_index + for s in match.seats.values() + if s.controller_type == SeatControllerType.REMOTE and not s.player.is_eliminated # type: ignore[union-attr] + } + return required.issubset(match.round_settlement_ready_seats) + + def clear_round_settlement(self, match: ActiveMatch) -> Optional[RoundSummary]: + """Clear the active round-settlement state and return its summary.""" + summary = match.round_summary_pending + match.round_settlement_pending = False + match.round_settlement_ready_seats.clear() + match.round_summary_pending = None + return summary + + def reselect_bots(self, match: ActiveMatch) -> None: + """ + Auto-select cards for all non-eliminated bot seats. + + Must be called after each turn resolution before re-entering the + SELECTING phase so that bots have a card ready for the next turn. + (execute_single_placement clears selected_card for every player.) + """ + rules: GameRules = match.rules # type: ignore[assignment] + for seat in match.seats.values(): + if seat.ai_controller is not None and not seat.player.is_eliminated: # type: ignore[union-attr] + seat.ai_controller.select_card(rules.board) # type: ignore[union-attr] + + # ------------------------------------------------------------------ + # Card selection + # ------------------------------------------------------------------ + + def submit_selection(self, match: ActiveMatch, seat_index: int, card_number: int) -> None: + """ + Record a human seat's card selection. + + Raises MatchError on: unknown seat, bot seat, wrong phase, eliminated + player, already-selected, or card not in hand. + """ + rules: GameRules = match.rules # type: ignore[assignment] + seat = match.seats.get(seat_index) + if seat is None: + raise MatchError(f"Seat {seat_index} not found in match") + if seat.controller_type != SeatControllerType.REMOTE: + raise MatchError("Only human (REMOTE) seats can submit card selections") + if rules.round_state.phase != RoundPhase.SELECTING: + raise MatchError("Not in SELECTING phase") + if seat.player.is_eliminated: # type: ignore[union-attr] + raise MatchError("Eliminated player cannot select a card") + if seat.player.selected_card is not None: # type: ignore[union-attr] + raise MatchError("This seat has already selected a card this turn") + + target_card = next( + (c for c in seat.player.hand if c.number == card_number), # type: ignore[union-attr] + None, + ) + if target_card is None: + raise MatchError(f"Card {card_number} is not in seat {seat_index}'s hand") + + seat.player.select_card(target_card) # type: ignore[union-attr] + + def all_selected(self, match: ActiveMatch) -> bool: + """True when every non-eliminated player has selected a card.""" + rules: GameRules = match.rules # type: ignore[assignment] + return rules.all_players_selected() + + # ------------------------------------------------------------------ + # Turn resolution + # ------------------------------------------------------------------ + + def resolve_turn(self, match: ActiveMatch) -> list[TurnStep]: + """ + Execute the full turn step by step. + + Returns the ordered list of TurnStep (one per non-eliminated player) + and updates board_row_owners and last_turn_steps. + """ + rules: GameRules = match.rules # type: ignore[assignment] + + # prepare_turn() validates all_selected and sets phase to PLACING. + play_order = rules.prepare_turn() + + steps: list[TurnStep] = [] + for order_idx, (player, card) in enumerate(play_order): + turn_result = rules.execute_single_placement(player, card, order_idx + 1) + placement = turn_result.result # fall_in.core.board.PlacementResult + seat_idx = match.player_to_seat[player.player_id] + + # Update board ownership. + if placement.had_to_take_row or placement.penalty_score > 0: + # Row was cleared (player took it or placed the 6th card). + # Our card is now the sole occupant. + match.board_row_owners[placement.row_index] = [seat_idx] + else: + # Normal placement — append to the existing owners list. + match.board_row_owners[placement.row_index].append(seat_idx) + + steps.append( + TurnStep( + seat_index=seat_idx, + card_number=card.number, + card_danger=card.danger, + row_index=placement.row_index, + penalty_score=placement.penalty_score, + had_to_take_row=placement.had_to_take_row, + order=order_idx + 1, + ) + ) + + # Update phase to SELECTING or ROUND_END. + rules.check_round_end() + + match.last_turn_steps = steps + return steps + + def resolve_turn_stepwise( + self, match: ActiveMatch + ) -> "list[tuple[TurnStep, PublicMatchState]]": + """ + Execute the full turn one placement at a time. + + After each placement the board ownership and last_turn_steps are + updated so that build_public_state() produces an incremental snapshot + (i.e., the board state *after* that specific card was placed, with + played_cards_this_turn reflecting only the cards placed so far). + + Returns a list of (TurnStep, PublicMatchState) pairs in placement order. + The caller should iterate the list to broadcast each step + snapshot. + + After this method returns, last_turn_steps contains all steps and + check_round_end() has been called to update the phase. + """ + rules: GameRules = match.rules # type: ignore[assignment] + play_order = rules.prepare_turn() + + results: list[tuple[TurnStep, PublicMatchState]] = [] + accumulated: list[TurnStep] = [] + + for order_idx, (player, card) in enumerate(play_order): + turn_result = rules.execute_single_placement(player, card, order_idx + 1) + placement = turn_result.result + seat_idx = match.player_to_seat[player.player_id] + + if placement.had_to_take_row or placement.penalty_score > 0: + match.board_row_owners[placement.row_index] = [seat_idx] + else: + match.board_row_owners[placement.row_index].append(seat_idx) + + step = TurnStep( + seat_index=seat_idx, + card_number=card.number, + card_danger=card.danger, + row_index=placement.row_index, + penalty_score=placement.penalty_score, + had_to_take_row=placement.had_to_take_row, + order=order_idx + 1, + penalty_card_count=len(placement.penalty_cards), + ) + accumulated.append(step) + # Temporarily set last_turn_steps so build_public_state reflects + # only the placements that have happened so far. + match.last_turn_steps = list(accumulated) + results.append((step, self.build_public_state(match))) + + rules.check_round_end() + # last_turn_steps already holds all steps for this turn. + return results + + # ------------------------------------------------------------------ + # Round finalisation + # ------------------------------------------------------------------ + + def finalize_round(self, match: ActiveMatch) -> RoundSummary: + """ + Commit round scores and determine if the game is over. + + Must be called after resolve_turn() confirms is_round_over(). + Players who voluntarily left mid-match are force-eliminated here. + """ + rules: GameRules = match.rules # type: ignore[assignment] + + # Force-eliminate players who left voluntarily during this round. + for seat_idx in match.voluntarily_left_seats: + seat = match.seats.get(seat_idx) + if seat is not None and not seat.player.is_eliminated: # type: ignore[union-attr] + seat.player.is_eliminated = True # type: ignore[union-attr] + match.voluntarily_left_seats.clear() + + # Sole-survivor check: if only one active player remains after + # voluntary eliminations, declare them the winner immediately + # — their score doesn't matter (they outlasted everyone else). + active_before_commit = [p for p in rules.players if not p.is_eliminated] + sole_survivor_win = len(active_before_commit) == 1 + + # {player_id: (round_danger, new_total)} + score_results = rules.commit_round_scores() + + # Override game-end result for the sole-survivor case: the last + # remaining player wins even if commit_round_scores() would have + # eliminated them for exceeding 66 danger. + if sole_survivor_win: + survivor = active_before_commit[0] + rules.game_over = True + rules.winner = survivor + # Undo the elimination that commit_round_scores may have set. + survivor.is_eliminated = False + + round_danger: dict[int, int] = {} + total_scores: dict[int, int] = {} + for player_id, (rd, total) in score_results.items(): + seat_idx = match.player_to_seat[player_id] + round_danger[seat_idx] = rd + total_scores[seat_idx] = total + + eliminated_seats = [ + seat.seat_index + for seat in match.seats.values() + if seat.player.is_eliminated # type: ignore[union-attr] + ] + + # Record the round each seat was first eliminated in. + current_round = rules.round_state.round_number + for seat_idx in eliminated_seats: + if seat_idx not in match.seat_eliminated_round: + match.seat_eliminated_round[seat_idx] = current_round + + winner_seat: Optional[int] = None + if rules.game_over and rules.winner is not None: + winner_seat = match.player_to_seat[rules.winner.player_id] + + return RoundSummary( + round_number=rules.round_state.round_number, + round_danger=round_danger, + total_scores=total_scores, + eliminated_seats=eliminated_seats, + game_over=rules.game_over, + winner_seat=winner_seat, + ) + + # ------------------------------------------------------------------ + # State snapshots + # ------------------------------------------------------------------ + + def build_public_state(self, match: ActiveMatch) -> PublicMatchState: + """ + Produce a wire-safe PublicMatchState for broadcasting to all seats. + + Uses board_row_owners for per-card owner_seat values. + played_cards_this_turn reflects the most recently resolved turn. + """ + rules: GameRules = match.rules # type: ignore[assignment] + + # Board rows: zip actual cards with their ownership tracking. + board_rows: list[list[MatchCardPublic]] = [] + for row_idx, row in enumerate(rules.board.rows): + owners = ( + match.board_row_owners[row_idx] if row_idx < len(match.board_row_owners) else [] + ) + board_rows.append( + [ + MatchCardPublic( + number=c.number, + danger=c.danger, + owner_seat=owners[pos] if pos < len(owners) else -1, + ) + for pos, c in enumerate(row) + ] + ) + + # Player turn order as seat indices (skip eliminated). + player_order_seats = [ + match.player_to_seat[p.player_id] for p in rules.player_order if not p.is_eliminated + ] + + # Cumulative committed scores keyed by seat_index. + committed_scores = { + match.player_to_seat[pid]: score for pid, score in rules.committed_scores.items() + } + + # Seat identity list for all four seats. + seat_identities: list[SeatIdentity] = [ + SeatIdentity( + seat_index=seat.seat_index, + controller_type=( + ControllerType.BOT + if seat.controller_type == SeatControllerType.BOT + else ControllerType.REMOTE + ), + display_name=seat.display_name, + user_id=seat.user_id, + ) + for seat in sorted(match.seats.values(), key=lambda s: s.seat_index) + ] + + # Cards placed during the most recently resolved turn. + played_this_turn: list[MatchCardPublic] = [ + MatchCardPublic( + number=step.card_number, + danger=step.card_danger, + owner_seat=step.seat_index, + ) + for step in match.last_turn_steps + ] + + return PublicMatchState( + match_id=match.match_id, + round_number=rules.round_state.round_number, + phase=rules.round_state.phase.name, + player_order_seats=player_order_seats, + board_rows=board_rows, + played_cards_this_turn=played_this_turn, + committed_scores=committed_scores, + seats=seat_identities, + ) + + def build_private_state(self, match: ActiveMatch, seat_index: int) -> PrivatePlayerState: + """ + Produce a PrivatePlayerState for unicasting to the given seat only. + + owner_seat on hand cards is always seat_index (the viewer's own seat). + """ + seat = match.seats.get(seat_index) + if seat is None: + raise MatchError(f"Seat {seat_index} not found in match {match.match_id}") + + hand = [ + MatchCardPublic( + number=c.number, + danger=c.danger, + owner_seat=seat_index, + ) + for c in seat.player.hand # type: ignore[union-attr] + ] + + return PrivatePlayerState( + seat_index=seat_index, + hand=hand, + has_selected=seat.player.selected_card is not None, # type: ignore[union-attr] + is_eliminated=seat.player.is_eliminated, # type: ignore[union-attr] + ) + + # ------------------------------------------------------------------ + # Lookup helpers + # ------------------------------------------------------------------ + + def get_match(self, match_id: str) -> Optional[ActiveMatch]: + return self.repo.get(match_id) + + def get_match_by_room(self, room_code: str) -> Optional[ActiveMatch]: + return self.repo.get_by_room(room_code) + + def delete_match(self, match_id: str) -> None: + self.repo.delete(match_id) + + # ------------------------------------------------------------------ + # Presence / reconnect helpers (PR-05) + # ------------------------------------------------------------------ + + def mark_seat_disconnected(self, match: ActiveMatch, seat_index: int) -> None: + """ + Record that a human seat has lost its WebSocket connection. + + Sets is_disconnected = True and captures disconnected_at timestamp. + The connection_id is cleared; the PresenceManager's grace timer will + either restore it on reconnect or convert the seat to bot control. + """ + import time + + seat = match.seats.get(seat_index) + if seat is None: + return + seat.is_disconnected = True + seat.disconnected_at = time.time() + seat.connection_id = None + + def reconnect_seat(self, match: ActiveMatch, seat_index: int, new_connection_id: str) -> None: + """ + Restore a human seat after a successful reconnect. + + Updates connection_id and clears the disconnected flags. + """ + seat = match.seats.get(seat_index) + if seat is None: + raise MatchError(f"Seat {seat_index} not found in match {match.match_id}") + seat.connection_id = new_connection_id + seat.is_disconnected = False + seat.disconnected_at = None + + def takeover_seat(self, match: ActiveMatch, seat_index: int) -> None: + """ + Permanently convert a disconnected REMOTE seat to BOT control. + + Installs an AIPlayer, marks took_over_by_bot = True, and immediately + auto-selects a card if the match is in SELECTING phase. Once this is + called the seat can never be reclaimed by a reconnecting human. + """ + seat = match.seats.get(seat_index) + if seat is None: + raise MatchError(f"Seat {seat_index} not found in match {match.match_id}") + + seat.controller_type = SeatControllerType.BOT + seat.took_over_by_bot = True + seat.is_disconnected = False # fully converted — no longer "disconnected" + seat.connection_id = None + + if seat.ai_controller is None: + # AIPlayer requires player_type == AI; upgrade before constructing. + seat.player.player_type = PlayerType.AI # type: ignore[union-attr] + seat.ai_controller = AIPlayer(seat.player) # type: ignore[arg-type] + + rules: GameRules = match.rules # type: ignore[assignment] + if ( + rules.round_state.phase == RoundPhase.SELECTING + and not seat.player.is_eliminated # type: ignore[union-attr] + and seat.player.selected_card is None # type: ignore[union-attr] + ): + seat.ai_controller.select_card(rules.board) # type: ignore[union-attr] + + def auto_select_timed_out(self, match: ActiveMatch) -> list[int]: + """ + Auto-select cards for any non-eliminated REMOTE seats that have not + yet selected during the current SELECTING phase. + + Uses temporary AIPlayer instances so the seat remains REMOTE (the + player is still considered human — they just timed out this turn). + Returns the list of seat indices that were auto-selected. + """ + rules: GameRules = match.rules # type: ignore[assignment] + if rules.round_state.phase != RoundPhase.SELECTING: + return [] + + timed_out: list[int] = [] + for seat in match.seats.values(): + if ( + seat.controller_type == SeatControllerType.REMOTE + and not seat.player.is_eliminated # type: ignore[union-attr] + and seat.player.selected_card is None # type: ignore[union-attr] + ): + # Temporarily mark player as AI so AIPlayer can be constructed. + # The seat remains REMOTE — the player is still human, just + # auto-selected for this turn because they timed out. + original_type = seat.player.player_type # type: ignore[union-attr] + seat.player.player_type = PlayerType.AI # type: ignore[union-attr] + try: + ai = ( + seat.ai_controller + if seat.ai_controller is not None + else AIPlayer(seat.player) # type: ignore[arg-type] + ) + ai.select_card(rules.board) + finally: + seat.player.player_type = original_type # type: ignore[union-attr] + timed_out.append(seat.seat_index) + return timed_out + + def reset(self) -> None: + """Clear all active matches. Called between tests.""" + self.repo.clear() diff --git a/backend/app/services/matchmaking_service.py b/backend/app/services/matchmaking_service.py new file mode 100644 index 0000000..89a810b --- /dev/null +++ b/backend/app/services/matchmaking_service.py @@ -0,0 +1,251 @@ +""" +Quick-match matchmaking service (PR-06). + +Responsibilities +---------------- +* Accept QUICK_MATCH_JOIN requests and place players into per-bucket queues. +* Immediately form a match when a bucket reaches 4 players. +* Start a per-bucket fill timer (QUICK_MATCH_FILL_SECONDS) when the first + player enters; on expiry, fill remaining seats with AI bots and start the + match regardless of queue size. +* Remove players from the queue on QUICK_MATCH_LEAVE or WebSocket disconnect. + +Match formation +--------------- +When 4 players are ready (either 4 humans or humans + AI fill), the service: + 1. Builds an in-memory Room in STARTING phase (no lobby hand-shake needed). + 2. Calls MatchService.create_match() to deal cards and auto-select bots. + 3. Sets match.is_ranked = True (all-human start) or False (AI fill). + 4. Issues a RECONNECT_TOKEN to each human seat via PresenceManager. + 5. Broadcasts MATCH_FOUND + MATCH_START + PHASE_SELECTING to all human + connections via ConnectionManager. + +The ``on_match_formed`` callback is passed by the WS endpoint so that the +actual broadcast work (which requires an async context) happens in the +correct event loop. This avoids circular imports between handler and service. + +Thread safety +------------- +All asyncio.Tasks are created on the running event loop. reset() cancels +every pending task before clearing state so test isolation is preserved. +""" + +from __future__ import annotations + +import asyncio +import time +import uuid +from typing import Any, Awaitable, Callable, Optional + +from app.config import settings +from app.models.room import Room, RoomParticipant, RoomPhase, SeatControllerType +from app.repositories.queue_repo import QueueEntry + +# Callback type: async (human_entries, ai_count, is_ranked) -> None +OnMatchFormed = Callable[[list[QueueEntry], int, bool], Awaitable[None]] + + +class MatchmakingService: + def __init__(self, queue_repo: Any) -> None: + self._repo = queue_repo # InMemoryQueueRepo | RedisQueueRepo + # bucket -> fill asyncio.Task + self._fill_tasks: dict[str, asyncio.Task] = {} + + # ------------------------------------------------------------------ + # MMR → Bucket + # ------------------------------------------------------------------ + + @staticmethod + def get_bucket(mmr: int) -> str: + """Map MMR to a bucket name used for queue grouping.""" + from app.services.mmr_service import mmr_to_bucket + + return mmr_to_bucket(mmr) + + # ------------------------------------------------------------------ + # Queue entry / exit + # ------------------------------------------------------------------ + + def join_queue( + self, + connection_id: str, + user_id: Optional[str], + display_name: str, + account_type: str, + mmr: int, + ) -> QueueEntry: + """ + Add a player to the appropriate bucket queue. + + Returns the created QueueEntry. The caller should then call + try_form_match() to check for an immediate 4-player match, and + start_fill_timer() if one wasn't formed. + """ + bucket = self.get_bucket(mmr) + entry = QueueEntry( + connection_id=connection_id, + user_id=user_id, + display_name=display_name, + account_type=account_type, + mmr=mmr, + bucket=bucket, + joined_at=time.time(), + ) + self._repo.add(entry) + return entry + + def leave_queue(self, connection_id: str) -> Optional[QueueEntry]: + """ + Remove a player from the queue. + + Returns the removed QueueEntry, or None if the player was not queued. + Call cancel_fill_timer() afterwards if the bucket is now empty. + """ + return self._repo.remove(connection_id) + + def get_entry(self, connection_id: str) -> Optional[QueueEntry]: + return self._repo.get_entry(connection_id) + + def bucket_size(self, bucket: str) -> int: + return self._repo.bucket_size(bucket) + + # ------------------------------------------------------------------ + # Immediate match formation (4 humans ready) + # ------------------------------------------------------------------ + + def try_form_match(self, bucket: str) -> Optional[list[QueueEntry]]: + """ + If >= 4 players are in *bucket*, pop the first 4 and return them. + + Returns None if fewer than 4 players are waiting. + The caller is responsible for cancelling the fill timer for this bucket. + """ + entries = self._repo.get_bucket(bucket) + if len(entries) < 4: + return None + four = entries[:4] + for e in four: + self._repo.remove(e.connection_id) + return four + + # ------------------------------------------------------------------ + # Fill timer (20-second AI fallback) + # ------------------------------------------------------------------ + + def start_fill_timer( + self, + bucket: str, + on_match_formed: OnMatchFormed, + ) -> None: + """ + Start the fill timer for *bucket* if one is not already running. + + On expiry, whatever humans are in the bucket (1–3) are matched with + AI bots to fill the remaining seats. + """ + if bucket in self._fill_tasks and not self._fill_tasks[bucket].done(): + return # timer already running + self._fill_tasks[bucket] = asyncio.create_task(self._fill_timer(bucket, on_match_formed)) + + def cancel_fill_timer(self, bucket: str) -> None: + task = self._fill_tasks.pop(bucket, None) + if task and not task.done(): + task.cancel() + + async def _fill_timer( + self, + bucket: str, + on_match_formed: OnMatchFormed, + ) -> None: + try: + await asyncio.sleep(settings.QUICK_MATCH_FILL_SECONDS) + + entries = self._repo.get_bucket(bucket) + if not entries: + return # everyone left before timer fired + + # Pop all waiting players (1–3); fill rest with AI. + human_entries = entries[:4] + for e in human_entries: + self._repo.remove(e.connection_id) + + ai_count = 4 - len(human_entries) + # AI-fill match: not ranked (MMR not updated). + is_ranked = False + await on_match_formed(human_entries, ai_count, is_ranked) + except asyncio.CancelledError: + pass + finally: + self._fill_tasks.pop(bucket, None) + + # ------------------------------------------------------------------ + # Room / Match scaffolding helpers + # ------------------------------------------------------------------ + + @staticmethod + def build_quick_match_room( + human_entries: list[QueueEntry], + ai_count: int, + ) -> Room: + """ + Build an in-memory Room in STARTING phase from queue entries. + + Human entries occupy the first N seats (0…N-1). + AI bots fill the remaining seats. + """ + room_code = f"QM-{uuid.uuid4().hex[:8].upper()}" + participants: dict[int, RoomParticipant] = {} + + for seat_idx, entry in enumerate(human_entries): + participants[seat_idx] = RoomParticipant( + seat_index=seat_idx, + display_name=entry.display_name, + controller_type=SeatControllerType.REMOTE, + is_ready=True, + account_type=entry.account_type, + user_id=entry.user_id, + connection_id=entry.connection_id, + ) + + for i in range(ai_count): + seat_idx = len(human_entries) + i + participants[seat_idx] = RoomParticipant( + seat_index=seat_idx, + display_name=f"AI Bot {seat_idx + 1}", + controller_type=SeatControllerType.BOT, + is_ready=True, + account_type="bot", + user_id=None, + connection_id=None, + ) + + return Room( + room_code=room_code, + host_seat_index=0, + phase=RoomPhase.STARTING, + participants=participants, + ) + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def reset(self) -> None: + """Cancel all pending tasks and clear queue state. Called between tests.""" + for task in list(self._fill_tasks.values()): + if not task.done(): + task.cancel() + self._fill_tasks.clear() + self._repo.clear() + + +# --------------------------------------------------------------------------- +# Module-level singleton — shared by the WS endpoint and handler. +# Tests override it via the reset_matchmaking_service fixture. +# --------------------------------------------------------------------------- + +from app.config import settings as _settings # noqa: E402 +from app.repositories.queue_repo import make_queue_repo # noqa: E402 + +_queue_repo = make_queue_repo(_settings.REDIS_URL) +matchmaking_service = MatchmakingService(_queue_repo) diff --git a/backend/app/services/mmr_service.py b/backend/app/services/mmr_service.py new file mode 100644 index 0000000..0bc93e1 --- /dev/null +++ b/backend/app/services/mmr_service.py @@ -0,0 +1,192 @@ +""" +Hidden MMR service (PR-06). + +Responsibilities +---------------- +* Read a player's current hidden MMR from the Profile row (or return DEFAULT_MMR + if the profile has none set yet). +* Compute per-seat MMR deltas after a match ends using a simple 4-player Elo + variant. +* Persist delta updates only for eligible matches: + - Match must be a ranked quick-match (ActiveMatch.is_ranked == True). + - 4 human seats → full K-factor update. + - 3 human seats → half K-factor update. + - ≤ 2 human seats → no update (AI-heavy; not meaningful data). + +MMR is never exposed via the profile API. It is used internally only for +matchmaking bucket assignment. + +Delta formula (zero-sum across 4 players): + winner: +K points + each of 3 losers: -K/3 points (integer division; small rounding accepted) + +Bucket thresholds (MMR → bucket name): + 0 – 799: "bronze" + 800 – 1199: "silver" + 1200 – 1599: "gold" + 1600+: "diamond" +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional + +from app.config import settings +from app.models.db import Profile + +if TYPE_CHECKING: + from sqlalchemy.orm import Session + + from app.models.match import ActiveMatch + + +# --------------------------------------------------------------------------- +# Bucket mapping +# --------------------------------------------------------------------------- + +_BUCKET_THRESHOLDS: list[tuple[int, str]] = [ + (1600, "diamond"), + (1200, "gold"), + (800, "silver"), + (0, "bronze"), +] + + +def mmr_to_bucket(mmr: int) -> str: + """Return the bucket name for a given MMR value.""" + for threshold, name in _BUCKET_THRESHOLDS: + if mmr >= threshold: + return name + return "bronze" + + +# --------------------------------------------------------------------------- +# MMR service +# --------------------------------------------------------------------------- + + +class MmrService: + # ------------------------------------------------------------------ + # Read + # ------------------------------------------------------------------ + + def get_mmr(self, db: "Session", user_id: str) -> int: + """ + Return the hidden MMR for a registered user. + + Falls back to DEFAULT_MMR if the profile row has no MMR set yet. + Guests must never be passed here; call site is responsible for + guarding with account_type checks. + """ + profile: Optional[Profile] = db.get(Profile, user_id) + if profile is None or profile.hidden_mmr is None: + return settings.DEFAULT_MMR + return profile.hidden_mmr + + def get_mmr_for_session( + self, + db: "Session", + user_id: Optional[str], + account_type: str, + ) -> int: + """ + Convenience wrapper: returns MMR for any session type. + + Guests always receive DEFAULT_MMR (not stored). + """ + if account_type != "registered" or user_id is None: + return settings.DEFAULT_MMR + return self.get_mmr(db, user_id) + + # ------------------------------------------------------------------ + # Delta computation + # ------------------------------------------------------------------ + + def compute_deltas( + self, + winner_seat: int, + human_seat_indices: list[int], + k_factor: int, + ) -> dict[int, int]: + """ + Compute MMR delta for each human seat. + + Returns {seat_index: delta} (positive for winner, negative for losers). + Only human seats are included; bot seats are not in the result. + """ + if winner_seat not in human_seat_indices: + # Winner was a bot (shouldn't happen in ranked matches, but guard). + return {s: 0 for s in human_seat_indices} + + losers = [s for s in human_seat_indices if s != winner_seat] + loser_penalty = k_factor // len(losers) if losers else 0 + + deltas: dict[int, int] = {winner_seat: k_factor} + for s in losers: + deltas[s] = -loser_penalty + return deltas + + # ------------------------------------------------------------------ + # Persistence + # ------------------------------------------------------------------ + + def persist_updates( + self, + db: "Session", + match: "ActiveMatch", + winner_seat: int, + ) -> None: + """ + Apply MMR deltas for an eligible ranked match. + + Rules: + - match.is_ranked must be True. + - 4 human seats (no bot takeover) → full K. + - 3 human seats → K // 2. + - ≤ 2 human seats → no update. + + Only registered (non-guest) seats have their MMR persisted. + Guest deltas are computed and discarded — guests do not accumulate MMR. + """ + if not match.is_ranked: + return + + from app.models.room import SeatControllerType + + # Count seats that were human throughout the match. + # took_over_by_bot == True means the seat was converted mid-match. + human_seats = [ + seat + for seat in match.seats.values() + if seat.controller_type == SeatControllerType.REMOTE and not seat.took_over_by_bot + ] + + n_humans = len(human_seats) + if n_humans <= 2: + return + + k = settings.MMR_K_FACTOR if n_humans == 4 else settings.MMR_K_FACTOR // 2 + human_indices = [s.seat_index for s in human_seats] + deltas = self.compute_deltas(winner_seat, human_indices, k) + + for seat in human_seats: + if seat.account_type != "registered" or seat.user_id is None: + continue # guests: compute but don't store + delta = deltas.get(seat.seat_index, 0) + if delta == 0: + continue + + profile: Optional[Profile] = db.get(Profile, seat.user_id) + if profile is None: + continue + current = profile.hidden_mmr if profile.hidden_mmr is not None else settings.DEFAULT_MMR + profile.hidden_mmr = max(0, current + delta) + + db.commit() + + +# --------------------------------------------------------------------------- +# Module-level singleton +# --------------------------------------------------------------------------- + +mmr_service = MmrService() diff --git a/backend/app/services/nickname_service.py b/backend/app/services/nickname_service.py new file mode 100644 index 0000000..d675fb7 --- /dev/null +++ b/backend/app/services/nickname_service.py @@ -0,0 +1,113 @@ +""" +Nickname validation and filtering (PR-08). + +Rules applied in order: + 1. Strip leading/trailing whitespace; reject if empty after strip. + 2. Length: 2–20 characters (post-strip). + 3. Allowed characters: Korean (가-힣 / ㄱ-ㅎ / ㅏ-ㅣ), ASCII letters, digits, + underscore, and a single space between words. + No other punctuation, emoji, or control characters. + 4. No all-digit nickname (looks like a phone number / ID). + 5. Reserved prefixes: "Guest_" is reserved for auto-generated guest names. + 6. Banned-term list: a small hardcoded set of clearly abusive/spam terms. + The list is intentionally minimal for beta; expand as incidents occur. + +All checks are case-insensitive for the banned-term matching step. + +Usage:: + + from app.services.nickname_service import validate_nickname, NicknameError + + try: + validated = validate_nickname(raw_nickname) + except NicknameError as exc: + raise HTTPException(400, detail=str(exc)) +""" + +from __future__ import annotations + +import re + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- + +_MIN_LEN = 2 +_MAX_LEN = 20 + +# Allowed: Korean syllables / jamo, ASCII letters, digits, underscore, space. +# Spaces are allowed as word separators but not at start/end (stripped earlier). +_ALLOWED_RE = re.compile(r"^[가-힣ㄱ-ㅎㅏ-ㅣa-zA-Z0-9_ ]+$") + +# Must contain at least one non-digit character so purely numeric names are blocked. +_ALL_DIGITS_RE = re.compile(r"^\d+$") + +_RESERVED_PREFIXES = ("guest_",) + +# Minimal banned-term list for beta. Lower-case; matched case-insensitively. +_BANNED_TERMS: frozenset[str] = frozenset( + { + "admin", + "moderator", + "mod", + "system", + "server", + "official", + # Korean slurs / spam placeholders for beta — expand as needed. + "운영자", + "관리자", + } +) + +# --------------------------------------------------------------------------- +# Exception +# --------------------------------------------------------------------------- + + +class NicknameError(ValueError): + """Raised when a nickname fails validation.""" + + +# --------------------------------------------------------------------------- +# Validator +# --------------------------------------------------------------------------- + + +def validate_nickname(raw: str) -> str: + """ + Validate and normalise a nickname. + + Returns the stripped, valid nickname string. + Raises NicknameError with a user-facing message if any rule is violated. + """ + nick = raw.strip() + + if not nick: + raise NicknameError("닉네임을 입력해주세요.") + + if len(nick) < _MIN_LEN: + raise NicknameError(f"닉네임은 최소 {_MIN_LEN}자 이상이어야 합니다.") + + if len(nick) > _MAX_LEN: + raise NicknameError(f"닉네임은 최대 {_MAX_LEN}자까지 허용됩니다.") + + if not _ALLOWED_RE.match(nick): + raise NicknameError("닉네임에는 한글, 영문, 숫자, 밑줄(_), 공백만 사용할 수 있습니다.") + + if " " in nick: + raise NicknameError("닉네임에 연속된 공백은 사용할 수 없습니다.") + + if _ALL_DIGITS_RE.match(nick): + raise NicknameError("닉네임은 숫자만으로 구성될 수 없습니다.") + + lower = nick.lower() + + for prefix in _RESERVED_PREFIXES: + if lower.startswith(prefix): + raise NicknameError(f"'{prefix.rstrip('_')}' 로 시작하는 닉네임은 사용할 수 없습니다.") + + for term in _BANNED_TERMS: + if term in lower: + raise NicknameError("사용할 수 없는 닉네임입니다.") + + return nick diff --git a/backend/app/services/report_service.py b/backend/app/services/report_service.py new file mode 100644 index 0000000..8e57b96 --- /dev/null +++ b/backend/app/services/report_service.py @@ -0,0 +1,137 @@ +""" +Report submission service (PR-08). + +Responsibilities +---------------- +* Validate that the reason code is known. +* Sanitise optional free-text details (strip, cap length, no HTML). +* Prevent trivially duplicate reports: if the same reporter has already filed + an open report with the same reason against the same target in the same + match, return the existing report rather than creating a new one. + This prevents duplicate-spam in the moderation queue. +* Persist via report_repo. +* Emit a structured log entry for every accepted report so that beta + moderators can correlate reports with WS logs without a GUI. + +Game-context fields (room_code, match_id) are stored as supplied by the +client. The server does not validate live membership because the report may +arrive after a match ends. Moderators should treat these fields as +client-provided context, not verified fact. + +No moderation workflow or notification logic lives here — that is deferred +post-beta. +""" + +from __future__ import annotations + +import logging +import re +from typing import Optional + +from sqlalchemy.orm import Session + +from app.models.db import Report, ReportReasonCode +from app.repositories import report_repo + +logger = logging.getLogger("fall_in.report") + +_DETAILS_MAX_LEN = 280 +_HTML_TAG_RE = re.compile(r"<[^>]+>") + + +class ReportError(ValueError): + """Raised when a report submission is rejected by business rules.""" + + +def submit_report( + db: Session, + *, + reporter_user_id: Optional[str], + reported_user_id: Optional[str], + reported_connection_id: Optional[str], + reason_code: str, + details: Optional[str], + room_code: Optional[str], + match_id: Optional[str], +) -> Report: + """ + Validate and persist a player report. + + Returns the created Report on success. + Raises ReportError for validation failures. + + Callers must supply at least one of reported_user_id or + reported_connection_id so there is something to act on. + """ + # Must have something to report against. + if not reported_user_id and not reported_connection_id: + raise ReportError("reported_user_id 또는 reported_connection_id 중 하나는 필수입니다.") + + # Validate reason code. + try: + rc = ReportReasonCode(reason_code) + except ValueError: + valid = ", ".join(r.value for r in ReportReasonCode) + raise ReportError(f"유효하지 않은 신고 사유입니다. 허용 값: {valid}") + + # Self-report guard. + if reporter_user_id and reporter_user_id == reported_user_id: + raise ReportError("자기 자신을 신고할 수 없습니다.") + + # Sanitise optional details: strip HTML tags, then trim. + clean_details: Optional[str] = None + if details: + clean_details = _HTML_TAG_RE.sub("", details).strip()[:_DETAILS_MAX_LEN] + if not clean_details: + clean_details = None + + # Lightweight dedup: if the same open report already exists for this + # (reporter, reported, reason, match) combination, return it rather than + # creating a duplicate entry in the moderation queue. + existing = report_repo.find_duplicate( + db, + reporter_user_id=reporter_user_id, + reported_user_id=reported_user_id, + reported_connection_id=reported_connection_id, + reason_code=rc, + match_id=match_id, + ) + if existing is not None: + logger.info( + "report_duplicate_skipped", + extra={ + "existing_report_id": existing.id, + "reporter": reporter_user_id, + "reported_user": reported_user_id, + "reported_conn": reported_connection_id, + "reason": rc.value, + "match": match_id, + }, + ) + return existing + + report = report_repo.create( + db, + reporter_user_id=reporter_user_id, + reported_user_id=reported_user_id, + reported_connection_id=reported_connection_id, + reason_code=rc, + details=clean_details, + room_code=room_code, + match_id=match_id, + ) + + logger.info( + "report_submitted", + extra={ + "report_id": report.id, + "reporter": reporter_user_id, + "reported_user": reported_user_id, + "reported_conn": reported_connection_id, + "reason": rc.value, + "room": room_code, + "match": match_id, + }, + ) + + return report diff --git a/backend/app/services/room_service.py b/backend/app/services/room_service.py new file mode 100644 index 0000000..b96a7da --- /dev/null +++ b/backend/app/services/room_service.py @@ -0,0 +1,245 @@ +""" +Room business logic. + +All methods are synchronous (no I/O) so they can be called from both sync +and async contexts. The WS handler awaits only the network sends, not these. + +Invariants enforced here: + - Only the host (seat 0 initially, or re-assigned on leave) can start. + - Starting fills every empty seat with a bot before phase transitions. + - Leaving as the last human destroys the room. + - Host role transfers to the lowest remaining seat on host departure. +""" + +from typing import Optional + +from app.models.room import Room, RoomParticipant, RoomPhase, SeatControllerType +from app.repositories.room_repo import InMemoryRoomRepo + + +class RoomError(Exception): + pass + + +class RoomService: + def __init__(self, repo: InMemoryRoomRepo) -> None: + self.repo = repo + + # ------------------------------------------------------------------ + # Create + # ------------------------------------------------------------------ + + def create_room( + self, + display_name: str, + connection_id: str, + user_id: Optional[str] = None, + account_type: str = "guest", + ) -> Room: + room_code = self.repo.generate_room_code() + host = RoomParticipant( + seat_index=0, + display_name=display_name, + controller_type=SeatControllerType.REMOTE, + is_ready=True, + account_type=account_type, + user_id=user_id, + connection_id=connection_id, + ) + room = Room( + room_code=room_code, + host_seat_index=0, + phase=RoomPhase.WAITING, + participants={0: host}, + ) + self.repo.create(room) + return room + + # ------------------------------------------------------------------ + # Join + # ------------------------------------------------------------------ + + def join_room( + self, + room_code: str, + display_name: str, + connection_id: str, + user_id: Optional[str] = None, + account_type: str = "guest", + ) -> Room: + room = self.repo.get(room_code) + if room is None: + raise RoomError("Room not found") + if room.phase != RoomPhase.WAITING: + raise RoomError("Room is not accepting new players") + seat = room.next_available_seat() + if seat is None: + raise RoomError("Room is full") + room.participants[seat] = RoomParticipant( + seat_index=seat, + display_name=display_name, + controller_type=SeatControllerType.REMOTE, + is_ready=False, + account_type=account_type, + user_id=user_id, + connection_id=connection_id, + ) + self.repo.update(room) + return room + + # ------------------------------------------------------------------ + # Leave + # ------------------------------------------------------------------ + + def leave_room(self, room_code: str, seat_index: int) -> Optional[Room]: + """ + Remove the participant at seat_index. + + Returns the updated Room, or None if the room was destroyed. + + Destroy conditions: + - All participants are gone (WAITING phase normal case). + - No human (REMOTE) participants remain — bots-only state is + unrecoverable, so the room is cleaned up immediately. This + prevents orphaned STARTING rooms after the last human leaves + or disconnects. + + Host is re-assigned to the lowest-indexed remaining REMOTE seat. + """ + room = self.repo.get(room_code) + if room is None: + return None + + room.participants.pop(seat_index, None) + + remaining_humans = [ + p for p in room.participants.values() if p.controller_type == SeatControllerType.REMOTE + ] + if not remaining_humans: + self.repo.delete(room_code) + return None + + if room.host_seat_index == seat_index: + room.host_seat_index = min(p.seat_index for p in remaining_humans) + + self.repo.update(room) + return room + + # ------------------------------------------------------------------ + # Ready toggle + # ------------------------------------------------------------------ + + def set_ready(self, room_code: str, seat_index: int, is_ready: bool) -> Room: + room = self.repo.get(room_code) + if room is None: + raise RoomError("Room not found") + participant = room.participants.get(seat_index) + if participant is None: + raise RoomError("Seat not found in room") + participant.is_ready = is_ready + self.repo.update(room) + return room + + # ------------------------------------------------------------------ + # Start + # ------------------------------------------------------------------ + + def start_room(self, room_code: str, requesting_seat: int) -> Room: + """ + Host starts the lobby. Empty seats (1-3) are filled with AI bots. + Phase transitions to STARTING so the match engine (PR-04) can pick up. + """ + room = self.repo.get(room_code) + if room is None: + raise RoomError("Room not found") + if room.host_seat_index != requesting_seat: + raise RoomError("Only the host can start the room") + if room.phase != RoomPhase.WAITING: + raise RoomError("Room is already starting") + + # All human participants must be ready before the host can start. + for participant in room.participants.values(): + if ( + participant.controller_type == SeatControllerType.REMOTE + and not participant.is_ready + ): + raise RoomError("Not all players are ready") + + for seat_idx in range(4): + if seat_idx not in room.participants: + room.participants[seat_idx] = RoomParticipant( + seat_index=seat_idx, + display_name=f"AI Bot {seat_idx + 1}", + controller_type=SeatControllerType.BOT, + is_ready=True, + account_type="bot", + user_id=None, + connection_id=None, + ) + + room.phase = RoomPhase.STARTING + self.repo.update(room) + return room + + # ------------------------------------------------------------------ + # Reconnect (PR-05) + # ------------------------------------------------------------------ + + def reconnect_participant( + self, + room_code: str, + seat_index: int, + new_connection_id: str, + ) -> Optional[Room]: + """ + Update connection_id for a seat that has successfully reconnected. + + Returns the updated Room, or None if the room no longer exists + (e.g. lobby was destroyed while the player was disconnected). + """ + room = self.repo.get(room_code) + if room is None: + return None + participant = room.participants.get(seat_index) + if participant is None: + return None + participant.connection_id = new_connection_id + self.repo.update(room) + return room + + # ------------------------------------------------------------------ + # Bot takeover sync (PR-05) + # ------------------------------------------------------------------ + + def mark_participant_bot_takeover( + self, + room_code: str, + seat_index: int, + ) -> None: + """ + Sync the room participant record after a match-level bot takeover. + + After MatchService.takeover_seat() permanently converts a seat to BOT + control, the corresponding RoomParticipant must also be updated so that + leave_room()'s human-count check stays accurate. Without this, the + stale REMOTE entry would make the room appear to have surviving humans + even after the last player disconnects and is taken over by a bot. + """ + room = self.repo.get(room_code) + if room is None: + return + participant = room.participants.get(seat_index) + if participant is None: + return + participant.controller_type = SeatControllerType.BOT + participant.account_type = "bot" + participant.connection_id = None + self.repo.update(room) + + # ------------------------------------------------------------------ + # Lookup + # ------------------------------------------------------------------ + + def find_participant_room(self, connection_id: str) -> Optional[tuple[str, int]]: + """Return (room_code, seat_index) or None if not in any room.""" + return self.repo.find_by_connection(connection_id) diff --git a/backend/app/ws/__init__.py b/backend/app/ws/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/ws/connection_manager.py b/backend/app/ws/connection_manager.py new file mode 100644 index 0000000..92879ae --- /dev/null +++ b/backend/app/ws/connection_manager.py @@ -0,0 +1,81 @@ +""" +WebSocket connection manager. + +Tracks the mapping from connection_id → WebSocket and room_code → members. +All operations are synchronous except the actual send, which must be awaited. + +This is a single-process in-memory implementation suitable for dev and tests. +A Redis pub/sub backed version replaces this in PR-06 for multi-worker prod. +""" + +from typing import TYPE_CHECKING, Optional + +from fastapi import WebSocket + +if TYPE_CHECKING: + from app.ws.session import WsSession + + +class ConnectionManager: + def __init__(self) -> None: + self._connections: dict[str, WebSocket] = {} + self._room_members: dict[str, set[str]] = {} # room_code → set[conn_id] + self._sessions: dict[str, "WsSession"] = {} # conn_id → WsSession + + async def connect(self, ws: WebSocket, conn_id: str) -> None: + await ws.accept() + self._connections[conn_id] = ws + + def register_session(self, conn_id: str, session: "WsSession") -> None: + """Register a WsSession so _start_quick_match() can update it by conn_id.""" + self._sessions[conn_id] = session + + def get_session(self, conn_id: str) -> Optional["WsSession"]: + """Return the WsSession for a connection, or None if not found.""" + return self._sessions.get(conn_id) + + def disconnect(self, conn_id: str, room_code: Optional[str] = None) -> None: + self._connections.pop(conn_id, None) + self._sessions.pop(conn_id, None) + if room_code: + self._room_members.get(room_code, set()).discard(conn_id) + if room_code in self._room_members and not self._room_members[room_code]: + del self._room_members[room_code] + + def join_room(self, conn_id: str, room_code: str) -> None: + if room_code not in self._room_members: + self._room_members[room_code] = set() + self._room_members[room_code].add(conn_id) + + def get_room_members(self, room_code: str) -> list[str]: + """Return a snapshot of connection IDs currently in room_code.""" + return list(self._room_members.get(room_code, set())) + + def leave_room(self, conn_id: str, room_code: str) -> None: + self._room_members.get(room_code, set()).discard(conn_id) + + async def send_to(self, conn_id: str, message: dict) -> None: + ws = self._connections.get(conn_id) + if ws: + try: + await ws.send_json(message) + except Exception: + pass + + async def broadcast_to_room(self, room_code: str, message: dict) -> None: + for conn_id in list(self._room_members.get(room_code, set())): + ws = self._connections.get(conn_id) + if ws: + try: + await ws.send_json(message) + except Exception: + pass + + def reset(self) -> None: + """Clear all state. Called between tests.""" + self._connections.clear() + self._room_members.clear() + self._sessions.clear() + + +manager = ConnectionManager() diff --git a/backend/app/ws/endpoint.py b/backend/app/ws/endpoint.py new file mode 100644 index 0000000..30c0dde --- /dev/null +++ b/backend/app/ws/endpoint.py @@ -0,0 +1,242 @@ +""" +WebSocket entry point: GET /ws + +Each connection gets a unique connection_id and a WsSession that tracks +identity and room/match membership for the duration of the connection. + +The DB session, room_service, match_service, and presence_manager are +injected via FastAPI's dependency system so tests can override them with +fresh instances per test. + +PR-05 changes +------------- +* A heartbeat asyncio.Task is spawned alongside the receive loop. + It sends a server-initiated PING every HEARTBEAT_INTERVAL_SECONDS and + closes the connection if no PONG arrives before the next PING. +* On WebSocketDisconnect the cleanup logic now distinguishes between + in-lobby and in-match disconnects: + - In-lobby: leave room as before. + - In-match: mark the seat disconnected and start the grace timer; + the room/match state is preserved for a possible reconnect. +""" + +import asyncio +import logging +import uuid + +from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect +from sqlalchemy.orm import Session + +from app.config import settings +from app.database import get_db +from app.models.room import SeatControllerType +from app.repositories.match_repo import InMemoryMatchRepo +from app.repositories.room_repo import InMemoryRoomRepo +from app.services.emote_service import emote_service +from app.services.match_service import MatchService +from app.services.matchmaking_service import MatchmakingService +from app.services.matchmaking_service import matchmaking_service as _default_matchmaking_service +from app.services.room_service import RoomService +from app.ws.connection_manager import manager +from app.ws.handler import _execute_turn, handle_message +from app.ws.presence import PresenceManager +from app.ws.presence import presence_manager as _default_presence_manager +from app.ws.session import WsSession + +logger = logging.getLogger("fall_in.ws") + +router = APIRouter() + +# Module-level singletons. Tests override get_room_service / get_match_service +# / get_presence_manager / get_matchmaking_service via app.dependency_overrides. +_room_repo = InMemoryRoomRepo() +_room_service = RoomService(_room_repo) + +_match_repo = InMemoryMatchRepo() +_match_service = MatchService(_match_repo) + + +def get_room_service() -> RoomService: + """Dependency — returns the shared RoomService (overridable in tests).""" + return _room_service + + +def get_match_service() -> MatchService: + """Dependency — returns the shared MatchService (overridable in tests).""" + return _match_service + + +def get_presence_manager() -> PresenceManager: + """Dependency — returns the shared PresenceManager (overridable in tests).""" + return _default_presence_manager + + +def get_matchmaking_service() -> MatchmakingService: + """Dependency — returns the shared MatchmakingService (overridable in tests).""" + return _default_matchmaking_service + + +# --------------------------------------------------------------------------- +# Heartbeat +# --------------------------------------------------------------------------- + + +async def _heartbeat_loop(ws: WebSocket, session: WsSession) -> None: + """ + Send server-initiated PINGs and close the connection if no PONG is received. + + Interval: HEARTBEAT_INTERVAL_SECONDS (default 15 s). + If session.awaiting_pong is still True when the next PING is due, the + previous PING went unanswered and the connection is considered dead. + """ + try: + while True: + await asyncio.sleep(settings.HEARTBEAT_INTERVAL_SECONDS) + if session.awaiting_pong: + # Previous PONG never arrived — close with "going away" code. + try: + await ws.close(1001) + except Exception: + pass + return + session.awaiting_pong = True + try: + await ws.send_json({"type": "PING", "data": {}}) + except Exception: + return + except asyncio.CancelledError: + pass + + +# --------------------------------------------------------------------------- +# WebSocket endpoint +# --------------------------------------------------------------------------- + + +@router.websocket("/ws") +async def websocket_endpoint( + ws: WebSocket, + db: Session = Depends(get_db), + room_service: RoomService = Depends(get_room_service), + match_service: MatchService = Depends(get_match_service), + presence_manager: PresenceManager = Depends(get_presence_manager), + matchmaking_service: MatchmakingService = Depends(get_matchmaking_service), +) -> None: + conn_id = str(uuid.uuid4()) + await manager.connect(ws, conn_id) + session = WsSession(connection_id=conn_id) + manager.register_session(conn_id, session) + logger.info("ws_connect", extra={"conn_id": conn_id}) + + heartbeat = asyncio.create_task(_heartbeat_loop(ws, session)) + + try: + while True: + try: + raw = await ws.receive_json() + except Exception: + break + await handle_message( + ws, + session, + raw, + manager, + room_service, + match_service, + presence_manager, + matchmaking_service, + db, + ) + except WebSocketDisconnect: + pass + finally: + heartbeat.cancel() + + # Clean up queue membership on disconnect. + if session.in_queue: + entry = matchmaking_service.leave_queue(session.connection_id) + session.in_queue = False + if entry is not None and matchmaking_service.bucket_size(entry.bucket) == 0: + matchmaking_service.cancel_fill_timer(entry.bucket) + + if session.in_room: + active_match = match_service.get_match_by_room(session.room_code) + + if active_match is not None and session.seat_index is not None: + seat = active_match.seats.get(session.seat_index) + if ( + seat is not None + and seat.controller_type == SeatControllerType.REMOTE + and not seat.took_over_by_bot + ): + match_service.mark_seat_disconnected(active_match, session.seat_index) + await manager.broadcast_to_room( + session.room_code, + { + "type": "PLAYER_DISCONNECTED", + "data": {"seat_index": session.seat_index}, + }, + ) + + async def _on_turn_ready(rc: str, m) -> None: + await _execute_turn(rc, m, manager, match_service, presence_manager) + + if seat.account_type != "registered": + # Guests cannot reconnect; convert immediately. + # Mark for elimination at round end. + if not seat.player.is_eliminated: + active_match.voluntarily_left_seats.add(session.seat_index) + presence_manager.revoke_seat_token( + active_match.match_id, + session.seat_index, + ) + match_service.takeover_seat(active_match, session.seat_index) + room_service.mark_participant_bot_takeover( + session.room_code, + session.seat_index, + ) + await manager.broadcast_to_room( + session.room_code, + { + "type": "SEAT_BOT_TAKEOVER", + "data": {"seat_index": session.seat_index}, + }, + ) + presence_manager.cancel_selection_timeout(active_match.match_id) + if match_service.all_selected(active_match): + await _on_turn_ready(session.room_code, active_match) + else: + # Registered users may reconnect during the same round. + presence_manager.start_grace_timer( + match_id=active_match.match_id, + seat_index=session.seat_index, + match=active_match, + match_service=match_service, + room_service=room_service, + manager=manager, + room_code=session.room_code, + on_turn_ready=_on_turn_ready, + ) + else: + # In-lobby disconnect: leave room normally. + updated = room_service.leave_room(session.room_code, session.seat_index) + if updated: + await manager.broadcast_to_room( + session.room_code, + { + "type": "ROOM_STATE", + "data": updated.to_dict(), + }, + ) + + emote_service.cleanup_connection(conn_id) + manager.disconnect(conn_id, session.room_code) + logger.info( + "ws_disconnect", + extra={ + "conn_id": conn_id, + "user_id": session.user_id, + "room_code": session.room_code, + "in_match": session.in_match, + }, + ) diff --git a/backend/app/ws/handler.py b/backend/app/ws/handler.py new file mode 100644 index 0000000..dbfa035 --- /dev/null +++ b/backend/app/ws/handler.py @@ -0,0 +1,1540 @@ +""" +WebSocket message router. + +Each handle_* function processes one client message type. The main +handle_message dispatcher routes by "type" field. + +Auth via WS replicates the HTTP Bearer logic but over the socket channel +so the client can authenticate without an extra REST call in the lobby. + +PR-05 additions +--------------- +* PONG handler: clears session.awaiting_pong (set by the heartbeat loop + in endpoint.py). +* RECONNECT handler: validates a reconnect token and restores a disconnected + seat without re-authenticating from scratch. +* _room_start now issues per-seat RECONNECT_TOKEN messages after MATCH_START. +* _room_start and _execute_turn start the card-selection timeout after each + new SELECTING phase via _start_selection_timeout(). +* _execute_turn receives a PresenceManager argument so it can cancel/restart + timeouts and revoke tokens when a match ends. + +PR-06 additions +--------------- +* QUICK_MATCH_JOIN / QUICK_MATCH_LEAVE handlers. +* _start_quick_match() shared entry point used by both the immediate 4-player + case and the fill-timer callback. +* _execute_turn triggers MMR persistence for eligible ranked matches. +""" + +import logging + +from fall_in.net.serializers import private_state_to_dict, public_state_to_dict +from fastapi import WebSocket +from jose import JWTError + +from app.auth.jwt import decode_token +from app.config import settings +from app.models.db import UserStatus +from app.models.room import SeatControllerType +from app.repositories import user_repo +from app.services.match_service import MatchError, MatchService +from app.services.matchmaking_service import MatchmakingService +from app.services.room_service import RoomError, RoomService +from app.ws.connection_manager import ConnectionManager +from app.ws.presence import PresenceManager +from app.ws.session import WsSession + +logger = logging.getLogger("fall_in.ws.handler") + + +async def handle_message( + ws: WebSocket, + session: WsSession, + raw: dict, + manager: ConnectionManager, + room_service: RoomService, + match_service: MatchService, + presence_manager: PresenceManager, + matchmaking_service: MatchmakingService, + db, +) -> None: + msg_type = raw.get("type") + data = raw.get("data", {}) + + if msg_type == "WS_HELLO": + await _hello(ws, session) + elif msg_type in ("AUTH_LOGIN", "AUTH_GUEST"): + await _auth(ws, session, data, db) + elif msg_type == "ROOM_CREATE": + await _room_create(ws, session, manager, room_service) + elif msg_type == "ROOM_JOIN": + await _room_join(ws, session, data, manager, room_service) + elif msg_type == "ROOM_LEAVE": + await _room_leave(ws, session, manager, room_service) + elif msg_type == "READY_SET": + await _ready_set(ws, session, data, manager, room_service) + elif msg_type == "ROOM_START": + await _room_start(ws, session, manager, room_service, match_service, presence_manager) + elif msg_type == "CARD_SELECT": + await _card_select(ws, session, data, manager, match_service, presence_manager) + elif msg_type == "ROUND_READY": + await _round_ready(ws, session, manager, match_service, room_service, presence_manager) + elif msg_type == "RECONNECT": + await _reconnect( + ws, session, data, manager, room_service, match_service, presence_manager, db + ) + elif msg_type == "QUICK_MATCH_JOIN": + await _quick_match_join( + ws, + session, + manager, + room_service, + match_service, + presence_manager, + matchmaking_service, + db, + ) + elif msg_type == "QUICK_MATCH_LEAVE": + await _quick_match_leave(ws, session, matchmaking_service) + elif msg_type == "MATCH_LEAVE": + await _match_leave(ws, session, manager, room_service, match_service, presence_manager) + elif msg_type == "EMOTE_SEND": + await _emote_send(ws, session, data, manager) + elif msg_type == "EMOTE_MUTE": + await _emote_mute(ws, session, data) + elif msg_type == "PING": + await ws.send_json({"type": "PONG", "data": {}}) + elif msg_type == "PONG": + # Client responding to a server-initiated PING; clear the awaiting flag. + session.awaiting_pong = False + else: + logger.warning( + "ws_unknown_message", + extra={"conn_id": session.connection_id, "msg_type": msg_type}, + ) + await _error(ws, "UNKNOWN_MESSAGE", f"Unknown message type: {msg_type!r}", session=session) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +async def _error( + ws: WebSocket, + code: str, + message: str, + *, + session: WsSession | None = None, + level: str = "warning", +) -> None: + """ + Send an ERROR frame to the client and emit a structured log entry. + + ``level`` controls the log severity: "warning" for expected client errors + (bad input, wrong state), "error" for unexpected server-side failures. + """ + log_fn = logger.error if level == "error" else logger.warning + log_fn( + "ws_error", + extra={ + "code": code, + "detail": message, + "conn_id": session.connection_id if session else None, + "user_id": session.user_id if session else None, + }, + ) + await ws.send_json({"type": "ERROR", "data": {"code": code, "message": message}}) + + +async def _broadcast_private_hands( + match, + manager: ConnectionManager, + match_service: MatchService, +) -> None: + """Unicast PRIVATE_HAND_STATE to every connected human seat.""" + for seat in match.seats.values(): + if seat.controller_type == SeatControllerType.REMOTE and seat.connection_id: + private = match_service.build_private_state(match, seat.seat_index) + await manager.send_to( + seat.connection_id, + { + "type": "PRIVATE_HAND_STATE", + "data": private_state_to_dict(private), + }, + ) + + +async def _broadcast_selecting( + room_code: str, + match, + manager: ConnectionManager, + match_service: MatchService, +) -> None: + """Broadcast PHASE_SELECTING + unicast PRIVATE_HAND_STATE to each human.""" + public = match_service.build_public_state(match) + data = public_state_to_dict(public) + data["remaining_time"] = settings.CARD_SELECTION_TIMEOUT_SECONDS + await manager.broadcast_to_room( + room_code, + { + "type": "PHASE_SELECTING", + "data": data, + }, + ) + await _broadcast_private_hands(match, manager, match_service) + + +async def _issue_reconnect_tokens( + room_code: str, + match, + manager: ConnectionManager, + presence_manager: PresenceManager, +) -> None: + """ + Issue reconnect tokens for the current round to connected registered seats. + + Guests are intentionally excluded from reconnect support. + """ + for seat in match.seats.values(): + if seat.controller_type != SeatControllerType.REMOTE or not seat.connection_id: + continue + if seat.account_type != "registered" or not seat.user_id: + continue + + token = presence_manager.issue_token( + match_id=match.match_id, + room_code=room_code, + seat_index=seat.seat_index, + user_id=seat.user_id, + display_name=seat.display_name, + account_type=seat.account_type, + ) + ws_session = manager.get_session(seat.connection_id) + if ws_session is not None: + ws_session.reconnect_token = token + await manager.send_to( + seat.connection_id, + { + "type": "RECONNECT_TOKEN", + "data": { + "token": token, + "seat_index": seat.seat_index, + "room_code": room_code, + "match_id": match.match_id, + }, + }, + ) + + +async def _take_over_disconnected_seats_for_new_round( + room_code: str, + match, + manager: ConnectionManager, + match_service: MatchService, + room_service: RoomService, + presence_manager: PresenceManager, +) -> None: + """ + Finalise any still-disconnected human seats before the next round starts. + + This enforces the "same round only" reconnect rule: once a fresh round + begins, a player who did not return in time permanently loses the seat. + """ + for seat in list(match.seats.values()): + if ( + seat.controller_type != SeatControllerType.REMOTE + or seat.took_over_by_bot + or not seat.is_disconnected + ): + continue + + presence_manager.cancel_grace_timer(match.match_id, seat.seat_index) + presence_manager.revoke_seat_token(match.match_id, seat.seat_index) + # Mark for elimination at round end (same as grace-timer expiry). + if not seat.player.is_eliminated: + match.voluntarily_left_seats.add(seat.seat_index) + match_service.takeover_seat(match, seat.seat_index) + room_service.mark_participant_bot_takeover(room_code, seat.seat_index) + await manager.broadcast_to_room( + room_code, + { + "type": "SEAT_BOT_TAKEOVER", + "data": {"seat_index": seat.seat_index}, + }, + ) + + +def _start_selection_timeout( + room_code: str, + match, + manager: ConnectionManager, + match_service: MatchService, + presence_manager: PresenceManager, + room_service: RoomService | None = None, +) -> None: + """ + Schedule a card-selection timeout for the current SELECTING phase. + + The callback passed to PresenceManager will resolve the turn if all + seats have selected after the timeout fires. + """ + import time + + match.selection_started_at = time.time() + + async def on_ready(rc: str, m) -> None: + await _execute_turn(rc, m, manager, match_service, presence_manager, room_service) + + presence_manager.start_selection_timeout( + match_id=match.match_id, + match=match, + match_service=match_service, + manager=manager, + room_code=room_code, + on_turn_ready=on_ready, + ) + + +# --------------------------------------------------------------------------- +# Handlers +# --------------------------------------------------------------------------- + + +async def _hello(ws: WebSocket, session: WsSession) -> None: + await ws.send_json( + { + "type": "WS_WELCOME", + "data": {"connection_id": session.connection_id}, + } + ) + + +async def _auth(ws: WebSocket, session: WsSession, data: dict, db) -> None: + token = data.get("token") + if not token: + await _error(ws, "MISSING_TOKEN", "token is required", session=session) + return + + try: + payload = decode_token(token) + except JWTError: + await _error(ws, "INVALID_TOKEN", "Invalid or expired token", session=session) + return + + if payload.get("type") != "access": + await _error(ws, "INVALID_TOKEN", "Must use an access token", session=session) + return + + user_id = payload.get("sub") + if not user_id: + await _error(ws, "INVALID_TOKEN", "Token has no subject", session=session) + return + + user = user_repo.get_by_id(db, user_id) + if user is None: + await _error(ws, "USER_NOT_FOUND", "User not found", session=session) + return + + if user.status != UserStatus.ACTIVE: + await _error(ws, "ACCOUNT_NOT_ACTIVE", "Account is not active", session=session) + return + + session.user_id = user_id + session.account_type = user.account_type.value + session.display_name = user.profile.nickname + + logger.info( + "ws_auth_ok", + extra={ + "conn_id": session.connection_id, + "user_id": user_id, + "account_type": session.account_type, + }, + ) + + await ws.send_json( + { + "type": "AUTH_OK", + "data": { + "user_id": user_id, + "display_name": session.display_name, + "account_type": session.account_type, + }, + } + ) + + +async def _room_create( + ws: WebSocket, + session: WsSession, + manager: ConnectionManager, + room_service: RoomService, +) -> None: + if not session.is_authenticated: + await _error( + ws, + "NOT_AUTHENTICATED", + "Authenticate before creating a room", + session=session, + ) + return + if session.in_room: + await _error(ws, "ALREADY_IN_ROOM", "Leave your current room first", session=session) + return + + room = room_service.create_room( + display_name=session.display_name, + connection_id=session.connection_id, + user_id=session.user_id, + account_type=session.account_type or "guest", + ) + session.room_code = room.room_code + session.seat_index = 0 + manager.join_room(session.connection_id, room.room_code) + await manager.broadcast_to_room( + room.room_code, + { + "type": "ROOM_STATE", + "data": room.to_dict(), + }, + ) + + +async def _room_join( + ws: WebSocket, + session: WsSession, + data: dict, + manager: ConnectionManager, + room_service: RoomService, +) -> None: + if not session.is_authenticated: + await _error(ws, "NOT_AUTHENTICATED", "Authenticate before joining a room", session=session) + return + if session.in_room: + await _error(ws, "ALREADY_IN_ROOM", "Leave your current room first", session=session) + return + + room_code = (data.get("room_code") or "").strip().upper() + if not room_code: + await _error(ws, "MISSING_ROOM_CODE", "room_code is required", session=session) + return + + try: + room = room_service.join_room( + room_code=room_code, + display_name=session.display_name, + connection_id=session.connection_id, + user_id=session.user_id, + account_type=session.account_type or "guest", + ) + except RoomError as exc: + await _error(ws, "ROOM_ERROR", str(exc), session=session) + return + + session.room_code = room.room_code + for seat_idx, p in room.participants.items(): + if p.connection_id == session.connection_id: + session.seat_index = seat_idx + break + + manager.join_room(session.connection_id, room.room_code) + await manager.broadcast_to_room( + room.room_code, + { + "type": "ROOM_STATE", + "data": room.to_dict(), + }, + ) + + +async def _room_leave( + ws: WebSocket, + session: WsSession, + manager: ConnectionManager, + room_service: RoomService, +) -> None: + if not session.in_room: + await _error(ws, "NOT_IN_ROOM", "You are not in a room", session=session) + return + + room_code = session.room_code + seat_index = session.seat_index + session.room_code = None + session.seat_index = None + + manager.leave_room(session.connection_id, room_code) + updated = room_service.leave_room(room_code, seat_index) + if updated: + await manager.broadcast_to_room( + room_code, + { + "type": "ROOM_STATE", + "data": updated.to_dict(), + }, + ) + + +async def _ready_set( + ws: WebSocket, + session: WsSession, + data: dict, + manager: ConnectionManager, + room_service: RoomService, +) -> None: + if not session.in_room: + await _error(ws, "NOT_IN_ROOM", "You are not in a room", session=session) + return + + is_ready = bool(data.get("is_ready", False)) + try: + room = room_service.set_ready(session.room_code, session.seat_index, is_ready) + except RoomError as exc: + await _error(ws, "ROOM_ERROR", str(exc), session=session) + return + + await manager.broadcast_to_room( + session.room_code, + { + "type": "ROOM_STATE", + "data": room.to_dict(), + }, + ) + + +async def _room_start( + ws: WebSocket, + session: WsSession, + manager: ConnectionManager, + room_service: RoomService, + match_service: MatchService, + presence_manager: PresenceManager, +) -> None: + if not session.in_room: + await _error(ws, "NOT_IN_ROOM", "You are not in a room", session=session) + return + + try: + room = room_service.start_room(session.room_code, session.seat_index) + except RoomError as exc: + await _error(ws, "ROOM_ERROR", str(exc), session=session) + return + + # Broadcast final lobby state (phase=STARTING, all bots visible). + await manager.broadcast_to_room( + session.room_code, + { + "type": "ROOM_STATE", + "data": room.to_dict(), + }, + ) + + # Create the server-side match (deals cards, auto-selects bots). + try: + match = match_service.create_match(room) + except MatchError as exc: + await _error(ws, "MATCH_ERROR", str(exc), session=session) + return + + session.match_id = match.match_id + + # Announce match to all room members. + await manager.broadcast_to_room( + session.room_code, + { + "type": "MATCH_START", + "data": {"match_id": match.match_id}, + }, + ) + + await _issue_reconnect_tokens(session.room_code, match, manager, presence_manager) + + # Send initial game state so clients can start rendering. + await _broadcast_selecting(session.room_code, match, manager, match_service) + # All-bot edge case: if every seat is already selected (all bots), resolve + # immediately rather than waiting for the timeout. + if match_service.all_selected(match): + await _execute_turn(session.room_code, match, manager, match_service, presence_manager) + else: + _start_selection_timeout(session.room_code, match, manager, match_service, presence_manager) + + +async def _card_select( + ws: WebSocket, + session: WsSession, + data: dict, + manager: ConnectionManager, + match_service: MatchService, + presence_manager: PresenceManager, +) -> None: + if not session.in_room: + await _error(ws, "NOT_IN_MATCH", "You are not in an active match", session=session) + return + + match = match_service.get_match_by_room(session.room_code) + if match is None: + await _error(ws, "MATCH_NOT_FOUND", "Match not found", session=session) + return + + card_number = data.get("card_number") + if card_number is None: + await _error(ws, "MISSING_CARD", "card_number is required", session=session) + return + + try: + match_service.submit_selection(match, session.seat_index, int(card_number)) + except MatchError as exc: + await _error(ws, "MATCH_ERROR", str(exc), session=session) + return + + # Acknowledge selection to the submitting seat. + private = match_service.build_private_state(match, session.seat_index) + await manager.send_to( + session.connection_id, + { + "type": "PRIVATE_HAND_STATE", + "data": private_state_to_dict(private), + }, + ) + + # If all seats have now selected, cancel timeout and resolve the turn. + if match_service.all_selected(match): + presence_manager.cancel_selection_timeout(match.match_id) + await _execute_turn(session.room_code, match, manager, match_service, presence_manager) + + +async def _round_ready( + ws: WebSocket, + session: WsSession, + manager: ConnectionManager, + match_service: MatchService, + room_service: RoomService, + presence_manager: PresenceManager, +) -> None: + """Acknowledge the round-settlement screen and continue when all are ready.""" + if not session.in_room: + await _error(ws, "NOT_IN_MATCH", "You are not in an active match", session=session) + return + + match = match_service.get_match_by_room(session.room_code) + if match is None: + await _error(ws, "MATCH_NOT_FOUND", "Match not found", session=session) + return + + try: + everyone_ready = match_service.mark_round_ready(match, session.seat_index) + except MatchError as exc: + await _error(ws, "MATCH_ERROR", str(exc), session=session) + return + + if everyone_ready: + presence_manager.cancel_round_settlement_timeout(match.match_id) + await _continue_after_round_settlement( + session.room_code, + match, + manager, + match_service, + room_service, + presence_manager, + ) + + +async def _match_leave( + ws: WebSocket, + session: WsSession, + manager: ConnectionManager, + room_service: RoomService, + match_service: MatchService, + presence_manager: PresenceManager, +) -> None: + """ + Handle a player voluntarily leaving mid-match. + + The seat is immediately converted to bot control. If the player was + not already eliminated, they are added to voluntarily_left_seats so + that finalize_round() will mark them as eliminated at round end. + """ + if not session.in_room: + await _error(ws, "NOT_IN_MATCH", "You are not in an active match", session=session) + return + + match = match_service.get_match_by_room(session.room_code) + if match is None: + await _error(ws, "MATCH_NOT_FOUND", "Match not found", session=session) + return + + seat = match.seats.get(session.seat_index) + if seat is None: + await _error(ws, "SEAT_NOT_FOUND", "Seat not found", session=session) + return + + room_code = session.room_code + seat_index = session.seat_index + + # Mark as voluntarily left (unless already eliminated). + if not seat.player.is_eliminated: # type: ignore[union-attr] + match.voluntarily_left_seats.add(seat_index) + + # Convert to bot immediately. + if seat.controller_type == SeatControllerType.REMOTE: + presence_manager.cancel_grace_timer(match.match_id, seat_index) + presence_manager.revoke_seat_token(match.match_id, seat_index) + match_service.takeover_seat(match, seat_index) + room_service.mark_participant_bot_takeover(room_code, seat_index) + await manager.broadcast_to_room( + room_code, + { + "type": "SEAT_BOT_TAKEOVER", + "data": {"seat_index": seat_index}, + }, + ) + + # Acknowledge the leave to the departing client. + await ws.send_json({"type": "MATCH_LEAVE_OK", "data": {}}) + + # Clear session match state so the client can return to lobby. + session.room_code = None + session.seat_index = None + session.match_id = None + session.reconnect_token = None + manager.leave_room(session.connection_id, room_code) + + # If this was the last human seat pending selection, check all_selected. + if match_service.all_selected(match): + presence_manager.cancel_selection_timeout(match.match_id) + await _execute_turn(room_code, match, manager, match_service, presence_manager) + + # If round settlement was waiting on this seat, check if everyone ready now. + if match_service.has_round_settlement_pending(match): + required = { + s.seat_index + for s in match.seats.values() + if s.controller_type == SeatControllerType.REMOTE and not s.player.is_eliminated # type: ignore[union-attr] + } + if required.issubset(match.round_settlement_ready_seats): + presence_manager.cancel_round_settlement_timeout(match.match_id) + await _continue_after_round_settlement( + room_code, + match, + manager, + match_service, + room_service, + presence_manager, + ) + + +async def _reconnect( + ws: WebSocket, + session: WsSession, + data: dict, + manager: ConnectionManager, + room_service: RoomService, + match_service: MatchService, + presence_manager: PresenceManager, + db, +) -> None: + """ + Restore a disconnected seat to a live WebSocket connection. + + The client sends RECONNECT with the token that was issued at match start. + On success the session is fully restored and a snapshot is sent so the + client can re-render the current game state. + """ + token = data.get("token") + if not token: + await _error(ws, "MISSING_TOKEN", "token is required", session=session) + return + + entry = presence_manager.lookup_token(token) + if entry is None: + logger.warning("reconnect_invalid_token", extra={"conn_id": session.connection_id}) + await _error( + ws, + "INVALID_RECONNECT_TOKEN", + "Reconnect token is invalid or expired", + session=session, + ) + return + + if entry.account_type != "registered": + logger.warning( + "reconnect_guest_denied", + extra={"conn_id": session.connection_id, "match_id": entry.match_id}, + ) + await _error( + ws, + "GUEST_RECONNECT_NOT_ALLOWED", + "Guest seats cannot reconnect", + session=session, + ) + return + + # Re-check account status for registered users so that suspended/deleted + # accounts cannot slip back in via a still-valid reconnect token. + if entry.account_type == "registered" and entry.user_id: + from app.models.db import UserStatus + + user = user_repo.get_by_id(db, entry.user_id) + if user is None or user.status != UserStatus.ACTIVE: + logger.warning( + "reconnect_account_inactive", + extra={"user_id": entry.user_id, "match_id": entry.match_id}, + ) + await _error( + ws, + "ACCOUNT_NOT_ACTIVE", + "Account is not active", + session=session, + ) + return + + match = match_service.get_match(entry.match_id) + if match is None: + logger.info( + "reconnect_match_ended", + extra={"match_id": entry.match_id, "conn_id": session.connection_id}, + ) + await _error(ws, "MATCH_NOT_FOUND", "Match has already ended", session=session) + return + + seat = match.seats.get(entry.seat_index) + if seat is None: + logger.warning( + "reconnect_seat_missing", + extra={"match_id": entry.match_id, "seat": entry.seat_index}, + ) + await _error(ws, "SEAT_NOT_FOUND", "Seat no longer exists in match", session=session) + return + + if seat.took_over_by_bot: + logger.info( + "reconnect_seat_taken_over", + extra={"match_id": entry.match_id, "seat": entry.seat_index}, + ) + await _error( + ws, + "SEAT_TAKEN_OVER", + "This seat has been permanently taken over by a bot", + session=session, + ) + return + + old_room_code = session.room_code + + # Restore full session state. + session.user_id = entry.user_id + session.display_name = entry.display_name + session.account_type = entry.account_type + session.room_code = entry.room_code + session.seat_index = entry.seat_index + session.match_id = entry.match_id + session.reconnect_token = token + session.awaiting_pong = False + + # If this socket is already in a room (possibly different), leave it first + # to prevent cross-room broadcast leaks. + if old_room_code: + manager.leave_room(session.connection_id, old_room_code) + + # Update match seat and room participant to the new connection. + match_service.reconnect_seat(match, entry.seat_index, session.connection_id) + room_service.reconnect_participant(entry.room_code, entry.seat_index, session.connection_id) + + # Re-join the ConnectionManager room so broadcasts reach this connection. + manager.join_room(session.connection_id, entry.room_code) + + # Cancel the grace timer — the player is back. + presence_manager.cancel_grace_timer(entry.match_id, entry.seat_index) + + # Acknowledge reconnect and send a full state snapshot. + await ws.send_json( + { + "type": "RECONNECT_OK", + "data": { + "seat_index": entry.seat_index, + "match_id": entry.match_id, + "room_code": entry.room_code, + }, + } + ) + public = match_service.build_public_state(match) + await ws.send_json( + { + "type": "PUBLIC_BOARD_STATE", + "data": public_state_to_dict(public), + } + ) + private = match_service.build_private_state(match, entry.seat_index) + await ws.send_json( + { + "type": "PRIVATE_HAND_STATE", + "data": private_state_to_dict(private), + } + ) + if match_service.has_round_settlement_pending(match): + summary = match.round_summary_pending + if summary is not None: + await ws.send_json( + { + "type": "ROUND_RESULT", + "data": { + "round_number": summary.round_number, + "round_danger": summary.round_danger, + "total_scores": summary.total_scores, + "eliminated_seats": summary.eliminated_seats, + "game_over": summary.game_over, + "winner_seat": summary.winner_seat, + "timeout_seconds": settings.ROUND_SETTLEMENT_TIMEOUT_SECONDS, + }, + } + ) + + # Notify remaining room members. + await manager.broadcast_to_room( + entry.room_code, + { + "type": "PLAYER_RECONNECTED", + "data": {"seat_index": entry.seat_index}, + }, + ) + + +async def _execute_turn( + room_code: str, + match, + manager: ConnectionManager, + match_service: MatchService, + presence_manager: PresenceManager, + room_service: RoomService | None = None, +) -> None: + """ + Resolve a full turn: broadcast each placement step, then either start + the next selection phase or broadcast round/match end results. + + NOTE: callers are responsible for cancelling the selection timeout + before invoking this function. Cancelling inside _execute_turn would + self-cancel the task when the call originates from _selection_timer's + on_turn_ready callback, interrupting the turn resolution. + """ + if room_service is None: + from app.ws.endpoint import get_room_service + + room_service = get_room_service() + + await manager.broadcast_to_room( + room_code, + { + "type": "TURN_REVEAL_START", + "data": {"match_id": match.match_id}, + }, + ) + + # Resolve placements one at a time; each snapshot reflects the board + # state *after* that specific card was placed, not the final board. + step_snapshots = match_service.resolve_turn_stepwise(match) + + for step, snapshot in step_snapshots: + await manager.broadcast_to_room( + room_code, + { + "type": "TURN_REVEAL_STEP", + "data": { + "seat_index": step.seat_index, + "card_number": step.card_number, + "card_danger": step.card_danger, + "row_index": step.row_index, + "penalty_score": step.penalty_score, + "had_to_take_row": step.had_to_take_row, + "placement_order": step.order, + "penalty_card_count": step.penalty_card_count, + }, + }, + ) + # Board state after this individual placement (incremental snapshot). + await manager.broadcast_to_room( + room_code, + { + "type": "PUBLIC_BOARD_STATE", + "data": public_state_to_dict(snapshot), + }, + ) + + await manager.broadcast_to_room( + room_code, + { + "type": "TURN_RESOLVED", + "data": {"match_id": match.match_id}, + }, + ) + + rules = match.rules # fall_in.core.rules.GameRules + if rules.is_round_over(): + summary = match_service.finalize_round(match) + match_service.begin_round_settlement(match, summary) + + await manager.broadcast_to_room( + room_code, + { + "type": "ROUND_RESULT", + "data": { + "round_number": summary.round_number, + "round_danger": summary.round_danger, + "total_scores": summary.total_scores, + "eliminated_seats": summary.eliminated_seats, + "game_over": summary.game_over, + "winner_seat": summary.winner_seat, + "timeout_seconds": settings.ROUND_SETTLEMENT_TIMEOUT_SECONDS, + }, + }, + ) + presence_manager.start_round_settlement_timeout( + match.match_id, + lambda: _continue_after_round_settlement( + room_code, + match, + manager, + match_service, + room_service, + presence_manager, + ), + ) + else: + # More turns to go in this round — re-select bots, then re-enter SELECTING. + match_service.reselect_bots(match) + await _broadcast_selecting(room_code, match, manager, match_service) + # Short-circuit: if bots hold all seats, no humans need to select. + if match_service.all_selected(match): + await _execute_turn( + room_code, + match, + manager, + match_service, + presence_manager, + room_service, + ) + else: + _start_selection_timeout( + room_code, + match, + manager, + match_service, + presence_manager, + room_service, + ) + + +async def _continue_after_round_settlement( + room_code: str, + match, + manager: ConnectionManager, + match_service: MatchService, + room_service: RoomService, + presence_manager: PresenceManager, +) -> None: + """ + Resume match flow after the round-settlement screen. + + Non-gameover matches start the next round; gameover matches emit + MATCH_RESULT and then clean up the match. + """ + if not match_service.has_round_settlement_pending(match): + return + + summary = match_service.clear_round_settlement(match) + if summary is None: + return + + presence_manager.cancel_round_settlement_timeout(match.match_id) + + if summary.game_over: + rewards = _calculate_match_rewards(match, summary) + _grant_match_rewards(match, rewards) + await manager.broadcast_to_room( + room_code, + { + "type": "MATCH_RESULT", + "data": { + "match_id": match.match_id, + "winner_seat": summary.winner_seat, + "final_scores": summary.total_scores, + "rewards": rewards, + }, + }, + ) + if match.is_ranked and summary.winner_seat is not None: + _apply_mmr_update(match, summary.winner_seat) + presence_manager.revoke_match_tokens(match.match_id) + match_service.delete_match(match.match_id) + return + + await _take_over_disconnected_seats_for_new_round( + room_code, + match, + manager, + match_service, + room_service, + presence_manager, + ) + presence_manager.revoke_match_tokens(match.match_id) + match_service.start_next_round(match) + await _issue_reconnect_tokens(room_code, match, manager, presence_manager) + await _broadcast_selecting(room_code, match, manager, match_service) + if match_service.all_selected(match): + await _execute_turn(room_code, match, manager, match_service, presence_manager) + else: + _start_selection_timeout(room_code, match, manager, match_service, presence_manager) + + +# --------------------------------------------------------------------------- +# MMR helper (PR-06) +# --------------------------------------------------------------------------- + + +def _apply_mmr_update(match, winner_seat: int) -> None: + """ + Persist hidden MMR deltas for a ranked quick-match in a fresh DB session. + + Called synchronously from _execute_turn (which is async) so we create our + own short-lived session rather than threading `db` through all callers. + """ + from app.database import SessionLocal + from app.services.mmr_service import mmr_service + + db = SessionLocal() + try: + mmr_service.persist_updates(db, match, winner_seat) + except Exception: + logger.exception( + "mmr_update_failed", + extra={"match_id": match.match_id, "winner_seat": winner_seat}, + ) + finally: + db.close() + + +# --------------------------------------------------------------------------- +# Reward helper (PR-09) +# --------------------------------------------------------------------------- + + +def _calculate_match_rewards(match, summary) -> dict[int, int]: + """Return {seat_index: reward_amount} for every seat in the match. + + Winners receive a victory bonus based on the final round number. + Losers receive a defeat bonus based on the round they were eliminated + in — a player who survived longer gets a larger reward. + """ + rewards: dict[int, int] = {} + for seat_index in match.seats: + is_winner = seat_index == summary.winner_seat + if is_winner: + amount = ( + settings.REWARD_VICTORY_BASE + + summary.round_number * settings.REWARD_VICTORY_PER_ROUND + ) + else: + # Use the round the player was actually eliminated in, not the + # final round. Falls back to summary.round_number for seats + # that were never formally eliminated (shouldn't happen). + elim_round = match.seat_eliminated_round.get(seat_index, summary.round_number) + amount = settings.REWARD_DEFEAT_BASE + elim_round * settings.REWARD_DEFEAT_PER_ROUND + rewards[seat_index] = amount + return rewards + + +def _grant_match_rewards(match, rewards: dict[int, int]) -> None: + """Persist currency rewards for registered human seats.""" + from app.database import SessionLocal + from app.repositories import profile_repo + from app.repositories import user_repo as _user_repo + + db = SessionLocal() + try: + for seat_index, amount in rewards.items(): + seat = match.seats.get(seat_index) + if seat is None or seat.account_type != "registered" or seat.user_id is None: + continue + user = _user_repo.get_by_id(db, seat.user_id) + if user is None or user.profile is None: + continue + profile_repo.add_currency(db, user.profile, amount) + except Exception: + logger.exception( + "reward_grant_failed", + extra={"match_id": match.match_id}, + ) + finally: + db.close() + + +# --------------------------------------------------------------------------- +# Quick-match handlers (PR-06) +# --------------------------------------------------------------------------- + + +async def _quick_match_join( + ws: WebSocket, + session: WsSession, + manager: ConnectionManager, + room_service: RoomService, + match_service: MatchService, + presence_manager: PresenceManager, + matchmaking_service: MatchmakingService, + db, +) -> None: + """ + Enter the quick-match queue. + + Flow: + 1. Validate session state. + 2. Look up (or default) the player's hidden MMR. + 3. Add to the appropriate bucket queue. + 4. If bucket now has 4 players → form match immediately. + 5. Else start (or reuse) the bucket's fill timer. + 6. Send QUEUE_JOINED acknowledgement. + """ + if not session.is_authenticated: + await _error( + ws, + "NOT_AUTHENTICATED", + "Authenticate before joining quick match", + session=session, + ) + return + if session.in_room: + await _error( + ws, + "ALREADY_IN_ROOM", + "Leave your current room before joining quick match", + session=session, + ) + return + if session.in_queue: + await _error( + ws, + "ALREADY_IN_QUEUE", + "You are already in the matchmaking queue", + session=session, + ) + return + + # Resolve MMR (guests always get DEFAULT_MMR; not stored). + from app.services.mmr_service import mmr_service + + mmr = mmr_service.get_mmr_for_session(db, session.user_id, session.account_type or "guest") + + entry = matchmaking_service.join_queue( + connection_id=session.connection_id, + user_id=session.user_id, + display_name=session.display_name, + account_type=session.account_type or "guest", + mmr=mmr, + ) + session.in_queue = True + + bucket = entry.bucket + queue_size = matchmaking_service.bucket_size(bucket) + + # Check for immediate 4-player match. + four = matchmaking_service.try_form_match(bucket) + if four is not None: + # Cancel the fill timer if one was running for this bucket. + matchmaking_service.cancel_fill_timer(bucket) + # All 4 are human — ranked match. + is_ranked = True + await _start_quick_match( + four, + 0, + is_ranked, + manager, + room_service, + match_service, + presence_manager, + ) + # WsSession fields (room_code, seat_index, match_id, in_queue) for all + # four players are updated inside _start_quick_match() via the session + # registry. No extra work needed here. + return + + # Start fill timer if this is the first player in the bucket. + async def _on_fill(human_entries: list, ai_count: int, ranked: bool) -> None: + await _start_quick_match( + human_entries, + ai_count, + ranked, + manager, + room_service, + match_service, + presence_manager, + ) + # Clear in_queue for each matched human connection (best-effort). + for _e in human_entries: + # The WsSession is not directly accessible here; endpoint.py + # manages in_queue via the disconnect handler. The session flag + # is cleared when the connection receives MATCH_FOUND. + pass + + matchmaking_service.start_fill_timer(bucket, _on_fill) + + await ws.send_json( + { + "type": "QUEUE_JOINED", + "data": { + # bucket is intentionally omitted — it would expose approximate + # hidden MMR tier to the client, violating the hidden-MMR policy. + "queue_size": queue_size, + "fill_seconds": settings.QUICK_MATCH_FILL_SECONDS, + }, + } + ) + + +async def _quick_match_leave( + ws: WebSocket, + session: WsSession, + matchmaking_service: MatchmakingService, +) -> None: + """Leave the quick-match queue before a match is formed.""" + if not session.in_queue: + await _error(ws, "NOT_IN_QUEUE", "You are not in the matchmaking queue", session=session) + return + + entry = matchmaking_service.leave_queue(session.connection_id) + session.in_queue = False + + if entry is not None: + # Cancel the fill timer if the bucket is now empty. + if matchmaking_service.bucket_size(entry.bucket) == 0: + matchmaking_service.cancel_fill_timer(entry.bucket) + + await ws.send_json({"type": "QUEUE_LEFT", "data": {}}) + + +async def _start_quick_match( + human_entries: list, + ai_count: int, + is_ranked: bool, + manager: ConnectionManager, + room_service: RoomService, + match_service: MatchService, + presence_manager: PresenceManager, +) -> None: + """ + Create a match from queued players and broadcast the start to all humans. + + This is the shared entry point used by both the immediate-match path + (4 humans found) and the fill-timer path (1-3 humans + AI bots). + """ + from app.services.matchmaking_service import MatchmakingService as _MS + + room = _MS.build_quick_match_room(human_entries, ai_count) + + # Register the room so match_service can look it up by room_code. + room_service.repo.create(room) + + try: + match = match_service.create_match(room) + except MatchError as exc: + # Notify all affected humans; they must re-queue. + for entry in human_entries: + await manager.send_to( + entry.connection_id, + { + "type": "ERROR", + "data": {"code": "MATCH_ERROR", "message": str(exc)}, + }, + ) + return + + match.is_ranked = is_ranked + + logger.info( + "match_start", + extra={ + "match_id": match.match_id, + "room_code": room.room_code, + "is_ranked": is_ranked, + "human_count": len(human_entries), + "ai_count": ai_count, + }, + ) + + # Notify each human: MATCH_FOUND clears in_queue on the client side. + for seat_idx, entry in enumerate(human_entries): + # Update the server-side WsSession so CARD_SELECT / disconnect paths + # work correctly. The session must reflect room/match membership before + # the client can send any match messages. + ws_session = manager.get_session(entry.connection_id) + if ws_session is not None: + ws_session.in_queue = False + ws_session.room_code = room.room_code + ws_session.seat_index = seat_idx + ws_session.match_id = match.match_id + + await manager.send_to( + entry.connection_id, + { + "type": "MATCH_FOUND", + "data": { + "match_id": match.match_id, + "room_code": room.room_code, + "seat_index": seat_idx, + "is_ranked": is_ranked, + }, + }, + ) + # Add to ConnectionManager room so broadcasts reach this connection. + manager.join_room(entry.connection_id, room.room_code) + + # Announce match start. + await manager.broadcast_to_room( + room.room_code, + { + "type": "MATCH_START", + "data": {"match_id": match.match_id}, + }, + ) + + await _issue_reconnect_tokens(room.room_code, match, manager, presence_manager) + + # Send initial game state. + public = match_service.build_public_state(match) + await manager.broadcast_to_room( + room.room_code, + { + "type": "PHASE_SELECTING", + "data": public_state_to_dict(public), + }, + ) + for seat in match.seats.values(): + if seat.controller_type == SeatControllerType.REMOTE and seat.connection_id: + private = match_service.build_private_state(match, seat.seat_index) + await manager.send_to( + seat.connection_id, + { + "type": "PRIVATE_HAND_STATE", + "data": private_state_to_dict(private), + }, + ) + + # Schedule selection timeout (or short-circuit if all-bot). + if match_service.all_selected(match): + await _execute_turn(room.room_code, match, manager, match_service, presence_manager) + else: + import time as _time + + match.selection_started_at = _time.time() + + async def _on_ready(rc: str, m) -> None: + await _execute_turn(rc, m, manager, match_service, presence_manager) + + presence_manager.start_selection_timeout( + match_id=match.match_id, + match=match, + match_service=match_service, + manager=manager, + room_code=room.room_code, + on_turn_ready=_on_ready, + ) + + +# --------------------------------------------------------------------------- +# Emote handlers (PR-07) +# --------------------------------------------------------------------------- + + +async def _emote_send( + ws: WebSocket, + session: WsSession, + data: dict, + manager: ConnectionManager, +) -> None: + """ + Relay an emote from one player to all room participants. + + Validation: + - Session must be authenticated and in a room. + - emote_id must be in the permitted catalog. + - Per-connection rate limits (cooldown + burst cap) must not be exceeded. + + On success: broadcasts EMOTE_BROADCAST to every room member whose + session does not have emotes_muted=True. + The sender always receives the broadcast (they see their own emote). + """ + from app.services.emote_service import emote_service + + if not session.is_authenticated: + await _error(ws, "NOT_AUTHENTICATED", "Authenticate before sending emotes", session=session) + return + + if not session.in_room: + await _error(ws, "NOT_IN_ROOM", "Join a room before sending emotes", session=session) + return + + emote_id = data.get("emote_id", "") + if not emote_service.is_valid_emote(emote_id): + await _error(ws, "INVALID_EMOTE", f"Unknown emote: {emote_id!r}", session=session) + return + + if not emote_service.check_and_record(session.connection_id, emote_id): + reason = emote_service.last_deny_reason(session.connection_id) + if reason == "cooldown": + cooldown = emote_service.remaining_cooldown(session.connection_id) + msg = f"Sending emotes too fast — wait {cooldown:.1f}s" + elif reason == "burst": + msg = "Emote burst cap reached — slow down" + else: # same_emote + msg = "Send a different emote before repeating" + logger.warning( + "emote_rate_limited", + extra={ + "conn_id": session.connection_id, + "emote_id": emote_id, + "reason": reason, + }, + ) + await ws.send_json( + { + "type": "ERROR", + "data": { + "code": "EMOTE_RATE_LIMITED", + "reason": reason, + "message": msg, + }, + } + ) + return + + payload = { + "type": "EMOTE_BROADCAST", + "data": { + "seat_index": session.seat_index, + "emote_id": emote_id, + }, + } + + # Fan out to every room member, skipping connections with mutes. + for conn_id in manager.get_room_members(session.room_code): + target_session = manager.get_session(conn_id) + if target_session is not None and target_session.emotes_muted: + continue + await manager.send_to(conn_id, payload) + + +async def _emote_mute( + ws: WebSocket, + session: WsSession, + data: dict, +) -> None: + """ + Toggle emote broadcast reception for this connection. + + The client sends {"type": "EMOTE_MUTE", "data": {"muted": true|false}}. + The server acknowledges with EMOTE_MUTE_ACK carrying the new state. + When muted=True, this connection will no longer receive EMOTE_BROADCAST + messages until it explicitly unmutes. + """ + muted = bool(data.get("muted", True)) + session.emotes_muted = muted + await ws.send_json({"type": "EMOTE_MUTE_ACK", "data": {"muted": session.emotes_muted}}) diff --git a/backend/app/ws/presence.py b/backend/app/ws/presence.py new file mode 100644 index 0000000..f60fb59 --- /dev/null +++ b/backend/app/ws/presence.py @@ -0,0 +1,330 @@ +""" +PresenceManager — heartbeat, reconnect token management, grace-period and +card-selection-timeout coordination. + +Responsibilities +---------------- +* Issue and revoke reconnect tokens (via the injected ReconnectRepo). +* Start/cancel per-seat grace-period asyncio tasks. + On expiry → call MatchService.takeover_seat() and broadcast SEAT_BOT_TAKEOVER. +* Start/cancel per-match card-selection-timeout asyncio tasks. + On expiry → call MatchService.auto_select_timed_out() and broadcast + SELECTION_TIMEOUT, then resolve the turn if all seats have now selected. + +The actual turn-resolution logic lives in handler.py; callers pass it in as +an async callback (``on_turn_ready``) so this module stays free of handler +imports (avoiding circular imports). + +Thread safety +------------- +All asyncio.Tasks are created on the running event loop. reset() cancels +every pending task before clearing state, so test isolation is preserved even +when tasks are still sleeping. +""" + +from __future__ import annotations + +import asyncio +from typing import Any, Awaitable, Callable, Optional + +from app.config import settings +from app.repositories.reconnect_repo import ReconnectEntry + + +class PresenceManager: + def __init__(self, reconnect_repo: Any) -> None: + self._repo = reconnect_repo # InMemoryReconnectRepo | RedisReconnectRepo + # f"{match_id}:{seat_index}" -> asyncio.Task + self._grace_tasks: dict[str, asyncio.Task] = {} + # match_id -> asyncio.Task + self._selection_tasks: dict[str, asyncio.Task] = {} + # match_id -> asyncio.Task + self._settlement_tasks: dict[str, asyncio.Task] = {} + + # ------------------------------------------------------------------ + # Reconnect token management + # ------------------------------------------------------------------ + + def issue_token( + self, + match_id: str, + room_code: str, + seat_index: int, + user_id: Optional[str], + display_name: str, + account_type: str, + ) -> str: + """ + Generate and store a reconnect token for one human seat. + + TTL is 4× the grace period so the token remains valid across several + reconnect attempts during the grace window. + """ + return self._repo.create( + match_id=match_id, + room_code=room_code, + seat_index=seat_index, + user_id=user_id, + display_name=display_name, + account_type=account_type, + ttl_seconds=settings.RECONNECT_GRACE_SECONDS * 4, + ) + + def lookup_token(self, token: str) -> Optional[ReconnectEntry]: + return self._repo.get(token) + + def revoke_seat_token(self, match_id: str, seat_index: int) -> None: + self._repo.revoke_by_match_seat(match_id, seat_index) + + def revoke_match_tokens(self, match_id: str) -> None: + """Invalidate all tokens for a finished match.""" + self._repo.revoke_by_match(match_id) + + # ------------------------------------------------------------------ + # Grace-period timer (disconnect → bot takeover) + # ------------------------------------------------------------------ + + def start_grace_timer( + self, + match_id: str, + seat_index: int, + match: Any, + match_service: Any, + room_service: Any, + manager: Any, + room_code: str, + on_turn_ready: Callable[[str, Any], Awaitable[None]], + ) -> None: + """ + Start a RECONNECT_GRACE_SECONDS timer for a disconnected seat. + + If the timer fires without the seat reconnecting, the seat is + permanently converted to bot control and SEAT_BOT_TAKEOVER is broadcast. + Both the match seat and the room participant are updated so that + room-level human-count checks remain accurate. + If the takeover causes all remaining seats to have selected, the turn + is resolved via *on_turn_ready*. + """ + key = f"{match_id}:{seat_index}" + self._cancel_task(self._grace_tasks, key) + self._grace_tasks[key] = asyncio.create_task( + self._grace_timer( + key, + match_id, + seat_index, + match, + match_service, + room_service, + manager, + room_code, + on_turn_ready, + ) + ) + + def cancel_grace_timer(self, match_id: str, seat_index: int) -> None: + self._cancel_task(self._grace_tasks, f"{match_id}:{seat_index}") + + async def _grace_timer( + self, + key: str, + match_id: str, + seat_index: int, + match: Any, + match_service: Any, + room_service: Any, + manager: Any, + room_code: str, + on_turn_ready: Callable[[str, Any], Awaitable[None]], + ) -> None: + try: + await asyncio.sleep(settings.RECONNECT_GRACE_SECONDS) + + # Verify match is still active in the repo. + if match_service.get_match(match_id) is None: + return + + seat = match.seats.get(seat_index) + if seat is None or seat.took_over_by_bot or not seat.is_disconnected: + return # already reconnected or already taken over + + # Grace expired — permanently convert to bot in both match and room. + # Mark for elimination at round end. + if not seat.player.is_eliminated: + match.voluntarily_left_seats.add(seat_index) + match_service.takeover_seat(match, seat_index) + # Sync room participant so human-count checks remain correct. + room_service.mark_participant_bot_takeover(room_code, seat_index) + self._repo.revoke_by_match_seat(match_id, seat_index) + + await manager.broadcast_to_room( + room_code, + { + "type": "SEAT_BOT_TAKEOVER", + "data": {"seat_index": seat_index}, + }, + ) + + # Cancel any outstanding selection timeout; the bot just selected. + self.cancel_selection_timeout(match_id) + + # If takeover completed the last pending selection, resolve the turn. + if match_service.all_selected(match): + await on_turn_ready(room_code, match) + except asyncio.CancelledError: + pass + finally: + self._grace_tasks.pop(key, None) + + # ------------------------------------------------------------------ + # Card-selection timeout + # ------------------------------------------------------------------ + + def start_selection_timeout( + self, + match_id: str, + match: Any, + match_service: Any, + manager: Any, + room_code: str, + on_turn_ready: Callable[[str, Any], Awaitable[None]], + ) -> None: + """ + Start a CARD_SELECTION_TIMEOUT_SECONDS timer for the current turn. + + On expiry, any non-eliminated REMOTE seat that hasn't selected yet + has a card chosen for it by bot logic, then the turn is resolved if + all seats are now ready. + """ + self._cancel_task(self._selection_tasks, match_id) + self._selection_tasks[match_id] = asyncio.create_task( + self._selection_timer( + match_id, + match, + match_service, + manager, + room_code, + on_turn_ready, + ) + ) + + def cancel_selection_timeout(self, match_id: str) -> None: + self._cancel_task(self._selection_tasks, match_id) + + async def _selection_timer( + self, + match_id: str, + match: Any, + match_service: Any, + manager: Any, + room_code: str, + on_turn_ready: Callable[[str, Any], Awaitable[None]], + ) -> None: + _task = asyncio.current_task() + try: + await asyncio.sleep(settings.CARD_SELECTION_TIMEOUT_SECONDS) + + if match_service.get_match(match_id) is None: + return + + timed_out = match_service.auto_select_timed_out(match) + if timed_out: + await manager.broadcast_to_room( + room_code, + { + "type": "SELECTION_TIMEOUT", + "data": {"timed_out_seats": timed_out}, + }, + ) + + if match_service.all_selected(match): + # Remove self from the task dict before calling on_turn_ready. + # on_turn_ready → _execute_turn may start a *new* selection + # timeout for the next turn; if we're still registered, that + # call would cancel this task mid-execution. + if self._selection_tasks.get(match_id) is _task: + del self._selection_tasks[match_id] + await on_turn_ready(room_code, match) + except asyncio.CancelledError: + pass + finally: + # Only clean up if this task is still the registered one + # (a newer timer for the next turn may already be stored). + if self._selection_tasks.get(match_id) is _task: + del self._selection_tasks[match_id] + + # ------------------------------------------------------------------ + # Round-settlement timeout + # ------------------------------------------------------------------ + + def start_round_settlement_timeout( + self, + match_id: str, + on_timeout: Callable[[], Awaitable[None]], + ) -> None: + """Start the post-round settlement timeout for one match.""" + self._cancel_task(self._settlement_tasks, match_id) + self._settlement_tasks[match_id] = asyncio.create_task( + self._round_settlement_timer(match_id, on_timeout) + ) + + def cancel_round_settlement_timeout(self, match_id: str) -> None: + self._cancel_task(self._settlement_tasks, match_id) + + async def _round_settlement_timer( + self, + match_id: str, + on_timeout: Callable[[], Awaitable[None]], + ) -> None: + try: + await asyncio.sleep(settings.ROUND_SETTLEMENT_TIMEOUT_SECONDS) + await on_timeout() + except asyncio.CancelledError: + pass + finally: + self._settlement_tasks.pop(match_id, None) + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def reset(self) -> None: + """ + Cancel all pending tasks and clear stored state. + + Called between tests to prevent task leakage. Also clears the + underlying token repo so test runs see a fresh store. + """ + for task in list(self._grace_tasks.values()): + if not task.done(): + task.cancel() + for task in list(self._selection_tasks.values()): + if not task.done(): + task.cancel() + for task in list(self._settlement_tasks.values()): + if not task.done(): + task.cancel() + self._grace_tasks.clear() + self._selection_tasks.clear() + self._settlement_tasks.clear() + self._repo.clear() + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _cancel_task(self, tasks: dict, key: str) -> None: + task = tasks.pop(key, None) + if task and not task.done(): + task.cancel() + + +# --------------------------------------------------------------------------- +# Module-level singleton — shared by the WS endpoint and handler. +# Tests override it via the reset_presence_manager fixture. +# --------------------------------------------------------------------------- + +from app.config import settings as _settings # noqa: E402 (already imported above) +from app.repositories.reconnect_repo import make_reconnect_repo # noqa: E402 + +_reconnect_repo = make_reconnect_repo(_settings.REDIS_URL) +presence_manager = PresenceManager(_reconnect_repo) diff --git a/backend/app/ws/session.py b/backend/app/ws/session.py new file mode 100644 index 0000000..5432723 --- /dev/null +++ b/backend/app/ws/session.py @@ -0,0 +1,45 @@ +"""Per-connection session state, kept in the WS endpoint's call stack.""" + +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class WsSession: + connection_id: str + user_id: Optional[str] = None + display_name: Optional[str] = None + account_type: Optional[str] = None # "registered" | "guest" + room_code: Optional[str] = None + seat_index: Optional[int] = None + match_id: Optional[str] = None + + # Heartbeat state (PR-05). + # Set True when the server sends a PING; cleared when the client PONGs. + # If still True when the next PING is due, the connection is considered dead. + awaiting_pong: bool = False + + # Reconnect token issued at match start for this session's seat (PR-05). + # Stored so the endpoint can look it up without querying the repo on + # normal (non-reconnect) disconnects. + reconnect_token: Optional[str] = None + + # True while this connection is sitting in the quick-match queue (PR-06). + # Cleared when a match is formed or the player leaves the queue manually. + in_queue: bool = False + + # True when this connection has opted out of receiving emote broadcasts + # (PR-07). Emotes sent by others are silently dropped for this session. + emotes_muted: bool = False + + @property + def is_authenticated(self) -> bool: + return self.display_name is not None + + @property + def in_room(self) -> bool: + return self.room_code is not None + + @property + def in_match(self) -> bool: + return self.match_id is not None diff --git a/backend/migrations/env.py b/backend/migrations/env.py new file mode 100644 index 0000000..6960b80 --- /dev/null +++ b/backend/migrations/env.py @@ -0,0 +1,71 @@ +""" +Alembic migration environment. + +DATABASE_URL is loaded from app.config (which reads from .env), +so no credentials need to live in alembic.ini. + +Usage: + cd backend + uv run alembic upgrade head # apply all migrations + uv run alembic revision --autogenerate -m "describe change" +""" + +import sys +from logging.config import fileConfig +from pathlib import Path + +from alembic import context +from sqlalchemy import engine_from_config, pool + +# Make sure 'app' is importable when running alembic from backend/ +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from app.config import settings +from app.database import Base + +# Import all models so Alembic can detect them during autogenerate +from app.models import db as _models_db # noqa: F401 + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def get_url() -> str: + return settings.DATABASE_URL + + +def run_migrations_offline() -> None: + context.configure( + url=get_url(), + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + cfg = config.get_section(config.config_ini_section, {}) + cfg["sqlalchemy.url"] = get_url() + + connectable = engine_from_config( + cfg, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/migrations/script.py.mako b/backend/migrations/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/backend/migrations/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/backend/migrations/versions/001_initial_schema.py b/backend/migrations/versions/001_initial_schema.py new file mode 100644 index 0000000..b5a9b8d --- /dev/null +++ b/backend/migrations/versions/001_initial_schema.py @@ -0,0 +1,99 @@ +"""Initial schema: users, profiles, user_collection + +Revision ID: 001 +Revises: +Create Date: 2026-03-31 + +Creates the three core tables needed for account identity and collection +persistence. No game-state tables are created here (those come in later PRs). +""" + +from typing import Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "001" +down_revision: Union[str, None] = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "users", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column( + "account_type", + sa.Enum("registered", "guest", name="accounttype"), + nullable=False, + ), + sa.Column("email", sa.String(255), unique=True, nullable=True), + sa.Column("password_hash", sa.String(255), nullable=True), + sa.Column( + "status", + sa.Enum("active", "suspended", "deleted", name="userstatus"), + nullable=False, + server_default="active", + ), + sa.Column( + "created_at", + sa.DateTime(), + nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + sa.Column("last_login_at", sa.DateTime(), nullable=True), + ) + op.create_index("ix_users_email", "users", ["email"]) + + op.create_table( + "profiles", + sa.Column( + "user_id", + sa.String(36), + sa.ForeignKey("users.id", ondelete="CASCADE"), + primary_key=True, + ), + sa.Column("nickname", sa.String(50), nullable=False), + sa.Column("avatar_id", sa.String(50), nullable=True), + sa.Column("currency", sa.Integer(), nullable=False, server_default="0"), + sa.Column("hidden_mmr", sa.Integer(), nullable=True), + sa.Column("total_games", sa.Integer(), nullable=False, server_default="0"), + sa.Column("total_wins", sa.Integer(), nullable=False, server_default="0"), + sa.Column("emote_loadout_json", sa.String(), nullable=True), + sa.Column( + "updated_at", + sa.DateTime(), + nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + ) + + op.create_table( + "user_collection", + sa.Column( + "user_id", + sa.String(36), + sa.ForeignKey("users.id", ondelete="CASCADE"), + primary_key=True, + ), + sa.Column("soldier_id", sa.Integer(), primary_key=True), + sa.Column( + "unlocked_at", + sa.DateTime(), + nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + sa.Column("source", sa.String(50), nullable=True), + ) + + +def downgrade() -> None: + op.drop_table("user_collection") + op.drop_table("profiles") + op.drop_index("ix_users_email", "users") + op.drop_table("users") + # Drop enum types (PostgreSQL only; SQLite has no user-defined types). + if op.get_bind().dialect.name == "postgresql": + op.execute("DROP TYPE IF EXISTS accounttype") + op.execute("DROP TYPE IF EXISTS userstatus") diff --git a/backend/migrations/versions/002_add_reports.py b/backend/migrations/versions/002_add_reports.py new file mode 100644 index 0000000..617c19d --- /dev/null +++ b/backend/migrations/versions/002_add_reports.py @@ -0,0 +1,81 @@ +"""Add reports table + +Revision ID: 002 +Revises: 001 +Create Date: 2026-04-06 + +Adds the reports table for beta moderation (PR-08). +Reason codes: emote_spam, abusive_language, cheating, nickname_violation, other. +Status values: open, reviewed, dismissed. +""" + +from typing import Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "002" +down_revision: Union[str, None] = "001" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "reports", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column( + "reporter_user_id", + sa.String(36), + sa.ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column( + "reported_user_id", + sa.String(36), + sa.ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column("reported_connection_id", sa.String(36), nullable=True), + sa.Column( + "reason_code", + sa.Enum( + "emote_spam", + "abusive_language", + "cheating", + "nickname_violation", + "other", + name="reportreasoncode", + ), + nullable=False, + ), + sa.Column("details", sa.Text(), nullable=True), + sa.Column("room_code", sa.String(10), nullable=True), + sa.Column("match_id", sa.String(36), nullable=True), + sa.Column( + "status", + sa.Enum("open", "reviewed", "dismissed", name="reportstatus"), + nullable=False, + server_default="open", + ), + sa.Column( + "created_at", + sa.DateTime(), + nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + ) + op.create_index("ix_reports_reporter_user_id", "reports", ["reporter_user_id"]) + op.create_index("ix_reports_reported_user_id", "reports", ["reported_user_id"]) + op.create_index("ix_reports_status", "reports", ["status"]) + + +def downgrade() -> None: + op.drop_index("ix_reports_status", "reports") + op.drop_index("ix_reports_reported_user_id", "reports") + op.drop_index("ix_reports_reporter_user_id", "reports") + op.drop_table("reports") + # Drop enum types (PostgreSQL only; SQLite has no user-defined types). + if op.get_bind().dialect.name == "postgresql": + op.execute("DROP TYPE IF EXISTS reportreasoncode") + op.execute("DROP TYPE IF EXISTS reportstatus") diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..2888a66 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,51 @@ +[project] +name = "fall-in-backend" +version = "0.1.0" +description = "Fall-In Multiplayer API Server" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.115.0", + "uvicorn[standard]>=0.31.0", + "sqlalchemy>=2.0.36", + "alembic>=1.14.0", + "pydantic-settings>=2.6.0", + "pydantic[email]>=2.10.0", + "python-jose[cryptography]>=3.3.0", + "bcrypt>=4.2.0", + "psycopg2-binary>=2.9.10", + "python-multipart>=0.0.20", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-cov>=4.1.0", + "httpx>=0.28.0", + "ruff>=0.14.14", +] + +# Production extras: install with `uv pip install -e ".[prod]"` +# The remote server already has Redis; set REDIS_URL in .env to activate +# the Redis-backed reconnect token store with automatic TTL expiry. +prod = [ + "redis[hiredis]>=5.0.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["app"] + +[tool.pytest.ini_options] +# Run from backend/ directory: uv run pytest tests/ +# ../src makes fall_in.core (GameRules etc.) importable for MatchService tests. +pythonpath = [".", "../src"] +testpaths = ["tests"] + +[tool.ruff] +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "I"] diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..0774cf8 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,129 @@ +""" +Test fixtures for the Fall-In backend. + +Each test function gets a fresh in-memory SQLite database via the +`client` and `db` fixtures. Test isolation is achieved by creating +a new engine (and therefore a new in-memory database) per test. + +Key design decisions: + - StaticPool ensures all connections within one test share a single + underlying SQLite connection, so data written by the test setup is + visible to HTTP requests made through TestClient. + - `db` and the session used inside `override_get_db` are the SAME + session object, so commits in one context are immediately visible + in the other — no race conditions with in-memory SQLite. + - `app.dependency_overrides` is cleared after every test to prevent + state leakage between test functions. +""" + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + +from app.database import Base, get_db +from app.main import app + + +@pytest.fixture(autouse=True) +def reset_ws_manager(): + """Reset the WebSocket connection manager between tests.""" + from app.ws.connection_manager import manager + + manager.reset() + yield + manager.reset() + + +@pytest.fixture(autouse=True) +def reset_match_service(): + """Reset the module-level MatchService singleton between tests.""" + from app.ws.endpoint import _match_service + + _match_service.reset() + yield + _match_service.reset() + + +@pytest.fixture(autouse=True) +def reset_presence_manager(): + """ + Cancel all pending asyncio tasks and clear token state between tests. + + Calls PresenceManager.reset() which cancels grace-period and selection- + timeout tasks AND clears the underlying reconnect token repo, so that + tokens issued in one test are never visible in the next. + """ + from app.ws.presence import presence_manager + + presence_manager.reset() + yield + presence_manager.reset() + + +@pytest.fixture(autouse=True) +def reset_matchmaking_service(): + """Cancel all fill-timer tasks and clear the quick-match queue between tests.""" + from app.services.matchmaking_service import matchmaking_service + + matchmaking_service.reset() + yield + matchmaking_service.reset() + + +@pytest.fixture(autouse=True) +def reset_emote_service(): + """Clear per-connection rate-limit state between tests.""" + from app.services.emote_service import emote_service + + emote_service.reset() + yield + emote_service.reset() + + +@pytest.fixture() +def db_engine(): + """Fresh in-memory SQLite engine for one test.""" + engine = create_engine( + "sqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + yield engine + Base.metadata.drop_all(bind=engine) + engine.dispose() + + +@pytest.fixture() +def db(db_engine) -> Session: + """ + SQLAlchemy session tied to the test engine. + + This SAME session object is injected into every request handled + by the TestClient (via the override below), so test setup writes + are immediately visible inside request handlers. + """ + SessionFactory = sessionmaker(bind=db_engine, autocommit=False, autoflush=False) + session = SessionFactory() + yield session + session.close() + + +@pytest.fixture() +def client(db) -> TestClient: + """ + FastAPI TestClient backed by the test database. + + The get_db dependency is overridden to always yield the shared + `db` session, ensuring test data and request data share state. + """ + + def override_get_db(): + yield db + + app.dependency_overrides[get_db] = override_get_db + with TestClient(app) as c: + yield c + app.dependency_overrides.clear() diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..38e5404 --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,348 @@ +""" +Auth flow tests. + +Covers: register, login, guest, refresh, logout, and basic token validation. +""" + +from fastapi.testclient import TestClient + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _register( + client: TestClient, email: str, password: str = "password123", nickname: str = "Player" +) -> dict: + resp = client.post( + "/auth/register", + json={ + "email": email, + "password": password, + "nickname": nickname, + }, + ) + return resp + + +# --------------------------------------------------------------------------- +# Register +# --------------------------------------------------------------------------- + + +class TestRegister: + def test_success_returns_201_with_tokens(self, client): + resp = _register(client, "alice@example.com", nickname="Alice") + assert resp.status_code == 201 + data = resp.json() + assert "access_token" in data + assert "refresh_token" in data + assert data["account_type"] == "registered" + assert data["token_type"] == "bearer" + + def test_duplicate_email_returns_400(self, client): + _register(client, "dup@example.com") + resp = _register(client, "dup@example.com") + assert resp.status_code == 400 + assert "already registered" in resp.json()["detail"] + + def test_short_password_returns_422(self, client): + resp = client.post( + "/auth/register", + json={ + "email": "short@example.com", + "password": "abc", # < 8 chars + "nickname": "ShortPw", + }, + ) + assert resp.status_code == 422 + + def test_empty_nickname_returns_422(self, client): + resp = client.post( + "/auth/register", + json={ + "email": "nonick@example.com", + "password": "password123", + "nickname": "", + }, + ) + assert resp.status_code == 422 + + def test_missing_fields_returns_422(self, client): + resp = client.post("/auth/register", json={"email": "nopw@example.com"}) + assert resp.status_code == 422 + + +# --------------------------------------------------------------------------- +# Login +# --------------------------------------------------------------------------- + + +class TestLogin: + def test_success_returns_tokens(self, client): + _register(client, "bob@example.com", password="secure123", nickname="Bob") + resp = client.post( + "/auth/login", + json={ + "email": "bob@example.com", + "password": "secure123", + }, + ) + assert resp.status_code == 200 + data = resp.json() + assert "access_token" in data + assert "refresh_token" in data + assert data["account_type"] == "registered" + + def test_wrong_password_returns_401(self, client): + _register(client, "carol@example.com", password="correct123", nickname="Carol") + resp = client.post( + "/auth/login", + json={ + "email": "carol@example.com", + "password": "wrongpassword", + }, + ) + assert resp.status_code == 401 + + def test_nonexistent_email_returns_401(self, client): + resp = client.post( + "/auth/login", + json={ + "email": "nobody@example.com", + "password": "password123", + }, + ) + assert resp.status_code == 401 + + def test_wrong_password_does_not_reveal_which_part_failed(self, client): + """Same error message for wrong password vs unknown email (timing-safe).""" + _register(client, "dave@example.com", nickname="Dave") + wrong_pw = client.post( + "/auth/login", json={"email": "dave@example.com", "password": "wrong"} + ) + no_user = client.post( + "/auth/login", json={"email": "ghost@example.com", "password": "wrong"} + ) + assert wrong_pw.json()["detail"] == no_user.json()["detail"] + + +# --------------------------------------------------------------------------- +# Guest login +# --------------------------------------------------------------------------- + + +class TestGuestLogin: + def test_success_with_custom_nickname(self, client): + resp = client.post("/auth/guest", json={"nickname": "GuestUser"}) + assert resp.status_code == 200 + data = resp.json() + assert "access_token" in data + assert data["account_type"] == "guest" + assert data["token_type"] == "bearer" + + def test_no_refresh_token_for_guest(self, client): + resp = client.post("/auth/guest", json={"nickname": "GuestUser"}) + data = resp.json() + # refresh_token must be absent or explicitly null + assert data.get("refresh_token") is None + + def test_auto_nickname_when_not_provided(self, client): + resp = client.post("/auth/guest", json={}) + assert resp.status_code == 200 + assert resp.json()["account_type"] == "guest" + + def test_guest_token_is_valid_for_profile(self, client): + resp = client.post("/auth/guest", json={"nickname": "ProfileGuest"}) + token = resp.json()["access_token"] + profile = client.get("/me/profile", headers={"Authorization": f"Bearer {token}"}) + assert profile.status_code == 200 + assert profile.json()["account_type"] == "guest" + + +# --------------------------------------------------------------------------- +# Token refresh +# --------------------------------------------------------------------------- + + +class TestRefresh: + def test_success_returns_new_access_token(self, client): + reg = _register(client, "refresh@example.com", nickname="RefreshPlayer") + refresh_token = reg.json()["refresh_token"] + + resp = client.post("/auth/refresh", json={"refresh_token": refresh_token}) + assert resp.status_code == 200 + data = resp.json() + assert "access_token" in data + assert data["token_type"] == "bearer" + + def test_access_token_cannot_be_used_as_refresh(self, client): + reg = _register(client, "badrefresh@example.com", nickname="BadRefresh") + access_token = reg.json()["access_token"] + + resp = client.post("/auth/refresh", json={"refresh_token": access_token}) + assert resp.status_code == 401 + + def test_garbage_token_returns_401(self, client): + resp = client.post("/auth/refresh", json={"refresh_token": "not.a.jwt.at.all"}) + assert resp.status_code == 401 + + def test_new_access_token_is_usable(self, client): + reg = _register(client, "newtoken@example.com", nickname="NewToken") + refresh_token = reg.json()["refresh_token"] + + new_token = client.post("/auth/refresh", json={"refresh_token": refresh_token}).json()[ + "access_token" + ] + profile = client.get("/me/profile", headers={"Authorization": f"Bearer {new_token}"}) + assert profile.status_code == 200 + + def test_refresh_blocked_after_suspension(self, client, db): + """Suspended account must not receive a new access token via refresh.""" + from app.models.db import User, UserStatus + + reg = _register(client, "suspend_refresh@example.com", nickname="SuspRefresh") + refresh_token = reg.json()["refresh_token"] + + # Suspend the account + user = db.query(User).filter(User.email == "suspend_refresh@example.com").first() + user.status = UserStatus.SUSPENDED + db.commit() + + resp = client.post("/auth/refresh", json={"refresh_token": refresh_token}) + assert resp.status_code == 401 + + def test_refresh_blocked_after_deletion(self, client, db): + """Deleted account must not receive a new access token via refresh.""" + from app.models.db import User, UserStatus + + reg = _register(client, "delete_refresh@example.com", nickname="DelRefresh") + refresh_token = reg.json()["refresh_token"] + + user = db.query(User).filter(User.email == "delete_refresh@example.com").first() + user.status = UserStatus.DELETED + db.commit() + + resp = client.post("/auth/refresh", json={"refresh_token": refresh_token}) + assert resp.status_code == 401 + + +# --------------------------------------------------------------------------- +# Logout +# --------------------------------------------------------------------------- + + +class TestLogout: + def test_logout_returns_200(self, client): + resp = client.post("/auth/logout") + assert resp.status_code == 200 + + def test_logout_response_has_detail(self, client): + resp = client.post("/auth/logout") + assert "detail" in resp.json() + + +# --------------------------------------------------------------------------- +# Account status enforcement +# --------------------------------------------------------------------------- + + +class TestAccountStatus: + """ + Suspended and deleted accounts must be blocked at every auth boundary: + login, token-bearing HTTP requests, and WS auth. + """ + + def _suspend(self, db, email: str) -> None: + from app.models.db import User, UserStatus + + user = db.query(User).filter(User.email == email).first() + user.status = UserStatus.SUSPENDED + db.commit() + + def _delete(self, db, email: str) -> None: + from app.models.db import User, UserStatus + + user = db.query(User).filter(User.email == email).first() + user.status = UserStatus.DELETED + db.commit() + + def test_suspended_user_cannot_login(self, client, db): + _register(client, "suspended@example.com", nickname="Suspended") + self._suspend(db, "suspended@example.com") + resp = client.post( + "/auth/login", + json={ + "email": "suspended@example.com", + "password": "password123", + }, + ) + assert resp.status_code == 401 + + def test_deleted_user_cannot_login(self, client, db): + _register(client, "deleted@example.com", nickname="Deleted") + self._delete(db, "deleted@example.com") + resp = client.post( + "/auth/login", + json={ + "email": "deleted@example.com", + "password": "password123", + }, + ) + assert resp.status_code == 401 + + def test_suspended_user_same_error_message_as_wrong_password(self, client, db): + """Status check must not leak more info than a wrong-password failure.""" + _register(client, "suscheck@example.com", nickname="SusCheck") + self._suspend(db, "suscheck@example.com") + resp_suspended = client.post( + "/auth/login", + json={ + "email": "suscheck@example.com", + "password": "password123", + }, + ) + resp_wrong_pw = client.post( + "/auth/login", + json={ + "email": "suscheck@example.com", + "password": "wrongpassword", + }, + ) + assert resp_suspended.status_code == 401 + assert resp_wrong_pw.status_code == 401 + assert resp_suspended.json()["detail"] == resp_wrong_pw.json()["detail"] + + def test_suspended_user_existing_token_rejected_on_profile(self, client, db): + """A token issued before suspension must be rejected on subsequent requests.""" + resp = _register(client, "willsuspend@example.com", nickname="WillSuspend") + token = resp.json()["access_token"] + # Works before suspension + assert ( + client.get("/me/profile", headers={"Authorization": f"Bearer {token}"}).status_code + == 200 + ) + # Suspend and retry + self._suspend(db, "willsuspend@example.com") + assert ( + client.get("/me/profile", headers={"Authorization": f"Bearer {token}"}).status_code + == 401 + ) + + def test_deleted_user_existing_token_rejected_on_profile(self, client, db): + resp = _register(client, "willdelete@example.com", nickname="WillDelete") + token = resp.json()["access_token"] + self._delete(db, "willdelete@example.com") + assert ( + client.get("/me/profile", headers={"Authorization": f"Bearer {token}"}).status_code + == 401 + ) + + def test_suspended_user_cannot_access_collection(self, client, db): + resp = _register(client, "sus_col@example.com", nickname="SusCol") + token = resp.json()["access_token"] + self._suspend(db, "sus_col@example.com") + assert ( + client.get("/me/collection", headers={"Authorization": f"Bearer {token}"}).status_code + == 401 + ) diff --git a/backend/tests/test_collection.py b/backend/tests/test_collection.py new file mode 100644 index 0000000..b2c9b72 --- /dev/null +++ b/backend/tests/test_collection.py @@ -0,0 +1,315 @@ +""" +Profile and collection isolation tests. + +Critical invariants verified: + 1. Collection is strictly keyed by user_id — no cross-user leakage. + 2. Guest accounts cannot read or write collection data. + 3. Profile is readable by both registered and guest users. + 4. Unauthenticated requests are rejected. + 5. hidden_mmr is NOT present in any profile response. +""" + +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _register_and_get_token(client: TestClient, email: str, nickname: str = "Player") -> str: + resp = client.post( + "/auth/register", + json={ + "email": email, + "password": "password123", + "nickname": nickname, + }, + ) + assert resp.status_code == 201, resp.json() + return resp.json()["access_token"] + + +def _guest_token(client: TestClient, nickname: str = "Guest") -> str: + resp = client.post("/auth/guest", json={"nickname": nickname}) + assert resp.status_code == 200, resp.json() + return resp.json()["access_token"] + + +def _auth(token: str) -> dict: + return {"Authorization": f"Bearer {token}"} + + +# --------------------------------------------------------------------------- +# Profile endpoint +# --------------------------------------------------------------------------- + + +class TestProfile: + def test_registered_user_gets_correct_profile(self, client): + token = _register_and_get_token(client, "profile@example.com", nickname="ProfilePlayer") + resp = client.get("/me/profile", headers=_auth(token)) + assert resp.status_code == 200 + data = resp.json() + assert data["nickname"] == "ProfilePlayer" + assert data["account_type"] == "registered" + assert data["currency"] == 0 + assert data["total_games"] == 0 + assert data["total_wins"] == 0 + assert "user_id" in data + + def test_guest_user_gets_correct_profile(self, client): + token = _guest_token(client, nickname="GuestProfile") + resp = client.get("/me/profile", headers=_auth(token)) + assert resp.status_code == 200 + data = resp.json() + assert data["nickname"] == "GuestProfile" + assert data["account_type"] == "guest" + + def test_hidden_mmr_not_in_profile_response(self, client): + """hidden_mmr must never be exposed via the API.""" + token = _register_and_get_token(client, "mmr@example.com", nickname="MMRPlayer") + resp = client.get("/me/profile", headers=_auth(token)) + assert "hidden_mmr" not in resp.json() + + def test_unauthenticated_profile_rejected(self, client): + resp = client.get("/me/profile") + assert resp.status_code in (401, 403) # HTTPBearer rejects missing credentials + + def test_invalid_token_profile_rejected(self, client): + resp = client.get("/me/profile", headers={"Authorization": "Bearer totally.fake.token"}) + assert resp.status_code == 401 + + +# --------------------------------------------------------------------------- +# Collection endpoint — registered users +# --------------------------------------------------------------------------- + + +class TestCollection: + def test_empty_collection_for_new_registered_user(self, client): + token = _register_and_get_token(client, "empty@example.com", nickname="EmptyPlayer") + resp = client.get("/me/collection", headers=_auth(token)) + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 0 + assert data["items"] == [] + + def test_collection_contains_added_soldier(self, client, db: Session): + """Directly insert a collection row and verify it appears via the API.""" + from app.repositories import collection_repo + + token = _register_and_get_token(client, "collector@example.com", nickname="Collector") + profile = client.get("/me/profile", headers=_auth(token)).json() + user_id = profile["user_id"] + + # Simulate what the match-result service will do (PR-04+) + collection_repo.add_soldier(db, user_id, 42, source="test") + + resp = client.get("/me/collection", headers=_auth(token)) + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 1 + assert data["items"][0]["soldier_id"] == 42 + assert data["items"][0]["source"] == "test" + + def test_collection_entry_has_required_fields(self, client, db: Session): + from app.repositories import collection_repo + + token = _register_and_get_token(client, "fields@example.com", nickname="FieldsPlayer") + user_id = client.get("/me/profile", headers=_auth(token)).json()["user_id"] + collection_repo.add_soldier(db, user_id, 77, source="interview") + + items = client.get("/me/collection", headers=_auth(token)).json()["items"] + assert len(items) == 1 + item = items[0] + assert "soldier_id" in item + assert "unlocked_at" in item + assert "source" in item + + +# --------------------------------------------------------------------------- +# Collection isolation — the critical multiplayer invariant +# --------------------------------------------------------------------------- + + +class TestCollectionIsolation: + """ + Verify that user A's collection is never visible to user B. + + This directly tests the core PR-01 multiplayer invariant: + 'collection data must never cross user boundaries.' + """ + + def test_user_b_cannot_see_user_a_collection(self, client, db: Session): + from app.repositories import collection_repo + + token_a = _register_and_get_token(client, "usera@example.com", nickname="UserA") + token_b = _register_and_get_token(client, "userb@example.com", nickname="UserB") + + user_a_id = client.get("/me/profile", headers=_auth(token_a)).json()["user_id"] + client.get("/me/profile", headers=_auth(token_b)).json()["user_id"] + + # Give user A soldier 42 + collection_repo.add_soldier(db, user_a_id, 42, source="test") + + # User A sees soldier 42 + col_a = client.get("/me/collection", headers=_auth(token_a)).json() + assert any(item["soldier_id"] == 42 for item in col_a["items"]) + + # User B sees NOTHING + col_b = client.get("/me/collection", headers=_auth(token_b)).json() + assert col_b["total"] == 0 + assert not any(item["soldier_id"] == 42 for item in col_b["items"]) + + def test_user_ids_are_distinct(self, client): + token_a = _register_and_get_token(client, "ida@example.com", nickname="IDA") + token_b = _register_and_get_token(client, "idb@example.com", nickname="IDB") + + id_a = client.get("/me/profile", headers=_auth(token_a)).json()["user_id"] + id_b = client.get("/me/profile", headers=_auth(token_b)).json()["user_id"] + + assert id_a != id_b + + def test_multiple_soldiers_isolated(self, client, db: Session): + """User A has [42, 77]; user B has [55]; each sees only their own.""" + from app.repositories import collection_repo + + token_a = _register_and_get_token(client, "multi_a@example.com", nickname="MultiA") + token_b = _register_and_get_token(client, "multi_b@example.com", nickname="MultiB") + + id_a = client.get("/me/profile", headers=_auth(token_a)).json()["user_id"] + id_b = client.get("/me/profile", headers=_auth(token_b)).json()["user_id"] + + collection_repo.add_soldier(db, id_a, 42) + collection_repo.add_soldier(db, id_a, 77) + collection_repo.add_soldier(db, id_b, 55) + + a_ids = { + item["soldier_id"] + for item in client.get("/me/collection", headers=_auth(token_a)).json()["items"] + } + b_ids = { + item["soldier_id"] + for item in client.get("/me/collection", headers=_auth(token_b)).json()["items"] + } + + assert a_ids == {42, 77} + assert b_ids == {55} + assert a_ids.isdisjoint(b_ids) + + +# --------------------------------------------------------------------------- +# Guest collection restrictions +# --------------------------------------------------------------------------- + + +class TestGuestCollectionRestrictions: + def test_guest_cannot_read_collection(self, client): + token = _guest_token(client, "GuestNoCol") + resp = client.get("/me/collection", headers=_auth(token)) + assert resp.status_code == 403 + assert "registered" in resp.json()["detail"].lower() + + def test_guest_cannot_access_collection_even_with_valid_token(self, client): + """A valid guest access token must still be rejected for collection access.""" + token = _guest_token(client, "ValidGuest") + # Verify the token works for profile + assert client.get("/me/profile", headers=_auth(token)).status_code == 200 + # But not for collection + assert client.get("/me/collection", headers=_auth(token)).status_code == 403 + + def test_unauthenticated_collection_rejected(self, client): + resp = client.get("/me/collection") + assert resp.status_code in (401, 403) # HTTPBearer rejects missing credentials + + +# --------------------------------------------------------------------------- +# Progress sync endpoints +# --------------------------------------------------------------------------- + + +class TestProgressSync: + def test_merge_progress_uses_max_currency_and_union_collection(self, client, db: Session): + from app.repositories import collection_repo, profile_repo + + token = _register_and_get_token(client, "merge@example.com", nickname="MergePlayer") + profile = client.get("/me/profile", headers=_auth(token)).json() + user_id = profile["user_id"] + + from app.models.db import User + + row = db.query(User).filter(User.id == user_id).first() + collection_repo.add_soldier(db, user_id, 11, source="server") + profile_repo.set_currency(db, row.profile, 120) + + resp = client.post( + "/me/progress/merge", + headers=_auth(token), + json={"currency": 180, "collected_soldier_ids": [11, 42, 77]}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["currency"] == 180 + assert data["collected_soldier_ids"] == [11, 42, 77] + assert data["total_collected"] == 3 + + def test_reward_claim_adds_currency(self, client): + token = _register_and_get_token(client, "currency@example.com", nickname="Wallet") + resp = client.post( + "/me/reward", + headers=_auth(token), + json={"amount": 35, "reason": "single_play_victory"}, + ) + assert resp.status_code == 200 + assert resp.json()["granted"] == 35 + assert resp.json()["currency"] == 35 + + profile = client.get("/me/profile", headers=_auth(token)) + assert profile.status_code == 200 + assert profile.json()["currency"] == 35 + + def test_collection_unlock_is_idempotent(self, client): + token = _register_and_get_token(client, "unlock@example.com", nickname="Unlocker") + + first = client.post( + "/me/collection/unlock", + headers=_auth(token), + json={"soldier_id": 22}, + ) + second = client.post( + "/me/collection/unlock", + headers=_auth(token), + json={"soldier_id": 22}, + ) + + assert first.status_code == 200 + assert first.json()["added"] is True + assert first.json()["total_collected"] == 1 + + assert second.status_code == 200 + assert second.json()["added"] is False + assert second.json()["total_collected"] == 1 + + def test_guest_cannot_write_progress(self, client): + token = _guest_token(client, "GuestSync") + + merge = client.post( + "/me/progress/merge", + headers=_auth(token), + json={"currency": 50, "collected_soldier_ids": [7]}, + ) + update = client.post( + "/me/reward", + headers=_auth(token), + json={"amount": 50, "reason": "single_play_victory"}, + ) + unlock = client.post( + "/me/collection/unlock", + headers=_auth(token), + json={"soldier_id": 7}, + ) + + assert merge.status_code == 403 + assert update.status_code == 403 + assert unlock.status_code == 403 diff --git a/backend/tests/test_emote.py b/backend/tests/test_emote.py new file mode 100644 index 0000000..ff16a7a --- /dev/null +++ b/backend/tests/test_emote.py @@ -0,0 +1,434 @@ +""" +Tests for the PR-07 emote system. + +Coverage: + - EmoteService: catalog validation, cooldown, burst cap, reset + - WS protocol: EMOTE_SEND happy path, invalid emote_id, not-in-room guard, + not-authenticated guard, rate-limit rejection + - EMOTE_BROADCAST fanout: all room members receive, sender receives + - Mute: EMOTE_MUTE sets flag; muted connection does not receive broadcasts +""" + +import time +import uuid + +import pytest + +from app.config import settings +from app.services.emote_service import VALID_EMOTE_IDS, EmoteService + +# =========================================================================== +# Helpers +# =========================================================================== + + +def _register_and_get_token(client, email: str = None, nickname: str = None) -> str: + email = email or f"u{uuid.uuid4().hex[:8]}@test.com" + nickname = nickname or f"P{uuid.uuid4().hex[:6]}" + resp = client.post( + "/auth/register", + json={"email": email, "password": "Test1234!", "nickname": nickname}, + ) + assert resp.status_code in (200, 201) + return resp.json()["access_token"] + + +def _ws_auth(ws, token: str) -> None: + """Send WS_HELLO + AUTH_LOGIN and consume their responses.""" + ws.send_json({"type": "WS_HELLO", "data": {}}) + ws.receive_json() # WS_WELCOME + ws.send_json({"type": "AUTH_LOGIN", "data": {"token": token}}) + ws.receive_json() # AUTH_OK + + +def _ws_create_room(ws) -> str: + """Create a room and return room_code.""" + ws.send_json({"type": "ROOM_CREATE", "data": {}}) + msg = ws.receive_json() # ROOM_STATE + assert msg["type"] == "ROOM_STATE" + return msg["data"]["room_code"] + + +def _ws_join_room(ws, room_code: str) -> None: + ws.send_json({"type": "ROOM_JOIN", "data": {"room_code": room_code}}) + ws.receive_json() # ROOM_STATE (host sees it too) + + +# =========================================================================== +# TestEmoteService — unit tests, no WS +# =========================================================================== + + +class TestEmoteService: + def setup_method(self): + self.svc = EmoteService() + + # ------------------------------------------------------------------ + # Catalog + # ------------------------------------------------------------------ + + def test_valid_emote_ids_are_non_empty(self): + assert len(VALID_EMOTE_IDS) > 0 + + def test_is_valid_emote_known(self): + for emote_id in VALID_EMOTE_IDS: + assert self.svc.is_valid_emote(emote_id) + + def test_is_valid_emote_unknown(self): + assert not self.svc.is_valid_emote("free_text_lol") + assert not self.svc.is_valid_emote("") + assert not self.svc.is_valid_emote("THUMBSUP") # case-sensitive + + # ------------------------------------------------------------------ + # Cooldown + # ------------------------------------------------------------------ + + def test_first_emote_is_allowed(self): + assert self.svc.check_and_record("conn1", "smile") is True + + def test_second_emote_within_cooldown_denied(self): + self.svc.check_and_record("conn1", "smile") + assert self.svc.check_and_record("conn1", "fire") is False + + def test_different_connections_independent_cooldowns(self): + assert self.svc.check_and_record("conn1", "smile") is True + assert self.svc.check_and_record("conn2", "smile") is True + + def test_remaining_cooldown_after_send(self): + self.svc.check_and_record("conn1", "smile") + remaining = self.svc.remaining_cooldown("conn1") + assert 0.0 < remaining <= settings.EMOTE_COOLDOWN_SECONDS + + def test_remaining_cooldown_before_first_send_is_zero(self): + assert self.svc.remaining_cooldown("never_sent") == 0.0 + + # ------------------------------------------------------------------ + # Burst cap + # ------------------------------------------------------------------ + + def test_burst_cap_enforced(self): + """ + After EMOTE_BURST_CAP allowed emotes within the burst window, the + next attempt is denied — even if the per-send cooldown has elapsed. + + We fake elapsed time by manipulating _last_sent so the cooldown + check passes but the window accumulates. Different emote IDs are + used on each send to avoid triggering the same-emote soft block. + """ + svc = EmoteService() + conn = "burst_test" + cap = settings.EMOTE_BURST_CAP + emotes = list(VALID_EMOTE_IDS) + + for i in range(cap): + # Advance last_sent far enough back to pass cooldown + svc._last_sent[conn] = time.time() - settings.EMOTE_COOLDOWN_SECONDS - 0.1 + result = svc.check_and_record(conn, emotes[i % len(emotes)]) + assert result is True, f"Should be allowed on attempt {i + 1}" + + # Now all cap slots are used — deny even after cooldown + svc._last_sent[conn] = time.time() - settings.EMOTE_COOLDOWN_SECONDS - 0.1 + assert svc.check_and_record(conn, "clap") is False + + def test_burst_window_expires(self): + """ + Entries older than EMOTE_BURST_WINDOW_SECONDS are pruned, freeing + the burst cap. + """ + svc = EmoteService() + conn = "window_test" + cap = settings.EMOTE_BURST_CAP + + # Fill the window with old timestamps + old_ts = time.time() - settings.EMOTE_BURST_WINDOW_SECONDS - 1.0 + svc._window_history[conn] = [old_ts] * cap + + # Cooldown has also elapsed + svc._last_sent[conn] = time.time() - settings.EMOTE_COOLDOWN_SECONDS - 0.1 + + # Should be allowed — old entries pruned + assert svc.check_and_record(conn, "thumbsup") is True + + # ------------------------------------------------------------------ + # Same-emote soft block + # ------------------------------------------------------------------ + + def test_same_emote_soft_block_after_cap(self): + """ + Sending the same emote more than EMOTE_SAME_REPEAT_CAP times in + a row is denied even after the cooldown passes. + """ + svc = EmoteService() + conn = "spam_test" + cap = settings.EMOTE_SAME_REPEAT_CAP + + for i in range(cap): + svc._last_sent[conn] = time.time() - settings.EMOTE_COOLDOWN_SECONDS - 0.1 + result = svc.check_and_record(conn, "fire") + assert result is True, f"Should be allowed on attempt {i + 1}" + + # cap+1 consecutive same emote → denied + svc._last_sent[conn] = time.time() - settings.EMOTE_COOLDOWN_SECONDS - 0.1 + assert svc.check_and_record(conn, "fire") is False + + def test_different_emote_resets_same_emote_streak(self): + """ + Switching to a different emote resets the same-emote streak counter, + allowing the original emote to be sent again. + + We use cap-1 repeated sends to fill the streak without exhausting the + burst window (BURST_CAP > SAME_REPEAT_CAP in the spec values). + """ + svc = EmoteService() + conn = "streak_test" + cap = settings.EMOTE_SAME_REPEAT_CAP + + # Build up cap-1 "fire" sends (streak below block threshold) + for _ in range(cap - 1): + svc._last_sent[conn] = time.time() - settings.EMOTE_COOLDOWN_SECONDS - 0.1 + svc.check_and_record(conn, "fire") + + # Sending a different emote should be allowed and reset the streak + svc._last_sent[conn] = time.time() - settings.EMOTE_COOLDOWN_SECONDS - 0.1 + assert svc.check_and_record(conn, "smile") is True + + # "fire" is allowed again — streak was reset by "smile" + svc._last_sent[conn] = time.time() - settings.EMOTE_COOLDOWN_SECONDS - 0.1 + assert svc.check_and_record(conn, "fire") is True + + # ------------------------------------------------------------------ + # Reset + # ------------------------------------------------------------------ + + def test_reset_clears_all_state(self): + self.svc.check_and_record("conn1", "smile") + self.svc.reset() + # After reset the connection should be allowed again + assert self.svc.check_and_record("conn1", "smile") is True + + +# =========================================================================== +# TestEmoteWsProtocol — WS integration tests +# =========================================================================== + + +class TestEmoteWsProtocol: + """ + Integration tests for EMOTE_SEND and EMOTE_MUTE over the WS endpoint. + + Each test uses the `client` fixture which provides a fresh in-memory DB. + The autouse reset_emote_service fixture (defined in conftest.py below) + clears rate-limit state between tests. + """ + + # ------------------------------------------------------------------ + # Guard conditions + # ------------------------------------------------------------------ + + def test_emote_send_not_authenticated(self, client): + with client.websocket_connect("/ws") as ws: + ws.send_json({"type": "WS_HELLO", "data": {}}) + ws.receive_json() + ws.send_json({"type": "EMOTE_SEND", "data": {"emote_id": "smile"}}) + msg = ws.receive_json() + assert msg["type"] == "ERROR" + assert msg["data"]["code"] == "NOT_AUTHENTICATED" + + def test_emote_send_not_in_room(self, client): + token = _register_and_get_token(client) + with client.websocket_connect("/ws") as ws: + _ws_auth(ws, token) + ws.send_json({"type": "EMOTE_SEND", "data": {"emote_id": "smile"}}) + msg = ws.receive_json() + assert msg["type"] == "ERROR" + assert msg["data"]["code"] == "NOT_IN_ROOM" + + def test_emote_send_invalid_emote_id(self, client): + token = _register_and_get_token(client) + with client.websocket_connect("/ws") as ws: + _ws_auth(ws, token) + _ws_create_room(ws) + ws.send_json({"type": "EMOTE_SEND", "data": {"emote_id": "freetext_attack"}}) + msg = ws.receive_json() + assert msg["type"] == "ERROR" + assert msg["data"]["code"] == "INVALID_EMOTE" + + # ------------------------------------------------------------------ + # Happy path — sender receives own broadcast + # ------------------------------------------------------------------ + + def test_emote_send_happy_path(self, client): + token = _register_and_get_token(client) + with client.websocket_connect("/ws") as ws: + _ws_auth(ws, token) + _ws_create_room(ws) + ws.send_json({"type": "EMOTE_SEND", "data": {"emote_id": "thumbsup"}}) + msg = ws.receive_json() + assert msg["type"] == "EMOTE_BROADCAST" + assert msg["data"]["emote_id"] == "thumbsup" + assert msg["data"]["seat_index"] == 0 # host is seat 0 + + def test_emote_broadcast_delivered_to_all_room_members(self, client): + """Both the sender and a second room member receive the broadcast.""" + token_a = _register_and_get_token(client) + token_b = _register_and_get_token(client) + + with ( + client.websocket_connect("/ws") as ws_a, + client.websocket_connect("/ws") as ws_b, + ): + _ws_auth(ws_a, token_a) + room_code = _ws_create_room(ws_a) + + _ws_auth(ws_b, token_b) + ws_b.send_json({"type": "ROOM_JOIN", "data": {"room_code": room_code}}) + # ws_a receives ROOM_STATE from ws_b joining + ws_a.receive_json() + # ws_b receives ROOM_STATE + ws_b.receive_json() + + # ws_a sends emote + ws_a.send_json({"type": "EMOTE_SEND", "data": {"emote_id": "clap"}}) + + # Both should receive EMOTE_BROADCAST + msg_a = ws_a.receive_json() + msg_b = ws_b.receive_json() + + assert msg_a["type"] == "EMOTE_BROADCAST" + assert msg_a["data"]["emote_id"] == "clap" + assert msg_b["type"] == "EMOTE_BROADCAST" + assert msg_b["data"]["emote_id"] == "clap" + # seat_index must match the sender's seat (0 = host) + assert msg_a["data"]["seat_index"] == msg_b["data"]["seat_index"] + + # ------------------------------------------------------------------ + # Rate limiting via WS + # ------------------------------------------------------------------ + + def test_emote_rate_limited_on_second_immediate_send(self, client): + token = _register_and_get_token(client) + with client.websocket_connect("/ws") as ws: + _ws_auth(ws, token) + _ws_create_room(ws) + + # First emote is allowed + ws.send_json({"type": "EMOTE_SEND", "data": {"emote_id": "fire"}}) + msg1 = ws.receive_json() + assert msg1["type"] == "EMOTE_BROADCAST" + + # Immediate second emote: rate-limited + ws.send_json({"type": "EMOTE_SEND", "data": {"emote_id": "fire"}}) + msg2 = ws.receive_json() + assert msg2["type"] == "ERROR" + assert msg2["data"]["code"] == "EMOTE_RATE_LIMITED" + + # ------------------------------------------------------------------ + # Mute + # ------------------------------------------------------------------ + + def test_emote_mute_ack_returned(self, client): + token = _register_and_get_token(client) + with client.websocket_connect("/ws") as ws: + _ws_auth(ws, token) + ws.send_json({"type": "EMOTE_MUTE", "data": {"muted": True}}) + msg = ws.receive_json() + assert msg["type"] == "EMOTE_MUTE_ACK" + assert msg["data"]["muted"] is True + + def test_emote_mute_unmute_ack(self, client): + token = _register_and_get_token(client) + with client.websocket_connect("/ws") as ws: + _ws_auth(ws, token) + ws.send_json({"type": "EMOTE_MUTE", "data": {"muted": False}}) + msg = ws.receive_json() + assert msg["type"] == "EMOTE_MUTE_ACK" + assert msg["data"]["muted"] is False + + def test_muted_member_does_not_receive_emote_broadcast(self, client): + """ + ws_b mutes emotes → when ws_a sends an emote, ws_b does not receive + EMOTE_BROADCAST. ws_a (sender) still receives it. + """ + token_a = _register_and_get_token(client) + token_b = _register_and_get_token(client) + + with ( + client.websocket_connect("/ws") as ws_a, + client.websocket_connect("/ws") as ws_b, + ): + _ws_auth(ws_a, token_a) + room_code = _ws_create_room(ws_a) + + _ws_auth(ws_b, token_b) + ws_b.send_json({"type": "ROOM_JOIN", "data": {"room_code": room_code}}) + ws_a.receive_json() # ROOM_STATE from b joining + ws_b.receive_json() # ROOM_STATE + + # ws_b mutes emotes + ws_b.send_json({"type": "EMOTE_MUTE", "data": {"muted": True}}) + ack = ws_b.receive_json() + assert ack["type"] == "EMOTE_MUTE_ACK" + + # ws_a sends emote + ws_a.send_json({"type": "EMOTE_SEND", "data": {"emote_id": "cry"}}) + + # ws_a receives its own broadcast + msg_a = ws_a.receive_json() + assert msg_a["type"] == "EMOTE_BROADCAST" + + # ws_b should not receive anything (no message queued) + # Use timeout to confirm no message arrives. + import pytest + + with pytest.raises(Exception): + # TestClient websocket receive will raise on timeout/disconnect + ws_b.receive_json(timeout=0.05) + + def test_muted_sender_still_broadcasts_to_unmuted_peers(self, client): + """ + Muting only affects *reception*. A muted connection can still send + emotes, and unmuted peers receive the broadcast. + """ + token_a = _register_and_get_token(client) + token_b = _register_and_get_token(client) + + with ( + client.websocket_connect("/ws") as ws_a, + client.websocket_connect("/ws") as ws_b, + ): + _ws_auth(ws_a, token_a) + room_code = _ws_create_room(ws_a) + + _ws_auth(ws_b, token_b) + ws_b.send_json({"type": "ROOM_JOIN", "data": {"room_code": room_code}}) + ws_a.receive_json() # ROOM_STATE + ws_b.receive_json() # ROOM_STATE + + # ws_a mutes itself (affects only its reception) + ws_a.send_json({"type": "EMOTE_MUTE", "data": {"muted": True}}) + ws_a.receive_json() # EMOTE_MUTE_ACK + + # ws_a sends emote — ws_b (unmuted) should receive + ws_a.send_json({"type": "EMOTE_SEND", "data": {"emote_id": "thinking"}}) + + # ws_b receives broadcast + msg_b = ws_b.receive_json() + assert msg_b["type"] == "EMOTE_BROADCAST" + assert msg_b["data"]["emote_id"] == "thinking" + + # ------------------------------------------------------------------ + # Catalog completeness + # ------------------------------------------------------------------ + + @pytest.mark.parametrize("emote_id", sorted(VALID_EMOTE_IDS)) + def test_every_valid_emote_sends_successfully(self, client, emote_id): + token = _register_and_get_token(client) + with client.websocket_connect("/ws") as ws: + _ws_auth(ws, token) + _ws_create_room(ws) + ws.send_json({"type": "EMOTE_SEND", "data": {"emote_id": emote_id}}) + msg = ws.receive_json() + assert msg["type"] == "EMOTE_BROADCAST", ( + f"Expected EMOTE_BROADCAST for {emote_id!r}, got {msg!r}" + ) + assert msg["data"]["emote_id"] == emote_id diff --git a/backend/tests/test_match.py b/backend/tests/test_match.py new file mode 100644 index 0000000..04699af --- /dev/null +++ b/backend/tests/test_match.py @@ -0,0 +1,909 @@ +""" +Authoritative match engine tests. + +Structure: + TestMatchService — unit tests (no WS, no HTTP) + TestMatchDtoSafety — DTO field-boundary tests + TestMatchWebSocket — WS integration tests for the full match flow +""" + +import asyncio + +import pytest +from fall_in.core.rules import RoundPhase +from fall_in.multiplayer.models import MatchCardPublic +from fall_in.net.serializers import ( + _PRIVATE_CARD_FIELDS, + private_state_to_dict, + public_state_to_dict, +) +from fastapi.testclient import TestClient + +from app.database import get_db +from app.main import app +from app.models.match import RoundSummary +from app.models.room import Room, SeatControllerType +from app.repositories.match_repo import InMemoryMatchRepo +from app.repositories.room_repo import InMemoryRoomRepo +from app.services.match_service import MatchError, MatchService +from app.services.room_service import RoomService +from app.ws.endpoint import get_match_service, get_room_service +from app.ws.handler import _continue_after_round_settlement + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def room_service(): + return RoomService(InMemoryRoomRepo()) + + +@pytest.fixture() +def match_service(): + return MatchService(InMemoryMatchRepo()) + + +@pytest.fixture() +def client(db, room_service, match_service) -> TestClient: + def override_get_db(): + yield db + + app.dependency_overrides[get_db] = override_get_db + app.dependency_overrides[get_room_service] = lambda: room_service + app.dependency_overrides[get_match_service] = lambda: match_service + with TestClient(app) as c: + yield c + app.dependency_overrides.clear() + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_full_room(room_service: RoomService, host_name: str = "Host") -> Room: + """Create a room with seat 0 = REMOTE (host), seats 1-3 = BOT.""" + room = room_service.create_room( + display_name=host_name, + connection_id="conn-host", + user_id="user-host", + ) + # start_room fills remaining seats with bots and sets phase=STARTING. + room = room_service.start_room(room.room_code, 0) + return room + + +def _register(client: TestClient, email: str, nickname: str) -> str: + resp = client.post( + "/auth/register", + json={ + "email": email, + "password": "password123", + "nickname": nickname, + }, + ) + return resp.json()["access_token"] + + +def _ws_auth(ws, token: str) -> None: + ws.send_json({"type": "WS_HELLO", "data": {}}) + ws.receive_json() # WS_WELCOME + ws.send_json({"type": "AUTH_LOGIN", "data": {"token": token}}) + ws.receive_json() # AUTH_OK + + +def _consume_until(ws, target_type: str, max_msgs: int = 20) -> dict: + """Drain messages until one with the given type is found.""" + for _ in range(max_msgs): + msg = ws.receive_json() + if msg["type"] == target_type: + return msg + raise AssertionError(f"Did not receive {target_type!r} in {max_msgs} messages") + + +def _play_remote_turn(ws) -> None: + """Play one turn in a 1-human match until TURN_RESOLVED is reached.""" + _consume_until(ws, "PHASE_SELECTING") + hand_msg = _consume_until(ws, "PRIVATE_HAND_STATE") + card_number = hand_msg["data"]["hand"][0]["number"] + ws.send_json({"type": "CARD_SELECT", "data": {"card_number": card_number}}) + _consume_until(ws, "PRIVATE_HAND_STATE") + _consume_until(ws, "TURN_RESOLVED") + + +# --------------------------------------------------------------------------- +# TestMatchService — unit tests +# --------------------------------------------------------------------------- + + +class TestMatchService: + def test_create_match_produces_four_seats(self, match_service, room_service): + room = _make_full_room(room_service) + match = match_service.create_match(room) + assert len(match.seats) == 4 + + def test_create_match_host_seat_is_remote(self, match_service, room_service): + room = _make_full_room(room_service) + match = match_service.create_match(room) + assert match.seats[0].controller_type == SeatControllerType.REMOTE + + def test_create_match_bot_seats_are_bots(self, match_service, room_service): + room = _make_full_room(room_service) + match = match_service.create_match(room) + for seat_idx in [1, 2, 3]: + assert match.seats[seat_idx].controller_type == SeatControllerType.BOT + assert match.seats[seat_idx].ai_controller is not None + + def test_create_match_bots_have_already_selected(self, match_service, room_service): + room = _make_full_room(room_service) + match = match_service.create_match(room) + # Bots auto-select at round start. + for seat_idx in [1, 2, 3]: + assert match.seats[seat_idx].player.selected_card is not None + + def test_create_match_human_has_not_selected(self, match_service, room_service): + room = _make_full_room(room_service) + match = match_service.create_match(room) + assert match.seats[0].player.selected_card is None + + def test_create_match_deals_ten_cards_per_seat(self, match_service, room_service): + room = _make_full_room(room_service) + match = match_service.create_match(room) + for seat in match.seats.values(): + assert len(seat.player.hand) == 10 + + def test_create_match_board_has_four_rows(self, match_service, room_service): + room = _make_full_room(room_service) + match = match_service.create_match(room) + rules = match.rules + assert len(rules.board.rows) == 4 + for row in rules.board.rows: + assert len(row) == 1 # one starter card per row + + def test_create_match_stored_in_repo(self, match_service, room_service): + room = _make_full_room(room_service) + match = match_service.create_match(room) + found = match_service.get_match(match.match_id) + assert found is match + + def test_create_match_indexed_by_room_code(self, match_service, room_service): + room = _make_full_room(room_service) + match = match_service.create_match(room) + found = match_service.get_match_by_room(room.room_code) + assert found is match + + def test_submit_selection_records_card(self, match_service, room_service): + room = _make_full_room(room_service) + match = match_service.create_match(room) + # Pick any card from human hand. + card = match.seats[0].player.hand[0] + match_service.submit_selection(match, 0, card.number) + assert match.seats[0].player.selected_card is not None + assert match.seats[0].player.selected_card.number == card.number + + def test_submit_selection_invalid_card_raises(self, match_service, room_service): + room = _make_full_room(room_service) + match = match_service.create_match(room) + with pytest.raises(MatchError, match="not in"): + match_service.submit_selection(match, 0, 9999) + + def test_submit_selection_bot_seat_raises(self, match_service, room_service): + room = _make_full_room(room_service) + match = match_service.create_match(room) + card = match.seats[1].player.hand[0] + with pytest.raises(MatchError, match="REMOTE"): + match_service.submit_selection(match, 1, card.number) + + def test_submit_selection_twice_raises(self, match_service, room_service): + room = _make_full_room(room_service) + match = match_service.create_match(room) + card = match.seats[0].player.hand[0] + match_service.submit_selection(match, 0, card.number) + with pytest.raises(MatchError, match="already selected"): + match_service.submit_selection(match, 0, card.number) + + def test_not_all_selected_before_human_submits(self, match_service, room_service): + room = _make_full_room(room_service) + match = match_service.create_match(room) + assert not match_service.all_selected(match) + + def test_all_selected_after_human_submits(self, match_service, room_service): + room = _make_full_room(room_service) + match = match_service.create_match(room) + card = match.seats[0].player.hand[0] + match_service.submit_selection(match, 0, card.number) + assert match_service.all_selected(match) + + def test_resolve_turn_returns_four_steps(self, match_service, room_service): + room = _make_full_room(room_service) + match = match_service.create_match(room) + card = match.seats[0].player.hand[0] + match_service.submit_selection(match, 0, card.number) + steps = match_service.resolve_turn(match) + assert len(steps) == 4 + + def test_resolve_turn_seat_indices_present(self, match_service, room_service): + room = _make_full_room(room_service) + match = match_service.create_match(room) + card = match.seats[0].player.hand[0] + match_service.submit_selection(match, 0, card.number) + steps = match_service.resolve_turn(match) + seats_seen = {s.seat_index for s in steps} + assert seats_seen == {0, 1, 2, 3} + + def test_resolve_turn_cards_removed_from_hands(self, match_service, room_service): + room = _make_full_room(room_service) + match = match_service.create_match(room) + card = match.seats[0].player.hand[0] + match_service.submit_selection(match, 0, card.number) + match_service.resolve_turn(match) + # Each player's hand should now have 9 cards. + for seat in match.seats.values(): + if not seat.player.is_eliminated: + assert len(seat.player.hand) == 9 + + def test_resolve_turn_updates_last_turn_steps(self, match_service, room_service): + room = _make_full_room(room_service) + match = match_service.create_match(room) + card = match.seats[0].player.hand[0] + match_service.submit_selection(match, 0, card.number) + steps = match_service.resolve_turn(match) + assert match.last_turn_steps is steps + + def test_finalize_round_tracks_scores(self, match_service, room_service): + room = _make_full_room(room_service) + match = match_service.create_match(room) + rules = match.rules + # Play all 10 turns to reach ROUND_END. + for turn in range(10): + card = match.seats[0].player.hand[0] + match_service.submit_selection(match, 0, card.number) + match_service.resolve_turn(match) + # Re-select bots for the next turn only if round is not over yet. + if not rules.is_round_over(): + for seat in match.seats.values(): + if seat.ai_controller and not seat.player.is_eliminated: + seat.ai_controller.select_card(rules.board) + assert rules.is_round_over() + summary = match_service.finalize_round(match) + assert summary.round_number == 1 + # All scores must be non-negative integers. + for seat_idx in range(4): + assert isinstance(summary.total_scores.get(seat_idx, 0), int) + + def test_delete_match_removes_from_repo(self, match_service, room_service): + room = _make_full_room(room_service) + match = match_service.create_match(room) + match_service.delete_match(match.match_id) + assert match_service.get_match(match.match_id) is None + assert match_service.get_match_by_room(room.room_code) is None + + +# --------------------------------------------------------------------------- +# TestMatchDtoSafety — DTO boundary tests +# --------------------------------------------------------------------------- + + +class TestMatchDtoSafety: + def test_public_state_no_private_card_fields(self, match_service, room_service): + room = _make_full_room(room_service) + match = match_service.create_match(room) + pub = match_service.build_public_state(match) + d = public_state_to_dict(pub) + for row in d["board_rows"]: + for card_dict in row: + for field in _PRIVATE_CARD_FIELDS: + assert field not in card_dict, ( + f"Private field {field!r} leaked into board row card" + ) + + def test_private_state_no_private_card_fields(self, match_service, room_service): + room = _make_full_room(room_service) + match = match_service.create_match(room) + priv = match_service.build_private_state(match, 0) + d = private_state_to_dict(priv) + for card_dict in d["hand"]: + for field in _PRIVATE_CARD_FIELDS: + assert field not in card_dict, f"Private field {field!r} leaked into hand card" + + def test_hand_card_owner_seat_is_viewer_seat(self, match_service, room_service): + room = _make_full_room(room_service) + match = match_service.create_match(room) + for seat_idx in range(4): + priv = match_service.build_private_state(match, seat_idx) + for card in priv.hand: + assert card.owner_seat == seat_idx, ( + f"Hand card owner_seat should be {seat_idx}, got {card.owner_seat}" + ) + + def test_public_state_user_id_not_in_wire_dict(self, match_service, room_service): + room = _make_full_room(room_service) + match = match_service.create_match(room) + pub = match_service.build_public_state(match) + d = public_state_to_dict(pub) + for seat_dict in d["seats"]: + assert "user_id" not in seat_dict, "user_id must not appear in wire-level seat dict" + + def test_public_state_has_four_seats(self, match_service, room_service): + room = _make_full_room(room_service) + match = match_service.create_match(room) + pub = match_service.build_public_state(match) + assert len(pub.seats) == 4 + + def test_public_state_phase_is_selecting_at_round_start(self, match_service, room_service): + room = _make_full_room(room_service) + match = match_service.create_match(room) + pub = match_service.build_public_state(match) + assert pub.phase == RoundPhase.SELECTING.name + + def test_private_state_has_selected_false_at_start(self, match_service, room_service): + room = _make_full_room(room_service) + match = match_service.create_match(room) + priv = match_service.build_private_state(match, 0) + assert priv.has_selected is False + + def test_private_state_has_selected_true_after_selection(self, match_service, room_service): + room = _make_full_room(room_service) + match = match_service.create_match(room) + card = match.seats[0].player.hand[0] + match_service.submit_selection(match, 0, card.number) + priv = match_service.build_private_state(match, 0) + assert priv.has_selected is True + + def test_board_row_owners_initialised_with_starters(self, match_service, room_service): + room = _make_full_room(room_service) + match = match_service.create_match(room) + for owners in match.board_row_owners: + assert owners == [-1], "Starter cards must have owner_seat -1" + + def test_board_cards_have_public_number_and_danger(self, match_service, room_service): + room = _make_full_room(room_service) + match = match_service.create_match(room) + pub = match_service.build_public_state(match) + for row in pub.board_rows: + for card in row: + assert isinstance(card, MatchCardPublic) + assert 1 <= card.number <= 104 + assert 1 <= card.danger <= 7 + + +# --------------------------------------------------------------------------- +# TestMatchWebSocket — integration tests via /ws +# --------------------------------------------------------------------------- + + +class TestMatchWebSocket: + def test_room_start_broadcasts_match_start(self, client): + token = _register(client, "ms_host@example.com", "MSHost") + with client.websocket_connect("/ws") as ws: + _ws_auth(ws, token) + ws.send_json({"type": "ROOM_CREATE", "data": {}}) + _consume_until(ws, "ROOM_STATE") + ws.send_json({"type": "ROOM_START", "data": {}}) + _consume_until(ws, "ROOM_STATE") # STARTING lobby state + msg = _consume_until(ws, "MATCH_START") + assert msg["data"]["match_id"] + + def test_room_start_broadcasts_phase_selecting(self, client): + token = _register(client, "ms_sel@example.com", "MSSel") + with client.websocket_connect("/ws") as ws: + _ws_auth(ws, token) + ws.send_json({"type": "ROOM_CREATE", "data": {}}) + _consume_until(ws, "ROOM_STATE") + ws.send_json({"type": "ROOM_START", "data": {}}) + _consume_until(ws, "MATCH_START") + msg = _consume_until(ws, "PHASE_SELECTING") + assert msg["data"]["round_number"] == 1 + assert msg["data"]["phase"] == "SELECTING" + + def test_room_start_unicasts_private_hand_state(self, client): + token = _register(client, "ms_hand@example.com", "MSHand") + with client.websocket_connect("/ws") as ws: + _ws_auth(ws, token) + ws.send_json({"type": "ROOM_CREATE", "data": {}}) + _consume_until(ws, "ROOM_STATE") + ws.send_json({"type": "ROOM_START", "data": {}}) + _consume_until(ws, "MATCH_START") + _consume_until(ws, "PHASE_SELECTING") + msg = _consume_until(ws, "PRIVATE_HAND_STATE") + assert len(msg["data"]["hand"]) == 10 + assert msg["data"]["has_selected"] is False + + def test_card_select_before_match_returns_error(self, client): + token = _register(client, "ms_early@example.com", "MSEarly") + with client.websocket_connect("/ws") as ws: + _ws_auth(ws, token) + ws.send_json({"type": "CARD_SELECT", "data": {"card_number": 1}}) + msg = ws.receive_json() + assert msg["type"] == "ERROR" + assert msg["data"]["code"] == "NOT_IN_MATCH" + + def test_card_select_invalid_card_returns_error(self, client): + token = _register(client, "ms_inv@example.com", "MSInv") + with client.websocket_connect("/ws") as ws: + _ws_auth(ws, token) + ws.send_json({"type": "ROOM_CREATE", "data": {}}) + _consume_until(ws, "ROOM_STATE") + ws.send_json({"type": "ROOM_START", "data": {}}) + _consume_until(ws, "MATCH_START") + _consume_until(ws, "PHASE_SELECTING") + _consume_until(ws, "PRIVATE_HAND_STATE") + ws.send_json({"type": "CARD_SELECT", "data": {"card_number": 9999}}) + msg = _consume_until(ws, "ERROR") + assert msg["data"]["code"] == "MATCH_ERROR" + + def test_card_select_valid_card_acks_with_private_hand(self, client): + token = _register(client, "ms_ack@example.com", "MSAck") + with client.websocket_connect("/ws") as ws: + _ws_auth(ws, token) + ws.send_json({"type": "ROOM_CREATE", "data": {}}) + _consume_until(ws, "ROOM_STATE") + ws.send_json({"type": "ROOM_START", "data": {}}) + _consume_until(ws, "MATCH_START") + _consume_until(ws, "PHASE_SELECTING") + private_msg = _consume_until(ws, "PRIVATE_HAND_STATE") + + card_number = private_msg["data"]["hand"][0]["number"] + ws.send_json({"type": "CARD_SELECT", "data": {"card_number": card_number}}) + + ack = _consume_until(ws, "PRIVATE_HAND_STATE") + assert ack["data"]["has_selected"] is True + + def test_card_select_triggers_full_turn_resolution(self, client): + """After human selects (all 3 bots already selected), full turn runs.""" + token = _register(client, "ms_turn@example.com", "MSTurn") + with client.websocket_connect("/ws") as ws: + _ws_auth(ws, token) + ws.send_json({"type": "ROOM_CREATE", "data": {}}) + _consume_until(ws, "ROOM_STATE") + ws.send_json({"type": "ROOM_START", "data": {}}) + _consume_until(ws, "MATCH_START") + _consume_until(ws, "PHASE_SELECTING") + private_msg = _consume_until(ws, "PRIVATE_HAND_STATE") + + card_number = private_msg["data"]["hand"][0]["number"] + ws.send_json({"type": "CARD_SELECT", "data": {"card_number": card_number}}) + + # Consume acknowledgement + _consume_until(ws, "PRIVATE_HAND_STATE") + # Turn reveal sequence + _consume_until(ws, "TURN_REVEAL_START") + + def test_turn_resolution_broadcasts_four_reveal_steps(self, client): + token = _register(client, "ms_steps@example.com", "MSSteps") + with client.websocket_connect("/ws") as ws: + _ws_auth(ws, token) + ws.send_json({"type": "ROOM_CREATE", "data": {}}) + _consume_until(ws, "ROOM_STATE") + ws.send_json({"type": "ROOM_START", "data": {}}) + _consume_until(ws, "MATCH_START") + _consume_until(ws, "PHASE_SELECTING") + private_msg = _consume_until(ws, "PRIVATE_HAND_STATE") + + card_number = private_msg["data"]["hand"][0]["number"] + ws.send_json({"type": "CARD_SELECT", "data": {"card_number": card_number}}) + + _consume_until(ws, "PRIVATE_HAND_STATE") # ack + _consume_until(ws, "TURN_REVEAL_START") + + # Expect 4 TURN_REVEAL_STEP messages (one per seat). + steps = [] + for _ in range(4): + msg = _consume_until(ws, "TURN_REVEAL_STEP") + steps.append(msg) + assert len(steps) == 4 + # Each step has required fields. + for step in steps: + d = step["data"] + assert "seat_index" in d + assert "card_number" in d + assert "row_index" in d + + def test_turn_resolution_ends_with_turn_resolved(self, client): + token = _register(client, "ms_resolved@example.com", "MSResolved") + with client.websocket_connect("/ws") as ws: + _ws_auth(ws, token) + ws.send_json({"type": "ROOM_CREATE", "data": {}}) + _consume_until(ws, "ROOM_STATE") + ws.send_json({"type": "ROOM_START", "data": {}}) + _consume_until(ws, "MATCH_START") + _consume_until(ws, "PHASE_SELECTING") + private_msg = _consume_until(ws, "PRIVATE_HAND_STATE") + + card_number = private_msg["data"]["hand"][0]["number"] + ws.send_json({"type": "CARD_SELECT", "data": {"card_number": card_number}}) + _consume_until(ws, "PRIVATE_HAND_STATE") # ack + _consume_until(ws, "TURN_REVEAL_START") + for _ in range(4): + _consume_until(ws, "TURN_REVEAL_STEP") + _consume_until(ws, "TURN_RESOLVED") + + def test_after_turn_resolved_returns_to_selecting(self, client): + """After 1 turn, there are 9 more in round 1 — next message is PHASE_SELECTING.""" + token = _register(client, "ms_resel@example.com", "MSResel") + with client.websocket_connect("/ws") as ws: + _ws_auth(ws, token) + ws.send_json({"type": "ROOM_CREATE", "data": {}}) + _consume_until(ws, "ROOM_STATE") + ws.send_json({"type": "ROOM_START", "data": {}}) + _consume_until(ws, "MATCH_START") + _consume_until(ws, "PHASE_SELECTING") + private_msg = _consume_until(ws, "PRIVATE_HAND_STATE") + + card_number = private_msg["data"]["hand"][0]["number"] + ws.send_json({"type": "CARD_SELECT", "data": {"card_number": card_number}}) + _consume_until(ws, "PRIVATE_HAND_STATE") # ack + _consume_until(ws, "TURN_REVEAL_START") + for _ in range(4): + _consume_until(ws, "TURN_REVEAL_STEP") + _consume_until(ws, "TURN_RESOLVED") + + # After first turn there are 9 turns left — should re-enter SELECTING. + msg = _consume_until(ws, "PHASE_SELECTING") + assert msg["data"]["round_number"] == 1 + + def test_card_select_without_missing_field_returns_error(self, client): + token = _register(client, "ms_nofield@example.com", "MSNoField") + with client.websocket_connect("/ws") as ws: + _ws_auth(ws, token) + ws.send_json({"type": "ROOM_CREATE", "data": {}}) + _consume_until(ws, "ROOM_STATE") + ws.send_json({"type": "ROOM_START", "data": {}}) + _consume_until(ws, "MATCH_START") + _consume_until(ws, "PHASE_SELECTING") + _consume_until(ws, "PRIVATE_HAND_STATE") + + # Send CARD_SELECT without card_number. + ws.send_json({"type": "CARD_SELECT", "data": {}}) + msg = _consume_until(ws, "ERROR") + assert msg["data"]["code"] == "MISSING_CARD" + + def test_phase_selecting_board_has_four_rows(self, client): + token = _register(client, "ms_board@example.com", "MSBoard") + with client.websocket_connect("/ws") as ws: + _ws_auth(ws, token) + ws.send_json({"type": "ROOM_CREATE", "data": {}}) + _consume_until(ws, "ROOM_STATE") + ws.send_json({"type": "ROOM_START", "data": {}}) + _consume_until(ws, "MATCH_START") + phase_msg = _consume_until(ws, "PHASE_SELECTING") + assert len(phase_msg["data"]["board_rows"]) == 4 + + def test_phase_selecting_no_private_fields_in_board(self, client): + token = _register(client, "ms_priv@example.com", "MSPriv") + with client.websocket_connect("/ws") as ws: + _ws_auth(ws, token) + ws.send_json({"type": "ROOM_CREATE", "data": {}}) + _consume_until(ws, "ROOM_STATE") + ws.send_json({"type": "ROOM_START", "data": {}}) + _consume_until(ws, "MATCH_START") + phase_msg = _consume_until(ws, "PHASE_SELECTING") + for row in phase_msg["data"]["board_rows"]: + for card_dict in row: + for field in _PRIVATE_CARD_FIELDS: + assert field not in card_dict + + def test_round_result_waits_for_round_ready_before_next_round(self, client, match_service): + token = _register(client, "ms_round_ready@example.com", "MSRoundReady") + with client.websocket_connect("/ws") as ws: + _ws_auth(ws, token) + ws.send_json({"type": "ROOM_CREATE", "data": {}}) + room_msg = _consume_until(ws, "ROOM_STATE") + room_code = room_msg["data"]["room_code"] + ws.send_json({"type": "ROOM_START", "data": {}}) + _consume_until(ws, "MATCH_START") + + for _ in range(9): + _play_remote_turn(ws) + + _consume_until(ws, "PHASE_SELECTING") + hand_msg = _consume_until(ws, "PRIVATE_HAND_STATE") + card_number = hand_msg["data"]["hand"][0]["number"] + ws.send_json({"type": "CARD_SELECT", "data": {"card_number": card_number}}) + _consume_until(ws, "PRIVATE_HAND_STATE") + _consume_until(ws, "TURN_RESOLVED") + round_result = _consume_until(ws, "ROUND_RESULT") + + assert round_result["data"]["round_number"] == 1 + match = match_service.get_match_by_room(room_code) + assert match is not None + assert match_service.has_round_settlement_pending(match) + + ws.send_json({"type": "ROUND_READY", "data": {}}) + next_phase = _consume_until(ws, "PHASE_SELECTING") + assert next_phase["data"]["round_number"] == 2 + assert not match_service.has_round_settlement_pending(match) + + +# --------------------------------------------------------------------------- +# Regression tests for code-review fixes +# --------------------------------------------------------------------------- + + +class TestRegressionFixes: + """ + Targeted tests for the four issues found in the PR-04 code review. + + Fix 1 – non-host humans can submit CARD_SELECT (room_code lookup). + Fix 2 – bots are re-selected after each turn (no stall on turn 2+). + Fix 3 – multiplayer game-end uses survivor count, not players[0]. + Fix 4 – resolve_turn_stepwise produces incremental board snapshots. + """ + + # ------------------------------------------------------------------ + # Fix 1: non-host human CARD_SELECT + # ------------------------------------------------------------------ + + def test_non_host_human_can_submit_card_select(self, client): + """ + A player who joined a room but did NOT start it (no match_id on + their session) must still be able to submit CARD_SELECT. + """ + token_host = _register(client, "fix1_host@example.com", "Fix1Host") + token_guest = _register(client, "fix1_guest@example.com", "Fix1Guest") + + with client.websocket_connect("/ws") as ws_host: + with client.websocket_connect("/ws") as ws_guest: + _ws_auth(ws_host, token_host) + _ws_auth(ws_guest, token_guest) + + # Host creates room; guest joins before start. + ws_host.send_json({"type": "ROOM_CREATE", "data": {}}) + room_msg = _consume_until(ws_host, "ROOM_STATE") + room_code = room_msg["data"]["room_code"] + + ws_guest.send_json({"type": "ROOM_JOIN", "data": {"room_code": room_code}}) + _consume_until(ws_guest, "ROOM_STATE") # join broadcast + + # Guest must be ready before host can start. + ws_guest.send_json({"type": "READY_SET", "data": {"is_ready": True}}) + _consume_until(ws_guest, "ROOM_STATE") # ready broadcast + + # Host starts — fills seats 2-3 with bots. + ws_host.send_json({"type": "ROOM_START", "data": {}}) + _consume_until(ws_guest, "MATCH_START") + _consume_until(ws_guest, "PHASE_SELECTING") + hand_msg = _consume_until(ws_guest, "PRIVATE_HAND_STATE") + + card_number = hand_msg["data"]["hand"][0]["number"] + ws_guest.send_json( + { + "type": "CARD_SELECT", + "data": {"card_number": card_number}, + } + ) + # Must NOT return NOT_IN_MATCH; must ack with updated hand state. + ack = _consume_until(ws_guest, "PRIVATE_HAND_STATE") + assert ack["data"]["has_selected"] is True + + # ------------------------------------------------------------------ + # Fix 2: bot reselection after each turn + # ------------------------------------------------------------------ + + def test_reselect_bots_restores_bot_selections(self, match_service, room_service): + """After resolve_turn clears selected_card, reselect_bots gives bots new cards.""" + room = _make_full_room(room_service) + match = match_service.create_match(room) + card = match.seats[0].player.hand[0] + match_service.submit_selection(match, 0, card.number) + match_service.resolve_turn(match) + # Bots' selected_card is now None after turn resolution. + for seat_idx in [1, 2, 3]: + assert match.seats[seat_idx].player.selected_card is None + # After reselect_bots they should have a card chosen again. + match_service.reselect_bots(match) + for seat_idx in [1, 2, 3]: + assert match.seats[seat_idx].player.selected_card is not None + + def test_second_turn_can_resolve(self, client): + """ + Two consecutive turns must both resolve without stalling. + (Regression: bots were not re-selected before turn 2.) + """ + token = _register(client, "fix2_t2@example.com", "Fix2T2") + with client.websocket_connect("/ws") as ws: + _ws_auth(ws, token) + ws.send_json({"type": "ROOM_CREATE", "data": {}}) + _consume_until(ws, "ROOM_STATE") + ws.send_json({"type": "ROOM_START", "data": {}}) + _consume_until(ws, "MATCH_START") + + # --- Turn 1 --- + _consume_until(ws, "PHASE_SELECTING") + hand_msg = _consume_until(ws, "PRIVATE_HAND_STATE") + card_number = hand_msg["data"]["hand"][0]["number"] + ws.send_json({"type": "CARD_SELECT", "data": {"card_number": card_number}}) + _consume_until(ws, "TURN_RESOLVED") + + # --- Turn 2 --- + # Server must have re-selected bots before broadcasting PHASE_SELECTING. + phase2 = _consume_until(ws, "PHASE_SELECTING") + assert phase2["data"]["round_number"] == 1 # still round 1 + + hand2_msg = _consume_until(ws, "PRIVATE_HAND_STATE") + card2_number = hand2_msg["data"]["hand"][0]["number"] + ws.send_json({"type": "CARD_SELECT", "data": {"card_number": card2_number}}) + # Must trigger another turn — not stall waiting for bots. + _consume_until(ws, "TURN_RESOLVED") + + # ------------------------------------------------------------------ + # Fix 3: multiplayer game-end logic + # ------------------------------------------------------------------ + + def test_multiplayer_game_does_not_end_when_seat0_eliminated(self, match_service, room_service): + """ + In multiplayer mode (human_seat=None), eliminating seat 0 alone must + NOT end the game while other players remain active. + """ + room = _make_full_room(room_service) + match = match_service.create_match(room) + rules = match.rules + + # Forcibly eliminate seat 0 without touching the others. + seat0_player = match.seats[0].player + seat0_player.penalty_score = 200 + seat0_player.check_elimination(threshold=66) + assert seat0_player.is_eliminated + + # Directly invoke the game-end check. + rules._check_game_end() + + # Three other players are still active → game must NOT be over. + assert not rules.game_over + + def test_multiplayer_game_ends_when_one_survivor_remains(self, match_service, room_service): + """Game ends when only 1 active player survives (multiplayer mode).""" + room = _make_full_room(room_service) + match = match_service.create_match(room) + rules = match.rules + + # Eliminate seats 0, 1, 2 — leave only seat 3 active. + for seat_idx in [0, 1, 2]: + p = match.seats[seat_idx].player + p.penalty_score = 200 + p.check_elimination(threshold=66) + + rules._check_game_end() + + assert rules.game_over + assert rules.winner is match.seats[3].player + + # ------------------------------------------------------------------ + # Fix 4: stepwise board snapshots + # ------------------------------------------------------------------ + + def test_resolve_turn_stepwise_returns_four_entries(self, match_service, room_service): + room = _make_full_room(room_service) + match = match_service.create_match(room) + card = match.seats[0].player.hand[0] + match_service.submit_selection(match, 0, card.number) + results = match_service.resolve_turn_stepwise(match) + assert len(results) == 4 + + def test_resolve_turn_stepwise_snapshots_grow_incrementally(self, match_service, room_service): + """Each snapshot's played_cards_this_turn must be one longer than the previous.""" + room = _make_full_room(room_service) + match = match_service.create_match(room) + card = match.seats[0].player.hand[0] + match_service.submit_selection(match, 0, card.number) + results = match_service.resolve_turn_stepwise(match) + for idx, (step, snapshot) in enumerate(results): + assert len(snapshot.played_cards_this_turn) == idx + 1, ( + f"Step {idx}: expected {idx + 1} played cards, " + f"got {len(snapshot.played_cards_this_turn)}" + ) + + def test_resolve_turn_stepwise_last_step_matches_resolve_turn( + self, match_service, room_service + ): + """The final last_turn_steps after stepwise resolution equals the full step list.""" + room = _make_full_room(room_service) + match = match_service.create_match(room) + card = match.seats[0].player.hand[0] + match_service.submit_selection(match, 0, card.number) + results = match_service.resolve_turn_stepwise(match) + steps_from_stepwise = [step for step, _ in results] + assert [s.seat_index for s in match.last_turn_steps] == [ + s.seat_index for s in steps_from_stepwise + ] + + +class TestRoundSettlementFlow: + def test_continue_after_round_settlement_broadcasts_match_result_for_game_over( + self, match_service, room_service + ): + room = _make_full_room(room_service) + match = match_service.create_match(room) + summary = RoundSummary( + round_number=3, + round_danger={0: 2, 1: 0, 2: 0, 3: 0}, + total_scores={0: 12, 1: 40, 2: 66, 3: 66}, + eliminated_seats=[2, 3], + game_over=True, + winner_seat=0, + ) + match_service.begin_round_settlement(match, summary) + + class _FakeManager: + def __init__(self) -> None: + self.broadcasts: list[tuple[str, dict]] = [] + + async def broadcast_to_room(self, room_code: str, payload: dict) -> None: + self.broadcasts.append((room_code, payload)) + + class _FakePresence: + def __init__(self) -> None: + self.cancelled: list[str] = [] + self.revoked: list[str] = [] + + def cancel_round_settlement_timeout(self, match_id: str) -> None: + self.cancelled.append(match_id) + + def revoke_match_tokens(self, match_id: str) -> None: + self.revoked.append(match_id) + + manager = _FakeManager() + presence = _FakePresence() + fake_room_service = object() + + asyncio.run( + _continue_after_round_settlement( + room.room_code, + match, + manager, + match_service, + fake_room_service, + presence, + ) + ) + + assert manager.broadcasts == [ + ( + room.room_code, + { + "type": "MATCH_RESULT", + "data": { + "match_id": match.match_id, + "winner_seat": 0, + "final_scores": {0: 12, 1: 40, 2: 66, 3: 66}, + "rewards": {0: 130, 1: 45, 2: 45, 3: 45}, + }, + }, + ) + ] + assert presence.cancelled == [match.match_id] + assert presence.revoked == [match.match_id] + assert match_service.get_match(match.match_id) is None + + def test_rewards_use_per_seat_elimination_round(self, match_service, room_service): + """Players eliminated in different rounds receive different rewards.""" + room = _make_full_room(room_service) + match = match_service.create_match(room) + + # Simulate seat 3 eliminated in round 2, seats 1 & 2 in round 4. + match.seat_eliminated_round[3] = 2 + match.seat_eliminated_round[1] = 4 + match.seat_eliminated_round[2] = 4 + + summary = RoundSummary( + round_number=4, + round_danger={0: 5, 1: 20, 2: 20, 3: 0}, + total_scores={0: 30, 1: 66, 2: 70, 3: 80}, + eliminated_seats=[1, 2, 3], + game_over=True, + winner_seat=0, + ) + + from app.ws.handler import _calculate_match_rewards + + rewards = _calculate_match_rewards(match, summary) + + # Winner (seat 0): 100 + 4*10 = 140 + assert rewards[0] == 140 + # Losers eliminated in round 4 (seats 1, 2): 30 + 4*5 = 50 + assert rewards[1] == 50 + assert rewards[2] == 50 + # Loser eliminated in round 2 (seat 3): 30 + 2*5 = 40 + assert rewards[3] == 40 diff --git a/backend/tests/test_matchmaking.py b/backend/tests/test_matchmaking.py new file mode 100644 index 0000000..31098c3 --- /dev/null +++ b/backend/tests/test_matchmaking.py @@ -0,0 +1,646 @@ +""" +Tests for PR-06: Quick-match queue, MMR, and matchmaking service. + +Coverage: + - QueueRepo: add / remove / get_bucket / bucket_size / get_entry + - MatchmakingService: join_queue, leave_queue, try_form_match (immediate 4), + fill timer AI fallback, build_quick_match_room, bucket assignment + - MmrService: get_mmr defaults, bucket mapping, compute_deltas, persist_updates + (eligible 4-human, eligible 3-human, ineligible ≤2-human, guest-only match) + - WS integration: QUICK_MATCH_JOIN (bucket grouping, immediate match, + AI-fill path), QUICK_MATCH_LEAVE, queue cleanup on disconnect +""" + +import time +import uuid + +from app.config import settings +from app.models.db import AccountType, Profile, User, UserStatus +from app.models.match import ActiveMatch, MatchSeat +from app.models.room import RoomPhase, SeatControllerType +from app.repositories.queue_repo import InMemoryQueueRepo, QueueEntry +from app.services.matchmaking_service import MatchmakingService +from app.services.mmr_service import MmrService, mmr_to_bucket + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_entry( + connection_id: str = None, + mmr: int = 1000, + bucket: str = "silver", + account_type: str = "registered", + user_id: str = None, +) -> QueueEntry: + return QueueEntry( + connection_id=connection_id or str(uuid.uuid4()), + user_id=user_id or str(uuid.uuid4()), + display_name="Player", + account_type=account_type, + mmr=mmr, + bucket=bucket, + joined_at=time.time(), + ) + + +def _make_matchmaking() -> MatchmakingService: + return MatchmakingService(InMemoryQueueRepo()) + + +def _make_user_with_profile(db, mmr: int = None) -> User: + user = User( + id=str(uuid.uuid4()), + account_type=AccountType.REGISTERED, + status=UserStatus.ACTIVE, + ) + db.add(user) + db.flush() + profile = Profile( + user_id=user.id, + nickname="TestPlayer", + hidden_mmr=mmr, + ) + db.add(profile) + db.commit() + db.refresh(user) + return user + + +# =========================================================================== +# TestQueueRepo +# =========================================================================== + + +class TestQueueRepo: + def test_add_and_get_bucket(self): + repo = InMemoryQueueRepo() + e = _make_entry(bucket="silver") + repo.add(e) + result = repo.get_bucket("silver") + assert len(result) == 1 + assert result[0].connection_id == e.connection_id + + def test_bucket_size(self): + repo = InMemoryQueueRepo() + repo.add(_make_entry(bucket="gold")) + repo.add(_make_entry(bucket="gold")) + assert repo.bucket_size("gold") == 2 + assert repo.bucket_size("silver") == 0 + + def test_remove_returns_entry(self): + repo = InMemoryQueueRepo() + e = _make_entry() + repo.add(e) + removed = repo.remove(e.connection_id) + assert removed is not None + assert removed.connection_id == e.connection_id + assert repo.bucket_size(e.bucket) == 0 + + def test_remove_unknown_returns_none(self): + repo = InMemoryQueueRepo() + assert repo.remove("no-such-id") is None + + def test_get_entry(self): + repo = InMemoryQueueRepo() + e = _make_entry() + repo.add(e) + found = repo.get_entry(e.connection_id) + assert found is not None + assert found.mmr == e.mmr + + def test_get_bucket_sorted_by_joined_at(self): + repo = InMemoryQueueRepo() + e1 = _make_entry(bucket="bronze") + e1.joined_at = 100.0 + e2 = _make_entry(bucket="bronze") + e2.joined_at = 50.0 # earlier + repo.add(e1) + repo.add(e2) + bucket = repo.get_bucket("bronze") + assert bucket[0].connection_id == e2.connection_id # oldest first + + def test_clear_empties_all_state(self): + repo = InMemoryQueueRepo() + repo.add(_make_entry(bucket="silver")) + repo.clear() + assert repo.bucket_size("silver") == 0 + + +# =========================================================================== +# TestMmrBuckets +# =========================================================================== + + +class TestMmrBuckets: + def test_bronze_low(self): + assert mmr_to_bucket(0) == "bronze" + assert mmr_to_bucket(799) == "bronze" + + def test_silver(self): + assert mmr_to_bucket(800) == "silver" + assert mmr_to_bucket(1199) == "silver" + + def test_gold(self): + assert mmr_to_bucket(1200) == "gold" + assert mmr_to_bucket(1599) == "gold" + + def test_diamond(self): + assert mmr_to_bucket(1600) == "diamond" + assert mmr_to_bucket(9999) == "diamond" + + def test_default_mmr_is_silver(self): + assert mmr_to_bucket(settings.DEFAULT_MMR) == "silver" + + +# =========================================================================== +# TestMmrService +# =========================================================================== + + +class TestMmrService: + def setup_method(self): + self.svc = MmrService() + + def test_get_mmr_returns_default_if_no_profile_mmr(self, db): + user = _make_user_with_profile(db, mmr=None) + assert self.svc.get_mmr(db, user.id) == settings.DEFAULT_MMR + + def test_get_mmr_returns_stored_value(self, db): + user = _make_user_with_profile(db, mmr=1400) + assert self.svc.get_mmr(db, user.id) == 1400 + + def test_get_mmr_for_session_guest_always_default(self, db): + result = self.svc.get_mmr_for_session(db, user_id=None, account_type="guest") + assert result == settings.DEFAULT_MMR + + def test_get_mmr_for_session_registered(self, db): + user = _make_user_with_profile(db, mmr=900) + result = self.svc.get_mmr_for_session(db, user_id=user.id, account_type="registered") + assert result == 900 + + def test_compute_deltas_zero_sum(self): + deltas = self.svc.compute_deltas( + winner_seat=0, + human_seat_indices=[0, 1, 2, 3], + k_factor=32, + ) + assert deltas[0] == 32 # winner + assert deltas[1] < 0 + assert deltas[2] < 0 + assert deltas[3] < 0 + # Zero-sum (within integer rounding) + total = sum(deltas.values()) + assert abs(total) <= 2 # small rounding acceptable + + def test_compute_deltas_winner_not_in_humans(self): + # Bot won (shouldn't happen in ranked, but guard exists) + deltas = self.svc.compute_deltas( + winner_seat=3, + human_seat_indices=[0, 1, 2], + k_factor=32, + ) + assert all(v == 0 for v in deltas.values()) + + def test_persist_updates_full_4_human(self, db): + users = [_make_user_with_profile(db, mmr=1000) for _ in range(4)] + match = _make_mock_match_all_human(users, winner_seat=0, is_ranked=True) + self.svc.persist_updates(db, match, winner_seat=0) + # Winner should have gained MMR. + for u in users: + db.refresh(u.profile) + winner_profile = users[0].profile + assert winner_profile.hidden_mmr > 1000 + # Losers should have lost MMR. + for u in users[1:]: + assert u.profile.hidden_mmr < 1000 + + def test_persist_updates_3_human_half_k(self, db): + users = [_make_user_with_profile(db, mmr=1000) for _ in range(3)] + match = _make_mock_match_3_human(users, winner_seat=0, is_ranked=True) + self.svc.persist_updates(db, match, winner_seat=0) + for u in users: + db.refresh(u.profile) + k_half = settings.MMR_K_FACTOR // 2 + assert users[0].profile.hidden_mmr == 1000 + k_half + + def test_persist_updates_2_human_no_update(self, db): + users = [_make_user_with_profile(db, mmr=1000) for _ in range(2)] + match = _make_mock_match_2_human(users, winner_seat=0, is_ranked=True) + self.svc.persist_updates(db, match, winner_seat=0) + for u in users: + db.refresh(u.profile) + assert users[0].profile.hidden_mmr == 1000 + assert users[1].profile.hidden_mmr == 1000 + + def test_persist_updates_not_ranked_no_op(self, db): + users = [_make_user_with_profile(db, mmr=1000) for _ in range(4)] + match = _make_mock_match_all_human(users, winner_seat=0, is_ranked=False) + self.svc.persist_updates(db, match, winner_seat=0) + for u in users: + db.refresh(u.profile) + assert all(u.profile.hidden_mmr == 1000 for u in users) + + def test_persist_updates_guest_seats_not_stored(self, db): + """Guest seat deltas are computed but not written to DB.""" + # 3 registered + 1 guest; all human, ranked + reg_users = [_make_user_with_profile(db, mmr=1000) for _ in range(3)] + match = _make_mock_match_with_guest(reg_users, guest_seat=3, winner_seat=0, is_ranked=True) + self.svc.persist_updates(db, match, winner_seat=0) + for u in reg_users: + db.refresh(u.profile) + # Registered winner gained MMR + assert reg_users[0].profile.hidden_mmr > 1000 + + +# =========================================================================== +# TestMatchmakingService +# =========================================================================== + + +class TestMatchmakingService: + def setup_method(self): + self.svc = _make_matchmaking() + + def test_join_queue_assigns_bucket(self): + entry = self.svc.join_queue( + connection_id="c1", + user_id="u1", + display_name="P", + account_type="registered", + mmr=1000, + ) + assert entry.bucket == "silver" + assert self.svc.bucket_size("silver") == 1 + + def test_join_queue_guest_gets_default_bucket(self): + entry = self.svc.join_queue( + connection_id="c1", + user_id=None, + display_name="Guest", + account_type="guest", + mmr=settings.DEFAULT_MMR, + ) + assert entry.bucket == mmr_to_bucket(settings.DEFAULT_MMR) + + def test_leave_queue(self): + entry = self.svc.join_queue("c1", "u1", "P", "registered", 1000) + removed = self.svc.leave_queue("c1") + assert removed is not None + assert removed.connection_id == "c1" + assert self.svc.bucket_size(entry.bucket) == 0 + + def test_leave_queue_unknown_returns_none(self): + assert self.svc.leave_queue("nobody") is None + + def test_try_form_match_returns_none_if_under_4(self): + for i in range(3): + self.svc.join_queue(f"c{i}", f"u{i}", "P", "registered", 1000) + assert self.svc.try_form_match("silver") is None + assert self.svc.bucket_size("silver") == 3 + + def test_try_form_match_pops_4(self): + for i in range(5): + self.svc.join_queue(f"c{i}", f"u{i}", "P", "registered", 1000) + four = self.svc.try_form_match("silver") + assert four is not None + assert len(four) == 4 + # 5th player remains + assert self.svc.bucket_size("silver") == 1 + + def test_try_form_match_takes_oldest_first(self): + entries = [] + for i in range(4): + e = self.svc.join_queue(f"c{i}", f"u{i}", f"P{i}", "registered", 1000) + entries.append(e) + # Ensure distinct joined_at by nudging the value. + e.joined_at = float(i) + # Re-add with explicit timestamps. + self.svc._repo.clear() + for e in entries: + self.svc._repo.add(e) + four = self.svc.try_form_match("silver") + assert [e.connection_id for e in four] == ["c0", "c1", "c2", "c3"] + + def test_build_quick_match_room_all_human(self): + entries = [_make_entry(connection_id=f"c{i}") for i in range(4)] + room = MatchmakingService.build_quick_match_room(entries, ai_count=0) + assert room.phase == RoomPhase.STARTING + assert len(room.participants) == 4 + human_count = sum( + 1 for p in room.participants.values() if p.controller_type == SeatControllerType.REMOTE + ) + assert human_count == 4 + + def test_build_quick_match_room_with_ai_fill(self): + entries = [_make_entry(connection_id="c0")] + room = MatchmakingService.build_quick_match_room(entries, ai_count=3) + assert len(room.participants) == 4 + human_seats = [ + p for p in room.participants.values() if p.controller_type == SeatControllerType.REMOTE + ] + bot_seats = [ + p for p in room.participants.values() if p.controller_type == SeatControllerType.BOT + ] + assert len(human_seats) == 1 + assert len(bot_seats) == 3 + + def test_bucket_different_mmr_ranges(self): + bronze = self.svc.join_queue("b1", "u1", "P", "registered", 500) + gold = self.svc.join_queue("g1", "u2", "P", "registered", 1300) + assert bronze.bucket == "bronze" + assert gold.bucket == "gold" + assert self.svc.bucket_size("bronze") == 1 + assert self.svc.bucket_size("gold") == 1 + # Different buckets → no immediate match + assert self.svc.try_form_match("bronze") is None + assert self.svc.try_form_match("gold") is None + + def test_reset_clears_queue(self): + self.svc.join_queue("c1", "u1", "P", "registered", 1000) + self.svc.reset() + assert self.svc.bucket_size("silver") == 0 + + +# =========================================================================== +# TestWsQuickMatch — WebSocket handler integration +# =========================================================================== + + +class TestWsQuickMatch: + """ + Test QUICK_MATCH_JOIN and QUICK_MATCH_LEAVE via the WS endpoint. + + These tests use the TestClient from conftest; queue state is reset by the + autouse reset_matchmaking_service fixture. + """ + + def _register_and_auth(self, client) -> tuple[str, str]: + """Register a user and authenticate via WS. Returns (token, nickname).""" + nick = f"Player{uuid.uuid4().hex[:6]}" + resp = client.post( + "/auth/register", + json={ + "email": f"{nick}@test.com", + "password": "Test1234!", + "nickname": nick, + }, + ) + assert resp.status_code in (200, 201) + token = resp.json()["access_token"] + return token, nick + + def test_quick_match_join_not_authenticated(self, client): + with client.websocket_connect("/ws") as ws: + ws.send_json({"type": "WS_HELLO", "data": {}}) + ws.receive_json() + ws.send_json({"type": "QUICK_MATCH_JOIN", "data": {}}) + msg = ws.receive_json() + assert msg["type"] == "ERROR" + assert msg["data"]["code"] == "NOT_AUTHENTICATED" + + def test_quick_match_join_sends_queue_joined(self, client): + token, _ = self._register_and_auth(client) + with client.websocket_connect("/ws") as ws: + ws.send_json({"type": "WS_HELLO", "data": {}}) + ws.receive_json() + ws.send_json({"type": "AUTH_LOGIN", "data": {"token": token}}) + ws.receive_json() # AUTH_OK + ws.send_json({"type": "QUICK_MATCH_JOIN", "data": {}}) + msg = ws.receive_json() + assert msg["type"] == "QUEUE_JOINED" + assert "bucket" not in msg["data"] # bucket is hidden (MMR policy) + assert "queue_size" in msg["data"] + assert "fill_seconds" in msg["data"] + assert isinstance(msg["data"]["fill_seconds"], int) + + def test_quick_match_join_already_in_room(self, client): + token, _ = self._register_and_auth(client) + with client.websocket_connect("/ws") as ws: + ws.send_json({"type": "WS_HELLO", "data": {}}) + ws.receive_json() + ws.send_json({"type": "AUTH_LOGIN", "data": {"token": token}}) + ws.receive_json() # AUTH_OK + ws.send_json({"type": "ROOM_CREATE", "data": {}}) + ws.receive_json() # ROOM_STATE + ws.send_json({"type": "QUICK_MATCH_JOIN", "data": {}}) + msg = ws.receive_json() + assert msg["type"] == "ERROR" + assert msg["data"]["code"] == "ALREADY_IN_ROOM" + + def test_quick_match_join_already_in_queue(self, client): + token, _ = self._register_and_auth(client) + with client.websocket_connect("/ws") as ws: + ws.send_json({"type": "WS_HELLO", "data": {}}) + ws.receive_json() + ws.send_json({"type": "AUTH_LOGIN", "data": {"token": token}}) + ws.receive_json() + ws.send_json({"type": "QUICK_MATCH_JOIN", "data": {}}) + ws.receive_json() # QUEUE_JOINED + ws.send_json({"type": "QUICK_MATCH_JOIN", "data": {}}) + msg = ws.receive_json() + assert msg["type"] == "ERROR" + assert msg["data"]["code"] == "ALREADY_IN_QUEUE" + + def test_quick_match_leave_when_not_in_queue(self, client): + token, _ = self._register_and_auth(client) + with client.websocket_connect("/ws") as ws: + ws.send_json({"type": "WS_HELLO", "data": {}}) + ws.receive_json() + ws.send_json({"type": "AUTH_LOGIN", "data": {"token": token}}) + ws.receive_json() + ws.send_json({"type": "QUICK_MATCH_LEAVE", "data": {}}) + msg = ws.receive_json() + assert msg["type"] == "ERROR" + assert msg["data"]["code"] == "NOT_IN_QUEUE" + + def test_quick_match_leave_sends_queue_left(self, client): + token, _ = self._register_and_auth(client) + with client.websocket_connect("/ws") as ws: + ws.send_json({"type": "WS_HELLO", "data": {}}) + ws.receive_json() + ws.send_json({"type": "AUTH_LOGIN", "data": {"token": token}}) + ws.receive_json() + ws.send_json({"type": "QUICK_MATCH_JOIN", "data": {}}) + ws.receive_json() # QUEUE_JOINED + ws.send_json({"type": "QUICK_MATCH_LEAVE", "data": {}}) + msg = ws.receive_json() + assert msg["type"] == "QUEUE_LEFT" + + def test_4_players_form_immediate_match(self, client, db): + """ + When 4 players with the same default MMR join quick match, a match + should form immediately without waiting for the fill timer. + + Message flow: + Players 1-3: first response after QUICK_MATCH_JOIN → QUEUE_JOINED + Player 4: first response after QUICK_MATCH_JOIN → MATCH_FOUND + (match forms immediately; QUEUE_JOINED is skipped) + Players 1-3: next message in buffer → MATCH_FOUND + """ + tokens = [] + for _ in range(4): + nick = f"P{uuid.uuid4().hex[:6]}" + resp = client.post( + "/auth/register", + json={ + "email": f"{nick}@t.com", + "password": "Test1234!", + "nickname": nick, + }, + ) + tokens.append(resp.json()["access_token"]) + + received = [] + connections = [] + for token in tokens: + ws = client.websocket_connect("/ws").__enter__() + connections.append(ws) + ws.send_json({"type": "WS_HELLO", "data": {}}) + ws.receive_json() # WS_WELCOME + ws.send_json({"type": "AUTH_LOGIN", "data": {"token": token}}) + ws.receive_json() # AUTH_OK + ws.send_json({"type": "QUICK_MATCH_JOIN", "data": {}}) + msg = ws.receive_json() + received.append(msg) + + # Players 1-3 receive QUEUE_JOINED (still waiting for a 4th seat). + for msg in received[:3]: + assert msg["type"] == "QUEUE_JOINED", f"expected QUEUE_JOINED, got {msg}" + + # Player 4 triggers the match immediately: their first response is + # MATCH_FOUND (no QUEUE_JOINED is sent for the triggering player). + assert received[3]["type"] == "MATCH_FOUND", f"expected MATCH_FOUND, got {received[3]}" + assert received[3]["data"]["is_ranked"] is True + + # Players 1-3 now receive MATCH_FOUND from the broadcast. + for ws in connections[:3]: + match_msg = ws.receive_json() + assert match_msg["type"] == "MATCH_FOUND", f"expected MATCH_FOUND, got {match_msg}" + assert match_msg["data"]["is_ranked"] is True + + for ws in connections: + ws.__exit__(None, None, None) + + def test_ai_fill_match_is_not_ranked(self): + """ + A match formed with AI fill (< 4 humans) has is_ranked=False. + This is the contract enforced by _fill_timer and _start_quick_match. + """ + # The fill timer passes is_ranked=False to on_match_formed. + # We test the build_quick_match_room + is_ranked contract directly. + entry = _make_entry(connection_id="c0") + room = MatchmakingService.build_quick_match_room([entry], ai_count=3) + bots = [ + p for p in room.participants.values() if p.controller_type == SeatControllerType.BOT + ] + assert len(bots) == 3 + # is_ranked would be set False by the caller after create_match; + # verify the flag semantics on ActiveMatch. + import dataclasses + + # Default is False — verified via dataclass field introspection + fields = { + f.name: f.default + for f in dataclasses.fields(ActiveMatch) + if f.default is not dataclasses.MISSING + } + assert fields.get("is_ranked") is False + + +# =========================================================================== +# Mock ActiveMatch builders for MmrService tests +# =========================================================================== + + +def _make_mock_seat( + seat_index: int, + user_id: str = None, + account_type: str = "registered", + took_over_by_bot: bool = False, + controller_type: SeatControllerType = SeatControllerType.REMOTE, +) -> MatchSeat: + from fall_in.core.player import Player, PlayerType + + player = Player( + name="P", + player_type=PlayerType.HUMAN + if controller_type == SeatControllerType.REMOTE + else PlayerType.AI, + player_id=seat_index, + ) + seat = MatchSeat( + seat_index=seat_index, + player=player, + connection_id=None, + user_id=user_id, + display_name="P", + controller_type=controller_type, + took_over_by_bot=took_over_by_bot, + ) + seat.account_type = account_type # type: ignore[attr-defined] + return seat + + +def _make_stub_match(seats: list[MatchSeat], is_ranked: bool) -> object: + """Minimal stub that duck-types as ActiveMatch for MmrService.""" + + class _Stub: + pass + + stub = _Stub() + stub.is_ranked = is_ranked + stub.seats = {s.seat_index: s for s in seats} + stub.match_id = "test-match" + return stub + + +def _make_mock_match_all_human(users, winner_seat: int, is_ranked: bool): + seats = [_make_mock_seat(i, user_id=users[i].id, account_type="registered") for i in range(4)] + return _make_stub_match(seats, is_ranked) + + +def _make_mock_match_3_human(users, winner_seat: int, is_ranked: bool): + seats = [_make_mock_seat(i, user_id=users[i].id, account_type="registered") for i in range(3)] + # 4th seat is bot-takeover + seats.append( + _make_mock_seat( + 3, + user_id=None, + account_type="guest", + took_over_by_bot=True, + controller_type=SeatControllerType.BOT, + ) + ) + return _make_stub_match(seats, is_ranked) + + +def _make_mock_match_2_human(users, winner_seat: int, is_ranked: bool): + seats = [_make_mock_seat(i, user_id=users[i].id, account_type="registered") for i in range(2)] + for i in range(2, 4): + seats.append( + _make_mock_seat( + i, + user_id=None, + account_type="guest", + took_over_by_bot=True, + controller_type=SeatControllerType.BOT, + ) + ) + return _make_stub_match(seats, is_ranked) + + +def _make_mock_match_with_guest(reg_users, guest_seat: int, winner_seat: int, is_ranked: bool): + seats = [ + _make_mock_seat(i, user_id=reg_users[i].id, account_type="registered") + for i in range(len(reg_users)) + ] + seats.append( + _make_mock_seat( + guest_seat, + user_id=None, + account_type="guest", + ) + ) + return _make_stub_match(seats, is_ranked) diff --git a/backend/tests/test_nickname.py b/backend/tests/test_nickname.py new file mode 100644 index 0000000..0ffbb51 --- /dev/null +++ b/backend/tests/test_nickname.py @@ -0,0 +1,199 @@ +""" +Tests for the nickname validation service (PR-08). + +Covers: + - Valid nicknames accepted and returned stripped. + - Empty / whitespace-only rejected. + - Too short / too long rejected. + - Disallowed characters rejected. + - All-digit names rejected. + - Reserved prefix "Guest_" rejected. + - Banned terms rejected (case-insensitive). + - Integration: register endpoint rejects invalid nicknames. + - Integration: guest endpoint rejects invalid nicknames. +""" + +import uuid + +import pytest + +from app.services.nickname_service import NicknameError, validate_nickname + +# =========================================================================== +# Unit tests — validate_nickname +# =========================================================================== + + +class TestValidateNickname: + # ------------------------------------------------------------------ + # Happy path + # ------------------------------------------------------------------ + + def test_plain_ascii_accepted(self): + assert validate_nickname("Alice") == "Alice" + + def test_korean_accepted(self): + assert validate_nickname("김철수") == "김철수" + + def test_mixed_korean_ascii_accepted(self): + assert validate_nickname("Kim철수") == "Kim철수" + + def test_underscore_accepted(self): + assert validate_nickname("sky_king") == "sky_king" + + def test_leading_trailing_whitespace_stripped(self): + assert validate_nickname(" hello ") == "hello" + + def test_minimum_length(self): + assert validate_nickname("AB") == "AB" + + def test_maximum_length(self): + assert validate_nickname("A" * 20) == "A" * 20 + + def test_space_between_words_accepted(self): + assert validate_nickname("Air Force") == "Air Force" + + # ------------------------------------------------------------------ + # Rejection cases + # ------------------------------------------------------------------ + + def test_empty_string_rejected(self): + with pytest.raises(NicknameError): + validate_nickname("") + + def test_whitespace_only_rejected(self): + with pytest.raises(NicknameError): + validate_nickname(" ") + + def test_too_short_rejected(self): + with pytest.raises(NicknameError): + validate_nickname("A") + + def test_too_long_rejected(self): + with pytest.raises(NicknameError): + validate_nickname("A" * 21) + + def test_emoji_rejected(self): + with pytest.raises(NicknameError): + validate_nickname("cool😎") + + def test_special_chars_rejected(self): + with pytest.raises(NicknameError): + validate_nickname("user@name") + + def test_dash_rejected(self): + with pytest.raises(NicknameError): + validate_nickname("air-force") + + def test_all_digits_rejected(self): + with pytest.raises(NicknameError): + validate_nickname("123456") + + def test_guest_prefix_lowercase_rejected(self): + with pytest.raises(NicknameError): + validate_nickname("guest_abc") + + def test_guest_prefix_uppercase_rejected(self): + with pytest.raises(NicknameError): + validate_nickname("Guest_XYZ") + + def test_banned_term_admin_rejected(self): + with pytest.raises(NicknameError): + validate_nickname("admin") + + def test_banned_term_embedded_rejected(self): + with pytest.raises(NicknameError): + validate_nickname("superadmin") + + def test_banned_term_case_insensitive(self): + with pytest.raises(NicknameError): + validate_nickname("ADMIN") + + def test_banned_korean_term_rejected(self): + with pytest.raises(NicknameError): + validate_nickname("관리자123") + + def test_banned_korean_operator_rejected(self): + with pytest.raises(NicknameError): + validate_nickname("운영자") + + +# =========================================================================== +# Integration tests — auth endpoints enforce nickname policy +# =========================================================================== + + +def _unique_email(): + return f"u{uuid.uuid4().hex[:8]}@test.com" + + +class TestNicknameInAuthEndpoints: + def test_register_rejects_banned_nickname(self, client): + resp = client.post( + "/auth/register", + json={ + "email": _unique_email(), + "password": "Test1234!", + "nickname": "admin", + }, + ) + assert resp.status_code == 422 + + def test_register_rejects_emoji_nickname(self, client): + resp = client.post( + "/auth/register", + json={ + "email": _unique_email(), + "password": "Test1234!", + "nickname": "cool😎", + }, + ) + assert resp.status_code == 422 + + def test_register_rejects_too_short_nickname(self, client): + resp = client.post( + "/auth/register", + json={ + "email": _unique_email(), + "password": "Test1234!", + "nickname": "A", + }, + ) + assert resp.status_code == 422 + + def test_register_accepts_valid_nickname(self, client): + resp = client.post( + "/auth/register", + json={ + "email": _unique_email(), + "password": "Test1234!", + "nickname": "SkyKing99", + }, + ) + assert resp.status_code == 201 + + def test_guest_rejects_banned_nickname(self, client): + resp = client.post( + "/auth/guest", + json={"nickname": "moderator"}, + ) + assert resp.status_code == 422 + + def test_guest_rejects_guest_prefix_nickname(self, client): + resp = client.post( + "/auth/guest", + json={"nickname": "Guest_Impersonator"}, + ) + assert resp.status_code == 422 + + def test_guest_accepts_valid_nickname(self, client): + resp = client.post( + "/auth/guest", + json={"nickname": "BravePilot"}, + ) + assert resp.status_code == 200 + + def test_guest_without_nickname_gets_auto_name(self, client): + """Auto-generated Guest_ names bypass the validator (server-generated).""" + resp = client.post("/auth/guest", json={}) + assert resp.status_code == 200 diff --git a/backend/tests/test_reconnect.py b/backend/tests/test_reconnect.py new file mode 100644 index 0000000..3431779 --- /dev/null +++ b/backend/tests/test_reconnect.py @@ -0,0 +1,695 @@ +""" +PR-05 — Reconnect, heartbeat, timeout, and bot-takeover tests. + +Structure +--------- +TestHeartbeat — PING/PONG message protocol and session state. +TestReconnectToken — Token issuance and validation (unit + WS). +TestReconnectFlow — Full disconnect-then-reconnect via WS. +TestBotTakeover — MatchService.takeover_seat() logic (unit). +TestSelectionTimeout — MatchService.auto_select_timed_out() logic (unit). +TestPresenceManager — PresenceManager token helpers (unit). + +Timing-based background tasks (grace timer, selection timer) are tested +via the service-level helpers they call rather than waiting for actual +asyncio.sleep durations. This keeps the suite fast and deterministic. +""" + +import time + +import pytest +from fall_in.core.rules import RoundPhase +from fastapi.testclient import TestClient + +from app.database import get_db +from app.main import app +from app.models.room import Room, SeatControllerType +from app.repositories.match_repo import InMemoryMatchRepo +from app.repositories.reconnect_repo import InMemoryReconnectRepo +from app.repositories.room_repo import InMemoryRoomRepo +from app.services.match_service import MatchService +from app.services.room_service import RoomService +from app.ws.endpoint import get_match_service, get_presence_manager, get_room_service +from app.ws.presence import PresenceManager + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def room_service(): + return RoomService(InMemoryRoomRepo()) + + +@pytest.fixture() +def match_service(): + return MatchService(InMemoryMatchRepo()) + + +@pytest.fixture() +def reconnect_repo(): + return InMemoryReconnectRepo() + + +@pytest.fixture() +def presence(reconnect_repo): + return PresenceManager(reconnect_repo) + + +@pytest.fixture() +def client(db, room_service, match_service, presence) -> TestClient: + def override_get_db(): + yield db + + app.dependency_overrides[get_db] = override_get_db + app.dependency_overrides[get_room_service] = lambda: room_service + app.dependency_overrides[get_match_service] = lambda: match_service + app.dependency_overrides[get_presence_manager] = lambda: presence + with TestClient(app) as c: + yield c + app.dependency_overrides.clear() + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _register(client: TestClient, email: str, nickname: str) -> str: + resp = client.post( + "/auth/register", + json={ + "email": email, + "password": "password123", + "nickname": nickname, + }, + ) + assert resp.status_code in (200, 201), resp.text + return resp.json()["access_token"] + + +def _ws_auth(ws, token: str) -> None: + ws.send_json({"type": "WS_HELLO", "data": {}}) + ws.receive_json() # WS_WELCOME + ws.send_json({"type": "AUTH_LOGIN", "data": {"token": token}}) + ws.receive_json() # AUTH_OK + + +def _guest_token(client: TestClient, nickname: str) -> str: + resp = client.post("/auth/guest", json={"nickname": nickname}) + assert resp.status_code == 200, resp.text + return resp.json()["access_token"] + + +def _ws_auth_guest(ws, token: str) -> None: + ws.send_json({"type": "WS_HELLO", "data": {}}) + ws.receive_json() # WS_WELCOME + ws.send_json({"type": "AUTH_GUEST", "data": {"token": token}}) + ws.receive_json() # AUTH_OK + + +def _consume_until(ws, target_type: str, max_msgs: int = 20) -> dict: + for _ in range(max_msgs): + msg = ws.receive_json() + if msg["type"] == target_type: + return msg + raise AssertionError(f"Did not receive {target_type!r} in {max_msgs} messages") + + +def _play_remote_turn(ws) -> None: + """Play one remote-human turn until TURN_RESOLVED is reached.""" + _consume_until(ws, "PHASE_SELECTING") + hand_msg = _consume_until(ws, "PRIVATE_HAND_STATE") + card_number = hand_msg["data"]["hand"][0]["number"] + ws.send_json({"type": "CARD_SELECT", "data": {"card_number": card_number}}) + _consume_until(ws, "PRIVATE_HAND_STATE") + _consume_until(ws, "TURN_RESOLVED") + + +def _make_full_room(room_service: RoomService, host_name: str = "Host") -> Room: + room = room_service.create_room( + display_name=host_name, + connection_id="conn-host", + user_id="user-host", + ) + room = room_service.start_room(room.room_code, 0) + return room + + +# --------------------------------------------------------------------------- +# TestHeartbeat +# --------------------------------------------------------------------------- + + +class TestHeartbeat: + def test_pong_clears_awaiting_flag(self, client: TestClient): + """ + Sending PONG with session.awaiting_pong = True should set it to False. + + We validate the *result* of the PONG handler (session state) rather + than testing real heartbeat timing. + """ + token = _register(client, "hb@example.com", "HBUser") + with client.websocket_connect("/ws") as ws: + _ws_auth(ws, token) + # Simulate the server having sent a PING (set awaiting_pong via PING echo). + # The client-side PONG handler in the server clears the flag. + ws.send_json({"type": "PONG", "data": {}}) + # No error response means the message was accepted. + # Send PING (client-initiated) to verify connection is alive. + ws.send_json({"type": "PING", "data": {}}) + msg = ws.receive_json() + assert msg["type"] == "PONG" + + def test_client_ping_receives_pong(self, client: TestClient): + """Client-initiated PING must still be answered with PONG.""" + token = _register(client, "ping@example.com", "PingUser") + with client.websocket_connect("/ws") as ws: + _ws_auth(ws, token) + ws.send_json({"type": "PING", "data": {}}) + msg = ws.receive_json() + assert msg["type"] == "PONG" + + def test_unknown_message_returns_error(self, client: TestClient): + """Non-existent message types produce an ERROR response, not a crash.""" + token = _register(client, "unk@example.com", "UnkUser") + with client.websocket_connect("/ws") as ws: + _ws_auth(ws, token) + ws.send_json({"type": "NOT_A_REAL_TYPE", "data": {}}) + msg = ws.receive_json() + assert msg["type"] == "ERROR" + assert msg["data"]["code"] == "UNKNOWN_MESSAGE" + + +# --------------------------------------------------------------------------- +# TestReconnectToken +# --------------------------------------------------------------------------- + + +class TestReconnectToken: + def test_token_issued_on_match_start(self, client: TestClient): + """ROOM_START should unicast a RECONNECT_TOKEN to each human seat.""" + token = _register(client, "tok@example.com", "TokUser") + with client.websocket_connect("/ws") as ws: + _ws_auth(ws, token) + ws.send_json({"type": "ROOM_CREATE", "data": {}}) + _consume_until(ws, "ROOM_STATE") + ws.send_json({"type": "ROOM_START", "data": {}}) + reconnect_msg = _consume_until(ws, "RECONNECT_TOKEN", max_msgs=30) + assert "token" in reconnect_msg["data"] + assert reconnect_msg["data"]["seat_index"] == 0 + + def test_guest_match_start_does_not_issue_reconnect_token(self, client: TestClient): + """Guest-controlled seats must not receive reconnect tokens.""" + token = _guest_token(client, "GuestNoReconnect") + with client.websocket_connect("/ws") as ws: + _ws_auth_guest(ws, token) + ws.send_json({"type": "ROOM_CREATE", "data": {}}) + _consume_until(ws, "ROOM_STATE") + ws.send_json({"type": "ROOM_START", "data": {}}) + + seen_types = set() + for _ in range(4): + msg = ws.receive_json() + seen_types.add(msg["type"]) + + assert "RECONNECT_TOKEN" not in seen_types + + def test_token_lookup_valid(self, reconnect_repo: InMemoryReconnectRepo): + """A freshly created token is retrievable until TTL expires.""" + token = reconnect_repo.create( + match_id="m1", + room_code="ABCDEF", + seat_index=0, + user_id="u1", + display_name="Alice", + account_type="registered", + ttl_seconds=60, + ) + entry = reconnect_repo.get(token) + assert entry is not None + assert entry.match_id == "m1" + assert entry.seat_index == 0 + + def test_token_lookup_expired(self, reconnect_repo: InMemoryReconnectRepo): + """An expired token returns None.""" + token = reconnect_repo.create( + match_id="m1", + room_code="ABCDEF", + seat_index=0, + user_id="u1", + display_name="Alice", + account_type="registered", + ttl_seconds=0, # expires immediately + ) + # Advance mock time by directly backdating the entry. + entry = reconnect_repo._tokens[token] + entry.expires_at = time.time() - 1 + assert reconnect_repo.get(token) is None + + def test_token_revoke_by_seat(self, reconnect_repo: InMemoryReconnectRepo): + """revoke_by_match_seat removes the specific seat's token only.""" + t0 = reconnect_repo.create("m1", "ABCDEF", 0, None, "Alice", "guest", 60) + t1 = reconnect_repo.create("m1", "ABCDEF", 1, None, "Bob", "guest", 60) + reconnect_repo.revoke_by_match_seat("m1", 0) + assert reconnect_repo.get(t0) is None + assert reconnect_repo.get(t1) is not None + + def test_token_revoke_by_match(self, reconnect_repo: InMemoryReconnectRepo): + """revoke_by_match removes all tokens for that match.""" + t0 = reconnect_repo.create("m1", "ABCDEF", 0, None, "Alice", "guest", 60) + t1 = reconnect_repo.create("m1", "ABCDEF", 1, None, "Bob", "guest", 60) + reconnect_repo.revoke_by_match("m1") + assert reconnect_repo.get(t0) is None + assert reconnect_repo.get(t1) is None + + def test_create_revokes_previous_for_same_seat(self, reconnect_repo: InMemoryReconnectRepo): + """Creating a new token for the same (match, seat) invalidates the old one.""" + old = reconnect_repo.create("m1", "ABCDEF", 0, None, "Alice", "guest", 60) + new = reconnect_repo.create("m1", "ABCDEF", 0, None, "Alice", "guest", 60) + assert reconnect_repo.get(old) is None + assert reconnect_repo.get(new) is not None + + +# --------------------------------------------------------------------------- +# TestReconnectFlow +# --------------------------------------------------------------------------- + + +class TestReconnectFlow: + def test_reconnect_ok_with_valid_token(self, client: TestClient): + """ + A client that disconnects then reconnects with a valid token should: + - Receive RECONNECT_OK + - Receive PUBLIC_BOARD_STATE snapshot + - Receive PRIVATE_HAND_STATE snapshot + """ + host_token = _register(client, "rc_host@example.com", "RcHost") + + # --- First connection: start match and capture reconnect token --- + with client.websocket_connect("/ws") as ws: + _ws_auth(ws, host_token) + ws.send_json({"type": "ROOM_CREATE", "data": {}}) + _consume_until(ws, "ROOM_STATE") + ws.send_json({"type": "ROOM_START", "data": {}}) + rc_msg = _consume_until(ws, "RECONNECT_TOKEN", max_msgs=30) + reconnect_token = rc_msg["data"]["token"] + # Connection closed here — grace timer starts in background. + + # --- Second connection: reconnect with the token --- + with client.websocket_connect("/ws") as ws2: + ws2.send_json({"type": "WS_HELLO", "data": {}}) + ws2.receive_json() # WS_WELCOME + + ws2.send_json({"type": "RECONNECT", "data": {"token": reconnect_token}}) + + ok_msg = _consume_until(ws2, "RECONNECT_OK", max_msgs=10) + assert ok_msg["data"]["seat_index"] == 0 + + board_msg = _consume_until(ws2, "PUBLIC_BOARD_STATE", max_msgs=10) + assert "board_rows" in board_msg["data"] + + hand_msg = _consume_until(ws2, "PRIVATE_HAND_STATE", max_msgs=10) + assert "hand" in hand_msg["data"] + + def test_reconnect_invalid_token_returns_error(self, client: TestClient): + """An unknown token must produce an INVALID_RECONNECT_TOKEN error.""" + with client.websocket_connect("/ws") as ws: + ws.send_json({"type": "WS_HELLO", "data": {}}) + ws.receive_json() # WS_WELCOME + ws.send_json({"type": "RECONNECT", "data": {"token": "not-a-real-token"}}) + err = ws.receive_json() + assert err["type"] == "ERROR" + assert err["data"]["code"] == "INVALID_RECONNECT_TOKEN" + + def test_reconnect_missing_token_returns_error(self, client: TestClient): + """RECONNECT without a token field must return MISSING_TOKEN.""" + with client.websocket_connect("/ws") as ws: + ws.send_json({"type": "WS_HELLO", "data": {}}) + ws.receive_json() # WS_WELCOME + ws.send_json({"type": "RECONNECT", "data": {}}) + err = ws.receive_json() + assert err["type"] == "ERROR" + assert err["data"]["code"] == "MISSING_TOKEN" + + def test_reconnect_after_bot_takeover_returns_error( + self, client: TestClient, match_service: MatchService, room_service: RoomService + ): + """Once a seat is taken over by a bot, reconnect must be refused.""" + host_token = _register(client, "to_host@example.com", "ToHost") + + with client.websocket_connect("/ws") as ws: + _ws_auth(ws, host_token) + ws.send_json({"type": "ROOM_CREATE", "data": {}}) + _consume_until(ws, "ROOM_STATE") + ws.send_json({"type": "ROOM_START", "data": {}}) + rc_msg = _consume_until(ws, "RECONNECT_TOKEN", max_msgs=30) + reconnect_token = rc_msg["data"]["token"] + match = match_service.get_match_by_room( + # Find the room by iterating room_service repo + next(rc for rc in room_service.repo._rooms) + ) + + # Simulate grace expiry by calling takeover directly. + if match is None: + # Find match another way + + # Just use the match_service's repo + all_matches = list(match_service.repo._matches.values()) + assert len(all_matches) == 1 + match = all_matches[0] + + match_service.takeover_seat(match, 0) + assert match.seats[0].took_over_by_bot is True + + # Now reconnect should fail. + with client.websocket_connect("/ws") as ws2: + ws2.send_json({"type": "WS_HELLO", "data": {}}) + ws2.receive_json() # WS_WELCOME + ws2.send_json({"type": "RECONNECT", "data": {"token": reconnect_token}}) + err = _consume_until(ws2, "ERROR", max_msgs=5) + assert err["data"]["code"] == "SEAT_TAKEN_OVER" + + def test_reconnect_seat_restores_connection_id( + self, match_service: MatchService, room_service: RoomService + ): + """reconnect_seat() updates MatchSeat.connection_id and clears flags.""" + room = _make_full_room(room_service) + match = match_service.create_match(room) + + match_service.mark_seat_disconnected(match, 0) + assert match.seats[0].is_disconnected is True + assert match.seats[0].connection_id is None + + match_service.reconnect_seat(match, 0, "new-conn-id") + assert match.seats[0].is_disconnected is False + assert match.seats[0].connection_id == "new-conn-id" + + def test_reconnect_participant_updates_room(self, room_service: RoomService): + """reconnect_participant() updates the connection_id in the room repo.""" + room = room_service.create_room( + display_name="Alice", + connection_id="old-conn", + user_id="u1", + ) + updated = room_service.reconnect_participant(room.room_code, 0, "new-conn") + assert updated is not None + assert updated.participants[0].connection_id == "new-conn" + + def test_registered_old_round_token_is_rejected_after_next_round(self, client: TestClient): + """Reconnect tokens are rotated each round; old-round tokens must stop working.""" + token = _register(client, "round_rotate@example.com", "RoundRotate") + with client.websocket_connect("/ws") as ws: + _ws_auth(ws, token) + ws.send_json({"type": "ROOM_CREATE", "data": {}}) + _consume_until(ws, "ROOM_STATE") + ws.send_json({"type": "ROOM_START", "data": {}}) + first_token_msg = _consume_until(ws, "RECONNECT_TOKEN", max_msgs=30) + old_token = first_token_msg["data"]["token"] + + for _ in range(9): + _play_remote_turn(ws) + + _consume_until(ws, "PHASE_SELECTING") + hand_msg = _consume_until(ws, "PRIVATE_HAND_STATE") + ws.send_json( + { + "type": "CARD_SELECT", + "data": {"card_number": hand_msg["data"]["hand"][0]["number"]}, + } + ) + _consume_until(ws, "PRIVATE_HAND_STATE") + _consume_until(ws, "TURN_RESOLVED") + _consume_until(ws, "ROUND_RESULT") + + ws.send_json({"type": "ROUND_READY", "data": {}}) + new_token_msg = _consume_until(ws, "RECONNECT_TOKEN", max_msgs=30) + assert new_token_msg["data"]["token"] != old_token + + with client.websocket_connect("/ws") as ws2: + ws2.send_json({"type": "WS_HELLO", "data": {}}) + ws2.receive_json() + ws2.send_json({"type": "RECONNECT", "data": {"token": old_token}}) + err = _consume_until(ws2, "ERROR", max_msgs=10) + assert err["data"]["code"] == "INVALID_RECONNECT_TOKEN" + + +# --------------------------------------------------------------------------- +# TestBotTakeover +# --------------------------------------------------------------------------- + + +class TestBotTakeover: + def test_takeover_converts_remote_to_bot( + self, match_service: MatchService, room_service: RoomService + ): + """takeover_seat() must change controller_type to BOT.""" + room = _make_full_room(room_service) + match = match_service.create_match(room) + assert match.seats[0].controller_type == SeatControllerType.REMOTE + + match_service.takeover_seat(match, 0) + assert match.seats[0].controller_type == SeatControllerType.BOT + + def test_takeover_sets_took_over_by_bot( + self, match_service: MatchService, room_service: RoomService + ): + """took_over_by_bot must be True after takeover.""" + room = _make_full_room(room_service) + match = match_service.create_match(room) + match_service.takeover_seat(match, 0) + assert match.seats[0].took_over_by_bot is True + + def test_takeover_installs_ai_controller( + self, match_service: MatchService, room_service: RoomService + ): + """takeover_seat() must install an AIPlayer on the seat.""" + room = _make_full_room(room_service) + match = match_service.create_match(room) + assert match.seats[0].ai_controller is None + match_service.takeover_seat(match, 0) + assert match.seats[0].ai_controller is not None + + def test_takeover_auto_selects_when_in_selecting_phase( + self, match_service: MatchService, room_service: RoomService + ): + """ + If the match is in SELECTING phase at takeover time, the new AI + controller must immediately choose a card. + """ + room = _make_full_room(room_service) + match = match_service.create_match(room) + + rules = match.rules + assert rules.round_state.phase == RoundPhase.SELECTING + assert match.seats[0].player.selected_card is None # human hasn't selected + + match_service.takeover_seat(match, 0) + assert match.seats[0].player.selected_card is not None + + def test_takeover_clears_disconnected_flag( + self, match_service: MatchService, room_service: RoomService + ): + """After takeover, is_disconnected should be False (fully converted).""" + room = _make_full_room(room_service) + match = match_service.create_match(room) + match_service.mark_seat_disconnected(match, 0) + assert match.seats[0].is_disconnected is True + + match_service.takeover_seat(match, 0) + assert match.seats[0].is_disconnected is False + + def test_takeover_makes_all_selected_after_human_only_seat( + self, match_service: MatchService, room_service: RoomService + ): + """ + With a single human seat (seats 1-3 are bots), taking over seat 0 + should cause all_selected() to return True. + """ + room = _make_full_room(room_service) + match = match_service.create_match(room) + + # Bots already selected at round start; only seat 0 (human) is pending. + assert not match_service.all_selected(match) + + match_service.takeover_seat(match, 0) + assert match_service.all_selected(match) + + def test_mark_seat_disconnected_records_time( + self, match_service: MatchService, room_service: RoomService + ): + """mark_seat_disconnected must set disconnected_at to a recent timestamp.""" + room = _make_full_room(room_service) + match = match_service.create_match(room) + + before = time.time() + match_service.mark_seat_disconnected(match, 0) + after = time.time() + + assert match.seats[0].is_disconnected is True + assert before <= match.seats[0].disconnected_at <= after + assert match.seats[0].connection_id is None + + def test_guest_disconnect_is_immediate_bot_takeover( + self, + client: TestClient, + match_service: MatchService, + room_service: RoomService, + ): + """Guests cannot reconnect, so their seat is converted to BOT on disconnect.""" + token = _guest_token(client, "GuestTakeover") + room_code = None + with client.websocket_connect("/ws") as ws: + _ws_auth_guest(ws, token) + ws.send_json({"type": "ROOM_CREATE", "data": {}}) + room_msg = _consume_until(ws, "ROOM_STATE") + room_code = room_msg["data"]["room_code"] + ws.send_json({"type": "ROOM_START", "data": {}}) + _consume_until(ws, "MATCH_START") + + assert room_code is not None + match = match_service.get_match_by_room(room_code) + assert match is not None + assert match.seats[0].took_over_by_bot is True + room = room_service.repo.get(room_code) + assert room is not None + assert room.participants[0].controller_type == SeatControllerType.BOT + + +# --------------------------------------------------------------------------- +# TestSelectionTimeout +# --------------------------------------------------------------------------- + + +class TestSelectionTimeout: + def test_auto_select_timed_out_selects_for_unselected_humans( + self, match_service: MatchService, room_service: RoomService + ): + """auto_select_timed_out() must pick a card for each human with no selection.""" + room = _make_full_room(room_service) + match = match_service.create_match(room) + + assert match.seats[0].player.selected_card is None + timed_out = match_service.auto_select_timed_out(match) + assert 0 in timed_out + assert match.seats[0].player.selected_card is not None + + def test_auto_select_does_not_affect_already_selected( + self, match_service: MatchService, room_service: RoomService + ): + """Seats that already selected a card are not overwritten.""" + room = _make_full_room(room_service) + match = match_service.create_match(room) + + card = match.seats[0].player.hand[0] + match_service.submit_selection(match, 0, card.number) + original = match.seats[0].player.selected_card + + match_service.auto_select_timed_out(match) + assert match.seats[0].player.selected_card is original + + def test_auto_select_returns_empty_outside_selecting_phase( + self, match_service: MatchService, room_service: RoomService + ): + """auto_select_timed_out() is a no-op outside SELECTING phase.""" + room = _make_full_room(room_service) + match = match_service.create_match(room) + + # Manually move to PLACING phase to simulate wrong state. + match.rules.round_state.phase = RoundPhase.PLACING + result = match_service.auto_select_timed_out(match) + assert result == [] + + def test_auto_select_skips_bot_seats( + self, match_service: MatchService, room_service: RoomService + ): + """auto_select_timed_out() should not touch bot seats.""" + room = _make_full_room(room_service) + match = match_service.create_match(room) + timed_out = match_service.auto_select_timed_out(match) + # Only seat 0 is REMOTE; seats 1-3 are BOT. + assert timed_out == [0] + + def test_auto_select_makes_all_selected( + self, match_service: MatchService, room_service: RoomService + ): + """After auto_select_timed_out(), all_selected() should return True.""" + room = _make_full_room(room_service) + match = match_service.create_match(room) + assert not match_service.all_selected(match) + match_service.auto_select_timed_out(match) + assert match_service.all_selected(match) + + +# --------------------------------------------------------------------------- +# TestPresenceManager +# --------------------------------------------------------------------------- + + +class TestPresenceManager: + def test_issue_and_lookup_token(self, presence: PresenceManager): + """issue_token() must store a retrievable entry.""" + token = presence.issue_token( + match_id="m1", + room_code="ABCDEF", + seat_index=1, + user_id="u1", + display_name="Bob", + account_type="registered", + ) + entry = presence.lookup_token(token) + assert entry is not None + assert entry.seat_index == 1 + assert entry.match_id == "m1" + + def test_revoke_seat_token_removes_entry(self, presence: PresenceManager): + """revoke_seat_token() must make the token unretrievable.""" + token = presence.issue_token( + match_id="m1", + room_code="ABCDEF", + seat_index=0, + user_id=None, + display_name="Alice", + account_type="guest", + ) + presence.revoke_seat_token("m1", 0) + assert presence.lookup_token(token) is None + + def test_revoke_match_tokens_removes_all(self, presence: PresenceManager): + """revoke_match_tokens() must clear every token for that match.""" + t0 = presence.issue_token("m1", "ABCDEF", 0, None, "Alice", "guest") + t1 = presence.issue_token("m1", "ABCDEF", 1, None, "Bob", "guest") + t_other = presence.issue_token("m2", "XXXXXX", 0, None, "Carol", "guest") + presence.revoke_match_tokens("m1") + assert presence.lookup_token(t0) is None + assert presence.lookup_token(t1) is None + assert presence.lookup_token(t_other) is not None + + def test_reset_clears_tokens(self, presence: PresenceManager): + """reset() must clear all token entries.""" + token = presence.issue_token("m1", "ABCDEF", 0, None, "Alice", "guest") + presence.reset() + assert presence.lookup_token(token) is None + + def test_selection_timeout_set_on_match( + self, + match_service: MatchService, + room_service: RoomService, + client: TestClient, + ): + """ + After ROOM_START, ActiveMatch.selection_started_at must be set + (set by _start_selection_timeout in the handler). + """ + host_token = _register(client, "st@example.com", "StUser") + with client.websocket_connect("/ws") as ws: + _ws_auth(ws, host_token) + ws.send_json({"type": "ROOM_CREATE", "data": {}}) + _consume_until(ws, "ROOM_STATE") + ws.send_json({"type": "ROOM_START", "data": {}}) + _consume_until(ws, "PHASE_SELECTING", max_msgs=30) + + all_matches = list(match_service.repo._matches.values()) + assert len(all_matches) == 1 + assert all_matches[0].selection_started_at is not None diff --git a/backend/tests/test_report.py b/backend/tests/test_report.py new file mode 100644 index 0000000..4dcc798 --- /dev/null +++ b/backend/tests/test_report.py @@ -0,0 +1,453 @@ +""" +Tests for the PR-08 report system. + +Coverage: + - ReportService: valid submission, self-report guard, missing target, + invalid reason code, details sanitisation. + - POST /report endpoint: auth required, happy path, bad reason code. + - GET /admin/reports: disabled without ADMIN_TOKEN, auth required, + list / filter / get / status update. +""" + +import uuid + +import pytest + +from app.models.db import ReportReasonCode, ReportStatus +from app.services.report_service import ReportError, submit_report + +# =========================================================================== +# Helpers +# =========================================================================== + + +def _register_and_get_token(client, email: str = None, nickname: str = None) -> tuple[str, str]: + """Register a user, return (access_token, user_id).""" + email = email or f"u{uuid.uuid4().hex[:8]}@test.com" + nickname = nickname or f"P{uuid.uuid4().hex[:5]}" + resp = client.post( + "/auth/register", + json={"email": email, "password": "Test1234!", "nickname": nickname}, + ) + assert resp.status_code == 201 + token = resp.json()["access_token"] + # Fetch user_id via /me/profile + profile = client.get("/me/profile", headers={"Authorization": f"Bearer {token}"}) + user_id = profile.json()["user_id"] + return token, user_id + + +# =========================================================================== +# TestReportService — unit tests (no HTTP) +# =========================================================================== + + +class TestReportService: + def test_valid_submission(self, db): + report = submit_report( + db, + reporter_user_id="reporter-1", + reported_user_id="reported-1", + reported_connection_id=None, + reason_code="emote_spam", + details="Too many fire emotes", + room_code="ABCD12", + match_id=None, + ) + assert report.id is not None + assert report.reason_code == ReportReasonCode.EMOTE_SPAM + assert report.status == ReportStatus.OPEN + assert report.details == "Too many fire emotes" + + def test_missing_target_raises(self, db): + with pytest.raises(ReportError, match="필수"): + submit_report( + db, + reporter_user_id="r1", + reported_user_id=None, + reported_connection_id=None, + reason_code="cheating", + details=None, + room_code=None, + match_id=None, + ) + + def test_self_report_raises(self, db): + with pytest.raises(ReportError, match="자기 자신"): + submit_report( + db, + reporter_user_id="same-user", + reported_user_id="same-user", + reported_connection_id=None, + reason_code="other", + details=None, + room_code=None, + match_id=None, + ) + + def test_invalid_reason_code_raises(self, db): + with pytest.raises(ReportError, match="유효하지 않은"): + submit_report( + db, + reporter_user_id="r1", + reported_user_id="r2", + reported_connection_id=None, + reason_code="totally_made_up", + details=None, + room_code=None, + match_id=None, + ) + + def test_details_trimmed_to_280(self, db): + long_details = "x" * 400 + report = submit_report( + db, + reporter_user_id=None, + reported_user_id=None, + reported_connection_id="conn-abc", + reason_code="other", + details=long_details, + room_code=None, + match_id=None, + ) + assert len(report.details) == 280 + + def test_connection_id_only_accepted(self, db): + """Reporter can target by connection_id alone (guest vs guest).""" + report = submit_report( + db, + reporter_user_id=None, + reported_user_id=None, + reported_connection_id="conn-xyz", + reason_code="abusive_language", + details=None, + room_code=None, + match_id=None, + ) + assert report.reported_connection_id == "conn-xyz" + + def test_empty_details_stored_as_none(self, db): + report = submit_report( + db, + reporter_user_id="r1", + reported_user_id="r2", + reported_connection_id=None, + reason_code="other", + details=" ", # whitespace-only → None + room_code=None, + match_id=None, + ) + assert report.details is None + + def test_duplicate_report_returns_existing(self, db): + """Submitting same (reporter, reported, reason, match) twice returns first.""" + first = submit_report( + db, + reporter_user_id="reporter-dup", + reported_user_id="reported-dup", + reported_connection_id=None, + reason_code="emote_spam", + details="first report", + room_code="ROOM01", + match_id="match-abc", + ) + second = submit_report( + db, + reporter_user_id="reporter-dup", + reported_user_id="reported-dup", + reported_connection_id=None, + reason_code="emote_spam", + details="duplicate report", + room_code="ROOM01", + match_id="match-abc", + ) + assert second.id == first.id + + def test_different_reason_creates_new_report(self, db): + first = submit_report( + db, + reporter_user_id="reporter-x", + reported_user_id="reported-x", + reported_connection_id=None, + reason_code="emote_spam", + details=None, + room_code=None, + match_id="match-xyz", + ) + second = submit_report( + db, + reporter_user_id="reporter-x", + reported_user_id="reported-x", + reported_connection_id=None, + reason_code="cheating", # different reason + details=None, + room_code=None, + match_id="match-xyz", + ) + assert second.id != first.id + + +# =========================================================================== +# TestReportEndpoint — HTTP integration tests +# =========================================================================== + + +class TestReportEndpoint: + def test_unauthenticated_returns_401(self, client): + resp = client.post( + "/report", + json={ + "reported_connection_id": "conn-abc", + "reason_code": "emote_spam", + }, + ) + assert resp.status_code == 401 + + def test_happy_path_returns_201(self, client): + token, reporter_id = _register_and_get_token(client) + _, reported_id = _register_and_get_token(client) + + resp = client.post( + "/report", + headers={"Authorization": f"Bearer {token}"}, + json={ + "reported_user_id": reported_id, + "reason_code": "emote_spam", + "details": "Sent fire 100 times", + }, + ) + assert resp.status_code == 201 + data = resp.json() + assert "report_id" in data + assert data["status"] == "open" + + def test_invalid_reason_returns_400(self, client): + token, _ = _register_and_get_token(client) + resp = client.post( + "/report", + headers={"Authorization": f"Bearer {token}"}, + json={ + "reported_connection_id": "conn-abc", + "reason_code": "nonsense", + }, + ) + assert resp.status_code == 400 + + def test_missing_target_returns_400(self, client): + token, _ = _register_and_get_token(client) + resp = client.post( + "/report", + headers={"Authorization": f"Bearer {token}"}, + json={"reason_code": "cheating"}, + ) + assert resp.status_code == 400 + + def test_self_report_returns_400(self, client): + token, user_id = _register_and_get_token(client) + resp = client.post( + "/report", + headers={"Authorization": f"Bearer {token}"}, + json={ + "reported_user_id": user_id, + "reason_code": "other", + }, + ) + assert resp.status_code == 400 + + @pytest.mark.parametrize( + "reason", + ["emote_spam", "abusive_language", "cheating", "nickname_violation", "other"], + ) + def test_all_reason_codes_accepted(self, client, reason): + token, _ = _register_and_get_token(client) + resp = client.post( + "/report", + headers={"Authorization": f"Bearer {token}"}, + json={ + "reported_connection_id": "conn-test", + "reason_code": reason, + }, + ) + assert resp.status_code == 201 + + +# =========================================================================== +# TestAdminEndpoints — HTTP integration tests +# =========================================================================== + + +class TestAdminEndpoints: + def test_list_reports_disabled_without_token(self, client): + """Admin endpoints return 503 when ADMIN_TOKEN is not set.""" + resp = client.get( + "/admin/reports", + headers={"Authorization": "Bearer anything"}, + ) + assert resp.status_code == 503 + + def test_list_reports_requires_admin_token(self, client, monkeypatch): + from app.config import settings + + monkeypatch.setattr(settings, "ADMIN_TOKEN", "secret-admin-token") + + resp = client.get( + "/admin/reports", + headers={"Authorization": "Bearer wrong-token"}, + ) + assert resp.status_code == 401 + + def test_list_reports_returns_empty(self, client, monkeypatch): + from app.config import settings + + monkeypatch.setattr(settings, "ADMIN_TOKEN", "secret-admin-token") + + resp = client.get( + "/admin/reports", + headers={"Authorization": "Bearer secret-admin-token"}, + ) + assert resp.status_code == 200 + assert resp.json()["items"] == [] + + def test_list_reports_returns_submitted(self, client, monkeypatch): + from app.config import settings + + monkeypatch.setattr(settings, "ADMIN_TOKEN", "secret-admin-token") + + token, _ = _register_and_get_token(client) + client.post( + "/report", + headers={"Authorization": f"Bearer {token}"}, + json={"reported_connection_id": "c1", "reason_code": "emote_spam"}, + ) + + resp = client.get( + "/admin/reports", + headers={"Authorization": "Bearer secret-admin-token"}, + ) + assert resp.status_code == 200 + assert len(resp.json()["items"]) == 1 + assert resp.json()["items"][0]["reason_code"] == "emote_spam" + + def test_filter_by_status(self, client, monkeypatch, db): + from app.config import settings + from app.services.report_service import submit_report + + monkeypatch.setattr(settings, "ADMIN_TOKEN", "secret-admin-token") + + submit_report( + db, + reporter_user_id=None, + reported_user_id=None, + reported_connection_id="c1", + reason_code="other", + details=None, + room_code=None, + match_id=None, + ) + + resp = client.get( + "/admin/reports?status=open", + headers={"Authorization": "Bearer secret-admin-token"}, + ) + assert resp.status_code == 200 + items = resp.json()["items"] + assert all(i["status"] == "open" for i in items) + + resp2 = client.get( + "/admin/reports?status=reviewed", + headers={"Authorization": "Bearer secret-admin-token"}, + ) + assert resp2.json()["items"] == [] + + def test_get_report_by_id(self, client, monkeypatch, db): + from app.config import settings + from app.services.report_service import submit_report + + monkeypatch.setattr(settings, "ADMIN_TOKEN", "secret-admin-token") + + report = submit_report( + db, + reporter_user_id=None, + reported_user_id=None, + reported_connection_id="c2", + reason_code="cheating", + details=None, + room_code=None, + match_id=None, + ) + + resp = client.get( + f"/admin/reports/{report.id}", + headers={"Authorization": "Bearer secret-admin-token"}, + ) + assert resp.status_code == 200 + assert resp.json()["id"] == report.id + assert resp.json()["reason_code"] == "cheating" + + def test_update_report_status(self, client, monkeypatch, db): + from app.config import settings + from app.services.report_service import submit_report + + monkeypatch.setattr(settings, "ADMIN_TOKEN", "secret-admin-token") + + report = submit_report( + db, + reporter_user_id=None, + reported_user_id=None, + reported_connection_id="c3", + reason_code="other", + details=None, + room_code=None, + match_id=None, + ) + + resp = client.patch( + f"/admin/reports/{report.id}", + headers={"Authorization": "Bearer secret-admin-token"}, + json={"status": "reviewed"}, + ) + assert resp.status_code == 200 + assert resp.json()["status"] == "reviewed" + + def test_get_nonexistent_report_returns_404(self, client, monkeypatch): + from app.config import settings + + monkeypatch.setattr(settings, "ADMIN_TOKEN", "secret-admin-token") + + resp = client.get( + "/admin/reports/no-such-id", + headers={"Authorization": "Bearer secret-admin-token"}, + ) + assert resp.status_code == 404 + + def test_pagination_total_reflects_all_matches_not_page_size(self, client, monkeypatch, db): + """total must be the full filtered count, not len(items) on the current page.""" + from app.config import settings + from app.services.report_service import submit_report + + monkeypatch.setattr(settings, "ADMIN_TOKEN", "secret-admin-token") + + # Insert 5 reports. + for i in range(5): + submit_report( + db, + reporter_user_id=None, + reported_user_id=None, + reported_connection_id=f"conn-{i}", + reason_code="other", + details=None, + room_code=None, + match_id=None, + ) + + # Fetch only 2 per page. + resp = client.get( + "/admin/reports?limit=2&offset=0", + headers={"Authorization": "Bearer secret-admin-token"}, + ) + assert resp.status_code == 200 + body = resp.json() + assert len(body["items"]) == 2 # page has 2 items + assert body["total"] == 5 # but total reflects all 5 rows + assert body["limit"] == 2 + assert body["offset"] == 0 diff --git a/backend/tests/test_room.py b/backend/tests/test_room.py new file mode 100644 index 0000000..fa34a51 --- /dev/null +++ b/backend/tests/test_room.py @@ -0,0 +1,739 @@ +""" +Room lobby and WebSocket session tests. + +Structure: + TestRoomService — unit tests for business logic (no WS, no HTTP) + TestRoomWebSocket — integration tests via the /ws endpoint + +WebSocket integration tests use a single client at a time. Multi-client +broadcast behaviour is covered by TestRoomService unit tests, keeping the +WS tests simple and free of threading. + +The local `client` fixture overrides the conftest.py one to also inject a +fresh RoomService (via get_room_service dependency override) so each test +starts with an empty in-memory room store. +""" + +import pytest +from fastapi.testclient import TestClient + +from app.database import get_db +from app.main import app +from app.models.room import RoomPhase, SeatControllerType +from app.repositories.match_repo import InMemoryMatchRepo +from app.repositories.room_repo import InMemoryRoomRepo +from app.services.match_service import MatchService +from app.services.room_service import RoomError, RoomService +from app.ws.endpoint import get_match_service, get_room_service + +# --------------------------------------------------------------------------- +# Fixtures — local overrides of conftest.py +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def room_service(): + """Fresh room service for each test.""" + return RoomService(InMemoryRoomRepo()) + + +@pytest.fixture() +def match_service(): + """Fresh match service for each test.""" + return MatchService(InMemoryMatchRepo()) + + +@pytest.fixture() +def client(db, room_service, match_service) -> TestClient: + """TestClient that overrides get_db, get_room_service, and get_match_service.""" + + def override_get_db(): + yield db + + app.dependency_overrides[get_db] = override_get_db + app.dependency_overrides[get_room_service] = lambda: room_service + app.dependency_overrides[get_match_service] = lambda: match_service + with TestClient(app) as c: + yield c + app.dependency_overrides.clear() + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _register(client: TestClient, email: str, nickname: str) -> str: + resp = client.post( + "/auth/register", + json={ + "email": email, + "password": "password123", + "nickname": nickname, + }, + ) + assert resp.status_code == 201, resp.json() + return resp.json()["access_token"] + + +def _guest(client: TestClient, nickname: str = "GuestPlayer") -> str: + resp = client.post("/auth/guest", json={"nickname": nickname}) + assert resp.status_code == 200, resp.json() + return resp.json()["access_token"] + + +def _ws_auth(ws, token: str) -> None: + """Send HELLO+AUTH and consume the two response messages.""" + ws.send_json({"type": "WS_HELLO"}) + welcome = ws.receive_json() + assert welcome["type"] == "WS_WELCOME" + + ws.send_json({"type": "AUTH_LOGIN", "data": {"token": token}}) + auth_ok = ws.receive_json() + assert auth_ok["type"] == "AUTH_OK" + + +# =========================================================================== +# Unit tests — RoomService +# =========================================================================== + + +class TestRoomService: + def test_create_room_returns_six_char_code(self, room_service): + room = room_service.create_room("Alice", "conn-1") + assert len(room.room_code) == 6 + assert room.room_code.isupper() or room.room_code.isalnum() + + def test_create_room_host_at_seat_0(self, room_service): + room = room_service.create_room("Alice", "conn-1") + assert room.host_seat_index == 0 + assert 0 in room.participants + assert room.participants[0].display_name == "Alice" + assert room.participants[0].controller_type == SeatControllerType.REMOTE + + def test_create_room_initial_phase_is_waiting(self, room_service): + room = room_service.create_room("Alice", "conn-1") + assert room.phase == RoomPhase.WAITING + + def test_join_room_assigns_next_seat(self, room_service): + room = room_service.create_room("Alice", "conn-1") + updated = room_service.join_room(room.room_code, "Bob", "conn-2") + assert 1 in updated.participants + assert updated.participants[1].display_name == "Bob" + + def test_join_room_returns_two_participants(self, room_service): + room = room_service.create_room("Alice", "conn-1") + updated = room_service.join_room(room.room_code, "Bob", "conn-2") + assert len(updated.participants) == 2 + + def test_join_nonexistent_room_raises(self, room_service): + with pytest.raises(RoomError, match="not found"): + room_service.join_room("XXXXXX", "Bob", "conn-2") + + def test_join_full_room_raises(self, room_service): + room = room_service.create_room("A", "c0") + room_service.join_room(room.room_code, "B", "c1") + room_service.join_room(room.room_code, "C", "c2") + room_service.join_room(room.room_code, "D", "c3") + with pytest.raises(RoomError, match="full"): + room_service.join_room(room.room_code, "E", "c4") + + def test_join_starting_room_raises(self, room_service): + room = room_service.create_room("Alice", "conn-1") + room_service.start_room(room.room_code, 0) + with pytest.raises(RoomError, match="not accepting"): + room_service.join_room(room.room_code, "Late", "conn-late") + + def test_leave_room_removes_participant(self, room_service): + room = room_service.create_room("Alice", "conn-1") + room_service.join_room(room.room_code, "Bob", "conn-2") + updated = room_service.leave_room(room.room_code, 1) + assert 1 not in updated.participants + assert len(updated.participants) == 1 + + def test_leave_room_last_player_returns_none(self, room_service): + room = room_service.create_room("Alice", "conn-1") + result = room_service.leave_room(room.room_code, 0) + assert result is None + + def test_leave_room_host_reassigns_to_lowest_seat(self, room_service): + room = room_service.create_room("Alice", "conn-1") # seat 0 = host + room_service.join_room(room.room_code, "Bob", "conn-2") # seat 1 + updated = room_service.leave_room(room.room_code, 0) # host leaves + assert updated.host_seat_index == 1 + + def test_set_ready_updates_flag(self, room_service): + room = room_service.create_room("Alice", "conn-1") + updated = room_service.set_ready(room.room_code, 0, True) + assert updated.participants[0].is_ready is True + + def test_set_ready_nonexistent_room_raises(self, room_service): + with pytest.raises(RoomError, match="not found"): + room_service.set_ready("XXXXXX", 0, True) + + def test_start_fills_missing_seats_with_bots(self, room_service): + room = room_service.create_room("Alice", "conn-1") + started = room_service.start_room(room.room_code, 0) + assert len(started.participants) == 4 + bots = [ + p for p in started.participants.values() if p.controller_type == SeatControllerType.BOT + ] + assert len(bots) == 3 + + def test_start_bots_are_marked_ready(self, room_service): + room = room_service.create_room("Alice", "conn-1") + started = room_service.start_room(room.room_code, 0) + for p in started.participants.values(): + if p.controller_type == SeatControllerType.BOT: + assert p.is_ready is True + + def test_start_bots_have_no_connection_id(self, room_service): + room = room_service.create_room("Alice", "conn-1") + started = room_service.start_room(room.room_code, 0) + for p in started.participants.values(): + if p.controller_type == SeatControllerType.BOT: + assert p.connection_id is None + assert p.user_id is None + + def test_start_changes_phase_to_starting(self, room_service): + room = room_service.create_room("Alice", "conn-1") + started = room_service.start_room(room.room_code, 0) + assert started.phase == RoomPhase.STARTING + + def test_start_non_host_raises(self, room_service): + room = room_service.create_room("Alice", "conn-1") + room_service.join_room(room.room_code, "Bob", "conn-2") + with pytest.raises(RoomError, match="host"): + room_service.start_room(room.room_code, 1) + + def test_start_already_starting_raises(self, room_service): + room = room_service.create_room("Alice", "conn-1") + room_service.start_room(room.room_code, 0) + with pytest.raises(RoomError, match="already"): + room_service.start_room(room.room_code, 0) + + def test_two_rooms_have_distinct_codes(self, room_service): + room_a = room_service.create_room("Alice", "conn-1") + room_b = room_service.create_room("Bob", "conn-2") + assert room_a.room_code != room_b.room_code + + def test_room_to_dict_structure(self, room_service): + room = room_service.create_room("Alice", "conn-1") + d = room.to_dict() + assert "room_code" in d + assert "phase" in d + assert "host_seat_index" in d + assert "participants" in d + assert isinstance(d["participants"], list) + assert d["participants"][0]["seat_index"] == 0 + assert d["participants"][0]["controller_type"] == SeatControllerType.REMOTE + + def test_room_to_dict_excludes_connection_id(self, room_service): + """connection_id must never leak to the wire.""" + room = room_service.create_room("Alice", "conn-secret") + d = room.to_dict() + for p in d["participants"]: + assert "connection_id" not in p + + def test_room_to_dict_excludes_user_id(self, room_service): + """user_id is a stable account identifier — must not appear in broadcast.""" + room = room_service.create_room("Alice", "conn-1", user_id="secret-uuid") + d = room.to_dict() + for p in d["participants"]: + assert "user_id" not in p + + def test_leave_room_last_human_in_starting_room_destroys_room(self, room_service): + """ + After ROOM_START bots fill all empty seats. When the only human leaves, + the room must be destroyed (bots-only state is unrecoverable). + """ + room = room_service.create_room("Solo", "conn-solo") + started = room_service.start_room(room.room_code, 0) + assert started.phase == RoomPhase.STARTING + assert len(started.participants) == 4 + # Human (seat 0) leaves + result = room_service.leave_room(room.room_code, 0) + assert result is None + assert room_service.repo.get(room.room_code) is None + + def test_leave_room_host_reassigns_only_among_humans(self, room_service): + """Host re-assignment must skip bot seats.""" + room = room_service.create_room("HostA", "conn-a") # seat 0 = host + room_service.join_room(room.room_code, "PlayerB", "conn-b") # seat 1 + # Manually add a bot at seat 2 (simulating a partial-fill scenario) + from app.models.room import RoomParticipant, SeatControllerType + + room.participants[2] = RoomParticipant( + seat_index=2, + display_name="Bot3", + controller_type=SeatControllerType.BOT, + is_ready=True, + ) + room_service.repo.update(room) + # Host (seat 0) leaves; new host should be seat 1 (human), not seat 2 (bot) + updated = room_service.leave_room(room.room_code, 0) + assert updated is not None + assert updated.host_seat_index == 1 + + +# =========================================================================== +# Integration tests — WebSocket endpoint +# =========================================================================== + + +class TestRoomWebSocket: + def test_ws_hello_welcome(self, client): + with client.websocket_connect("/ws") as ws: + ws.send_json({"type": "WS_HELLO"}) + msg = ws.receive_json() + assert msg["type"] == "WS_WELCOME" + assert "connection_id" in msg["data"] + + def test_ws_welcome_connection_id_is_string(self, client): + with client.websocket_connect("/ws") as ws: + ws.send_json({"type": "WS_HELLO"}) + msg = ws.receive_json() + assert isinstance(msg["data"]["connection_id"], str) + assert len(msg["data"]["connection_id"]) > 0 + + def test_ws_ping_pong(self, client): + with client.websocket_connect("/ws") as ws: + ws.send_json({"type": "PING"}) + msg = ws.receive_json() + assert msg["type"] == "PONG" + + def test_ws_unknown_message_returns_error(self, client): + with client.websocket_connect("/ws") as ws: + ws.send_json({"type": "DOES_NOT_EXIST"}) + msg = ws.receive_json() + assert msg["type"] == "ERROR" + assert msg["data"]["code"] == "UNKNOWN_MESSAGE" + + def test_ws_auth_registered_user(self, client): + token = _register(client, "ws_reg@example.com", "WsReg") + with client.websocket_connect("/ws") as ws: + ws.send_json({"type": "WS_HELLO"}) + ws.receive_json() # WELCOME + ws.send_json({"type": "AUTH_LOGIN", "data": {"token": token}}) + msg = ws.receive_json() + assert msg["type"] == "AUTH_OK" + assert msg["data"]["account_type"] == "registered" + assert msg["data"]["display_name"] == "WsReg" + + def test_ws_auth_guest_user(self, client): + token = _guest(client, "WsGuest") + with client.websocket_connect("/ws") as ws: + ws.send_json({"type": "WS_HELLO"}) + ws.receive_json() + ws.send_json({"type": "AUTH_GUEST", "data": {"token": token}}) + msg = ws.receive_json() + assert msg["type"] == "AUTH_OK" + assert msg["data"]["account_type"] == "guest" + assert msg["data"]["display_name"] == "WsGuest" + + def test_ws_auth_invalid_token_returns_error(self, client): + with client.websocket_connect("/ws") as ws: + ws.send_json({"type": "AUTH_LOGIN", "data": {"token": "not.a.token"}}) + msg = ws.receive_json() + assert msg["type"] == "ERROR" + assert msg["data"]["code"] == "INVALID_TOKEN" + + def test_ws_auth_missing_token_returns_error(self, client): + with client.websocket_connect("/ws") as ws: + ws.send_json({"type": "AUTH_LOGIN", "data": {}}) + msg = ws.receive_json() + assert msg["type"] == "ERROR" + assert msg["data"]["code"] == "MISSING_TOKEN" + + def test_ws_room_create_returns_room_state(self, client): + token = _guest(client, "Creator") + with client.websocket_connect("/ws") as ws: + _ws_auth(ws, token) + ws.send_json({"type": "ROOM_CREATE"}) + msg = ws.receive_json() + assert msg["type"] == "ROOM_STATE" + data = msg["data"] + assert len(data["room_code"]) == 6 + assert data["phase"] == RoomPhase.WAITING + assert data["host_seat_index"] == 0 + assert len(data["participants"]) == 1 + assert data["participants"][0]["controller_type"] == SeatControllerType.REMOTE + + def test_ws_room_create_without_auth_rejected(self, client): + with client.websocket_connect("/ws") as ws: + ws.send_json({"type": "ROOM_CREATE"}) + msg = ws.receive_json() + assert msg["type"] == "ERROR" + assert msg["data"]["code"] == "NOT_AUTHENTICATED" + + def test_ws_room_join_success(self, client, room_service): + """Use room_service fixture to pre-create a room, then join via WS.""" + existing = room_service.create_room("Host", "pre-conn") + room_code = existing.room_code + + token = _guest(client, "Joiner") + with client.websocket_connect("/ws") as ws: + _ws_auth(ws, token) + ws.send_json({"type": "ROOM_JOIN", "data": {"room_code": room_code}}) + msg = ws.receive_json() + assert msg["type"] == "ROOM_STATE" + assert len(msg["data"]["participants"]) == 2 + + def test_ws_room_join_nonexistent_returns_error(self, client): + token = _guest(client, "Orphan") + with client.websocket_connect("/ws") as ws: + _ws_auth(ws, token) + ws.send_json({"type": "ROOM_JOIN", "data": {"room_code": "XXXXXX"}}) + msg = ws.receive_json() + assert msg["type"] == "ERROR" + assert msg["data"]["code"] == "ROOM_ERROR" + assert "not found" in msg["data"]["message"].lower() + + def test_ws_room_join_missing_code_returns_error(self, client): + token = _guest(client, "NoCode") + with client.websocket_connect("/ws") as ws: + _ws_auth(ws, token) + ws.send_json({"type": "ROOM_JOIN", "data": {}}) + msg = ws.receive_json() + assert msg["type"] == "ERROR" + assert msg["data"]["code"] == "MISSING_ROOM_CODE" + + def test_ws_ready_set_true(self, client): + token = _guest(client, "ReadyPlayer") + with client.websocket_connect("/ws") as ws: + _ws_auth(ws, token) + ws.send_json({"type": "ROOM_CREATE"}) + ws.receive_json() # ROOM_STATE from create + ws.send_json({"type": "READY_SET", "data": {"is_ready": True}}) + msg = ws.receive_json() + assert msg["type"] == "ROOM_STATE" + assert msg["data"]["participants"][0]["is_ready"] is True + + def test_ws_ready_set_false(self, client): + token = _guest(client, "UnreadyPlayer") + with client.websocket_connect("/ws") as ws: + _ws_auth(ws, token) + ws.send_json({"type": "ROOM_CREATE"}) + ws.receive_json() + ws.send_json({"type": "READY_SET", "data": {"is_ready": True}}) + ws.receive_json() + ws.send_json({"type": "READY_SET", "data": {"is_ready": False}}) + msg = ws.receive_json() + assert msg["data"]["participants"][0]["is_ready"] is False + + def test_ws_ready_without_room_returns_error(self, client): + token = _guest(client, "NoRoom") + with client.websocket_connect("/ws") as ws: + _ws_auth(ws, token) + ws.send_json({"type": "READY_SET", "data": {"is_ready": True}}) + msg = ws.receive_json() + assert msg["type"] == "ERROR" + assert msg["data"]["code"] == "NOT_IN_ROOM" + + def test_ws_room_start_fills_ai_bots(self, client): + """Single human starts → 3 bots fill empty seats.""" + token = _guest(client, "LoneHost") + with client.websocket_connect("/ws") as ws: + _ws_auth(ws, token) + ws.send_json({"type": "ROOM_CREATE"}) + ws.receive_json() # ROOM_STATE (1 human) + ws.send_json({"type": "ROOM_START"}) + msg = ws.receive_json() + assert msg["type"] == "ROOM_STATE" + data = msg["data"] + assert data["phase"] == RoomPhase.STARTING + assert len(data["participants"]) == 4 + bots = [p for p in data["participants"] if p["controller_type"] == SeatControllerType.BOT] + assert len(bots) == 3 + + def test_ws_room_start_bots_marked_correctly(self, client): + token = _guest(client, "HostOnly") + with client.websocket_connect("/ws") as ws: + _ws_auth(ws, token) + ws.send_json({"type": "ROOM_CREATE"}) + ws.receive_json() + ws.send_json({"type": "ROOM_START"}) + msg = ws.receive_json() + for p in msg["data"]["participants"]: + if p["controller_type"] == SeatControllerType.BOT: + assert p["is_ready"] is True + # user_id is never sent over the wire (privacy fix) + assert "user_id" not in p + + def test_ws_room_start_without_room_returns_error(self, client): + token = _guest(client, "StartNoRoom") + with client.websocket_connect("/ws") as ws: + _ws_auth(ws, token) + ws.send_json({"type": "ROOM_START"}) + msg = ws.receive_json() + assert msg["type"] == "ERROR" + assert msg["data"]["code"] == "NOT_IN_ROOM" + + def test_ws_room_start_non_host_rejected(self, client, room_service): + """Non-host member cannot start the room.""" + room_service.create_room("Host", "host-conn") + # The room code won't be accessible here directly because the host + # connected via a pre-created room fixture — use the repo. + existing = room_service.create_room("Host2", "host-conn-2") + room_code = existing.room_code + + token = _guest(client, "NonHost") + with client.websocket_connect("/ws") as ws: + _ws_auth(ws, token) + ws.send_json({"type": "ROOM_JOIN", "data": {"room_code": room_code}}) + ws.receive_json() # ROOM_STATE (NonHost at seat 1) + ws.send_json({"type": "ROOM_START"}) + msg = ws.receive_json() + assert msg["type"] == "ERROR" + assert msg["data"]["code"] == "ROOM_ERROR" + assert "host" in msg["data"]["message"].lower() + + def test_ws_room_leave_without_room_returns_error(self, client): + token = _guest(client, "LeaveNoRoom") + with client.websocket_connect("/ws") as ws: + _ws_auth(ws, token) + ws.send_json({"type": "ROOM_LEAVE"}) + msg = ws.receive_json() + assert msg["type"] == "ERROR" + assert msg["data"]["code"] == "NOT_IN_ROOM" + + def test_ws_room_leave_success(self, client): + token = _guest(client, "Leaver") + with client.websocket_connect("/ws") as ws: + _ws_auth(ws, token) + ws.send_json({"type": "ROOM_CREATE"}) + ws.receive_json() # ROOM_STATE + ws.send_json({"type": "ROOM_LEAVE"}) + # Room destroyed (last member left) — no broadcast expected. + # Verify we can still send PING without error. + ws.send_json({"type": "PING"}) + msg = ws.receive_json() + assert msg["type"] == "PONG" + + def test_ws_already_in_room_create_rejected(self, client): + token = _guest(client, "DoubleCreate") + with client.websocket_connect("/ws") as ws: + _ws_auth(ws, token) + ws.send_json({"type": "ROOM_CREATE"}) + ws.receive_json() # ROOM_STATE + ws.send_json({"type": "ROOM_CREATE"}) + msg = ws.receive_json() + assert msg["type"] == "ERROR" + assert msg["data"]["code"] == "ALREADY_IN_ROOM" + + def test_ws_already_in_room_join_rejected(self, client, room_service): + existing = room_service.create_room("OtherHost", "other-conn") + token = _guest(client, "DoubleJoin") + with client.websocket_connect("/ws") as ws: + _ws_auth(ws, token) + ws.send_json({"type": "ROOM_CREATE"}) + ws.receive_json() + ws.send_json({"type": "ROOM_JOIN", "data": {"room_code": existing.room_code}}) + msg = ws.receive_json() + assert msg["type"] == "ERROR" + assert msg["data"]["code"] == "ALREADY_IN_ROOM" + + def test_ws_disconnect_cleans_up_room(self, client, room_service): + """Disconnecting the last human destroys the room via the endpoint finally block.""" + token = _guest(client, "Disconnecter") + room_code = None + with client.websocket_connect("/ws") as ws: + _ws_auth(ws, token) + ws.send_json({"type": "ROOM_CREATE"}) + msg = ws.receive_json() + room_code = msg["data"]["room_code"] + # Context exit closes WS; endpoint finally block calls leave_room() + assert room_service.repo.get(room_code) is None + + def test_ws_room_participant_has_no_user_id_on_wire(self, client): + """user_id must not appear in any ROOM_STATE participant entry.""" + token = _guest(client, "PrivacyCheck") + with client.websocket_connect("/ws") as ws: + _ws_auth(ws, token) + ws.send_json({"type": "ROOM_CREATE"}) + msg = ws.receive_json() + for p in msg["data"]["participants"]: + assert "user_id" not in p + + def test_ws_auth_suspended_account_rejected(self, client, db): + """A suspended account's access token must be rejected in WS auth.""" + token = _register(client, "wssuspended@example.com", nickname="WsSuspended") + from app.models.db import User, UserStatus + + user = db.query(User).filter(User.email == "wssuspended@example.com").first() + user.status = UserStatus.SUSPENDED + db.commit() + with client.websocket_connect("/ws") as ws: + ws.send_json({"type": "WS_HELLO"}) + ws.receive_json() + ws.send_json({"type": "AUTH_LOGIN", "data": {"token": token}}) + msg = ws.receive_json() + assert msg["type"] == "ERROR" + assert msg["data"]["code"] == "ACCOUNT_NOT_ACTIVE" + + +# =========================================================================== +# Multi-client WebSocket tests — broadcast, disconnect cleanup, host transfer +# =========================================================================== + + +class TestMultiClientWebSocket: + """ + Tests that require two concurrent WebSocket connections. + Threading is used to hold both connections open simultaneously. + Errors from child threads are re-raised in the main thread. + """ + + def test_join_broadcasts_to_existing_member(self, client): + """When B joins, A receives a ROOM_STATE broadcast with 2 participants.""" + import threading + + token_a = _guest(client, "BroadcastA") + token_b = _guest(client, "BroadcastB") + shared: dict = {"room_code": None, "a_msgs": [], "error": None} + a_ready = threading.Event() + + def run_a(): + try: + with client.websocket_connect("/ws") as ws: + _ws_auth(ws, token_a) + ws.send_json({"type": "ROOM_CREATE"}) + msg = ws.receive_json() # ROOM_STATE (1 member) + shared["room_code"] = msg["data"]["room_code"] + shared["a_msgs"].append(msg) + a_ready.set() + msg2 = ws.receive_json() # broadcast when B joins + shared["a_msgs"].append(msg2) + except Exception as exc: + shared["error"] = exc + a_ready.set() + + def run_b(): + a_ready.wait(timeout=5) + try: + with client.websocket_connect("/ws") as ws: + _ws_auth(ws, token_b) + ws.send_json({"type": "ROOM_JOIN", "data": {"room_code": shared["room_code"]}}) + ws.receive_json() # ROOM_STATE (2 members) + except Exception as exc: + shared["error"] = exc + + t_a = threading.Thread(target=run_a) + t_b = threading.Thread(target=run_b) + t_a.start() + t_b.start() + t_b.join(timeout=5) + t_a.join(timeout=5) + + assert shared["error"] is None, f"Thread error: {shared['error']}" + assert len(shared["a_msgs"]) == 2 + assert len(shared["a_msgs"][1]["data"]["participants"]) == 2 + + def test_room_start_broadcasts_to_all_members(self, client): + """When host starts, all connected members receive the ROOM_STATE broadcast.""" + import threading + + token_a = _guest(client, "StartHostX") + token_b = _guest(client, "StartMemberY") + shared: dict = {"room_code": None, "b_start": None, "error": None} + a_created = threading.Event() + b_joined = threading.Event() + + def run_a(): + try: + with client.websocket_connect("/ws") as ws: + _ws_auth(ws, token_a) + ws.send_json({"type": "ROOM_CREATE"}) + msg = ws.receive_json() + shared["room_code"] = msg["data"]["room_code"] + a_created.set() + b_joined.wait(timeout=5) + ws.receive_json() # B-join broadcast + ws.receive_json() # B-ready broadcast + ws.send_json({"type": "ROOM_START"}) + ws.receive_json() # start broadcast to A + except Exception as exc: + shared["error"] = exc + + def run_b(): + a_created.wait(timeout=5) + try: + with client.websocket_connect("/ws") as ws: + _ws_auth(ws, token_b) + ws.send_json({"type": "ROOM_JOIN", "data": {"room_code": shared["room_code"]}}) + ws.receive_json() # ROOM_STATE after join + ws.send_json({"type": "READY_SET", "data": {"is_ready": True}}) + ws.receive_json() # ROOM_STATE after ready + b_joined.set() + msg = ws.receive_json() # start broadcast to B + shared["b_start"] = msg + except Exception as exc: + shared["error"] = exc + b_joined.set() + + t_a = threading.Thread(target=run_a) + t_b = threading.Thread(target=run_b) + t_a.start() + t_b.start() + t_a.join(timeout=10) + t_b.join(timeout=10) + + assert shared["error"] is None, f"Thread error: {shared['error']}" + assert shared["b_start"] is not None + assert shared["b_start"]["type"] == "ROOM_STATE" + assert shared["b_start"]["data"]["phase"] == RoomPhase.STARTING + + def test_host_disconnect_transfers_host_to_remaining_member(self, client): + """When the host disconnects, the remaining human receives ROOM_STATE with updated host.""" + import threading + + token_a = _guest(client, "DiscoHost") + token_b = _guest(client, "DiscoMember") + shared: dict = {"room_code": None, "b_msgs": [], "error": None} + a_created = threading.Event() + b_joined = threading.Event() + a_should_leave = threading.Event() + + def run_a(): + try: + with client.websocket_connect("/ws") as ws: + _ws_auth(ws, token_a) + ws.send_json({"type": "ROOM_CREATE"}) + msg = ws.receive_json() + shared["room_code"] = msg["data"]["room_code"] + a_created.set() + b_joined.wait(timeout=5) + ws.receive_json() # B-join broadcast + a_should_leave.wait(timeout=5) + # A's context exits here — endpoint finally block fires + except Exception as exc: + shared["error"] = exc + + def run_b(): + a_created.wait(timeout=5) + try: + with client.websocket_connect("/ws") as ws: + _ws_auth(ws, token_b) + ws.send_json({"type": "ROOM_JOIN", "data": {"room_code": shared["room_code"]}}) + msg = ws.receive_json() # ROOM_STATE (2 members) + shared["b_msgs"].append(msg) + b_joined.set() + a_should_leave.set() + msg2 = ws.receive_json() # ROOM_STATE after A disconnects + shared["b_msgs"].append(msg2) + except Exception as exc: + shared["error"] = exc + + t_a = threading.Thread(target=run_a) + t_b = threading.Thread(target=run_b) + t_b.start() + t_a.start() + t_a.join(timeout=10) + t_b.join(timeout=10) + + assert shared["error"] is None, f"Thread error: {shared['error']}" + assert len(shared["b_msgs"]) == 2 + final = shared["b_msgs"][1]["data"] + # B was at seat 1; after A (seat 0) leaves, B becomes host + assert final["host_seat_index"] == 1 + assert len(final["participants"]) == 1 diff --git a/backend/uv.lock b/backend/uv.lock new file mode 100644 index 0000000..f39d20b --- /dev/null +++ b/backend/uv.lock @@ -0,0 +1,1313 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "alembic" +version = "1.18.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "bcrypt" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806, upload-time = "2025-09-25T19:49:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626, upload-time = "2025-09-25T19:49:06.723Z" }, + { url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853, upload-time = "2025-09-25T19:49:08.028Z" }, + { url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793, upload-time = "2025-09-25T19:49:09.727Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930, upload-time = "2025-09-25T19:49:11.204Z" }, + { url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194, upload-time = "2025-09-25T19:49:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381, upload-time = "2025-09-25T19:49:14.308Z" }, + { url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750, upload-time = "2025-09-25T19:49:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757, upload-time = "2025-09-25T19:49:17.244Z" }, + { url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740, upload-time = "2025-09-25T19:49:18.693Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197, upload-time = "2025-09-25T19:49:20.523Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974, upload-time = "2025-09-25T19:49:22.254Z" }, + { url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498, upload-time = "2025-09-25T19:49:24.134Z" }, + { url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853, upload-time = "2025-09-25T19:49:25.702Z" }, + { url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626, upload-time = "2025-09-25T19:49:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" }, + { url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" }, + { url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" }, + { url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" }, + { url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" }, + { url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" }, + { url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" }, + { url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" }, + { url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" }, + { url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" }, + { url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" }, + { url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" }, + { url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" }, + { url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" }, + { url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" }, + { url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" }, + { url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" }, + { url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" }, + { url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" }, + { url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" }, + { url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" }, + { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" }, + { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" }, + { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" }, + { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" }, + { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" }, + { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" }, + { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" }, + { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" }, + { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" }, + { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" }, + { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, + { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, + { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, + { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, + { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, + { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, + { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, + { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, + { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, + { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, + { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, + { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, + { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, + { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, + { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, + { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, + { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, + { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, + { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, + { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, + { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, + { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, + { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, +] + +[[package]] +name = "cryptography" +version = "46.0.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a4/ba/04b1bd4218cbc58dc90ce967106d51582371b898690f3ae0402876cc4f34/cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759", size = 750542, upload-time = "2026-03-25T23:34:53.396Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/23/9285e15e3bc57325b0a72e592921983a701efc1ee8f91c06c5f0235d86d9/cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8", size = 7176401, upload-time = "2026-03-25T23:33:22.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/f8/e61f8f13950ab6195b31913b42d39f0f9afc7d93f76710f299b5ec286ae6/cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30", size = 4275275, upload-time = "2026-03-25T23:33:23.844Z" }, + { url = "https://files.pythonhosted.org/packages/19/69/732a736d12c2631e140be2348b4ad3d226302df63ef64d30dfdb8db7ad1c/cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a", size = 4425320, upload-time = "2026-03-25T23:33:25.703Z" }, + { url = "https://files.pythonhosted.org/packages/d4/12/123be7292674abf76b21ac1fc0e1af50661f0e5b8f0ec8285faac18eb99e/cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175", size = 4278082, upload-time = "2026-03-25T23:33:27.423Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ba/d5e27f8d68c24951b0a484924a84c7cdaed7502bac9f18601cd357f8b1d2/cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463", size = 4926514, upload-time = "2026-03-25T23:33:29.206Z" }, + { url = "https://files.pythonhosted.org/packages/34/71/1ea5a7352ae516d5512d17babe7e1b87d9db5150b21f794b1377eac1edc0/cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97", size = 4457766, upload-time = "2026-03-25T23:33:30.834Z" }, + { url = "https://files.pythonhosted.org/packages/01/59/562be1e653accee4fdad92c7a2e88fced26b3fdfce144047519bbebc299e/cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c", size = 3986535, upload-time = "2026-03-25T23:33:33.02Z" }, + { url = "https://files.pythonhosted.org/packages/d6/8b/b1ebfeb788bf4624d36e45ed2662b8bd43a05ff62157093c1539c1288a18/cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507", size = 4277618, upload-time = "2026-03-25T23:33:34.567Z" }, + { url = "https://files.pythonhosted.org/packages/dd/52/a005f8eabdb28df57c20f84c44d397a755782d6ff6d455f05baa2785bd91/cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19", size = 4890802, upload-time = "2026-03-25T23:33:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4d/8e7d7245c79c617d08724e2efa397737715ca0ec830ecb3c91e547302555/cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738", size = 4457425, upload-time = "2026-03-25T23:33:38.904Z" }, + { url = "https://files.pythonhosted.org/packages/1d/5c/f6c3596a1430cec6f949085f0e1a970638d76f81c3ea56d93d564d04c340/cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c", size = 4405530, upload-time = "2026-03-25T23:33:40.842Z" }, + { url = "https://files.pythonhosted.org/packages/7e/c9/9f9cea13ee2dbde070424e0c4f621c091a91ffcc504ffea5e74f0e1daeff/cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f", size = 4667896, upload-time = "2026-03-25T23:33:42.781Z" }, + { url = "https://files.pythonhosted.org/packages/ad/b5/1895bc0821226f129bc74d00eccfc6a5969e2028f8617c09790bf89c185e/cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2", size = 3026348, upload-time = "2026-03-25T23:33:45.021Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f8/c9bcbf0d3e6ad288b9d9aa0b1dee04b063d19e8c4f871855a03ab3a297ab/cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124", size = 3483896, upload-time = "2026-03-25T23:33:46.649Z" }, + { url = "https://files.pythonhosted.org/packages/01/41/3a578f7fd5c70611c0aacba52cd13cb364a5dee895a5c1d467208a9380b0/cryptography-46.0.6-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275", size = 7117147, upload-time = "2026-03-25T23:33:48.249Z" }, + { url = "https://files.pythonhosted.org/packages/fa/87/887f35a6fca9dde90cad08e0de0c89263a8e59b2d2ff904fd9fcd8025b6f/cryptography-46.0.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4", size = 4266221, upload-time = "2026-03-25T23:33:49.874Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a8/0a90c4f0b0871e0e3d1ed126aed101328a8a57fd9fd17f00fb67e82a51ca/cryptography-46.0.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b", size = 4408952, upload-time = "2026-03-25T23:33:52.128Z" }, + { url = "https://files.pythonhosted.org/packages/16/0b/b239701eb946523e4e9f329336e4ff32b1247e109cbab32d1a7b61da8ed7/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707", size = 4270141, upload-time = "2026-03-25T23:33:54.11Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a8/976acdd4f0f30df7b25605f4b9d3d89295351665c2091d18224f7ad5cdbf/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361", size = 4904178, upload-time = "2026-03-25T23:33:55.725Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1b/bf0e01a88efd0e59679b69f42d4afd5bced8700bb5e80617b2d63a3741af/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b", size = 4441812, upload-time = "2026-03-25T23:33:57.364Z" }, + { url = "https://files.pythonhosted.org/packages/bb/8b/11df86de2ea389c65aa1806f331cae145f2ed18011f30234cc10ca253de8/cryptography-46.0.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca", size = 3963923, upload-time = "2026-03-25T23:33:59.361Z" }, + { url = "https://files.pythonhosted.org/packages/91/e0/207fb177c3a9ef6a8108f234208c3e9e76a6aa8cf20d51932916bd43bda0/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013", size = 4269695, upload-time = "2026-03-25T23:34:00.909Z" }, + { url = "https://files.pythonhosted.org/packages/21/5e/19f3260ed1e95bced52ace7501fabcd266df67077eeb382b79c81729d2d3/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4", size = 4869785, upload-time = "2026-03-25T23:34:02.796Z" }, + { url = "https://files.pythonhosted.org/packages/10/38/cd7864d79aa1d92ef6f1a584281433419b955ad5a5ba8d1eb6c872165bcb/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a", size = 4441404, upload-time = "2026-03-25T23:34:04.35Z" }, + { url = "https://files.pythonhosted.org/packages/09/0a/4fe7a8d25fed74419f91835cf5829ade6408fd1963c9eae9c4bce390ecbb/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d", size = 4397549, upload-time = "2026-03-25T23:34:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a0/7d738944eac6513cd60a8da98b65951f4a3b279b93479a7e8926d9cd730b/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736", size = 4651874, upload-time = "2026-03-25T23:34:07.916Z" }, + { url = "https://files.pythonhosted.org/packages/cb/f1/c2326781ca05208845efca38bf714f76939ae446cd492d7613808badedf1/cryptography-46.0.6-cp314-cp314t-win32.whl", hash = "sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed", size = 3001511, upload-time = "2026-03-25T23:34:09.892Z" }, + { url = "https://files.pythonhosted.org/packages/c9/57/fe4a23eb549ac9d903bd4698ffda13383808ef0876cc912bcb2838799ece/cryptography-46.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4", size = 3471692, upload-time = "2026-03-25T23:34:11.613Z" }, + { url = "https://files.pythonhosted.org/packages/c4/cc/f330e982852403da79008552de9906804568ae9230da8432f7496ce02b71/cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a", size = 7162776, upload-time = "2026-03-25T23:34:13.308Z" }, + { url = "https://files.pythonhosted.org/packages/49/b3/dc27efd8dcc4bff583b3f01d4a3943cd8b5821777a58b3a6a5f054d61b79/cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8", size = 4270529, upload-time = "2026-03-25T23:34:15.019Z" }, + { url = "https://files.pythonhosted.org/packages/e6/05/e8d0e6eb4f0d83365b3cb0e00eb3c484f7348db0266652ccd84632a3d58d/cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77", size = 4414827, upload-time = "2026-03-25T23:34:16.604Z" }, + { url = "https://files.pythonhosted.org/packages/2f/97/daba0f5d2dc6d855e2dcb70733c812558a7977a55dd4a6722756628c44d1/cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290", size = 4271265, upload-time = "2026-03-25T23:34:18.586Z" }, + { url = "https://files.pythonhosted.org/packages/89/06/fe1fce39a37ac452e58d04b43b0855261dac320a2ebf8f5260dd55b201a9/cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410", size = 4916800, upload-time = "2026-03-25T23:34:20.561Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8a/b14f3101fe9c3592603339eb5d94046c3ce5f7fc76d6512a2d40efd9724e/cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d", size = 4448771, upload-time = "2026-03-25T23:34:22.406Z" }, + { url = "https://files.pythonhosted.org/packages/01/b3/0796998056a66d1973fd52ee89dc1bb3b6581960a91ad4ac705f182d398f/cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70", size = 3978333, upload-time = "2026-03-25T23:34:24.281Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3d/db200af5a4ffd08918cd55c08399dc6c9c50b0bc72c00a3246e099d3a849/cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d", size = 4271069, upload-time = "2026-03-25T23:34:25.895Z" }, + { url = "https://files.pythonhosted.org/packages/d7/18/61acfd5b414309d74ee838be321c636fe71815436f53c9f0334bf19064fa/cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa", size = 4878358, upload-time = "2026-03-25T23:34:27.67Z" }, + { url = "https://files.pythonhosted.org/packages/8b/65/5bf43286d566f8171917cae23ac6add941654ccf085d739195a4eacf1674/cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58", size = 4448061, upload-time = "2026-03-25T23:34:29.375Z" }, + { url = "https://files.pythonhosted.org/packages/e0/25/7e49c0fa7205cf3597e525d156a6bce5b5c9de1fd7e8cb01120e459f205a/cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb", size = 4399103, upload-time = "2026-03-25T23:34:32.036Z" }, + { url = "https://files.pythonhosted.org/packages/44/46/466269e833f1c4718d6cd496ffe20c56c9c8d013486ff66b4f69c302a68d/cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72", size = 4659255, upload-time = "2026-03-25T23:34:33.679Z" }, + { url = "https://files.pythonhosted.org/packages/0a/09/ddc5f630cc32287d2c953fc5d32705e63ec73e37308e5120955316f53827/cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c", size = 3010660, upload-time = "2026-03-25T23:34:35.418Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/ca4893968aeb2709aacfb57a30dec6fa2ab25b10fa9f064b8882ce33f599/cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f", size = 3471160, upload-time = "2026-03-25T23:34:37.191Z" }, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "ecdsa" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/ca/8de7744cb3bc966c85430ca2d0fcaeea872507c6a4cf6e007f7fe269ed9d/ecdsa-0.19.2.tar.gz", hash = "sha256:62635b0ac1ca2e027f82122b5b81cb706edc38cd91c63dda28e4f3455a2bf930", size = 202432, upload-time = "2026-03-26T09:58:17.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/79/119091c98e2bf49e24ed9f3ae69f816d715d2904aefa6a2baa039a2ba0b0/ecdsa-0.19.2-py2.py3-none-any.whl", hash = "sha256:840f5dc5e375c68f36c1a7a5b9caad28f95daa65185c9253c0c08dd952bb7399", size = 150818, upload-time = "2026-03-26T09:58:15.808Z" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + +[[package]] +name = "fall-in-backend" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "alembic" }, + { name = "bcrypt" }, + { name = "fastapi" }, + { name = "psycopg2-binary" }, + { name = "pydantic", extra = ["email"] }, + { name = "pydantic-settings" }, + { name = "python-jose", extra = ["cryptography"] }, + { name = "python-multipart" }, + { name = "sqlalchemy" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.optional-dependencies] +dev = [ + { name = "httpx" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] +prod = [ + { name = "redis", extra = ["hiredis"] }, +] + +[package.metadata] +requires-dist = [ + { name = "alembic", specifier = ">=1.14.0" }, + { name = "bcrypt", specifier = ">=4.2.0" }, + { name = "fastapi", specifier = ">=0.115.0" }, + { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.28.0" }, + { name = "psycopg2-binary", specifier = ">=2.9.10" }, + { name = "pydantic", extras = ["email"], specifier = ">=2.10.0" }, + { name = "pydantic-settings", specifier = ">=2.6.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" }, + { name = "python-jose", extras = ["cryptography"], specifier = ">=3.3.0" }, + { name = "python-multipart", specifier = ">=0.0.20" }, + { name = "redis", extras = ["hiredis"], marker = "extra == 'prod'", specifier = ">=5.0.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.14.14" }, + { name = "sqlalchemy", specifier = ">=2.0.36" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.31.0" }, +] +provides-extras = ["dev", "prod"] + +[[package]] +name = "fastapi" +version = "0.135.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/73/5903c4b13beae98618d64eb9870c3fac4f605523dd0312ca5c80dadbd5b9/fastapi-0.135.2.tar.gz", hash = "sha256:88a832095359755527b7f63bb4c6bc9edb8329a026189eed83d6c1afcf419d56", size = 395833, upload-time = "2026-03-23T14:12:41.697Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/ea/18f6d0457f9efb2fc6fa594857f92810cadb03024975726db6546b3d6fcf/fastapi-0.135.2-py3-none-any.whl", hash = "sha256:0af0447d541867e8db2a6a25c23a8c4bd80e2394ac5529bd87501bbb9e240ca5", size = 117407, upload-time = "2026-03-23T14:12:43.284Z" }, +] + +[[package]] +name = "greenlet" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" }, + { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" }, + { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" }, + { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" }, + { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" }, + { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/cc802e067d02af8b60b6771cea7d57e21ef5e6659912814babb42b864713/greenlet-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:34308836d8370bddadb41f5a7ce96879b72e2fdfb4e87729330c6ab52376409f", size = 231081, upload-time = "2026-02-20T20:17:28.121Z" }, + { url = "https://files.pythonhosted.org/packages/58/2e/fe7f36ff1982d6b10a60d5e0740c759259a7d6d2e1dc41da6d96de32fff6/greenlet-3.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:d3a62fa76a32b462a97198e4c9e99afb9ab375115e74e9a83ce180e7a496f643", size = 230331, upload-time = "2026-02-20T20:17:23.34Z" }, + { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" }, + { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" }, + { url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" }, + { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" }, + { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" }, + { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" }, + { url = "https://files.pythonhosted.org/packages/91/39/5ef5aa23bc545aa0d31e1b9b55822b32c8da93ba657295840b6b34124009/greenlet-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:a7945dd0eab63ded0a48e4dcade82939783c172290a7903ebde9e184333ca124", size = 230961, upload-time = "2026-02-20T20:16:58.461Z" }, + { url = "https://files.pythonhosted.org/packages/62/6b/a89f8456dcb06becff288f563618e9f20deed8dd29beea14f9a168aef64b/greenlet-3.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:394ead29063ee3515b4e775216cb756b2e3b4a7e55ae8fd884f17fa579e6b327", size = 230221, upload-time = "2026-02-20T20:17:37.152Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, + { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, + { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, + { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/2101ca3d9223a1dc125140dbc063644dca76df6ff356531eb27bc267b446/greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492", size = 232034, upload-time = "2026-02-20T20:20:08.186Z" }, + { url = "https://files.pythonhosted.org/packages/f6/4a/ecf894e962a59dea60f04877eea0fd5724618da89f1867b28ee8b91e811f/greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71", size = 231437, upload-time = "2026-02-20T20:18:59.722Z" }, + { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, + { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" }, + { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, + { url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "hiredis" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/97/d6/9bef6dc3052c168c93fbf7e6c0f2b12c45f0f741a2d30fd919096774343a/hiredis-3.3.1.tar.gz", hash = "sha256:da6f0302360e99d32bc2869772692797ebadd536e1b826d0103c72ba49d38698", size = 89101, upload-time = "2026-03-16T15:21:08.092Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/1d/1a7d925d886211948ab9cca44221b1d9dd4d3481d015511e98794e37d369/hiredis-3.3.1-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:60543f3b068b16a86e99ed96b7fdae71cdc1d8abdfe9b3f82032a555e52ece7e", size = 82023, upload-time = "2026-03-16T15:19:34.157Z" }, + { url = "https://files.pythonhosted.org/packages/13/2f/a6017fe1db47cd63a4aefc0dd21dd4dcb0c4e857bfbcfaa27329745f24a3/hiredis-3.3.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:2611bfaaadc5e8d43fb7967f9bbf1110c8beaa83aee2f2d812c76f11cfb56c6a", size = 46215, upload-time = "2026-03-16T15:19:35.068Z" }, + { url = "https://files.pythonhosted.org/packages/77/4b/35a71d088c6934e162aa81c7e289fa3110a3aca84ab695d88dbd488c74a2/hiredis-3.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8e3754ce60e1b11b0afad9a053481ff184d2ee24bea47099107156d1b84a84aa", size = 41861, upload-time = "2026-03-16T15:19:36.32Z" }, + { url = "https://files.pythonhosted.org/packages/1f/54/904bc723a95926977764fefd6f0d46067579bac38fffc32b806f3f2c05c0/hiredis-3.3.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e89dabf436ee79b358fd970dcbed6333a36d91db73f27069ca24a02fb138a404", size = 170196, upload-time = "2026-03-16T15:19:37.274Z" }, + { url = "https://files.pythonhosted.org/packages/1d/01/4e840cd4cb53c28578234708b08fb9ec9e41c2880acc0e269a7264e1b3af/hiredis-3.3.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4f7e242eab698ad0be5a4b2ec616fa856569c57455cc67c625fd567726290e5f", size = 181808, upload-time = "2026-03-16T15:19:38.637Z" }, + { url = "https://files.pythonhosted.org/packages/87/0d/fc845f06f8203ab76c401d4d2b97f9fb768e644b053a40f441f7dcc71f2d/hiredis-3.3.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53148a4e21057541b6d8e493b2ea1b500037ddf34433c391970036f3cbce00e3", size = 180577, upload-time = "2026-03-16T15:19:39.749Z" }, + { url = "https://files.pythonhosted.org/packages/52/3a/859afe2620666bf6d58eb977870c47d98af4999d473b50528b323918f3f7/hiredis-3.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25132902d3eff38781e0d54f27a0942ec849e3c07dbdce83c4d92b7e43c8dce", size = 172507, upload-time = "2026-03-16T15:19:40.87Z" }, + { url = "https://files.pythonhosted.org/packages/60/a8/004349708ad8bf0d188d46049f846d3fe2d4a7a8d0d5a6a8ba024017d8b3/hiredis-3.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3fb6573efa15a29c12c0c0f7170b14e7c1347fe4bb39b6a15b779f46015cc929", size = 166339, upload-time = "2026-03-16T15:19:41.912Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fb/bfc6df29381830c99bfd9e97ed3b6d75d9303866a28c23d51ab8c50f63e3/hiredis-3.3.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:487658e1db83c1ee9fbbac6a43039ea76957767a5987ffb16b590613f9e68297", size = 176766, upload-time = "2026-03-16T15:19:42.981Z" }, + { url = "https://files.pythonhosted.org/packages/53/e7/f54aaad4559a413ec8b1043a89567a5a1f898426e4091b9af5e0f2120371/hiredis-3.3.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a1d190790ee39b8b7adeeb10fc4090dc4859eb4e75ed27bd8108710eef18f358", size = 170313, upload-time = "2026-03-16T15:19:44.082Z" }, + { url = "https://files.pythonhosted.org/packages/60/51/b80394db4c74d4cba342fa4208f690a2739c16f1125c2a62ba1701b8e2b7/hiredis-3.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a42c7becd4c9ec4ab5769c754eb61112777bdc6e1c1525e2077389e193b5f5aa", size = 167964, upload-time = "2026-03-16T15:19:45.237Z" }, + { url = "https://files.pythonhosted.org/packages/47/ef/5e438d1e058be57cdc1bafc1b1ec8ab43cc890c61447e88f8b878a0e32c3/hiredis-3.3.1-cp312-cp312-win32.whl", hash = "sha256:17ec8b524055a88b80d76c177dbbbe475a25c17c5bf4b67bdbdbd0629bcae838", size = 20532, upload-time = "2026-03-16T15:19:46.233Z" }, + { url = "https://files.pythonhosted.org/packages/e9/c6/39994b9c5646e7bf7d5e92170c07fd5f224ae9f34d95ff202f31845eb94b/hiredis-3.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:0fac4af8515e6cca74fc701169ae4dc9a71a90e9319c9d21006ec9454b43aa2f", size = 22381, upload-time = "2026-03-16T15:19:47.082Z" }, + { url = "https://files.pythonhosted.org/packages/d8/4b/c7f4d6d6643622f296395269e24b02c69d4ac72822f052b8cae16fa3af03/hiredis-3.3.1-cp313-cp313-macosx_10_15_universal2.whl", hash = "sha256:afe3c3863f16704fb5d7c2c6ff56aaf9e054f6d269f7b4c9074c5476178d1aba", size = 82027, upload-time = "2026-03-16T15:19:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/9b/45/198be960a7443d6eb5045751e929480929c0defbca316ce1a47d15187330/hiredis-3.3.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:f19ee7dc1ef8a6497570d91fa4057ba910ad98297a50b8c44ff37589f7c89d17", size = 46220, upload-time = "2026-03-16T15:19:48.953Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a4/6ab925177f289830008dbe1488a9858675e2e234f48c9c1653bd4d0eaddc/hiredis-3.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:09f5e510f637f2c72d2a79fb3ad05f7b6211e057e367ca5c4f97bb3d8c9d71f4", size = 41858, upload-time = "2026-03-16T15:19:49.939Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c8/a0ddbb9e9c27fcb0022f7b7e93abc75727cb634c6a5273ca5171033dac78/hiredis-3.3.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b46e96b50dad03495447860510daebd2c96fd44ed25ba8ccb03e9f89eaa9d34", size = 170095, upload-time = "2026-03-16T15:19:51.216Z" }, + { url = "https://files.pythonhosted.org/packages/94/06/618d509cc454912028f71995f3dd6eb54606f0aa8163ff79c5b7ec1f2bda/hiredis-3.3.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b4fe7f38aa8956fcc1cea270e62601e0e11066aff78e384be70fd283d30293b6", size = 181745, upload-time = "2026-03-16T15:19:52.72Z" }, + { url = "https://files.pythonhosted.org/packages/06/14/75b2deb62a61fc75a41ce1a6a781fe239133bbc88fef404d32a148ad152a/hiredis-3.3.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b96da7e365d6488d2a75266a662cbe3cc14b28c23dd9b0c9aa04b5bc5c20192", size = 180465, upload-time = "2026-03-16T15:19:53.847Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8c/8e03dcbfde8e2ca3f880fce06ad0877b3f098ed5fdfb17cf3b821a32323a/hiredis-3.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:52d5641027d6731bc7b5e7d126a5158a99784a9f8c6de3d97ca89aca4969e9f8", size = 172419, upload-time = "2026-03-16T15:19:54.959Z" }, + { url = "https://files.pythonhosted.org/packages/03/05/843005d68403a3805309075efc6638360a3ababa6cb4545163bf80c8e7f7/hiredis-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eddeb9a153795cf6e615f9f3cef66a1d573ff3b6ee16df2b10d1d1c2f2baeaa8", size = 166398, upload-time = "2026-03-16T15:19:56.36Z" }, + { url = "https://files.pythonhosted.org/packages/f5/23/abe2476244fd792f5108009ec0ae666eaa5b2165ca19f2e86638d8324ac9/hiredis-3.3.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:011a9071c3df4885cac7f58a2623feac6c8e2ad30e6ba93c55195af05ce61ff5", size = 176844, upload-time = "2026-03-16T15:19:57.462Z" }, + { url = "https://files.pythonhosted.org/packages/c6/47/e1cdccc559b98e548bcff0868c3938d375663418c0adca465895ee1f72e7/hiredis-3.3.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:264ee7e9cb6c30dc78da4ecf71d74cf14ca122817c665d838eda8b4384bce1b0", size = 170366, upload-time = "2026-03-16T15:19:58.548Z" }, + { url = "https://files.pythonhosted.org/packages/a2/e1/fda8325f51d06877e8e92500b15d4aff3855b4c3c91dbd9636a82e4591f2/hiredis-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d1434d0bcc1b3ef048bae53f26456405c08aeed9827e65b24094f5f3a6793f1", size = 168023, upload-time = "2026-03-16T15:19:59.727Z" }, + { url = "https://files.pythonhosted.org/packages/cd/21/2839d1625095989c116470e2b6841bbe1a2a5509585e82a4f3f5cd47f511/hiredis-3.3.1-cp313-cp313-win32.whl", hash = "sha256:f915a34fb742e23d0d61573349aa45d6f74037fde9d58a9f340435eff8d62736", size = 20535, upload-time = "2026-03-16T15:20:00.938Z" }, + { url = "https://files.pythonhosted.org/packages/84/f9/534c2a89b24445a9a9623beb4697fd72b8c8f16286f6f3bda012c7af004a/hiredis-3.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:d8e56e0d1fe607bfff422633f313aec9191c3859ab99d11ff097e3e6e068000c", size = 22383, upload-time = "2026-03-16T15:20:01.865Z" }, + { url = "https://files.pythonhosted.org/packages/03/72/0450d6b449da58120c5497346eb707738f8f67b9e60c28a8ef90133fc81f/hiredis-3.3.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:439f9a5cc8f9519ce208a24cdebfa0440fef26aa682a40ba2c92acb10a53f5e0", size = 82112, upload-time = "2026-03-16T15:20:02.865Z" }, + { url = "https://files.pythonhosted.org/packages/22/c0/0be33a29bcd463e6cbb0282515dd4d0cdfe33c30c7afc6d4d8c460e23266/hiredis-3.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3724f0e58c6ff76fd683429945491de71324ab1bc0ad943a8d68cb0932d24075", size = 46238, upload-time = "2026-03-16T15:20:03.896Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/f999854bfaf3bcbee0f797f24706c182ecfaca825f6a582f6281a6aa97e0/hiredis-3.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29fe35e3c6fe03204e75c86514f452591957a1e06b05d86e10d795455b71c355", size = 41891, upload-time = "2026-03-16T15:20:04.939Z" }, + { url = "https://files.pythonhosted.org/packages/f2/c8/cd9ab90fec3a301d864d8ab6167aea387add8e2287969d89cbcd45d6b0e0/hiredis-3.3.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d42f3a13290f89191568fc113d95a3d2c8759cdd8c3672f021d8b7436f909e75", size = 170485, upload-time = "2026-03-16T15:20:06.284Z" }, + { url = "https://files.pythonhosted.org/packages/ac/9a/1ddf9ea236a292963146cbaf6722abeb9d503ca47d821267bb8b3b81c4f7/hiredis-3.3.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2afc675b831f7552da41116fffffca4340f387dc03f56d6ec0c7895ab0b59a10", size = 182030, upload-time = "2026-03-16T15:20:07.857Z" }, + { url = "https://files.pythonhosted.org/packages/d4/b8/e070a1dbf8a1bbb8814baa0b00836fbe3f10c7af8e11f942cc739c64e062/hiredis-3.3.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4106201cd052d9eabe3cb7b5a24b0fe37307792bda4fcb3cf6ddd72f697828e8", size = 180543, upload-time = "2026-03-16T15:20:09.096Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bb/b5f4f98e44626e2446cd8a52ce6cb1fc1c99786b6e2db3bf09cea97b90cd/hiredis-3.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8887bf0f31e4b550bd988c8863b527b6587d200653e9375cd91eea2b944b7424", size = 172356, upload-time = "2026-03-16T15:20:10.245Z" }, + { url = "https://files.pythonhosted.org/packages/ef/93/73a77b54ba94e82f76d02563c588d8a062513062675f483a033a43015f2c/hiredis-3.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1ac7697365dbe45109273b34227fee6826b276ead9a4a007e0877e1d3f0fcf21", size = 166433, upload-time = "2026-03-16T15:20:11.789Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c2/1b2dcbe5dc53a46a8cb05bed67d190a7e30bad2ad1f727ebe154dfeededd/hiredis-3.3.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2b6da6e07359107c653a809b3cff2d9ccaeedbafe33c6f16434aef6f53ce4a2b", size = 177220, upload-time = "2026-03-16T15:20:12.991Z" }, + { url = "https://files.pythonhosted.org/packages/02/09/f4314cf096552568b5ea785ceb60c424771f4d35a76c410ad39d258f74bc/hiredis-3.3.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:ce334915f5d31048f76a42c607bf26687cf045eb1bc852b7340f09729c6a64fc", size = 170475, upload-time = "2026-03-16T15:20:14.519Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2e/3f56e438efc8fc27ed4a3dbad58c0280061466473ec35d8f86c90c841a84/hiredis-3.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ee11fd431f83d8a5b29d370b9d79a814d3218d30113bdcd44657e9bdf715fc92", size = 167913, upload-time = "2026-03-16T15:20:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/56/34/053e5ee91d6dc478faac661996d1fd4886c5acb7a1b5ac30e7d3c794bb51/hiredis-3.3.1-cp314-cp314-win32.whl", hash = "sha256:e0356561b4a97c83b9ee3de657a41b8d1a1781226853adaf47b550bb988fda6f", size = 21167, upload-time = "2026-03-16T15:20:17.013Z" }, + { url = "https://files.pythonhosted.org/packages/ea/33/06776c641d17881a9031e337e81b3b934c38c2adbb83c85062d6b5f83b72/hiredis-3.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:80aba5f85d6227faee628ae28d1c3b69c661806a0636548ac56c68782606454f", size = 23000, upload-time = "2026-03-16T15:20:17.966Z" }, + { url = "https://files.pythonhosted.org/packages/dd/5a/94f9a505b2ff5376d4a05fb279b69d89bafa7219dd33f6944026e3e56f80/hiredis-3.3.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:907f7b5501a534030738f0f27459a612d2266fd0507b007bb8f3e6de08167920", size = 83039, upload-time = "2026-03-16T15:20:19.316Z" }, + { url = "https://files.pythonhosted.org/packages/93/ae/d3752a8f03a1fca43d402389d2a2d234d3db54c4d1f07f26c1041ca3c5de/hiredis-3.3.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:de94b409f49eb6a588ebdd5872e826caec417cd77c17af0fb94f2128427f1a2a", size = 46703, upload-time = "2026-03-16T15:20:20.401Z" }, + { url = "https://files.pythonhosted.org/packages/9f/76/e32c868a2fa23cd82bacaffd38649d938173244a0e717ec1c0c76874dbdd/hiredis-3.3.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79cd03e7ff550c17758a7520bf437c156d3d4c8bb74214deeafa69cda49c85a4", size = 42379, upload-time = "2026-03-16T15:20:21.705Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f6/d687d36a74ce6cf448826cf2e8edfc1eb37cc965308f74eb696aa97c69df/hiredis-3.3.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ffa7ba2e2da1f806f3181b9730b3e87ba9dbfec884806725d4584055ba3faa6", size = 180311, upload-time = "2026-03-16T15:20:23.037Z" }, + { url = "https://files.pythonhosted.org/packages/db/ac/f520dc0066a62a15aa920c7dd0a2028c213f4862d5f901409ae92ee5d785/hiredis-3.3.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ee37fe8cf081b72dea72f96a0ee604f492ec02252eb77dc26ff6eec3f997b580", size = 190488, upload-time = "2026-03-16T15:20:24.357Z" }, + { url = "https://files.pythonhosted.org/packages/4d/f5/ae10fff82d0f291e90c41bf10a5d6543a96aae00cccede01bf2b6f7e178d/hiredis-3.3.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9bfdeff778d3f7ff449ca5922ab773899e7d31e26a576028b06a5e9cf0ed8c34", size = 189210, upload-time = "2026-03-16T15:20:25.51Z" }, + { url = "https://files.pythonhosted.org/packages/0f/8f/5be4344e542aa8d349a03d05486c59d9ca26f69c749d11e114bf34b84d50/hiredis-3.3.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:027ce4fabfeff5af5b9869d5524770877f9061d118bc36b85703ae3faf5aad8e", size = 180971, upload-time = "2026-03-16T15:20:26.631Z" }, + { url = "https://files.pythonhosted.org/packages/41/a2/29e230226ec2a31f13f8a832fbafe366e263f3b090553ebe49bb4581a7bd/hiredis-3.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:dcea8c3f53674ae68e44b12e853b844a1d315250ca6677b11ec0c06aff85e86c", size = 175314, upload-time = "2026-03-16T15:20:27.848Z" }, + { url = "https://files.pythonhosted.org/packages/89/2e/bf241707ad86b9f3ebfbc7ab89e19d5ec243ff92ca77644a383622e8740b/hiredis-3.3.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0b5ff2f643f4b452b0597b7fe6aa35d398cb31d8806801acfafb1558610ea2aa", size = 185652, upload-time = "2026-03-16T15:20:29.364Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c1/b39170d8bcccd01febd45af4ac6b43ff38e134a868e2ec167a82a036fb35/hiredis-3.3.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3586c8a5f56d34b9dddaaa9e76905f31933cac267251006adf86ec0eef7d0400", size = 179033, upload-time = "2026-03-16T15:20:30.549Z" }, + { url = "https://files.pythonhosted.org/packages/b7/3a/4fe39a169115434f911abff08ff485b9b6201c168500e112b3f6a8110c0a/hiredis-3.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a110d19881ca78a88583d3b07231e7c6864864f5f1f3491b638863ea45fa8708", size = 176126, upload-time = "2026-03-16T15:20:31.958Z" }, + { url = "https://files.pythonhosted.org/packages/44/99/c1d0b0bc4f9e9150e24beb0dca2e186e32d5e749d0022e0d26453749ed51/hiredis-3.3.1-cp314-cp314t-win32.whl", hash = "sha256:98fd5b39410e9d69e10e90d0330e35650becaa5dd2548f509b9598f1f3c6124d", size = 22028, upload-time = "2026-03-16T15:20:33.33Z" }, + { url = "https://files.pythonhosted.org/packages/35/d6/191e6741addc97bcf5e755661f8c82f0fd0aa35f07ece56e858da689b57e/hiredis-3.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ab1f646ff531d70bfd25f01e60708dfa3d105eb458b7dedd9fe9a443039fd809", size = 23811, upload-time = "2026-03-16T15:20:34.292Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, + { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, + { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, + { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, + { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, + { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, + { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, + { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, + { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, + { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, + { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, + { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, + { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "mako" +version = "1.3.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "psycopg2-binary" +version = "2.9.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/6c/8767aaa597ba424643dc87348c6f1754dd9f48e80fdc1b9f7ca5c3a7c213/psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c", size = 379620, upload-time = "2025-10-10T11:14:48.041Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/91/f870a02f51be4a65987b45a7de4c2e1897dd0d01051e2b559a38fa634e3e/psycopg2_binary-2.9.11-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:be9b840ac0525a283a96b556616f5b4820e0526addb8dcf6525a0fa162730be4", size = 3756603, upload-time = "2025-10-10T11:11:52.213Z" }, + { url = "https://files.pythonhosted.org/packages/27/fa/cae40e06849b6c9a95eb5c04d419942f00d9eaac8d81626107461e268821/psycopg2_binary-2.9.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f090b7ddd13ca842ebfe301cd587a76a4cf0913b1e429eb92c1be5dbeb1a19bc", size = 3864509, upload-time = "2025-10-10T11:11:56.452Z" }, + { url = "https://files.pythonhosted.org/packages/2d/75/364847b879eb630b3ac8293798e380e441a957c53657995053c5ec39a316/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ab8905b5dcb05bf3fb22e0cf90e10f469563486ffb6a96569e51f897c750a76a", size = 4411159, upload-time = "2025-10-10T11:12:00.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a0/567f7ea38b6e1c62aafd58375665a547c00c608a471620c0edc364733e13/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf940cd7e7fec19181fdbc29d76911741153d51cab52e5c21165f3262125685e", size = 4468234, upload-time = "2025-10-10T11:12:04.892Z" }, + { url = "https://files.pythonhosted.org/packages/30/da/4e42788fb811bbbfd7b7f045570c062f49e350e1d1f3df056c3fb5763353/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa0f693d3c68ae925966f0b14b8edda71696608039f4ed61b1fe9ffa468d16db", size = 4166236, upload-time = "2025-10-10T11:12:11.674Z" }, + { url = "https://files.pythonhosted.org/packages/3c/94/c1777c355bc560992af848d98216148be5f1be001af06e06fc49cbded578/psycopg2_binary-2.9.11-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a1cf393f1cdaf6a9b57c0a719a1068ba1069f022a59b8b1fe44b006745b59757", size = 3983083, upload-time = "2025-10-30T02:55:15.73Z" }, + { url = "https://files.pythonhosted.org/packages/bd/42/c9a21edf0e3daa7825ed04a4a8588686c6c14904344344a039556d78aa58/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7a6beb4beaa62f88592ccc65df20328029d721db309cb3250b0aae0fa146c3", size = 3652281, upload-time = "2025-10-10T11:12:17.713Z" }, + { url = "https://files.pythonhosted.org/packages/12/22/dedfbcfa97917982301496b6b5e5e6c5531d1f35dd2b488b08d1ebc52482/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:31b32c457a6025e74d233957cc9736742ac5a6cb196c6b68499f6bb51390bd6a", size = 3298010, upload-time = "2025-10-10T11:12:22.671Z" }, + { url = "https://files.pythonhosted.org/packages/66/ea/d3390e6696276078bd01b2ece417deac954dfdd552d2edc3d03204416c0c/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:edcb3aeb11cb4bf13a2af3c53a15b3d612edeb6409047ea0b5d6a21a9d744b34", size = 3044641, upload-time = "2025-10-30T02:55:19.929Z" }, + { url = "https://files.pythonhosted.org/packages/12/9a/0402ded6cbd321da0c0ba7d34dc12b29b14f5764c2fc10750daa38e825fc/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b6d93d7c0b61a1dd6197d208ab613eb7dcfdcca0a49c42ceb082257991de9d", size = 3347940, upload-time = "2025-10-10T11:12:26.529Z" }, + { url = "https://files.pythonhosted.org/packages/b1/d2/99b55e85832ccde77b211738ff3925a5d73ad183c0b37bcbbe5a8ff04978/psycopg2_binary-2.9.11-cp312-cp312-win_amd64.whl", hash = "sha256:b33fabeb1fde21180479b2d4667e994de7bbf0eec22832ba5d9b5e4cf65b6c6d", size = 2714147, upload-time = "2025-10-10T11:12:29.535Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a8/a2709681b3ac11b0b1786def10006b8995125ba268c9a54bea6f5ae8bd3e/psycopg2_binary-2.9.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b8fb3db325435d34235b044b199e56cdf9ff41223a4b9752e8576465170bb38c", size = 3756572, upload-time = "2025-10-10T11:12:32.873Z" }, + { url = "https://files.pythonhosted.org/packages/62/e1/c2b38d256d0dafd32713e9f31982a5b028f4a3651f446be70785f484f472/psycopg2_binary-2.9.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:366df99e710a2acd90efed3764bb1e28df6c675d33a7fb40df9b7281694432ee", size = 3864529, upload-time = "2025-10-10T11:12:36.791Z" }, + { url = "https://files.pythonhosted.org/packages/11/32/b2ffe8f3853c181e88f0a157c5fb4e383102238d73c52ac6d93a5c8bffe6/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c55b385daa2f92cb64b12ec4536c66954ac53654c7f15a203578da4e78105c0", size = 4411242, upload-time = "2025-10-10T11:12:42.388Z" }, + { url = "https://files.pythonhosted.org/packages/10/04/6ca7477e6160ae258dc96f67c371157776564679aefd247b66f4661501a2/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c0377174bf1dd416993d16edc15357f6eb17ac998244cca19bc67cdc0e2e5766", size = 4468258, upload-time = "2025-10-10T11:12:48.654Z" }, + { url = "https://files.pythonhosted.org/packages/3c/7e/6a1a38f86412df101435809f225d57c1a021307dd0689f7a5e7fe83588b1/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6ff3335ce08c75afaed19e08699e8aacf95d4a260b495a4a8545244fe2ceb3", size = 4166295, upload-time = "2025-10-10T11:12:52.525Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7d/c07374c501b45f3579a9eb761cbf2604ddef3d96ad48679112c2c5aa9c25/psycopg2_binary-2.9.11-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84011ba3109e06ac412f95399b704d3d6950e386b7994475b231cf61eec2fc1f", size = 3983133, upload-time = "2025-10-30T02:55:24.329Z" }, + { url = "https://files.pythonhosted.org/packages/82/56/993b7104cb8345ad7d4516538ccf8f0d0ac640b1ebd8c754a7b024e76878/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ba34475ceb08cccbdd98f6b46916917ae6eeb92b5ae111df10b544c3a4621dc4", size = 3652383, upload-time = "2025-10-10T11:12:56.387Z" }, + { url = "https://files.pythonhosted.org/packages/2d/ac/eaeb6029362fd8d454a27374d84c6866c82c33bfc24587b4face5a8e43ef/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b31e90fdd0f968c2de3b26ab014314fe814225b6c324f770952f7d38abf17e3c", size = 3298168, upload-time = "2025-10-10T11:13:00.403Z" }, + { url = "https://files.pythonhosted.org/packages/2b/39/50c3facc66bded9ada5cbc0de867499a703dc6bca6be03070b4e3b65da6c/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d526864e0f67f74937a8fce859bd56c979f5e2ec57ca7c627f5f1071ef7fee60", size = 3044712, upload-time = "2025-10-30T02:55:27.975Z" }, + { url = "https://files.pythonhosted.org/packages/9c/8e/b7de019a1f562f72ada81081a12823d3c1590bedc48d7d2559410a2763fe/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04195548662fa544626c8ea0f06561eb6203f1984ba5b4562764fbeb4c3d14b1", size = 3347549, upload-time = "2025-10-10T11:13:03.971Z" }, + { url = "https://files.pythonhosted.org/packages/80/2d/1bb683f64737bbb1f86c82b7359db1eb2be4e2c0c13b947f80efefa7d3e5/psycopg2_binary-2.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:efff12b432179443f54e230fdf60de1f6cc726b6c832db8701227d089310e8aa", size = 2714215, upload-time = "2025-10-10T11:13:07.14Z" }, + { url = "https://files.pythonhosted.org/packages/64/12/93ef0098590cf51d9732b4f139533732565704f45bdc1ffa741b7c95fb54/psycopg2_binary-2.9.11-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:92e3b669236327083a2e33ccfa0d320dd01b9803b3e14dd986a4fc54aa00f4e1", size = 3756567, upload-time = "2025-10-10T11:13:11.885Z" }, + { url = "https://files.pythonhosted.org/packages/7c/a9/9d55c614a891288f15ca4b5209b09f0f01e3124056924e17b81b9fa054cc/psycopg2_binary-2.9.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e0deeb03da539fa3577fcb0b3f2554a97f7e5477c246098dbb18091a4a01c16f", size = 3864755, upload-time = "2025-10-10T11:13:17.727Z" }, + { url = "https://files.pythonhosted.org/packages/13/1e/98874ce72fd29cbde93209977b196a2edae03f8490d1bd8158e7f1daf3a0/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b52a3f9bb540a3e4ec0f6ba6d31339727b2950c9772850d6545b7eae0b9d7c5", size = 4411646, upload-time = "2025-10-10T11:13:24.432Z" }, + { url = "https://files.pythonhosted.org/packages/5a/bd/a335ce6645334fb8d758cc358810defca14a1d19ffbc8a10bd38a2328565/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:db4fd476874ccfdbb630a54426964959e58da4c61c9feba73e6094d51303d7d8", size = 4468701, upload-time = "2025-10-10T11:13:29.266Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/c8b4f53f34e295e45709b7568bf9b9407a612ea30387d35eb9fa84f269b4/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47f212c1d3be608a12937cc131bd85502954398aaa1320cb4c14421a0ffccf4c", size = 4166293, upload-time = "2025-10-10T11:13:33.336Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e0/f8cc36eadd1b716ab36bb290618a3292e009867e5c97ce4aba908cb99644/psycopg2_binary-2.9.11-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e35b7abae2b0adab776add56111df1735ccc71406e56203515e228a8dc07089f", size = 3983184, upload-time = "2025-10-30T02:55:32.483Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/2a8fe18a4e61cfb3417da67b6318e12691772c0696d79434184a511906dc/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fcf21be3ce5f5659daefd2b3b3b6e4727b028221ddc94e6c1523425579664747", size = 3652650, upload-time = "2025-10-10T11:13:38.181Z" }, + { url = "https://files.pythonhosted.org/packages/76/36/03801461b31b29fe58d228c24388f999fe814dfc302856e0d17f97d7c54d/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9bd81e64e8de111237737b29d68039b9c813bdf520156af36d26819c9a979e5f", size = 3298663, upload-time = "2025-10-10T11:13:44.878Z" }, + { url = "https://files.pythonhosted.org/packages/97/77/21b0ea2e1a73aa5fa9222b2a6b8ba325c43c3a8d54272839c991f2345656/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:32770a4d666fbdafab017086655bcddab791d7cb260a16679cc5a7338b64343b", size = 3044737, upload-time = "2025-10-30T02:55:35.69Z" }, + { url = "https://files.pythonhosted.org/packages/67/69/f36abe5f118c1dca6d3726ceae164b9356985805480731ac6712a63f24f0/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3cb3a676873d7506825221045bd70e0427c905b9c8ee8d6acd70cfcbd6e576d", size = 3347643, upload-time = "2025-10-10T11:13:53.499Z" }, + { url = "https://files.pythonhosted.org/packages/e1/36/9c0c326fe3a4227953dfb29f5d0c8ae3b8eb8c1cd2967aa569f50cb3c61f/psycopg2_binary-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316", size = 2803913, upload-time = "2025-10-10T11:13:57.058Z" }, +] + +[[package]] +name = "pyasn1" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-jose" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ecdsa" }, + { name = "pyasn1" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/77/3a1c9039db7124eb039772b935f2244fbb73fc8ee65b9acf2375da1c07bf/python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b", size = 92726, upload-time = "2025-05-28T17:31:54.288Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" }, +] + +[package.optional-dependencies] +cryptography = [ + { name = "cryptography" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "redis" +version = "7.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/7f/3759b1d0d72b7c92f0d70ffd9dc962b7b7b5ee74e135f9d7d8ab06b8a318/redis-7.4.0.tar.gz", hash = "sha256:64a6ea7bf567ad43c964d2c30d82853f8df927c5c9017766c55a1d1ed95d18ad", size = 4943913, upload-time = "2026-03-24T09:14:37.53Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/3a/95deec7db1eb53979973ebd156f3369a72732208d1391cd2e5d127062a32/redis-7.4.0-py3-none-any.whl", hash = "sha256:a9c74a5c893a5ef8455a5adb793a31bb70feb821c86eccb62eebef5a19c429ec", size = 409772, upload-time = "2026-03-24T09:14:35.968Z" }, +] + +[package.optional-dependencies] +hiredis = [ + { name = "hiredis" }, +] + +[[package]] +name = "rsa" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/14/b0/73cf7550861e2b4824950b8b52eebdcc5adc792a00c514406556c5b80817/ruff-0.15.8.tar.gz", hash = "sha256:995f11f63597ee362130d1d5a327a87cb6f3f5eae3094c620bcc632329a4d26e", size = 4610921, upload-time = "2026-03-26T18:39:38.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/92/c445b0cd6da6e7ae51e954939cb69f97e008dbe750cfca89b8cedc081be7/ruff-0.15.8-py3-none-linux_armv6l.whl", hash = "sha256:cbe05adeba76d58162762d6b239c9056f1a15a55bd4b346cfd21e26cd6ad7bc7", size = 10527394, upload-time = "2026-03-26T18:39:41.566Z" }, + { url = "https://files.pythonhosted.org/packages/eb/92/f1c662784d149ad1414cae450b082cf736430c12ca78367f20f5ed569d65/ruff-0.15.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d3e3d0b6ba8dca1b7ef9ab80a28e840a20070c4b62e56d675c24f366ef330570", size = 10905693, upload-time = "2026-03-26T18:39:30.364Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f2/7a631a8af6d88bcef997eb1bf87cc3da158294c57044aafd3e17030613de/ruff-0.15.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ee3ae5c65a42f273f126686353f2e08ff29927b7b7e203b711514370d500de3", size = 10323044, upload-time = "2026-03-26T18:39:33.37Z" }, + { url = "https://files.pythonhosted.org/packages/67/18/1bf38e20914a05e72ef3b9569b1d5c70a7ef26cd188d69e9ca8ef588d5bf/ruff-0.15.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdce027ada77baa448077ccc6ebb2fa9c3c62fd110d8659d601cf2f475858d94", size = 10629135, upload-time = "2026-03-26T18:39:44.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/e9/138c150ff9af60556121623d41aba18b7b57d95ac032e177b6a53789d279/ruff-0.15.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12e617fc01a95e5821648a6df341d80456bd627bfab8a829f7cfc26a14a4b4a3", size = 10348041, upload-time = "2026-03-26T18:39:52.178Z" }, + { url = "https://files.pythonhosted.org/packages/02/f1/5bfb9298d9c323f842c5ddeb85f1f10ef51516ac7a34ba446c9347d898df/ruff-0.15.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:432701303b26416d22ba696c39f2c6f12499b89093b61360abc34bcc9bf07762", size = 11121987, upload-time = "2026-03-26T18:39:55.195Z" }, + { url = "https://files.pythonhosted.org/packages/10/11/6da2e538704e753c04e8d86b1fc55712fdbdcc266af1a1ece7a51fff0d10/ruff-0.15.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d910ae974b7a06a33a057cb87d2a10792a3b2b3b35e33d2699fdf63ec8f6b17a", size = 11951057, upload-time = "2026-03-26T18:39:19.18Z" }, + { url = "https://files.pythonhosted.org/packages/83/f0/c9208c5fd5101bf87002fed774ff25a96eea313d305f1e5d5744698dc314/ruff-0.15.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2033f963c43949d51e6fdccd3946633c6b37c484f5f98c3035f49c27395a8ab8", size = 11464613, upload-time = "2026-03-26T18:40:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/f8/22/d7f2fabdba4fae9f3b570e5605d5eb4500dcb7b770d3217dca4428484b17/ruff-0.15.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f29b989a55572fb885b77464cf24af05500806ab4edf9a0fd8977f9759d85b1", size = 11257557, upload-time = "2026-03-26T18:39:57.972Z" }, + { url = "https://files.pythonhosted.org/packages/71/8c/382a9620038cf6906446b23ce8632ab8c0811b8f9d3e764f58bedd0c9a6f/ruff-0.15.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ac51d486bf457cdc985a412fb1801b2dfd1bd8838372fc55de64b1510eff4bec", size = 11169440, upload-time = "2026-03-26T18:39:22.205Z" }, + { url = "https://files.pythonhosted.org/packages/4d/0d/0994c802a7eaaf99380085e4e40c845f8e32a562e20a38ec06174b52ef24/ruff-0.15.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c9861eb959edab053c10ad62c278835ee69ca527b6dcd72b47d5c1e5648964f6", size = 10605963, upload-time = "2026-03-26T18:39:46.682Z" }, + { url = "https://files.pythonhosted.org/packages/19/aa/d624b86f5b0aad7cef6bbf9cd47a6a02dfdc4f72c92a337d724e39c9d14b/ruff-0.15.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8d9a5b8ea13f26ae90838afc33f91b547e61b794865374f114f349e9036835fb", size = 10357484, upload-time = "2026-03-26T18:39:49.176Z" }, + { url = "https://files.pythonhosted.org/packages/35/c3/e0b7835d23001f7d999f3895c6b569927c4d39912286897f625736e1fd04/ruff-0.15.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c2a33a529fb3cbc23a7124b5c6ff121e4d6228029cba374777bd7649cc8598b8", size = 10830426, upload-time = "2026-03-26T18:40:03.702Z" }, + { url = "https://files.pythonhosted.org/packages/f0/51/ab20b322f637b369383adc341d761eaaa0f0203d6b9a7421cd6e783d81b9/ruff-0.15.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:75e5cd06b1cf3f47a3996cfc999226b19aa92e7cce682dcd62f80d7035f98f49", size = 11345125, upload-time = "2026-03-26T18:39:27.799Z" }, + { url = "https://files.pythonhosted.org/packages/37/e6/90b2b33419f59d0f2c4c8a48a4b74b460709a557e8e0064cf33ad894f983/ruff-0.15.8-py3-none-win32.whl", hash = "sha256:bc1f0a51254ba21767bfa9a8b5013ca8149dcf38092e6a9eb704d876de94dc34", size = 10571959, upload-time = "2026-03-26T18:39:36.117Z" }, + { url = "https://files.pythonhosted.org/packages/1f/a2/ef467cb77099062317154c63f234b8a7baf7cb690b99af760c5b68b9ee7f/ruff-0.15.8-py3-none-win_amd64.whl", hash = "sha256:04f79eff02a72db209d47d665ba7ebcad609d8918a134f86cb13dd132159fc89", size = 11743893, upload-time = "2026-03-26T18:39:25.01Z" }, + { url = "https://files.pythonhosted.org/packages/15/e2/77be4fff062fa78d9b2a4dea85d14785dac5f1d0c1fb58ed52331f0ebe28/ruff-0.15.8-py3-none-win_arm64.whl", hash = "sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2", size = 11048175, upload-time = "2026-03-26T18:40:01.06Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.48" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/73/b4a9737255583b5fa858e0bb8e116eb94b88c910164ed2ed719147bde3de/sqlalchemy-2.0.48.tar.gz", hash = "sha256:5ca74f37f3369b45e1f6b7b06afb182af1fd5dde009e4ffd831830d98cbe5fe7", size = 9886075, upload-time = "2026-03-02T15:28:51.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/91/a42ae716f8925e9659df2da21ba941f158686856107a61cc97a95e7647a3/sqlalchemy-2.0.48-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:348174f228b99f33ca1f773e85510e08927620caa59ffe7803b37170df30332b", size = 2155737, upload-time = "2026-03-02T15:49:13.207Z" }, + { url = "https://files.pythonhosted.org/packages/b9/52/f75f516a1f3888f027c1cfb5d22d4376f4b46236f2e8669dcb0cddc60275/sqlalchemy-2.0.48-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53667b5f668991e279d21f94ccfa6e45b4e3f4500e7591ae59a8012d0f010dcb", size = 3337020, upload-time = "2026-03-02T15:50:34.547Z" }, + { url = "https://files.pythonhosted.org/packages/37/9a/0c28b6371e0cdcb14f8f1930778cb3123acfcbd2c95bb9cf6b4a2ba0cce3/sqlalchemy-2.0.48-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34634e196f620c7a61d18d5cf7dc841ca6daa7961aed75d532b7e58b309ac894", size = 3349983, upload-time = "2026-03-02T15:53:25.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/46/0aee8f3ff20b1dcbceb46ca2d87fcc3d48b407925a383ff668218509d132/sqlalchemy-2.0.48-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:546572a1793cc35857a2ffa1fe0e58571af1779bcc1ffa7c9fb0839885ed69a9", size = 3279690, upload-time = "2026-03-02T15:50:36.277Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/a957bc91293b49181350bfd55e6dfc6e30b7f7d83dc6792d72043274a390/sqlalchemy-2.0.48-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:07edba08061bc277bfdc772dd2a1a43978f5a45994dd3ede26391b405c15221e", size = 3314738, upload-time = "2026-03-02T15:53:27.519Z" }, + { url = "https://files.pythonhosted.org/packages/4b/44/1d257d9f9556661e7bdc83667cc414ba210acfc110c82938cb3611eea58f/sqlalchemy-2.0.48-cp312-cp312-win32.whl", hash = "sha256:908a3fa6908716f803b86896a09a2c4dde5f5ce2bb07aacc71ffebb57986ce99", size = 2115546, upload-time = "2026-03-02T15:54:31.591Z" }, + { url = "https://files.pythonhosted.org/packages/f2/af/c3c7e1f3a2b383155a16454df62ae8c62a30dd238e42e68c24cebebbfae6/sqlalchemy-2.0.48-cp312-cp312-win_amd64.whl", hash = "sha256:68549c403f79a8e25984376480959975212a670405e3913830614432b5daa07a", size = 2142484, upload-time = "2026-03-02T15:54:34.072Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c6/569dc8bf3cd375abc5907e82235923e986799f301cd79a903f784b996fca/sqlalchemy-2.0.48-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e3070c03701037aa418b55d36532ecb8f8446ed0135acb71c678dbdf12f5b6e4", size = 2152599, upload-time = "2026-03-02T15:49:14.41Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ff/f4e04a4bd5a24304f38cb0d4aa2ad4c0fb34999f8b884c656535e1b2b74c/sqlalchemy-2.0.48-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2645b7d8a738763b664a12a1542c89c940daa55196e8d73e55b169cc5c99f65f", size = 3278825, upload-time = "2026-03-02T15:50:38.269Z" }, + { url = "https://files.pythonhosted.org/packages/fe/88/cb59509e4668d8001818d7355d9995be90c321313078c912420603a7cb95/sqlalchemy-2.0.48-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b19151e76620a412c2ac1c6f977ab1b9fa7ad43140178345136456d5265b32ed", size = 3295200, upload-time = "2026-03-02T15:53:29.366Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/1609a4442aefd750ea2f32629559394ec92e89ac1d621a7f462b70f736ff/sqlalchemy-2.0.48-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5b193a7e29fd9fa56e502920dca47dffe60f97c863494946bd698c6058a55658", size = 3226876, upload-time = "2026-03-02T15:50:39.802Z" }, + { url = "https://files.pythonhosted.org/packages/37/c3/6ae2ab5ea2fa989fbac4e674de01224b7a9d744becaf59bb967d62e99bed/sqlalchemy-2.0.48-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:36ac4ddc3d33e852da9cb00ffb08cea62ca05c39711dc67062ca2bb1fae35fd8", size = 3265045, upload-time = "2026-03-02T15:53:31.421Z" }, + { url = "https://files.pythonhosted.org/packages/6f/82/ea4665d1bb98c50c19666e672f21b81356bd6077c4574e3d2bbb84541f53/sqlalchemy-2.0.48-cp313-cp313-win32.whl", hash = "sha256:389b984139278f97757ea9b08993e7b9d1142912e046ab7d82b3fbaeb0209131", size = 2113700, upload-time = "2026-03-02T15:54:35.825Z" }, + { url = "https://files.pythonhosted.org/packages/b7/2b/b9040bec58c58225f073f5b0c1870defe1940835549dafec680cbd58c3c3/sqlalchemy-2.0.48-cp313-cp313-win_amd64.whl", hash = "sha256:d612c976cbc2d17edfcc4c006874b764e85e990c29ce9bd411f926bbfb02b9a2", size = 2139487, upload-time = "2026-03-02T15:54:37.079Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/7b17bd50244b78a49d22cc63c969d71dc4de54567dc152a9b46f6fae40ce/sqlalchemy-2.0.48-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69f5bc24904d3bc3640961cddd2523e361257ef68585d6e364166dfbe8c78fae", size = 3558851, upload-time = "2026-03-02T15:57:48.607Z" }, + { url = "https://files.pythonhosted.org/packages/20/0d/213668e9aca61d370f7d2a6449ea4ec699747fac67d4bda1bb3d129025be/sqlalchemy-2.0.48-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd08b90d211c086181caed76931ecfa2bdfc83eea3cfccdb0f82abc6c4b876cb", size = 3525525, upload-time = "2026-03-02T16:04:38.058Z" }, + { url = "https://files.pythonhosted.org/packages/85/d7/a84edf412979e7d59c69b89a5871f90a49228360594680e667cb2c46a828/sqlalchemy-2.0.48-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1ccd42229aaac2df431562117ac7e667d702e8e44afdb6cf0e50fa3f18160f0b", size = 3466611, upload-time = "2026-03-02T15:57:50.759Z" }, + { url = "https://files.pythonhosted.org/packages/86/55/42404ce5770f6be26a2b0607e7866c31b9a4176c819e9a7a5e0a055770be/sqlalchemy-2.0.48-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0dcbc588cd5b725162c076eb9119342f6579c7f7f55057bb7e3c6ff27e13121", size = 3475812, upload-time = "2026-03-02T16:04:40.092Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ae/29b87775fadc43e627cf582fe3bda4d02e300f6b8f2747c764950d13784c/sqlalchemy-2.0.48-cp313-cp313t-win32.whl", hash = "sha256:9764014ef5e58aab76220c5664abb5d47d5bc858d9debf821e55cfdd0f128485", size = 2141335, upload-time = "2026-03-02T15:52:51.518Z" }, + { url = "https://files.pythonhosted.org/packages/91/44/f39d063c90f2443e5b46ec4819abd3d8de653893aae92df42a5c4f5843de/sqlalchemy-2.0.48-cp313-cp313t-win_amd64.whl", hash = "sha256:e2f35b4cccd9ed286ad62e0a3c3ac21e06c02abc60e20aa51a3e305a30f5fa79", size = 2173095, upload-time = "2026-03-02T15:52:52.79Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b3/f437eaa1cf028bb3c927172c7272366393e73ccd104dcf5b6963f4ab5318/sqlalchemy-2.0.48-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e2d0d88686e3d35a76f3e15a34e8c12d73fc94c1dea1cd55782e695cc14086dd", size = 2154401, upload-time = "2026-03-02T15:49:17.24Z" }, + { url = "https://files.pythonhosted.org/packages/6c/1c/b3abdf0f402aa3f60f0df6ea53d92a162b458fca2321d8f1f00278506402/sqlalchemy-2.0.48-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49b7bddc1eebf011ea5ab722fdbe67a401caa34a350d278cc7733c0e88fecb1f", size = 3274528, upload-time = "2026-03-02T15:50:41.489Z" }, + { url = "https://files.pythonhosted.org/packages/f2/5e/327428a034407651a048f5e624361adf3f9fbac9d0fa98e981e9c6ff2f5e/sqlalchemy-2.0.48-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:426c5ca86415d9b8945c7073597e10de9644802e2ff502b8e1f11a7a2642856b", size = 3279523, upload-time = "2026-03-02T15:53:32.962Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ca/ece73c81a918add0965b76b868b7b5359e068380b90ef1656ee995940c02/sqlalchemy-2.0.48-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:288937433bd44e3990e7da2402fabc44a3c6c25d3704da066b85b89a85474ae0", size = 3224312, upload-time = "2026-03-02T15:50:42.996Z" }, + { url = "https://files.pythonhosted.org/packages/88/11/fbaf1ae91fa4ee43f4fe79661cead6358644824419c26adb004941bdce7c/sqlalchemy-2.0.48-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8183dc57ae7d9edc1346e007e840a9f3d6aa7b7f165203a99e16f447150140d2", size = 3246304, upload-time = "2026-03-02T15:53:34.937Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5fb0deb13930b4f2f698c5541ae076c18981173e27dd00376dbaea7a9c82/sqlalchemy-2.0.48-cp314-cp314-win32.whl", hash = "sha256:1182437cb2d97988cfea04cf6cdc0b0bb9c74f4d56ec3d08b81e23d621a28cc6", size = 2116565, upload-time = "2026-03-02T15:54:38.321Z" }, + { url = "https://files.pythonhosted.org/packages/95/7e/e83615cb63f80047f18e61e31e8e32257d39458426c23006deeaf48f463b/sqlalchemy-2.0.48-cp314-cp314-win_amd64.whl", hash = "sha256:144921da96c08feb9e2b052c5c5c1d0d151a292c6135623c6b2c041f2a45f9e0", size = 2142205, upload-time = "2026-03-02T15:54:39.831Z" }, + { url = "https://files.pythonhosted.org/packages/83/e3/69d8711b3f2c5135e9cde5f063bc1605860f0b2c53086d40c04017eb1f77/sqlalchemy-2.0.48-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5aee45fd2c6c0f2b9cdddf48c48535e7471e42d6fb81adfde801da0bd5b93241", size = 3563519, upload-time = "2026-03-02T15:57:52.387Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4f/a7cce98facca73c149ea4578981594aaa5fd841e956834931de503359336/sqlalchemy-2.0.48-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7cddca31edf8b0653090cbb54562ca027c421c58ddde2c0685f49ff56a1690e0", size = 3528611, upload-time = "2026-03-02T16:04:42.097Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7d/5936c7a03a0b0cb0fa0cc425998821c6029756b0855a8f7ee70fba1de955/sqlalchemy-2.0.48-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7a936f1bb23d370b7c8cc079d5fce4c7d18da87a33c6744e51a93b0f9e97e9b3", size = 3472326, upload-time = "2026-03-02T15:57:54.423Z" }, + { url = "https://files.pythonhosted.org/packages/f4/33/cea7dfc31b52904efe3dcdc169eb4514078887dff1f5ae28a7f4c5d54b3c/sqlalchemy-2.0.48-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e004aa9248e8cb0a5f9b96d003ca7c1c0a5da8decd1066e7b53f59eb8ce7c62b", size = 3478453, upload-time = "2026-03-02T16:04:44.584Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/32107c4d13be077a9cae61e9ae49966a35dc4bf442a8852dd871db31f62e/sqlalchemy-2.0.48-cp314-cp314t-win32.whl", hash = "sha256:b8438ec5594980d405251451c5b7ea9aa58dda38eb7ac35fb7e4c696712ee24f", size = 2147209, upload-time = "2026-03-02T15:52:54.274Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d7/1e073da7a4bc645eb83c76067284a0374e643bc4be57f14cc6414656f92c/sqlalchemy-2.0.48-cp314-cp314t-win_amd64.whl", hash = "sha256:d854b3970067297f3a7fbd7a4683587134aa9b3877ee15aa29eea478dc68f933", size = 2182198, upload-time = "2026-03-02T15:52:55.606Z" }, + { url = "https://files.pythonhosted.org/packages/46/2c/9664130905f03db57961b8980b05cab624afd114bf2be2576628a9f22da4/sqlalchemy-2.0.48-py3-none-any.whl", hash = "sha256:a66fe406437dd65cacd96a72689a3aaaecaebbcd62d81c5ac1c0fdbeac835096", size = 1940202, upload-time = "2026-03-02T15:52:43.285Z" }, +] + +[[package]] +name = "starlette" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.42.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e3/ad/4a96c425be6fb67e0621e62d86c402b4a17ab2be7f7c055d9bd2f638b9e2/uvicorn-0.42.0.tar.gz", hash = "sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775", size = 85393, upload-time = "2026-03-16T06:19:50.077Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830, upload-time = "2026-03-16T06:19:48.325Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, + { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, + { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, + { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, + { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, + { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, + { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, + { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, + { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, + { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, + { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, + { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] diff --git a/docs/local-beta-setup.md b/docs/local-beta-setup.md new file mode 100644 index 0000000..d4f3515 --- /dev/null +++ b/docs/local-beta-setup.md @@ -0,0 +1,239 @@ +# Local Beta Stack — Setup and Smoke Test + +This guide walks through starting the Fall-In backend locally and verifying +the critical paths before a beta session. + +--- + +## Prerequisites + +| Tool | Version | +|------|---------| +| Python | 3.12+ | +| uv | 0.4+ (`pip install uv`) | +| SQLite | bundled with Python | +| Redis | *optional* — only for single-worker quick-match queue persistence | + +--- + +## 1. Clone and Install + +```bash +git clone https://github.com/bnbong/Fall-In.git +cd Fall-In/backend +uv sync +``` + +--- + +## 2. Environment Configuration + +```bash +cp .env.example .env +``` + +Edit `.env`. Minimum required values: + +```dotenv +# JWT — MUST change for any shared environment +SECRET_KEY=change-me-to-a-long-random-string + +# Auto-create tables on startup (dev only — use Alembic for staging/prod) +CREATE_TABLES_ON_STARTUP=true + +# Logging level: INFO for normal use, DEBUG for step-through debugging +LOG_LEVEL=INFO + +# Admin token — set a strong random value to enable /admin/* endpoints +# Leave empty to disable admin endpoints during local dev (safe default) +ADMIN_TOKEN=local-dev-admin-token +``` + +Optional Redis (leave unset for local dev — in-memory fallback is used): + +```dotenv +REDIS_URL=redis://localhost:6379/0 +``` + +--- + +## 3. Database Setup + +### Option A — Auto-create (local dev) + +With `CREATE_TABLES_ON_STARTUP=true` in `.env`, tables are created on first +startup. No migration command needed. + +### Option B — Alembic (staging / production) + +```bash +uv run alembic upgrade head +``` + +This applies all migrations in order: +- `001` — users, profiles, user_collection +- `002` — reports + +--- + +## 4. Start the Server + +```bash +uv run uvicorn app.main:app --reload --port 8000 +``` + +Logs are emitted as JSON to stdout: + +```json +{"ts": "2026-04-06T09:00:00.000Z", "level": "INFO", "logger": "fall_in.ws", "msg": "ws_connect", "conn_id": "..."} +``` + +Swagger UI: [http://localhost:8000/docs](http://localhost:8000/docs) + +--- + +## 5. Smoke Tests + +### 5.1 Health check + +```bash +curl http://localhost:8000/healthz +# → {"status":"ok"} +``` + +### 5.2 Register a player + +```bash +curl -s -X POST http://localhost:8000/auth/register \ + -H "Content-Type: application/json" \ + -d '{"email":"test@example.com","password":"Test1234!","nickname":"BravePilot"}' \ + | python3 -m json.tool +``` + +Expected: `201` with `access_token`, `refresh_token`, `account_type: "registered"`. + +Invalid nickname example (should return `422`): + +```bash +curl -s -o /dev/null -w "%{http_code}" -X POST http://localhost:8000/auth/register \ + -H "Content-Type: application/json" \ + -d '{"email":"bad@example.com","password":"Test1234!","nickname":"admin"}' +``` + +### 5.3 Guest login + +```bash +curl -s -X POST http://localhost:8000/auth/guest \ + -H "Content-Type: application/json" \ + -d '{"nickname":"AceRookie"}' \ + | python3 -m json.tool +``` + +Expected: `200` with short-lived `access_token`, no `refresh_token`. + +### 5.4 Submit a report + +Replace `TOKEN` with the access token from step 5.2. + +```bash +TOKEN=... +curl -s -X POST http://localhost:8000/report \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"reported_connection_id":"test-conn","reason_code":"emote_spam","details":"Testing report submission"}' \ + | python3 -m json.tool +``` + +Expected: `201` with `report_id` and `status: "open"`. + +### 5.5 Admin: list reports + +```bash +curl -s http://localhost:8000/admin/reports \ + -H "Authorization: Bearer local-dev-admin-token" \ + | python3 -m json.tool +``` + +Expected: `200` with the report from 5.4. + +Filter by status: append `?status=open` +Filter by reason: append `?reason_code=emote_spam` + +### 5.6 WebSocket flow (requires wscat or Postman) + +```bash +npx wscat -c ws://localhost:8000/ws +``` + +Then in the wscat prompt: + +```json +> {"type":"WS_HELLO","data":{}} +< {"type":"WS_WELCOME","data":{"connection_id":"..."}} + +> {"type":"AUTH_LOGIN","data":{"token":""}} +< {"type":"AUTH_OK","data":{"user_id":"...","display_name":"BravePilot","account_type":"registered"}} + +> {"type":"ROOM_CREATE","data":{}} +< {"type":"ROOM_STATE","data":{"room_code":"...","seats":[...],"phase":"waiting"}} +``` + +--- + +## 6. Running the Test Suite + +```bash +cd backend +uv run pytest -q +``` + +To run only PR-08 tests: + +```bash +uv run pytest tests/test_report.py tests/test_nickname.py -v +``` + +Expected: all tests pass (no external services needed). + +--- + +## 7. Beta Deployment Checklist + +Before opening to beta testers: + +- [ ] `SECRET_KEY` is set to a cryptographically random string (not the default). +- [ ] `ADMIN_TOKEN` is set and shared only with moderators. +- [ ] `CREATE_TABLES_ON_STARTUP=false`; run `alembic upgrade head` to apply migrations. +- [ ] `LOG_LEVEL=INFO` (not DEBUG) to avoid leaking user data to logs. +- [ ] HTTPS/WSS termination in place (nginx or cloud load balancer). +- [ ] `DATABASE_URL` points to PostgreSQL (not SQLite) for concurrent load. +- [ ] **Run only ONE uvicorn worker.** Room state, match state, connection + tracking, and the reconnect token store are all in-process singletons. + Running multiple workers splits this state across processes and breaks + room broadcast, active-match lookup, and reconnect paths. + Multi-worker support requires a future PR to move these stores to Redis/DB. + Redis only covers the quick-match queue and reconnect token TTLs — it does + **not** make the rest of the stack multi-worker-safe. + +--- + +## 8. Useful Log Queries (beta debugging) + +All logs are JSON. Filter with `jq` or your log aggregator. + +```bash +# All WebSocket connects +cat app.log | jq 'select(.msg == "ws_connect")' + +# Auth/room/WS failure events +cat app.log | jq 'select(.msg == "ws_error")' + +# Match starts +cat app.log | jq 'select(.msg == "match_start")' + +# Emote rate-limit hits (potential emote-spam reports) +cat app.log | jq 'select(.msg == "emote_rate_limited")' + +# Report submissions +cat app.log | jq 'select(.msg == "report_submitted")' +``` diff --git a/fall_in.spec b/fall_in.spec index bc8bc4c..f640630 100644 --- a/fall_in.spec +++ b/fall_in.spec @@ -34,7 +34,7 @@ a = Analysis( ], hookspath=[], hooksconfig={}, - runtime_hooks=[], + runtime_hooks=["runtime_hook_production.py"], excludes=[ "tkinter", "unittest", diff --git a/pyproject.toml b/pyproject.toml index 8f5923e..dab451f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ authors = [ requires-python = ">=3.12" dependencies = [ "pygame-ce>=2.5.0", + "websockets>=14.0", ] [project.optional-dependencies] diff --git a/runtime_hook_production.py b/runtime_hook_production.py new file mode 100644 index 0000000..a545f42 --- /dev/null +++ b/runtime_hook_production.py @@ -0,0 +1,5 @@ +"""PyInstaller runtime hook — mark packaged builds as production.""" + +import os + +os.environ["FALL_IN_PRODUCTION"] = "1" diff --git a/src/fall_in/config.py b/src/fall_in/config.py index d05c2f1..12b2ed6 100644 --- a/src/fall_in/config.py +++ b/src/fall_in/config.py @@ -5,6 +5,7 @@ so they can be adjusted from a single location. """ +import os as _os from pathlib import Path @@ -28,7 +29,11 @@ # ============================================================================= # Debug Mode # ============================================================================= -DEBUG_MODE = True # Set to False for production builds +# Defaults to False in packaged builds (PyInstaller sets FALL_IN_PRODUCTION=1). +# For local dev, set FALL_IN_DEBUG=1 to enable. +DEBUG_MODE = _os.environ.get("FALL_IN_DEBUG", "") == "1" and not _os.environ.get( + "FALL_IN_PRODUCTION", "" +) # ============================================================================= # Colors diff --git a/src/fall_in/core/card.py b/src/fall_in/core/card.py index 4925882..d0416ef 100644 --- a/src/fall_in/core/card.py +++ b/src/fall_in/core/card.py @@ -103,12 +103,13 @@ def create_deck() -> list[Card]: Returns: List of 104 Card objects """ - # Try to load soldier data + # Try to load soldier data from the singleton manager, which holds + # the authoritative in-memory state (synced from server for registered users). soldier_data = {} try: - from fall_in.data.soldier_data import SoldierDataManager + from fall_in.data.soldier_data import get_soldier_manager - manager = SoldierDataManager() + manager = get_soldier_manager() for soldier in manager.soldiers.values(): soldier_data[soldier.id] = soldier except Exception: diff --git a/src/fall_in/core/debug_manager.py b/src/fall_in/core/debug_manager.py index 3d0114d..a0d7d8a 100644 --- a/src/fall_in/core/debug_manager.py +++ b/src/fall_in/core/debug_manager.py @@ -17,39 +17,42 @@ class DebugManager: @classmethod def is_debug_enabled(cls) -> bool: - """Check if debug mode is enabled""" + """Check if debug mode is enabled. + + Always returns False when a multiplayer session is active, + regardless of the DEBUG_MODE config flag. + """ from fall_in.config import DEBUG_MODE + from fall_in.core.game_manager import GameManager - return DEBUG_MODE + return DEBUG_MODE and not GameManager().has_auth_session() @classmethod def unlock_all_soldiers(cls) -> None: """Mark all soldiers as collected.""" - import json - - from fall_in.config import DATA_DIR, TOTAL_CARDS + from fall_in.config import TOTAL_CARDS + from fall_in.data.soldier_data import get_soldier_manager - path = DATA_DIR / "collected_soldiers.json" - data = {"collected_ids": list(range(1, TOTAL_CARDS + 1))} + manager = get_soldier_manager() + all_ids = set(range(1, TOTAL_CARDS + 1)) + manager.replace_collected_state(all_ids) - with open(path, "w", encoding="utf-8") as f: - json.dump(data, f, ensure_ascii=False, indent=2) + from fall_in.core.game_manager import GameManager + GameManager().collected_soldiers = set(all_ids) print("[DEBUG] All soldiers unlocked!") @classmethod def clear_all_soldiers(cls) -> None: """Reset all soldier collection progress.""" - import json - - from fall_in.config import DATA_DIR + from fall_in.data.soldier_data import get_soldier_manager - path = DATA_DIR / "collected_soldiers.json" - data: dict[str, list[int]] = {"collected_ids": []} + manager = get_soldier_manager() + manager.replace_collected_state(set()) - with open(path, "w", encoding="utf-8") as f: - json.dump(data, f, ensure_ascii=False, indent=2) + from fall_in.core.game_manager import GameManager + GameManager().collected_soldiers = set() print("[DEBUG] All soldiers cleared!") @classmethod @@ -71,23 +74,11 @@ def add_currency(cls, amount: int = 10000) -> None: @classmethod def set_currency(cls, amount: int) -> None: """Set currency to a specific amount.""" - import json - - from fall_in.config import DATA_DIR - - path = DATA_DIR / "player_data.json" - if path.exists(): - with open(path, "r", encoding="utf-8") as f: - data = json.load(f) - - data["currency"] = amount - - with open(path, "w", encoding="utf-8") as f: - json.dump(data, f, ensure_ascii=False, indent=2) - from fall_in.core.game_manager import GameManager - GameManager().currency = amount + game = GameManager() + game.currency = amount + game.save_currency() # respects _use_local_storage print(f"[DEBUG] Currency set to: {amount}") @classmethod diff --git a/src/fall_in/core/game_manager.py b/src/fall_in/core/game_manager.py index f2eda0e..e784e20 100644 --- a/src/fall_in/core/game_manager.py +++ b/src/fall_in/core/game_manager.py @@ -3,6 +3,7 @@ """ import json +import threading from enum import Enum, auto from pathlib import Path from typing import Optional, TYPE_CHECKING @@ -64,13 +65,20 @@ def __init__(self): self.currency = 0 # In-game currency self.collected_soldiers: set[int] = set() # Collected soldier IDs self.current_difficulty = "normal" + self.user_id: Optional[str] = None + self.nickname: str = "" + self.account_type: Optional[str] = None + self.access_token: Optional[str] = None + self.refresh_token: Optional[str] = None + self.pending_match_reconnect: Optional[dict] = None # Audio settings self.bgm_volume = 0.5 self.sfx_volume = 0.7 - # Load saved currency - self.load_currency() + # Load saved local progress (will be overridden by server data for + # registered users after bootstrap_authenticated_account()). + self.load_local_progress() def _get_data_path(self) -> Path: """Get path to player data file""" @@ -79,16 +87,29 @@ def _get_data_path(self) -> Path: return DATA_DIR / self._DATA_FILE - def load_currency(self) -> None: - """Load currency from saved data""" + def load_local_progress(self) -> None: + """Load locally saved wallet and collection data.""" try: - data_path = self._get_data_path() - if data_path.exists(): - with open(data_path, "r", encoding="utf-8") as f: - data = json.load(f) - self.currency = data.get("currency", 0) + self.currency = self.load_player_data().get("currency", 0) except Exception: self.currency = 0 + try: + pending = self.load_player_data().get("pending_match_reconnect") + self.pending_match_reconnect = ( + pending if isinstance(pending, dict) else None + ) + except Exception: + self.pending_match_reconnect = None + try: + from fall_in.data.soldier_data import get_soldier_manager + + self.collected_soldiers = set(get_soldier_manager().collected_ids) + except Exception: + self.collected_soldiers = set() + + def load_currency(self) -> None: + """Backward-compatible alias used by older scenes.""" + self.load_local_progress() def load_player_data(self) -> dict: """Load full player data from saved JSON file.""" @@ -102,29 +123,31 @@ def load_player_data(self) -> dict: return {} def save_currency(self) -> None: - """Save currency to data file, preserving other fields""" - try: - data_path = self._get_data_path() - data_path.parent.mkdir(parents=True, exist_ok=True) + """Save currency to data file, preserving other fields. - # Read existing data to preserve other fields - existing_data = {} - if data_path.exists(): - with open(data_path, "r", encoding="utf-8") as f: - existing_data = json.load(f) - - # Update only the currency field + Skipped for registered users — the server DB is the source of truth. + """ + if not self._use_local_storage: + return + try: + existing_data = self.load_player_data() existing_data["currency"] = self.currency - - with open(data_path, "w", encoding="utf-8") as f: - json.dump(existing_data, f, ensure_ascii=False, indent=2) + self._write_player_data(existing_data) except Exception: pass # Fail silently - def add_currency(self, amount: int) -> None: - """Add currency (수당) to player wallet""" + def _write_player_data(self, data: dict) -> None: + """Persist the shared player-data JSON file.""" + data_path = self._get_data_path() + data_path.parent.mkdir(parents=True, exist_ok=True) + with open(data_path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + def add_currency(self, amount: int, *, reason: str | None = None) -> None: + """Add currency (수당) to player wallet.""" self.currency += amount self.save_currency() + self._sync_reward_async(amount, reason) def spend_currency(self, amount: int) -> bool: """ @@ -141,6 +164,193 @@ def has_currency(self, amount: int) -> bool: """Check if player has enough currency""" return self.currency >= amount + @property + def _use_local_storage(self) -> bool: + """Local file persistence is only for guests / unauthenticated.""" + return self.account_type != "registered" + + def has_auth_session(self) -> bool: + return bool(self.access_token and self.account_type) + + def has_registered_session(self) -> bool: + return self.has_auth_session() and self.account_type == "registered" + + def apply_auth_session( + self, + *, + access_token: str, + account_type: str, + refresh_token: str | None = None, + ) -> None: + self.access_token = access_token + self.account_type = account_type + self.refresh_token = refresh_token + + def clear_auth_session(self) -> None: + self.user_id = None + self.nickname = "" + self.account_type = None + self.access_token = None + self.refresh_token = None + + def store_pending_match_reconnect( + self, + *, + token: str, + match_id: str, + room_code: str, + seat_index: int, + ) -> None: + """Persist the current match reconnect token for later app relaunch.""" + if not token: + return + + payload = { + "token": token, + "match_id": match_id, + "room_code": room_code, + "seat_index": int(seat_index), + } + if self.user_id: + payload["user_id"] = self.user_id + + self.pending_match_reconnect = payload + try: + data = self.load_player_data() + data["pending_match_reconnect"] = payload + self._write_player_data(data) + except Exception: + pass + + def get_pending_match_reconnect(self) -> Optional[dict]: + """Return the locally persisted match reconnect payload, if any.""" + pending = self.pending_match_reconnect + return dict(pending) if isinstance(pending, dict) else None + + def clear_pending_match_reconnect(self) -> None: + """Forget any saved reconnect token for a finished/invalid match.""" + self.pending_match_reconnect = None + try: + data = self.load_player_data() + data.pop("pending_match_reconnect", None) + self._write_player_data(data) + except Exception: + pass + + def get_local_progress_snapshot(self) -> dict: + from fall_in.data.soldier_data import get_soldier_manager + + manager = get_soldier_manager() + self.collected_soldiers = set(manager.collected_ids) + return { + "currency": self.currency, + "collected_soldier_ids": sorted(self.collected_soldiers), + } + + def apply_synced_progress( + self, + *, + currency: int, + collected_soldier_ids: list[int], + ) -> None: + """Apply server-authoritative progress to in-memory state. + + For registered users, only updates in-memory (no local file write). + For guests, persists to local files as well. + """ + self.currency = currency + self.save_currency() # no-op for registered (guarded internally) + + from fall_in.data.soldier_data import get_soldier_manager + + manager = get_soldier_manager() + manager.replace_collected_state( + set(collected_soldier_ids), + persist=self._use_local_storage, + ) + self.collected_soldiers = set(collected_soldier_ids) + + def bootstrap_authenticated_account(self, sync_local_progress: bool) -> dict: + """ + Populate account identity and optionally merge local progress into the + authenticated registered account. + """ + if not self.has_auth_session() or self.access_token is None: + return {} + + from fall_in.net.backend_api import get_json, post_json + + profile: dict = {} + if self.has_registered_session() and sync_local_progress: + merged = post_json( + "/me/progress/merge", + self.get_local_progress_snapshot(), + token=self.access_token, + ) + self.apply_synced_progress( + currency=merged.get("currency", self.currency), + collected_soldier_ids=merged.get("collected_soldier_ids", []), + ) + elif self.has_registered_session(): + profile = get_json("/me/profile", self.access_token) + collection = get_json("/me/collection", self.access_token) + self.apply_synced_progress( + currency=profile.get("currency", self.currency), + collected_soldier_ids=[ + item.get("soldier_id") + for item in collection.get("items", []) + if "soldier_id" in item + ], + ) + + if not profile: + profile = get_json("/me/profile", self.access_token) + self.user_id = profile.get("user_id") + self.nickname = profile.get("nickname", self.nickname) + self.account_type = profile.get("account_type", self.account_type) + return profile + + def sync_registered_unlock_async(self, soldier_id: int) -> None: + if not self.has_registered_session() or self.access_token is None: + return + + def _worker() -> None: + try: + from fall_in.net.backend_api import post_json + + post_json( + "/me/collection/unlock", + {"soldier_id": soldier_id}, + token=self.access_token, + ) + except Exception: + pass + + threading.Thread(target=_worker, daemon=True).start() + + def _sync_reward_async(self, amount: int, reason: str | None) -> None: + """Send a delta-based reward claim to the server.""" + if not self.has_registered_session() or self.access_token is None: + return + if amount <= 0 or reason is None: + return + + token = self.access_token + + def _worker() -> None: + try: + from fall_in.net.backend_api import post_json + + post_json( + "/me/reward", + {"amount": amount, "reason": reason}, + token=token, + ) + except Exception: + pass + + threading.Thread(target=_worker, daemon=True).start() + def initialize(self) -> None: """Initialize pygame and create game window""" pygame.init() @@ -204,7 +414,7 @@ def run(self) -> None: def cleanup(self) -> None: """Clean up resources""" - self.save_currency() # Save before exit + self.save_currency() # no-op for registered users pygame.mixer.quit() pygame.quit() diff --git a/src/fall_in/core/medal_manager.py b/src/fall_in/core/medal_manager.py index 043c78e..e82edb9 100644 --- a/src/fall_in/core/medal_manager.py +++ b/src/fall_in/core/medal_manager.py @@ -57,7 +57,15 @@ def _load_player_medals(self) -> None: self._player_medals = [] def _save_player_medals(self) -> None: - """Save player's medals to player_data.json""" + """Save player's medals to player_data.json. + + Skipped for registered users — medals are tracked in-memory only; + server persistence for medals is not yet implemented. + """ + from fall_in.core.game_manager import GameManager + + if not GameManager()._use_local_storage: + return try: path = DATA_DIR / self._PLAYER_DATA_FILE data = {} @@ -159,20 +167,16 @@ def check_medal_conditions( return newly_awarded def has_all_soldiers_collected(self) -> bool: - """Check if all soldiers have been collected (interviewed)""" - try: - collected_path = DATA_DIR / "collected_soldiers.json" - if not collected_path.exists(): - return False - - with open(collected_path, "r", encoding="utf-8") as f: - data = json.load(f) - collected_ids = set(data.get("collected_ids", [])) + """Check if all soldiers have been collected (interviewed). - # Total soldiers is 104 (cards 1-104) + Uses the in-memory SoldierDataManager state, not the local file, + so this works correctly for both registered and guest users. + """ + try: from fall_in.config import TOTAL_CARDS + from fall_in.data.soldier_data import get_soldier_manager - return len(collected_ids) >= TOTAL_CARDS + return get_soldier_manager().get_collected_count() >= TOTAL_CARDS except Exception: return False diff --git a/src/fall_in/core/prestige_manager.py b/src/fall_in/core/prestige_manager.py index b38c862..a2583c6 100644 --- a/src/fall_in/core/prestige_manager.py +++ b/src/fall_in/core/prestige_manager.py @@ -57,7 +57,15 @@ def _load_prestige_data(self) -> None: self._coup_unlocked = False def _save_prestige_data(self) -> None: - """Save prestige data""" + """Save prestige data. + + Skipped for registered users — prestige state is tracked in-memory + only; server persistence for prestige is not yet implemented. + """ + from fall_in.core.game_manager import GameManager + + if not GameManager()._use_local_storage: + return try: path = DATA_DIR / self._PLAYER_DATA_FILE data = {} @@ -133,29 +141,46 @@ def execute_prestige(self) -> bool: # Reset coup_unlocked - player must achieve coup again for next prestige self._coup_unlocked = False - # Reset player data - path = DATA_DIR / self._PLAYER_DATA_FILE - new_data = { - "currency": 0, - "prestige_count": self._prestige_count, - "profile": { - "icon": "default", - "border": self.get_prestige_rewards().get("border_style", "basic"), - }, - "medals": ["coup_master"], # Preserve coup medal - "coup_unlocked": False, # Reset - must achieve coup again - "max_smuggle_count": 1 + self._prestige_count, # Base + prestige bonus - "win_count": 0, - "max_survived_rounds": 0, - } - - with open(path, "w", encoding="utf-8") as f: - json.dump(new_data, f, ensure_ascii=False, indent=2) - - # Reset collected soldiers - collected_path = DATA_DIR / self._COLLECTED_SOLDIERS_FILE - with open(collected_path, "w", encoding="utf-8") as f: - json.dump({"collected_ids": []}, f, ensure_ascii=False, indent=2) + from fall_in.core.game_manager import GameManager + + game = GameManager() + + # Reset in-memory state + game.currency = 0 + game.collected_soldiers = set() + + from fall_in.data.soldier_data import get_soldier_manager + + get_soldier_manager().replace_collected_state( + set(), + persist=game._use_local_storage, + ) + + from fall_in.core.medal_manager import MedalManager + + MedalManager().reset(keep_special=True) + + # Persist to local files only for guests + if game._use_local_storage: + path = DATA_DIR / self._PLAYER_DATA_FILE + new_data = { + "currency": 0, + "prestige_count": self._prestige_count, + "profile": { + "icon": "default", + "border": self.get_prestige_rewards().get( + "border_style", + "basic", + ), + }, + "medals": ["coup_master"], + "coup_unlocked": False, + "max_smuggle_count": 1 + self._prestige_count, + "win_count": 0, + "max_survived_rounds": 0, + } + with open(path, "w", encoding="utf-8") as f: + json.dump(new_data, f, ensure_ascii=False, indent=2) return True except Exception: diff --git a/src/fall_in/core/rules.py b/src/fall_in/core/rules.py index fb21025..dc0a018 100644 --- a/src/fall_in/core/rules.py +++ b/src/fall_in/core/rules.py @@ -77,11 +77,18 @@ class GameRules: 5. On ResultScene: calculate penalties, check for 66+ points (elimination). """ - def __init__(self, players: list[Player]): + def __init__(self, players: list[Player], human_seat: Optional[int] = 0): if len(players) != NUM_PLAYERS: raise ValueError(f"Need exactly {NUM_PLAYERS} players") + if human_seat is not None and not (0 <= human_seat < NUM_PLAYERS): + raise ValueError( + f"human_seat must be in [0, {NUM_PLAYERS}), got {human_seat}" + ) self.players = players + # Index of the "human" player for single-player game-over detection. + # Pass None to disable the single-player shortcut (multiplayer mode). + self._human_seat = human_seat self.board = Board() self.deck: list[Card] = [] @@ -300,16 +307,29 @@ def commit_round_scores(self) -> dict[int, tuple[int, int]]: return results def _check_game_end(self) -> None: - """Check if game should end (human eliminated or only 1 player left).""" + """Check if game should end (human eliminated or only 1 player left). + + When human_seat is None (multiplayer mode), only ends when <=1 active + player remains — no single-seat special-case. + """ active_players = [p for p in self.players if not p.is_eliminated] - human_eliminated = self.players[0].is_eliminated # Player 0 is always human + human_eliminated = ( + self._human_seat is not None + and self.players[self._human_seat].is_eliminated + ) if len(active_players) <= 1 or human_eliminated: self.game_over = True self.round_state.phase = RoundPhase.GAME_END if human_eliminated: - ai_players = [p for p in self.players[1:] if not p.is_eliminated] + # Single-player: human lost — lowest-score AI wins. + human = self.players[ + self._human_seat + ] # type-safe: human_eliminated guarantees _human_seat is not None + ai_players = [ + p for p in self.players if p is not human and not p.is_eliminated + ] if ai_players: self.winner = min(ai_players, key=lambda p: p.penalty_score) else: diff --git a/src/fall_in/data/soldier_data.py b/src/fall_in/data/soldier_data.py index daae40e..b070881 100644 --- a/src/fall_in/data/soldier_data.py +++ b/src/fall_in/data/soldier_data.py @@ -105,24 +105,61 @@ def _load_collected_state(self) -> None: pass # No save file yet def save_collected_state(self) -> None: - """Save collected soldier IDs to file.""" - save_path = self._get_save_path() + """Save collected soldier IDs to local file. - data = {"collected_ids": sorted(list(self.collected_ids))} + Skipped for registered users — the server DB is the source of truth. + """ + from fall_in.core.game_manager import GameManager + + if not GameManager()._use_local_storage: + return + save_path = self._get_save_path() + data = {"collected_ids": sorted(list(self.collected_ids))} with open(save_path, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) + def replace_collected_state( + self, + collected_ids: set[int], + *, + persist: bool = True, + ) -> None: + """Replace the full collected snapshot in memory. + + Args: + collected_ids: The authoritative set of collected IDs. + persist: If True, write to local file. False when the server + is the source of truth (registered users). + """ + self.collected_ids = set(collected_ids) + for soldier in self.soldiers.values(): + soldier.is_collected = soldier.id in self.collected_ids + if persist: + self.save_collected_state() + def get_soldier(self, soldier_id: int) -> Optional[SoldierInfo]: """Get soldier info by ID""" return self.soldiers.get(soldier_id) def collect_soldier(self, soldier_id: int) -> bool: - """Mark soldier as collected and save state""" + """Mark soldier as collected. + + Updates in-memory state immediately. For guests, persists to local + file. For registered users, syncs to server (no local file write). + """ if soldier_id in self.soldiers: self.soldiers[soldier_id].is_collected = True self.collected_ids.add(soldier_id) - self.save_collected_state() + self.save_collected_state() # no-op for registered users + try: + from fall_in.core.game_manager import GameManager + + game = GameManager() + game.collected_soldiers = set(self.collected_ids) + game.sync_registered_unlock_async(soldier_id) + except Exception: + pass return True return False diff --git a/src/fall_in/multiplayer/__init__.py b/src/fall_in/multiplayer/__init__.py new file mode 100644 index 0000000..ec19da5 --- /dev/null +++ b/src/fall_in/multiplayer/__init__.py @@ -0,0 +1,13 @@ +""" +fall_in.multiplayer — Multiplayer adapter and state model layer. + +This package contains: + - models.py : Network-safe state DTOs (PublicMatchState, PrivatePlayerState, + SeatIdentity, MatchCardPublic, AccountProgressRef). + - local_adapter.py (PR-04+) : Wraps existing single-player GameRules as an + adapter so GameScene can remain the same renderer. + - remote_adapter.py (PR-04+): Receives server snapshots and feeds them to + GameScene via the same adapter interface. + +PR-01 adds models.py only. No live adapters yet. +""" diff --git a/src/fall_in/multiplayer/bootstrap.py b/src/fall_in/multiplayer/bootstrap.py new file mode 100644 index 0000000..287b581 --- /dev/null +++ b/src/fall_in/multiplayer/bootstrap.py @@ -0,0 +1,185 @@ +""" +Helpers for bootstrapping a remote multiplayer GameScene. + +This keeps the RoomLobbyScene and MultiplayerMenuScene reconnect path in +sync so both launch the exact same remote adapter / network routing stack. +""" + +from __future__ import annotations + +from typing import Iterable + +from fall_in.core.game_manager import GameManager, GameState +from fall_in.multiplayer.models import ( + ControllerType, + MatchCardPublic, + PrivatePlayerState, + PublicMatchState, + SeatIdentity, +) +from fall_in.multiplayer.remote_adapter import RemoteGameAdapter + + +def build_remote_match_loading_scene( + *, + ws, + my_seat: int, + bootstrap_messages: Iterable[tuple[str, dict]] = (), +): + """ + Build the loading-scene transition that launches a remote GameScene. + + ``bootstrap_messages`` lets callers preserve messages that arrived in the + same pump batch as MATCH_START / RECONNECT_OK so no initial game-state + packets are lost before the first frame. + """ + adapter = RemoteGameAdapter(my_seat=my_seat) + pending_messages = list(bootstrap_messages) + + def _network_tick() -> None: + nonlocal pending_messages + pending_reveal_step: dict | None = None + + messages = pending_messages + ws.pump() + pending_messages = [] + for msg_type, data in messages: + if msg_type == "TURN_REVEAL_START": + adapter.begin_turn_reveal() + elif msg_type == "TURN_REVEAL_STEP": + pending_reveal_step = dict(data) + elif msg_type == "TURN_RESOLVED": + pending_reveal_step = None + adapter.finish_turn_reveal() + elif msg_type in ("PHASE_SELECTING", "PUBLIC_BOARD_STATE"): + state = _deserialise_public(data) + if state is not None: + if msg_type == "PHASE_SELECTING": + remaining = float(data.get("remaining_time", 30.0)) + adapter.notify_selecting_phase_started(remaining) + if ( + pending_reveal_step is not None + and adapter.is_turn_reveal_active() + ): + adapter.queue_turn_reveal_step(pending_reveal_step, state) + pending_reveal_step = None + elif adapter.is_turn_reveal_active(): + adapter.queue_post_reveal_public_state(state) + else: + adapter.apply_public_state(state) + elif msg_type == "PRIVATE_HAND_STATE": + private = _deserialise_private(data) + if private is not None: + try: + adapter.apply_private_state(private) + except ValueError: + pass + elif msg_type == "ROUND_RESULT": + adapter.queue_round_result(data) + elif msg_type == "MATCH_RESULT": + adapter.queue_match_result(data) + elif msg_type == "EMOTE_BROADCAST": + seat_idx = data.get("seat_index", 0) + emote_id = data.get("emote_id", "") + if emote_id: + adapter.apply_emote(seat_idx, emote_id) + elif msg_type == "SELECTION_TIMEOUT": + timed_out = data.get("timed_out_seats", []) + adapter.notify_selection_timeout([int(s) for s in timed_out]) + elif msg_type == "PLAYER_DISCONNECTED": + adapter.mark_seat_disconnected(int(data.get("seat_index", -1))) + elif msg_type == "PLAYER_RECONNECTED": + adapter.mark_seat_reconnected(int(data.get("seat_index", -1))) + elif msg_type == "SEAT_BOT_TAKEOVER": + adapter.mark_seat_bot_takeover(int(data.get("seat_index", -1))) + elif msg_type == "RECONNECT_TOKEN": + _store_reconnect_token(data) + elif msg_type == "PING": + ws.send("PONG") + + def _scene_builder(): + from fall_in.scenes.game_scene import GameScene + + GameManager().state = GameState.PLAYING + scene = GameScene() + scene.set_remote_adapter(adapter) + scene.set_card_select_callback( + lambda card_number: ws.send("CARD_SELECT", {"card_number": card_number}) + ) + scene.set_round_ready_callback(lambda: ws.send("ROUND_READY")) + scene.set_emote_send_callback( + lambda emote_id: ws.send("EMOTE_SEND", {"emote_id": emote_id}) + ) + scene.set_exit_match_callback(lambda: ws.send("MATCH_LEAVE")) + scene.set_network_tick_callback(_network_tick) + return scene + + gm = GameManager() + prev_screen = gm.screen.copy() if gm.screen else None + from fall_in.scenes.game_loading_scene import GameLoadingScene + + return GameLoadingScene(prev_screen=prev_screen, scene_builder=_scene_builder) + + +def _store_reconnect_token(data: dict) -> None: + token = data.get("token") + if not token: + return + + GameManager().store_pending_match_reconnect( + token=token, + match_id=data.get("match_id", ""), + room_code=data.get("room_code", ""), + seat_index=int(data.get("seat_index", 0)), + ) + + +def _deserialise_public(data: dict): + """Build a PublicMatchState from a wire dict, or return None on error.""" + try: + board_rows = [ + [MatchCardPublic(**card) for card in row] + for row in data.get("board_rows", []) + ] + played = [ + MatchCardPublic(**card) for card in data.get("played_cards_this_turn", []) + ] + seats = [ + SeatIdentity( + seat_index=seat["seat_index"], + controller_type=ControllerType(seat.get("controller_type", "remote")), + display_name=seat.get("display_name", ""), + user_id=seat.get("user_id"), + ) + for seat in data.get("seats", []) + ] + return PublicMatchState( + match_id=data.get("match_id", ""), + round_number=data.get("round_number", 1), + phase=data.get("phase", ""), + player_order_seats=[ + int(seat) for seat in data.get("player_order_seats", []) + ], + board_rows=board_rows, + played_cards_this_turn=played, + committed_scores={ + int(seat_index): int(value) + for seat_index, value in data.get("committed_scores", {}).items() + }, + seats=seats, + ) + except Exception: + return None + + +def _deserialise_private(data: dict): + """Build a PrivatePlayerState from a wire dict, or return None on error.""" + try: + hand = [MatchCardPublic(**card) for card in data.get("hand", [])] + return PrivatePlayerState( + seat_index=int(data["seat_index"]), + hand=hand, + has_selected=bool(data.get("has_selected", False)), + is_eliminated=bool(data.get("is_eliminated", False)), + ) + except Exception: + return None diff --git a/src/fall_in/multiplayer/local_adapter.py b/src/fall_in/multiplayer/local_adapter.py new file mode 100644 index 0000000..28cd463 --- /dev/null +++ b/src/fall_in/multiplayer/local_adapter.py @@ -0,0 +1,230 @@ +""" +LocalGameAdapter — wraps GameRules for the single-player path. + +Provides the same query interface as RemoteGameAdapter so that rendering +code can be written against one interface regardless of whether a match +is local or remote. + +Design notes: + - seat_index 0 is always the local human player. + - Seats 1-3 are AI-controlled bots. + - player_id == seat_index (same convention as the server MatchService). + - board_row_owners mirrors the server's tracking: -1 for starter cards, + seat_index for any subsequently placed card. + - get_public_state() / get_private_state() never include private cosmetic + fields from Card; only MatchCardPublic (number / danger / owner_seat) is + returned. +""" + +from __future__ import annotations + + +from fall_in.ai.ai_player import AIPlayer +from fall_in.core.rules import GameRules +from fall_in.multiplayer.models import ( + ControllerType, + MatchCardPublic, + PrivatePlayerState, + PublicMatchState, + SeatIdentity, +) + + +class LocalGameAdapter: + """ + Adapter for single-player (or offline) mode. + + Usage:: + + rules = GameRules(create_players()) + ais = create_ai_players(rules.players) + adapter = LocalGameAdapter(rules, ais) + + adapter.start_round() + adapter.select_card_for_human(42) # human picks card 42 + if adapter.all_selected(): + for seat, card in adapter.resolve_turn(): + ... # render each placement + if adapter.is_round_over(): + scores = adapter.commit_round() + if adapter.is_game_over(): + ... + """ + + def __init__( + self, + rules: GameRules, + ai_controllers: list[AIPlayer], + match_id: str = "local", + ) -> None: + self._rules = rules + self._ai: dict[int, AIPlayer] = { + ai.player.player_id: ai for ai in ai_controllers + } + self._match_id = match_id + # player_id == seat_index throughout single-player. + self._player_to_seat: dict[int, int] = { + p.player_id: p.player_id for p in rules.players + } + # Board row ownership parallel to rules.board.rows. + # -1 = starter card (no owner). + self._board_row_owners: list[list[int]] = [[], [], [], []] + + # ------------------------------------------------------------------ + # Round management + # ------------------------------------------------------------------ + + def start_round(self) -> None: + """Deal cards for a new round and auto-select for bot seats.""" + self._rules.start_new_round() + self._board_row_owners = [[-1] for _ in range(4)] + for player_id, ai in self._ai.items(): + player = next(p for p in self._rules.players if p.player_id == player_id) + if not player.is_eliminated: + ai.select_card(self._rules.board) + + # ------------------------------------------------------------------ + # Card selection + # ------------------------------------------------------------------ + + def select_card_for_human(self, card_number: int) -> None: + """Local human (seat 0) selects a card by number.""" + human = self._rules.players[0] + target = next((c for c in human.hand if c.number == card_number), None) + if target is None: + raise ValueError(f"Card {card_number} is not in the human player's hand") + human.select_card(target) + + def all_selected(self) -> bool: + """True when every non-eliminated player has selected a card.""" + return self._rules.all_players_selected() + + # ------------------------------------------------------------------ + # Turn resolution + # ------------------------------------------------------------------ + + def resolve_turn(self) -> list[tuple[int, MatchCardPublic]]: + """ + Execute the full turn step by step. + + Returns a list of (seat_index, MatchCardPublic) pairs in placement + order. The caller should iterate and render each entry sequentially. + + Updates board_row_owners after each placement. + """ + play_order = self._rules.prepare_turn() + results: list[tuple[int, MatchCardPublic]] = [] + + for order_idx, (player, card) in enumerate(play_order): + turn_result = self._rules.execute_single_placement( + player, card, order_idx + 1 + ) + placement = turn_result.result # fall_in.core.board.PlacementResult + seat_idx = self._player_to_seat[player.player_id] + + if placement.had_to_take_row or placement.penalty_score > 0: + self._board_row_owners[placement.row_index] = [seat_idx] + else: + self._board_row_owners[placement.row_index].append(seat_idx) + + pub_card = MatchCardPublic( + number=card.number, + danger=card.danger, + owner_seat=seat_idx, + ) + results.append((seat_idx, pub_card)) + + self._rules.check_round_end() + return results + + # ------------------------------------------------------------------ + # Round finalisation + # ------------------------------------------------------------------ + + def is_round_over(self) -> bool: + return self._rules.is_round_over() + + def is_game_over(self) -> bool: + return self._rules.game_over + + def commit_round(self) -> dict[int, tuple[int, int]]: + """ + Commit round scores. + + Returns {seat_index: (round_danger, new_total)}. + """ + raw = self._rules.commit_round_scores() # {player_id: (rd, total)} + return {self._player_to_seat[pid]: vals for pid, vals in raw.items()} + + # ------------------------------------------------------------------ + # State snapshots + # ------------------------------------------------------------------ + + def get_public_state(self) -> PublicMatchState: + """Produce a PublicMatchState from the current local game state.""" + rules = self._rules + + board_rows: list[list[MatchCardPublic]] = [] + for row_idx, row in enumerate(rules.board.rows): + owners = ( + self._board_row_owners[row_idx] + if row_idx < len(self._board_row_owners) + else [] + ) + board_rows.append( + [ + MatchCardPublic( + number=c.number, + danger=c.danger, + owner_seat=owners[pos] if pos < len(owners) else -1, + ) + for pos, c in enumerate(row) + ] + ) + + player_order_seats = [ + self._player_to_seat[p.player_id] + for p in rules.player_order + if not p.is_eliminated + ] + + committed_scores = { + self._player_to_seat[pid]: score + for pid, score in rules.committed_scores.items() + } + + seats = [ + SeatIdentity( + seat_index=p.player_id, + controller_type=( + ControllerType.LOCAL if p.player_id == 0 else ControllerType.BOT + ), + display_name=p.name, + user_id=None, + ) + for p in sorted(rules.players, key=lambda pl: pl.player_id) + ] + + return PublicMatchState( + match_id=self._match_id, + round_number=rules.round_state.round_number, + phase=rules.round_state.phase.name, + player_order_seats=player_order_seats, + board_rows=board_rows, + played_cards_this_turn=[], # populated by the caller during reveal + committed_scores=committed_scores, + seats=seats, + ) + + def get_private_state(self) -> PrivatePlayerState: + """Return the local human player's private state (seat 0).""" + human = self._rules.players[0] + hand = [ + MatchCardPublic(number=c.number, danger=c.danger, owner_seat=0) + for c in human.hand + ] + return PrivatePlayerState( + seat_index=0, + hand=hand, + has_selected=human.selected_card is not None, + ) diff --git a/src/fall_in/multiplayer/models.py b/src/fall_in/multiplayer/models.py new file mode 100644 index 0000000..f88bac7 --- /dev/null +++ b/src/fall_in/multiplayer/models.py @@ -0,0 +1,179 @@ +""" +Multiplayer state models — network-safe DTOs and seat identity types. + +Design invariants enforced here (from multiplayer plan §§ 4, 8, 19): + +1. MatchCardPublic contains ONLY number / danger / owner_seat. + Private cosmetic fields (is_collected, name, rank, unit, note, body_type) + are deliberately absent. If you need to add a field, add it to Card in + fall_in.core.card — NOT here. + +2. seat_index ≠ user_id. + A seat is a position in a room (0-3). A user is an account. Guests have + no persistent user_id. Bots have no user_id at all. + +3. AccountProgressRef is NEVER broadcast. It exists only on the client that + owns it and is used for local cosmetic projection only. + +4. PublicMatchState and PrivatePlayerState are always separate objects. + Public state is broadcast to all seats. Private state is unicast to the + owning seat only. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Optional + + +# --------------------------------------------------------------------------- +# Seat identity +# --------------------------------------------------------------------------- + + +class ControllerType(str, Enum): + """Who (or what) controls a seat.""" + + LOCAL = "local" # Human on this client (single-player path) + REMOTE = "remote" # Human on another client (multiplayer) + BOT = "bot" # AI-controlled seat + + +@dataclass +class SeatIdentity: + """ + Maps a seat position to the entity that controls it. + + seat_index : 0-3, position at the table this round. + controller_type: LOCAL | REMOTE | BOT. + user_id : Persistent account id (None for guests and bots). + display_name : Name shown in UI (player nickname or "AI N"). + """ + + seat_index: int + controller_type: ControllerType + display_name: str + user_id: Optional[str] = None # None for guests and bots + + def is_bot(self) -> bool: + return self.controller_type == ControllerType.BOT + + def is_guest(self) -> bool: + return self.controller_type == ControllerType.REMOTE and self.user_id is None + + +# --------------------------------------------------------------------------- +# Card DTO — the ONLY card representation allowed in public match state +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class MatchCardPublic: + """ + Network-safe card representation for public match state. + + Contains only the fields needed for game logic and board rendering. + Private cosmetic fields from Card are intentionally excluded. + + Fields intentionally NOT present (must never be added here): + - is_collected + - name + - rank + - unit + - note + - body_type + - portrait path + - figure variant id + """ + + number: int # 1-104 + danger: int # 1-7 + owner_seat: int # Seat index of the player who played this card + + +# --------------------------------------------------------------------------- +# Public match state — broadcast to all seats +# --------------------------------------------------------------------------- + + +@dataclass +class PublicMatchState: + """ + The portion of match state that is safe to broadcast to every participant. + + board_rows : Current board, each row is a list of MatchCardPublic. + played_cards_this_turn : Cards revealed this turn (in player_order sequence). + committed_scores : {seat_index: cumulative_danger} after round commit. + player_order_seats : Current turn's player_order as seat indices. + seats : SeatIdentity for all 4 seats. + phase : Current round phase string (matches RoundPhase.value). + round_number : 1-based current round. + match_id : Server-assigned match identifier. + """ + + match_id: str + round_number: int + phase: str + player_order_seats: list[int] # seat indices in play order + board_rows: list[list[MatchCardPublic]] + played_cards_this_turn: list[MatchCardPublic] + committed_scores: dict[int, int] # seat_index -> danger total + seats: list[SeatIdentity] + + def get_seat(self, seat_index: int) -> Optional[SeatIdentity]: + for s in self.seats: + if s.seat_index == seat_index: + return s + return None + + +# --------------------------------------------------------------------------- +# Private player state — unicast to the owning seat only +# --------------------------------------------------------------------------- + + +@dataclass +class PrivatePlayerState: + """ + The portion of match state visible only to the owning seat. + + hand : The player's current hand as MatchCardPublic entries. + owner_seat is always this player's seat_index. + has_selected: Whether this seat has already committed a card this turn. + seat_index : Which seat this private state belongs to. + + Note: hand cards use MatchCardPublic (number/danger/owner_seat). + The client applies cosmetic projection locally using AccountProgressRef. + """ + + seat_index: int + hand: list[MatchCardPublic] = field(default_factory=list) + has_selected: bool = False + is_eliminated: bool = False + + +# --------------------------------------------------------------------------- +# Account progress reference — local only, never sent to server in public state +# --------------------------------------------------------------------------- + + +@dataclass +class AccountProgressRef: + """ + Client-side reference to the viewer's own persistent progression. + + This is NEVER serialised into public match state or broadcast. + It is used solely for local cosmetic projection (resolve_card_visual). + + user_id : None for guests (cosmetics derived from local JSON save). + collection : Set of card numbers the viewer has collected (interviewed). + Populated from server profile (logged-in) or local + collected_soldiers.json (guest / single-player). + """ + + user_id: Optional[str] + collection: set[int] = field(default_factory=set) + + def has_collected(self, card_number: int) -> bool: + return card_number in self.collection diff --git a/src/fall_in/multiplayer/remote_adapter.py b/src/fall_in/multiplayer/remote_adapter.py new file mode 100644 index 0000000..76f76bd --- /dev/null +++ b/src/fall_in/multiplayer/remote_adapter.py @@ -0,0 +1,313 @@ +""" +RemoteGameAdapter — receives server-side state snapshots for rendering. + +No game logic is executed here. All authoritative state comes from the +server via WebSocket messages. The adapter stores the latest snapshot of +each type and exposes them for the client renderer. + +Usage (client-side networking loop):: + + adapter = RemoteGameAdapter(my_seat=1) + + # When PHASE_SELECTING / PUBLIC_BOARD_STATE arrives: + state = deserialise_public_state(msg["data"]) + adapter.apply_public_state(state) + + # When PRIVATE_HAND_STATE arrives: + priv = deserialise_private_state(msg["data"]) + adapter.apply_private_state(priv) + + # In the render loop: + public = adapter.get_public_state() + private = adapter.get_private_state() + if public: + render_board(public.board_rows) + if private: + render_hand(private.hand) + + if adapter.is_my_turn_to_select(): + send_card_select(chosen_card_number) +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional + +from fall_in.multiplayer.models import ( + ControllerType, + PrivatePlayerState, + PublicMatchState, +) + + +# Emote entry: (seat_index, emote_id) +EmoteEntry = tuple[int, str] + + +@dataclass(frozen=True) +class RevealStep: + seat_index: int + card_number: int + placement_order: int = 0 + card_danger: int = 0 + row_index: int = 0 + penalty_score: int = 0 + had_to_take_row: bool = False + penalty_card_count: int = 0 + + +class RemoteGameAdapter: + """ + Client-side adapter for remote (server-authoritative) matches. + + Caches the most recent PublicMatchState and PrivatePlayerState received + from the server. Rendering code reads these; the WS layer writes them. + """ + + def __init__(self, my_seat: int) -> None: + self._my_seat = my_seat + self._public: Optional[PublicMatchState] = None + self._private: Optional[PrivatePlayerState] = None + # Emotes received since the last call to pop_pending_emotes(). + # The renderer drains this list each frame to trigger display. + self._pending_emotes: list[EmoteEntry] = [] + self._turn_reveal_active = False + self._pending_reveal_steps: list[tuple[RevealStep, PublicMatchState]] = [] + self._pending_post_reveal_public: list[PublicMatchState] = [] + self._pending_selecting_phase_count = 0 + self._pending_selecting_remaining_times: list[float] = [] + self._pending_round_results: list[dict] = [] + self._pending_match_results: list[dict] = [] + self._disconnected_seats: set[int] = set() + self._bot_takeover_seats: set[int] = set() + # Per-seat penalty card counts for the current round. + self._round_penalty_counts: dict[int, int] = {} + self._last_penalty_round: int = 0 + # Selection timeout info queued from SELECTION_TIMEOUT messages. + self._pending_selection_timeouts: list[list[int]] = [] + + # ------------------------------------------------------------------ + # Write path (called by the WS receive loop) + # ------------------------------------------------------------------ + + def apply_public_state(self, state: PublicMatchState) -> None: + """Cache the latest public match state broadcast by the server.""" + self._public = state + + def begin_turn_reveal(self) -> None: + """Start buffering incremental turn-reveal snapshots.""" + self._turn_reveal_active = True + + def is_turn_reveal_active(self) -> bool: + return self._turn_reveal_active or bool(self._pending_reveal_steps) + + def queue_turn_reveal_step( + self, step_data: dict, snapshot: PublicMatchState + ) -> None: + """Store one incremental placement snapshot for later playback.""" + penalty_card_count = int(step_data.get("penalty_card_count", 0)) + step = RevealStep( + seat_index=step_data.get("seat_index", 0), + card_number=step_data.get("card_number", 0), + placement_order=step_data.get("placement_order", 0), + card_danger=step_data.get("card_danger", 0), + row_index=step_data.get("row_index", 0), + penalty_score=step_data.get("penalty_score", 0), + had_to_take_row=step_data.get("had_to_take_row", False), + penalty_card_count=penalty_card_count, + ) + # Auto-reset penalty counts when a new round starts. + current_round = snapshot.round_number if snapshot else 0 + if current_round != self._last_penalty_round: + self._round_penalty_counts.clear() + self._last_penalty_round = current_round + # Accumulate per-seat penalty card counts. + if penalty_card_count > 0: + seat = step.seat_index + self._round_penalty_counts[seat] = ( + self._round_penalty_counts.get(seat, 0) + penalty_card_count + ) + self._pending_reveal_steps.append((step, snapshot)) + + def has_pending_reveal_steps(self) -> bool: + return bool(self._pending_reveal_steps) + + def pop_next_reveal_step(self) -> Optional[tuple[RevealStep, PublicMatchState]]: + if not self._pending_reveal_steps: + return None + return self._pending_reveal_steps.pop(0) + + def finish_turn_reveal(self) -> None: + """Mark the reveal stream complete; queued steps still play out.""" + self._turn_reveal_active = False + + def queue_post_reveal_public_state(self, state: PublicMatchState) -> None: + self._pending_post_reveal_public.append(state) + + def flush_post_reveal_public_states(self) -> list[PublicMatchState]: + pending = list(self._pending_post_reveal_public) + self._pending_post_reveal_public.clear() + return pending + + def notify_selecting_phase_started(self, remaining_time: float = 30.0) -> None: + """Record that the server started a fresh SELECTING phase.""" + self._pending_selecting_phase_count += 1 + self._pending_selecting_remaining_times.append(remaining_time) + + def consume_selecting_phase_started(self) -> Optional[float]: + """Return the remaining_time for the next queued SELECTING event, or None.""" + if self._pending_selecting_phase_count <= 0: + return None + self._pending_selecting_phase_count -= 1 + if self._pending_selecting_remaining_times: + return self._pending_selecting_remaining_times.pop(0) + return 30.0 + + def queue_round_result(self, data: dict) -> None: + """Store a ROUND_RESULT payload until the renderer is ready for it.""" + self._pending_round_results.append(dict(data)) + + def pop_round_result(self) -> Optional[dict]: + """Return the oldest queued ROUND_RESULT payload, if any.""" + if not self._pending_round_results: + return None + return self._pending_round_results.pop(0) + + def queue_match_result(self, data: dict) -> None: + """Store a MATCH_RESULT payload until the renderer consumes it.""" + self._pending_match_results.append(dict(data)) + + def pop_match_result(self) -> Optional[dict]: + """Return the oldest queued MATCH_RESULT payload, if any.""" + if not self._pending_match_results: + return None + return self._pending_match_results.pop(0) + + def get_round_penalty_card_count(self, seat_index: int) -> int: + """Return the accumulated penalty card count for *seat_index* this round.""" + return self._round_penalty_counts.get(seat_index, 0) + + def reset_round_penalty_counts(self) -> None: + """Clear per-seat penalty counts (called on new round).""" + self._round_penalty_counts.clear() + + def notify_selection_timeout(self, timed_out_seats: list[int]) -> None: + """Record a SELECTION_TIMEOUT event from the server.""" + self._pending_selection_timeouts.append(list(timed_out_seats)) + + def pop_selection_timeout(self) -> Optional[list[int]]: + """Return the oldest queued SELECTION_TIMEOUT seat list, if any.""" + if not self._pending_selection_timeouts: + return None + return self._pending_selection_timeouts.pop(0) + + def apply_emote(self, seat_index: int, emote_id: str) -> None: + """ + Record an EMOTE_BROADCAST received from the server. + + The renderer calls pop_pending_emotes() each frame to consume these. + """ + self._pending_emotes.append((seat_index, emote_id)) + + def pop_pending_emotes(self) -> list[EmoteEntry]: + """ + Return and clear all emotes received since the last call. + + The renderer should call this once per frame and trigger per-seat + display animations for each returned entry. + """ + pending = list(self._pending_emotes) + self._pending_emotes.clear() + return pending + + def apply_private_state(self, state: PrivatePlayerState) -> None: + """ + Cache the private hand state unicast to this seat. + + Raises ValueError if the state is for a different seat. + """ + if state.seat_index != self._my_seat: + raise ValueError( + f"PrivatePlayerState is for seat {state.seat_index}, " + f"but this adapter is for seat {self._my_seat}" + ) + self._private = state + + def mark_seat_disconnected(self, seat_index: int) -> None: + if seat_index < 0: + return + self._disconnected_seats.add(seat_index) + + def mark_seat_reconnected(self, seat_index: int) -> None: + if seat_index < 0: + return + self._disconnected_seats.discard(seat_index) + self._bot_takeover_seats.discard(seat_index) + + def mark_seat_bot_takeover(self, seat_index: int) -> None: + if seat_index < 0: + return + self._disconnected_seats.discard(seat_index) + self._bot_takeover_seats.add(seat_index) + if self._public is not None: + seat = self._public.get_seat(seat_index) + if seat is not None: + seat.controller_type = ControllerType.BOT + + def is_seat_disconnected(self, seat_index: int) -> bool: + return seat_index in self._disconnected_seats + + def is_seat_bot_takeover(self, seat_index: int) -> bool: + return seat_index in self._bot_takeover_seats + + # ------------------------------------------------------------------ + # Read path (called by the renderer) + # ------------------------------------------------------------------ + + @property + def my_seat(self) -> int: + return self._my_seat + + def get_public_state(self) -> Optional[PublicMatchState]: + """Latest broadcast state, or None before the first snapshot arrives.""" + return self._public + + def get_private_state(self) -> Optional[PrivatePlayerState]: + """Latest hand state, or None before the first snapshot arrives.""" + return self._private + + def has_match_started(self) -> bool: + """True after the first PublicMatchState is received.""" + return self._public is not None + + def get_phase(self) -> Optional[str]: + """Current phase string (e.g. "SELECTING"), or None if no state yet.""" + return self._public.phase if self._public else None + + def is_my_turn_to_select(self) -> bool: + """ + True when the phase is SELECTING and this seat has not yet submitted + a card selection. + """ + if self._public is None or self._private is None: + return False + return self._public.phase == "SELECTING" and not self._private.has_selected + + def get_my_hand_cards(self) -> list: + """ + Return the list of MatchCardPublic in this seat's hand. + Returns an empty list if no private state is available yet. + """ + return list(self._private.hand) if self._private is not None else [] + + def get_board_rows(self) -> list: + """ + Return a snapshot of the current board rows (list of lists of + MatchCardPublic). Returns an empty list if no public state yet. + """ + return [list(row) for row in self._public.board_rows] if self._public else [] + + def get_committed_scores(self) -> dict: + """Return {seat_index: cumulative_danger} or {} if no state.""" + return dict(self._public.committed_scores) if self._public else {} diff --git a/src/fall_in/net/__init__.py b/src/fall_in/net/__init__.py new file mode 100644 index 0000000..f12f7fd --- /dev/null +++ b/src/fall_in/net/__init__.py @@ -0,0 +1,8 @@ +""" +fall_in.net — Network protocol layer for multiplayer. + +This package defines message types, serializers, and state projection helpers +that will be used by the WebSocket client (PR-02+) and the backend server. + +No live network code lives here yet — PR-01 only establishes the contracts. +""" diff --git a/src/fall_in/net/backend_api.py b/src/fall_in/net/backend_api.py new file mode 100644 index 0000000..8352233 --- /dev/null +++ b/src/fall_in/net/backend_api.py @@ -0,0 +1,67 @@ +""" +Minimal REST client helpers for the local Fall-In backend. +""" + +from __future__ import annotations + +import json +import urllib.error +import urllib.request +from typing import Any + +REST_BASE_URL = "http://localhost:8000" + + +def request_json( + method: str, + path: str, + payload: dict | None = None, + token: str | None = None, + timeout: float = 8.0, +) -> dict[str, Any]: + url = REST_BASE_URL + path + body = None if payload is None else json.dumps(payload).encode("utf-8") + headers = {"Content-Type": "application/json"} + if token: + headers["Authorization"] = f"Bearer {token}" + + req = urllib.request.Request( + url, + data=body, + method=method.upper(), + headers=headers, + ) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + raw = resp.read() + return json.loads(raw) if raw else {} + except urllib.error.HTTPError as exc: + try: + detail = json.loads(exc.read()).get("detail", str(exc)) + except Exception: + detail = str(exc) + raise RuntimeError(detail) + except OSError as exc: + raise RuntimeError(f"서버에 연결할 수 없습니다: {exc}") + + +def get_json(path: str, token: str, timeout: float = 8.0) -> dict[str, Any]: + return request_json("GET", path, token=token, timeout=timeout) + + +def post_json( + path: str, + payload: dict | None = None, + token: str | None = None, + timeout: float = 8.0, +) -> dict[str, Any]: + return request_json("POST", path, payload=payload, token=token, timeout=timeout) + + +def put_json( + path: str, + payload: dict | None = None, + token: str | None = None, + timeout: float = 8.0, +) -> dict[str, Any]: + return request_json("PUT", path, payload=payload, token=token, timeout=timeout) diff --git a/src/fall_in/net/messages.py b/src/fall_in/net/messages.py new file mode 100644 index 0000000..5a137f4 --- /dev/null +++ b/src/fall_in/net/messages.py @@ -0,0 +1,99 @@ +""" +Network message type definitions for Fall-In multiplayer protocol. + +Defines the string constants for every message type exchanged between client +and server over WebSocket. No transport code lives here — these are pure +identifiers used by serializers and the future ws_client / server handlers. + +Naming convention: + Client → Server : verb in imperative form (CARD_SELECT, EMOTE_SEND …) + Server → Client : noun / past-tense form (MATCH_START, TURN_RESOLVED …) +""" + +from enum import Enum + + +class ClientMessageType(str, Enum): + """Messages the client sends to the server.""" + + # Handshake + WS_HELLO = "WS_HELLO" + + # Authentication + AUTH_LOGIN = "AUTH_LOGIN" + AUTH_GUEST = "AUTH_GUEST" + + # Room management + ROOM_CREATE = "ROOM_CREATE" + ROOM_JOIN = "ROOM_JOIN" + ROOM_LEAVE = "ROOM_LEAVE" + READY_SET = "READY_SET" + ROOM_START = "ROOM_START" + + # Quick match queue (server uses QUICK_MATCH_* prefix) + QUICK_MATCH_JOIN = "QUICK_MATCH_JOIN" + QUICK_MATCH_LEAVE = "QUICK_MATCH_LEAVE" + + # In-game actions + CARD_SELECT = "CARD_SELECT" + ROUND_READY = "ROUND_READY" + EMOTE_SEND = "EMOTE_SEND" + EMOTE_MUTE = "EMOTE_MUTE" + + # Connection management + PING = "PING" + PONG = "PONG" + RECONNECT = "RECONNECT" + + +class ServerMessageType(str, Enum): + """Messages the server sends to the client.""" + + # Handshake + WS_WELCOME = "WS_WELCOME" + + # Authentication + AUTH_OK = "AUTH_OK" + + # Room management + ROOM_STATE = "ROOM_STATE" + + # Match lifecycle + MATCH_FOUND = "MATCH_FOUND" + MATCH_START = "MATCH_START" + MATCH_RESULT = "MATCH_RESULT" + + # Quick match queue + QUEUE_JOINED = "QUEUE_JOINED" + QUEUE_LEFT = "QUEUE_LEFT" + + # In-game phases + PHASE_SELECTING = "PHASE_SELECTING" + TURN_REVEAL_START = "TURN_REVEAL_START" + TURN_REVEAL_STEP = "TURN_REVEAL_STEP" + TURN_RESOLVED = "TURN_RESOLVED" + ROUND_RESULT = "ROUND_RESULT" + + # Per-client state pushes (sent only to the recipient) + PRIVATE_HAND_STATE = "PRIVATE_HAND_STATE" + + # Shared state broadcast + PUBLIC_BOARD_STATE = "PUBLIC_BOARD_STATE" + + # Presence / connection + PLAYER_PRESENCE = "PLAYER_PRESENCE" + PLAYER_DISCONNECTED = "PLAYER_DISCONNECTED" + PLAYER_RECONNECTED = "PLAYER_RECONNECTED" + SEAT_BOT_TAKEOVER = "SEAT_BOT_TAKEOVER" + RECONNECT_TOKEN = "RECONNECT_TOKEN" + RECONNECT_OK = "RECONNECT_OK" + + # Social + EMOTE_BROADCAST = "EMOTE_BROADCAST" + EMOTE_MUTE_ACK = "EMOTE_MUTE_ACK" + + # Errors + ERROR = "ERROR" + + # Connection management + PONG = "PONG" diff --git a/src/fall_in/net/serializers.py b/src/fall_in/net/serializers.py new file mode 100644 index 0000000..83cf44e --- /dev/null +++ b/src/fall_in/net/serializers.py @@ -0,0 +1,134 @@ +""" +Serializers for network-safe state objects. + +Rules enforced here: + - A Card's private cosmetic fields (is_collected, name, rank, unit, note, + body_type) are NEVER included in any dict that will be sent over the wire. + - Only MatchCardPublic (number, danger, owner_seat) crosses the network. + - PublicMatchState and PrivatePlayerState are serialized separately; the + private state is sent only to the owning seat. + +These functions work on the DTO types defined in fall_in.multiplayer.models. +They do NOT import Card directly — the boundary is enforced by accepting only +MatchCardPublic instances. +""" + +from __future__ import annotations + +from typing import Any + +from fall_in.multiplayer.models import ( + MatchCardPublic, + PublicMatchState, + PrivatePlayerState, + SeatIdentity, +) + +# Fields that must NEVER appear in a public-facing dict. +# Checked at runtime by the assertion helpers below. +_PRIVATE_CARD_FIELDS: frozenset[str] = frozenset( + {"is_collected", "name", "rank", "unit", "note", "body_type"} +) + + +# --------------------------------------------------------------------------- +# Card DTO +# --------------------------------------------------------------------------- + + +def match_card_to_dict(card: MatchCardPublic) -> dict[str, Any]: + """Serialise a public card DTO to a plain dict safe for JSON encoding.""" + return { + "number": card.number, + "danger": card.danger, + "owner_seat": card.owner_seat, + } + + +def _assert_no_private_fields(d: dict[str, Any], context: str = "") -> None: + """Raise AssertionError if any private card field is present in *d*.""" + leaked = _PRIVATE_CARD_FIELDS & d.keys() + if leaked: + raise AssertionError( + f"Private card fields leaked into public dict{' (' + context + ')' if context else ''}: " + f"{leaked}" + ) + + +# --------------------------------------------------------------------------- +# Seat identity +# --------------------------------------------------------------------------- + + +def seat_identity_to_dict(seat: SeatIdentity) -> dict[str, Any]: + """ + Serialise a SeatIdentity for broadcast. + + user_id is always omitted — it is a stable account identifier that + other clients have no need for. display_name is the only identity + signal in public match state. + """ + return { + "seat_index": seat.seat_index, + "controller_type": seat.controller_type.value, + "display_name": seat.display_name, + } + + +# --------------------------------------------------------------------------- +# Public match state +# --------------------------------------------------------------------------- + + +def public_state_to_dict(state: PublicMatchState) -> dict[str, Any]: + """ + Serialise PublicMatchState to a wire-safe dict. + + Asserts that no private card fields are present before returning. + """ + board_rows = [[match_card_to_dict(c) for c in row] for row in state.board_rows] + played_this_turn = [match_card_to_dict(c) for c in state.played_cards_this_turn] + seats = [seat_identity_to_dict(s) for s in state.seats] + + result: dict[str, Any] = { + "match_id": state.match_id, + "round_number": state.round_number, + "phase": state.phase, + "player_order_seats": state.player_order_seats, + "board_rows": board_rows, + "played_cards_this_turn": played_this_turn, + "committed_scores": state.committed_scores, + "seats": seats, + } + + # Runtime safety: walk every card dict and assert no leakage. + for row in board_rows: + for card_dict in row: + _assert_no_private_fields(card_dict, context="board_rows") + for card_dict in played_this_turn: + _assert_no_private_fields(card_dict, context="played_cards_this_turn") + + return result + + +# --------------------------------------------------------------------------- +# Private player state +# --------------------------------------------------------------------------- + + +def private_state_to_dict(state: PrivatePlayerState) -> dict[str, Any]: + """ + Serialise PrivatePlayerState to a dict. + + This is only ever sent to the owning seat — never broadcast. + Hand cards are the player's own cards (owner_seat == viewer_seat), so + cosmetic projection happens client-side after deserialisation. + """ + hand = [match_card_to_dict(c) for c in state.hand] + result: dict[str, Any] = { + "seat_index": state.seat_index, + "hand": hand, + "has_selected": state.has_selected, + "is_eliminated": state.is_eliminated, + } + return result diff --git a/src/fall_in/net/state_projection.py b/src/fall_in/net/state_projection.py new file mode 100644 index 0000000..fe565db --- /dev/null +++ b/src/fall_in/net/state_projection.py @@ -0,0 +1,74 @@ +""" +Client-side visual projection for multiplayer card state. + +Design principle (from multiplayer plan §8): + - The server sends only number / danger / owner_seat in public state. + - Each client projects its OWN collection onto cards it owns. + - Cards belonging to other seats always render as "unknown_default". + - This means the same card number can legitimately render differently on + different clients — that is intentional and correct. + +No server data is modified here. These helpers are pure functions that +take a viewer's local state and return a sprite/visual identifier string. +""" + +from __future__ import annotations + + +_UNKNOWN_VISUAL = "unknown_default" + + +def resolve_card_visual( + card_number: int, + owner_seat: int, + viewer_seat: int, + viewer_collection: set[int], +) -> str: + """ + Determine the visual identifier for a card on the board or in hand. + + Args: + card_number: The card's number (1-104). + owner_seat: Seat index of the player who played / owns the card. + viewer_seat: Seat index of the local viewer. + viewer_collection: Set of card numbers the viewer has collected + (from their local or server-side collection). + + Returns: + A sprite/asset key string: + - ``"soldier_{number}"`` when the viewer owns the card AND has + collected (interviewed) it. + - ``"unknown_default"`` in all other cases. + + Notes: + Cards played by other seats always return ``"unknown_default"``, + even if the viewer happens to own the same number in their collection. + The viewer's private hand cards (owner_seat == viewer_seat) are + visible only when collected. + """ + if owner_seat != viewer_seat: + return _UNKNOWN_VISUAL + if card_number not in viewer_collection: + return _UNKNOWN_VISUAL + return f"soldier_{card_number}" + + +def resolve_hand_card_visual( + card_number: int, + viewer_collection: set[int], +) -> str: + """ + Determine the visual for a card in the viewer's own hand. + + Convenience wrapper — hand cards are always owner == viewer. + + Args: + card_number: The card's number (1-104). + viewer_collection: Set of card numbers the viewer has collected. + + Returns: + ``"soldier_{number}"`` if collected, else ``"unknown_default"``. + """ + if card_number not in viewer_collection: + return _UNKNOWN_VISUAL + return f"soldier_{card_number}" diff --git a/src/fall_in/net/ws_client.py b/src/fall_in/net/ws_client.py new file mode 100644 index 0000000..54d507c --- /dev/null +++ b/src/fall_in/net/ws_client.py @@ -0,0 +1,236 @@ +""" +WebSocket client for Fall-In multiplayer (PR-09). + +Runs the asyncio send/receive loop in a background daemon thread so the +synchronous pygame main loop is never blocked by network I/O. + +Thread-safety contract +---------------------- +* _recv_queue (stdlib queue.Queue) — bg thread writes, main thread reads. +* _send_queue (asyncio.Queue) — main thread enqueues via + loop.call_soon_threadsafe(); bg thread dequeues and sends. + +Usage:: + + client = WsClient.get() # singleton + client.connect() + if not client.wait_connected(3.0): + print("connection failed:", client.connect_error) + + # Each pygame frame: + for msg_type, data in client.pump(): + handle_server_message(msg_type, data) + + # Sending: + client.send("AUTH_LOGIN", {"token": "..."}) + client.send("WS_HELLO") + + # Disconnect: + WsClient.reset() +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import queue +import threading +from typing import Optional + +logger = logging.getLogger("fall_in.net.ws_client") + +_DEFAULT_WS_URL = "ws://localhost:8000/ws" + + +class WsClient: + """ + Thread-safe singleton WebSocket client. + + The asyncio event loop runs in a dedicated daemon thread. The pygame + main thread interacts exclusively through: + - send() — enqueue a message to transmit + - pump() — drain all messages received since last call + - is_connected — read-only property + - connect_error — set if the connection attempt failed + """ + + _instance: Optional["WsClient"] = None + + # ------------------------------------------------------------------ + # Singleton management + # ------------------------------------------------------------------ + + @classmethod + def get(cls, url: str = _DEFAULT_WS_URL) -> "WsClient": + """Return the shared WsClient instance, creating it if needed.""" + if cls._instance is None: + cls._instance = cls(url) + return cls._instance + + @classmethod + def reset(cls) -> None: + """Disconnect and discard the singleton so a fresh one can be created.""" + if cls._instance is not None: + cls._instance.disconnect() + cls._instance = None + + # ------------------------------------------------------------------ + # Construction + # ------------------------------------------------------------------ + + def __init__(self, url: str = _DEFAULT_WS_URL) -> None: + self._url = url + self._thread: Optional[threading.Thread] = None + self._loop: Optional[asyncio.AbstractEventLoop] = None + self._send_queue: Optional[asyncio.Queue] = None + # Thread-safe inbox: bg thread writes, main thread reads + self._recv_queue: queue.Queue = queue.Queue() + self._connected = False + self._connect_error: Optional[str] = None + # Signalled (from bg thread) once the connection attempt completes + self._connect_event = threading.Event() + + # ------------------------------------------------------------------ + # Connection management (called from main thread) + # ------------------------------------------------------------------ + + def connect(self) -> None: + """Start the background thread and connect to the WS server.""" + if self._thread and self._thread.is_alive(): + return + self._connect_event.clear() + self._connect_error = None + self._connected = False + self._thread = threading.Thread(target=self._run, daemon=True, name="ws-client") + self._thread.start() + + def wait_connected(self, timeout: float = 5.0) -> bool: + """ + Block the calling thread until the connection succeeds or fails. + + Returns True only if the connection was established without error. + Typically called right after connect() from the scene's on_enter() + while showing a "연결 중..." spinner. + """ + ok = self._connect_event.wait(timeout) + return ok and self._connect_error is None + + def disconnect(self) -> None: + """Send a sentinel to the bg thread, causing it to close the socket.""" + if self._loop is not None and self._send_queue is not None: + try: + self._loop.call_soon_threadsafe(self._send_queue.put_nowait, None) + except Exception: + pass + + # ------------------------------------------------------------------ + # Main-thread API + # ------------------------------------------------------------------ + + def send(self, msg_type: str, data: Optional[dict] = None) -> None: + """ + Enqueue a message to be sent to the server. + + Safe to call from the pygame main thread at any time after connect(). + """ + if not self._connected or self._loop is None or self._send_queue is None: + return + msg = {"type": msg_type, "data": data or {}} + try: + self._loop.call_soon_threadsafe(self._send_queue.put_nowait, msg) + except Exception: + pass + + def pump(self) -> list[tuple[str, dict]]: + """ + Drain and return all messages received since the last call. + + Call once per pygame frame. Returns a list of (msg_type, data) tuples. + Never blocks. + """ + messages: list[tuple[str, dict]] = [] + while True: + try: + messages.append(self._recv_queue.get_nowait()) + except queue.Empty: + break + return messages + + @property + def is_connected(self) -> bool: + return self._connected + + @property + def connect_error(self) -> Optional[str]: + return self._connect_error + + # ------------------------------------------------------------------ + # Background-thread implementation + # ------------------------------------------------------------------ + + def _run(self) -> None: + """Entry point for the background thread.""" + self._loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._loop) + try: + self._loop.run_until_complete(self._async_run()) + except Exception as e: + logger.error("ws_client_loop_error", extra={"error": str(e)}) + finally: + self._connected = False + # Signal main thread if it's still waiting (e.g. on error before connect) + self._connect_event.set() + try: + self._loop.close() + except Exception: + pass + + async def _async_run(self) -> None: + import websockets + + self._send_queue = asyncio.Queue() + try: + async with websockets.connect(self._url) as ws: + self._connected = True + self._connect_event.set() + logger.info("ws_client_connected", extra={"url": self._url}) + await asyncio.gather( + self._recv_loop(ws), + self._send_loop(ws), + return_exceptions=True, + ) + except Exception as e: + self._connect_error = str(e) + self._connect_event.set() + logger.warning( + "ws_client_connect_failed", extra={"url": self._url, "error": str(e)} + ) + finally: + self._connected = False + + async def _recv_loop(self, ws) -> None: + try: + async for raw in ws: + try: + msg = json.loads(raw) + msg_type = msg.get("type", "") + data = msg.get("data") or {} + if not isinstance(data, dict): + data = {} + self._recv_queue.put((msg_type, data)) + except Exception as e: + logger.warning("ws_client_parse_error", extra={"error": str(e)}) + except Exception: + pass + + async def _send_loop(self, ws) -> None: + try: + while True: + msg = await self._send_queue.get() + if msg is None: # disconnect sentinel + await ws.close() + break + await ws.send(json.dumps(msg)) + except Exception: + pass diff --git a/src/fall_in/scenes/account_gate_scene.py b/src/fall_in/scenes/account_gate_scene.py new file mode 100644 index 0000000..d977c83 --- /dev/null +++ b/src/fall_in/scenes/account_gate_scene.py @@ -0,0 +1,379 @@ +""" +Account gate scene shown immediately after the intro cutscene. + +Players choose login, register, or guest play before reaching the title +screen so single-player progress can be reconciled early. +""" + +from __future__ import annotations + +import threading +from enum import Enum, auto +from typing import Optional + +import pygame + +from fall_in.config import ( + AIR_FORCE_BLUE, + LIGHT_BLUE, + BLACK, + SCREEN_WIDTH, + SCREEN_HEIGHT, + SAND_BEIGE, +) +from fall_in.scenes.base_scene import Scene +from fall_in.ui.button import Button +from fall_in.utils.asset_loader import get_font + + +class _State(Enum): + AUTH = auto() + AUTH_REST = auto() + + +class _TextInput: + CURSOR_BLINK_RATE = 0.5 + + def __init__( + self, + x: int, + y: int, + width: int, + height: int, + placeholder: str = "", + password: bool = False, + ) -> None: + self.rect = pygame.Rect(x, y, width, height) + self.placeholder = placeholder + self.password = password + self.text = "" + self.focused = False + self._cursor_timer = 0.0 + self._cursor_visible = True + + def handle_event(self, event: pygame.event.Event) -> None: + if event.type == pygame.MOUSEBUTTONDOWN: + self.focused = self.rect.collidepoint(event.pos) + if not self.focused: + return + if event.type == pygame.KEYDOWN: + if event.key == pygame.K_BACKSPACE: + self.text = self.text[:-1] + elif event.key == pygame.K_v and (event.mod & pygame.KMOD_CTRL): + try: + clip = pygame.scrap.get(pygame.SCRAP_TEXT) + if clip: + self.text += clip.decode("utf-8", errors="ignore").strip("\x00") + except Exception: + pass + elif event.unicode and event.unicode.isprintable(): + self.text += event.unicode + + def update(self, dt: float) -> None: + if self.focused: + self._cursor_timer += dt + if self._cursor_timer >= self.CURSOR_BLINK_RATE: + self._cursor_timer = 0.0 + self._cursor_visible = not self._cursor_visible + else: + self._cursor_visible = False + + def render(self, screen: pygame.Surface) -> None: + bg = (255, 255, 255) if self.focused else (240, 240, 248) + border = LIGHT_BLUE if self.focused else (160, 170, 190) + pygame.draw.rect(screen, bg, self.rect, border_radius=6) + pygame.draw.rect(screen, border, self.rect, 2, border_radius=6) + + font = get_font(18) + if self.text: + display = ("*" * len(self.text)) if self.password else self.text + surf = font.render(display, True, BLACK) + else: + surf = font.render(self.placeholder, True, (180, 180, 190)) + + clip_rect = self.rect.inflate(-16, -8) + screen.set_clip(clip_rect) + screen.blit( + surf, (self.rect.x + 10, self.rect.centery - surf.get_height() // 2) + ) + screen.set_clip(None) + + if self.focused and self._cursor_visible and self.text: + cursor_x = self.rect.x + 10 + surf.get_width() + 2 + cursor_y = self.rect.y + 8 + pygame.draw.line( + screen, + BLACK, + (cursor_x, cursor_y), + (cursor_x, self.rect.bottom - 8), + 1, + ) + + +class AccountGateScene(Scene): + def __init__(self) -> None: + super().__init__() + self._state = _State.AUTH + self._error_msg = "" + self._spinner_timer = 0.0 + self._auth_done = False + self._auth_error: Optional[str] = None + + input_w = 320 + cx = SCREEN_WIDTH // 2 + + # Hint text sits at y=280, first input at y=305. + self._login_email = _TextInput(cx - input_w // 2, 305, input_w, 44, "이메일") + self._login_pw = _TextInput( + cx - input_w // 2, 360, input_w, 44, "비밀번호", password=True + ) + self._reg_nick = _TextInput( + cx - input_w // 2, 305, input_w, 44, "닉네임 (2~20자)" + ) + self._reg_email = _TextInput(cx - input_w // 2, 358, input_w, 44, "이메일") + self._reg_pw = _TextInput( + cx - input_w // 2, 411, input_w, 44, "비밀번호", password=True + ) + self._guest_nick = _TextInput( + cx - input_w // 2, 305, input_w, 44, "닉네임 (2~20자)" + ) + + self._auth_tab = "guest" + self._btn_tab_login = Button( + cx - 175, 225, 110, 40, "로그인", self._on_tab_login + ) + self._btn_tab_register = Button( + cx - 55, 225, 110, 40, "회원가입", self._on_tab_register + ) + self._btn_tab_guest = Button( + cx + 65, 225, 110, 40, "게스트", self._on_tab_guest + ) + # Submit button position varies by tab — set dynamically in render. + self._btn_auth_submit = Button( + cx - 100, 420, 200, 44, "계속", self._on_auth_submit + ) + + def _on_tab_login(self) -> None: + self._auth_tab = "login" + self._error_msg = "" + + def _on_tab_register(self) -> None: + self._auth_tab = "register" + self._error_msg = "" + + def _on_tab_guest(self) -> None: + self._auth_tab = "guest" + self._error_msg = "" + + def _on_auth_submit(self) -> None: + if self._state != _State.AUTH: + return + self._error_msg = "" + + if self._auth_tab == "guest": + nick = self._guest_nick.text.strip() + if not nick: + self._error_msg = "닉네임을 입력하세요." + return + self._state = _State.AUTH_REST + threading.Thread( + target=self._do_auth_guest, args=(nick,), daemon=True + ).start() + return + + if self._auth_tab == "login": + email = self._login_email.text.strip() + pw = self._login_pw.text + if not email or not pw: + self._error_msg = "이메일과 비밀번호를 입력하세요." + return + self._state = _State.AUTH_REST + threading.Thread( + target=self._do_auth_login, args=(email, pw), daemon=True + ).start() + return + + nick = self._reg_nick.text.strip() + email = self._reg_email.text.strip() + pw = self._reg_pw.text + if not nick or not email or not pw: + self._error_msg = "모든 필드를 입력하세요." + return + self._state = _State.AUTH_REST + threading.Thread( + target=self._do_auth_register, + args=(email, pw, nick), + daemon=True, + ).start() + + def _finish_auth(self, response: dict) -> None: + from fall_in.core.game_manager import GameManager + + game = GameManager() + game.apply_auth_session( + access_token=response["access_token"], + refresh_token=response.get("refresh_token"), + account_type=response.get("account_type", "guest"), + ) + game.bootstrap_authenticated_account( + sync_local_progress=game.account_type == "registered" + ) + self._auth_done = True + + def _do_auth_guest(self, nickname: str) -> None: + try: + from fall_in.net.backend_api import post_json + + self._finish_auth(post_json("/auth/guest", {"nickname": nickname})) + except Exception as exc: + self._auth_error = str(exc) + + def _do_auth_login(self, email: str, password: str) -> None: + try: + from fall_in.net.backend_api import post_json + + self._finish_auth( + post_json("/auth/login", {"email": email, "password": password}) + ) + except Exception as exc: + self._auth_error = str(exc) + + def _do_auth_register(self, email: str, password: str, nickname: str) -> None: + try: + from fall_in.net.backend_api import post_json + + self._finish_auth( + post_json( + "/auth/register", + {"email": email, "password": password, "nickname": nickname}, + ) + ) + except Exception as exc: + self._auth_error = str(exc) + + def handle_event(self, event: pygame.event.Event) -> None: + if self._state != _State.AUTH: + return + self._btn_tab_login.handle_event(event) + self._btn_tab_register.handle_event(event) + self._btn_tab_guest.handle_event(event) + self._btn_auth_submit.handle_event(event) + if self._auth_tab == "login": + self._login_email.handle_event(event) + self._login_pw.handle_event(event) + elif self._auth_tab == "register": + self._reg_nick.handle_event(event) + self._reg_email.handle_event(event) + self._reg_pw.handle_event(event) + else: + self._guest_nick.handle_event(event) + + def update(self, dt: float) -> None: + self._spinner_timer += dt + + if self._auth_error is not None: + self._error_msg = self._auth_error + self._auth_error = None + self._state = _State.AUTH + + if self._auth_done: + from fall_in.core.game_manager import GameManager, GameState + from fall_in.scenes.title_scene import TitleScene + + game = GameManager() + game.state = GameState.TITLE + game.change_scene(TitleScene()) + return + + if self._state == _State.AUTH: + self._btn_tab_login.update(dt) + self._btn_tab_register.update(dt) + self._btn_tab_guest.update(dt) + self._btn_auth_submit.update(dt) + if self._auth_tab == "login": + self._login_email.update(dt) + self._login_pw.update(dt) + elif self._auth_tab == "register": + self._reg_nick.update(dt) + self._reg_email.update(dt) + self._reg_pw.update(dt) + else: + self._guest_nick.update(dt) + + def render(self, screen: pygame.Surface) -> None: + screen.fill(SAND_BEIGE) + + title_font = get_font(36, "bold") + title = title_font.render("계정 선택", True, AIR_FORCE_BLUE) + screen.blit(title, title.get_rect(center=(SCREEN_WIDTH // 2, 80))) + + sub_font = get_font(18) + sub = sub_font.render( + "싱글플레이 진행도와 멀티플레이 데이터를 맞추기 위해 먼저 계정을 선택해 주세요.", + True, + AIR_FORCE_BLUE, + ) + screen.blit(sub, sub.get_rect(center=(SCREEN_WIDTH // 2, 130))) + + if self._state == _State.AUTH: + self._render_auth(screen) + else: + dots = "." * (int(self._spinner_timer * 3) % 4) + msg = sub_font.render(f"계정 동기화 중{dots}", True, AIR_FORCE_BLUE) + screen.blit( + msg, msg.get_rect(center=(SCREEN_WIDTH // 2, SCREEN_HEIGHT // 2)) + ) + + if self._error_msg: + err_surf = get_font(16).render(self._error_msg, True, (180, 40, 40)) + screen.blit( + err_surf, + err_surf.get_rect(center=(SCREEN_WIDTH // 2, SCREEN_HEIGHT - 60)), + ) + + def _render_auth(self, screen: pygame.Surface) -> None: + cx = SCREEN_WIDTH // 2 + + self._btn_tab_login.render(screen) + self._btn_tab_register.render(screen) + self._btn_tab_guest.render(screen) + + active_map = { + "login": self._btn_tab_login, + "register": self._btn_tab_register, + "guest": self._btn_tab_guest, + } + active_btn = active_map[self._auth_tab] + pygame.draw.line( + screen, + LIGHT_BLUE, + (active_btn.rect.left, active_btn.rect.bottom + 3), + (active_btn.rect.right, active_btn.rect.bottom + 3), + 3, + ) + + lbl_font = get_font(16) + if self._auth_tab == "login": + hint = lbl_font.render("기존 계정으로 이어서 플레이", True, AIR_FORCE_BLUE) + screen.blit(hint, hint.get_rect(center=(cx, 280))) + self._login_email.render(screen) + self._login_pw.render(screen) + self._btn_auth_submit.rect.y = 420 + elif self._auth_tab == "register": + hint = lbl_font.render( + "새 계정을 만들고 진행도를 서버와 연결", True, AIR_FORCE_BLUE + ) + screen.blit(hint, hint.get_rect(center=(cx, 280))) + self._reg_nick.render(screen) + self._reg_email.render(screen) + self._reg_pw.render(screen) + self._btn_auth_submit.rect.y = 470 + else: + hint = lbl_font.render( + "게스트로 시작하며 로컬 진행도를 그대로 사용", True, AIR_FORCE_BLUE + ) + screen.blit(hint, hint.get_rect(center=(cx, 280))) + self._guest_nick.render(screen) + self._btn_auth_submit.rect.y = 370 + + self._btn_auth_submit.render(screen) diff --git a/src/fall_in/scenes/game_mode_select_scene.py b/src/fall_in/scenes/game_mode_select_scene.py new file mode 100644 index 0000000..e6a5adb --- /dev/null +++ b/src/fall_in/scenes/game_mode_select_scene.py @@ -0,0 +1,136 @@ +""" +Game Mode Select Scene (PR-09). + +Shown when the player taps "게임 시작" on the title screen. +Presents two choices: + + 싱글플레이 — proceeds through GameLoadingScene → GameScene (existing path) + 멀티플레이 — proceeds to MultiplayerMenuScene (WS auth + lobby) + +A "← 뒤로" button returns to TitleScene. +""" + +from __future__ import annotations + +import pygame + +from fall_in.config import ( + AIR_FORCE_BLUE, + WHITE, + SAND_BEIGE, + SCREEN_WIDTH, + SCREEN_HEIGHT, +) +from fall_in.scenes.base_scene import Scene +from fall_in.ui.button import Button +from fall_in.utils.asset_loader import get_font +from fall_in.utils.text_utils import draw_outlined_text + + +class GameModeSelectScene(Scene): + """Single choice: 싱글플레이 vs 멀티플레이.""" + + def __init__(self) -> None: + super().__init__() + cx = SCREEN_WIDTH // 2 + btn_w, btn_h = 260, 60 + gap = 24 + + self._btn_single = Button( + x=cx - btn_w // 2, + y=SCREEN_HEIGHT // 2 - btn_h - gap // 2, + width=btn_w, + height=btn_h, + text="싱글플레이", + callback=self._on_single, + ) + self._btn_multi = Button( + x=cx - btn_w // 2, + y=SCREEN_HEIGHT // 2 + gap // 2, + width=btn_w, + height=btn_h, + text="멀티플레이", + callback=self._on_multi, + ) + self._btn_back = Button(20, 20, 100, 40, "← 뒤로", self._on_back) + + # Reuse the title background if available + from fall_in.config import IMAGES_DIR + + self._bg_image: pygame.Surface | None = None + try: + path = IMAGES_DIR / "ui" / "backgrounds" / "title.png" + if path.exists(): + img = pygame.image.load(str(path)).convert() + self._bg_image = pygame.transform.smoothscale( + img, (SCREEN_WIDTH, SCREEN_HEIGHT) + ) + except Exception: + pass + + # ------------------------------------------------------------------ + # Callbacks + # ------------------------------------------------------------------ + + def _on_single(self) -> None: + from fall_in.core.game_manager import GameManager + from fall_in.scenes.game_loading_scene import GameLoadingScene + + game = GameManager() + prev_screen = game.screen.copy() if game.screen else None + game.change_scene(GameLoadingScene(prev_screen=prev_screen)) + + def _on_multi(self) -> None: + from fall_in.core.game_manager import GameManager + from fall_in.scenes.multiplayer_menu_scene import MultiplayerMenuScene + + GameManager().change_scene(MultiplayerMenuScene()) + + def _on_back(self) -> None: + from fall_in.core.game_manager import GameManager + from fall_in.scenes.title_scene import TitleScene + + GameManager().change_scene(TitleScene()) + + # ------------------------------------------------------------------ + # Scene interface + # ------------------------------------------------------------------ + + def handle_event(self, event: pygame.event.Event) -> None: + self._btn_single.handle_event(event) + self._btn_multi.handle_event(event) + self._btn_back.handle_event(event) + + def update(self, dt: float) -> None: + self._btn_single.update(dt) + self._btn_multi.update(dt) + self._btn_back.update(dt) + + def render(self, screen: pygame.Surface) -> None: + if self._bg_image: + screen.blit(self._bg_image, (0, 0)) + else: + screen.fill(SAND_BEIGE) + + # Semi-transparent overlay so buttons stand out over any background + overlay = pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT), pygame.SRCALPHA) + overlay.fill((245, 235, 220, 180)) + screen.blit(overlay, (0, 0)) + + # Heading + font = get_font(36, "bold") + draw_outlined_text( + screen, + "게임 방식 선택", + font, + font.render("게임 방식 선택", True, AIR_FORCE_BLUE) + .get_rect(center=(SCREEN_WIDTH // 2, SCREEN_HEIGHT // 2 - 120)) + .topleft, + AIR_FORCE_BLUE, + WHITE, + outline_offset=2, + ) + + self._btn_single.render(screen) + self._btn_multi.render(screen) + self._btn_back.render(screen) diff --git a/src/fall_in/scenes/game_over_scene.py b/src/fall_in/scenes/game_over_scene.py index 6169886..0c215ea 100644 --- a/src/fall_in/scenes/game_over_scene.py +++ b/src/fall_in/scenes/game_over_scene.py @@ -45,13 +45,19 @@ class GameOverScene(Scene): PHASE_DETAILS = 1 def __init__( - self, winner: Optional[Player], players: list[Player], round_number: int + self, + winner: Optional[Player], + players: list[Player], + round_number: int, + early_exit: bool = False, + multiplayer_reward: Optional[int] = None, ): super().__init__() self.winner = winner self.players = players self.round_number = round_number self.phase = self.PHASE_BANNER + self.early_exit = early_exit # Find human player self.human_player = next( @@ -59,16 +65,38 @@ def __init__( ) self.is_victory = (winner == self.human_player) if winner else False + # Early exit: force defeat, no rewards, no medals + if self.early_exit: + self.is_victory = False + # Check for coup ending - self.is_coup_ending = self._check_coup_ending() + self.is_coup_ending = ( + self._check_coup_ending() if not self.early_exit else False + ) # Calculate and apply rewards - self.reward = self._calculate_reward() + if self.early_exit: + self.reward = 0 + elif multiplayer_reward is not None: + # Multiplayer: server already granted the reward to the DB. + # Just update local display state — no server sync needed. + self.reward = multiplayer_reward + else: + self.reward = self._calculate_reward() from fall_in.core.game_manager import GameManager - GameManager().add_currency(self.reward) - self._award_medals() + gm = GameManager() + if multiplayer_reward is not None: + # Server already persisted; just bump the local counter for display. + gm.currency += self.reward + else: + reason = "single_play_victory" if self.is_victory else "single_play_defeat" + gm.add_currency(self.reward, reason=reason) + if not self.early_exit: + self._award_medals() + else: + self.new_medals = [] # If coup ending achieved, unlock prestige if self.is_coup_ending: @@ -81,19 +109,26 @@ def __init__( self._setup_buttons() # Determine ending scenario and load background image - from fall_in.core.ending_manager import EndingManager - from fall_in.core.smuggling_manager import SmugglingManager from fall_in.utils.asset_manifest import AssetManifest - smuggled = SmugglingManager().get_smuggled_soldiers() - self._scenario = EndingManager().determine_ending(self.is_victory, smuggled) + if self.early_exit: + # Early exit uses defeat_x.png background + bg_stem = "defeat_x" + self._scenario = None + else: + from fall_in.core.ending_manager import EndingManager + from fall_in.core.smuggling_manager import SmugglingManager + + smuggled = SmugglingManager().get_smuggled_soldiers() + self._scenario = EndingManager().determine_ending(self.is_victory, smuggled) + result_str = "victory" if self.is_victory else "defeat" + bg_stem = f"{result_str}_{self._scenario.bg_suffix}" # Load background image from filesystem: # gameover/{result}/{result}_{bg_suffix}.png - result_str = "victory" if self.is_victory else "defeat" - bg_stem = f"{result_str}_{self._scenario.bg_suffix}" self._bg_image: pygame.Surface | None = None - bg_path = GAMEOVER_IMAGES_DIR / result_str / f"{bg_stem}.png" + bg_dir = "defeat" if (not self.is_victory or self.early_exit) else "victory" + bg_path = GAMEOVER_IMAGES_DIR / bg_dir / f"{bg_stem}.png" if bg_path.exists(): try: raw = pygame.image.load(str(bg_path)).convert() @@ -109,7 +144,8 @@ def __init__( self._ui_images.update(AssetManifest.get_loaded(category)) # Record this ending as seen (store bg stem for gallery, e.g. "victory_bg") - self._record_seen_ending(bg_stem) + if not self.early_exit: + self._record_seen_ending(bg_stem) # Stop in-game BGM and play result SFX from fall_in.core.audio_manager import AudioManager diff --git a/src/fall_in/scenes/game_scene.py b/src/fall_in/scenes/game_scene.py index 61cecb4..91d8c37 100644 --- a/src/fall_in/scenes/game_scene.py +++ b/src/fall_in/scenes/game_scene.py @@ -7,7 +7,7 @@ import random from enum import Enum, auto -from typing import Optional +from typing import Callable, Optional import pygame @@ -22,7 +22,7 @@ ) from fall_in.utils.text_utils import draw_outlined_text from fall_in.core.card import Card -from fall_in.core.player import create_players +from fall_in.core.player import Player, PlayerType, create_players from fall_in.core.rules import GameRules, TurnResult from fall_in.ai.ai_player import create_ai_players from fall_in.entities.soldier_figure import SoldierFigure @@ -169,6 +169,49 @@ def __init__( self._settings_btn_center = (SCREEN_WIDTH - 230, 30) self._settings_btn_radius = 18 + # Emote popup (PR-07) — palette that opens on player icon click. + # Wire the send-side by calling set_emote_send_callback() after + # construction. In single-player the palette opens but does nothing. + from fall_in.ui.emote_popup import EmotePopup + + self._emote_popup = EmotePopup() + # Route emote button clicks through _on_emote_selected so the scene + # can own the send logic rather than the popup itself. + self._emote_popup.set_callback(self._on_emote_selected) + # External send callback — set by the networking layer for multiplayer. + self._emote_send_callback: Optional[Callable[[str], None]] = None + self._card_select_callback: Optional[Callable[[int], None]] = None + + # Player icon position — matches _draw_player_icon_ui() + self._player_icon_center = (SCREEN_WIDTH - 170, UI_TOP_BAR_Y + 20) + self._player_icon_radius = 28 + + # Per-seat emote display state: seat_index → (emote_label, ttl_seconds) + # Populated from RemoteGameAdapter in multiplayer (drained in update()), + # or directly via show_emote() in single-player / tests. + self._emote_display: dict[int, tuple[str, float]] = {} + # Display duration for each received emote (seconds) + self._emote_display_duration: float = 3.0 + + # RemoteGameAdapter for multiplayer — set by the networking layer. + # When set, update() drains pending emotes from it each frame. + self._remote_adapter: Optional[object] = None # RemoteGameAdapter + + # Optional network tick callback — set by the multiplayer bootstrap. + # Called once per frame (before emote drain) so the networking layer + # can pump the WS client and route incoming messages to the adapter. + # TODO (PR-09 WS client bootstrap): set via set_network_tick_callback(). + self._network_tick_callback: Optional[Callable[[], None]] = None + self._remote_reveal_step_timer = 0.0 + self._remote_reveal_step_duration = 0.42 + self._remote_current_reveal_seat: Optional[int] = None + self._remote_round_result: Optional[dict] = None + self._remote_round_result_timer = 0.0 + self._remote_round_result_acknowledged = False + self._remote_selection_sent_pending = False + self._round_ready_callback: Optional[Callable[[], None]] = None + self._exit_match_callback: Optional[Callable[[], None]] = None + # Timeout SFX (plays every second when timer <= 5) from fall_in.config import SOUNDS_DIR @@ -369,14 +412,14 @@ def _draw_isometric_tile( def _draw_board(self, screen: pygame.Surface) -> None: """Draw the isometric game board with soldier figures.""" - board = self.rules.board + board_rows = self._get_display_board_rows() # Collect all tiles with depth for proper z-ordering tiles_to_draw = [] for row_idx in range(NUM_ROWS): for col in range(MAX_CARDS_PER_ROW + 1): visual_col = MAX_CARDS_PER_ROW - col - row = board.rows[row_idx] + row = board_rows[row_idx] if row_idx < len(board_rows) else [] tile_type = ( get_tile_type_by_danger(row[col].danger) @@ -394,7 +437,7 @@ def _draw_board(self, screen: pygame.Surface) -> None: # Draw soldier figures (sorted by depth) soldiers_to_draw = [] for row_idx in range(NUM_ROWS): - row = board.rows[row_idx] + row = board_rows[row_idx] if row_idx < len(board_rows) else [] for col in range(len(row)): visual_col = MAX_CARDS_PER_ROW - col card = row[col] @@ -412,6 +455,578 @@ def _draw_board(self, screen: pygame.Surface) -> None: figure = self.soldier_figures[card.number] figure.render(screen, iso_x, iso_y, int(ISO_TILE_HEIGHT)) + def _to_display_card(self, card_like) -> Card: + """ + Convert a network DTO or local Card into a renderable Card object. + + Multiplayer public/private state carries only number + danger. + We enrich the Card with the local player's collection state so that + collected soldiers display their individual portrait and info. + Each player sees cards based on their own collection — a soldier + collected by player A but not player B will appear collected only + on A's screen. + """ + if isinstance(card_like, Card): + return card_like + + from fall_in.data.soldier_data import get_soldier_manager + + number = card_like.number + danger = card_like.danger + manager = get_soldier_manager() + soldier = manager.get_soldier(number) + if soldier is not None and soldier.is_collected: + return Card( + number=number, + danger=danger, + is_collected=True, + name=soldier.name, + rank=soldier.rank, + unit=soldier.unit, + note=soldier.note, + body_type=soldier.body_type, + ) + return Card(number=number, danger=danger) + + def _get_public_state(self): + """Return the latest multiplayer public state, or None in single-player.""" + if self._remote_adapter is None: + return None + return self._remote_adapter.get_public_state() + + def _get_private_state(self): + """Return the latest multiplayer private hand state, or None if unavailable.""" + if self._remote_adapter is None: + return None + return self._remote_adapter.get_private_state() + + def _get_display_board_rows(self) -> list[list[Card]]: + """Return board rows suitable for rendering in local or remote play.""" + public = self._get_public_state() + if public is None: + return self.rules.board.rows + return [ + [self._to_display_card(card) for card in row] for row in public.board_rows + ] + + def _get_display_hand_cards(self) -> list[Card]: + """Return the local player's visible hand for the current mode.""" + private = self._get_private_state() + if private is None: + return self.human_player.hand + cards = [self._to_display_card(card) for card in private.hand] + # The server keeps the selected card in the hand until turn resolution. + # Hide it from the display once the selection has been confirmed so the + # player doesn't see a ghost card (especially on the last turn). + selected_num = getattr(self, "_last_remote_selected_card_number", None) + if selected_num is not None and private.has_selected: + cards = [c for c in cards if c.number != selected_num] + return cards + + def _get_round_number(self) -> int: + """Return the authoritative round number for the current mode.""" + public = self._get_public_state() + if public is not None: + return public.round_number + return self.rules.round_state.round_number + + def _get_local_committed_score(self) -> int: + """Return the committed danger score for the local seat.""" + public = self._get_public_state() + if public is not None: + return public.committed_scores.get(self._get_my_seat(), 0) + return self.rules.get_player_committed_score(self.human_player) + + def _get_local_round_penalty_card_count(self) -> int: + """Return the local player's penalty-card count for this round.""" + if self._remote_adapter is not None: + return self._remote_adapter.get_round_penalty_card_count( + self._get_my_seat() + ) + try: + return int(self.rules.get_player_round_penalty_count(self.human_player)) + except Exception: + return 0 + + def _consume_selection_timer( + self, dt: float, *, on_timeout: Optional[Callable[[], None]] + ) -> bool: + """ + Advance the selection timer with hitch protection. + + Large frame spikes can otherwise chew through most of the timer in one + update and make the turn look like it expired early. + """ + timer_dt = max(0.0, min(float(dt), 0.25)) + self.turn_timer = max(0.0, self.turn_timer - timer_dt) + if self.turn_timer <= 5.0 and self._timeout_sfx is not None: + current_tick = int(self.turn_timer) + if current_tick != self._last_timeout_tick and self.turn_timer > 0: + self._last_timeout_tick = current_tick + self._timeout_sfx.play() + if self.turn_timer > 0: + return False + self._last_timeout_tick = -1 + if on_timeout is not None: + on_timeout() + return True + return False + + def _get_player_order_entries(self) -> list[tuple[str, bool]]: + """ + Return player-order labels for the current mode. + + Each item is ``(label, is_local_player)``. + """ + public = self._get_public_state() + if public is None: + entries = [] + for player in self.rules.player_order: + entries.append( + ( + "나" + if player == self.human_player + else player.name.replace("AI ", ""), + player == self.human_player, + ) + ) + return entries + + my_seat = self._get_my_seat() + entries = [] + for seat_index in public.player_order_seats: + if seat_index == my_seat: + entries.append(("나", True)) + else: + entries.append((str(seat_index + 1), False)) + return entries + + def _get_multiplayer_display_name(self, seat_index: int) -> str: + """Return the display name for a multiplayer seat (nickname or AI name).""" + public = self._get_public_state() + if public is None: + return f"P{seat_index + 1}" + seat = public.get_seat(seat_index) + if seat is None: + return f"P{seat_index + 1}" + return seat.display_name or f"P{seat_index + 1}" + + def _advance_remote_reveal(self, dt: float) -> None: + if self._remote_adapter is None: + return + + # Update running penalty tweens. + if self.penalty_cards_animating: + if self.penalty_tweens.update(dt): + self.penalty_cards_animating.clear() + + if self._remote_reveal_step_timer > 0: + self._remote_reveal_step_timer = max( + 0.0, self._remote_reveal_step_timer - dt + ) + if self._remote_reveal_step_timer > 0: + return + + next_step = self._remote_adapter.pop_next_reveal_step() + if next_step is not None: + step, snapshot = next_step + self._remote_adapter.apply_public_state(snapshot) + self._remote_current_reveal_seat = step.seat_index + actor = ( + "나" + if step.seat_index == self._get_my_seat() + else self._get_multiplayer_display_name(step.seat_index) + ) + self.message = f"{actor}: #{step.card_number}" + + if step.penalty_score > 0 and step.penalty_card_count > 0: + # Extend the reveal timer to allow the penalty animation to play. + anim_duration = 0.4 + step.penalty_card_count * 0.1 + self._remote_reveal_step_timer = ( + self._remote_reveal_step_duration + anim_duration + ) + self.message_timer = self._remote_reveal_step_timer + self._start_remote_penalty_animation(step) + else: + self._remote_reveal_step_timer = self._remote_reveal_step_duration + self.message_timer = self._remote_reveal_step_duration + return + + if not self._remote_adapter.is_turn_reveal_active(): + self._remote_current_reveal_seat = None + for state in self._remote_adapter.flush_post_reveal_public_states(): + self._remote_adapter.apply_public_state(state) + + def _start_remote_penalty_animation(self, step) -> None: + """Start penalty card animation for a multiplayer reveal step.""" + from types import SimpleNamespace + + self.penalty_cards_animating.clear() + self.penalty_tweens = TweenGroup() + + my_seat = self._get_my_seat() + other_seats = self._get_other_seat_order() + + if step.seat_index == my_seat: + target_x, target_y = ICON_HANGER_X + 48, UI_TOP_BAR_Y + 18 + elif step.seat_index in other_seats: + panel_index = other_seats.index(step.seat_index) + panel_w, panel_h = 200, 70 + panel_spacing = 10 + panel_count = len(other_seats) + total_h = panel_count * panel_h + max(0, panel_count - 1) * panel_spacing + panel_area_top = UI_TOP_BAR_HEIGHT + 10 + panel_area_bottom = SCREEN_HEIGHT - 220 + panel_start_y = ( + panel_area_top + (panel_area_bottom - panel_area_top - total_h) // 2 + ) + panel_x = SCREEN_WIDTH - panel_w - 10 + target_x = panel_x + panel_w // 2 + target_y = ( + panel_start_y + panel_index * (panel_h + panel_spacing) + panel_h // 2 + ) + else: + target_x, target_y = SCREEN_WIDTH // 2, SCREEN_HEIGHT // 2 + + for i in range(step.penalty_card_count): + tween = Tween( + start=(BOARD_OFFSET_X, BOARD_OFFSET_Y + 50), + end=(target_x, target_y), + duration=0.4 + i * 0.1, + easing="ease_in", + ) + proxy_card = SimpleNamespace( + number=step.card_number, danger=step.card_danger + ) + self.penalty_cards_animating.append((proxy_card, tween)) + self.penalty_tweens.add(tween) + + if step.seat_index == my_seat: + self.commander.say_penalty_taken() + + def _has_remote_round_result_overlay(self) -> bool: + return getattr(self, "_remote_round_result", None) is not None + + def _is_remote_reveal_in_progress(self) -> bool: + if self._remote_adapter is None: + return False + if getattr(self, "_remote_current_reveal_seat", None) is not None: + return True + has_pending = getattr(self._remote_adapter, "has_pending_reveal_steps", None) + if callable(has_pending) and has_pending(): + return True + is_active = getattr(self._remote_adapter, "is_turn_reveal_active", None) + if callable(is_active) and is_active(): + return True + return getattr(self, "_remote_reveal_step_timer", 0.0) > 0 + + def _is_local_player_eliminated(self) -> bool: + """Return True when the local seat is eliminated in multiplayer.""" + private = self._get_private_state() + if private is None: + return False + return private.is_eliminated + + def _can_interact_with_cards(self) -> bool: + if self._remote_adapter is None: + return self.phase == GamePhase.SELECTING + + if self._is_local_player_eliminated(): + return False + public = self._get_public_state() + if public is None or public.phase != "SELECTING": + return False + if self._has_remote_round_result_overlay(): + return False + if self._is_remote_reveal_in_progress(): + return False + return not self._is_waiting_for_other_players() + + def _is_waiting_for_other_players(self) -> bool: + if self._remote_adapter is None: + return False + public = self._get_public_state() + private = self._get_private_state() + if public is None or public.phase != "SELECTING": + return False + if self._has_remote_round_result_overlay(): + return False + if private is not None and private.has_selected: + return True + return bool(getattr(self, "_remote_selection_sent_pending", False)) + + def _should_show_selection_ui(self) -> bool: + if self._remote_adapter is None: + return self.phase == GamePhase.SELECTING + if self._is_local_player_eliminated(): + return False + public = self._get_public_state() + return ( + public is not None + and public.phase == "SELECTING" + and not self._has_remote_round_result_overlay() + and not self._is_remote_reveal_in_progress() + ) + + def _start_remote_round_result(self, data: dict) -> None: + normalised = dict(data) + normalised["round_danger"] = { + int(seat_index): int(value) + for seat_index, value in (data.get("round_danger") or {}).items() + } + normalised["total_scores"] = { + int(seat_index): int(value) + for seat_index, value in (data.get("total_scores") or {}).items() + } + normalised["eliminated_seats"] = [ + int(seat) for seat in data.get("eliminated_seats", []) + ] + self._remote_round_result = normalised + self._remote_round_result_timer = float(data.get("timeout_seconds", 0)) + self._remote_round_result_acknowledged = False + self._remote_selection_sent_pending = False + self.selected_card_index = None + self.message = "" + self.message_timer = 0.0 + + def _clear_remote_round_result(self) -> None: + self._remote_round_result = None + self._remote_round_result_timer = 0.0 + self._remote_round_result_acknowledged = False + + def _confirm_remote_round_result(self) -> None: + if not self._has_remote_round_result_overlay(): + return + if self._remote_round_result_acknowledged: + return + self._remote_round_result_acknowledged = True + if self._round_ready_callback is not None: + self._round_ready_callback() + + def _reset_remote_selecting_phase( + self, remaining_time: float = TURN_TIMEOUT_SECONDS + ) -> None: + """Reset transient local UI state when the server enters SELECTING.""" + self.turn_timer = remaining_time + self._last_timeout_tick = -1 + self.selected_card_index = None + self._remote_selection_sent_pending = False + self._last_remote_selected_card_number = None + self._clear_remote_round_result() + # Reset penalty counters when the round number advances so stale + # counts from the previous round don't linger on turn 1. + if self._remote_adapter is not None: + public = self._remote_adapter.get_public_state() + if public is not None: + current_round = public.round_number + prev_round = getattr(self, "_last_penalty_reset_round", 0) + if current_round != prev_round: + self._remote_adapter.reset_round_penalty_counts() + self._last_penalty_reset_round = current_round + # Clear soldier figures so cards from the new deck get + # fresh drop animations (same numbers reused across rounds). + if hasattr(self, "soldier_figures"): + self.soldier_figures.clear() + + def _enter_remote_result_scene(self, round_result: dict) -> None: + """Switch to the shared ResultScene for multiplayer round settlement.""" + public = self._get_public_state() + if public is None or self._remote_adapter is None: + return + + from fall_in.core.game_manager import GameManager, GameState + from fall_in.scenes.result_scene import ResultScene + + gm = GameManager() + gm.state = GameState.RESULT + gm.change_scene( + ResultScene.from_remote( + round_result=round_result, + public_state=public, + remote_adapter=self._remote_adapter, + resume_scene=self, + network_tick_callback=self._network_tick_callback, + round_ready_callback=self._round_ready_callback, + ) + ) + + def _update_remote_match_flow(self, dt: float) -> bool: + if self._remote_adapter is None: + return False + + # Handle server-side SELECTION_TIMEOUT events FIRST, before + # consuming PHASE_SELECTING. When the server times out, it sends + # SELECTION_TIMEOUT, resolves the turn, and immediately broadcasts + # PHASE_SELECTING for the next turn — all in one pump batch. + # Processing the timeout first lets the subsequent PHASE_SELECTING + # reset cleanly override the stale state (pending flag, timer=0). + pop_timeout = getattr(self._remote_adapter, "pop_selection_timeout", None) + if callable(pop_timeout): + timed_out_seats = pop_timeout() + if timed_out_seats is not None: + my_seat = self._get_my_seat() + if my_seat in timed_out_seats: + self.message = "시간 초과! 자동 선택됨" + self.message_timer = 1.0 + self._remote_selection_sent_pending = True + self.turn_timer = 0.0 + + consume_selecting = getattr( + self._remote_adapter, "consume_selecting_phase_started", None + ) + if callable(consume_selecting): + remaining = consume_selecting() + while remaining is not None: + self._reset_remote_selecting_phase(remaining) + remaining = consume_selecting() + + if not self._is_remote_reveal_in_progress(): + pop_round_result = getattr(self._remote_adapter, "pop_round_result", None) + if callable(pop_round_result): + round_result = pop_round_result() + if round_result is not None: + self._enter_remote_result_scene(round_result) + return True + + pop_match_result = getattr(self._remote_adapter, "pop_match_result", None) + if callable(pop_match_result): + match_result = pop_match_result() + if match_result is not None: + self._clear_remote_round_result() + self._go_to_remote_game_over(match_result) + return True + + # Always consume the selection timer when in SELECTING phase so it + # stays in sync with the server's 30-second timeout. Previously the + # timer was paused during reveal animations, causing drift where the + # server would auto-select while the client still showed time left. + # Eliminated players skip the timer entirely — they are spectators. + public = self._get_public_state() + in_selecting = ( + public is not None + and public.phase == "SELECTING" + and not self._has_remote_round_result_overlay() + and not self._is_local_player_eliminated() + ) + if in_selecting: + self._consume_selection_timer(dt, on_timeout=self._remote_auto_select_card) + else: + self._last_timeout_tick = -1 + + if self._has_remote_round_result_overlay(): + self._remote_round_result_timer = max( + 0.0, self._remote_round_result_timer - dt + ) + return False + + def _build_remote_game_over_players( + self, match_result: dict + ) -> tuple[Optional[Player], list[Player]]: + public = self._get_public_state() + if public is None: + return None, [] + + my_seat = self._get_my_seat() + final_scores = { + int(seat_index): int(value) + for seat_index, value in (match_result.get("final_scores") or {}).items() + } + players: list[Player] = [] + for seat in sorted(public.seats, key=lambda s: s.seat_index): + player = Player( + name=seat.display_name, + player_type=PlayerType.HUMAN + if seat.seat_index == my_seat + else PlayerType.AI, + player_id=seat.seat_index, + ) + player.penalty_score = int( + final_scores.get( + seat.seat_index, public.committed_scores.get(seat.seat_index, 0) + ) + ) + player.is_eliminated = player.penalty_score >= GAME_OVER_SCORE + players.append(player) + + winner_seat = match_result.get("winner_seat") + if winner_seat is not None: + winner_seat = int(winner_seat) + winner = next( + (player for player in players if player.player_id == winner_seat), None + ) + return winner, players + + def _go_to_remote_game_over(self, match_result: dict) -> None: + winner, players = self._build_remote_game_over_players(match_result) + if not players: + return + + # Extract per-seat reward granted by the server. + my_seat = self._get_my_seat() + rewards = match_result.get("rewards") or {} + my_reward = int(rewards.get(str(my_seat), rewards.get(my_seat, 0))) + + from fall_in.core.game_manager import GameManager, GameState + from fall_in.scenes.game_over_scene import GameOverScene + + gm = GameManager() + gm.clear_pending_match_reconnect() + gm.state = GameState.GAMEOVER + gm.change_scene( + GameOverScene( + winner=winner, + players=players, + round_number=self._get_round_number(), + multiplayer_reward=my_reward, + ) + ) + + def _handle_exit_match(self) -> None: + """Handle voluntary exit from a multiplayer match.""" + is_eliminated = self._is_local_player_eliminated() + + # Send MATCH_LEAVE to server + if self._exit_match_callback is not None: + self._exit_match_callback() + + # Build a minimal player list for GameOverScene + from fall_in.core.game_manager import GameManager, GameState + from fall_in.scenes.game_over_scene import GameOverScene + + public = self._get_public_state() + my_seat = self._get_my_seat() + players: list[Player] = [] + + if public is not None: + for seat in sorted(public.seats, key=lambda s: s.seat_index): + player = Player( + name=seat.display_name, + player_type=PlayerType.HUMAN + if seat.seat_index == my_seat + else PlayerType.AI, + player_id=seat.seat_index, + ) + player.penalty_score = int( + public.committed_scores.get(seat.seat_index, 0) + ) + players.append(player) + else: + # Fallback: create a single human player + player = Player(name="", player_type=PlayerType.HUMAN, player_id=my_seat) + players.append(player) + + gm = GameManager() + gm.clear_pending_match_reconnect() + gm.state = GameState.GAMEOVER + gm.change_scene( + GameOverScene( + winner=None, + players=players, + round_number=self._get_round_number(), + early_exit=not is_eliminated, + ) + ) + # ------------------------------------------------------------------ # UI drawing # ------------------------------------------------------------------ @@ -457,7 +1072,7 @@ def _draw_ui(self, screen: pygame.Surface) -> None: # Draw round number centered on the badge round_num_font = get_font(20, "bold") round_text = round_num_font.render( - f"ROUND {self.rules.round_state.round_number}", True, WHITE + f"ROUND {self._get_round_number()}", True, WHITE ) round_rect = round_text.get_rect( center=(badge_x + badge_w // 2, badge_y + badge_h // 2) @@ -466,7 +1081,7 @@ def _draw_ui(self, screen: pygame.Surface) -> None: else: draw_outlined_text( screen, - f"ROUND {self.rules.round_state.round_number}", + f"ROUND {self._get_round_number()}", title_font, (20, top_y), WHITE, @@ -490,15 +1105,12 @@ def _draw_ui(self, screen: pygame.Surface) -> None: pygame.draw.polygon(screen, LIGHT_BLUE, hangar_points) pygame.draw.polygon(screen, WHITE, hangar_points, width=2) - cards_taken = self.rules.get_player_round_penalty_count(self.human_player) - draw_outlined_text( - screen, - str(cards_taken), - font, - (hangar_x + 22, top_y + 25), - WHITE, - AIR_FORCE_BLUE, - ) + cards_taken = self._get_local_round_penalty_card_count() + badge_center = (hangar_x + 48, top_y + 18) + pygame.draw.circle(screen, AIR_FORCE_BLUE, badge_center, 12) + pygame.draw.circle(screen, WHITE, badge_center, 12, 2) + count_text = mini_font.render(str(cards_taken), True, WHITE) + screen.blit(count_text, count_text.get_rect(center=badge_center)) # Danger color legend below UI bar legend_y = UI_TOP_BAR_HEIGHT + 3 @@ -539,49 +1151,57 @@ def _draw_ui(self, screen: pygame.Surface) -> None: ) order_x = UI_ELEMENT_PLAYER_ORDER_X - for i, player in enumerate(self.rules.player_order): - name = ( - "나" if player == self.human_player else player.name.replace("AI ", "") - ) - color = DANGER_SAFE if player == self.human_player else LIGHT_BLUE - - is_current = ( - self.current_placement - and self.current_placement.player == player - and self.phase - in [GamePhase.PLACING_PLAYER, GamePhase.PENALTY_ANIMATION] - ) + order_entries = self._get_player_order_entries() + for i, (name, is_local_player) in enumerate(order_entries): + label = name + color = DANGER_SAFE if is_local_player else LIGHT_BLUE + + is_current = False + if self._remote_adapter is None: + player = self.rules.player_order[i] + is_current = ( + self.current_placement + and self.current_placement.player == player + and self.phase + in [GamePhase.PLACING_PLAYER, GamePhase.PENALTY_ANIMATION] + ) + elif self._remote_current_reveal_seat is not None: + public = self._get_public_state() + if public is not None and i < len(public.player_order_seats): + is_current = ( + public.player_order_seats[i] == self._remote_current_reveal_seat + ) if is_current: pygame.draw.rect( screen, DANGER_WARNING, - (order_x - 2, order_y - 2, 20, 16), + (order_x - 4, order_y - 2, 24, 16), border_radius=3, ) draw_outlined_text( screen, - name, + label, mini_font, (order_x, order_y), WHITE if is_current else color, TOP_BAR_OUTLINE_COLOR, ) - if i < len(self.rules.player_order) - 1: + if i < len(order_entries) - 1: draw_outlined_text( screen, "→", mini_font, - (order_x + 14, order_y), + (order_x + 18, order_y), LIGHT_BLUE, TOP_BAR_OUTLINE_COLOR, ) - order_x += 28 + order_x += 38 # Danger gauge (center-right) - committed = self.rules.get_player_committed_score(self.human_player) + committed = self._get_local_committed_score() gauge_x = SCREEN_WIDTH // 2 + 50 # Danger warning icon @@ -665,39 +1285,58 @@ def _draw_ui(self, screen: pygame.Surface) -> None: self._draw_player_icon_ui(screen, top_y) # === OTHER PLAYERS (Right sidebar, center-right aligned) === - other_players = self.players[1:] + # Single-player: iterate Player objects directly (original approach). + # Multiplayer: use server-state dicts from _get_other_player_panels(). + if self._remote_adapter is None: + other_players = self.players[1:] + else: + other_players = [] + + mp_panel_data = ( + self._get_other_player_panels() if self._remote_adapter is not None else [] + ) + + panel_count = ( + len(other_players) if self._remote_adapter is None else len(mp_panel_data) + ) panel_w, panel_h = 200, 70 panel_spacing = 10 total_panels_height = ( - len(other_players) * panel_h + (len(other_players) - 1) * panel_spacing + panel_count * panel_h + max(0, panel_count - 1) * panel_spacing ) - # Vertically center panels in the playfield area (below top bar, above hand) panel_area_top = UI_TOP_BAR_HEIGHT + 10 - panel_area_bottom = SCREEN_HEIGHT - 220 # above hand cards area + panel_area_bottom = SCREEN_HEIGHT - 220 panel_start_y = ( panel_area_top + (panel_area_bottom - panel_area_top - total_panels_height) // 2 ) panel_x = SCREEN_WIDTH - panel_w - 10 - for i, player in enumerate(other_players): + _panel_iter = other_players if self._remote_adapter is None else mp_panel_data + for i, panel_item in enumerate(_panel_iter): p_rect = pygame.Rect( panel_x, panel_start_y + i * (panel_h + panel_spacing), panel_w, panel_h ) # Panel background + if self._remote_adapter is None: + player = panel_item + is_elim = player.is_eliminated + else: + pdata = panel_item + is_elim = pdata.get("is_eliminated", False) if "player_panel" in self._hud_images: panel_img = pygame.transform.smoothscale( self._hud_images["player_panel"], (p_rect.width, p_rect.height) ) - if player.is_eliminated: + if is_elim: tint = pygame.Surface(panel_img.get_size(), pygame.SRCALPHA) tint.fill((180, 40, 40, 100)) panel_img = panel_img.copy() panel_img.blit(tint, (0, 0)) screen.blit(panel_img, p_rect.topleft) else: - bg_color = DANGER_DANGER if player.is_eliminated else LIGHT_BLUE + bg_color = DANGER_DANGER if is_elim else LIGHT_BLUE pygame.draw.rect(screen, bg_color, p_rect, border_radius=6) pygame.draw.rect( screen, AIR_FORCE_BLUE, p_rect, width=2, border_radius=6 @@ -754,21 +1393,35 @@ def _draw_ui(self, screen: pygame.Surface) -> None: # Text info (right of avatar) text_x = avatar_cx + avatar_radius + 6 - order_pos = self.rules.get_player_order_position(player) + if self._remote_adapter is None: + order_pos = self.rules.get_player_order_position(player) + p_name = player.name + p_committed = self.rules.get_player_committed_score(player) + p_cards = self.rules.get_player_round_penalty_count(player) + p_status = "" + else: + order_pos = pdata.get("order_pos", 0) + p_name = pdata.get("name", "?") + p_committed = pdata.get("committed", 0) + p_cards = pdata.get("penalty", 0) + p_status = pdata.get("status", "") screen.blit( - small_font.render(f"{order_pos}.{player.name}", True, WHITE), + small_font.render(f"{order_pos}.{p_name}", True, WHITE), (text_x, p_rect.y + 8), ) - p_committed = self.rules.get_player_committed_score(player) screen.blit( mini_font.render(f"위험: {p_committed}", True, WHITE), (text_x, p_rect.y + 28), ) - p_cards = self.rules.get_player_round_penalty_count(player) + meta_text = f"벌칙: {p_cards}장" + meta_color = WHITE + if p_status: + meta_text = f"{meta_text} · {p_status}" + meta_color = DANGER_WARNING if p_status == "재접속 중" else LIGHT_BLUE screen.blit( - mini_font.render(f"벌칙: {p_cards}장", True, WHITE), + mini_font.render(meta_text, True, meta_color), (text_x, p_rect.y + 45), ) @@ -836,6 +1489,9 @@ def _draw_ui(self, screen: pygame.Surface) -> None: ) screen.blit(msg_surface, msg_rect) + # Emote display — floating emoji badges above player panels (PR-07) + self._draw_emote_display(screen, panel_x, panel_start_y, panel_h, panel_spacing) + # Phase indicator phase_text = small_font.render( f"[{self._get_phase_text()}]", True, AIR_FORCE_BLUE @@ -900,6 +1556,28 @@ def _draw_player_icon_ui(self, screen: pygame.Surface, top_y: int) -> None: def _get_phase_text(self) -> str: """Get current phase description in Korean.""" + if self._has_remote_round_result_overlay(): + return "라운드 정산" + + public = self._get_public_state() + if public is not None: + has_pending_reveal = False + if self._remote_adapter is not None: + has_pending = getattr( + self._remote_adapter, "has_pending_reveal_steps", None + ) + if callable(has_pending): + has_pending_reveal = has_pending() + if self._remote_current_reveal_seat is not None or has_pending_reveal: + return "카드 배치" + remote_phase_texts = { + "SELECTING": "카드 선택", + "PLACING": "카드 배치", + "ROUND_END": "라운드 종료", + "GAME_OVER": "게임 종료", + } + return remote_phase_texts.get(public.phase.upper(), public.phase) + phase_texts = { GamePhase.STARTING: "라운드 시작", GamePhase.SELECTING: "카드 선택", @@ -913,13 +1591,181 @@ def _get_phase_text(self) -> str: } return phase_texts.get(self.phase, "") + # ------------------------------------------------------------------ + # Emote display + # ------------------------------------------------------------------ + + def _get_my_seat(self) -> int: + """Return the local player's seat index (0 in single-player, adapter.my_seat in multiplayer).""" + if self._remote_adapter is not None: + return self._remote_adapter.my_seat + return 0 + + def _get_other_seat_order(self) -> list[int]: + """ + Return seat indices of all non-local seats, in the order they appear as + sidebar panels (ascending seat_index, skipping my seat). + + Single-player: seats 1, 2, 3. + Multiplayer: sorted server seat indices excluding my_seat. + """ + my_seat = self._get_my_seat() + if self._remote_adapter is not None: + public = self._remote_adapter.get_public_state() + if public: + return sorted( + s.seat_index for s in public.seats if s.seat_index != my_seat + ) + return [i for i in range(1, len(self.players))] + + def _get_other_player_panels(self) -> list[dict]: + """ + Build panel data list for the right sidebar. + + In single-player mode: uses local GameRules data. + In multiplayer mode: uses RemoteGameAdapter public state so that the + server-authoritative names and scores are shown. + """ + if self._remote_adapter is None: + # Single-player — local rules + panels: list[dict] = [] + for i, p in enumerate(self.players[1:]): + try: + panels.append( + { + "name": p.name, + "committed": self.rules.get_player_committed_score(p), + "penalty": self.rules.get_player_round_penalty_count(p), + "order_pos": self.rules.get_player_order_position(p), + "seat_index": i + 1, + "is_eliminated": p.is_eliminated, + } + ) + except Exception as exc: # noqa: BLE001 + import sys + + print( + f"[PANEL] failed for player {p.name!r} (id={p.player_id}): {exc}", + file=sys.stderr, + ) + panels.append( + { + "name": p.name, + "committed": 0, + "penalty": 0, + "order_pos": 0, + "seat_index": i + 1, + "is_eliminated": getattr(p, "is_eliminated", False), + } + ) + return panels + + # Multiplayer — server state + public = self._remote_adapter.get_public_state() + my_seat = self._remote_adapter.my_seat + if public is None: + return [] + + order = public.player_order_seats + scores = public.committed_scores + panels = [] + for seat in sorted(public.seats, key=lambda s: s.seat_index): + if seat.seat_index == my_seat: + continue + order_pos = seat.seat_index + 1 + committed = scores.get(seat.seat_index, 0) + # Eliminated: score >= 66 or removed from player order + is_elim = committed >= GAME_OVER_SCORE or ( + seat.seat_index not in order and committed > 0 + ) + status = "" + if self._remote_adapter.is_seat_bot_takeover(seat.seat_index): + status = "BOT 대체" + elif self._remote_adapter.is_seat_disconnected(seat.seat_index): + status = "재접속 중" + panels.append( + { + "name": seat.display_name, + "committed": committed, + "penalty": self._remote_adapter.get_round_penalty_card_count( + seat.seat_index + ), + "order_pos": order_pos, + "seat_index": seat.seat_index, + "is_eliminated": is_elim, + "status": status, + } + ) + return panels + + def _draw_emote_display( + self, + screen: pygame.Surface, + panel_x: int, + panel_start_y: int, + panel_h: int, + panel_spacing: int, + ) -> None: + """ + Render floating emote badges near each player's UI element. + + My seat → below my player icon in the top bar (right side). + Other seats → to the LEFT of their sidebar panel so the badge + doesn't obscure the panel content. + + Seat mapping is relative to the local player's my_seat so that + multiplayer clients (seat != 0) are handled correctly. + """ + if not self._emote_display: + return + + emote_font = get_font(26) + my_seat = self._get_my_seat() + other_seats = self._get_other_seat_order() + + for seat_index, (emoji, ttl) in self._emote_display.items(): + alpha = 255 + if ttl < 0.5: + alpha = int(255 * (ttl / 0.5)) + + if seat_index == my_seat: + # My emote: below the player icon in the top bar + cx, cy = self._player_icon_center + badge_cx = cx + badge_cy = cy + self._player_icon_radius + 20 + elif seat_index in other_seats: + panel_index = other_seats.index(seat_index) + panel_top = panel_start_y + panel_index * (panel_h + panel_spacing) + panel_center_y = panel_top + panel_h // 2 + # Badge to the LEFT of the panel, vertically centred + badge_cx = panel_x - 28 + badge_cy = panel_center_y + else: + continue # unknown seat — skip + + emoji_surf = emote_font.render(emoji, True, WHITE) + emoji_surf.set_alpha(alpha) + emoji_rect = emoji_surf.get_rect(center=(badge_cx, badge_cy)) + + bg = pygame.Surface( + (emoji_rect.width + 10, emoji_rect.height + 6), pygame.SRCALPHA + ) + pygame.draw.rect( + bg, + (0, 0, 0, min(alpha, 140)), + (0, 0, bg.get_width(), bg.get_height()), + border_radius=8, + ) + screen.blit(bg, (emoji_rect.x - 5, emoji_rect.y - 3)) + screen.blit(emoji_surf, emoji_rect) + # ------------------------------------------------------------------ # Hand drawing # ------------------------------------------------------------------ def _draw_hand(self, screen: pygame.Surface) -> None: """Draw player's hand cards in a fan layout at the bottom.""" - hand = self.human_player.hand + hand = self._get_display_hand_cards() if not hand: return @@ -995,7 +1841,7 @@ def _draw_hand(self, screen: pygame.Surface) -> None: card, x, draw_y, - is_interviewed=card.is_collected, + is_interviewed=getattr(card, "is_collected", False), is_selected=is_selected, is_hovered=is_hovered, rotation=rotation, @@ -1008,10 +1854,31 @@ def _draw_hand(self, screen: pygame.Surface) -> None: def handle_event(self, event: pygame.event.Event) -> None: """Handle pygame events.""" + if self._has_remote_round_result_overlay(): + if event.type == pygame.KEYDOWN and event.key in ( + pygame.K_SPACE, + pygame.K_RETURN, + ): + self._confirm_remote_round_result() + return + if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1: + if self._get_round_ready_button_rect().collidepoint(event.pos): + self._confirm_remote_round_result() + return + # Settings popup consumes events when visible if self._settings_popup.handle_event(event): return + # Emote popup: forward non-click events (motion, keyboard) so that + # hover tracking and Escape-to-close work, but never block gameplay. + # Click events are handled inline in the MOUSEBUTTONDOWN block below + # so we can prevent the icon from immediately re-opening the palette + # when an outside click dismisses it. + if event.type != pygame.MOUSEBUTTONDOWN: + if self._emote_popup.handle_event(event): + return + # Debug overlay handling (via mixin) if self.handle_debug_event(event): return @@ -1022,20 +1889,39 @@ def handle_event(self, event: pygame.event.Event) -> None: return elif event.key == pygame.K_SPACE: if ( - self.phase == GamePhase.SELECTING + self._can_interact_with_cards() and self.selected_card_index is not None ): self._confirm_card_selection() if event.type == pygame.MOUSEBUTTONDOWN: - # Settings gear button click mx, my = event.pos + + # Emote popup gets first crack at clicks when it is open. + # If the click is outside the palette, the popup closes and returns + # False — we must NOT then check the player icon, or the palette + # would immediately re-open. + _popup_was_open = self._emote_popup.visible + if _popup_was_open: + if self._emote_popup.handle_event(event): + return # click consumed by palette button / frame + # Outside click: palette just closed — skip icon re-check but + # fall through so the click still reaches card selection. + + # Settings gear button click sx, sy = self._settings_btn_center if ((mx - sx) ** 2 + (my - sy) ** 2) ** 0.5 <= self._settings_btn_radius: self._settings_popup.toggle() return - if self.phase == GamePhase.SELECTING: + # Player icon click → open emote palette (only when it was closed) + if not _popup_was_open: + ix, iy = self._player_icon_center + if ((mx - ix) ** 2 + (my - iy) ** 2) ** 0.5 <= self._player_icon_radius: + self._emote_popup.show(self._player_icon_center) + return + + if self._can_interact_with_cards(): if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1: self._handle_card_click(event.pos) elif event.type == pygame.MOUSEMOTION and self.dragging: @@ -1046,7 +1932,10 @@ def handle_event(self, event: pygame.event.Event) -> None: def _handle_card_click(self, pos: tuple[int, int]) -> None: """Handle clicking on a card in hand (fan layout).""" - hand = self.human_player.hand + if not self._can_interact_with_cards(): + return + + hand = self._get_display_hand_cards() if not hand: return @@ -1082,8 +1971,26 @@ def _confirm_card_selection(self) -> None: """Confirm card selection and proceed to AI phase.""" if self.selected_card_index is None: return + if self._remote_adapter is not None and not self._can_interact_with_cards(): + return + + hand = self._get_display_hand_cards() + if not 0 <= self.selected_card_index < len(hand): + self.selected_card_index = None + return + + card = hand[self.selected_card_index] + + if self._remote_adapter is not None: + self.selected_card_index = None + if self._card_select_callback is not None: + self._remote_selection_sent_pending = True + self._last_remote_selected_card_number = card.number + self._card_select_callback(card.number) + self.message = "선택 완료" + self.message_timer = 0.6 + return - card = self.human_player.hand[self.selected_card_index] self.human_player.select_card(card) self.selected_card_index = None @@ -1193,6 +2100,21 @@ def _auto_select_card(self) -> None: self.phase = GamePhase.AI_THINKING self.phase_timer = 0.3 + def _remote_auto_select_card(self) -> None: + """Auto-select a random card in multiplayer when client timer expires.""" + if self._is_waiting_for_other_players(): + return + if self._is_local_player_eliminated(): + return + hand = self._get_display_hand_cards() + if hand and self._card_select_callback is not None: + card = random.choice(hand) + self._remote_selection_sent_pending = True + self._last_remote_selected_card_number = card.number + self._card_select_callback(card.number) + self.message = "시간 초과! 자동 선택됨" + self.message_timer = 1.0 + def _go_to_result_scene(self) -> None: """Navigate to ResultScene for round settlement.""" from fall_in.core.game_manager import GameManager @@ -1204,11 +2126,205 @@ def _go_to_result_scene(self) -> None: # Update / Render # ------------------------------------------------------------------ + def set_emote_send_callback(self, callback: Callable[[str], None]) -> None: + """ + Register the function that transmits an emote to the server. + + Called by the networking layer before the scene starts rendering. + In single-player this is never called; the palette opens but does + nothing when the player clicks an emote button. + + Example (multiplayer setup):: + + scene.set_emote_send_callback(ws_client.send_emote) + + .. note:: + TODO (PR-09 WS client bootstrap): call this during the multiplayer + scene setup after the WS connection is established, before the + first ``update()`` tick. + """ + self._emote_send_callback = callback + + def set_card_select_callback(self, callback: Callable[[int], None]) -> None: + """ + Register the function that submits the chosen card number to the server. + + Multiplayer uses this instead of mutating local GameRules state. + """ + self._card_select_callback = callback + + def set_round_ready_callback(self, callback: Callable[[], None]) -> None: + """Register the function that acknowledges the round-settlement screen.""" + self._round_ready_callback = callback + + def set_remote_adapter(self, adapter: object) -> None: + """ + Attach a RemoteGameAdapter so that incoming EMOTE_BROADCAST messages + are consumed each frame and displayed via show_emote(). + + The adapter must expose ``pop_pending_emotes() -> list[(seat, emote_id)]``. + Call this once after the match starts and before the first update(). + + .. note:: + TODO (PR-09 WS client bootstrap): call this during the multiplayer + scene setup, passing the ``RemoteGameAdapter`` instance that + receives ``EMOTE_BROADCAST`` messages from the server. + """ + self._remote_adapter = adapter + # Disable the local-only opening round flow once the scene becomes + # server-authoritative. The actual match state now comes from the adapter. + self.phase = GamePhase.SELECTING + self.turn_timer = TURN_TIMEOUT_SECONDS + self.phase_timer = 0.0 + self.message = "" + self.message_timer = 0.0 + self._remote_selection_sent_pending = False + self._clear_remote_round_result() + self.dealing_cards.clear() + self.placement_queue.clear() + self.penalty_cards_animating.clear() + self.current_placement = None + self._remote_reveal_step_timer = 0.0 + self._remote_current_reveal_seat = None + + # Wire exit button in settings popup for multiplayer + self._settings_popup.set_exit_callback(self._handle_exit_match) + + def set_network_tick_callback(self, callback: Callable[[], None]) -> None: + """ + Register a zero-argument callable that is invoked once per frame from + update(), before emotes are drained from the remote adapter. + + The networking layer uses this hook to pump the WsClient and route + incoming server messages (PHASE_SELECTING, PRIVATE_HAND_STATE, + EMOTE_BROADCAST, etc.) to the RemoteGameAdapter. + + .. note:: + TODO (PR-09 WS client bootstrap): wired from RoomLobbyScene after + MATCH_START is received and the GameScene is constructed. + """ + self._network_tick_callback = callback + + def set_exit_match_callback(self, callback: Callable[[], None]) -> None: + """Register the function that sends MATCH_LEAVE to the server.""" + self._exit_match_callback = callback + + def _on_emote_selected(self, emote_id: str) -> None: + """ + Internal callback wired to EmotePopup. + + Always displays the emote immediately on the local player's panel + (seat 0) so single-player mode gives visual feedback. In multiplayer + the send callback transmits it to the server; the server broadcast + then triggers show_emote() again via the adapter — the duplicate + display is harmless as it simply resets the TTL. + """ + self.show_emote(self._get_my_seat(), emote_id) + if self._emote_send_callback is not None: + self._emote_send_callback(emote_id) + + def show_emote(self, seat_index: int, emote_id: str) -> None: + """ + Display an emote above the given seat's panel. + + Called by the networking layer when an EMOTE_BROADCAST arrives, or + directly in tests. Maps emote_id slug to an emoji string for display. + """ + from fall_in.ui.emote_popup import EMOTE_CATALOG + + emoji = emote_id # fallback: show slug + for slug, em, _ in EMOTE_CATALOG: + if slug == emote_id: + emoji = em + break + self._emote_display[seat_index] = (emoji, self._emote_display_duration) + def update(self, dt: float) -> None: """Update scene state.""" if self.message_timer > 0: self.message_timer -= dt + # Network tick: pump WS client and route messages to the adapter. + if self._network_tick_callback is not None: + self._network_tick_callback() + + # Sync eliminated state to settings popup (so exit button label updates) + if self._remote_adapter is not None: + self._settings_popup.set_eliminated(self._is_local_player_eliminated()) + + # Drain incoming emotes from RemoteGameAdapter (multiplayer only) + if self._remote_adapter is not None: + for seat_index, emote_id in self._remote_adapter.pop_pending_emotes(): + self.show_emote(seat_index, emote_id) + + # Decay emote display TTLs + expired = [s for s, (_, ttl) in self._emote_display.items() if ttl - dt <= 0] + for s in expired: + del self._emote_display[s] + for s in list(self._emote_display): + if s not in expired: + emoji, ttl = self._emote_display[s] + self._emote_display[s] = (emoji, ttl - dt) + + if self._remote_adapter is not None: + self._advance_remote_reveal(dt) + if self._update_remote_match_flow(dt): + return + if self.screen_shake_timer > 0: + self.screen_shake_timer -= dt + intensity = self.screen_shake_intensity + self.screen_shake_offset = ( + random.randint(-intensity, intensity), + random.randint(-intensity, intensity), + ) + if self.screen_shake_timer <= 0: + self.screen_shake_offset = (0, 0) + + self.dust_effect.update(dt) + + board_rows = self._get_display_board_rows() + for row_idx in range(min(NUM_ROWS, len(board_rows))): + for col in range(len(board_rows[row_idx])): + card = board_rows[row_idx][col] + if card.number in self.soldier_figures: + figure = self.soldier_figures[card.number] + spawn_dust, trigger_shake = figure.update(dt) + + if spawn_dust or trigger_shake: + visual_col = MAX_CARDS_PER_ROW - col + iso_x, iso_y = self._cart_to_iso(visual_col, row_idx) + + if spawn_dust: + self.dust_effect.spawn( + iso_x, iso_y, figure.get_dust_count() + ) + # Play danger-level SFX on tile landing + from fall_in.core.audio_manager import AudioManager + + danger = card.danger + sfx_map = { + 1: "sfx/drop_danger_1.wav", + 2: "sfx/drop_danger_2.wav", + 3: "sfx/drop_danger_3.wav", + 5: "sfx/drop_danger_5.mp3", + 7: "sfx/drop_danger_7.mp3", + } + sfx_path = sfx_map.get(danger) + if sfx_path: + AudioManager().play_sfx(sfx_path) + # Trigger commander reaction on landing + self.commander.react_to_soldier(card.danger) + if trigger_shake: + self.screen_shake_intensity = ( + figure.get_shake_intensity() + ) + self.screen_shake_timer = SCREEN_SHAKE_DURATION + + committed = self._get_local_committed_score() + self.commander.set_expression_from_danger(committed) + self.commander.update(dt) + return + # Screen shake if self.screen_shake_timer > 0: self.screen_shake_timer -= dt @@ -1293,20 +2409,11 @@ def update(self, dt: float) -> None: # Turn timer during selection if self.phase == GamePhase.SELECTING: - self.turn_timer -= dt - # Timeout SFX every second when <= 5s - if self.turn_timer <= 5.0 and self._timeout_sfx is not None: - current_tick = int(self.turn_timer) - if current_tick != self._last_timeout_tick and self.turn_timer > 0: - self._last_timeout_tick = current_tick - self._timeout_sfx.play() - if self.turn_timer <= 0: - self._last_timeout_tick = -1 - self._auto_select_card() + if self._consume_selection_timer(dt, on_timeout=self._auto_select_card): return # Commander expression - committed = self.rules.get_player_committed_score(self.human_player) + committed = self._get_local_committed_score() self.commander.set_expression_from_danger(committed) self.commander.update(dt) @@ -1363,27 +2470,36 @@ def render(self, screen: pygame.Surface) -> None: self._draw_penalty_animation(screen) # Timer & hint during selection - if self.phase == GamePhase.SELECTING: + if self._should_show_selection_ui(): self._draw_turn_timer(screen) - hint = get_font(14).render( - "카드를 클릭하여 선택, 다시 클릭 또는 [SPACE]로 확정", - True, - AIR_FORCE_BLUE, - ) - hint_x = SCREEN_WIDTH // 2 - hint.get_width() // 2 - hint_y = UI_TOP_BAR_HEIGHT + 5 - # White background pill for readability - pill_w = hint.get_width() + 16 - pill_h = hint.get_height() + 6 - hint_bg = pygame.Surface((pill_w, pill_h), pygame.SRCALPHA) - pygame.draw.rect( - hint_bg, - (255, 255, 255, 200), - (0, 0, pill_w, pill_h), - border_radius=4, - ) - screen.blit(hint_bg, (hint_x - 8, hint_y - 3)) - screen.blit(hint, (hint_x, hint_y)) + if self._is_waiting_for_other_players(): + self._draw_waiting_dialog(screen) + else: + hint = get_font(14).render( + "카드를 클릭하여 선택, 다시 클릭 또는 [SPACE]로 확정", + True, + AIR_FORCE_BLUE, + ) + hint_x = SCREEN_WIDTH // 2 - hint.get_width() // 2 + hint_y = UI_TOP_BAR_HEIGHT + 5 + pill_w = hint.get_width() + 16 + pill_h = hint.get_height() + 6 + hint_bg = pygame.Surface((pill_w, pill_h), pygame.SRCALPHA) + pygame.draw.rect( + hint_bg, + (255, 255, 255, 200), + (0, 0, pill_w, pill_h), + border_radius=4, + ) + screen.blit(hint_bg, (hint_x - 8, hint_y - 3)) + screen.blit(hint, (hint_x, hint_y)) + + # Eliminated spectator banner (multiplayer only) + if ( + self._is_local_player_eliminated() + and not self._has_remote_round_result_overlay() + ): + self._draw_eliminated_banner(screen) # Settings gear button (top-right) sx, sy = self._settings_btn_center @@ -1406,6 +2522,12 @@ def render(self, screen: pygame.Surface) -> None: # Debug overlay (via mixin) self.draw_debug_overlay(screen) + # Emote popup palette (PR-07) — rendered above everything except settings + self._emote_popup.render(screen) + + if self._has_remote_round_result_overlay(): + self._draw_remote_round_result_overlay(screen) + # Settings popup (always last — modal overlay) self._settings_popup.render(screen) @@ -1425,6 +2547,162 @@ def _draw_turn_timer(self, screen: pygame.Surface) -> None: screen, f"{seconds}s", timer_font, (280, 20), color, TOP_BAR_OUTLINE_COLOR ) + def _get_round_ready_button_rect(self) -> pygame.Rect: + return pygame.Rect(SCREEN_WIDTH // 2 - 95, SCREEN_HEIGHT // 2 + 158, 190, 48) + + def _draw_waiting_dialog(self, screen: pygame.Surface) -> None: + dots = "." * (int(pygame.time.get_ticks() / 400) % 4) + font = get_font(18, "bold") + small_font = get_font(14) + title = font.render(f"다른 플레이어의 선택을 기다리는 중{dots}", True, WHITE) + subtitle = small_font.render( + "모든 플레이어가 카드를 고르면 배치가 시작됩니다.", True, WHITE + ) + + box_w = max(title.get_width(), subtitle.get_width()) + 36 + box_h = title.get_height() + subtitle.get_height() + 30 + rect = pygame.Rect(0, 0, box_w, box_h) + rect.center = (SCREEN_WIDTH // 2, UI_TOP_BAR_HEIGHT + 48) + + overlay = pygame.Surface((rect.width, rect.height), pygame.SRCALPHA) + overlay.fill((20, 36, 56, 215)) + screen.blit(overlay, rect.topleft) + pygame.draw.rect(screen, LIGHT_BLUE, rect, width=2, border_radius=10) + screen.blit(title, title.get_rect(center=(rect.centerx, rect.y + 22))) + screen.blit(subtitle, subtitle.get_rect(center=(rect.centerx, rect.y + 48))) + + def _draw_eliminated_banner(self, screen: pygame.Surface) -> None: + """Draw a spectator banner for an eliminated player.""" + font = get_font(18, "bold") + small_font = get_font(14) + title = font.render("탈락 - 관전 중", True, (255, 100, 100)) + subtitle = small_font.render("위험도 66점 초과로 탈락했습니다.", True, WHITE) + + box_w = max(title.get_width(), subtitle.get_width()) + 36 + box_h = title.get_height() + subtitle.get_height() + 30 + rect = pygame.Rect(0, 0, box_w, box_h) + rect.center = (SCREEN_WIDTH // 2, UI_TOP_BAR_HEIGHT + 48) + + overlay = pygame.Surface((rect.width, rect.height), pygame.SRCALPHA) + overlay.fill((56, 20, 20, 215)) + screen.blit(overlay, rect.topleft) + pygame.draw.rect(screen, (255, 100, 100), rect, width=2, border_radius=10) + screen.blit(title, title.get_rect(center=(rect.centerx, rect.y + 22))) + screen.blit(subtitle, subtitle.get_rect(center=(rect.centerx, rect.y + 48))) + + def _draw_remote_round_result_overlay(self, screen: pygame.Surface) -> None: + result = self._remote_round_result + if result is None: + return + + overlay = pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT), pygame.SRCALPHA) + overlay.fill((8, 14, 22, 180)) + screen.blit(overlay, (0, 0)) + + panel = pygame.Rect(0, 0, 760, 470) + panel.center = (SCREEN_WIDTH // 2, SCREEN_HEIGHT // 2) + panel_surface = pygame.Surface((panel.width, panel.height), pygame.SRCALPHA) + panel_surface.fill((22, 34, 48, 235)) + screen.blit(panel_surface, panel.topleft) + pygame.draw.rect(screen, LIGHT_BLUE, panel, width=3, border_radius=16) + + title_font = get_font(34, "bold") + header_font = get_font(20, "bold") + body_font = get_font(18) + small_font = get_font(15) + + title = title_font.render( + f"라운드 {result.get('round_number', '?')} 정산", True, WHITE + ) + screen.blit(title, title.get_rect(center=(panel.centerx, panel.y + 42))) + + public = self._get_public_state() + seats = ( + sorted(public.seats, key=lambda seat: seat.seat_index) + if public is not None + else [] + ) + col_x = [panel.x + 70, panel.x + 320, panel.x + 460, panel.x + 610] + header_y = panel.y + 95 + headers = ["플레이어", "이번 라운드", "누적 위험도", "상태"] + for idx, header in enumerate(headers): + header_surf = header_font.render(header, True, WHITE) + screen.blit(header_surf, (col_x[idx], header_y)) + pygame.draw.line( + screen, + LIGHT_BLUE, + (panel.x + 45, header_y + 34), + (panel.right - 45, header_y + 34), + 2, + ) + + round_danger = result.get("round_danger", {}) + total_scores = result.get("total_scores", {}) + eliminated = set(result.get("eliminated_seats", [])) + row_y = header_y + 56 + row_height = 58 + + for seat in seats: + is_local = seat.seat_index == self._get_my_seat() + label = "나" if is_local else seat.display_name + if seat.controller_type.name == "BOT": + label = seat.display_name + + if seat.seat_index in eliminated: + bg = pygame.Surface((panel.width - 90, row_height - 8), pygame.SRCALPHA) + bg.fill((120, 32, 32, 105)) + screen.blit(bg, (panel.x + 45, row_y - 6)) + + screen.blit(body_font.render(label, True, WHITE), (col_x[0], row_y)) + + round_value = int(round_danger.get(seat.seat_index, 0)) + round_color = DANGER_DANGER if round_value > 0 else DANGER_SAFE + round_text = f"+{round_value}" if round_value > 0 else "0" + screen.blit( + body_font.render(round_text, True, round_color), (col_x[1], row_y) + ) + + total_value = int(total_scores.get(seat.seat_index, 0)) + total_color = get_danger_color(total_value) + screen.blit( + body_font.render(f"{total_value}/{GAME_OVER_SCORE}", True, total_color), + (col_x[2], row_y), + ) + + if seat.seat_index in eliminated: + status = "탈락" + status_color = DANGER_DANGER + else: + status = "생존" + status_color = DANGER_SAFE + screen.blit(body_font.render(status, True, status_color), (col_x[3], row_y)) + row_y += row_height + + countdown = max(0, int(self._remote_round_result_timer + 0.999)) + if self._remote_round_result_acknowledged: + footer_text = "확인 완료. 다른 플레이어를 기다리는 중..." + elif result.get("game_over"): + footer_text = f"{countdown}초 후 최종 결과 화면으로 이동합니다." + else: + footer_text = f"{countdown}초 후 자동으로 다음 라운드가 시작됩니다." + footer = small_font.render(footer_text, True, WHITE) + screen.blit(footer, footer.get_rect(center=(panel.centerx, panel.bottom - 118))) + + btn_rect = self._get_round_ready_button_rect() + btn_color = ( + (90, 120, 150) if self._remote_round_result_acknowledged else AIR_FORCE_BLUE + ) + pygame.draw.rect(screen, btn_color, btn_rect, border_radius=10) + pygame.draw.rect(screen, WHITE, btn_rect, width=2, border_radius=10) + btn_label = "확인 완료" if self._remote_round_result_acknowledged else "확인" + btn_text = header_font.render(btn_label, True, WHITE) + screen.blit(btn_text, btn_text.get_rect(center=btn_rect.center)) + + shortcut = small_font.render("[ENTER] 또는 [SPACE]로 확인", True, WHITE) + screen.blit( + shortcut, shortcut.get_rect(center=(panel.centerx, panel.bottom - 24)) + ) + def _draw_dealing_animation(self, screen: pygame.Surface) -> None: """Draw cards flying from barracks to hand positions.""" for card, tween in self.dealing_cards: diff --git a/src/fall_in/scenes/intro_cutscene_scene.py b/src/fall_in/scenes/intro_cutscene_scene.py index aa2c2be..e2d0bde 100644 --- a/src/fall_in/scenes/intro_cutscene_scene.py +++ b/src/fall_in/scenes/intro_cutscene_scene.py @@ -379,11 +379,11 @@ def _play_panel_sfx(self) -> None: def _transition_to_title(self) -> None: from fall_in.core.game_manager import GameManager, GameState - from fall_in.scenes.title_scene import TitleScene + from fall_in.scenes.account_gate_scene import AccountGateScene gm = GameManager() gm.state = GameState.TITLE - gm.change_scene(TitleScene()) + gm.change_scene(AccountGateScene()) # ------------------------------------------------------------------ # Rendering helpers diff --git a/src/fall_in/scenes/multiplayer_menu_scene.py b/src/fall_in/scenes/multiplayer_menu_scene.py new file mode 100644 index 0000000..67e3172 --- /dev/null +++ b/src/fall_in/scenes/multiplayer_menu_scene.py @@ -0,0 +1,799 @@ +""" +Multiplayer Menu Scene (PR-09). + +Flow +---- +1. CONNECTING — WS is being established; shows spinner. +2. AUTH — Player enters credentials (login / guest). +3. AUTH_REST — REST /auth/guest or /auth/login call in progress. +4. AUTH_WS_WAIT — JWT token sent over WS; waiting for AUTH_OK. +5. MODE_SELECT — Choose: quick match, create room, or join room. +6. QM_QUEUE — In quick-match queue; waiting for MATCH_FOUND / ROOM_STATE. +7. ROOM_CREATE_WAIT — Waiting for ROOM_STATE after ROOM_CREATE. +8. ROOM_CODE_INPUT — Player is typing a room code to join. +9. ROOM_JOIN_WAIT — Waiting for ROOM_STATE after ROOM_JOIN. + +Authentication design +--------------------- +The WS _auth handler only accepts { "token": }. +It does NOT accept credentials directly over the socket. + +Correct flow: + 1. Call REST API in a background thread (non-blocking for pygame): + POST /auth/guest {"nickname": "..."} + POST /auth/login {"email": "...", "password": "..."} + POST /auth/register {"email": "...", "password": "...", "nickname": "..."} + 2. Receive access_token from REST response. + 3. Send over WS: AUTH_LOGIN or AUTH_GUEST with {"token": access_token}. + 4. Server validates JWT and responds with AUTH_OK. +""" + +from __future__ import annotations + +import json +import threading +import urllib.error +import urllib.request +from enum import Enum, auto +from typing import Optional + +import pygame + +from fall_in.config import ( + AIR_FORCE_BLUE, + LIGHT_BLUE, + BLACK, + SCREEN_WIDTH, + SCREEN_HEIGHT, + SAND_BEIGE, +) +from fall_in.multiplayer.bootstrap import build_remote_match_loading_scene +from fall_in.scenes.base_scene import Scene +from fall_in.ui.button import Button +from fall_in.utils.asset_loader import get_font + +_WS_URL = "ws://localhost:8000/ws" +_REST_BASE = "http://localhost:8000" + + +class _State(Enum): + CONNECTING = auto() + RECONNECT_WAIT = auto() + AUTH = auto() + AUTH_REST = auto() # REST token request in-flight + AUTH_WS_WAIT = auto() # JWT sent over WS, awaiting AUTH_OK + MODE_SELECT = auto() + QM_QUEUE = auto() + ROOM_CREATE_WAIT = auto() + ROOM_CODE_INPUT = auto() + ROOM_JOIN_WAIT = auto() + ERROR = auto() + + +# --------------------------------------------------------------------------- +# Minimal text-input widget +# --------------------------------------------------------------------------- + + +class _TextInput: + """Single-line text input box for the multiplayer scenes.""" + + CURSOR_BLINK_RATE = 0.5 + + def __init__( + self, + x: int, + y: int, + width: int, + height: int, + placeholder: str = "", + password: bool = False, + ) -> None: + self.rect = pygame.Rect(x, y, width, height) + self.placeholder = placeholder + self.password = password + self.text = "" + self.focused = False + self._cursor_timer = 0.0 + self._cursor_visible = True + + def handle_event(self, event: pygame.event.Event) -> None: + if event.type == pygame.MOUSEBUTTONDOWN: + self.focused = self.rect.collidepoint(event.pos) + if not self.focused: + return + if event.type == pygame.KEYDOWN: + if event.key == pygame.K_BACKSPACE: + self.text = self.text[:-1] + elif event.key == pygame.K_v and (event.mod & pygame.KMOD_CTRL): + try: + clip = pygame.scrap.get(pygame.SCRAP_TEXT) + if clip: + self.text += clip.decode("utf-8", errors="ignore").strip("\x00") + except Exception: + pass + elif event.unicode and event.unicode.isprintable(): + self.text += event.unicode + + def update(self, dt: float) -> None: + if self.focused: + self._cursor_timer += dt + if self._cursor_timer >= self.CURSOR_BLINK_RATE: + self._cursor_timer = 0.0 + self._cursor_visible = not self._cursor_visible + else: + self._cursor_visible = False + + def render(self, screen: pygame.Surface) -> None: + bg = (255, 255, 255) if self.focused else (240, 240, 248) + border = LIGHT_BLUE if self.focused else (160, 170, 190) + pygame.draw.rect(screen, bg, self.rect, border_radius=6) + pygame.draw.rect(screen, border, self.rect, 2, border_radius=6) + + font = get_font(18) + if self.text: + display = ("*" * len(self.text)) if self.password else self.text + surf = font.render(display, True, BLACK) + else: + surf = font.render(self.placeholder, True, (180, 180, 190)) + + clip_rect = self.rect.inflate(-16, -8) + screen.set_clip(clip_rect) + screen.blit( + surf, (self.rect.x + 10, self.rect.centery - surf.get_height() // 2) + ) + screen.set_clip(None) + + if self.focused and self._cursor_visible and self.text: + cursor_x = self.rect.x + 10 + surf.get_width() + 2 + cursor_y = self.rect.y + 8 + pygame.draw.line( + screen, + BLACK, + (cursor_x, cursor_y), + (cursor_x, self.rect.bottom - 8), + 1, + ) + + +# --------------------------------------------------------------------------- +# REST helper (called from background threads) +# --------------------------------------------------------------------------- + + +def _rest_post(path: str, payload: dict) -> dict: + """ + Synchronous POST to the backend REST API. Run in a background thread. + + Returns the parsed JSON response dict on HTTP 200/201. + Raises RuntimeError with a user-friendly Korean message on failure. + """ + url = _REST_BASE + path + body = json.dumps(payload).encode("utf-8") + req = urllib.request.Request( + url, + data=body, + method="POST", + headers={"Content-Type": "application/json"}, + ) + try: + with urllib.request.urlopen(req, timeout=8) as resp: + return json.loads(resp.read()) + except urllib.error.HTTPError as e: + try: + detail = json.loads(e.read()).get("detail", str(e)) + except Exception: + detail = str(e) + raise RuntimeError(detail) + except OSError as e: + raise RuntimeError(f"서버에 연결할 수 없습니다: {e}") + + +# --------------------------------------------------------------------------- +# MultiplayerMenuScene +# --------------------------------------------------------------------------- + + +class MultiplayerMenuScene(Scene): + """ + Auth and multiplayer-mode selection scene. + + After authentication and mode selection, transitions to RoomLobbyScene. + """ + + def __init__(self) -> None: + super().__init__() + self._state = _State.CONNECTING + self._error_msg = "" + self._spinner_timer = 0.0 + + from fall_in.core.game_manager import GameManager + from fall_in.net.ws_client import WsClient + + self._ws = WsClient.get(url=_WS_URL) + game = GameManager() + self._auto_access_token = game.access_token + self._auto_auth_type = ( + "AUTH_LOGIN" if game.account_type == "registered" else "AUTH_GUEST" + ) + self._pending_reconnect: Optional[dict] = game.get_pending_match_reconnect() + + # REST call result: written by bg thread, read by main thread + self._rest_token: Optional[str] = None + self._rest_error: Optional[str] = None + # WS message type to use after token arrives ("AUTH_LOGIN" or "AUTH_GUEST") + self._ws_auth_type: str = "AUTH_LOGIN" + # display_name received in AUTH_OK — passed to RoomLobbyScene + self._my_display_name: str = game.nickname or "" + + # AUTH form fields — separate instances per tab to avoid y-position juggling + input_w = 320 + cx = SCREEN_WIDTH // 2 + # Login tab + self._login_email = _TextInput(cx - input_w // 2, 295, input_w, 44, "이메일") + self._login_pw = _TextInput( + cx - input_w // 2, 355, input_w, 44, "비밀번호", password=True + ) + # Register tab (nick on top) + self._reg_nick = _TextInput( + cx - input_w // 2, 285, input_w, 44, "닉네임 (2~20자)" + ) + self._reg_email = _TextInput(cx - input_w // 2, 340, input_w, 44, "이메일") + self._reg_pw = _TextInput( + cx - input_w // 2, 395, input_w, 44, "비밀번호", password=True + ) + # Guest tab + self._guest_nick = _TextInput( + cx - input_w // 2, 350, input_w, 44, "닉네임 (2~20자)" + ) + + self._room_code_input = _TextInput( + cx - input_w // 2, 280, input_w, 44, "방 코드 (예: ABCD12)" + ) + + # Quick-match seat index (set by MATCH_FOUND, consumed by MATCH_START) + self._qm_seat_index: Optional[int] = None + + # "login" | "register" | "guest" + self._auth_tab = "guest" + + # AUTH tab buttons + self._btn_tab_login = Button( + cx - 175, 225, 110, 40, "로그인", self._on_tab_login + ) + self._btn_tab_register = Button( + cx - 55, 225, 110, 40, "회원가입", self._on_tab_register + ) + self._btn_tab_guest = Button( + cx + 65, 225, 110, 40, "게스트", self._on_tab_guest + ) + + # AUTH submit + self._btn_auth_submit = Button( + cx - 100, 415, 200, 44, "접속", self._on_auth_submit + ) + + # MODE_SELECT buttons + self._btn_quick = Button( + cx - 110, 280, 220, 50, "빠른 대전", self._on_quick_match + ) + self._btn_create = Button( + cx - 110, 345, 220, 50, "방 만들기", self._on_create_room + ) + self._btn_join = Button(cx - 110, 410, 220, 50, "방 참가", self._on_join_room) + + # ROOM_CODE_INPUT buttons + self._btn_join_confirm = Button( + cx - 110, 340, 220, 50, "입장", self._on_join_confirm + ) + self._btn_join_cancel = Button( + cx - 110, 405, 220, 50, "취소", self._on_mode_cancel + ) + + self._btn_back = Button(20, 20, 100, 40, "← 뒤로", self._on_back) + + # Start WS connection + self._connect_thread = threading.Thread(target=self._do_connect, daemon=True) + self._connect_thread.start() + + # ------------------------------------------------------------------ + # Connection bootstrap + # ------------------------------------------------------------------ + + def _do_connect(self) -> None: + self._ws.connect() + ok = self._ws.wait_connected(timeout=6.0) + if not ok: + self._state = _State.ERROR + self._error_msg = f"서버 연결 실패: {self._ws.connect_error or '시간 초과'}" + return + self._ws.send("WS_HELLO") + + # ------------------------------------------------------------------ + # Button callbacks — AUTH tabs + # ------------------------------------------------------------------ + + def _on_tab_login(self) -> None: + self._auth_tab = "login" + self._error_msg = "" + + def _on_tab_register(self) -> None: + self._auth_tab = "register" + self._error_msg = "" + + def _on_tab_guest(self) -> None: + self._auth_tab = "guest" + self._error_msg = "" + + def _on_auth_submit(self) -> None: + if self._state != _State.AUTH: + return + self._error_msg = "" + + if self._auth_tab == "guest": + nick = self._guest_nick.text.strip() + if not nick: + self._error_msg = "닉네임을 입력하세요." + return + self._ws_auth_type = "AUTH_GUEST" + self._state = _State.AUTH_REST + threading.Thread( + target=self._do_rest_guest, args=(nick,), daemon=True + ).start() + + elif self._auth_tab == "login": + email = self._login_email.text.strip() + pw = self._login_pw.text + if not email or not pw: + self._error_msg = "이메일과 비밀번호를 입력하세요." + return + self._ws_auth_type = "AUTH_LOGIN" + self._state = _State.AUTH_REST + threading.Thread( + target=self._do_rest_login, args=(email, pw), daemon=True + ).start() + + elif self._auth_tab == "register": + nick = self._reg_nick.text.strip() + email = self._reg_email.text.strip() + pw = self._reg_pw.text + if not nick or not email or not pw: + self._error_msg = "모든 필드를 입력하세요." + return + self._ws_auth_type = "AUTH_LOGIN" + self._state = _State.AUTH_REST + threading.Thread( + target=self._do_rest_register, args=(email, pw, nick), daemon=True + ).start() + + # ------------------------------------------------------------------ + # REST calls (run in background threads) + # ------------------------------------------------------------------ + + def _do_rest_guest(self, nickname: str) -> None: + try: + resp = _rest_post("/auth/guest", {"nickname": nickname}) + self._rest_token = resp["access_token"] + except RuntimeError as e: + self._rest_error = str(e) + + def _do_rest_login(self, email: str, password: str) -> None: + try: + resp = _rest_post("/auth/login", {"email": email, "password": password}) + self._rest_token = resp["access_token"] + except RuntimeError as e: + self._rest_error = str(e) + + def _do_rest_register(self, email: str, password: str, nickname: str) -> None: + try: + resp = _rest_post( + "/auth/register", + {"email": email, "password": password, "nickname": nickname}, + ) + self._rest_token = resp["access_token"] + except RuntimeError as e: + self._rest_error = str(e) + + # ------------------------------------------------------------------ + # Button callbacks — mode select + # ------------------------------------------------------------------ + + def _on_quick_match(self) -> None: + if self._state != _State.MODE_SELECT: + return + self._ws.send("QUICK_MATCH_JOIN") + self._state = _State.QM_QUEUE + self._error_msg = "" + + def _on_create_room(self) -> None: + if self._state != _State.MODE_SELECT: + return + self._ws.send("ROOM_CREATE") + self._state = _State.ROOM_CREATE_WAIT + self._error_msg = "" + + def _on_join_room(self) -> None: + if self._state != _State.MODE_SELECT: + return + self._room_code_input.text = "" + self._state = _State.ROOM_CODE_INPUT + self._error_msg = "" + + def _on_join_confirm(self) -> None: + code = self._room_code_input.text.strip().upper() + if not code: + self._error_msg = "방 코드를 입력하세요." + return + self._ws.send("ROOM_JOIN", {"room_code": code}) + self._state = _State.ROOM_JOIN_WAIT + self._error_msg = "" + + def _on_mode_cancel(self) -> None: + self._state = _State.MODE_SELECT + self._error_msg = "" + + def _on_back(self) -> None: + from fall_in.core.game_manager import GameManager + from fall_in.scenes.title_scene import TitleScene + from fall_in.net.ws_client import WsClient + + WsClient.reset() + GameManager().change_scene(TitleScene()) + + # ------------------------------------------------------------------ + # WS + REST result polling + # ------------------------------------------------------------------ + + def _handle_ws_messages(self) -> None: + # Poll REST result when waiting for token + if self._state == _State.AUTH_REST: + if self._rest_error is not None: + self._error_msg = self._rest_error + self._rest_error = None + self._rest_token = None + self._state = _State.AUTH + return + if self._rest_token is not None: + token = self._rest_token + self._rest_token = None + self._ws.send(self._ws_auth_type, {"token": token}) + self._state = _State.AUTH_WS_WAIT + return + + messages = self._ws.pump() + idx = 0 + while idx < len(messages): + msg_type, data = messages[idx] + if msg_type == "WS_WELCOME": + if self._state == _State.CONNECTING: + if self._try_pending_reconnect(): + pass + elif self._auto_access_token: + self._ws.send( + self._auto_auth_type, + {"token": self._auto_access_token}, + ) + self._state = _State.AUTH_WS_WAIT + else: + self._state = _State.AUTH + elif msg_type == "RECONNECT_OK": + bootstrap_messages = list(messages[idx + 1 :]) + self._go_to_reconnected_match(data, bootstrap_messages) + break + elif msg_type == "AUTH_OK": + self._my_display_name = data.get("display_name", "") + self._state = _State.MODE_SELECT + self._error_msg = "" + elif msg_type == "ROOM_STATE": + self._go_to_lobby(data) + elif msg_type == "MATCH_FOUND": + self._qm_seat_index = int(data.get("seat_index", 0)) + self._state = _State.QM_QUEUE + elif msg_type == "MATCH_START": + # Quick-match: transition to game scene. + # Any remaining messages (PHASE_SELECTING, PRIVATE_HAND_STATE) + # are forwarded as bootstrap messages. + bootstrap_messages = list(messages[idx + 1 :]) + my_seat = self._qm_seat_index if self._qm_seat_index is not None else 0 + self._go_to_quick_match(my_seat, bootstrap_messages) + break + elif msg_type == "QUEUE_JOINED": + self._state = _State.QM_QUEUE + elif msg_type == "ERROR": + code = data.get("code", "") + msg = data.get("message", "오류가 발생했습니다.") + self._error_msg = f"[{code}] {msg}" if code else msg + if self._state == _State.RECONNECT_WAIT: + self._fallback_from_reconnect_error() + elif self._state == _State.AUTH_WS_WAIT: + self._state = _State.AUTH + elif self._state in ( + _State.ROOM_CREATE_WAIT, + _State.ROOM_JOIN_WAIT, + _State.QM_QUEUE, + ): + self._state = _State.MODE_SELECT + elif msg_type == "PING": + self._ws.send("PONG") + idx += 1 + + def _try_pending_reconnect(self) -> bool: + pending = self._pending_reconnect or {} + token = pending.get("token") + if not token: + return False + self._ws.send("RECONNECT", {"token": token}) + self._state = _State.RECONNECT_WAIT + return True + + def _fallback_from_reconnect_error(self) -> None: + from fall_in.core.game_manager import GameManager + + GameManager().clear_pending_match_reconnect() + self._pending_reconnect = None + if self._auto_access_token: + self._ws.send( + self._auto_auth_type, + {"token": self._auto_access_token}, + ) + self._state = _State.AUTH_WS_WAIT + else: + self._state = _State.AUTH + + def _go_to_reconnected_match( + self, + reconnect_data: dict, + bootstrap_messages: list[tuple[str, dict]], + ) -> None: + from fall_in.core.game_manager import GameManager + + my_seat = reconnect_data.get("seat_index", 0) + GameManager().change_scene( + build_remote_match_loading_scene( + ws=self._ws, + my_seat=int(my_seat), + bootstrap_messages=bootstrap_messages, + ) + ) + + def _go_to_quick_match( + self, + my_seat: int, + bootstrap_messages: list[tuple[str, dict]], + ) -> None: + from fall_in.core.game_manager import GameManager + + GameManager().change_scene( + build_remote_match_loading_scene( + ws=self._ws, + my_seat=my_seat, + bootstrap_messages=bootstrap_messages, + ) + ) + + def _go_to_lobby(self, room_data: dict) -> None: + from fall_in.core.game_manager import GameManager + from fall_in.scenes.room_lobby_scene import RoomLobbyScene + + GameManager().change_scene( + RoomLobbyScene( + ws=self._ws, + room_data=room_data, + my_display_name=self._my_display_name, + ) + ) + + # ------------------------------------------------------------------ + # Scene interface + # ------------------------------------------------------------------ + + def handle_event(self, event: pygame.event.Event) -> None: + self._btn_back.handle_event(event) + + if self._state == _State.AUTH: + self._btn_tab_login.handle_event(event) + self._btn_tab_register.handle_event(event) + self._btn_tab_guest.handle_event(event) + self._btn_auth_submit.handle_event(event) + if self._auth_tab == "login": + self._login_email.handle_event(event) + self._login_pw.handle_event(event) + elif self._auth_tab == "register": + self._reg_nick.handle_event(event) + self._reg_email.handle_event(event) + self._reg_pw.handle_event(event) + elif self._auth_tab == "guest": + self._guest_nick.handle_event(event) + + elif self._state == _State.MODE_SELECT: + self._btn_quick.handle_event(event) + self._btn_create.handle_event(event) + self._btn_join.handle_event(event) + + elif self._state == _State.ROOM_CODE_INPUT: + self._room_code_input.handle_event(event) + self._btn_join_confirm.handle_event(event) + self._btn_join_cancel.handle_event(event) + + def update(self, dt: float) -> None: + self._spinner_timer += dt + self._handle_ws_messages() + + if self._state == _State.AUTH: + self._btn_tab_login.update(dt) + self._btn_tab_register.update(dt) + self._btn_tab_guest.update(dt) + self._btn_auth_submit.update(dt) + if self._auth_tab == "login": + self._login_email.update(dt) + self._login_pw.update(dt) + elif self._auth_tab == "register": + self._reg_nick.update(dt) + self._reg_email.update(dt) + self._reg_pw.update(dt) + elif self._auth_tab == "guest": + self._guest_nick.update(dt) + + elif self._state == _State.MODE_SELECT: + self._btn_quick.update(dt) + self._btn_create.update(dt) + self._btn_join.update(dt) + + elif self._state == _State.ROOM_CODE_INPUT: + self._room_code_input.update(dt) + self._btn_join_confirm.update(dt) + self._btn_join_cancel.update(dt) + + def render(self, screen: pygame.Surface) -> None: + screen.fill(SAND_BEIGE) + + font_title = get_font(36, "bold") + title = font_title.render("멀티플레이", True, AIR_FORCE_BLUE) + screen.blit(title, title.get_rect(center=(SCREEN_WIDTH // 2, 80))) + + font_sub = get_font(20) + + if self._state == _State.CONNECTING: + dots = "." * (int(self._spinner_timer * 3) % 4) + msg = font_sub.render(f"서버에 연결 중{dots}", True, AIR_FORCE_BLUE) + screen.blit( + msg, msg.get_rect(center=(SCREEN_WIDTH // 2, SCREEN_HEIGHT // 2)) + ) + + elif self._state == _State.AUTH: + self._render_auth(screen) + + elif self._state in ( + _State.RECONNECT_WAIT, + _State.AUTH_REST, + _State.AUTH_WS_WAIT, + ): + dots = "." * (int(self._spinner_timer * 3) % 4) + label = ( + "대전 복귀 중" if self._state == _State.RECONNECT_WAIT else "인증 중" + ) + msg = font_sub.render(f"{label}{dots}", True, AIR_FORCE_BLUE) + screen.blit( + msg, msg.get_rect(center=(SCREEN_WIDTH // 2, SCREEN_HEIGHT // 2)) + ) + + elif self._state == _State.MODE_SELECT: + lbl = font_sub.render("게임 방식을 선택하세요", True, AIR_FORCE_BLUE) + screen.blit(lbl, lbl.get_rect(center=(SCREEN_WIDTH // 2, 230))) + self._btn_quick.render(screen) + self._btn_create.render(screen) + self._btn_join.render(screen) + + elif self._state == _State.QM_QUEUE: + dots = "." * (int(self._spinner_timer * 3) % 4) + msg = font_sub.render(f"상대방을 찾는 중{dots}", True, AIR_FORCE_BLUE) + screen.blit( + msg, msg.get_rect(center=(SCREEN_WIDTH // 2, SCREEN_HEIGHT // 2)) + ) + hint = get_font(14).render("잠시 기다려 주세요...", True, (100, 100, 120)) + screen.blit( + hint, hint.get_rect(center=(SCREEN_WIDTH // 2, SCREEN_HEIGHT // 2 + 40)) + ) + + elif self._state in (_State.ROOM_CREATE_WAIT, _State.ROOM_JOIN_WAIT): + dots = "." * (int(self._spinner_timer * 3) % 4) + msg = font_sub.render(f"방 정보를 불러오는 중{dots}", True, AIR_FORCE_BLUE) + screen.blit( + msg, msg.get_rect(center=(SCREEN_WIDTH // 2, SCREEN_HEIGHT // 2)) + ) + + elif self._state == _State.ROOM_CODE_INPUT: + cx = SCREEN_WIDTH // 2 + lbl = font_sub.render("방 코드 입력", True, AIR_FORCE_BLUE) + screen.blit(lbl, lbl.get_rect(center=(cx, 230))) + self._room_code_input.render(screen) + self._btn_join_confirm.render(screen) + self._btn_join_cancel.render(screen) + + elif self._state == _State.ERROR: + err = font_sub.render("연결 오류", True, (180, 40, 40)) + screen.blit( + err, err.get_rect(center=(SCREEN_WIDTH // 2, SCREEN_HEIGHT // 2 - 30)) + ) + detail = get_font(16).render(self._error_msg, True, (140, 40, 40)) + screen.blit( + detail, + detail.get_rect(center=(SCREEN_WIDTH // 2, SCREEN_HEIGHT // 2 + 10)), + ) + + # Inline error banner (non-fatal errors in other states) + if self._error_msg and self._state not in (_State.ERROR, _State.AUTH): + err_surf = get_font(16).render(self._error_msg, True, (180, 40, 40)) + screen.blit( + err_surf, + err_surf.get_rect(center=(SCREEN_WIDTH // 2, SCREEN_HEIGHT - 60)), + ) + + if self._state != _State.CONNECTING: + self._btn_back.render(screen) + + # ------------------------------------------------------------------ + # AUTH rendering + # ------------------------------------------------------------------ + + def _render_auth(self, screen: pygame.Surface) -> None: + cx = SCREEN_WIDTH // 2 + + # Tab buttons + self._btn_tab_login.render(screen) + self._btn_tab_register.render(screen) + self._btn_tab_guest.render(screen) + + # Active tab underline + active_map = { + "login": self._btn_tab_login, + "register": self._btn_tab_register, + "guest": self._btn_tab_guest, + } + active_btn = active_map[self._auth_tab] + pygame.draw.line( + screen, + LIGHT_BLUE, + (active_btn.rect.left, active_btn.rect.bottom + 3), + (active_btn.rect.right, active_btn.rect.bottom + 3), + 3, + ) + + lbl_font = get_font(16) + + if self._auth_tab == "guest": + screen.blit( + lbl_font.render("닉네임", True, AIR_FORCE_BLUE), (cx - 160, 332) + ) + self._guest_nick.render(screen) + + elif self._auth_tab == "login": + screen.blit( + lbl_font.render("이메일", True, AIR_FORCE_BLUE), (cx - 160, 277) + ) + screen.blit( + lbl_font.render("비밀번호", True, AIR_FORCE_BLUE), (cx - 160, 337) + ) + self._login_email.render(screen) + self._login_pw.render(screen) + + elif self._auth_tab == "register": + screen.blit( + lbl_font.render("닉네임", True, AIR_FORCE_BLUE), (cx - 160, 267) + ) + screen.blit( + lbl_font.render("이메일", True, AIR_FORCE_BLUE), (cx - 160, 322) + ) + screen.blit( + lbl_font.render("비밀번호", True, AIR_FORCE_BLUE), (cx - 160, 377) + ) + self._reg_nick.render(screen) + self._reg_email.render(screen) + self._reg_pw.render(screen) + + # Error under form + if self._error_msg: + err = get_font(15).render(self._error_msg, True, (180, 40, 40)) + screen.blit(err, err.get_rect(center=(cx, 470))) + + self._btn_auth_submit.render(screen) diff --git a/src/fall_in/scenes/result_scene.py b/src/fall_in/scenes/result_scene.py index 2fccb82..5ad7843 100644 --- a/src/fall_in/scenes/result_scene.py +++ b/src/fall_in/scenes/result_scene.py @@ -2,6 +2,9 @@ Result Scene - Round settlement screen showing penalties and scores. """ +from dataclasses import dataclass +from typing import Callable, Optional + import pygame from fall_in.scenes.base_scene import Scene @@ -30,38 +33,63 @@ ) +@dataclass +class RemoteResultContext: + round_result: dict + public_state: object + remote_adapter: object + resume_scene: object + network_tick_callback: Optional[Callable[[], None]] + round_ready_callback: Optional[Callable[[], None]] + + class ResultScene(Scene): """ Round result/settlement scene. Shows penalties earned this round and cumulative scores. """ - def __init__(self, rules: GameRules, players: list[Player]): + def __init__( + self, + rules: Optional[GameRules] = None, + players: Optional[list[Player]] = None, + *, + remote_context: Optional[RemoteResultContext] = None, + ): super().__init__() self.rules = rules - self.players = players - self.round_number = rules.round_state.round_number - - # Track human player's penalty cards for smuggling - self.human_player = next( - (p for p in players if p.player_type == PlayerType.HUMAN), None - ) - - # Get penalty cards before committing scores - self.human_penalty_cards: list[Card] = [] - if self.human_player: - round_penalties = rules.get_round_penalties() - human_penalty = round_penalties.get(self.human_player.player_id) - if human_penalty: - self.human_penalty_cards = list(human_penalty.cards_taken) - - # Calculate scores (this commits the penalties) - self.round_results = rules.commit_round_scores() + self.players = players or [] + self._remote_context = remote_context + self._remote_timeout_remaining = 0.0 + self._remote_acknowledged = False + + if self._remote_context is None: + if self.rules is None or not self.players: + raise ValueError("Local ResultScene requires rules and players") + self.round_number = self.rules.round_state.round_number + + # Track human player's penalty cards for smuggling + self.human_player = next( + (p for p in self.players if p.player_type == PlayerType.HUMAN), None + ) - # Check for eliminations - self.eliminated_players = [p for p in players if p.is_eliminated] - self.game_over = rules.game_over - self.winner = rules.winner + # Get penalty cards before committing scores + self.human_penalty_cards: list[Card] = [] + if self.human_player: + round_penalties = self.rules.get_round_penalties() + human_penalty = round_penalties.get(self.human_player.player_id) + if human_penalty: + self.human_penalty_cards = list(human_penalty.cards_taken) + + # Calculate scores (this commits the penalties) + self.round_results = self.rules.commit_round_scores() + + # Check for eliminations + self.eliminated_players = [p for p in self.players if p.is_eliminated] + self.game_over = self.rules.game_over + self.winner = self.rules.winner + else: + self._init_remote_result() # Buttons self.buttons: list[Button] = [] @@ -86,6 +114,92 @@ def __init__(self, rules: GameRules, players: list[Player]): AudioManager().stop_bgm() + @classmethod + def from_remote( + cls, + *, + round_result: dict, + public_state: object, + remote_adapter: object, + resume_scene: object, + network_tick_callback: Optional[Callable[[], None]], + round_ready_callback: Optional[Callable[[], None]], + ) -> "ResultScene": + return cls( + remote_context=RemoteResultContext( + round_result=dict(round_result), + public_state=public_state, + remote_adapter=remote_adapter, + resume_scene=resume_scene, + network_tick_callback=network_tick_callback, + round_ready_callback=round_ready_callback, + ) + ) + + def _init_remote_result(self) -> None: + """Populate scene state from server-authoritative multiplayer data.""" + assert self._remote_context is not None + + public = self._remote_context.public_state + data = dict(self._remote_context.round_result) + my_seat = self._remote_context.remote_adapter.my_seat + self.round_number = int(data.get("round_number", public.round_number)) + self.human_penalty_cards = [] + self.round_results = { + int(seat_index): ( + int(value), + int( + data.get("total_scores", {}).get( + str(seat_index), data.get("total_scores", {}).get(seat_index, 0) + ) + ), + ) + for seat_index, value in (data.get("round_danger") or {}).items() + } + + # Ensure every visible seat has a score tuple, even if round_danger omitted it. + for seat in public.seats: + current = self.round_results.get(seat.seat_index) + if current is None: + total = int( + (data.get("total_scores") or {}).get( + seat.seat_index, + (data.get("total_scores") or {}).get(str(seat.seat_index), 0), + ) + ) + self.round_results[seat.seat_index] = (0, total) + + self.players = [] + for seat in sorted(public.seats, key=lambda item: item.seat_index): + player = Player( + name="나" if seat.seat_index == my_seat else seat.display_name, + player_type=PlayerType.HUMAN + if seat.seat_index == my_seat + else PlayerType.AI, + player_id=seat.seat_index, + ) + _, total = self.round_results.get(seat.seat_index, (0, 0)) + player.penalty_score = total + player.is_eliminated = seat.seat_index in { + int(value) for value in data.get("eliminated_seats", []) + } + self.players.append(player) + + self.human_player = next( + (player for player in self.players if player.player_id == my_seat), + None, + ) + self.eliminated_players = [p for p in self.players if p.is_eliminated] + self.game_over = bool(data.get("game_over", False)) + winner_seat = data.get("winner_seat") + if winner_seat is not None: + winner_seat = int(winner_seat) + self.winner = next( + (player for player in self.players if player.player_id == winner_seat), + None, + ) + self._remote_timeout_remaining = float(data.get("timeout_seconds", 0.0)) + def _setup_buttons(self) -> None: """Setup continue/title buttons.""" button_width = SCENE_BUTTON_WIDTH @@ -93,6 +207,19 @@ def _setup_buttons(self) -> None: button_x = SCREEN_WIDTH // 2 - button_width // 2 button_y = SCREEN_HEIGHT - 80 + if self._remote_context is not None: + self.buttons.append( + Button( + x=button_x, + y=button_y, + width=button_width, + height=button_height, + text="확인", + callback=self._confirm_remote_continue, + ) + ) + return + if self.game_over: self.buttons.append( Button( @@ -182,6 +309,17 @@ def _go_to_game_over(self) -> None: """Go to game over scene (optionally via smuggling).""" self._navigate_via_smuggling_or_direct(is_game_over=True) + def _confirm_remote_continue(self) -> None: + """Acknowledge the multiplayer settlement screen once per client.""" + if self._remote_context is None or self._remote_acknowledged: + return + self._remote_acknowledged = True + if self.buttons: + self.buttons[0].text = "확인 완료" + callback = self._remote_context.round_ready_callback + if callback is not None: + callback() + # ------------------------------------------------------------------ # Event / Update / Render # ------------------------------------------------------------------ @@ -193,7 +331,9 @@ def handle_event(self, event: pygame.event.Event) -> None: if event.type == pygame.KEYDOWN: if event.key == pygame.K_SPACE or event.key == pygame.K_RETURN: - if self.game_over: + if self._remote_context is not None: + self._confirm_remote_continue() + elif self.game_over: self._go_to_game_over() else: self._continue_game() @@ -203,6 +343,45 @@ def update(self, dt: float) -> None: for button in self.buttons: button.update(dt) + if self._remote_context is None: + return + + if self._remote_context.network_tick_callback is not None: + self._remote_context.network_tick_callback() + + self._remote_timeout_remaining = max(0.0, self._remote_timeout_remaining - dt) + if self._remote_timeout_remaining <= 0.0 and not self._remote_acknowledged: + self._confirm_remote_continue() + + pop_match_result = getattr( + self._remote_context.remote_adapter, "pop_match_result", None + ) + if callable(pop_match_result): + match_result = pop_match_result() + if match_result is not None: + self._remote_context.resume_scene._go_to_remote_game_over(match_result) + return + + consume_selecting = getattr( + self._remote_context.remote_adapter, + "consume_selecting_phase_started", + None, + ) + if callable(consume_selecting): + remaining = consume_selecting() + if remaining is not None: + self._remote_context.resume_scene._reset_remote_selecting_phase( + remaining + ) + from fall_in.core.audio_manager import AudioManager + from fall_in.core.game_manager import GameManager, GameState + from fall_in.config import GAME_BGM_PATH + + AudioManager().play_bgm(GAME_BGM_PATH) + gm = GameManager() + gm.state = GameState.PLAYING + gm.change_scene(self._remote_context.resume_scene) + def render(self, screen: pygame.Surface) -> None: """Render result screen.""" title_font = get_font(36, "bold") @@ -351,8 +530,30 @@ def render(self, screen: pygame.Surface) -> None: button.render(screen) # Hint - hint_text = small_font.render("[SPACE] 또는 버튼 클릭으로 계속", True, WHITE) + if self._remote_context is None: + hint_label = "[SPACE] 또는 버튼 클릭으로 계속" + elif self._remote_acknowledged: + hint_label = "다른 플레이어를 기다리는 중..." + else: + hint_label = "[SPACE] 또는 버튼 클릭으로 확인" + hint_text = small_font.render(hint_label, True, WHITE) screen.blit( hint_text, (SCREEN_WIDTH // 2 - hint_text.get_width() // 2, SCREEN_HEIGHT - 30), ) + + if self._remote_context is not None: + info_font = get_font(16) + countdown = max(0, int(self._remote_timeout_remaining + 0.999)) + countdown_label = ( + f"자동 진행까지 {countdown}초" + if not self._remote_acknowledged + else f"다음 단계 대기 중 ({countdown}초)" + ) + countdown_surf = info_font.render(countdown_label, True, WHITE) + screen.blit( + countdown_surf, + countdown_surf.get_rect( + center=(SCREEN_WIDTH // 2, SCREEN_HEIGHT - 120) + ), + ) diff --git a/src/fall_in/scenes/room_lobby_scene.py b/src/fall_in/scenes/room_lobby_scene.py new file mode 100644 index 0000000..ce8d7da --- /dev/null +++ b/src/fall_in/scenes/room_lobby_scene.py @@ -0,0 +1,306 @@ +""" +Room Lobby Scene (PR-09). + +Displays the current room state (seats, ready status) received via ROOM_STATE +messages. Players can toggle ready and the room owner can start the match. + +On MATCH_START the scene constructs a multiplayer GameScene, wires the +RemoteGameAdapter and network-tick callback, then transitions through the +loading screen. + +Server messages handled here +----------------------------- +ROOM_STATE — update seat list and room code display +MATCH_FOUND — brief "상대를 찾았습니다!" banner (quick-match path) +MATCH_START — build GameScene and transition +PLAYER_PRESENCE — show which seats are connected +ERROR — display inline error and stay in lobby +PING — respond with PONG +""" + +from __future__ import annotations + +from typing import Optional + +import pygame + +from fall_in.config import ( + AIR_FORCE_BLUE, + LIGHT_BLUE, + WHITE, + SCREEN_WIDTH, + SCREEN_HEIGHT, + SAND_BEIGE, +) +from fall_in.multiplayer.bootstrap import build_remote_match_loading_scene +from fall_in.scenes.base_scene import Scene +from fall_in.ui.button import Button +from fall_in.utils.asset_loader import get_font + + +class RoomLobbyScene(Scene): + """ + Room lobby — shows seats, ready status, and start controls. + + Parameters + ---------- + ws: Active WsClient singleton. + room_data: The ``data`` dict from the first ROOM_STATE message. + """ + + def __init__(self, ws, room_data: dict, my_display_name: str = "") -> None: + super().__init__() + self._ws = ws + self._room_data = room_data + self._my_display_name = my_display_name + self._pending_game_messages: list[tuple[str, dict]] = [] + self._error_msg = "" + self._info_msg = "" + self._info_timer = 0.0 + self._match_starting = False + + cx = SCREEN_WIDTH // 2 + self._btn_ready = Button( + cx - 110, SCREEN_HEIGHT - 130, 220, 50, "준비", self._on_ready + ) + self._btn_start = Button( + cx - 110, SCREEN_HEIGHT - 70, 220, 50, "게임 시작", self._on_start + ) + self._btn_leave = Button(20, 20, 100, 40, "← 나가기", self._on_leave) + + self._is_ready = False + self._my_seat: Optional[int] = None + self._am_owner = False + self._update_from_room_data(room_data) + + # ------------------------------------------------------------------ + # Room-data helpers + # ------------------------------------------------------------------ + + def _update_from_room_data(self, data: dict) -> None: + self._room_data = data + # Server key is "participants", not "seats" + participants = data.get("participants") or [] + # Find my seat by matching display_name + for p in participants: + if p.get("display_name") == self._my_display_name: + self._my_seat = p.get("seat_index") + self._is_ready = p.get("is_ready", False) + break + # Server key is "host_seat_index", not "owner_seat_index" + host_seat = data.get("host_seat_index") + self._am_owner = self._my_seat is not None and self._my_seat == host_seat + self._btn_ready.text = "준비 취소" if self._is_ready else "준비" + + # ------------------------------------------------------------------ + # Button callbacks + # ------------------------------------------------------------------ + + def _on_ready(self) -> None: + new_ready = not self._is_ready + # Server reads "is_ready", not "ready" + self._ws.send("READY_SET", {"is_ready": new_ready}) + + def _on_start(self) -> None: + self._ws.send("ROOM_START") + + def _on_leave(self) -> None: + self._ws.send("ROOM_LEAVE") + from fall_in.core.game_manager import GameManager + from fall_in.scenes.multiplayer_menu_scene import MultiplayerMenuScene + from fall_in.net.ws_client import WsClient + + WsClient.reset() + GameManager().change_scene(MultiplayerMenuScene()) + + # ------------------------------------------------------------------ + # WS message routing + # ------------------------------------------------------------------ + + def _handle_ws_messages(self) -> None: + messages = self._ws.pump() + idx = 0 + while idx < len(messages): + msg_type, data = messages[idx] + if msg_type == "ROOM_STATE": + self._update_from_room_data(data) + self._error_msg = "" + elif msg_type == "MATCH_FOUND": + if "seat_index" in data: + self._my_seat = data.get("seat_index") + self._show_info("상대를 찾았습니다! 잠시 후 게임이 시작됩니다.") + elif msg_type == "MATCH_START": + if not self._match_starting: + self._match_starting = True + self._pending_game_messages = list(messages[idx + 1 :]) + self._launch_game(data) + break + elif msg_type == "PLAYER_PRESENCE": + pass # handled via ROOM_STATE + elif msg_type == "ERROR": + code = data.get("code", "") + msg = data.get("message", "오류가 발생했습니다.") + self._error_msg = f"[{code}] {msg}" if code else msg + elif msg_type == "PING": + self._ws.send("PONG") + idx += 1 + + def _show_info(self, msg: str, duration: float = 3.0) -> None: + self._info_msg = msg + self._info_timer = duration + + # ------------------------------------------------------------------ + # Multiplayer GameScene bootstrap + # ------------------------------------------------------------------ + + def _launch_game(self, match_start_data: dict) -> None: + """ + Build a GameScene wired for multiplayer and transition to it via the + loading screen (hangar-door animation). + + match_start_data contains ``my_seat`` (server-assigned seat index). + """ + my_seat = match_start_data.get("my_seat") + if my_seat is None: + my_seat = match_start_data.get("seat_index") + if my_seat is None: + my_seat = self._my_seat if self._my_seat is not None else 0 + from fall_in.core.game_manager import GameManager + + bootstrap_messages = list(self._pending_game_messages) + self._pending_game_messages.clear() + GameManager().change_scene( + build_remote_match_loading_scene( + ws=self._ws, + my_seat=int(my_seat), + bootstrap_messages=bootstrap_messages, + ) + ) + + # ------------------------------------------------------------------ + # Scene interface + # ------------------------------------------------------------------ + + def handle_event(self, event: pygame.event.Event) -> None: + if self._match_starting: + return + self._btn_leave.handle_event(event) + self._btn_ready.handle_event(event) + if self._am_owner: + self._btn_start.handle_event(event) + + def update(self, dt: float) -> None: + self._handle_ws_messages() + if self._info_timer > 0: + self._info_timer -= dt + if self._info_timer <= 0: + self._info_msg = "" + self._btn_ready.update(dt) + self._btn_start.update(dt) + self._btn_leave.update(dt) + + def render(self, screen: pygame.Surface) -> None: + screen.fill(SAND_BEIGE) + + font_title = get_font(32, "bold") + room_code = self._room_data.get("room_code", "----") + title = font_title.render(f"대기실 [{room_code}]", True, AIR_FORCE_BLUE) + screen.blit(title, title.get_rect(center=(SCREEN_WIDTH // 2, 70))) + + # Seat list + self._render_seats(screen) + + # Info / error messages + if self._info_msg: + info_surf = get_font(18).render(self._info_msg, True, (30, 130, 30)) + screen.blit( + info_surf, + info_surf.get_rect(center=(SCREEN_WIDTH // 2, SCREEN_HEIGHT - 175)), + ) + if self._error_msg: + err_surf = get_font(16).render(self._error_msg, True, (180, 40, 40)) + screen.blit( + err_surf, + err_surf.get_rect(center=(SCREEN_WIDTH // 2, SCREEN_HEIGHT - 155)), + ) + + # Buttons + self._btn_leave.render(screen) + self._btn_ready.render(screen) + if self._am_owner: + self._btn_start.render(screen) + elif not self._am_owner: + # Non-owner: show hint that only owner can start + hint = get_font(14).render( + "방장이 게임을 시작하면 자동으로 입장됩니다.", True, (120, 120, 140) + ) + screen.blit( + hint, hint.get_rect(center=(SCREEN_WIDTH // 2, SCREEN_HEIGHT - 55)) + ) + + # Waiting / starting overlay + if self._match_starting: + overlay = pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT), pygame.SRCALPHA) + overlay.fill((0, 0, 0, 120)) + screen.blit(overlay, (0, 0)) + dots = "." * (int(pygame.time.get_ticks() / 400) % 4) + msg = get_font(30).render(f"게임 시작 중{dots}", True, WHITE) + screen.blit( + msg, msg.get_rect(center=(SCREEN_WIDTH // 2, SCREEN_HEIGHT // 2)) + ) + + def _render_seats(self, screen: pygame.Surface) -> None: + # Build a seat_index → participant dict from "participants" list + participants = self._room_data.get("participants") or [] + by_seat: dict[int, dict] = {p["seat_index"]: p for p in participants} + host_seat = self._room_data.get("host_seat_index") + + font = get_font(18) + font_small = get_font(14) + card_w, card_h = 260, 70 + gap = 20 + num_slots = 4 + total_w = num_slots * card_w + (num_slots - 1) * gap + start_x = SCREEN_WIDTH // 2 - total_w // 2 + y = 160 + + for slot in range(num_slots): + x = start_x + slot * (card_w + gap) + rect = pygame.Rect(x, y, card_w, card_h) + seat = by_seat.get(slot) + + if seat is None: + # Empty slot + pygame.draw.rect(screen, (230, 230, 235), rect, border_radius=10) + pygame.draw.rect(screen, (180, 180, 190), rect, 2, border_radius=10) + placeholder = font.render("(빈 자리)", True, (160, 160, 170)) + screen.blit(placeholder, placeholder.get_rect(center=rect.center)) + else: + is_ready = seat.get("is_ready", False) + is_me = seat.get("display_name") == self._my_display_name + bg = ( + (220, 245, 220) + if is_ready + else (230, 240, 255) + if is_me + else (240, 240, 252) + ) + border_color = (60, 180, 60) if is_ready else LIGHT_BLUE + pygame.draw.rect(screen, bg, rect, border_radius=10) + pygame.draw.rect(screen, border_color, rect, 2, border_radius=10) + + name = seat.get("display_name", f"플레이어 {slot + 1}") + label = f"{name[:16]} ◀ 나" if is_me else name[:18] + name_surf = font.render(label, True, AIR_FORCE_BLUE) + screen.blit(name_surf, (rect.x + 14, rect.y + 12)) + + status = "준비 완료" if is_ready else "대기 중" + status_color = (40, 150, 40) if is_ready else (120, 120, 140) + status_surf = font_small.render(status, True, status_color) + screen.blit(status_surf, (rect.x + 14, rect.y + 40)) + + if slot == host_seat: + crown = font_small.render("♛ 방장", True, (190, 140, 20)) + screen.blit( + crown, (rect.right - crown.get_width() - 12, rect.y + 12) + ) diff --git a/src/fall_in/scenes/title_scene.py b/src/fall_in/scenes/title_scene.py index fd5a2be..1f0d918 100644 --- a/src/fall_in/scenes/title_scene.py +++ b/src/fall_in/scenes/title_scene.py @@ -149,14 +149,11 @@ def _load_dev_profile_image(self) -> pygame.Surface | None: return None def _on_start_game(self) -> None: - """Start game callback — goes through loading screen""" + """Start game callback — opens single/multi mode selection.""" from fall_in.core.game_manager import GameManager - from fall_in.scenes.game_loading_scene import GameLoadingScene + from fall_in.scenes.game_mode_select_scene import GameModeSelectScene - game = GameManager() - # Capture current screen so title stays visible behind the closing door - prev_screen = game.screen.copy() if game.screen else None - game.change_scene(GameLoadingScene(prev_screen=prev_screen)) + GameManager().change_scene(GameModeSelectScene()) def _on_tutorial(self) -> None: """Tutorial callback - opens tutorial information""" @@ -263,13 +260,13 @@ def handle_event(self, event: pygame.event.Event) -> None: self._open_debug_menu() def _open_debug_menu(self) -> None: - """Open debug menu if debug mode is enabled""" + """Open debug menu if debug mode is enabled and not in multiplayer.""" from fall_in.config import DEBUG_MODE + from fall_in.core.game_manager import GameManager - if not DEBUG_MODE: + if not DEBUG_MODE or GameManager().has_auth_session(): return - from fall_in.core.game_manager import GameManager from fall_in.scenes.title_debug_scene import DebugScene GameManager().change_scene(DebugScene()) diff --git a/src/fall_in/ui/emote_popup.py b/src/fall_in/ui/emote_popup.py new file mode 100644 index 0000000..0cc1d96 --- /dev/null +++ b/src/fall_in/ui/emote_popup.py @@ -0,0 +1,314 @@ +""" +EmotePopup — in-game emote palette (PR-07). + +Opens when the local player clicks their profile icon in the top bar. +Displays a 4×2 grid of emoji buttons. Selecting one calls the registered +callback with the emote_id slug and closes the palette immediately. + +The popup is cosmetic-only: no free text, no targeted pings. +All emote slugs match the server-side VALID_EMOTE_IDS catalog. + +Usage (from GameScene or a multiplayer wrapper):: + + popup = EmotePopup() + popup.set_callback(lambda eid: ws_client.send_emote(eid)) + + # In handle_event: + if popup.handle_event(event): + return # event consumed + + # In render: + popup.render(screen) + +Asset fallback +-------------- +Each emote button uses an emoji character as its label. If a future +``emotes/.png`` asset is added to the manifest, it is used instead. +The popup is fully functional without any image assets. +""" + +from __future__ import annotations + +from typing import Callable, Optional + +import pygame + +from fall_in.config import ( + SCREEN_HEIGHT, + SCREEN_WIDTH, + WHITE, +) +from fall_in.utils.asset_loader import get_font + +# --------------------------------------------------------------------------- +# Emote catalog — must match server-side VALID_EMOTE_IDS +# --------------------------------------------------------------------------- + +# (emote_id, emoji_label, korean_label) +EMOTE_CATALOG: list[tuple[str, str, str]] = [ + ("thumbsup", "👍", "좋아요"), + ("thumbsdown", "👎", "별로"), + ("smile", "😄", "웃음"), + ("sweat", "😅", "땀"), + ("thinking", "🤔", "생각"), + ("fire", "🔥", "열정"), + ("cry", "😭", "눈물"), + ("clap", "👏", "박수"), +] + +# Grid layout +_COLS = 4 +_ROWS = 2 +_BTN_W = 72 +_BTN_H = 72 +_GAP = 8 +_PADDING = 14 + +_POPUP_W = _COLS * _BTN_W + (_COLS - 1) * _GAP + _PADDING * 2 +_POPUP_H = _ROWS * _BTN_H + (_ROWS - 1) * _GAP + _PADDING * 2 + 28 # +28 title bar + + +class EmotePopup: + """ + Small floating palette for emote selection. + + State machine: hidden ↔ visible. + """ + + def __init__(self) -> None: + self.visible: bool = False + self._callback: Optional[Callable[[str], None]] = None + + # Will be set to the screen-space rect when show() is called + self._rect = pygame.Rect(0, 0, _POPUP_W, _POPUP_H) + + # Hovered button index (0-7), or None + self._hovered: Optional[int] = None + + # Emote image cache (lazy-loaded from asset manifest) + self._emote_images: dict[str, pygame.Surface] = {} + # Pre-scaled 36x36 surfaces, computed once per emote + self._scaled_images: dict[str, pygame.Surface] = {} + self._images_loaded: bool = False + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def set_callback(self, callback: Callable[[str], None]) -> None: + """Register a function to call when the player picks an emote.""" + self._callback = callback + + def show(self, anchor: tuple[int, int]) -> None: + """ + Open the palette anchored below *anchor* (the profile icon center). + + The popup is kept within screen bounds. + """ + x = anchor[0] - _POPUP_W // 2 + y = anchor[1] + 10 # below the icon + + # Keep within screen + x = max(4, min(x, SCREEN_WIDTH - _POPUP_W - 4)) + y = max(4, min(y, SCREEN_HEIGHT - _POPUP_H - 4)) + + self._rect.topleft = (x, y) + self.visible = True + self._hovered = None + + def hide(self) -> None: + self.visible = False + + def toggle(self, anchor: tuple[int, int]) -> None: + if self.visible: + self.hide() + else: + self.show(anchor) + + # ------------------------------------------------------------------ + # Event handling + # ------------------------------------------------------------------ + + def handle_event(self, event: pygame.event.Event) -> bool: + """ + Handle a pygame event. Returns True only if the event was consumed + (i.e. it should NOT propagate to gameplay logic). + + Policy: + - Clicks ON a button → consumed (send emote + close). + - Clicks inside the popup frame but not on a button → consumed + (prevent accidental card clicks through the panel). + - Clicks OUTSIDE the popup → close, but NOT consumed so that the + underlying gameplay click still registers. + - Mouse motion → update hover highlight, never consumed. + - Escape → close, consumed. + - All other events → not consumed (gameplay receives them normally). + """ + if not self.visible: + return False + + if event.type == pygame.MOUSEMOTION: + self._hovered = self._hit_test(event.pos) + # Motion is never consumed — the cursor should still drive card hover + return False + + if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1: + idx = self._hit_test(event.pos) + if idx is not None: + # Clicked an emote button — send and close + emote_id, _, _ = EMOTE_CATALOG[idx] + self.hide() + if self._callback is not None: + self._callback(emote_id) + return True + if self._rect.collidepoint(event.pos): + # Clicked inside the popup frame but not on a button — absorb + return True + # Clicked outside the popup → close, but pass event through so + # gameplay (e.g. card selection) still receives the click. + self.hide() + return False + + if event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE: + self.hide() + return True + + # All other event types do not affect the popup and must not be eaten + return False + + # ------------------------------------------------------------------ + # Rendering + # ------------------------------------------------------------------ + + def render(self, screen: pygame.Surface) -> None: + if not self.visible: + return + + self._ensure_images_loaded() + + # Background panel + panel = pygame.Surface((_POPUP_W, _POPUP_H), pygame.SRCALPHA) + pygame.draw.rect( + panel, + (20, 35, 60, 230), + (0, 0, _POPUP_W, _POPUP_H), + border_radius=10, + ) + pygame.draw.rect( + panel, + (70, 120, 180), + (0, 0, _POPUP_W, _POPUP_H), + width=2, + border_radius=10, + ) + screen.blit(panel, self._rect.topleft) + + # Title bar + title_font = get_font(12, "bold") + title = title_font.render("이모티콘 선택", True, (180, 200, 220)) + screen.blit( + title, + title.get_rect( + centerx=self._rect.centerx, + top=self._rect.top + 6, + ), + ) + + # Emote buttons + emoji_font = get_font(28) + label_font = get_font(10) + + for idx, (emote_id, emoji, label_ko) in enumerate(EMOTE_CATALOG): + btn_rect = self._button_rect(idx) + is_hovered = self._hovered == idx + + # Button background + bg_color = (60, 90, 140, 220) if is_hovered else (35, 55, 90, 200) + btn_surf = pygame.Surface( + (btn_rect.width, btn_rect.height), pygame.SRCALPHA + ) + pygame.draw.rect( + btn_surf, + bg_color, + (0, 0, btn_rect.width, btn_rect.height), + border_radius=8, + ) + border_color = (120, 170, 230) if is_hovered else (60, 90, 130) + pygame.draw.rect( + btn_surf, + border_color, + (0, 0, btn_rect.width, btn_rect.height), + width=2, + border_radius=8, + ) + screen.blit(btn_surf, btn_rect.topleft) + + # Emote image — use PNG if available, else emoji text + drawn_image = False + if emote_id in self._emote_images: + if emote_id not in self._scaled_images: + self._scaled_images[emote_id] = pygame.transform.smoothscale( + self._emote_images[emote_id], (36, 36) + ) + img_scaled = self._scaled_images[emote_id] + img_rect = img_scaled.get_rect( + centerx=btn_rect.centerx, + top=btn_rect.top + 8, + ) + screen.blit(img_scaled, img_rect) + drawn_image = True + + if not drawn_image: + emoji_surf = emoji_font.render(emoji, True, WHITE) + screen.blit( + emoji_surf, + emoji_surf.get_rect( + centerx=btn_rect.centerx, + top=btn_rect.top + 8, + ), + ) + + # Korean label below emoji + label_surf = label_font.render(label_ko, True, (190, 205, 220)) + screen.blit( + label_surf, + label_surf.get_rect( + centerx=btn_rect.centerx, + bottom=btn_rect.bottom - 4, + ), + ) + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _button_rect(self, idx: int) -> pygame.Rect: + """Return the screen-space Rect for button at index *idx*.""" + row = idx // _COLS + col = idx % _COLS + x = self._rect.x + _PADDING + col * (_BTN_W + _GAP) + y = self._rect.y + _PADDING + 20 + row * (_BTN_H + _GAP) # 20 = title bar + return pygame.Rect(x, y, _BTN_W, _BTN_H) + + def _hit_test(self, pos: tuple[int, int]) -> Optional[int]: + """Return the button index under *pos*, or None.""" + for idx in range(len(EMOTE_CATALOG)): + if self._button_rect(idx).collidepoint(pos): + return idx + return None + + def _ensure_images_loaded(self) -> None: + """Lazy-load emote PNG assets from the asset manifest (if present).""" + if self._images_loaded: + return + self._images_loaded = True + try: + from fall_in.utils.asset_manifest import AssetManifest + + emote_assets = AssetManifest.get_loaded("emotes") + for emote_id, _, _ in EMOTE_CATALOG: + if emote_id in emote_assets: + self._emote_images[emote_id] = emote_assets[emote_id] + except Exception: + # Asset manifest may not have an "emotes" category yet — silent fallback + pass diff --git a/src/fall_in/ui/settings_popup.py b/src/fall_in/ui/settings_popup.py index 8995605..fa310be 100644 --- a/src/fall_in/ui/settings_popup.py +++ b/src/fall_in/ui/settings_popup.py @@ -5,6 +5,7 @@ """ import webbrowser +from collections.abc import Callable import pygame @@ -19,10 +20,10 @@ class SettingsPopup: - """Modal settings popup with volume sliders and bug report button.""" + """Modal settings popup with volume sliders, bug report, and exit button.""" POPUP_WIDTH = 400 - POPUP_HEIGHT = 340 + POPUP_HEIGHT = 400 SLIDER_WIDTH = 250 SLIDER_HEIGHT = 8 HANDLE_RADIUS = 10 @@ -46,8 +47,13 @@ def __init__(self) -> None: # Close button rect (top-right of popup) self._close_btn = pygame.Rect(self.rect.right - 36, self.rect.top + 8, 28, 28) - # Bug report button rect (bottom center) + # Bug report button rect self._bug_btn = pygame.Rect( + self.rect.centerx - 80, self.rect.bottom - 115, 160, 36 + ) + + # Exit button rect (below bug report) + self._exit_btn = pygame.Rect( self.rect.centerx - 80, self.rect.bottom - 65, 160, 36 ) @@ -55,6 +61,27 @@ def __init__(self) -> None: self._dragging_bgm = False self._dragging_sfx = False + # Exit confirmation state + self._confirm_exit_mode = False + self._confirm_yes_btn = pygame.Rect( + self.rect.centerx - 140, self.rect.bottom - 80, 120, 40 + ) + self._confirm_no_btn = pygame.Rect( + self.rect.centerx + 20, self.rect.bottom - 80, 120, 40 + ) + + # Exit callback and eliminated state (set by owning scene) + self._exit_callback: Callable[[], None] | None = None + self._is_eliminated = False + + def set_exit_callback(self, callback: Callable[[], None]) -> None: + """Set the callback invoked when the user confirms exit.""" + self._exit_callback = callback + + def set_eliminated(self, is_eliminated: bool) -> None: + """Update whether the local player is eliminated (spectating).""" + self._is_eliminated = is_eliminated + def toggle(self) -> None: """Toggle popup visibility.""" from fall_in.core.audio_manager import AudioManager @@ -62,15 +89,18 @@ def toggle(self) -> None: if self.visible: AudioManager().save_settings() self.visible = not self.visible + self._confirm_exit_mode = False def show(self) -> None: self.visible = True + self._confirm_exit_mode = False def hide(self) -> None: from fall_in.core.audio_manager import AudioManager AudioManager().save_settings() self.visible = False + self._confirm_exit_mode = False # ------------------------------------------------------------------ # Event handling @@ -81,6 +111,10 @@ def handle_event(self, event: pygame.event.Event) -> bool: if not self.visible: return False + # Confirmation mode: only handle confirm/cancel buttons + if self._confirm_exit_mode: + return self._handle_confirm_event(event) + if event.type == pygame.MOUSEBUTTONDOWN: pos = event.pos @@ -94,6 +128,11 @@ def handle_event(self, event: pygame.event.Event) -> bool: webbrowser.open(GITHUB_ISSUES_URL) return True + # Exit button + if self._exit_callback is not None and self._exit_btn.collidepoint(pos): + self._confirm_exit_mode = True + return True + # BGM slider bgm_handle = self._get_bgm_handle_rect() bgm_track = self._get_bgm_track_rect() @@ -139,6 +178,27 @@ def handle_event(self, event: pygame.event.Event) -> bool: return self.visible # consume all events while visible + def _handle_confirm_event(self, event: pygame.event.Event) -> bool: + """Handle events in exit confirmation mode.""" + if event.type == pygame.MOUSEBUTTONDOWN: + pos = event.pos + if self._confirm_yes_btn.collidepoint(pos): + self._confirm_exit_mode = False + self.hide() # saves audio settings via AudioManager + if self._exit_callback is not None: + self._exit_callback() + return True + if self._confirm_no_btn.collidepoint(pos): + self._confirm_exit_mode = False + return True + # Consume all clicks while in confirm mode + return True + elif event.type == pygame.KEYDOWN: + if event.key == pygame.K_ESCAPE: + self._confirm_exit_mode = False + return True + return self.visible + # ------------------------------------------------------------------ # Slider helpers # ------------------------------------------------------------------ @@ -247,6 +307,11 @@ def render(self, screen: pygame.Surface) -> None: ) screen.blit(popup_surf, self.rect.topleft) + # If in confirmation mode, render the confirm overlay instead + if self._confirm_exit_mode: + self._render_confirm(screen) + return + # Title title_font = get_font(24, "bold") title = title_font.render("설정", True, WHITE) @@ -295,6 +360,22 @@ def render(self, screen: pygame.Surface) -> None: bug_text = bug_font.render("🐛 버그 제보", True, WHITE) screen.blit(bug_text, bug_text.get_rect(center=self._bug_btn.center)) + # Exit button (only shown when callback is set = multiplayer mode) + if self._exit_callback is not None: + exit_label = "관전 나가기" if self._is_eliminated else "게임 나가기" + exit_color = (120, 60, 60) if not self._is_eliminated else (80, 80, 120) + pygame.draw.rect(screen, exit_color, self._exit_btn, border_radius=8) + pygame.draw.rect( + screen, + (180, 60, 60) if not self._is_eliminated else AIR_FORCE_BLUE, + self._exit_btn, + width=2, + border_radius=8, + ) + exit_font = get_font(14) + exit_text = exit_font.render(exit_label, True, WHITE) + screen.blit(exit_text, exit_text.get_rect(center=self._exit_btn.center)) + def _draw_slider( self, screen: pygame.Surface, @@ -347,3 +428,57 @@ def _draw_slider( pygame.draw.circle( screen, AIR_FORCE_BLUE, (handle_x, handle_y), self.HANDLE_RADIUS, 2 ) + + def _render_confirm(self, screen: pygame.Surface) -> None: + """Render the exit confirmation overlay inside the popup.""" + title_font = get_font(20, "bold") + body_font = get_font(14) + btn_font = get_font(16, "bold") + + if self._is_eliminated: + title_text = "관전 나가기" + lines = ["관전을 종료하시겠습니까?"] + else: + title_text = "게임 나가기" + lines = [ + "게임을 도중에 나가면", + "수당이 지급되지 않습니다.", + "", + "정말 나가시겠습니까?", + ] + + # Title + title_surf = title_font.render(title_text, True, WHITE) + screen.blit( + title_surf, + title_surf.get_rect(centerx=self.rect.centerx, top=self.rect.top + 30), + ) + + # Body text — vertically center between title and buttons + btn_top = self._confirm_yes_btn.top + text_zone_top = self.rect.top + 70 + text_zone_bottom = btn_top - 15 + total_text_height = len(lines) * 24 + y = text_zone_top + (text_zone_bottom - text_zone_top - total_text_height) // 2 + for line in lines: + if line: + line_surf = body_font.render(line, True, (200, 210, 220)) + screen.blit( + line_surf, + line_surf.get_rect(centerx=self.rect.centerx, top=y), + ) + y += 24 + + # Yes button + yes_hover = self._confirm_yes_btn.collidepoint(pygame.mouse.get_pos()) + yes_color = (180, 60, 60) if yes_hover else (140, 50, 50) + pygame.draw.rect(screen, yes_color, self._confirm_yes_btn, border_radius=8) + yes_text = btn_font.render("나가기", True, WHITE) + screen.blit(yes_text, yes_text.get_rect(center=self._confirm_yes_btn.center)) + + # No button + no_hover = self._confirm_no_btn.collidepoint(pygame.mouse.get_pos()) + no_color = (80, 100, 140) if no_hover else (60, 80, 120) + pygame.draw.rect(screen, no_color, self._confirm_no_btn, border_radius=8) + no_text = btn_font.render("취소", True, WHITE) + screen.blit(no_text, no_text.get_rect(center=self._confirm_no_btn.center)) diff --git a/src/fall_in/utils/debug_overlay.py b/src/fall_in/utils/debug_overlay.py index 01e1eb1..46353eb 100644 --- a/src/fall_in/utils/debug_overlay.py +++ b/src/fall_in/utils/debug_overlay.py @@ -109,11 +109,12 @@ def handle_debug_event(self, event: pygame.event.Event) -> bool: self._debug_overlay_active = False return True - # F12 toggles overlay (only when DEBUG_MODE is on) + # F12 toggles overlay (only when DEBUG_MODE is on and not authenticated) if event.key == pygame.K_F12: from fall_in.config import DEBUG_MODE + from fall_in.core.game_manager import GameManager - if DEBUG_MODE: + if DEBUG_MODE and not GameManager().has_auth_session(): self._debug_overlay_active = not self._debug_overlay_active return True diff --git a/tests/core/__init__.py b/tests/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/core/test_rules_current_behavior_regression.py b/tests/core/test_rules_current_behavior_regression.py new file mode 100644 index 0000000..91c9377 --- /dev/null +++ b/tests/core/test_rules_current_behavior_regression.py @@ -0,0 +1,499 @@ +""" +Regression tests for the current GameRules behavior. + +PURPOSE: + These tests lock in the CURRENT repository implementation so that: + 1. The multiplayer server can replicate identical deterministic results. + 2. Any accidental deviation from the current rules is caught immediately. + +Key behaviors locked in here: + - player_order is the resolution order, NOT ascending card number. + - Each round, player_order rotates (head moves to tail). + - A card smaller than all rows auto-takes the lowest-penalty row. + - No human "row selection" prompt exists in the current code path. + - The 6th card in a row causes the placer to collect 5 penalty cards. + - GameRules requires exactly 4 players (NUM_PLAYERS). + - _check_game_end currently treats players[0] as the human seat. + This is documented here as a KNOWN ASSUMPTION that PR-04 will + replace with a seat/controller model. +""" + +import pytest + +from fall_in.core.card import Card, calculate_danger +from fall_in.core.player import Player, PlayerType, create_players +from fall_in.core.board import Board +from fall_in.core.rules import GameRules, RoundPhase + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_players() -> list[Player]: + """4 players as the game normally creates them.""" + return create_players() + + +def _make_rules() -> GameRules: + return GameRules(_make_players()) + + +def _card(number: int) -> Card: + return Card(number=number, danger=calculate_danger(number)) + + +# --------------------------------------------------------------------------- +# Initialisation +# --------------------------------------------------------------------------- + + +class TestGameRulesInit: + def test_requires_exactly_four_players(self): + three = create_players(ai_count=2) + with pytest.raises(ValueError, match="4"): + GameRules(three) + + def test_requires_exactly_four_players_too_many(self): + five = [ + Player(name=f"P{i}", player_type=PlayerType.AI, player_id=i) + for i in range(5) + ] + with pytest.raises(ValueError, match="4"): + GameRules(five) + + def test_player_order_contains_all_players(self): + rules = _make_rules() + # Player is not hashable (custom __eq__ without __hash__), use sorted lists + assert sorted(p.player_id for p in rules.player_order) == sorted( + p.player_id for p in rules.players + ) + assert len(rules.player_order) == 4 + + def test_initial_phase_is_dealing(self): + rules = _make_rules() + assert rules.round_state.phase == RoundPhase.DEALING + + def test_game_not_over_at_start(self): + rules = _make_rules() + assert rules.game_over is False + assert rules.winner is None + + +# --------------------------------------------------------------------------- +# player_order rotation (not ascending number order) +# --------------------------------------------------------------------------- + + +class TestPlayerOrderRotation: + def test_first_round_order_is_randomised(self): + """player_order is randomised; with enough attempts it should differ.""" + original_order = _make_rules().players[:] + # Run many times to confirm shuffling occurs + seen_different = False + for _ in range(50): + r = _make_rules() + if r.player_order != original_order: + seen_different = True + break + assert seen_different, ( + "player_order never differed from players list in 50 attempts" + ) + + def test_second_round_rotates_head_to_tail(self): + rules = _make_rules() + + rules.start_new_round() # Round 1 — sets order, no rotation yet + order_round1 = rules.player_order.copy() + + rules.start_new_round() # Round 2 — rotate: head → tail + order_round2 = rules.player_order + + assert order_round2[:-1] == order_round1[1:] # tail unchanged + assert order_round2[-1] == order_round1[0] # old head is now tail + + def test_rotation_cycles_all_four_positions(self): + rules = _make_rules() + rules.start_new_round() + initial_head = rules.player_order[0] + + # After 4 rotations the original head should be back at position 0 + for _ in range(4): + rules.start_new_round() + + assert rules.player_order[0] == initial_head + + +# --------------------------------------------------------------------------- +# Resolution order follows player_order, NOT ascending card number +# --------------------------------------------------------------------------- + + +class TestPlayOrderIsPlayerOrder: + """ + CRITICAL: The current game resolves cards in player_order sequence. + This is different from classic 6 Nimmt! which uses ascending card number. + This test must never be removed or weakened. + """ + + def test_get_cards_in_play_order_follows_player_order(self): + rules = _make_rules() + rules.start_new_round() + + # Assign cards so player_order[0] gets a HIGH number + # and player_order[-1] gets a LOW number. + # In ascending-number order, the low card would go first. + # In player_order order, player_order[0] goes first. + high_card = _card(100) + low_card = _card(1) + + po = rules.player_order + po[0].selected_card = high_card + po[1].selected_card = _card(50) + po[2].selected_card = _card(30) + po[3].selected_card = low_card + + play_order = rules.get_cards_in_play_order() + + assert play_order[0] == (po[0], high_card), ( + "First to play must be player_order[0], even if they played the highest card" + ) + assert play_order[-1] == (po[3], low_card), ( + "Last to play must be player_order[3], even if they played the lowest card" + ) + + def test_play_order_is_not_ascending_card_number(self): + rules = _make_rules() + rules.start_new_round() + + # Create a scenario where ascending-number order ≠ player_order + po = rules.player_order + # Assign descending card numbers to ascending player_order positions + po[0].selected_card = _card(80) + po[1].selected_card = _card(60) + po[2].selected_card = _card(40) + po[3].selected_card = _card(20) + + play_order = rules.get_cards_in_play_order() + card_numbers_in_play_order = [c.number for _, c in play_order] + + # Cards come out in DESCENDING order (player_order sequence), + # NOT ascending (which would be 20, 40, 60, 80). + assert card_numbers_in_play_order == [80, 60, 40, 20], ( + "Cards must resolve in player_order sequence, not ascending number order" + ) + + +# --------------------------------------------------------------------------- +# Forced lowest-penalty row (no player choice for smallest card) +# --------------------------------------------------------------------------- + + +class TestForcedLowestPenaltyRow: + """ + When a card is smaller than all row-end cards, the game AUTOMATICALLY + selects the lowest-penalty row. There is no player choice prompt. + This is the current repository behaviour and must be preserved for + multiplayer rule parity. + """ + + def test_card_smaller_than_all_rows_auto_selects_lowest_penalty(self): + board = Board() + # Row dangers: row0=7(card66), row1=5(card11), row2=3(card30), row3=1(card1) + board.initialize_rows( + [ + _card(66), # danger 7 + _card(11), # danger 5 + _card(30), # danger 3 + _card(99), # danger 5 + ] + ) + + tiny = _card(1) + assert board.is_card_smaller_than_all(tiny) + + lowest = board.get_lowest_penalty_row() + result = board.place_card(tiny, forced_row=lowest) + + assert result.had_to_take_row is True + assert result.row_index == lowest + + def test_execute_turn_auto_forces_lowest_row_for_smallest_card(self): + """ + GameRules.execute_turn / execute_single_placement must call + get_lowest_penalty_row() automatically — no human interaction. + """ + rules = _make_rules() + rules.start_new_round() + + # Set up board with known row ends + rules.board.rows = [[_card(50)], [_card(60)], [_card(70)], [_card(80)]] + + po = rules.player_order + # Give player_order[0] a card smaller than all rows + tiny = _card(1) + po[0].selected_card = tiny + po[0].hand = [tiny] + + # Give everyone else a valid card + for i, p in enumerate(po[1:], start=51): + c = _card(i) + p.selected_card = c + p.hand = [c] + + # execute_single_placement should not raise — it picks the row automatically + play_order = rules.get_cards_in_play_order() + first_player, first_card = play_order[0] + + result = rules.execute_single_placement(first_player, first_card, order_idx=1) + # TurnResult wraps PlacementResult; had_to_take_row lives on result.result + assert result.result.had_to_take_row is True + + def test_no_row_selection_phase_in_current_rules(self): + """ + The current rules flow never enters RoundPhase.ROW_SELECTION for + the forced-row case. ROW_SELECTION exists in the enum but is not + triggered by execute_turn / execute_single_placement. + """ + rules = _make_rules() + rules.start_new_round() + + # Put a tiny card as the only card for player_order[0] + rules.board.rows = [[_card(50)], [_card(60)], [_card(70)], [_card(80)]] + po = rules.player_order + tiny = _card(1) + po[0].selected_card = tiny + po[0].hand = [tiny] + for i, p in enumerate(po[1:], start=51): + c = _card(i) + p.selected_card = c + p.hand = [c] + + play_order = rules.get_cards_in_play_order() + # Execute all placements + for idx, (player, card) in enumerate(play_order): + rules.execute_single_placement(player, card, order_idx=idx + 1) + rules.check_round_end() + + # Phase should be SELECTING (more turns left) or ROUND_END — never ROW_SELECTION + assert rules.round_state.phase != RoundPhase.ROW_SELECTION + + +# --------------------------------------------------------------------------- +# Sixth card penalty +# --------------------------------------------------------------------------- + + +class TestSixthCardPenalty: + def test_placing_sixth_card_takes_five_penalty_cards(self): + board = Board() + board.initialize_rows([_card(10), _card(30), _card(50), _card(70)]) + + # Fill row 0 to 5 cards + for num in [11, 12, 13, 14]: + board.place_card(_card(num)) + assert len(board.rows[0]) == 5 + + result = board.place_card(_card(15)) + assert len(result.penalty_cards) == 5 + assert result.penalty_score > 0 + assert len(board.rows[0]) == 1 + assert board.rows[0][0].number == 15 + + def test_penalty_score_accumulates_in_round_state(self): + rules = _make_rules() + rules.start_new_round() + + # Manually set up a board where the first player will take a penalty + rules.board.rows = [ + [_card(10), _card(11), _card(12), _card(13), _card(14)], # 5 cards + [_card(30)], + [_card(50)], + [_card(70)], + ] + po = rules.player_order + # player_order[0] plays card 15 → 6th card → takes 5 penalty cards + c15 = _card(15) + po[0].selected_card = c15 + po[0].hand = [c15] + for i, p in enumerate(po[1:], start=31): + c = _card(i) + p.selected_card = c + p.hand = [c] + + results = rules.execute_turn() + first_result = results[0] + + assert first_result.player is po[0] + penalty = rules.round_state.round_penalties[po[0].player_id] + assert penalty.card_count == 5 + assert penalty.total_danger > 0 + + +# --------------------------------------------------------------------------- +# Round flow +# --------------------------------------------------------------------------- + + +class TestRoundFlow: + def test_start_new_round_deals_ten_cards_per_player(self): + rules = _make_rules() + rules.start_new_round() + for player in rules.players: + assert player.hand_size == 10 + + def test_start_new_round_initialises_board_with_four_rows(self): + rules = _make_rules() + rules.start_new_round() + assert len(rules.board.rows) == 4 + for row in rules.board.rows: + assert len(row) == 1 + + def test_phase_is_selecting_after_deal(self): + rules = _make_rules() + rules.start_new_round() + assert rules.round_state.phase == RoundPhase.SELECTING + + def test_all_players_selected_false_before_selection(self): + rules = _make_rules() + rules.start_new_round() + assert rules.all_players_selected() is False + + def test_all_players_selected_true_after_all_select(self): + rules = _make_rules() + rules.start_new_round() + for player in rules.players: + player.selected_card = player.hand[0] + assert rules.all_players_selected() is True + + def test_execute_turn_raises_if_not_all_selected(self): + rules = _make_rules() + rules.start_new_round() + with pytest.raises(ValueError, match="selected"): + rules.execute_turn() + + def test_round_ends_after_ten_turns(self): + rules = _make_rules() + rules.start_new_round() + + # Play 10 full turns + for _ in range(10): + for player in rules.players: + if not player.is_eliminated and player.hand_size > 0: + player.selected_card = player.hand[0] + if rules.all_players_selected(): + rules.execute_turn() + + assert rules.round_state.phase == RoundPhase.ROUND_END + + def test_commit_round_scores_updates_player_penalty_scores(self): + rules = _make_rules() + rules.start_new_round() + # Force a penalty: put tiny card in everyone's hand + for player in rules.players: + player.selected_card = player.hand[0] + # Artificially add some penalty cards to round state + target = rules.players[0] + penalty_card = _card(66) # danger 7 + rules.round_state.round_penalties[target.player_id].cards_taken.append( + penalty_card + ) + + results = rules.commit_round_scores() + round_danger, new_total = results[target.player_id] + assert round_danger == 7 + assert target.penalty_score == 7 + + +# --------------------------------------------------------------------------- +# Known assumption: players[0] == human (documented for future replacement) +# --------------------------------------------------------------------------- + + +class TestPlayerZeroHumanAssumption: + """ + Documents the current _check_game_end() assumption that self.players[0] + is always the human seat. + + This assumption exists at rules.py line 305: + human_eliminated = self.players[0].is_eliminated + + PR-04 will replace this with seat/controller_type checks. + This test ensures we notice if the assumption is removed prematurely. + """ + + def test_game_ends_when_players_zero_eliminated(self): + rules = _make_rules() + rules.start_new_round() + + # Eliminate player 0 (human seat in single-player) + rules.players[0].is_eliminated = True + rules._check_game_end() + + assert rules.game_over is True + + def test_game_does_not_end_when_only_an_ai_is_eliminated(self): + rules = _make_rules() + rules.start_new_round() + + # Eliminate an AI player (index 1, 2, or 3) + rules.players[1].is_eliminated = True + rules._check_game_end() + + # Game continues — still 3 active players including human + assert rules.game_over is False + + def test_player_zero_is_human_type_in_create_players(self): + players = create_players() + assert players[0].player_type == PlayerType.HUMAN + for p in players[1:]: + assert p.player_type == PlayerType.AI + + +# --------------------------------------------------------------------------- +# Penalty tracking per player_id (not per index) +# --------------------------------------------------------------------------- + + +class TestPenaltyTracking: + def test_round_penalties_keyed_by_player_id(self): + rules = _make_rules() + rules.start_new_round() + for player in rules.players: + assert player.player_id in rules.round_state.round_penalties + + def test_penalty_cards_added_to_correct_player(self): + rules = _make_rules() + rules.start_new_round() + + target = rules.player_order[0] + rules.round_state.round_penalties[target.player_id].cards_taken.append( + _card(66) + ) + + assert rules.round_state.round_penalties[target.player_id].total_danger == 7 + for p in rules.players: + if p is not target: + assert rules.round_state.round_penalties[p.player_id].total_danger == 0 + + def test_get_player_round_danger_matches_penalty_total(self): + rules = _make_rules() + rules.start_new_round() + + target = rules.players[0] + rules.round_state.round_penalties[target.player_id].cards_taken.extend( + [_card(11), _card(22)] # danger 5 + 5 = 10 + ) + assert rules.get_player_round_danger(target) == 10 + + def test_rankings_sorted_by_penalty_score_low_first(self): + rules = _make_rules() + rules.players[0].penalty_score = 30 + rules.players[1].penalty_score = 5 + rules.players[2].penalty_score = 20 + rules.players[3].penalty_score = 15 + + rankings = rules.get_rankings() + scores = [p.penalty_score for p in rankings] + assert scores == sorted(scores) diff --git a/tests/multiplayer/__init__.py b/tests/multiplayer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/multiplayer/test_account_entry_flow.py b/tests/multiplayer/test_account_entry_flow.py new file mode 100644 index 0000000..d978e31 --- /dev/null +++ b/tests/multiplayer/test_account_entry_flow.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from fall_in.core.game_manager import GameManager +from fall_in.scenes.account_gate_scene import AccountGateScene +from fall_in.scenes.intro_cutscene_scene import IntroCutsceneScene + + +def test_intro_transitions_to_account_gate(monkeypatch): + scene = object.__new__(IntroCutsceneScene) + captured: dict[str, object] = {} + + monkeypatch.setattr( + GameManager, + "change_scene", + lambda self, new_scene: captured.setdefault("scene", new_scene), + ) + + IntroCutsceneScene._transition_to_title(scene) + + assert isinstance(captured["scene"], AccountGateScene) diff --git a/tests/multiplayer/test_adapters.py b/tests/multiplayer/test_adapters.py new file mode 100644 index 0000000..f2e9fc3 --- /dev/null +++ b/tests/multiplayer/test_adapters.py @@ -0,0 +1,426 @@ +""" +Tests for LocalGameAdapter and RemoteGameAdapter. + +Covers: + - LocalGameAdapter wraps GameRules correctly + - LocalGameAdapter produces DTO-safe state snapshots + - RemoteGameAdapter caches and exposes server snapshots + - RemoteGameAdapter rejects private state for wrong seat +""" + +import pytest + +from fall_in.ai.ai_player import create_ai_players +from fall_in.core.player import create_players +from fall_in.core.rules import GameRules, RoundPhase +from fall_in.multiplayer.local_adapter import LocalGameAdapter +from fall_in.multiplayer.models import ( + ControllerType, + MatchCardPublic, + PrivatePlayerState, + PublicMatchState, + SeatIdentity, +) +from fall_in.multiplayer.remote_adapter import RemoteGameAdapter +from fall_in.net.serializers import ( + _PRIVATE_CARD_FIELDS, + private_state_to_dict, + public_state_to_dict, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_local_adapter(match_id: str = "local-test") -> LocalGameAdapter: + players = create_players() # 1 human + 3 AI + ai_controllers = create_ai_players(players) + rules = GameRules(players) + adapter = LocalGameAdapter(rules, ai_controllers, match_id=match_id) + adapter.start_round() + return adapter + + +def _make_public_state() -> PublicMatchState: + card = MatchCardPublic(number=10, danger=3, owner_seat=0) + return PublicMatchState( + match_id="remote-test", + round_number=1, + phase="SELECTING", + player_order_seats=[0, 1, 2, 3], + board_rows=[[card]], + played_cards_this_turn=[], + committed_scores={0: 0, 1: 0, 2: 0, 3: 0}, + seats=[ + SeatIdentity( + seat_index=i, + controller_type=ControllerType.REMOTE, + display_name=f"P{i}", + ) + for i in range(4) + ], + ) + + +def _make_private_state(seat: int = 0) -> PrivatePlayerState: + return PrivatePlayerState( + seat_index=seat, + hand=[MatchCardPublic(number=42, danger=1, owner_seat=seat)], + has_selected=False, + ) + + +# --------------------------------------------------------------------------- +# TestLocalGameAdapter +# --------------------------------------------------------------------------- + + +class TestLocalGameAdapter: + def test_start_round_deals_ten_cards_to_human(self): + adapter = _make_local_adapter() + priv = adapter.get_private_state() + assert len(priv.hand) == 10 + + def test_start_round_bots_have_selected(self): + players = create_players() + ai_controllers = create_ai_players(players) + rules = GameRules(players) + adapter = LocalGameAdapter(rules, ai_controllers) + adapter.start_round() + for player in rules.players[1:]: # seats 1-3 are bots + assert player.selected_card is not None + + def test_human_has_not_selected_at_start(self): + adapter = _make_local_adapter() + priv = adapter.get_private_state() + assert priv.has_selected is False + + def test_all_selected_false_before_human_picks(self): + adapter = _make_local_adapter() + assert not adapter.all_selected() + + def test_all_selected_true_after_human_picks(self): + adapter = _make_local_adapter() + card_number = adapter.get_private_state().hand[0].number + adapter.select_card_for_human(card_number) + assert adapter.all_selected() + + def test_select_invalid_card_raises(self): + adapter = _make_local_adapter() + with pytest.raises(ValueError, match="not in"): + adapter.select_card_for_human(9999) + + def test_resolve_turn_returns_four_placements(self): + adapter = _make_local_adapter() + card_number = adapter.get_private_state().hand[0].number + adapter.select_card_for_human(card_number) + results = adapter.resolve_turn() + assert len(results) == 4 + + def test_resolve_turn_results_are_tuples_of_seat_and_card(self): + adapter = _make_local_adapter() + card_number = adapter.get_private_state().hand[0].number + adapter.select_card_for_human(card_number) + results = adapter.resolve_turn() + for seat_idx, card in results: + assert isinstance(seat_idx, int) + assert isinstance(card, MatchCardPublic) + + def test_resolve_turn_card_owner_seat_matches_seat_index(self): + adapter = _make_local_adapter() + card_number = adapter.get_private_state().hand[0].number + adapter.select_card_for_human(card_number) + results = adapter.resolve_turn() + for seat_idx, card in results: + assert card.owner_seat == seat_idx + + def test_public_state_has_four_board_rows(self): + adapter = _make_local_adapter() + pub = adapter.get_public_state() + assert len(pub.board_rows) == 4 + + def test_public_state_phase_is_selecting(self): + adapter = _make_local_adapter() + pub = adapter.get_public_state() + assert pub.phase == RoundPhase.SELECTING.name + + def test_public_state_no_private_card_fields(self): + adapter = _make_local_adapter() + pub = adapter.get_public_state() + d = public_state_to_dict(pub) + for row in d["board_rows"]: + for card_dict in row: + for field in _PRIVATE_CARD_FIELDS: + assert field not in card_dict + + def test_private_state_no_private_card_fields(self): + adapter = _make_local_adapter() + priv = adapter.get_private_state() + d = private_state_to_dict(priv) + for card_dict in d["hand"]: + for field in _PRIVATE_CARD_FIELDS: + assert field not in card_dict + + def test_private_state_hand_owner_seat_is_zero(self): + """Human is always seat 0; hand cards must carry owner_seat=0.""" + adapter = _make_local_adapter() + priv = adapter.get_private_state() + for card in priv.hand: + assert card.owner_seat == 0 + + def test_public_state_seat_0_is_local(self): + adapter = _make_local_adapter() + pub = adapter.get_public_state() + seat0 = pub.get_seat(0) + assert seat0 is not None + assert seat0.controller_type == ControllerType.LOCAL + + def test_public_state_other_seats_are_bots(self): + adapter = _make_local_adapter() + pub = adapter.get_public_state() + for seat in pub.seats[1:]: + assert seat.controller_type == ControllerType.BOT + + def test_is_round_over_false_before_ten_turns(self): + adapter = _make_local_adapter() + card_number = adapter.get_private_state().hand[0].number + adapter.select_card_for_human(card_number) + adapter.resolve_turn() + assert not adapter.is_round_over() + + def test_commit_round_returns_seat_indexed_scores(self): + adapter = _make_local_adapter() + rules = adapter._rules + # Play all 10 turns. + for _ in range(10): + card_number = adapter.get_private_state().hand[0].number + adapter.select_card_for_human(card_number) + adapter.resolve_turn() + if not adapter.is_round_over(): + # Re-select bots for next turn. + for player_id, ai in adapter._ai.items(): + player = next(p for p in rules.players if p.player_id == player_id) + if not player.is_eliminated: + ai.select_card(rules.board) + assert adapter.is_round_over() + scores = adapter.commit_round() + for seat_idx in range(4): + assert seat_idx in scores + rd, total = scores[seat_idx] + assert rd >= 0 and total >= 0 + + +# --------------------------------------------------------------------------- +# TestRemoteGameAdapter +# --------------------------------------------------------------------------- + + +class TestRemoteGameAdapter: + def test_has_match_started_false_initially(self): + adapter = RemoteGameAdapter(my_seat=1) + assert not adapter.has_match_started() + + def test_apply_public_state_marks_match_started(self): + adapter = RemoteGameAdapter(my_seat=1) + adapter.apply_public_state(_make_public_state()) + assert adapter.has_match_started() + + def test_get_public_state_returns_applied_state(self): + adapter = RemoteGameAdapter(my_seat=0) + state = _make_public_state() + adapter.apply_public_state(state) + assert adapter.get_public_state() is state + + def test_get_private_state_returns_applied_state(self): + adapter = RemoteGameAdapter(my_seat=2) + priv = _make_private_state(seat=2) + adapter.apply_private_state(priv) + assert adapter.get_private_state() is priv + + def test_apply_private_state_wrong_seat_raises(self): + adapter = RemoteGameAdapter(my_seat=1) + priv = _make_private_state(seat=3) + with pytest.raises(ValueError, match="seat 3"): + adapter.apply_private_state(priv) + + def test_is_my_turn_false_without_state(self): + adapter = RemoteGameAdapter(my_seat=0) + assert not adapter.is_my_turn_to_select() + + def test_is_my_turn_true_when_selecting_and_not_selected(self): + adapter = RemoteGameAdapter(my_seat=0) + adapter.apply_public_state(_make_public_state()) # phase=SELECTING + adapter.apply_private_state(_make_private_state(seat=0)) # has_selected=False + assert adapter.is_my_turn_to_select() + + def test_is_my_turn_false_after_selecting(self): + adapter = RemoteGameAdapter(my_seat=0) + adapter.apply_public_state(_make_public_state()) + priv = PrivatePlayerState( + seat_index=0, + hand=[MatchCardPublic(number=42, danger=1, owner_seat=0)], + has_selected=True, + ) + adapter.apply_private_state(priv) + assert not adapter.is_my_turn_to_select() + + def test_get_my_hand_cards_empty_before_state(self): + adapter = RemoteGameAdapter(my_seat=0) + assert adapter.get_my_hand_cards() == [] + + def test_get_my_hand_cards_returns_hand(self): + adapter = RemoteGameAdapter(my_seat=1) + priv = _make_private_state(seat=1) + adapter.apply_private_state(priv) + cards = adapter.get_my_hand_cards() + assert len(cards) == 1 + assert cards[0].number == 42 + + def test_get_board_rows_empty_before_state(self): + adapter = RemoteGameAdapter(my_seat=0) + assert adapter.get_board_rows() == [] + + def test_get_board_rows_returns_snapshot(self): + adapter = RemoteGameAdapter(my_seat=0) + adapter.apply_public_state(_make_public_state()) + rows = adapter.get_board_rows() + assert len(rows) == 1 # _make_public_state has 1 row with 1 card + + def test_get_phase_none_before_state(self): + adapter = RemoteGameAdapter(my_seat=0) + assert adapter.get_phase() is None + + def test_get_phase_returns_current_phase(self): + adapter = RemoteGameAdapter(my_seat=0) + adapter.apply_public_state(_make_public_state()) + assert adapter.get_phase() == "SELECTING" + + def test_my_seat_property(self): + adapter = RemoteGameAdapter(my_seat=3) + assert adapter.my_seat == 3 + + def test_get_committed_scores_empty_before_state(self): + adapter = RemoteGameAdapter(my_seat=0) + assert adapter.get_committed_scores() == {} + + def test_get_committed_scores_returns_dict(self): + adapter = RemoteGameAdapter(my_seat=0) + state = _make_public_state() + state.committed_scores = {0: 5, 1: 12} + adapter.apply_public_state(state) + scores = adapter.get_committed_scores() + assert scores[0] == 5 + assert scores[1] == 12 + + # ------------------------------------------------------------------ + # Emote path (PR-07) + # ------------------------------------------------------------------ + + def test_pop_pending_emotes_empty_initially(self): + adapter = RemoteGameAdapter(my_seat=0) + assert adapter.pop_pending_emotes() == [] + + def test_apply_emote_queued_for_pop(self): + adapter = RemoteGameAdapter(my_seat=0) + adapter.apply_emote(seat_index=2, emote_id="smile") + pending = adapter.pop_pending_emotes() + assert pending == [(2, "smile")] + + def test_pop_pending_emotes_clears_queue(self): + adapter = RemoteGameAdapter(my_seat=0) + adapter.apply_emote(1, "fire") + adapter.pop_pending_emotes() + # Second pop must return empty + assert adapter.pop_pending_emotes() == [] + + def test_apply_emote_multiple_entries_ordered(self): + adapter = RemoteGameAdapter(my_seat=0) + adapter.apply_emote(0, "thumbsup") + adapter.apply_emote(3, "cry") + pending = adapter.pop_pending_emotes() + assert len(pending) == 2 + assert pending[0] == (0, "thumbsup") + assert pending[1] == (3, "cry") + + def test_apply_emote_independent_of_state(self): + """Emotes can arrive before or after public/private state is set.""" + adapter = RemoteGameAdapter(my_seat=1) + adapter.apply_emote(1, "clap") + # No public or private state yet — should still work + assert not adapter.has_match_started() + pending = adapter.pop_pending_emotes() + assert pending == [(1, "clap")] + + def test_turn_reveal_steps_are_buffered_until_consumed(self): + adapter = RemoteGameAdapter(my_seat=0) + state = _make_public_state() + + adapter.begin_turn_reveal() + adapter.queue_turn_reveal_step( + { + "seat_index": 2, + "card_number": 44, + "placement_order": 1, + "card_danger": 3, + }, + state, + ) + adapter.finish_turn_reveal() + + queued = adapter.pop_next_reveal_step() + assert queued is not None + step, snapshot = queued + assert step.seat_index == 2 + assert step.card_number == 44 + assert step.placement_order == 1 + assert snapshot is state + + def test_post_reveal_public_states_flush_after_animation(self): + adapter = RemoteGameAdapter(my_seat=0) + state = _make_public_state() + + adapter.begin_turn_reveal() + adapter.queue_post_reveal_public_state(state) + adapter.finish_turn_reveal() + + flushed = adapter.flush_post_reveal_public_states() + assert flushed == [state] + assert adapter.flush_post_reveal_public_states() == [] + + def test_selecting_phase_start_is_consumed_once(self): + adapter = RemoteGameAdapter(my_seat=0) + + adapter.notify_selecting_phase_started() + + result = adapter.consume_selecting_phase_started() + assert result is not None # Returns remaining_time (float) + assert adapter.consume_selecting_phase_started() is None + + def test_selecting_phase_start_preserves_remaining_time(self): + adapter = RemoteGameAdapter(my_seat=0) + + adapter.notify_selecting_phase_started(remaining_time=25.5) + + result = adapter.consume_selecting_phase_started() + assert result == 25.5 + assert adapter.consume_selecting_phase_started() is None + + def test_round_result_queue_preserves_order(self): + adapter = RemoteGameAdapter(my_seat=0) + + adapter.queue_round_result({"round_number": 1}) + adapter.queue_round_result({"round_number": 2}) + + assert adapter.pop_round_result() == {"round_number": 1} + assert adapter.pop_round_result() == {"round_number": 2} + assert adapter.pop_round_result() is None + + def test_match_result_queue_returns_oldest_payload(self): + adapter = RemoteGameAdapter(my_seat=0) + + adapter.queue_match_result({"winner_seat": 2}) + + assert adapter.pop_match_result() == {"winner_seat": 2} + assert adapter.pop_match_result() is None diff --git a/tests/multiplayer/test_game_scene_multiplayer.py b/tests/multiplayer/test_game_scene_multiplayer.py new file mode 100644 index 0000000..b1c01e8 --- /dev/null +++ b/tests/multiplayer/test_game_scene_multiplayer.py @@ -0,0 +1,370 @@ +""" +Focused tests for multiplayer-specific GameScene helpers. +""" + +from __future__ import annotations + +from types import SimpleNamespace + +from fall_in.core.game_manager import GameManager +from fall_in.core.player import PlayerType +from fall_in.multiplayer.models import ( + ControllerType, + MatchCardPublic, + PrivatePlayerState, + PublicMatchState, + SeatIdentity, +) +from fall_in.multiplayer.remote_adapter import RemoteGameAdapter +from fall_in.scenes.game_scene import GameScene + + +def _make_public_state() -> PublicMatchState: + return PublicMatchState( + match_id="match-42", + round_number=1, + phase="SELECTING", + player_order_seats=[2, 0, 1, 3], + board_rows=[[], [], [], []], + played_cards_this_turn=[], + committed_scores={0: 0, 1: 0, 2: 5, 3: 0}, + seats=[ + SeatIdentity(0, ControllerType.REMOTE, "HostGuest"), + SeatIdentity(1, ControllerType.BOT, "AI 1"), + SeatIdentity(2, ControllerType.REMOTE, "GuestTwo"), + SeatIdentity(3, ControllerType.BOT, "AI 2"), + ], + ) + + +def test_on_emote_selected_uses_local_multiplayer_seat(): + scene = object.__new__(GameScene) + scene._remote_adapter = SimpleNamespace(my_seat=2) + sent: list[str] = [] + shown: list[tuple[int, str]] = [] + scene._emote_send_callback = sent.append + scene.show_emote = lambda seat_index, emote_id: shown.append((seat_index, emote_id)) + + GameScene._on_emote_selected(scene, "fire") + + assert shown == [(2, "fire")] + assert sent == ["fire"] + + +def test_confirm_card_selection_sends_card_number_in_remote_mode(): + adapter = RemoteGameAdapter(my_seat=2) + adapter.apply_public_state(_make_public_state()) + adapter.apply_private_state( + PrivatePlayerState( + seat_index=2, + hand=[ + MatchCardPublic(number=7, danger=1, owner_seat=2), + MatchCardPublic(number=15, danger=2, owner_seat=2), + ], + has_selected=False, + ) + ) + + scene = object.__new__(GameScene) + scene._remote_adapter = adapter + scene._card_select_callback = None + scene._emote_send_callback = None + scene.selected_card_index = 1 + scene.message = "" + scene.message_timer = 0.0 + scene.human_player = SimpleNamespace(hand=[]) + + sent_cards: list[int] = [] + scene._card_select_callback = sent_cards.append + + GameScene._confirm_card_selection(scene) + + assert sent_cards == [15] + assert scene.selected_card_index is None + + +def test_player_order_entries_use_remote_display_names(): + adapter = RemoteGameAdapter(my_seat=2) + adapter.apply_public_state(_make_public_state()) + + scene = object.__new__(GameScene) + scene._remote_adapter = adapter + + entries = GameScene._get_player_order_entries(scene) + + assert entries == [ + ("나", True), + ("1", False), + ("2", False), + ("4", False), + ] + + +def test_remote_reveal_applies_one_snapshot_per_tick(): + selecting = _make_public_state() + step_one = _make_public_state() + step_two = _make_public_state() + step_one.board_rows = [ + [MatchCardPublic(number=10, danger=3, owner_seat=0)], + [], + [], + [], + ] + step_two.board_rows = [ + [ + MatchCardPublic(number=10, danger=3, owner_seat=0), + MatchCardPublic(number=20, danger=2, owner_seat=1), + ], + [], + [], + [], + ] + + adapter = RemoteGameAdapter(my_seat=2) + adapter.apply_public_state(selecting) + adapter.begin_turn_reveal() + adapter.queue_turn_reveal_step({"seat_index": 0, "card_number": 10}, step_one) + adapter.queue_turn_reveal_step({"seat_index": 1, "card_number": 20}, step_two) + adapter.finish_turn_reveal() + + scene = object.__new__(GameScene) + scene._remote_adapter = adapter + scene._remote_reveal_step_timer = 0.0 + scene._remote_reveal_step_duration = 0.4 + scene._remote_current_reveal_seat = None + scene.message = "" + scene.message_timer = 0.0 + scene.penalty_cards_animating = [] + scene.penalty_tweens = type( + "FakeTweenGroup", + (), + {"update": lambda self, dt: True, "clear": lambda self: None}, + )() + scene.commander = type( + "FakeCommander", (), {"say_penalty_taken": lambda self: None} + )() + + GameScene._advance_remote_reveal(scene, 0.0) + assert adapter.get_public_state() is step_one + assert scene._remote_current_reveal_seat == 0 + + GameScene._advance_remote_reveal(scene, 0.1) + assert adapter.get_public_state() is step_one + + GameScene._advance_remote_reveal(scene, 0.4) + assert adapter.get_public_state() is step_two + assert scene._remote_current_reveal_seat == 1 + + +def test_waiting_for_other_players_after_remote_selection(): + adapter = RemoteGameAdapter(my_seat=2) + adapter.apply_public_state(_make_public_state()) + adapter.apply_private_state( + PrivatePlayerState( + seat_index=2, + hand=[MatchCardPublic(number=7, danger=1, owner_seat=2)], + has_selected=True, + ) + ) + + scene = object.__new__(GameScene) + scene._remote_adapter = adapter + scene._remote_round_result = None + scene._remote_selection_sent_pending = False + scene._remote_current_reveal_seat = None + scene._remote_reveal_step_timer = 0.0 + + assert GameScene._is_waiting_for_other_players(scene) is True + assert GameScene._can_interact_with_cards(scene) is False + + +def test_remote_selecting_phase_resets_timer_and_clears_waiting_state(): + adapter = RemoteGameAdapter(my_seat=2) + adapter.apply_public_state(_make_public_state()) + adapter.notify_selecting_phase_started() + + scene = object.__new__(GameScene) + scene._remote_adapter = adapter + scene._remote_round_result = {"round_number": 1} + scene._remote_round_result_timer = 5.0 + scene._remote_round_result_acknowledged = True + scene._remote_selection_sent_pending = True + scene._remote_current_reveal_seat = None + scene._remote_reveal_step_timer = 0.0 + scene.turn_timer = 3.0 + scene._last_timeout_tick = 2 + scene.selected_card_index = 1 + scene._timeout_sfx = None + + changed = GameScene._update_remote_match_flow(scene, 0.5) + + assert changed is False + assert scene.turn_timer < 30.0 and scene.turn_timer > 29.0 + assert scene._remote_round_result is None + assert scene._remote_selection_sent_pending is False + assert scene.selected_card_index is None + + +def test_remote_round_result_transitions_to_shared_result_scene(monkeypatch): + adapter = RemoteGameAdapter(my_seat=2) + adapter.apply_public_state(_make_public_state()) + adapter.queue_round_result({"round_number": 1, "timeout_seconds": 8}) + + scene = object.__new__(GameScene) + scene._remote_adapter = adapter + scene._network_tick_callback = None + scene._round_ready_callback = None + scene._remote_round_result = None + scene._remote_round_result_timer = 0.0 + scene._remote_round_result_acknowledged = False + scene._remote_selection_sent_pending = False + scene._remote_current_reveal_seat = None + scene._remote_reveal_step_timer = 0.0 + scene.turn_timer = 30.0 + scene._last_timeout_tick = -1 + scene.selected_card_index = None + scene._timeout_sfx = None + + captured: dict[str, object] = {} + import fall_in.scenes.result_scene as result_scene_module + + class _FakeResultScene: + pass + + monkeypatch.setattr( + result_scene_module.ResultScene, + "from_remote", + classmethod(lambda cls, **kwargs: _FakeResultScene()), + ) + monkeypatch.setattr( + GameManager, + "change_scene", + lambda self, new_scene: captured.setdefault("scene", new_scene), + ) + + changed = GameScene._update_remote_match_flow(scene, 0.0) + + assert changed is True + assert isinstance(captured["scene"], _FakeResultScene) + + +def test_start_remote_round_result_normalizes_json_keys(): + scene = object.__new__(GameScene) + scene._remote_selection_sent_pending = True + scene.selected_card_index = 2 + scene.message = "busy" + scene.message_timer = 1.0 + + GameScene._start_remote_round_result( + scene, + { + "round_number": 2, + "round_danger": {"0": 3, "2": 1}, + "total_scores": {"0": 14, "2": 9}, + "eliminated_seats": ["3"], + "timeout_seconds": 8, + }, + ) + + assert scene._remote_round_result["round_danger"] == {0: 3, 2: 1} + assert scene._remote_round_result["total_scores"] == {0: 14, 2: 9} + assert scene._remote_round_result["eliminated_seats"] == [3] + assert scene._remote_round_result_timer == 8 + assert scene._remote_selection_sent_pending is False + assert scene.selected_card_index is None + + +def test_selection_timer_clamps_large_frame_hitch(): + scene = object.__new__(GameScene) + scene.turn_timer = 30.0 + scene._timeout_sfx = None + scene._last_timeout_tick = -1 + + timed_out: list[bool] = [] + changed = GameScene._consume_selection_timer( + scene, + 5.0, + on_timeout=lambda: timed_out.append(True), + ) + + assert changed is False + assert timed_out == [] + assert abs(scene.turn_timer - 29.75) < 1e-6 + + +def test_remote_match_result_transitions_to_game_over_scene(monkeypatch): + adapter = RemoteGameAdapter(my_seat=2) + adapter.apply_public_state(_make_public_state()) + adapter.queue_match_result({"winner_seat": 0, "final_scores": {"0": 12, "2": 8}}) + + scene = object.__new__(GameScene) + scene._remote_adapter = adapter + scene._remote_round_result = {"round_number": 1} + scene._remote_round_result_timer = 5.0 + scene._remote_round_result_acknowledged = False + scene._remote_selection_sent_pending = False + scene._remote_current_reveal_seat = None + scene._remote_reveal_step_timer = 0.0 + scene.turn_timer = 30.0 + scene._last_timeout_tick = -1 + scene.selected_card_index = None + scene._timeout_sfx = None + + captured: dict[str, object] = {} + import fall_in.scenes.game_over_scene as game_over_module + + class _FakeGameOverScene: + def __init__(self, winner, players, round_number, multiplayer_reward=None): + self.winner = winner + self.players = players + self.round_number = round_number + self.multiplayer_reward = multiplayer_reward + + monkeypatch.setattr(game_over_module, "GameOverScene", _FakeGameOverScene) + monkeypatch.setattr( + GameManager, + "change_scene", + lambda self, new_scene: captured.setdefault("scene", new_scene), + ) + + changed = GameScene._update_remote_match_flow(scene, 0.0) + + assert changed is True + assert isinstance(captured["scene"], _FakeGameOverScene) + assert captured["scene"].winner.player_id == 0 + assert any( + player.player_type == PlayerType.HUMAN for player in captured["scene"].players + ) + + +def test_other_player_panels_include_disconnect_status(): + public = _make_public_state() + public.seats[3] = SeatIdentity(3, ControllerType.REMOTE, "GuestThree") + + adapter = RemoteGameAdapter(my_seat=2) + adapter.apply_public_state(public) + adapter.mark_seat_disconnected(0) + adapter.mark_seat_bot_takeover(3) + + scene = object.__new__(GameScene) + scene._remote_adapter = adapter + + panels = GameScene._get_other_player_panels(scene) + by_seat = {panel["seat_index"]: panel for panel in panels} + + assert by_seat[0]["status"] == "재접속 중" + assert by_seat[3]["status"] == "BOT 대체" + + +def test_confirm_remote_round_result_uses_callback_once(): + scene = object.__new__(GameScene) + scene._remote_round_result = {"round_number": 1} + scene._remote_round_result_acknowledged = False + calls: list[str] = [] + scene._round_ready_callback = lambda: calls.append("ready") + + GameScene._confirm_remote_round_result(scene) + GameScene._confirm_remote_round_result(scene) + + assert calls == ["ready"] + assert scene._remote_round_result_acknowledged is True diff --git a/tests/multiplayer/test_public_private_state.py b/tests/multiplayer/test_public_private_state.py new file mode 100644 index 0000000..07545db --- /dev/null +++ b/tests/multiplayer/test_public_private_state.py @@ -0,0 +1,441 @@ +""" +Tests for the public/private state boundary and collection-field exclusion. + +These tests enforce the core multiplayer invariant: + Private cosmetic fields (is_collected, name, rank, unit, note, body_type) + must NEVER appear in any public-facing DTO or serialised dict. + +They also verify that PrivatePlayerState is never reachable from +PublicMatchState — the two objects are always kept separate. +""" + +import dataclasses +import pytest + +from fall_in.multiplayer.models import ( + MatchCardPublic, + PublicMatchState, + PrivatePlayerState, + AccountProgressRef, + SeatIdentity, + ControllerType, +) +from fall_in.net.serializers import ( + match_card_to_dict, + public_state_to_dict, + private_state_to_dict, + _PRIVATE_CARD_FIELDS, +) +from fall_in.net.state_projection import resolve_card_visual, resolve_hand_card_visual +from fall_in.core.card import Card + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_card(number: int = 42) -> Card: + """Create a Card with all private cosmetic fields populated.""" + from fall_in.core.card import calculate_danger + + return Card( + number=number, + danger=calculate_danger(number), + is_collected=True, + name="홍길동", + rank="병장", + unit="제1전투비행단", + note="우수 병사", + body_type="NORMAL", + ) + + +def _make_public_card(number: int = 42, owner_seat: int = 0) -> MatchCardPublic: + from fall_in.core.card import calculate_danger + + return MatchCardPublic( + number=number, + danger=calculate_danger(number), + owner_seat=owner_seat, + ) + + +def _make_seat( + seat_index: int = 0, controller_type: ControllerType = ControllerType.LOCAL +) -> SeatIdentity: + return SeatIdentity( + seat_index=seat_index, + controller_type=controller_type, + display_name=f"Player {seat_index}", + ) + + +def _make_public_state( + board_cards: list[MatchCardPublic] | None = None, +) -> PublicMatchState: + cards = board_cards or [_make_public_card(10, 0)] + return PublicMatchState( + match_id="test-match-001", + round_number=1, + phase="SELECTING", + player_order_seats=[0, 1, 2, 3], + board_rows=[[c] for c in cards], + played_cards_this_turn=[], + committed_scores={0: 0, 1: 0, 2: 0, 3: 0}, + seats=[_make_seat(i) for i in range(4)], + ) + + +# --------------------------------------------------------------------------- +# MatchCardPublic field contract +# --------------------------------------------------------------------------- + + +class TestMatchCardPublicFields: + """MatchCardPublic must have exactly the three allowed fields.""" + + def test_has_number_danger_owner_seat(self): + card = _make_public_card(42, 2) + assert card.number == 42 + assert card.danger == 1 + assert card.owner_seat == 2 + + def test_private_fields_do_not_exist_on_match_card_public(self): + card = _make_public_card() + for field_name in _PRIVATE_CARD_FIELDS: + assert not hasattr(card, field_name), ( + f"MatchCardPublic must not have field '{field_name}'" + ) + + def test_match_card_public_dataclass_fields_are_exactly_three(self): + field_names = {f.name for f in dataclasses.fields(MatchCardPublic)} + assert field_names == {"number", "danger", "owner_seat"} + + def test_match_card_public_is_frozen(self): + card = _make_public_card() + with pytest.raises((dataclasses.FrozenInstanceError, AttributeError)): + card.number = 99 # type: ignore[misc] + + +# --------------------------------------------------------------------------- +# Serialiser — single card +# --------------------------------------------------------------------------- + + +class TestMatchCardToDict: + """card_to_public_dto must strip all private fields.""" + + def test_dict_contains_only_allowed_keys(self): + card = _make_public_card(55, 1) + d = match_card_to_dict(card) + assert set(d.keys()) == {"number", "danger", "owner_seat"} + + def test_dict_values_are_correct(self): + card = _make_public_card(66, 3) + d = match_card_to_dict(card) + assert d["number"] == 66 + assert d["danger"] == 7 # Card 66 has danger 7 + assert d["owner_seat"] == 3 + + def test_no_private_fields_in_dict(self): + card = _make_public_card(42, 0) + d = match_card_to_dict(card) + for field_name in _PRIVATE_CARD_FIELDS: + assert field_name not in d, ( + f"Private field '{field_name}' leaked into public card dict" + ) + + +# --------------------------------------------------------------------------- +# Serialiser — public state +# --------------------------------------------------------------------------- + + +class TestPublicStateSerialization: + """public_state_to_dict must produce a clean wire-safe dict.""" + + def test_serialised_dict_has_expected_top_level_keys(self): + state = _make_public_state() + d = public_state_to_dict(state) + expected_keys = { + "match_id", + "round_number", + "phase", + "player_order_seats", + "board_rows", + "played_cards_this_turn", + "committed_scores", + "seats", + } + assert set(d.keys()) == expected_keys + + def test_board_row_cards_have_no_private_fields(self): + cards = [_make_public_card(n, 0) for n in [10, 30, 50, 70]] + state = _make_public_state(board_cards=cards) + d = public_state_to_dict(state) + for row in d["board_rows"]: + for card_dict in row: + for field_name in _PRIVATE_CARD_FIELDS: + assert field_name not in card_dict, ( + f"Private field '{field_name}' found in board row card dict" + ) + + def test_played_cards_this_turn_have_no_private_fields(self): + state = _make_public_state() + state.played_cards_this_turn = [ + _make_public_card(42, 1), + _make_public_card(77, 2), + ] + d = public_state_to_dict(state) + for card_dict in d["played_cards_this_turn"]: + for field_name in _PRIVATE_CARD_FIELDS: + assert field_name not in card_dict + + def test_serialiser_raises_if_private_field_injected(self): + """ + Guard: if someone accidentally puts a raw dict with private fields + into the state, the serialiser's assertion must catch it. + """ + # We test the assertion helper directly. + from fall_in.net.serializers import _assert_no_private_fields + + bad_dict = {"number": 1, "danger": 1, "owner_seat": 0, "is_collected": True} + with pytest.raises(AssertionError, match="is_collected"): + _assert_no_private_fields(bad_dict, context="test") + + def test_round_number_and_phase_preserved(self): + state = _make_public_state() + state.round_number = 3 + state.phase = "PLACING" + d = public_state_to_dict(state) + assert d["round_number"] == 3 + assert d["phase"] == "PLACING" + + def test_committed_scores_are_seat_indexed(self): + state = _make_public_state() + state.committed_scores = {0: 5, 1: 12, 2: 0, 3: 33} + d = public_state_to_dict(state) + # Keys become strings after JSON round-trip, but at serialiser level they're int. + assert d["committed_scores"][0] == 5 + assert d["committed_scores"][3] == 33 + + +# --------------------------------------------------------------------------- +# Serialiser — private state +# --------------------------------------------------------------------------- + + +class TestPrivateStateSerialization: + """private_state_to_dict reflects hand cards — these stay private.""" + + def test_private_state_contains_seat_index_and_hand(self): + priv = PrivatePlayerState( + seat_index=2, + hand=[_make_public_card(42, 2), _make_public_card(77, 2)], + has_selected=False, + ) + d = private_state_to_dict(priv) + assert d["seat_index"] == 2 + assert len(d["hand"]) == 2 + assert d["has_selected"] is False + + def test_hand_card_dicts_have_no_private_fields(self): + priv = PrivatePlayerState( + seat_index=0, + hand=[_make_public_card(n, 0) for n in [5, 15, 25]], + ) + d = private_state_to_dict(priv) + for card_dict in d["hand"]: + for field_name in _PRIVATE_CARD_FIELDS: + assert field_name not in card_dict + + +# --------------------------------------------------------------------------- +# State separation — public never exposes private +# --------------------------------------------------------------------------- + + +class TestStateSeparation: + """PublicMatchState and PrivatePlayerState are distinct, non-overlapping objects.""" + + def test_public_state_has_no_hand_field(self): + state = _make_public_state() + assert not hasattr(state, "hand"), ( + "PublicMatchState must not contain a 'hand' field" + ) + + def test_private_state_has_no_board_rows_field(self): + priv = PrivatePlayerState(seat_index=1) + assert not hasattr(priv, "board_rows"), ( + "PrivatePlayerState must not contain 'board_rows'" + ) + + def test_public_and_private_are_independent_objects(self): + state = _make_public_state() + priv = PrivatePlayerState( + seat_index=0, + hand=[_make_public_card(42, 0)], + ) + # Modifying private state must not affect public state + priv.hand.append(_make_public_card(55, 0)) + assert all(len(row) == 1 for row in state.board_rows) + + def test_public_state_serialised_dict_has_no_hand_key(self): + state = _make_public_state() + d = public_state_to_dict(state) + assert "hand" not in d + + +# --------------------------------------------------------------------------- +# AccountProgressRef — local-only, not broadcast +# --------------------------------------------------------------------------- + + +class TestAccountProgressRef: + """AccountProgressRef is a local-only object; it has no serialiser.""" + + def test_has_collected_returns_true_for_owned_number(self): + ref = AccountProgressRef(user_id="user-001", collection={10, 42, 77}) + assert ref.has_collected(42) is True + + def test_has_collected_returns_false_for_missing(self): + ref = AccountProgressRef(user_id="user-001", collection={10, 42}) + assert ref.has_collected(99) is False + + def test_guest_ref_has_no_user_id(self): + ref = AccountProgressRef(user_id=None, collection={5, 6}) + assert ref.user_id is None + + def test_account_progress_ref_has_no_serialiser(self): + """There must be no public serialise function for AccountProgressRef.""" + import fall_in.net.serializers as ser + + # Verify no function named *_account_progress* is exported + public_names = [n for n in dir(ser) if not n.startswith("_")] + for name in public_names: + assert "account_progress" not in name.lower(), ( + f"Unexpected serialiser for AccountProgressRef found: '{name}'" + ) + + +# --------------------------------------------------------------------------- +# Viewer projection +# --------------------------------------------------------------------------- + + +class TestResolveCardVisual: + """resolve_card_visual enforces the cosmetic projection rules.""" + + def test_own_collected_card_returns_soldier_key(self): + result = resolve_card_visual( + card_number=42, + owner_seat=1, + viewer_seat=1, + viewer_collection={42, 55}, + ) + assert result == "soldier_42" + + def test_own_uncollected_card_returns_unknown(self): + result = resolve_card_visual( + card_number=42, + owner_seat=1, + viewer_seat=1, + viewer_collection={55}, # 42 not collected + ) + assert result == "unknown_default" + + def test_other_seat_card_always_returns_unknown(self): + result = resolve_card_visual( + card_number=42, + owner_seat=2, + viewer_seat=1, + viewer_collection={42, 55, 2}, # Even if collected, must be unknown + ) + assert result == "unknown_default" + + def test_empty_collection_always_returns_unknown(self): + result = resolve_card_visual( + card_number=1, + owner_seat=0, + viewer_seat=0, + viewer_collection=set(), + ) + assert result == "unknown_default" + + def test_hand_card_visual_collected(self): + result = resolve_hand_card_visual(card_number=10, viewer_collection={10, 20}) + assert result == "soldier_10" + + def test_hand_card_visual_not_collected(self): + result = resolve_hand_card_visual(card_number=10, viewer_collection={20}) + assert result == "unknown_default" + + def test_same_card_different_owners_different_visuals(self): + """ + The same card number renders differently depending on who owns it. + This is the intended multi-client cosmetic behaviour. + """ + card_number = 42 + viewer_seat = 0 + collection = {42} # viewer has collected this card + + # Own card → personalised + own = resolve_card_visual( + card_number, + owner_seat=0, + viewer_seat=viewer_seat, + viewer_collection=collection, + ) + # Other player's card → unknown + other = resolve_card_visual( + card_number, + owner_seat=1, + viewer_seat=viewer_seat, + viewer_collection=collection, + ) + + assert own == "soldier_42" + assert other == "unknown_default" + assert own != other + + +# --------------------------------------------------------------------------- +# Card → public DTO conversion (using a Card domain object) +# --------------------------------------------------------------------------- + + +class TestCardDomainToPublicDto: + """ + When converting a raw Card to a public network DTO, private fields + must be stripped. This tests the conversion pattern PR-04 adapters + will use. + """ + + def test_card_with_all_private_fields_converts_safely(self): + card = _make_card(42) # has name, rank, etc. + # Manual conversion (the adapter will do this in PR-04) + pub = MatchCardPublic( + number=card.number, + danger=card.danger, + owner_seat=0, + ) + d = match_card_to_dict(pub) + assert "is_collected" not in d + assert "name" not in d + assert "rank" not in d + assert "unit" not in d + assert "note" not in d + assert "body_type" not in d + + def test_card_number_and_danger_are_preserved(self): + card = _make_card(66) # danger 7, special card + pub = MatchCardPublic(number=card.number, danger=card.danger, owner_seat=2) + assert pub.number == 66 + assert pub.danger == 7 + + def test_two_cards_with_same_number_but_different_owners_are_not_equal(self): + """owner_seat is part of the public DTO identity.""" + pub_a = MatchCardPublic(number=42, danger=1, owner_seat=0) + pub_b = MatchCardPublic(number=42, danger=1, owner_seat=1) + assert pub_a != pub_b diff --git a/tests/multiplayer/test_room_lobby_scene.py b/tests/multiplayer/test_room_lobby_scene.py new file mode 100644 index 0000000..14e92ed --- /dev/null +++ b/tests/multiplayer/test_room_lobby_scene.py @@ -0,0 +1,250 @@ +""" +Tests for RoomLobbyScene multiplayer bootstrap. + +These protect against two regressions: + - MATCH_START must not force every client into seat 0. + - Initial PHASE_SELECTING / PRIVATE_HAND_STATE messages that arrive in the + same pump batch as MATCH_START must be preserved for the GameScene. +""" + +from __future__ import annotations + +from fall_in.core.game_manager import GameManager +from fall_in.scenes.room_lobby_scene import RoomLobbyScene + + +class _FakeWs: + def __init__(self, messages: list[tuple[str, dict]]) -> None: + self._messages = list(messages) + self.sent: list[tuple[str, dict]] = [] + + def pump(self) -> list[tuple[str, dict]]: + messages = list(self._messages) + self._messages.clear() + return messages + + def send(self, msg_type: str, data: dict | None = None) -> None: + self.sent.append((msg_type, data or {})) + + +class _FakeGameScene: + def __init__(self) -> None: + self.remote_adapter = None + self.card_select_callback = None + self.round_ready_callback = None + self.emote_send_callback = None + self.exit_match_callback = None + self.network_tick_callback = None + + def set_remote_adapter(self, adapter) -> None: + self.remote_adapter = adapter + + def set_card_select_callback(self, callback) -> None: + self.card_select_callback = callback + + def set_round_ready_callback(self, callback) -> None: + self.round_ready_callback = callback + + def set_emote_send_callback(self, callback) -> None: + self.emote_send_callback = callback + + def set_exit_match_callback(self, callback) -> None: + self.exit_match_callback = callback + + def set_network_tick_callback(self, callback) -> None: + self.network_tick_callback = callback + + +class _FakeLoadingScene: + def __init__(self, prev_screen=None, scene_builder=None) -> None: + self.prev_screen = prev_screen + self.scene_builder = scene_builder + + +def _make_room_data() -> dict: + return { + "room_code": "ABCD12", + "host_seat_index": 0, + "participants": [ + { + "seat_index": 0, + "display_name": "HostGuest", + "controller_type": "remote", + "is_ready": True, + }, + { + "seat_index": 1, + "display_name": "GuestTwo", + "controller_type": "remote", + "is_ready": True, + }, + ], + } + + +def _make_public_state_dict() -> dict: + return { + "match_id": "match-123", + "round_number": 1, + "phase": "SELECTING", + "player_order_seats": [1, 0, 2, 3], + "board_rows": [[], [], [], []], + "played_cards_this_turn": [], + "committed_scores": {0: 0, 1: 0, 2: 0, 3: 0}, + "seats": [ + {"seat_index": 0, "controller_type": "remote", "display_name": "HostGuest"}, + {"seat_index": 1, "controller_type": "remote", "display_name": "GuestTwo"}, + {"seat_index": 2, "controller_type": "bot", "display_name": "AI 1"}, + {"seat_index": 3, "controller_type": "bot", "display_name": "AI 2"}, + ], + } + + +def _make_private_state_dict() -> dict: + return { + "seat_index": 1, + "hand": [ + {"number": 7, "danger": 1, "owner_seat": 1}, + {"number": 15, "danger": 2, "owner_seat": 1}, + ], + "has_selected": False, + } + + +def test_launch_game_uses_room_seat_when_match_start_has_no_my_seat(monkeypatch): + ws = _FakeWs([("MATCH_START", {"match_id": "match-123"})]) + scene = RoomLobbyScene( + ws=ws, room_data=_make_room_data(), my_display_name="GuestTwo" + ) + + captured: dict[str, object] = {} + + import fall_in.scenes.game_loading_scene as loading_module + import fall_in.scenes.game_scene as game_scene_module + + monkeypatch.setattr(loading_module, "GameLoadingScene", _FakeLoadingScene) + monkeypatch.setattr(game_scene_module, "GameScene", _FakeGameScene) + monkeypatch.setattr( + GameManager, + "change_scene", + lambda self, new_scene: captured.setdefault("scene", new_scene), + ) + + GameManager().screen = None + + scene._handle_ws_messages() + + loading_scene = captured["scene"] + built_scene = loading_scene.scene_builder() + assert built_scene.remote_adapter.my_seat == 1 + + +def test_match_start_preserves_initial_public_and_private_messages(monkeypatch): + ws = _FakeWs( + [ + ("MATCH_START", {"match_id": "match-123"}), + ("PHASE_SELECTING", _make_public_state_dict()), + ("PRIVATE_HAND_STATE", _make_private_state_dict()), + ] + ) + scene = RoomLobbyScene( + ws=ws, room_data=_make_room_data(), my_display_name="GuestTwo" + ) + + captured: dict[str, object] = {} + + import fall_in.scenes.game_loading_scene as loading_module + import fall_in.scenes.game_scene as game_scene_module + + monkeypatch.setattr(loading_module, "GameLoadingScene", _FakeLoadingScene) + monkeypatch.setattr(game_scene_module, "GameScene", _FakeGameScene) + monkeypatch.setattr( + GameManager, + "change_scene", + lambda self, new_scene: captured.setdefault("scene", new_scene), + ) + + GameManager().screen = None + + scene._handle_ws_messages() + + loading_scene = captured["scene"] + built_scene = loading_scene.scene_builder() + built_scene.network_tick_callback() + + public = built_scene.remote_adapter.get_public_state() + private = built_scene.remote_adapter.get_private_state() + + assert public is not None + assert private is not None + assert public.seats[1].display_name == "GuestTwo" + assert private.seat_index == 1 + assert [card.number for card in private.hand] == [7, 15] + + +def test_network_tick_routes_round_and_match_results(monkeypatch): + ws = _FakeWs( + [ + ("MATCH_START", {"match_id": "match-123"}), + ("ROUND_RESULT", {"round_number": 1, "timeout_seconds": 8}), + ("MATCH_RESULT", {"winner_seat": 1}), + ] + ) + scene = RoomLobbyScene( + ws=ws, room_data=_make_room_data(), my_display_name="GuestTwo" + ) + + captured: dict[str, object] = {} + + import fall_in.scenes.game_loading_scene as loading_module + import fall_in.scenes.game_scene as game_scene_module + + monkeypatch.setattr(loading_module, "GameLoadingScene", _FakeLoadingScene) + monkeypatch.setattr(game_scene_module, "GameScene", _FakeGameScene) + monkeypatch.setattr( + GameManager, + "change_scene", + lambda self, new_scene: captured.setdefault("scene", new_scene), + ) + + GameManager().screen = None + + scene._handle_ws_messages() + + built_scene = captured["scene"].scene_builder() + built_scene.network_tick_callback() + + assert built_scene.remote_adapter.pop_round_result() == { + "round_number": 1, + "timeout_seconds": 8, + } + assert built_scene.remote_adapter.pop_match_result() == {"winner_seat": 1} + + +def test_scene_builder_wires_round_ready_callback(monkeypatch): + ws = _FakeWs([("MATCH_START", {"match_id": "match-123"})]) + scene = RoomLobbyScene( + ws=ws, room_data=_make_room_data(), my_display_name="GuestTwo" + ) + + captured: dict[str, object] = {} + + import fall_in.scenes.game_loading_scene as loading_module + import fall_in.scenes.game_scene as game_scene_module + + monkeypatch.setattr(loading_module, "GameLoadingScene", _FakeLoadingScene) + monkeypatch.setattr(game_scene_module, "GameScene", _FakeGameScene) + monkeypatch.setattr( + GameManager, + "change_scene", + lambda self, new_scene: captured.setdefault("scene", new_scene), + ) + + GameManager().screen = None + + scene._handle_ws_messages() + + built_scene = captured["scene"].scene_builder() + built_scene.round_ready_callback() + + assert ("ROUND_READY", {}) in ws.sent diff --git a/uv.lock b/uv.lock index 39e3c19..a889973 100644 --- a/uv.lock +++ b/uv.lock @@ -90,6 +90,7 @@ name = "fall-in" source = { editable = "." } dependencies = [ { name = "pygame-ce" }, + { name = "websockets" }, ] [package.optional-dependencies] @@ -105,6 +106,7 @@ requires-dist = [ { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.14.14" }, + { name = "websockets", specifier = ">=14.0" }, ] provides-extras = ["dev"] @@ -228,3 +230,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/87/44/90fa543014c45560cae1fffc63ea059fb3575ee6e1cb654562197e5d16fb/ruff-0.14.14-py3-none-win_amd64.whl", hash = "sha256:3dde1435e6b6fe5b66506c1dff67a421d0b7f6488d466f651c07f4cab3bf20fd", size = 11630486, upload-time = "2026-01-22T22:30:10.852Z" }, { url = "https://files.pythonhosted.org/packages/9e/6a/40fee331a52339926a92e17ae748827270b288a35ef4a15c9c8f2ec54715/ruff-0.14.14-py3-none-win_arm64.whl", hash = "sha256:56e6981a98b13a32236a72a8da421d7839221fa308b223b9283312312e5ac76c", size = 10920448, upload-time = "2026-01-22T22:30:15.417Z" }, ] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +]