Add complete ai.card Rust implementation
- Implement complete Rust API server with axum framework - Add database abstraction supporting PostgreSQL and SQLite - Implement comprehensive gacha system with probability calculations - Add JWT authentication with atproto DID integration - Create card master data system with rarities (Normal, Rare, SuperRare, Kira, Unique) - Implement draw history tracking and collection management - Add API endpoints for authentication, card drawing, and collection viewing - Include database migrations for both PostgreSQL and SQLite - Maintain full compatibility with Python API implementation - Add comprehensive documentation and development guide 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
		
							
								
								
									
										32
									
								
								python/api/.env.example
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								python/api/.env.example
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,32 @@ | ||||
| # Application | ||||
| APP_NAME=ai.card | ||||
| APP_VERSION=0.1.0 | ||||
| DEBUG=false | ||||
|  | ||||
| # Database (Local PostgreSQL) | ||||
| DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/aicard | ||||
|  | ||||
| # Database (Supabase - optional) | ||||
| DATABASE_URL_SUPABASE=postgresql+asyncpg://postgres.xxxxxxxxxxxx:password@aws-0-region.pooler.supabase.com:5432/postgres | ||||
| USE_SUPABASE=false | ||||
|  | ||||
| # atproto (optional) | ||||
| ATPROTO_PDS_URL=https://bsky.social | ||||
| ATPROTO_HANDLE=your.handle | ||||
| ATPROTO_PASSWORD=your-app-password | ||||
|  | ||||
| # Card probabilities (in percentage) | ||||
| PROB_NORMAL=99.789 | ||||
| PROB_RARE=0.1 | ||||
| PROB_SUPER_RARE=0.01 | ||||
| PROB_KIRA=0.1 | ||||
| PROB_UNIQUE=0.0001 | ||||
|  | ||||
| # Unique card settings | ||||
| MAX_UNIQUE_CARDS=1000 | ||||
|  | ||||
| # CORS | ||||
| CORS_ORIGINS=["http://localhost:3000", "https://card.syui.ai"] | ||||
|  | ||||
| # Security | ||||
| SECRET_KEY=your-secret-key-change-this-in-production | ||||
							
								
								
									
										24
									
								
								python/api/Dockerfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								python/api/Dockerfile
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | ||||
| FROM python:3.11-slim | ||||
|  | ||||
| WORKDIR /app | ||||
|  | ||||
| # Install system dependencies | ||||
| RUN apt-get update && apt-get install -y \ | ||||
|     gcc \ | ||||
|     postgresql-client \ | ||||
|     && rm -rf /var/lib/apt/lists/* | ||||
|  | ||||
| # Copy requirements first for better caching | ||||
| COPY requirements.txt . | ||||
| RUN pip install --no-cache-dir -r requirements.txt | ||||
|  | ||||
| # Copy application | ||||
| COPY . . | ||||
|  | ||||
| # Create non-root user | ||||
| RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app | ||||
| USER appuser | ||||
|  | ||||
| EXPOSE 8000 | ||||
|  | ||||
| CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] | ||||
							
								
								
									
										47
									
								
								python/api/alembic.ini
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								python/api/alembic.ini
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,47 @@ | ||||
| # Alembic Configuration | ||||
|  | ||||
| [alembic] | ||||
| script_location = alembic | ||||
| prepend_sys_path = . | ||||
| version_path_separator = os | ||||
| sqlalchemy.url = postgresql+asyncpg://postgres:postgres@localhost:5432/aicard | ||||
|  | ||||
| [post_write_hooks] | ||||
| hooks = black | ||||
| black.type = console_scripts | ||||
| black.entrypoint = black | ||||
| black.options = -l 88 | ||||
|  | ||||
| [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 | ||||
							
								
								
									
										82
									
								
								python/api/alembic/env.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								python/api/alembic/env.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,82 @@ | ||||
| """Alembic environment configuration""" | ||||
| import asyncio | ||||
| from logging.config import fileConfig | ||||
| from sqlalchemy import pool | ||||
| from sqlalchemy.engine import Connection | ||||
| from sqlalchemy.ext.asyncio import async_engine_from_config | ||||
| from alembic import context | ||||
| import os | ||||
| import sys | ||||
| from pathlib import Path | ||||
|  | ||||
| # Add parent directory to path | ||||
| sys.path.append(str(Path(__file__).parent.parent)) | ||||
|  | ||||
| from app.db.base import Base | ||||
| from app.db.models import *  # Import all models | ||||
| from app.core.config import settings | ||||
|  | ||||
| # Alembic Config object | ||||
| config = context.config | ||||
|  | ||||
| # Interpret the config file for Python logging | ||||
| if config.config_file_name is not None: | ||||
|     fileConfig(config.config_file_name) | ||||
|  | ||||
| # Model metadata | ||||
| target_metadata = Base.metadata | ||||
|  | ||||
| # Override sqlalchemy.url with environment variable if present | ||||
| if os.getenv("DATABASE_URL"): | ||||
|     config.set_main_option("sqlalchemy.url", os.getenv("DATABASE_URL")) | ||||
| else: | ||||
|     config.set_main_option("sqlalchemy.url", settings.database_url) | ||||
|  | ||||
|  | ||||
| def run_migrations_offline() -> None: | ||||
|     """Run migrations in 'offline' mode.""" | ||||
|     url = config.get_main_option("sqlalchemy.url") | ||||
|     context.configure( | ||||
|         url=url, | ||||
|         target_metadata=target_metadata, | ||||
|         literal_binds=True, | ||||
|         dialect_opts={"paramstyle": "named"}, | ||||
|     ) | ||||
|  | ||||
|     with context.begin_transaction(): | ||||
|         context.run_migrations() | ||||
|  | ||||
|  | ||||
| def do_run_migrations(connection: Connection) -> None: | ||||
|     context.configure(connection=connection, target_metadata=target_metadata) | ||||
|  | ||||
|     with context.begin_transaction(): | ||||
|         context.run_migrations() | ||||
|  | ||||
|  | ||||
| async def run_async_migrations() -> None: | ||||
|     """Run migrations in 'online' mode with async engine.""" | ||||
|     configuration = config.get_section(config.config_ini_section) | ||||
|     configuration["sqlalchemy.url"] = config.get_main_option("sqlalchemy.url") | ||||
|      | ||||
|     connectable = async_engine_from_config( | ||||
|         configuration, | ||||
|         prefix="sqlalchemy.", | ||||
|         poolclass=pool.NullPool, | ||||
|     ) | ||||
|  | ||||
|     async with connectable.connect() as connection: | ||||
|         await connection.run_sync(do_run_migrations) | ||||
|  | ||||
|     await connectable.dispose() | ||||
|  | ||||
|  | ||||
| def run_migrations_online() -> None: | ||||
|     """Run migrations in 'online' mode.""" | ||||
|     asyncio.run(run_async_migrations()) | ||||
|  | ||||
|  | ||||
| if context.is_offline_mode(): | ||||
|     run_migrations_offline() | ||||
| else: | ||||
|     run_migrations_online() | ||||
							
								
								
									
										24
									
								
								python/api/alembic/script.py.mako
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								python/api/alembic/script.py.mako
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | ||||
| """${message} | ||||
|  | ||||
| Revision ID: ${up_revision} | ||||
| Revises: ${down_revision | comma,n} | ||||
| Create Date: ${create_date} | ||||
|  | ||||
| """ | ||||
| from alembic import op | ||||
| import sqlalchemy as sa | ||||
| ${imports if imports else ""} | ||||
|  | ||||
| # revision identifiers, used by Alembic. | ||||
| revision = ${repr(up_revision)} | ||||
| down_revision = ${repr(down_revision)} | ||||
| branch_labels = ${repr(branch_labels)} | ||||
| depends_on = ${repr(depends_on)} | ||||
|  | ||||
|  | ||||
| def upgrade() -> None: | ||||
|     ${upgrades if upgrades else "pass"} | ||||
|  | ||||
|  | ||||
| def downgrade() -> None: | ||||
|     ${downgrades if downgrades else "pass"} | ||||
							
								
								
									
										1
									
								
								python/api/app/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								python/api/app/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| # ai.card API Package | ||||
							
								
								
									
										290
									
								
								python/api/app/ai_provider.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										290
									
								
								python/api/app/ai_provider.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,290 @@ | ||||
| """AI Provider integration for ai.card""" | ||||
|  | ||||
| import os | ||||
| import json | ||||
| from typing import Optional, Dict, List, Any | ||||
| from abc import ABC, abstractmethod | ||||
| import logging | ||||
| import httpx | ||||
| from openai import OpenAI | ||||
| import ollama | ||||
|  | ||||
|  | ||||
| class AIProvider(ABC): | ||||
|     """Base class for AI providers""" | ||||
|      | ||||
|     @abstractmethod | ||||
|     async def chat(self, prompt: str, system_prompt: Optional[str] = None) -> str: | ||||
|         """Generate a response based on prompt""" | ||||
|         pass | ||||
|  | ||||
|  | ||||
| class OllamaProvider(AIProvider): | ||||
|     """Ollama AI provider for ai.card""" | ||||
|      | ||||
|     def __init__(self, model: str = "qwen3", host: Optional[str] = None): | ||||
|         self.model = model | ||||
|         self.host = host or os.getenv('OLLAMA_HOST', 'http://127.0.0.1:11434') | ||||
|         if not self.host.startswith('http'): | ||||
|             self.host = f'http://{self.host}' | ||||
|         self.client = ollama.Client(host=self.host, timeout=60.0) | ||||
|         self.logger = logging.getLogger(__name__) | ||||
|         self.logger.info(f"OllamaProvider initialized with host: {self.host}, model: {self.model}") | ||||
|      | ||||
|     async def chat(self, prompt: str, system_prompt: Optional[str] = None) -> str: | ||||
|         """Simple chat interface""" | ||||
|         try: | ||||
|             messages = [] | ||||
|             if system_prompt: | ||||
|                 messages.append({"role": "system", "content": system_prompt}) | ||||
|             messages.append({"role": "user", "content": prompt}) | ||||
|              | ||||
|             response = self.client.chat( | ||||
|                 model=self.model, | ||||
|                 messages=messages, | ||||
|                 options={ | ||||
|                     "num_predict": 2000, | ||||
|                     "temperature": 0.7, | ||||
|                     "top_p": 0.9, | ||||
|                 }, | ||||
|                 stream=False | ||||
|             ) | ||||
|             return response['message']['content'] | ||||
|         except Exception as e: | ||||
|             self.logger.error(f"Ollama chat failed: {e}") | ||||
|             return "I'm having trouble connecting to the AI model." | ||||
|  | ||||
|  | ||||
| class OpenAIProvider(AIProvider): | ||||
|     """OpenAI API provider with MCP function calling support""" | ||||
|      | ||||
|     def __init__(self, model: str = "gpt-4o-mini", api_key: Optional[str] = None, mcp_client=None): | ||||
|         self.model = model | ||||
|         self.api_key = api_key or os.getenv("OPENAI_API_KEY") | ||||
|         if not self.api_key: | ||||
|             raise ValueError("OpenAI API key not provided") | ||||
|         self.client = OpenAI(api_key=self.api_key) | ||||
|         self.logger = logging.getLogger(__name__) | ||||
|         self.mcp_client = mcp_client | ||||
|      | ||||
|     def _get_mcp_tools(self) -> List[Dict[str, Any]]: | ||||
|         """Generate OpenAI tools from MCP endpoints""" | ||||
|         if not self.mcp_client: | ||||
|             return [] | ||||
|          | ||||
|         tools = [ | ||||
|             { | ||||
|                 "type": "function", | ||||
|                 "function": { | ||||
|                     "name": "get_user_cards", | ||||
|                     "description": "ユーザーが所有するカードの一覧を取得します", | ||||
|                     "parameters": { | ||||
|                         "type": "object", | ||||
|                         "properties": { | ||||
|                             "did": { | ||||
|                                 "type": "string", | ||||
|                                 "description": "ユーザーのDID" | ||||
|                             }, | ||||
|                             "limit": { | ||||
|                                 "type": "integer", | ||||
|                                 "description": "取得するカード数の上限", | ||||
|                                 "default": 10 | ||||
|                             } | ||||
|                         }, | ||||
|                         "required": ["did"] | ||||
|                     } | ||||
|                 } | ||||
|             }, | ||||
|             { | ||||
|                 "type": "function", | ||||
|                 "function": { | ||||
|                     "name": "draw_card", | ||||
|                     "description": "ガチャを引いてカードを取得します", | ||||
|                     "parameters": { | ||||
|                         "type": "object", | ||||
|                         "properties": { | ||||
|                             "did": { | ||||
|                                 "type": "string", | ||||
|                                 "description": "ユーザーのDID" | ||||
|                             }, | ||||
|                             "is_paid": { | ||||
|                                 "type": "boolean", | ||||
|                                 "description": "有料ガチャかどうか", | ||||
|                                 "default": False | ||||
|                             } | ||||
|                         }, | ||||
|                         "required": ["did"] | ||||
|                     } | ||||
|                 } | ||||
|             }, | ||||
|             { | ||||
|                 "type": "function", | ||||
|                 "function": { | ||||
|                     "name": "get_card_details", | ||||
|                     "description": "特定のカードの詳細情報を取得します", | ||||
|                     "parameters": { | ||||
|                         "type": "object", | ||||
|                         "properties": { | ||||
|                             "card_id": { | ||||
|                                 "type": "integer", | ||||
|                                 "description": "カードID" | ||||
|                             } | ||||
|                         }, | ||||
|                         "required": ["card_id"] | ||||
|                     } | ||||
|                 } | ||||
|             }, | ||||
|             { | ||||
|                 "type": "function", | ||||
|                 "function": { | ||||
|                     "name": "analyze_card_collection", | ||||
|                     "description": "ユーザーのカードコレクションを分析します", | ||||
|                     "parameters": { | ||||
|                         "type": "object", | ||||
|                         "properties": { | ||||
|                             "did": { | ||||
|                                 "type": "string", | ||||
|                                 "description": "ユーザーのDID" | ||||
|                             } | ||||
|                         }, | ||||
|                         "required": ["did"] | ||||
|                     } | ||||
|                 } | ||||
|             }, | ||||
|             { | ||||
|                 "type": "function", | ||||
|                 "function": { | ||||
|                     "name": "get_gacha_stats", | ||||
|                     "description": "ガチャの統計情報を取得します", | ||||
|                     "parameters": { | ||||
|                         "type": "object", | ||||
|                         "properties": {} | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         ] | ||||
|         return tools | ||||
|      | ||||
|     async def chat(self, prompt: str, system_prompt: Optional[str] = None) -> str: | ||||
|         """Simple chat interface without MCP tools""" | ||||
|         try: | ||||
|             messages = [] | ||||
|             if system_prompt: | ||||
|                 messages.append({"role": "system", "content": system_prompt}) | ||||
|             messages.append({"role": "user", "content": prompt}) | ||||
|              | ||||
|             response = self.client.chat.completions.create( | ||||
|                 model=self.model, | ||||
|                 messages=messages, | ||||
|                 max_tokens=2000, | ||||
|                 temperature=0.7 | ||||
|             ) | ||||
|             return response.choices[0].message.content | ||||
|         except Exception as e: | ||||
|             self.logger.error(f"OpenAI chat failed: {e}") | ||||
|             return "I'm having trouble connecting to the AI model." | ||||
|      | ||||
|     async def chat_with_mcp(self, prompt: str, did: str = "user") -> str: | ||||
|         """Chat interface with MCP function calling support""" | ||||
|         if not self.mcp_client: | ||||
|             return await self.chat(prompt) | ||||
|          | ||||
|         try: | ||||
|             tools = self._get_mcp_tools() | ||||
|              | ||||
|             response = self.client.chat.completions.create( | ||||
|                 model=self.model, | ||||
|                 messages=[ | ||||
|                     {"role": "system", "content": "あなたはai.cardシステムのアシスタントです。カードゲームの情報、ガチャ、コレクション分析などについて質問されたら、必要に応じてツールを使用して正確な情報を提供してください。"}, | ||||
|                     {"role": "user", "content": prompt} | ||||
|                 ], | ||||
|                 tools=tools, | ||||
|                 tool_choice="auto", | ||||
|                 max_tokens=2000, | ||||
|                 temperature=0.7 | ||||
|             ) | ||||
|              | ||||
|             message = response.choices[0].message | ||||
|              | ||||
|             # Handle tool calls | ||||
|             if message.tool_calls: | ||||
|                 messages = [ | ||||
|                     {"role": "system", "content": "カードゲームシステムのツールを使って正確な情報を提供してください。"}, | ||||
|                     {"role": "user", "content": prompt}, | ||||
|                     { | ||||
|                         "role": "assistant", | ||||
|                         "content": message.content, | ||||
|                         "tool_calls": [tc.model_dump() for tc in message.tool_calls] | ||||
|                     } | ||||
|                 ] | ||||
|                  | ||||
|                 # Execute each tool call | ||||
|                 for tool_call in message.tool_calls: | ||||
|                     tool_result = await self._execute_mcp_tool(tool_call, did) | ||||
|                     messages.append({ | ||||
|                         "role": "tool", | ||||
|                         "tool_call_id": tool_call.id, | ||||
|                         "name": tool_call.function.name, | ||||
|                         "content": json.dumps(tool_result, ensure_ascii=False) | ||||
|                     }) | ||||
|                  | ||||
|                 # Get final response | ||||
|                 final_response = self.client.chat.completions.create( | ||||
|                     model=self.model, | ||||
|                     messages=messages, | ||||
|                     max_tokens=2000, | ||||
|                     temperature=0.7 | ||||
|                 ) | ||||
|                  | ||||
|                 return final_response.choices[0].message.content | ||||
|             else: | ||||
|                 return message.content | ||||
|                  | ||||
|         except Exception as e: | ||||
|             self.logger.error(f"OpenAI MCP chat failed: {e}") | ||||
|             return f"申し訳ありません。エラーが発生しました: {e}" | ||||
|      | ||||
|     async def _execute_mcp_tool(self, tool_call, default_did: str = "user") -> Dict[str, Any]: | ||||
|         """Execute MCP tool call""" | ||||
|         try: | ||||
|             function_name = tool_call.function.name | ||||
|             arguments = json.loads(tool_call.function.arguments) | ||||
|              | ||||
|             if function_name == "get_user_cards": | ||||
|                 did = arguments.get("did", default_did) | ||||
|                 limit = arguments.get("limit", 10) | ||||
|                 return await self.mcp_client.get_user_cards(did, limit) | ||||
|              | ||||
|             elif function_name == "draw_card": | ||||
|                 did = arguments.get("did", default_did) | ||||
|                 is_paid = arguments.get("is_paid", False) | ||||
|                 return await self.mcp_client.draw_card(did, is_paid) | ||||
|              | ||||
|             elif function_name == "get_card_details": | ||||
|                 card_id = arguments.get("card_id") | ||||
|                 return await self.mcp_client.get_card_details(card_id) | ||||
|              | ||||
|             elif function_name == "analyze_card_collection": | ||||
|                 did = arguments.get("did", default_did) | ||||
|                 return await self.mcp_client.analyze_card_collection(did) | ||||
|              | ||||
|             elif function_name == "get_gacha_stats": | ||||
|                 return await self.mcp_client.get_gacha_stats() | ||||
|              | ||||
|             else: | ||||
|                 return {"error": f"未知のツール: {function_name}"} | ||||
|                  | ||||
|         except Exception as e: | ||||
|             return {"error": f"ツール実行エラー: {str(e)}"} | ||||
|  | ||||
|  | ||||
| def create_ai_provider(provider: str = "ollama", model: Optional[str] = None, mcp_client=None, **kwargs) -> AIProvider: | ||||
|     """Factory function to create AI providers""" | ||||
|     if provider == "ollama": | ||||
|         model = model or "qwen3" | ||||
|         return OllamaProvider(model=model, **kwargs) | ||||
|     elif provider == "openai": | ||||
|         model = model or "gpt-4o-mini" | ||||
|         return OpenAIProvider(model=model, mcp_client=mcp_client, **kwargs) | ||||
|     else: | ||||
|         raise ValueError(f"Unknown provider: {provider}") | ||||
							
								
								
									
										1
									
								
								python/api/app/auth/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								python/api/app/auth/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| # Auth Package | ||||
							
								
								
									
										116
									
								
								python/api/app/auth/dependencies.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										116
									
								
								python/api/app/auth/dependencies.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,116 @@ | ||||
| """Authentication dependencies""" | ||||
| from typing import Optional, Annotated | ||||
| from fastapi import Depends, HTTPException, Header, Cookie | ||||
| from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials | ||||
| from jose import JWTError, jwt | ||||
| from datetime import datetime, timedelta | ||||
|  | ||||
| # from app.services.atproto import AtprotoService  # Temporarily disabled | ||||
| from app.core.config import settings | ||||
|  | ||||
|  | ||||
| # Bearer token scheme | ||||
| bearer_scheme = HTTPBearer(auto_error=False) | ||||
|  | ||||
| # JWT settings | ||||
| SECRET_KEY = settings.secret_key if hasattr(settings, 'secret_key') else "your-secret-key" | ||||
| ALGORITHM = "HS256" | ||||
| ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24  # 24 hours | ||||
|  | ||||
|  | ||||
| class AuthUser: | ||||
|     """Authenticated user data""" | ||||
|     def __init__(self, did: str, handle: Optional[str] = None): | ||||
|         self.did = did | ||||
|         self.handle = handle | ||||
|  | ||||
|  | ||||
| def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): | ||||
|     """Create JWT access token""" | ||||
|     to_encode = data.copy() | ||||
|     if expires_delta: | ||||
|         expire = datetime.utcnow() + expires_delta | ||||
|     else: | ||||
|         expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) | ||||
|      | ||||
|     to_encode.update({"exp": expire}) | ||||
|     encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) | ||||
|     return encoded_jwt | ||||
|  | ||||
|  | ||||
| async def verify_token(token: str) -> Optional[AuthUser]: | ||||
|     """Verify JWT token and return user""" | ||||
|     try: | ||||
|         payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) | ||||
|         did: str = payload.get("did") | ||||
|         if did is None: | ||||
|             return None | ||||
|          | ||||
|         handle: Optional[str] = payload.get("handle") | ||||
|         return AuthUser(did=did, handle=handle) | ||||
|          | ||||
|     except JWTError: | ||||
|         return None | ||||
|  | ||||
|  | ||||
| async def get_current_user( | ||||
|     credentials: Optional[HTTPAuthorizationCredentials] = Depends(bearer_scheme), | ||||
|     token_cookie: Optional[str] = Cookie(None, alias="ai_card_token") | ||||
| ) -> Optional[AuthUser]: | ||||
|     """ | ||||
|     Get current user from JWT token | ||||
|     Supports both Bearer token and cookie | ||||
|     """ | ||||
|     token = None | ||||
|      | ||||
|     # Try Bearer token first | ||||
|     if credentials and credentials.credentials: | ||||
|         token = credentials.credentials | ||||
|     # Fall back to cookie | ||||
|     elif token_cookie: | ||||
|         token = token_cookie | ||||
|      | ||||
|     if not token: | ||||
|         return None | ||||
|      | ||||
|     user = await verify_token(token) | ||||
|     return user | ||||
|  | ||||
|  | ||||
| async def require_user( | ||||
|     current_user: Optional[AuthUser] = Depends(get_current_user) | ||||
| ) -> AuthUser: | ||||
|     """Require authenticated user""" | ||||
|     if not current_user: | ||||
|         raise HTTPException( | ||||
|             status_code=401, | ||||
|             detail="Not authenticated", | ||||
|             headers={"WWW-Authenticate": "Bearer"}, | ||||
|         ) | ||||
|     return current_user | ||||
|  | ||||
|  | ||||
| async def get_optional_user( | ||||
|     current_user: Optional[AuthUser] = Depends(get_current_user) | ||||
| ) -> Optional[AuthUser]: | ||||
|     """Get user if authenticated, None otherwise""" | ||||
|     return current_user | ||||
|  | ||||
|  | ||||
| # Temporarily disabled due to atproto dependency issues | ||||
| class AtprotoAuth: | ||||
|     """atproto authentication handler (mock implementation)""" | ||||
|      | ||||
|     def __init__(self): | ||||
|         pass  # self.service = AtprotoService() | ||||
|      | ||||
|     async def authenticate(self, identifier: str, password: str) -> Optional[AuthUser]: | ||||
|         """Mock authentication - always returns test user""" | ||||
|         # Mock implementation for testing | ||||
|         if identifier and password: | ||||
|             return AuthUser(did="did:plc:test123", handle=identifier) | ||||
|         return None | ||||
|      | ||||
|     async def verify_did_ownership(self, did: str, session_string: str) -> bool: | ||||
|         """Mock verification - always returns True for test""" | ||||
|         return True | ||||
							
								
								
									
										1
									
								
								python/api/app/core/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								python/api/app/core/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| # Core Package | ||||
							
								
								
									
										59
									
								
								python/api/app/core/config.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								python/api/app/core/config.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,59 @@ | ||||
| """Application configuration""" | ||||
| import os | ||||
| from pathlib import Path | ||||
| from typing import Optional | ||||
| from pydantic_settings import BaseSettings | ||||
|  | ||||
|  | ||||
| class Settings(BaseSettings): | ||||
|     # Application | ||||
|     app_name: str = "ai.card" | ||||
|     app_version: str = "0.1.0" | ||||
|     debug: bool = False | ||||
|      | ||||
|     # API | ||||
|     api_v1_prefix: str = "/api/v1" | ||||
|      | ||||
|     # Database | ||||
|     database_url: str = "sqlite+aiosqlite:///~/.config/syui/ai/card/aicard.db" | ||||
|     database_url_supabase: Optional[str] = None | ||||
|     use_supabase: bool = False | ||||
|      | ||||
|     # atproto | ||||
|     atproto_pds_url: Optional[str] = None | ||||
|     atproto_handle: Optional[str] = None | ||||
|     atproto_password: Optional[str] = None | ||||
|      | ||||
|     # Card probabilities (in percentage) | ||||
|     prob_normal: float = 99.789 | ||||
|     prob_rare: float = 0.1 | ||||
|     prob_super_rare: float = 0.01 | ||||
|     prob_kira: float = 0.1 | ||||
|     prob_unique: float = 0.0001 | ||||
|      | ||||
|     # Unique card settings | ||||
|     max_unique_cards: int = 1000  # Maximum number of unique cards | ||||
|      | ||||
|     # CORS | ||||
|     cors_origins: list[str] = [ | ||||
|         "http://localhost:3000",  | ||||
|         "http://localhost:5173", | ||||
|         "http://localhost:4173", | ||||
|         "https://card.syui.ai", | ||||
|         "https://xxxcard.syui.ai" | ||||
|     ] | ||||
|      | ||||
|     # Security | ||||
|     secret_key: str = "your-secret-key-change-this-in-production" | ||||
|      | ||||
|     class Config: | ||||
|         # 設定ファイルの優先順位: 1) 環境変数, 2) ~/.config/syui/ai/card/.env, 3) .env | ||||
|         config_dir = Path.home() / ".config" / "syui" / "ai" / "card" | ||||
|         env_file = [ | ||||
|             str(config_dir / ".env"),  # ~/.config/syui/ai/card/.env | ||||
|             ".env"  # カレントディレクトリの.env | ||||
|         ] | ||||
|         env_file_encoding = "utf-8" | ||||
|  | ||||
|  | ||||
| settings = Settings() | ||||
							
								
								
									
										1
									
								
								python/api/app/db/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								python/api/app/db/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| # Database Package | ||||
							
								
								
									
										53
									
								
								python/api/app/db/base.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								python/api/app/db/base.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,53 @@ | ||||
| """Database base configuration""" | ||||
| import os | ||||
| from pathlib import Path | ||||
| from sqlalchemy.ext.declarative import declarative_base | ||||
| from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker | ||||
| from app.core.config import settings | ||||
|  | ||||
| # Create base class for models | ||||
| Base = declarative_base() | ||||
|  | ||||
| # Ensure database directory exists | ||||
| db_path = Path.home() / ".config" / "syui" / "ai" / "card" | ||||
| db_path.mkdir(parents=True, exist_ok=True) | ||||
|  | ||||
| # Select database URL based on configuration | ||||
| database_url = settings.database_url_supabase if settings.use_supabase else settings.database_url | ||||
|  | ||||
| # Expand ~ in database URL | ||||
| if database_url.startswith("sqlite"): | ||||
|     database_url = database_url.replace("~", str(Path.home())) | ||||
|  | ||||
| # Create async engine (SQLite-optimized settings) | ||||
| if "sqlite" in database_url: | ||||
|     engine = create_async_engine( | ||||
|         database_url, | ||||
|         echo=settings.debug, | ||||
|         future=True, | ||||
|         # SQLite-specific optimizations | ||||
|         connect_args={"check_same_thread": False} | ||||
|     ) | ||||
| else: | ||||
|     # PostgreSQL settings (fallback) | ||||
|     engine = create_async_engine( | ||||
|         database_url, | ||||
|         echo=settings.debug, | ||||
|         future=True, | ||||
|         pool_pre_ping=True, | ||||
|         pool_size=5, | ||||
|         max_overflow=10 | ||||
|     ) | ||||
|  | ||||
| # Create async session factory | ||||
| async_session = async_sessionmaker( | ||||
|     engine, | ||||
|     class_=AsyncSession, | ||||
|     expire_on_commit=False | ||||
| ) | ||||
|  | ||||
|  | ||||
| async def get_session() -> AsyncSession: | ||||
|     """Dependency to get database session""" | ||||
|     async with async_session() as session: | ||||
|         yield session | ||||
							
								
								
									
										121
									
								
								python/api/app/db/models.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										121
									
								
								python/api/app/db/models.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,121 @@ | ||||
| """Database models""" | ||||
| from datetime import datetime | ||||
| from sqlalchemy import ( | ||||
|     Column, Integer, String, DateTime, Boolean,  | ||||
|     Float, ForeignKey, UniqueConstraint, Index, | ||||
|     Enum as SQLEnum | ||||
| ) | ||||
| from sqlalchemy.dialects.postgresql import UUID | ||||
| from sqlalchemy.orm import relationship | ||||
| import uuid | ||||
| import enum | ||||
|  | ||||
| from app.db.base import Base | ||||
| from app.models.card import CardRarity | ||||
|  | ||||
|  | ||||
| class User(Base): | ||||
|     """ユーザーモデル""" | ||||
|     __tablename__ = "users" | ||||
|      | ||||
|     id = Column(Integer, primary_key=True, index=True) | ||||
|     did = Column(String, unique=True, nullable=False, index=True) | ||||
|     handle = Column(String, nullable=True) | ||||
|     created_at = Column(DateTime, default=datetime.utcnow) | ||||
|     updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) | ||||
|      | ||||
|     # Relationships | ||||
|     cards = relationship("UserCard", back_populates="owner") | ||||
|     draws = relationship("DrawHistory", back_populates="user") | ||||
|  | ||||
|  | ||||
| class CardMaster(Base): | ||||
|     """カードマスタデータ""" | ||||
|     __tablename__ = "card_master" | ||||
|      | ||||
|     id = Column(Integer, primary_key=True)  # 0-15 | ||||
|     name = Column(String, nullable=False) | ||||
|     base_cp_min = Column(Integer, nullable=False) | ||||
|     base_cp_max = Column(Integer, nullable=False) | ||||
|     color = Column(String, nullable=False) | ||||
|     description = Column(String) | ||||
|      | ||||
|     # Relationships | ||||
|     user_cards = relationship("UserCard", back_populates="card_info") | ||||
|  | ||||
|  | ||||
| class UserCard(Base): | ||||
|     """ユーザー所有カード""" | ||||
|     __tablename__ = "user_cards" | ||||
|      | ||||
|     id = Column(Integer, primary_key=True, index=True) | ||||
|     user_id = Column(Integer, ForeignKey("users.id"), nullable=False) | ||||
|     card_id = Column(Integer, ForeignKey("card_master.id"), nullable=False) | ||||
|     cp = Column(Integer, nullable=False) | ||||
|     status = Column(SQLEnum(CardRarity), nullable=False) | ||||
|     skill = Column(String, nullable=True) | ||||
|     obtained_at = Column(DateTime, default=datetime.utcnow) | ||||
|     is_unique = Column(Boolean, default=False) | ||||
|     unique_id = Column(UUID(as_uuid=True), nullable=True, unique=True) | ||||
|      | ||||
|     # Relationships | ||||
|     owner = relationship("User", back_populates="cards") | ||||
|     card_info = relationship("CardMaster", back_populates="user_cards") | ||||
|      | ||||
|     # Indexes | ||||
|     __table_args__ = ( | ||||
|         Index('idx_user_cards', 'user_id', 'card_id'), | ||||
|     ) | ||||
|  | ||||
|  | ||||
| class UniqueCardRegistry(Base): | ||||
|     """uniqueカードのグローバルレジストリ""" | ||||
|     __tablename__ = "unique_card_registry" | ||||
|      | ||||
|     id = Column(Integer, primary_key=True) | ||||
|     unique_id = Column(UUID(as_uuid=True), default=uuid.uuid4, unique=True, nullable=False) | ||||
|     card_id = Column(Integer, ForeignKey("card_master.id"), nullable=False) | ||||
|     owner_did = Column(String, ForeignKey("users.did"), nullable=False) | ||||
|     obtained_at = Column(DateTime, default=datetime.utcnow) | ||||
|     verse_skill_id = Column(String, nullable=True)  # ai.verse連携用 | ||||
|      | ||||
|     # Unique constraint: 各card_idは1人のみ所有可能 | ||||
|     __table_args__ = ( | ||||
|         UniqueConstraint('card_id', name='unique_card_per_type'), | ||||
|     ) | ||||
|  | ||||
|  | ||||
| class DrawHistory(Base): | ||||
|     """ガチャ履歴""" | ||||
|     __tablename__ = "draw_history" | ||||
|      | ||||
|     id = Column(Integer, primary_key=True, index=True) | ||||
|     user_id = Column(Integer, ForeignKey("users.id"), nullable=False) | ||||
|     card_id = Column(Integer, nullable=False) | ||||
|     status = Column(SQLEnum(CardRarity), nullable=False) | ||||
|     cp = Column(Integer, nullable=False) | ||||
|     is_paid = Column(Boolean, default=False) | ||||
|     drawn_at = Column(DateTime, default=datetime.utcnow) | ||||
|      | ||||
|     # Relationships | ||||
|     user = relationship("User", back_populates="draws") | ||||
|      | ||||
|     # Indexes | ||||
|     __table_args__ = ( | ||||
|         Index('idx_draw_history_user', 'user_id', 'drawn_at'), | ||||
|     ) | ||||
|  | ||||
|  | ||||
| class GachaPool(Base): | ||||
|     """ガチャプール(ピックアップ管理)""" | ||||
|     __tablename__ = "gacha_pools" | ||||
|      | ||||
|     id = Column(Integer, primary_key=True) | ||||
|     name = Column(String, nullable=False) | ||||
|     description = Column(String) | ||||
|     is_active = Column(Boolean, default=True) | ||||
|     start_at = Column(DateTime, nullable=False) | ||||
|     end_at = Column(DateTime, nullable=True) | ||||
|     pickup_card_ids = Column(String)  # JSON array of card IDs | ||||
|     rate_up_multiplier = Column(Float, default=1.0) | ||||
|     created_at = Column(DateTime, default=datetime.utcnow) | ||||
							
								
								
									
										65
									
								
								python/api/app/main.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								python/api/app/main.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,65 @@ | ||||
| """FastAPI application entry point""" | ||||
| import os | ||||
| from fastapi import FastAPI | ||||
| from fastapi.middleware.cors import CORSMiddleware | ||||
|  | ||||
| from app.core.config import settings | ||||
| from app.routes import cards, auth, sync | ||||
| from app.mcp_server import AICardMcpServer | ||||
|  | ||||
| # Initialize MCP server | ||||
| enable_mcp = os.getenv("ENABLE_MCP", "true").lower() == "true" | ||||
| mcp_server = AICardMcpServer(enable_mcp=enable_mcp) | ||||
|  | ||||
| # Get FastAPI app from MCP server | ||||
| app = mcp_server.get_app() | ||||
|  | ||||
| # Update app configuration | ||||
| app.title = settings.app_name | ||||
| app.version = settings.app_version | ||||
| app.docs_url = "/docs" | ||||
| app.redoc_url = "/redoc" | ||||
|  | ||||
| # CORS middleware | ||||
| app.add_middleware( | ||||
|     CORSMiddleware, | ||||
|     allow_origins=settings.cors_origins, | ||||
|     allow_credentials=True, | ||||
|     allow_methods=["*"], | ||||
|     allow_headers=["*"], | ||||
| ) | ||||
|  | ||||
| # Include routers | ||||
| app.include_router(auth.router, prefix=settings.api_v1_prefix) | ||||
| app.include_router(cards.router, prefix=settings.api_v1_prefix) | ||||
| app.include_router(sync.router, prefix=settings.api_v1_prefix) | ||||
|  | ||||
|  | ||||
| @app.get("/") | ||||
| async def root(): | ||||
|     """Root endpoint""" | ||||
|     return { | ||||
|         "app": settings.app_name, | ||||
|         "version": settings.app_version, | ||||
|         "status": "running" | ||||
|     } | ||||
|  | ||||
|  | ||||
| @app.get("/health") | ||||
| async def health_check(): | ||||
|     """Health check endpoint""" | ||||
|     return { | ||||
|         "status": "healthy", | ||||
|         "mcp_enabled": enable_mcp, | ||||
|         "mcp_endpoint": "/mcp" if enable_mcp else None | ||||
|     } | ||||
|  | ||||
|  | ||||
| if __name__ == "__main__": | ||||
|     import uvicorn | ||||
|     uvicorn.run( | ||||
|         "app.main:app", | ||||
|         host="0.0.0.0", | ||||
|         port=8000, | ||||
|         reload=True | ||||
|     ) | ||||
							
								
								
									
										290
									
								
								python/api/app/mcp_server.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										290
									
								
								python/api/app/mcp_server.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,290 @@ | ||||
| """MCP Server for ai.card system""" | ||||
|  | ||||
| from typing import Optional, List, Dict, Any | ||||
| from mcp.server.fastmcp import FastMCP | ||||
| from fastapi import FastAPI, Depends, HTTPException | ||||
| from sqlalchemy.ext.asyncio import AsyncSession | ||||
| from pathlib import Path | ||||
| import logging | ||||
|  | ||||
| from app.core.config import settings | ||||
| from app.db.base import get_session | ||||
| from app.models.card import Card, CardRarity, CardDrawResult | ||||
| from app.repositories.card import CardRepository, UniqueCardRepository | ||||
| from app.repositories.user import UserRepository | ||||
| from app.services.gacha import GachaService | ||||
| # from app.services.card_sync import CardSyncService  # Temporarily disabled | ||||
|  | ||||
| logger = logging.getLogger(__name__) | ||||
|  | ||||
|  | ||||
| class AICardMcpServer: | ||||
|     """MCP Server that exposes ai.card functionality to AI assistants""" | ||||
|      | ||||
|     def __init__(self, enable_mcp: bool = True): | ||||
|         self.enable_mcp = enable_mcp | ||||
|          | ||||
|         # Create FastAPI app | ||||
|         self.app = FastAPI( | ||||
|             title="AI.Card - Card Game System", | ||||
|             description="MCP server for ai.card system", | ||||
|             version=settings.app_version | ||||
|         ) | ||||
|          | ||||
|         # Create MCP server with FastAPI app | ||||
|         self.server = None | ||||
|         if enable_mcp: | ||||
|             self.server = FastMCP("aicard") | ||||
|             self._register_mcp_tools() | ||||
|      | ||||
|     def get_app(self) -> FastAPI: | ||||
|         """Get the FastAPI app instance""" | ||||
|         return self.app | ||||
|      | ||||
|     def _register_mcp_tools(self): | ||||
|         """Register all MCP tools""" | ||||
|          | ||||
|         @self.app.get("/get_user_cards", operation_id="get_user_cards") | ||||
|         async def get_user_cards( | ||||
|             did: str, | ||||
|             limit: int = 10, | ||||
|             session: AsyncSession = Depends(get_session) | ||||
|         ) -> List[Dict[str, Any]]: | ||||
|             """Get all cards owned by a user""" | ||||
|             try: | ||||
|                 user_repo = UserRepository(session) | ||||
|                 card_repo = CardRepository(session) | ||||
|                  | ||||
|                 # Get user | ||||
|                 user = await user_repo.get_by_did(did) | ||||
|                 if not user: | ||||
|                     return [] | ||||
|                  | ||||
|                 # Get user cards | ||||
|                 user_cards = await card_repo.get_user_cards(user.id, limit=limit) | ||||
|                  | ||||
|                 return [ | ||||
|                     { | ||||
|                         "id": card.card_id, | ||||
|                         "cp": card.cp, | ||||
|                         "status": card.status, | ||||
|                         "skill": card.skill, | ||||
|                         "owner_did": did, | ||||
|                         "obtained_at": card.obtained_at.isoformat(), | ||||
|                         "is_unique": card.is_unique, | ||||
|                         "unique_id": str(card.unique_id) if card.unique_id else None | ||||
|                     } | ||||
|                     for card in user_cards | ||||
|                 ] | ||||
|             except Exception as e: | ||||
|                 logger.error(f"Error getting user cards: {e}") | ||||
|                 return [] | ||||
|          | ||||
|         @self.app.post("/draw_card", operation_id="draw_card") | ||||
|         async def draw_card( | ||||
|             did: str, | ||||
|             is_paid: bool = False, | ||||
|             session: AsyncSession = Depends(get_session) | ||||
|         ) -> Dict[str, Any]: | ||||
|             """Draw a new card (gacha) for user""" | ||||
|             try: | ||||
|                 gacha_service = GachaService(session) | ||||
|                  | ||||
|                 # Draw card | ||||
|                 card, is_unique = await gacha_service.draw_card(did, is_paid) | ||||
|                 await session.commit() | ||||
|                  | ||||
|                 return { | ||||
|                     "success": True, | ||||
|                     "card": { | ||||
|                         "id": card.id, | ||||
|                         "cp": card.cp, | ||||
|                         "status": card.status, | ||||
|                         "skill": card.skill, | ||||
|                         "owner_did": card.owner_did, | ||||
|                         "obtained_at": card.obtained_at.isoformat(), | ||||
|                         "is_unique": card.is_unique, | ||||
|                         "unique_id": card.unique_id | ||||
|                     }, | ||||
|                     "is_unique": is_unique, | ||||
|                     "animation_type": "kira" if card.status in [CardRarity.KIRA, CardRarity.UNIQUE] else "normal" | ||||
|                 } | ||||
|             except Exception as e: | ||||
|                 logger.error(f"Error drawing card: {e}") | ||||
|                 await session.rollback() | ||||
|                 return { | ||||
|                     "success": False, | ||||
|                     "error": str(e) | ||||
|                 } | ||||
|          | ||||
|         @self.app.get("/get_card_details", operation_id="get_card_details") | ||||
|         async def get_card_details( | ||||
|             card_id: int, | ||||
|             session: AsyncSession = Depends(get_session) | ||||
|         ) -> Dict[str, Any]: | ||||
|             """Get detailed information about a card type""" | ||||
|             try: | ||||
|                 # Get card info from gacha service | ||||
|                 gacha_service = GachaService(session) | ||||
|                  | ||||
|                 if card_id not in gacha_service.CARD_INFO: | ||||
|                     return {"error": f"Card ID {card_id} not found"} | ||||
|                  | ||||
|                 card_info = gacha_service.CARD_INFO[card_id] | ||||
|                  | ||||
|                 # Get unique card availability | ||||
|                 unique_repo = UniqueCardRepository(session) | ||||
|                 is_unique_available = await unique_repo.is_card_available(card_id) | ||||
|                  | ||||
|                 return { | ||||
|                     "id": card_id, | ||||
|                     "name": card_info["name"], | ||||
|                     "base_cp_range": card_info["base_cp_range"], | ||||
|                     "is_unique_available": is_unique_available, | ||||
|                     "description": f"Card {card_id}: {card_info['name']}" | ||||
|                 } | ||||
|             except Exception as e: | ||||
|                 logger.error(f"Error getting card details: {e}") | ||||
|                 return {"error": str(e)} | ||||
|          | ||||
|         @self.app.post("/sync_cards_atproto", operation_id="sync_cards_atproto") | ||||
|         async def sync_cards_atproto( | ||||
|             did: str, | ||||
|             session: AsyncSession = Depends(get_session) | ||||
|         ) -> Dict[str, str]: | ||||
|             """Sync user's cards with atproto (temporarily disabled)""" | ||||
|             return {"status": "atproto sync temporarily disabled due to dependency issues"} | ||||
|          | ||||
|         @self.app.get("/analyze_card_collection", operation_id="analyze_card_collection") | ||||
|         async def analyze_card_collection( | ||||
|             did: str, | ||||
|             session: AsyncSession = Depends(get_session) | ||||
|         ) -> Dict[str, Any]: | ||||
|             """Analyze user's card collection""" | ||||
|             try: | ||||
|                 user_repo = UserRepository(session) | ||||
|                 card_repo = CardRepository(session) | ||||
|                  | ||||
|                 # Get user | ||||
|                 user = await user_repo.get_by_did(did) | ||||
|                 if not user: | ||||
|                     return { | ||||
|                         "total_cards": 0, | ||||
|                         "rarity_distribution": {}, | ||||
|                         "message": "User not found" | ||||
|                     } | ||||
|                  | ||||
|                 # Get all user cards | ||||
|                 user_cards = await card_repo.get_user_cards(user.id, limit=1000) | ||||
|                  | ||||
|                 if not user_cards: | ||||
|                     return { | ||||
|                         "total_cards": 0, | ||||
|                         "rarity_distribution": {}, | ||||
|                         "message": "No cards found" | ||||
|                     } | ||||
|                  | ||||
|                 # Analyze collection | ||||
|                 rarity_count = {} | ||||
|                 total_cp = 0 | ||||
|                 card_type_count = {} | ||||
|                  | ||||
|                 for card in user_cards: | ||||
|                     # Rarity distribution | ||||
|                     rarity = card.status | ||||
|                     rarity_count[rarity] = rarity_count.get(rarity, 0) + 1 | ||||
|                      | ||||
|                     # Total CP | ||||
|                     total_cp += card.cp | ||||
|                      | ||||
|                     # Card type distribution | ||||
|                     card_type_count[card.card_id] = card_type_count.get(card.card_id, 0) + 1 | ||||
|                  | ||||
|                 # Find strongest card | ||||
|                 strongest_card = max(user_cards, key=lambda x: x.cp) | ||||
|                  | ||||
|                 return { | ||||
|                     "total_cards": len(user_cards), | ||||
|                     "rarity_distribution": rarity_count, | ||||
|                     "card_type_distribution": card_type_count, | ||||
|                     "average_cp": total_cp / len(user_cards) if user_cards else 0, | ||||
|                     "total_cp": total_cp, | ||||
|                     "strongest_card": { | ||||
|                         "id": strongest_card.card_id, | ||||
|                         "cp": strongest_card.cp, | ||||
|                         "status": strongest_card.status, | ||||
|                         "is_unique": strongest_card.is_unique | ||||
|                     }, | ||||
|                     "unique_count": len([c for c in user_cards if c.is_unique]) | ||||
|                 } | ||||
|             except Exception as e: | ||||
|                 logger.error(f"Error analyzing collection: {e}") | ||||
|                 return {"error": str(e)} | ||||
|          | ||||
|         @self.app.get("/get_unique_registry", operation_id="get_unique_registry") | ||||
|         async def get_unique_registry( | ||||
|             session: AsyncSession = Depends(get_session) | ||||
|         ) -> Dict[str, Any]: | ||||
|             """Get all registered unique cards""" | ||||
|             try: | ||||
|                 unique_repo = UniqueCardRepository(session) | ||||
|                  | ||||
|                 # Get all unique cards | ||||
|                 unique_cards = await unique_repo.get_all_unique_cards() | ||||
|                  | ||||
|                 # Get available unique card IDs | ||||
|                 available_ids = await unique_repo.get_available_unique_cards() | ||||
|                  | ||||
|                 return { | ||||
|                     "registered_unique_cards": [ | ||||
|                         { | ||||
|                             "card_id": card.card_id, | ||||
|                             "unique_id": card.unique_id, | ||||
|                             "owner_did": card.owner_did, | ||||
|                             "obtained_at": card.obtained_at.isoformat() | ||||
|                         } | ||||
|                         for card in unique_cards | ||||
|                     ], | ||||
|                     "available_unique_card_ids": available_ids, | ||||
|                     "total_registered": len(unique_cards), | ||||
|                     "total_available": len(available_ids) | ||||
|                 } | ||||
|             except Exception as e: | ||||
|                 logger.error(f"Error getting unique registry: {e}") | ||||
|                 return {"error": str(e)} | ||||
|          | ||||
|         @self.app.get("/get_gacha_stats", operation_id="get_gacha_stats") | ||||
|         async def get_gacha_stats( | ||||
|             session: AsyncSession = Depends(get_session) | ||||
|         ) -> Dict[str, Any]: | ||||
|             """Get gacha system statistics""" | ||||
|             try: | ||||
|                 return { | ||||
|                     "rarity_probabilities": { | ||||
|                         "normal": f"{100 - settings.prob_rare}%", | ||||
|                         "rare": f"{settings.prob_rare - settings.prob_super_rare}%", | ||||
|                         "super_rare": f"{settings.prob_super_rare - settings.prob_kira}%", | ||||
|                         "kira": f"{settings.prob_kira - settings.prob_unique}%", | ||||
|                         "unique": f"{settings.prob_unique}%" | ||||
|                     }, | ||||
|                     "total_card_types": 16, | ||||
|                     "card_names": [info["name"] for info in GachaService.CARD_INFO.values()], | ||||
|                     "system_info": { | ||||
|                         "daily_limit": "1 free draw per day", | ||||
|                         "paid_gacha": "Enhanced probabilities", | ||||
|                         "unique_system": "First-come-first-served globally unique cards" | ||||
|                     } | ||||
|                 } | ||||
|             except Exception as e: | ||||
|                 logger.error(f"Error getting gacha stats: {e}") | ||||
|                 return {"error": str(e)} | ||||
|          | ||||
|         # MCP server will be run separately, not here | ||||
|      | ||||
|     def get_server(self) -> Optional[FastMCP]: | ||||
|         """Get the FastAPI MCP server instance""" | ||||
|         return self.server | ||||
|      | ||||
|     def get_app(self) -> FastAPI: | ||||
|         """Get the FastAPI app instance""" | ||||
|         return self.app | ||||
							
								
								
									
										1
									
								
								python/api/app/models/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								python/api/app/models/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| # Models Package | ||||
							
								
								
									
										57
									
								
								python/api/app/models/card.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								python/api/app/models/card.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,57 @@ | ||||
| """Card data models""" | ||||
| from datetime import datetime | ||||
| from enum import Enum | ||||
| from typing import Optional | ||||
| from pydantic import BaseModel, Field | ||||
|  | ||||
|  | ||||
| class CardRarity(str, Enum): | ||||
|     """カードのレアリティ""" | ||||
|     NORMAL = "normal" | ||||
|     RARE = "rare" | ||||
|     SUPER_RARE = "super_rare" | ||||
|     KIRA = "kira"  # キラカード(0.1%) | ||||
|     UNIQUE = "unique"  # uniqueカード(0.0001%) | ||||
|  | ||||
|  | ||||
| class CardBase(BaseModel): | ||||
|     """カードの基本情報""" | ||||
|     id: int = Field(..., ge=0, le=15, description="カード種類ID (0-15)") | ||||
|     cp: int = Field(..., ge=1, le=999, description="カードパワー") | ||||
|     status: CardRarity = Field(default=CardRarity.NORMAL, description="レアリティ") | ||||
|     skill: Optional[str] = Field(None, description="スキル情報") | ||||
|  | ||||
|  | ||||
| class Card(CardBase): | ||||
|     """所有カード情報""" | ||||
|     owner_did: str = Field(..., description="所有者のatproto DID") | ||||
|     obtained_at: datetime = Field(default_factory=datetime.utcnow, description="取得日時") | ||||
|     is_unique: bool = Field(default=False, description="uniqueカードフラグ") | ||||
|     unique_id: Optional[str] = Field(None, description="unique時のグローバルID") | ||||
|      | ||||
|     class Config: | ||||
|         json_encoders = { | ||||
|             datetime: lambda v: v.isoformat() | ||||
|         } | ||||
|  | ||||
|  | ||||
| class CardDraw(BaseModel): | ||||
|     """カード抽選リクエスト""" | ||||
|     user_did: str = Field(..., description="ユーザーのDID") | ||||
|     is_paid: bool = Field(default=False, description="課金ガチャかどうか") | ||||
|  | ||||
|  | ||||
| class CardDrawResult(BaseModel): | ||||
|     """カード抽選結果""" | ||||
|     card: Card | ||||
|     is_new: bool = Field(..., description="新規取得かどうか") | ||||
|     animation_type: str = Field(..., description="演出タイプ") | ||||
|      | ||||
|      | ||||
| class UniqueCardRegistry(BaseModel): | ||||
|     """uniqueカードの登録情報""" | ||||
|     card_id: int | ||||
|     unique_id: str | ||||
|     owner_did: str | ||||
|     obtained_at: datetime | ||||
|     verse_skill_id: Optional[str] = None | ||||
							
								
								
									
										1
									
								
								python/api/app/repositories/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								python/api/app/repositories/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| # Repositories Package | ||||
							
								
								
									
										65
									
								
								python/api/app/repositories/base.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								python/api/app/repositories/base.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,65 @@ | ||||
| """Base repository class""" | ||||
| from typing import Generic, Type, TypeVar, Optional, List | ||||
| from sqlalchemy.ext.asyncio import AsyncSession | ||||
| from sqlalchemy import select, update, delete | ||||
| from sqlalchemy.orm import selectinload | ||||
|  | ||||
| from app.db.base import Base | ||||
|  | ||||
| ModelType = TypeVar("ModelType", bound=Base) | ||||
|  | ||||
|  | ||||
| class BaseRepository(Generic[ModelType]): | ||||
|     """Base repository with common CRUD operations""" | ||||
|      | ||||
|     def __init__(self, model: Type[ModelType], session: AsyncSession): | ||||
|         self.model = model | ||||
|         self.session = session | ||||
|      | ||||
|     async def create(self, **kwargs) -> ModelType: | ||||
|         """Create a new record""" | ||||
|         instance = self.model(**kwargs) | ||||
|         self.session.add(instance) | ||||
|         await self.session.flush() | ||||
|         return instance | ||||
|      | ||||
|     async def get(self, id: int) -> Optional[ModelType]: | ||||
|         """Get a record by ID""" | ||||
|         result = await self.session.execute( | ||||
|             select(self.model).where(self.model.id == id) | ||||
|         ) | ||||
|         return result.scalar_one_or_none() | ||||
|      | ||||
|     async def get_multi( | ||||
|         self,  | ||||
|         skip: int = 0,  | ||||
|         limit: int = 100, | ||||
|         **filters | ||||
|     ) -> List[ModelType]: | ||||
|         """Get multiple records with pagination""" | ||||
|         query = select(self.model) | ||||
|          | ||||
|         # Apply filters | ||||
|         for key, value in filters.items(): | ||||
|             if hasattr(self.model, key): | ||||
|                 query = query.where(getattr(self.model, key) == value) | ||||
|          | ||||
|         query = query.offset(skip).limit(limit) | ||||
|         result = await self.session.execute(query) | ||||
|         return result.scalars().all() | ||||
|      | ||||
|     async def update(self, id: int, **kwargs) -> Optional[ModelType]: | ||||
|         """Update a record""" | ||||
|         await self.session.execute( | ||||
|             update(self.model) | ||||
|             .where(self.model.id == id) | ||||
|             .values(**kwargs) | ||||
|         ) | ||||
|         return await self.get(id) | ||||
|      | ||||
|     async def delete(self, id: int) -> bool: | ||||
|         """Delete a record""" | ||||
|         result = await self.session.execute( | ||||
|             delete(self.model).where(self.model.id == id) | ||||
|         ) | ||||
|         return result.rowcount > 0 | ||||
							
								
								
									
										181
									
								
								python/api/app/repositories/card.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										181
									
								
								python/api/app/repositories/card.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,181 @@ | ||||
| """Card repository""" | ||||
| from typing import List, Optional | ||||
| from datetime import datetime | ||||
| from sqlalchemy import select, and_, func | ||||
| from sqlalchemy.ext.asyncio import AsyncSession | ||||
| from sqlalchemy.orm import selectinload | ||||
| import uuid | ||||
|  | ||||
| from app.repositories.base import BaseRepository | ||||
| from app.db.models import UserCard, UniqueCardRegistry, CardMaster | ||||
| from app.models.card import CardRarity | ||||
|  | ||||
|  | ||||
| class CardRepository(BaseRepository[UserCard]): | ||||
|     """Card repository with custom methods""" | ||||
|      | ||||
|     def __init__(self, session: AsyncSession): | ||||
|         super().__init__(UserCard, session) | ||||
|      | ||||
|     async def get_user_cards( | ||||
|         self,  | ||||
|         user_id: int, | ||||
|         skip: int = 0, | ||||
|         limit: int = 100 | ||||
|     ) -> List[UserCard]: | ||||
|         """Get all cards for a user""" | ||||
|         result = await self.session.execute( | ||||
|             select(UserCard) | ||||
|             .options(selectinload(UserCard.card_info)) | ||||
|             .where(UserCard.user_id == user_id) | ||||
|             .order_by(UserCard.obtained_at.desc()) | ||||
|             .offset(skip) | ||||
|             .limit(limit) | ||||
|         ) | ||||
|         return result.scalars().all() | ||||
|      | ||||
|     async def count_user_cards(self, user_id: int, card_id: int) -> int: | ||||
|         """Count how many of a specific card a user has""" | ||||
|         result = await self.session.execute( | ||||
|             select(func.count(UserCard.id)) | ||||
|             .where( | ||||
|                 and_( | ||||
|                     UserCard.user_id == user_id, | ||||
|                     UserCard.card_id == card_id | ||||
|                 ) | ||||
|             ) | ||||
|         ) | ||||
|         return result.scalar() or 0 | ||||
|      | ||||
|     async def create_user_card( | ||||
|         self, | ||||
|         user_id: int, | ||||
|         card_id: int, | ||||
|         cp: int, | ||||
|         status: CardRarity, | ||||
|         skill: Optional[str] = None, | ||||
|         is_unique: bool = False | ||||
|     ) -> UserCard: | ||||
|         """Create a new user card""" | ||||
|         unique_id = None | ||||
|         if is_unique: | ||||
|             unique_id = uuid.uuid4() | ||||
|          | ||||
|         card = await self.create( | ||||
|             user_id=user_id, | ||||
|             card_id=card_id, | ||||
|             cp=cp, | ||||
|             status=status, | ||||
|             skill=skill, | ||||
|             is_unique=is_unique, | ||||
|             unique_id=unique_id | ||||
|         ) | ||||
|          | ||||
|         # If unique, register it globally | ||||
|         if is_unique: | ||||
|             await self._register_unique_card(card) | ||||
|          | ||||
|         return card | ||||
|      | ||||
|     async def _register_unique_card(self, card: UserCard): | ||||
|         """Register a unique card in the global registry""" | ||||
|         # Get user DID | ||||
|         user_did = await self.session.execute( | ||||
|             select(User.did).where(User.id == card.user_id) | ||||
|         ) | ||||
|         user_did = user_did.scalar() | ||||
|          | ||||
|         registry = UniqueCardRegistry( | ||||
|             unique_id=card.unique_id, | ||||
|             card_id=card.card_id, | ||||
|             owner_did=user_did, | ||||
|             obtained_at=card.obtained_at | ||||
|         ) | ||||
|         self.session.add(registry) | ||||
|      | ||||
|     async def get_total_card_count(self) -> int: | ||||
|         """Get total number of cards obtained""" | ||||
|         result = await self.session.execute( | ||||
|             select(func.count(UserCard.id)) | ||||
|         ) | ||||
|         return result.scalar() or 0 | ||||
|      | ||||
|     async def get_cards_by_rarity(self) -> dict: | ||||
|         """Get card count by rarity""" | ||||
|         result = await self.session.execute( | ||||
|             select(UserCard.status, func.count(UserCard.id)) | ||||
|             .group_by(UserCard.status) | ||||
|         ) | ||||
|          | ||||
|         cards_by_rarity = {} | ||||
|         for status, count in result.all(): | ||||
|             cards_by_rarity[status.value if hasattr(status, 'value') else str(status)] = count | ||||
|          | ||||
|         return cards_by_rarity | ||||
|      | ||||
|     async def get_recent_cards(self, limit: int = 10) -> List[dict]: | ||||
|         """Get recent card activities""" | ||||
|         result = await self.session.execute( | ||||
|             select( | ||||
|                 UserCard.card_id, | ||||
|                 UserCard.status, | ||||
|                 UserCard.obtained_at, | ||||
|                 User.did.label('owner_did') | ||||
|             ) | ||||
|             .join(User, UserCard.user_id == User.id) | ||||
|             .order_by(UserCard.obtained_at.desc()) | ||||
|             .limit(limit) | ||||
|         ) | ||||
|          | ||||
|         activities = [] | ||||
|         for row in result.all(): | ||||
|             activities.append({ | ||||
|                 'card_id': row.card_id, | ||||
|                 'status': row.status.value if hasattr(row.status, 'value') else str(row.status), | ||||
|                 'obtained_at': row.obtained_at, | ||||
|                 'owner_did': row.owner_did | ||||
|             }) | ||||
|          | ||||
|         return activities | ||||
|  | ||||
|  | ||||
| class UniqueCardRepository(BaseRepository[UniqueCardRegistry]): | ||||
|     """Unique card registry repository""" | ||||
|      | ||||
|     def __init__(self, session: AsyncSession): | ||||
|         super().__init__(UniqueCardRegistry, session) | ||||
|      | ||||
|     async def is_card_available(self, card_id: int) -> bool: | ||||
|         """Check if a unique card is still available""" | ||||
|         result = await self.session.execute( | ||||
|             select(func.count(UniqueCardRegistry.id)) | ||||
|             .where(UniqueCardRegistry.card_id == card_id) | ||||
|         ) | ||||
|         count = result.scalar() or 0 | ||||
|         return count == 0 | ||||
|      | ||||
|     async def get_all_unique_cards(self) -> List[UniqueCardRegistry]: | ||||
|         """Get all registered unique cards""" | ||||
|         result = await self.session.execute( | ||||
|             select(UniqueCardRegistry) | ||||
|             .order_by(UniqueCardRegistry.obtained_at.desc()) | ||||
|         ) | ||||
|         return result.scalars().all() | ||||
|      | ||||
|     async def get_available_unique_cards(self) -> List[int]: | ||||
|         """Get list of card IDs that are still available as unique""" | ||||
|         # Get all card IDs | ||||
|         all_card_ids = set(range(16)) | ||||
|          | ||||
|         # Get taken card IDs | ||||
|         result = await self.session.execute( | ||||
|             select(UniqueCardRegistry.card_id).distinct() | ||||
|         ) | ||||
|         taken_ids = set(result.scalars().all()) | ||||
|          | ||||
|         # Return available IDs | ||||
|         return list(all_card_ids - taken_ids) | ||||
|  | ||||
|  | ||||
| # Import User model here to avoid circular import | ||||
| from app.db.models import User | ||||
							
								
								
									
										38
									
								
								python/api/app/repositories/user.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								python/api/app/repositories/user.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,38 @@ | ||||
| """User repository""" | ||||
| from typing import Optional | ||||
| from sqlalchemy import select | ||||
| from sqlalchemy.ext.asyncio import AsyncSession | ||||
| from sqlalchemy.orm import selectinload | ||||
|  | ||||
| from app.repositories.base import BaseRepository | ||||
| from app.db.models import User | ||||
|  | ||||
|  | ||||
| class UserRepository(BaseRepository[User]): | ||||
|     """User repository with custom methods""" | ||||
|      | ||||
|     def __init__(self, session: AsyncSession): | ||||
|         super().__init__(User, session) | ||||
|      | ||||
|     async def get_by_did(self, did: str) -> Optional[User]: | ||||
|         """Get user by DID""" | ||||
|         result = await self.session.execute( | ||||
|             select(User).where(User.did == did) | ||||
|         ) | ||||
|         return result.scalar_one_or_none() | ||||
|      | ||||
|     async def get_or_create(self, did: str, handle: Optional[str] = None) -> User: | ||||
|         """Get existing user or create new one""" | ||||
|         user = await self.get_by_did(did) | ||||
|         if not user: | ||||
|             user = await self.create(did=did, handle=handle) | ||||
|         return user | ||||
|      | ||||
|     async def get_with_cards(self, user_id: int) -> Optional[User]: | ||||
|         """Get user with all their cards""" | ||||
|         result = await self.session.execute( | ||||
|             select(User) | ||||
|             .options(selectinload(User.cards)) | ||||
|             .where(User.id == user_id) | ||||
|         ) | ||||
|         return result.scalar_one_or_none() | ||||
							
								
								
									
										1
									
								
								python/api/app/routes/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								python/api/app/routes/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| # Routes Package | ||||
							
								
								
									
										133
									
								
								python/api/app/routes/auth.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										133
									
								
								python/api/app/routes/auth.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,133 @@ | ||||
| """Authentication routes""" | ||||
| from datetime import timedelta | ||||
| from typing import Optional | ||||
| from fastapi import APIRouter, HTTPException, Depends, Response | ||||
| from fastapi.responses import JSONResponse | ||||
| from pydantic import BaseModel | ||||
| from sqlalchemy.ext.asyncio import AsyncSession | ||||
|  | ||||
| from app.auth.dependencies import ( | ||||
|     AtprotoAuth,  | ||||
|     create_access_token,  | ||||
|     require_user, | ||||
|     AuthUser, | ||||
|     ACCESS_TOKEN_EXPIRE_MINUTES | ||||
| ) | ||||
| from app.db.base import get_session | ||||
| from app.repositories.user import UserRepository | ||||
| # from app.services.atproto import AtprotoService | ||||
|  | ||||
|  | ||||
| router = APIRouter(prefix="/auth", tags=["auth"]) | ||||
|  | ||||
|  | ||||
| class LoginRequest(BaseModel): | ||||
|     """Login request model""" | ||||
|     identifier: str  # Handle or DID | ||||
|     password: str    # App password | ||||
|  | ||||
|  | ||||
| class LoginResponse(BaseModel): | ||||
|     """Login response model""" | ||||
|     access_token: str | ||||
|     token_type: str = "bearer" | ||||
|     did: str | ||||
|     handle: str | ||||
|  | ||||
|  | ||||
| class VerifyResponse(BaseModel): | ||||
|     """Verify response model""" | ||||
|     did: str | ||||
|     handle: str | ||||
|     valid: bool = True | ||||
|  | ||||
|  | ||||
| @router.post("/login", response_model=LoginResponse) | ||||
| async def login( | ||||
|     request: LoginRequest, | ||||
|     response: Response, | ||||
|     db: AsyncSession = Depends(get_session) | ||||
| ): | ||||
|     """ | ||||
|     Login with atproto credentials | ||||
|      | ||||
|     - **identifier**: atproto handle or DID | ||||
|     - **password**: App password (not main password) | ||||
|     """ | ||||
|     auth = AtprotoAuth() | ||||
|      | ||||
|     # Authenticate with atproto | ||||
|     user = await auth.authenticate(request.identifier, request.password) | ||||
|     if not user: | ||||
|         raise HTTPException( | ||||
|             status_code=401, | ||||
|             detail="Invalid credentials" | ||||
|         ) | ||||
|      | ||||
|     # Create or update user in database | ||||
|     user_repo = UserRepository(db) | ||||
|     await user_repo.get_or_create(did=user.did, handle=user.handle) | ||||
|     await db.commit() | ||||
|      | ||||
|     # Create access token | ||||
|     access_token = create_access_token( | ||||
|         data={"did": user.did, "handle": user.handle}, | ||||
|         expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) | ||||
|     ) | ||||
|      | ||||
|     # Set cookie for web clients | ||||
|     response.set_cookie( | ||||
|         key="ai_card_token", | ||||
|         value=access_token, | ||||
|         httponly=True, | ||||
|         secure=True, | ||||
|         samesite="lax", | ||||
|         max_age=ACCESS_TOKEN_EXPIRE_MINUTES * 60 | ||||
|     ) | ||||
|      | ||||
|     return LoginResponse( | ||||
|         access_token=access_token, | ||||
|         did=user.did, | ||||
|         handle=user.handle or "" | ||||
|     ) | ||||
|  | ||||
|  | ||||
| @router.post("/logout") | ||||
| async def logout(response: Response): | ||||
|     """Logout and clear session""" | ||||
|     response.delete_cookie("ai_card_token") | ||||
|     return {"message": "Logged out successfully"} | ||||
|  | ||||
|  | ||||
| @router.get("/verify", response_model=VerifyResponse) | ||||
| async def verify_session( | ||||
|     current_user: AuthUser = Depends(require_user) | ||||
| ): | ||||
|     """Verify current session is valid""" | ||||
|     return VerifyResponse( | ||||
|         did=current_user.did, | ||||
|         handle=current_user.handle or "", | ||||
|         valid=True | ||||
|     ) | ||||
|  | ||||
|  | ||||
| @router.post("/verify-did") | ||||
| async def verify_did(did: str, handle: Optional[str] = None): | ||||
|     """ | ||||
|     Verify DID is valid (public endpoint) | ||||
|      | ||||
|     - **did**: DID to verify | ||||
|     - **handle**: Optional handle to cross-check | ||||
|     """ | ||||
|     service = AtprotoService() | ||||
|     is_valid = await service.verify_did(did, handle) | ||||
|      | ||||
|     return { | ||||
|         "did": did, | ||||
|         "handle": handle, | ||||
|         "valid": is_valid | ||||
|     } | ||||
|  | ||||
|  | ||||
| # Import Optional here | ||||
| from typing import Optional | ||||
							
								
								
									
										173
									
								
								python/api/app/routes/cards.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										173
									
								
								python/api/app/routes/cards.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,173 @@ | ||||
| """Card-related API routes""" | ||||
| from typing import List, Dict | ||||
| from fastapi import APIRouter, HTTPException, Depends | ||||
| from sqlalchemy.ext.asyncio import AsyncSession | ||||
|  | ||||
| from app.models.card import Card, CardDraw, CardDrawResult | ||||
| from app.services.gacha import GachaService | ||||
| from app.services.card_master import card_master_service | ||||
| from app.repositories.user import UserRepository | ||||
| from app.repositories.card import CardRepository, UniqueCardRepository | ||||
| from app.db.base import get_session | ||||
|  | ||||
| router = APIRouter(prefix="/cards", tags=["cards"]) | ||||
|  | ||||
|  | ||||
| @router.post("/draw", response_model=CardDrawResult) | ||||
| async def draw_card( | ||||
|     draw_request: CardDraw, | ||||
|     db: AsyncSession = Depends(get_session) | ||||
| ): | ||||
|     """ | ||||
|     カードを抽選する | ||||
|      | ||||
|     - **user_did**: ユーザーのatproto DID | ||||
|     - **is_paid**: 課金ガチャかどうか | ||||
|     """ | ||||
|     try: | ||||
|         gacha_service = GachaService(db) | ||||
|         card, is_unique = await gacha_service.draw_card( | ||||
|             user_did=draw_request.user_did, | ||||
|             is_paid=draw_request.is_paid | ||||
|         ) | ||||
|          | ||||
|         # 演出タイプを決定 | ||||
|         animation_type = "normal" | ||||
|         if is_unique: | ||||
|             animation_type = "unique" | ||||
|         elif card.status.value == "kira": | ||||
|             animation_type = "kira" | ||||
|         elif card.status.value in ["super_rare", "rare"]: | ||||
|             animation_type = "rare" | ||||
|          | ||||
|         # 新規取得かチェック | ||||
|         user_repo = UserRepository(db) | ||||
|         card_repo = CardRepository(db) | ||||
|         user = await user_repo.get_by_did(draw_request.user_did) | ||||
|         count = await card_repo.count_user_cards(user.id, card.id) | ||||
|         is_new = count == 1  # 今引いたカードが初めてなら1枚 | ||||
|          | ||||
|         result = CardDrawResult( | ||||
|             card=card, | ||||
|             is_new=is_new, | ||||
|             animation_type=animation_type | ||||
|         ) | ||||
|          | ||||
|         await db.commit() | ||||
|         return result | ||||
|          | ||||
|     except Exception as e: | ||||
|         await db.rollback() | ||||
|         raise HTTPException(status_code=500, detail=str(e)) | ||||
|  | ||||
|  | ||||
| @router.get("/user/{user_did}", response_model=List[Card]) | ||||
| async def get_user_cards( | ||||
|     user_did: str, | ||||
|     skip: int = 0, | ||||
|     limit: int = 100, | ||||
|     db: AsyncSession = Depends(get_session) | ||||
| ): | ||||
|     """ | ||||
|     ユーザーの所有カード一覧を取得 | ||||
|      | ||||
|     - **user_did**: ユーザーのatproto DID | ||||
|     """ | ||||
|     user_repo = UserRepository(db) | ||||
|     card_repo = CardRepository(db) | ||||
|      | ||||
|     user = await user_repo.get_by_did(user_did) | ||||
|     if not user: | ||||
|         raise HTTPException(status_code=404, detail="User not found") | ||||
|      | ||||
|     user_cards = await card_repo.get_user_cards(user.id, skip=skip, limit=limit) | ||||
|      | ||||
|     # Convert to API model | ||||
|     cards = [] | ||||
|     for uc in user_cards: | ||||
|         card = Card( | ||||
|             id=uc.card_id, | ||||
|             cp=uc.cp, | ||||
|             status=uc.status, | ||||
|             skill=uc.skill, | ||||
|             owner_did=user_did, | ||||
|             obtained_at=uc.obtained_at, | ||||
|             is_unique=uc.is_unique, | ||||
|             unique_id=str(uc.unique_id) if uc.unique_id else None | ||||
|         ) | ||||
|         cards.append(card) | ||||
|      | ||||
|     return cards | ||||
|  | ||||
|  | ||||
| @router.get("/unique") | ||||
| async def get_unique_cards(db: AsyncSession = Depends(get_session)): | ||||
|     """ | ||||
|     全てのuniqueカード一覧を取得(所有者情報付き) | ||||
|     """ | ||||
|     unique_repo = UniqueCardRepository(db) | ||||
|     unique_cards = await unique_repo.get_all_unique_cards() | ||||
|      | ||||
|     return [ | ||||
|         { | ||||
|             "card_id": uc.card_id, | ||||
|             "owner_did": uc.owner_did, | ||||
|             "obtained_at": uc.obtained_at, | ||||
|             "unique_id": str(uc.unique_id) | ||||
|         } | ||||
|         for uc in unique_cards | ||||
|     ] | ||||
|  | ||||
|  | ||||
| @router.get("/stats") | ||||
| async def get_gacha_stats(db: AsyncSession = Depends(get_session)): | ||||
|     """ | ||||
|     ガチャ統計情報を取得 | ||||
|     """ | ||||
|     try: | ||||
|         card_repo = CardRepository(db) | ||||
|          | ||||
|         # 総ガチャ実行数 | ||||
|         total_draws = await card_repo.get_total_card_count() | ||||
|          | ||||
|         # レアリティ別カード数 | ||||
|         cards_by_rarity = await card_repo.get_cards_by_rarity() | ||||
|          | ||||
|         # 成功率計算(簡易版) | ||||
|         success_rates = {} | ||||
|         if total_draws > 0: | ||||
|             for rarity, count in cards_by_rarity.items(): | ||||
|                 success_rates[rarity] = count / total_draws | ||||
|          | ||||
|         # 最近の活動(最新10件) | ||||
|         recent_cards = await card_repo.get_recent_cards(limit=10) | ||||
|         recent_activity = [] | ||||
|         for card_data in recent_cards: | ||||
|             recent_activity.append({ | ||||
|                 "timestamp": card_data.get("obtained_at", "").isoformat() if card_data.get("obtained_at") else "", | ||||
|                 "user_did": card_data.get("owner_did", "unknown"), | ||||
|                 "card_name": f"Card #{card_data.get('card_id', 0)}", | ||||
|                 "rarity": card_data.get("status", "common") | ||||
|             }) | ||||
|          | ||||
|         return { | ||||
|             "total_draws": total_draws, | ||||
|             "cards_by_rarity": cards_by_rarity, | ||||
|             "success_rates": success_rates, | ||||
|             "recent_activity": recent_activity | ||||
|         } | ||||
|          | ||||
|     except Exception as e: | ||||
|         raise HTTPException(status_code=500, detail=f"Statistics error: {str(e)}") | ||||
|  | ||||
|  | ||||
| @router.get("/master", response_model=List[Dict]) | ||||
| async def get_card_master_data(): | ||||
|     """ | ||||
|     全カードマスターデータを取得(ai.jsonから) | ||||
|     """ | ||||
|     try: | ||||
|         cards = card_master_service.get_all_cards() | ||||
|         return cards | ||||
|     except Exception as e: | ||||
|         raise HTTPException(status_code=500, detail=f"Failed to get card master data: {str(e)}") | ||||
							
								
								
									
										152
									
								
								python/api/app/routes/sync.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										152
									
								
								python/api/app/routes/sync.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,152 @@ | ||||
| """Synchronization routes for atproto""" | ||||
| from typing import Optional | ||||
| from fastapi import APIRouter, HTTPException, Depends | ||||
| from sqlalchemy.ext.asyncio import AsyncSession | ||||
| from pydantic import BaseModel | ||||
|  | ||||
| from app.auth.dependencies import require_user, AuthUser | ||||
| from app.db.base import get_session | ||||
| from app.services.card_sync import CardSyncService | ||||
| from app.repositories.user import UserRepository | ||||
|  | ||||
|  | ||||
| router = APIRouter(prefix="/sync", tags=["sync"]) | ||||
|  | ||||
|  | ||||
| class SyncRequest(BaseModel): | ||||
|     """Sync request model""" | ||||
|     atproto_session: str  # Session string from atproto login | ||||
|  | ||||
|  | ||||
| class SyncResponse(BaseModel): | ||||
|     """Sync response model""" | ||||
|     synced_to_pds: int = 0 | ||||
|     imported_from_pds: int = 0 | ||||
|     message: str | ||||
|  | ||||
|  | ||||
| @router.post("/cards", response_model=SyncResponse) | ||||
| async def sync_cards( | ||||
|     request: SyncRequest, | ||||
|     current_user: AuthUser = Depends(require_user), | ||||
|     db: AsyncSession = Depends(get_session) | ||||
| ): | ||||
|     """ | ||||
|     Sync cards between database and atproto PDS | ||||
|      | ||||
|     - **atproto_session**: Session string from atproto login | ||||
|     """ | ||||
|     try: | ||||
|         # Get user from database | ||||
|         user_repo = UserRepository(db) | ||||
|         user = await user_repo.get_by_did(current_user.did) | ||||
|         if not user: | ||||
|             raise HTTPException(status_code=404, detail="User not found") | ||||
|          | ||||
|         # Create sync service | ||||
|         sync_service = CardSyncService(db, request.atproto_session) | ||||
|          | ||||
|         # Import from PDS first | ||||
|         imported = await sync_service.import_cards_from_pds(current_user.did) | ||||
|          | ||||
|         # Then sync all cards to PDS | ||||
|         synced = await sync_service.sync_all_user_cards(user.id, current_user.did) | ||||
|          | ||||
|         await db.commit() | ||||
|          | ||||
|         return SyncResponse( | ||||
|             synced_to_pds=synced, | ||||
|             imported_from_pds=imported, | ||||
|             message=f"Successfully synced {synced} cards to PDS and imported {imported} cards from PDS" | ||||
|         ) | ||||
|          | ||||
|     except Exception as e: | ||||
|         await db.rollback() | ||||
|         raise HTTPException(status_code=500, detail=str(e)) | ||||
|  | ||||
|  | ||||
| @router.post("/export") | ||||
| async def export_to_pds( | ||||
|     request: SyncRequest, | ||||
|     current_user: AuthUser = Depends(require_user), | ||||
|     db: AsyncSession = Depends(get_session) | ||||
| ): | ||||
|     """ | ||||
|     Export all cards to atproto PDS | ||||
|      | ||||
|     - **atproto_session**: Session string from atproto login | ||||
|     """ | ||||
|     try: | ||||
|         user_repo = UserRepository(db) | ||||
|         user = await user_repo.get_by_did(current_user.did) | ||||
|         if not user: | ||||
|             raise HTTPException(status_code=404, detail="User not found") | ||||
|          | ||||
|         sync_service = CardSyncService(db, request.atproto_session) | ||||
|         synced = await sync_service.sync_all_user_cards(user.id, current_user.did) | ||||
|          | ||||
|         return { | ||||
|             "exported": synced, | ||||
|             "message": f"Successfully exported {synced} cards to PDS" | ||||
|         } | ||||
|          | ||||
|     except Exception as e: | ||||
|         raise HTTPException(status_code=500, detail=str(e)) | ||||
|  | ||||
|  | ||||
| @router.post("/import") | ||||
| async def import_from_pds( | ||||
|     request: SyncRequest, | ||||
|     current_user: AuthUser = Depends(require_user), | ||||
|     db: AsyncSession = Depends(get_session) | ||||
| ): | ||||
|     """ | ||||
|     Import cards from atproto PDS | ||||
|      | ||||
|     - **atproto_session**: Session string from atproto login | ||||
|     """ | ||||
|     try: | ||||
|         sync_service = CardSyncService(db, request.atproto_session) | ||||
|         imported = await sync_service.import_cards_from_pds(current_user.did) | ||||
|          | ||||
|         await db.commit() | ||||
|          | ||||
|         return { | ||||
|             "imported": imported, | ||||
|             "message": f"Successfully imported {imported} cards from PDS" | ||||
|         } | ||||
|          | ||||
|     except Exception as e: | ||||
|         await db.rollback() | ||||
|         raise HTTPException(status_code=500, detail=str(e)) | ||||
|  | ||||
|  | ||||
| @router.get("/verify/{card_id}") | ||||
| async def verify_card_ownership( | ||||
|     card_id: int, | ||||
|     unique_id: Optional[str] = None, | ||||
|     current_user: AuthUser = Depends(require_user), | ||||
|     db: AsyncSession = Depends(get_session) | ||||
| ): | ||||
|     """ | ||||
|     Verify user owns a specific card | ||||
|      | ||||
|     - **card_id**: Card type ID (0-15) | ||||
|     - **unique_id**: Unique ID for unique cards | ||||
|     """ | ||||
|     sync_service = CardSyncService(db) | ||||
|     owns_card = await sync_service.verify_card_ownership( | ||||
|         current_user.did, | ||||
|         card_id, | ||||
|         unique_id | ||||
|     ) | ||||
|      | ||||
|     return { | ||||
|         "card_id": card_id, | ||||
|         "unique_id": unique_id, | ||||
|         "owned": owns_card | ||||
|     } | ||||
|  | ||||
|  | ||||
| # Import Optional | ||||
| from typing import Optional | ||||
							
								
								
									
										1
									
								
								python/api/app/services/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								python/api/app/services/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| # Services Package | ||||
							
								
								
									
										288
									
								
								python/api/app/services/atproto.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										288
									
								
								python/api/app/services/atproto.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,288 @@ | ||||
| """atproto integration service""" | ||||
| import json | ||||
| from typing import Optional, Dict, Any, List | ||||
| from datetime import datetime | ||||
| import httpx | ||||
| from atproto import Client, SessionString | ||||
| from atproto.exceptions import AtProtocolError | ||||
|  | ||||
| from app.core.config import settings | ||||
| from app.models.card import Card, CardRarity | ||||
|  | ||||
|  | ||||
| class AtprotoService: | ||||
|     """atproto integration service""" | ||||
|      | ||||
|     def __init__(self): | ||||
|         self.client = None | ||||
|         self.session_string = None | ||||
|          | ||||
|     async def login(self, identifier: str, password: str) -> SessionString: | ||||
|         """ | ||||
|         Login to atproto PDS | ||||
|          | ||||
|         Args: | ||||
|             identifier: Handle or DID | ||||
|             password: App password | ||||
|              | ||||
|         Returns: | ||||
|             Session string for future requests | ||||
|         """ | ||||
|         self.client = Client() | ||||
|         try: | ||||
|             self.client.login(identifier, password) | ||||
|             self.session_string = self.client.export_session_string() | ||||
|             return self.session_string | ||||
|         except AtProtocolError as e: | ||||
|             raise Exception(f"Failed to login to atproto: {str(e)}") | ||||
|      | ||||
|     def restore_session(self, session_string: str): | ||||
|         """Restore session from string""" | ||||
|         self.client = Client() | ||||
|         self.client.login_with_session_string(session_string) | ||||
|         self.session_string = session_string | ||||
|      | ||||
|     async def verify_did(self, did: str, handle: Optional[str] = None) -> bool: | ||||
|         """ | ||||
|         Verify DID is valid | ||||
|          | ||||
|         Args: | ||||
|             did: DID to verify | ||||
|             handle: Optional handle to cross-check | ||||
|              | ||||
|         Returns: | ||||
|             True if valid | ||||
|         """ | ||||
|         try: | ||||
|             # Use public API to resolve DID | ||||
|             async with httpx.AsyncClient() as client: | ||||
|                 response = await client.get( | ||||
|                     f"https://plc.directory/{did}", | ||||
|                     follow_redirects=True | ||||
|                 ) | ||||
|                  | ||||
|                 if response.status_code != 200: | ||||
|                     return False | ||||
|                  | ||||
|                 data = response.json() | ||||
|                  | ||||
|                 # Verify handle if provided | ||||
|                 if handle and data.get("alsoKnownAs"): | ||||
|                     expected_handle = f"at://{handle}" | ||||
|                     return expected_handle in data["alsoKnownAs"] | ||||
|                  | ||||
|                 return True | ||||
|                  | ||||
|         except Exception: | ||||
|             return False | ||||
|      | ||||
|     async def get_profile(self, did: str) -> Optional[Dict[str, Any]]: | ||||
|         """Get user profile from atproto""" | ||||
|         if not self.client: | ||||
|             raise Exception("Not logged in") | ||||
|          | ||||
|         try: | ||||
|             profile = self.client.get_profile(did) | ||||
|             return { | ||||
|                 "did": profile.did, | ||||
|                 "handle": profile.handle, | ||||
|                 "display_name": profile.display_name, | ||||
|                 "avatar": profile.avatar, | ||||
|                 "description": profile.description | ||||
|             } | ||||
|         except Exception: | ||||
|             return None | ||||
|      | ||||
|     async def create_card_record( | ||||
|         self,  | ||||
|         did: str, | ||||
|         card: Card, | ||||
|         collection: str = "ai.card.collection" | ||||
|     ) -> str: | ||||
|         """ | ||||
|         Create card record in user's PDS | ||||
|          | ||||
|         Args: | ||||
|             did: User's DID | ||||
|             card: Card data | ||||
|             collection: Collection name (lexicon) | ||||
|              | ||||
|         Returns: | ||||
|             Record URI | ||||
|         """ | ||||
|         if not self.client: | ||||
|             raise Exception("Not logged in") | ||||
|          | ||||
|         # Prepare card data for atproto | ||||
|         record_data = { | ||||
|             "$type": collection, | ||||
|             "cardId": card.id, | ||||
|             "cp": card.cp, | ||||
|             "status": card.status.value, | ||||
|             "skill": card.skill, | ||||
|             "obtainedAt": card.obtained_at.isoformat(), | ||||
|             "isUnique": card.is_unique, | ||||
|             "uniqueId": card.unique_id, | ||||
|             "createdAt": datetime.utcnow().isoformat() | ||||
|         } | ||||
|          | ||||
|         try: | ||||
|             # Create record | ||||
|             response = self.client.com.atproto.repo.create_record( | ||||
|                 repo=did, | ||||
|                 collection=collection, | ||||
|                 record=record_data | ||||
|             ) | ||||
|              | ||||
|             return response.uri | ||||
|              | ||||
|         except AtProtocolError as e: | ||||
|             raise Exception(f"Failed to create card record: {str(e)}") | ||||
|      | ||||
|     async def get_user_cards( | ||||
|         self,  | ||||
|         did: str, | ||||
|         collection: str = "ai.card.collection", | ||||
|         limit: int = 100 | ||||
|     ) -> List[Dict[str, Any]]: | ||||
|         """ | ||||
|         Get user's cards from PDS | ||||
|          | ||||
|         Args: | ||||
|             did: User's DID | ||||
|             collection: Collection name | ||||
|             limit: Maximum records to fetch | ||||
|              | ||||
|         Returns: | ||||
|             List of card records | ||||
|         """ | ||||
|         if not self.client: | ||||
|             raise Exception("Not logged in") | ||||
|          | ||||
|         try: | ||||
|             # List records | ||||
|             response = self.client.com.atproto.repo.list_records( | ||||
|                 repo=did, | ||||
|                 collection=collection, | ||||
|                 limit=limit | ||||
|             ) | ||||
|              | ||||
|             cards = [] | ||||
|             for record in response.records: | ||||
|                 card_data = record.value | ||||
|                 card_data["uri"] = record.uri | ||||
|                 card_data["cid"] = record.cid | ||||
|                 cards.append(card_data) | ||||
|              | ||||
|             return cards | ||||
|              | ||||
|         except AtProtocolError: | ||||
|             # Collection might not exist yet | ||||
|             return [] | ||||
|      | ||||
|     async def delete_card_record(self, did: str, record_uri: str): | ||||
|         """Delete a card record from PDS""" | ||||
|         if not self.client: | ||||
|             raise Exception("Not logged in") | ||||
|          | ||||
|         try: | ||||
|             # Parse collection and rkey from URI | ||||
|             # Format: at://did/collection/rkey | ||||
|             parts = record_uri.split("/") | ||||
|             if len(parts) < 5: | ||||
|                 raise ValueError("Invalid record URI") | ||||
|              | ||||
|             collection = parts[3] | ||||
|             rkey = parts[4] | ||||
|              | ||||
|             self.client.com.atproto.repo.delete_record( | ||||
|                 repo=did, | ||||
|                 collection=collection, | ||||
|                 rkey=rkey | ||||
|             ) | ||||
|              | ||||
|         except AtProtocolError as e: | ||||
|             raise Exception(f"Failed to delete record: {str(e)}") | ||||
|      | ||||
|     async def create_oauth_session(self, code: str, redirect_uri: str) -> Dict[str, Any]: | ||||
|         """ | ||||
|         Handle OAuth callback and create session | ||||
|          | ||||
|         Args: | ||||
|             code: Authorization code | ||||
|             redirect_uri: Redirect URI used in authorization | ||||
|              | ||||
|         Returns: | ||||
|             Session data including DID and access token | ||||
|         """ | ||||
|         # TODO: Implement when atproto OAuth is available | ||||
|         raise NotImplementedError("OAuth support is not yet available in atproto") | ||||
|  | ||||
|  | ||||
| class CardLexicon: | ||||
|     """Card collection lexicon definition""" | ||||
|      | ||||
|     LEXICON_ID = "ai.card.collection" | ||||
|      | ||||
|     @staticmethod | ||||
|     def get_lexicon() -> Dict[str, Any]: | ||||
|         """Get lexicon definition for card collection""" | ||||
|         return { | ||||
|             "lexicon": 1, | ||||
|             "id": CardLexicon.LEXICON_ID, | ||||
|             "defs": { | ||||
|                 "main": { | ||||
|                     "type": "record", | ||||
|                     "description": "A collectible card", | ||||
|                     "key": "tid", | ||||
|                     "record": { | ||||
|                         "type": "object", | ||||
|                         "required": ["cardId", "cp", "status", "obtainedAt", "createdAt"], | ||||
|                         "properties": { | ||||
|                             "cardId": { | ||||
|                                 "type": "integer", | ||||
|                                 "description": "Card type ID (0-15)", | ||||
|                                 "minimum": 0, | ||||
|                                 "maximum": 15 | ||||
|                             }, | ||||
|                             "cp": { | ||||
|                                 "type": "integer", | ||||
|                                 "description": "Card power", | ||||
|                                 "minimum": 1, | ||||
|                                 "maximum": 999 | ||||
|                             }, | ||||
|                             "status": { | ||||
|                                 "type": "string", | ||||
|                                 "description": "Card rarity", | ||||
|                                 "enum": ["normal", "rare", "super_rare", "kira", "unique"] | ||||
|                             }, | ||||
|                             "skill": { | ||||
|                                 "type": "string", | ||||
|                                 "description": "Card skill", | ||||
|                                 "maxLength": 1000 | ||||
|                             }, | ||||
|                             "obtainedAt": { | ||||
|                                 "type": "string", | ||||
|                                 "format": "datetime", | ||||
|                                 "description": "When the card was obtained" | ||||
|                             }, | ||||
|                             "isUnique": { | ||||
|                                 "type": "boolean", | ||||
|                                 "description": "Whether this is a unique card", | ||||
|                                 "default": False | ||||
|                             }, | ||||
|                             "uniqueId": { | ||||
|                                 "type": "string", | ||||
|                                 "description": "Global unique identifier", | ||||
|                                 "format": "uuid" | ||||
|                             }, | ||||
|                             "createdAt": { | ||||
|                                 "type": "string", | ||||
|                                 "format": "datetime", | ||||
|                                 "description": "Record creation time" | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
							
								
								
									
										142
									
								
								python/api/app/services/card_master.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										142
									
								
								python/api/app/services/card_master.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,142 @@ | ||||
| """ | ||||
| Card master data fetcher from external ai.json | ||||
| """ | ||||
| import httpx | ||||
| import json | ||||
| from typing import Dict, List, Optional | ||||
| from functools import lru_cache | ||||
| import logging | ||||
|  | ||||
| logger = logging.getLogger(__name__) | ||||
|  | ||||
| CARD_MASTER_URL = "https://git.syui.ai/ai/ai/raw/branch/main/ai.json" | ||||
|  | ||||
| # Default CP ranges for cards (matching existing gacha.py values) | ||||
| DEFAULT_CP_RANGES = { | ||||
|     0: (10, 100), | ||||
|     1: (20, 120), | ||||
|     2: (30, 130), | ||||
|     3: (40, 140), | ||||
|     4: (50, 150), | ||||
|     5: (25, 125), | ||||
|     6: (15, 115), | ||||
|     7: (60, 160), | ||||
|     8: (80, 180), | ||||
|     9: (70, 170), | ||||
|     10: (90, 190), | ||||
|     11: (35, 135), | ||||
|     12: (65, 165), | ||||
|     13: (75, 175), | ||||
|     14: (100, 200), | ||||
|     15: (85, 185), | ||||
|     135: (95, 195),  # world card | ||||
| } | ||||
|  | ||||
|  | ||||
| class CardMasterService: | ||||
|     def __init__(self): | ||||
|         self._cache = None | ||||
|         self._cache_time = 0 | ||||
|         self._cache_duration = 3600  # 1 hour cache | ||||
|      | ||||
|     @lru_cache(maxsize=1) | ||||
|     def fetch_card_master_data(self) -> Optional[Dict]: | ||||
|         """Fetch card master data from external source""" | ||||
|         try: | ||||
|             response = httpx.get(CARD_MASTER_URL, timeout=10.0) | ||||
|             response.raise_for_status() | ||||
|             data = response.json() | ||||
|             return data | ||||
|         except Exception as e: | ||||
|             logger.error(f"Failed to fetch card master data: {e}") | ||||
|             return None | ||||
|      | ||||
|     def get_card_info(self) -> Dict[int, Dict]: | ||||
|         """Get card information in the format expected by gacha service""" | ||||
|         master_data = self.fetch_card_master_data() | ||||
|          | ||||
|         if not master_data: | ||||
|             # Fallback to hardcoded data | ||||
|             return self._get_fallback_card_info() | ||||
|          | ||||
|         try: | ||||
|             cards = master_data.get("ai", {}).get("card", {}).get("cards", []) | ||||
|             card_info = {} | ||||
|              | ||||
|             for card in cards: | ||||
|                 card_id = card.get("id") | ||||
|                 if card_id is not None: | ||||
|                     # Use name from JSON, fallback to English name | ||||
|                     name = card.get("name", f"card_{card_id}") | ||||
|                      | ||||
|                     # Get CP range from defaults | ||||
|                     cp_range = DEFAULT_CP_RANGES.get(card_id, (50, 150)) | ||||
|                      | ||||
|                     card_info[card_id] = { | ||||
|                         "name": name, | ||||
|                         "base_cp_range": cp_range, | ||||
|                         "ja_name": card.get("lang", {}).get("ja", {}).get("name", name), | ||||
|                         "description": card.get("lang", {}).get("ja", {}).get("text", "") | ||||
|                     } | ||||
|              | ||||
|             return card_info | ||||
|              | ||||
|         except Exception as e: | ||||
|             logger.error(f"Failed to parse card master data: {e}") | ||||
|             return self._get_fallback_card_info() | ||||
|      | ||||
|     def _get_fallback_card_info(self) -> Dict[int, Dict]: | ||||
|         """Fallback card info if external source fails""" | ||||
|         return { | ||||
|             0: {"name": "ai", "base_cp_range": (10, 100)}, | ||||
|             1: {"name": "dream", "base_cp_range": (20, 120)}, | ||||
|             2: {"name": "radiance", "base_cp_range": (30, 130)}, | ||||
|             3: {"name": "neutron", "base_cp_range": (40, 140)}, | ||||
|             4: {"name": "sun", "base_cp_range": (50, 150)}, | ||||
|             5: {"name": "night", "base_cp_range": (25, 125)}, | ||||
|             6: {"name": "snow", "base_cp_range": (15, 115)}, | ||||
|             7: {"name": "thunder", "base_cp_range": (60, 160)}, | ||||
|             8: {"name": "ultimate", "base_cp_range": (80, 180)}, | ||||
|             9: {"name": "sword", "base_cp_range": (70, 170)}, | ||||
|             10: {"name": "destruction", "base_cp_range": (90, 190)}, | ||||
|             11: {"name": "earth", "base_cp_range": (35, 135)}, | ||||
|             12: {"name": "galaxy", "base_cp_range": (65, 165)}, | ||||
|             13: {"name": "create", "base_cp_range": (75, 175)}, | ||||
|             14: {"name": "supernova", "base_cp_range": (100, 200)}, | ||||
|             15: {"name": "world", "base_cp_range": (85, 185)}, | ||||
|         } | ||||
|      | ||||
|     def get_all_cards(self) -> List[Dict]: | ||||
|         """Get all cards with full information""" | ||||
|         master_data = self.fetch_card_master_data() | ||||
|          | ||||
|         if not master_data: | ||||
|             return [] | ||||
|          | ||||
|         try: | ||||
|             cards = master_data.get("ai", {}).get("card", {}).get("cards", []) | ||||
|             result = [] | ||||
|              | ||||
|             for card in cards: | ||||
|                 card_id = card.get("id") | ||||
|                 if card_id is not None: | ||||
|                     cp_range = DEFAULT_CP_RANGES.get(card_id, (50, 150)) | ||||
|                      | ||||
|                     result.append({ | ||||
|                         "id": card_id, | ||||
|                         "name": card.get("name", f"card_{card_id}"), | ||||
|                         "ja_name": card.get("lang", {}).get("ja", {}).get("name", ""), | ||||
|                         "description": card.get("lang", {}).get("ja", {}).get("text", ""), | ||||
|                         "base_cp_min": cp_range[0], | ||||
|                         "base_cp_max": cp_range[1] | ||||
|                     }) | ||||
|              | ||||
|             return result | ||||
|              | ||||
|         except Exception as e: | ||||
|             logger.error(f"Failed to get all cards: {e}") | ||||
|             return [] | ||||
|  | ||||
|  | ||||
| # Singleton instance | ||||
| card_master_service = CardMasterService() | ||||
							
								
								
									
										184
									
								
								python/api/app/services/card_sync.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										184
									
								
								python/api/app/services/card_sync.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,184 @@ | ||||
| """Card synchronization service for atproto""" | ||||
| from typing import List, Dict, Any, Optional | ||||
| from datetime import datetime | ||||
| from sqlalchemy.ext.asyncio import AsyncSession | ||||
|  | ||||
| # from app.services.atproto import AtprotoService, CardLexicon | ||||
| from app.repositories.card import CardRepository | ||||
| from app.repositories.user import UserRepository | ||||
| from app.models.card import Card as CardModel | ||||
| from app.db.models import UserCard | ||||
| from app.core.config import settings | ||||
|  | ||||
|  | ||||
| class CardSyncService: | ||||
|     """Service for syncing cards between database and atproto PDS""" | ||||
|      | ||||
|     def __init__(self, session: AsyncSession, atproto_session: Optional[str] = None): | ||||
|         self.db_session = session | ||||
|         self.card_repo = CardRepository(session) | ||||
|         self.user_repo = UserRepository(session) | ||||
|         self.atproto_service = AtprotoService() | ||||
|          | ||||
|         # Restore atproto session if provided | ||||
|         if atproto_session: | ||||
|             self.atproto_service.restore_session(atproto_session) | ||||
|      | ||||
|     async def sync_card_to_pds(self, user_card: UserCard, user_did: str) -> Optional[str]: | ||||
|         """ | ||||
|         Sync a single card to user's PDS | ||||
|          | ||||
|         Args: | ||||
|             user_card: Card from database | ||||
|             user_did: User's DID | ||||
|              | ||||
|         Returns: | ||||
|             Record URI if successful | ||||
|         """ | ||||
|         if not settings.atproto_handle or not settings.atproto_password: | ||||
|             # Skip if atproto credentials not configured | ||||
|             return None | ||||
|          | ||||
|         try: | ||||
|             # Login if not already | ||||
|             if not self.atproto_service.client: | ||||
|                 await self.atproto_service.login( | ||||
|                     settings.atproto_handle, | ||||
|                     settings.atproto_password | ||||
|                 ) | ||||
|              | ||||
|             # Convert to API model | ||||
|             card_model = CardModel( | ||||
|                 id=user_card.card_id, | ||||
|                 cp=user_card.cp, | ||||
|                 status=user_card.status, | ||||
|                 skill=user_card.skill, | ||||
|                 owner_did=user_did, | ||||
|                 obtained_at=user_card.obtained_at, | ||||
|                 is_unique=user_card.is_unique, | ||||
|                 unique_id=str(user_card.unique_id) if user_card.unique_id else None | ||||
|             ) | ||||
|              | ||||
|             # Create record in PDS | ||||
|             uri = await self.atproto_service.create_card_record( | ||||
|                 did=user_did, | ||||
|                 card=card_model, | ||||
|                 collection=CardLexicon.LEXICON_ID | ||||
|             ) | ||||
|              | ||||
|             # Store URI in database for future reference | ||||
|             # (You might want to add a field to UserCard model for this) | ||||
|              | ||||
|             return uri | ||||
|              | ||||
|         except Exception as e: | ||||
|             print(f"Failed to sync card to PDS: {e}") | ||||
|             return None | ||||
|      | ||||
|     async def sync_all_user_cards(self, user_id: int, user_did: str) -> int: | ||||
|         """ | ||||
|         Sync all user's cards to PDS | ||||
|          | ||||
|         Args: | ||||
|             user_id: Database user ID | ||||
|             user_did: User's DID | ||||
|              | ||||
|         Returns: | ||||
|             Number of cards synced | ||||
|         """ | ||||
|         # Get all user cards from database | ||||
|         user_cards = await self.card_repo.get_user_cards(user_id) | ||||
|          | ||||
|         synced_count = 0 | ||||
|         for card in user_cards: | ||||
|             uri = await self.sync_card_to_pds(card, user_did) | ||||
|             if uri: | ||||
|                 synced_count += 1 | ||||
|          | ||||
|         return synced_count | ||||
|      | ||||
|     async def import_cards_from_pds(self, user_did: str) -> int: | ||||
|         """ | ||||
|         Import cards from user's PDS to database | ||||
|          | ||||
|         Args: | ||||
|             user_did: User's DID | ||||
|              | ||||
|         Returns: | ||||
|             Number of cards imported | ||||
|         """ | ||||
|         if not self.atproto_service.client: | ||||
|             return 0 | ||||
|          | ||||
|         # Get user from database | ||||
|         user = await self.user_repo.get_by_did(user_did) | ||||
|         if not user: | ||||
|             return 0 | ||||
|          | ||||
|         # Get cards from PDS | ||||
|         pds_cards = await self.atproto_service.get_user_cards( | ||||
|             did=user_did, | ||||
|             collection=CardLexicon.LEXICON_ID | ||||
|         ) | ||||
|          | ||||
|         imported_count = 0 | ||||
|         for pds_card in pds_cards: | ||||
|             # Check if card already exists | ||||
|             existing_count = await self.card_repo.count_user_cards( | ||||
|                 user.id,  | ||||
|                 pds_card.get("cardId") | ||||
|             ) | ||||
|              | ||||
|             if existing_count == 0: | ||||
|                 # Import card | ||||
|                 await self.card_repo.create_user_card( | ||||
|                     user_id=user.id, | ||||
|                     card_id=pds_card.get("cardId"), | ||||
|                     cp=pds_card.get("cp"), | ||||
|                     status=pds_card.get("status"), | ||||
|                     skill=pds_card.get("skill"), | ||||
|                     is_unique=pds_card.get("isUnique", False) | ||||
|                 ) | ||||
|                 imported_count += 1 | ||||
|          | ||||
|         await self.db_session.commit() | ||||
|         return imported_count | ||||
|      | ||||
|     async def verify_card_ownership( | ||||
|         self,  | ||||
|         user_did: str,  | ||||
|         card_id: int, | ||||
|         unique_id: Optional[str] = None | ||||
|     ) -> bool: | ||||
|         """ | ||||
|         Verify user owns a card by checking both database and PDS | ||||
|          | ||||
|         Args: | ||||
|             user_did: User's DID | ||||
|             card_id: Card type ID | ||||
|             unique_id: Unique ID for unique cards | ||||
|              | ||||
|         Returns: | ||||
|             True if user owns the card | ||||
|         """ | ||||
|         # Check database first | ||||
|         user = await self.user_repo.get_by_did(user_did) | ||||
|         if user: | ||||
|             user_cards = await self.card_repo.get_user_cards(user.id) | ||||
|             for card in user_cards: | ||||
|                 if card.card_id == card_id: | ||||
|                     if not unique_id or str(card.unique_id) == unique_id: | ||||
|                         return True | ||||
|          | ||||
|         # Check PDS if configured | ||||
|         if self.atproto_service.client: | ||||
|             try: | ||||
|                 pds_cards = await self.atproto_service.get_user_cards(user_did) | ||||
|                 for card in pds_cards: | ||||
|                     if card.get("cardId") == card_id: | ||||
|                         if not unique_id or card.get("uniqueId") == unique_id: | ||||
|                             return True | ||||
|             except Exception: | ||||
|                 pass | ||||
|          | ||||
|         return False | ||||
							
								
								
									
										164
									
								
								python/api/app/services/gacha.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										164
									
								
								python/api/app/services/gacha.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,164 @@ | ||||
| """ガチャシステムのロジック""" | ||||
| import random | ||||
| import uuid | ||||
| from datetime import datetime | ||||
| from typing import Optional, Tuple | ||||
| from sqlalchemy.ext.asyncio import AsyncSession | ||||
|  | ||||
| from app.core.config import settings | ||||
| from app.models.card import Card, CardRarity | ||||
| from app.repositories.user import UserRepository | ||||
| from app.repositories.card import CardRepository, UniqueCardRepository | ||||
| from app.db.models import DrawHistory | ||||
| from app.services.card_master import card_master_service | ||||
|  | ||||
|  | ||||
| class GachaService: | ||||
|     """ガチャシステムのサービスクラス""" | ||||
|      | ||||
|     def __init__(self, session: AsyncSession): | ||||
|         self.session = session | ||||
|         self.user_repo = UserRepository(session) | ||||
|         self.card_repo = CardRepository(session) | ||||
|         self.unique_repo = UniqueCardRepository(session) | ||||
|         # Load card info from external source | ||||
|         self.CARD_INFO = card_master_service.get_card_info() | ||||
|      | ||||
|     async def draw_card(self, user_did: str, is_paid: bool = False) -> Tuple[Card, bool]: | ||||
|         """ | ||||
|         カードを抽選する | ||||
|          | ||||
|         Args: | ||||
|             user_did: ユーザーのDID | ||||
|             is_paid: 課金ガチャかどうか | ||||
|              | ||||
|         Returns: | ||||
|             (Card, is_unique): 抽選されたカードとuniqueかどうか | ||||
|         """ | ||||
|         # Get or create user | ||||
|         user = await self.user_repo.get_or_create(user_did) | ||||
|         # レアリティ抽選 | ||||
|         rarity = self._determine_rarity(is_paid) | ||||
|          | ||||
|         # カード種類を選択 | ||||
|         card_id = self._select_card_id(rarity) | ||||
|          | ||||
|         # CPを決定 | ||||
|         cp = self._calculate_cp(card_id, rarity) | ||||
|          | ||||
|         # uniqueカードチェック | ||||
|         is_unique = False | ||||
|          | ||||
|         if rarity == CardRarity.UNIQUE: | ||||
|             # uniqueカードの場合、利用可能かチェック | ||||
|             is_available = await self.unique_repo.is_card_available(card_id) | ||||
|             if not is_available: | ||||
|                 # 利用不可の場合はキラカードに変更 | ||||
|                 rarity = CardRarity.KIRA | ||||
|             else: | ||||
|                 is_unique = True | ||||
|          | ||||
|         # データベースにカードを保存 | ||||
|         user_card = await self.card_repo.create_user_card( | ||||
|             user_id=user.id, | ||||
|             card_id=card_id, | ||||
|             cp=cp, | ||||
|             status=rarity, | ||||
|             skill=self._get_skill_for_card(card_id, rarity), | ||||
|             is_unique=is_unique | ||||
|         ) | ||||
|          | ||||
|         # 抽選履歴を保存 | ||||
|         draw_history = DrawHistory( | ||||
|             user_id=user.id, | ||||
|             card_id=card_id, | ||||
|             status=rarity, | ||||
|             cp=cp, | ||||
|             is_paid=is_paid | ||||
|         ) | ||||
|         self.session.add(draw_history) | ||||
|          | ||||
|         # API用のCardモデルに変換 | ||||
|         card = Card( | ||||
|             id=card_id, | ||||
|             cp=cp, | ||||
|             status=rarity, | ||||
|             skill=user_card.skill, | ||||
|             owner_did=user_did, | ||||
|             obtained_at=user_card.obtained_at, | ||||
|             is_unique=is_unique, | ||||
|             unique_id=str(user_card.unique_id) if user_card.unique_id else None | ||||
|         ) | ||||
|          | ||||
|         # atproto PDSに同期(非同期で実行) | ||||
|         try: | ||||
|             from app.services.card_sync import CardSyncService | ||||
|             sync_service = CardSyncService(self.session) | ||||
|             await sync_service.sync_card_to_pds(user_card, user_did) | ||||
|         except Exception: | ||||
|             # 同期失敗してもガチャは成功とする | ||||
|             pass | ||||
|          | ||||
|         return card, is_unique | ||||
|      | ||||
|     def _determine_rarity(self, is_paid: bool) -> CardRarity: | ||||
|         """レアリティを抽選する""" | ||||
|         rand = random.random() * 100 | ||||
|          | ||||
|         if is_paid: | ||||
|             # 課金ガチャは確率アップ | ||||
|             if rand < settings.prob_unique * 2:  # 0.0002% | ||||
|                 return CardRarity.UNIQUE | ||||
|             elif rand < settings.prob_kira * 2:  # 0.2% | ||||
|                 return CardRarity.KIRA | ||||
|             elif rand < 0.5:  # 0.5% | ||||
|                 return CardRarity.SUPER_RARE | ||||
|             elif rand < 5:  # 5% | ||||
|                 return CardRarity.RARE | ||||
|         else: | ||||
|             # 通常ガチャ | ||||
|             if rand < settings.prob_unique: | ||||
|                 return CardRarity.UNIQUE | ||||
|             elif rand < settings.prob_kira: | ||||
|                 return CardRarity.KIRA | ||||
|             elif rand < settings.prob_super_rare: | ||||
|                 return CardRarity.SUPER_RARE | ||||
|             elif rand < settings.prob_rare: | ||||
|                 return CardRarity.RARE | ||||
|          | ||||
|         return CardRarity.NORMAL | ||||
|      | ||||
|     def _select_card_id(self, rarity: CardRarity) -> int: | ||||
|         """レアリティに応じてカードIDを選択""" | ||||
|         if rarity in [CardRarity.UNIQUE, CardRarity.KIRA]: | ||||
|             # レアカードは特定のIDに偏らせる | ||||
|             weights = [1, 1, 2, 2, 3, 1, 1, 3, 5, 4, 5, 2, 3, 4, 6, 5] | ||||
|         else: | ||||
|             # 通常は均等 | ||||
|             weights = [1] * 16 | ||||
|          | ||||
|         return random.choices(range(16), weights=weights)[0] | ||||
|      | ||||
|     def _calculate_cp(self, card_id: int, rarity: CardRarity) -> int: | ||||
|         """カードのCPを計算""" | ||||
|         base_range = self.CARD_INFO[card_id]["base_cp_range"] | ||||
|         base_cp = random.randint(*base_range) | ||||
|          | ||||
|         # レアリティボーナス | ||||
|         multiplier = { | ||||
|             CardRarity.NORMAL: 1.0, | ||||
|             CardRarity.RARE: 1.5, | ||||
|             CardRarity.SUPER_RARE: 2.0, | ||||
|             CardRarity.KIRA: 3.0, | ||||
|             CardRarity.UNIQUE: 5.0, | ||||
|         }[rarity] | ||||
|          | ||||
|         return int(base_cp * multiplier) | ||||
|      | ||||
|     def _get_skill_for_card(self, card_id: int, rarity: CardRarity) -> Optional[str]: | ||||
|         """カードのスキルを取得""" | ||||
|         if rarity in [CardRarity.KIRA, CardRarity.UNIQUE]: | ||||
|             # TODO: スキル情報を返す | ||||
|             return f"skill_{card_id}_{rarity.value}" | ||||
|         return None | ||||
|      | ||||
							
								
								
									
										1
									
								
								python/api/app/tests/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								python/api/app/tests/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| # Test Package | ||||
							
								
								
									
										57
									
								
								python/api/app/tests/test_gacha.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								python/api/app/tests/test_gacha.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,57 @@ | ||||
| """ガチャシステムのテスト""" | ||||
| import pytest | ||||
| from app.services.gacha import GachaService | ||||
| from app.models.card import CardRarity | ||||
|  | ||||
|  | ||||
| class TestGachaService: | ||||
|     """GachaServiceのテストクラス""" | ||||
|      | ||||
|     def test_determine_rarity_normal(self): | ||||
|         """通常ガチャのレアリティ判定テスト""" | ||||
|         rarities = [] | ||||
|         for _ in range(1000): | ||||
|             rarity = GachaService._determine_rarity(is_paid=False) | ||||
|             rarities.append(rarity) | ||||
|          | ||||
|         # 大部分がNORMALであることを確認 | ||||
|         normal_count = rarities.count(CardRarity.NORMAL) | ||||
|         assert normal_count > 900 | ||||
|          | ||||
|     def test_determine_rarity_paid(self): | ||||
|         """課金ガチャのレアリティ判定テスト""" | ||||
|         rarities = [] | ||||
|         for _ in range(1000): | ||||
|             rarity = GachaService._determine_rarity(is_paid=True) | ||||
|             rarities.append(rarity) | ||||
|          | ||||
|         # 課金ガチャの方がレアが出やすいことを確認 | ||||
|         rare_count = sum(1 for r in rarities if r != CardRarity.NORMAL) | ||||
|         assert rare_count > 50  # 5%以上 | ||||
|          | ||||
|     def test_card_id_selection(self): | ||||
|         """カードID選択のテスト""" | ||||
|         for rarity in CardRarity: | ||||
|             card_id = GachaService._select_card_id(rarity) | ||||
|             assert 0 <= card_id <= 15 | ||||
|              | ||||
|     def test_cp_calculation(self): | ||||
|         """CP計算のテスト""" | ||||
|         # 通常カード | ||||
|         cp_normal = GachaService._calculate_cp(0, CardRarity.NORMAL) | ||||
|         assert 10 <= cp_normal <= 100 | ||||
|          | ||||
|         # uniqueカード(5倍) | ||||
|         cp_unique = GachaService._calculate_cp(0, CardRarity.UNIQUE) | ||||
|         assert 50 <= cp_unique <= 500 | ||||
|          | ||||
|     @pytest.mark.asyncio | ||||
|     async def test_draw_card(self): | ||||
|         """カード抽選の統合テスト""" | ||||
|         user_did = "did:plc:test123" | ||||
|         card, is_unique = await GachaService.draw_card(user_did, is_paid=False) | ||||
|          | ||||
|         assert card.owner_did == user_did | ||||
|         assert 0 <= card.id <= 15 | ||||
|         assert card.cp > 0 | ||||
|         assert card.status in CardRarity | ||||
							
								
								
									
										76
									
								
								python/api/init_db.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								python/api/init_db.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,76 @@ | ||||
| """Initialize database with master data""" | ||||
| import asyncio | ||||
| from sqlalchemy import text, select, func | ||||
| from sqlalchemy.ext.asyncio import AsyncSession | ||||
| from app.db.base import engine, Base, async_session | ||||
| from app.db.models import CardMaster | ||||
| from app.core.config import settings | ||||
|  | ||||
| # Card master data from ai.json | ||||
| CARD_MASTER_DATA = [ | ||||
|     {"id": 0, "name": "ai", "base_cp_min": 10, "base_cp_max": 100, "color": "#fff700", "description": "世界の最小単位"}, | ||||
|     {"id": 1, "name": "dream", "base_cp_min": 20, "base_cp_max": 120, "color": "#b19cd9", "description": "意識が物質を作る"}, | ||||
|     {"id": 2, "name": "radiance", "base_cp_min": 30, "base_cp_max": 130, "color": "#ffd700", "description": "存在は光に向かう"}, | ||||
|     {"id": 3, "name": "neutron", "base_cp_min": 40, "base_cp_max": 140, "color": "#cacfd2", "description": "中性子"}, | ||||
|     {"id": 4, "name": "sun", "base_cp_min": 50, "base_cp_max": 150, "color": "#ff6b35", "description": "太陽"}, | ||||
|     {"id": 5, "name": "night", "base_cp_min": 25, "base_cp_max": 125, "color": "#1a1a2e", "description": "夜空"}, | ||||
|     {"id": 6, "name": "snow", "base_cp_min": 15, "base_cp_max": 115, "color": "#e3f2fd", "description": "雪"}, | ||||
|     {"id": 7, "name": "thunder", "base_cp_min": 60, "base_cp_max": 160, "color": "#ffd93d", "description": "雷"}, | ||||
|     {"id": 8, "name": "ultimate", "base_cp_min": 80, "base_cp_max": 180, "color": "#6c5ce7", "description": "超究"}, | ||||
|     {"id": 9, "name": "sword", "base_cp_min": 70, "base_cp_max": 170, "color": "#a8e6cf", "description": "剣"}, | ||||
|     {"id": 10, "name": "destruction", "base_cp_min": 90, "base_cp_max": 190, "color": "#ff4757", "description": "破壊"}, | ||||
|     {"id": 11, "name": "earth", "base_cp_min": 35, "base_cp_max": 135, "color": "#4834d4", "description": "地球"}, | ||||
|     {"id": 12, "name": "galaxy", "base_cp_min": 65, "base_cp_max": 165, "color": "#9c88ff", "description": "天の川"}, | ||||
|     {"id": 13, "name": "create", "base_cp_min": 75, "base_cp_max": 175, "color": "#00d2d3", "description": "創造"}, | ||||
|     {"id": 14, "name": "supernova", "base_cp_min": 100, "base_cp_max": 200, "color": "#ff9ff3", "description": "超新星"}, | ||||
|     {"id": 15, "name": "world", "base_cp_min": 85, "base_cp_max": 185, "color": "#54a0ff", "description": "存在と世界は同じもの"}, | ||||
| ] | ||||
|  | ||||
|  | ||||
| async def init_db(): | ||||
|     """Initialize database tables and master data""" | ||||
|     print("Creating database tables...") | ||||
|     async with engine.begin() as conn: | ||||
|         await conn.run_sync(Base.metadata.create_all) | ||||
|      | ||||
|     print("Inserting master data...") | ||||
|     async with async_session() as session: | ||||
|         # Check if master data already exists | ||||
|         try: | ||||
|             result = await session.execute( | ||||
|                 select(func.count()).select_from(CardMaster) | ||||
|             ) | ||||
|             count = result.scalar() | ||||
|         except Exception: | ||||
|             # Table might not exist yet | ||||
|             count = 0 | ||||
|          | ||||
|         if count == 0: | ||||
|             # Insert card master data | ||||
|             for card_data in CARD_MASTER_DATA: | ||||
|                 card = CardMaster(**card_data) | ||||
|                 session.add(card) | ||||
|              | ||||
|             await session.commit() | ||||
|             print(f"Inserted {len(CARD_MASTER_DATA)} card master records") | ||||
|         else: | ||||
|             print("Master data already exists, skipping...") | ||||
|      | ||||
|     print("Database initialization complete!") | ||||
|  | ||||
|  | ||||
| async def drop_db(): | ||||
|     """Drop all database tables""" | ||||
|     print("Dropping all database tables...") | ||||
|     async with engine.begin() as conn: | ||||
|         await conn.run_sync(Base.metadata.drop_all) | ||||
|     print("All tables dropped!") | ||||
|  | ||||
|  | ||||
| if __name__ == "__main__": | ||||
|     import sys | ||||
|      | ||||
|     if len(sys.argv) > 1 and sys.argv[1] == "drop": | ||||
|         asyncio.run(drop_db()) | ||||
|     else: | ||||
|         asyncio.run(init_db()) | ||||
							
								
								
									
										20
									
								
								python/api/requirements.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								python/api/requirements.txt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | ||||
| fastapi>=0.104.1 | ||||
| uvicorn[standard]>=0.24.0 | ||||
| pydantic>=2.7.0,<3.0.0 | ||||
| pydantic-settings>=2.1.0 | ||||
| python-multipart==0.0.6 | ||||
| httpx>=0.25.0,<0.29.0 | ||||
| python-jose[cryptography]==3.3.0 | ||||
| passlib[bcrypt]==1.7.4 | ||||
| sqlalchemy>=2.0.23 | ||||
| greenlet>=3.0.0 | ||||
| alembic>=1.12.1 | ||||
| # asyncpg==0.29.0  # Disabled: requires compilation | ||||
| # psycopg2-binary==2.9.9  # Disabled: requires compilation | ||||
| aiosqlite>=0.19.0 | ||||
| python-dotenv==1.0.0 | ||||
| pytest==7.4.3 | ||||
| pytest-asyncio==0.21.1 | ||||
| atproto>=0.0.55 | ||||
| # supabase>=2.3.0  # Temporarily disabled due to httpx version conflict | ||||
| fastapi-mcp==0.1.0 | ||||
		Reference in New Issue
	
	Block a user