diff --git a/.gitignore b/.gitignore index ecc51cd..9761cec 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,4 @@ yarn-error.log package-lock.json yarn.lock **DS_Store +cloudflared-config* diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md deleted file mode 100644 index 5856b6e..0000000 --- a/DEVELOPMENT.md +++ /dev/null @@ -1,143 +0,0 @@ -# ai.card プロジェクト固有情報 - -## プロジェクト概要 -- **名前**: ai.card -- **パッケージ**: aicard -- **タイプ**: atproto基盤カードゲーム -- **役割**: ユーザーデータ主権カードゲームシステム - -## 実装状況 - -### 現在の状況 -- **ai.bot統合**: ai.botの機能として実装済み -- **カード取得**: atproto accountでmentionすると1日1回カード取得可能 -- **データ管理**: ai.api (MCP server) でユーザー管理 - -### 独立MCPサーバー(ai.gpt連携) -- **場所**: `/Users/syui/ai/gpt/card/` -- **サーバー**: FastAPI + fastapi_mcp (port 8000) -- **統合**: ai.gptサーバーからHTTP連携 - -## アーキテクチャ構成 - -### 技術スタック -- **Backend**: FastAPI + MCP -- **Frontend**: React Web UI + SwiftUI iOS app -- **Data**: atproto collection record(ユーザー所有) -- **Auth**: OAuth 2.1 scope(実装待ち) - -### データフロー -``` -ユーザー → ai.bot mention → カード生成 → atproto collection → ユーザー所有 - ↑ ↓ - ← iOS app表示 ← ai.card API ← -``` - -## 移行計画 - -### Phase 1: 独立化 -- **iOS移植**: Claude担当予定 -- **Web UI**: React実装 -- **API独立**: ai.botからの分離 - -### Phase 2: データ主権実装 -- **atproto collection**: カードデータをユーザー所有に -- **OAuth 2.1**: 不正防止機能実装 -- **画像ファイル**: Cloudflare Pages最適化 - -### Phase 3: ゲーム機能拡張 -- **ガチャシステム**: 確率・レアリティ管理 -- **トレード機能**: ユーザー間カード交換 -- **デッキ構築**: カードゲーム戦略要素 - -## yui system適用 - -### 唯一性担保 -- **カード効果**: アカウント固有の効果設定 -- **改ざん防止**: ハッシュ・署名による保証 -- **ゲームバランス**: 唯一性による公平性維持 - -### ai.verse連携 -- **ゲーム内アイテム**: ai.verseでのカード利用 -- **固有スキル**: カードとキャラクターの連動 -- **現実反映**: カード取得がゲーム内能力に影響 - -## ディレクトリ構成 - -``` -/Users/syui/ai/gpt/card/ -├── api/ # FastAPI + MCP server -├── web/ # React Web UI -├── ios/ # SwiftUI iOS app -└── docs/ # 開発ドキュメント -``` - -## MCPツール(ai.gpt連携) - -### カード管理 -- **card_get_user_cards**: ユーザーカード取得 -- **card_draw_card**: ガチャ実行 -- **card_analyze_collection**: コレクション分析 -- **card_check_daily_limit**: 日次制限確認 -- **card_get_card_stats**: カード統計情報 -- **card_manage_deck**: デッキ管理 - -## 開発状況 - -### 完成済み機能 -- ✅ **基本カード生成**: ai.bot統合での1日1回取得 -- ✅ **atproto連携**: mention機能 -- ✅ **MCP統合**: ai.gptからの操作 - -### 開発中機能 -- 🔧 **iOS app**: SwiftUI実装 -- 🔧 **Web UI**: React実装 -- 🔧 **独立API**: FastAPI server - -### 将来機能 -- 📋 **OAuth 2.1**: 不正防止強化 -- 📋 **画像最適化**: Cloudflare Pages -- 📋 **ゲーム拡張**: トレード・デッキ戦略 - -## ai.botからの移行詳細 - -### 現在のai.bot実装 -- **Rust製**: seahorse CLI framework -- **atproto連携**: mention機能でカード配布 -- **日次制限**: 1アカウント1日1回取得 -- **自動生成**: AI絵画(Leonardo.AI + Stable Diffusion) - -### 独立化の理由 -- **iOS展開**: モバイルアプリでの独立した体験 -- **ゲーム拡張**: デッキ構築・バトル機能の追加 -- **データ主権**: ユーザーによる完全なデータ所有 -- **スケーラビリティ**: サーバー負荷分散 - -## 技術的課題と解決策 - -### データ改ざん防止 -- **短期**: MCP serverによる検証 -- **中期**: OAuth 2.1 scope実装待ち -- **長期**: ブロックチェーン的整合性チェック - -### スケーラビリティ -- **画像配信**: Cloudflare Pages活用 -- **API負荷**: FastAPIによる高速処理 -- **データ保存**: atproto分散ストレージ - -### ユーザー体験 -- **直感的UI**: iOS/Webでの統一UX -- **リアルタイム更新**: WebSocketでの即座反映 -- **オフライン対応**: ローカルキャッシュ機能 - -## ai.game連携構想 - -### Play-to-Work統合 -- **カードゲームプレイ → 業務成果変換**: ai.gameデバイスでの労働ゲーム化 -- **デッキ構築戦略 → 企業戦略思考**: カード組み合わせが戦略思考を鍛練 -- **トレード交渉 → ビジネススキル**: 他プレイヤーとの交渉が実務能力向上 - -### メタバース展開 -- **ai.verse統合**: 3D世界でのカードバトル -- **アバター連動**: 所有カードがキャラクター能力に影響 -- **配信コンテンツ**: カードゲームが配信可能なエンターテイメント \ No newline at end of file diff --git a/cloudflared-config.production.yml b/cloudflared-config.production.yml deleted file mode 100644 index 3281f8e..0000000 --- a/cloudflared-config.production.yml +++ /dev/null @@ -1,18 +0,0 @@ -tunnel: a6813327-f880-485d-a9d1-376e6e3df8ad -credentials-file: /Users/syui/.cloudflared/a6813327-f880-485d-a9d1-376e6e3df8ad.json - -ingress: - # API backend - 別ドメインで公開 - - hostname: xxxapi.syui.ai - service: http://localhost:8000 - originRequest: - noHappyEyeballs: true - - # Web frontend - - hostname: xxxcard.syui.ai - service: http://localhost:4173 - originRequest: - noHappyEyeballs: true - - # Catch-all rule - - service: http_status:404 \ No newline at end of file diff --git a/cloudflared-config.yml b/cloudflared-config.yml deleted file mode 100644 index 5e91c7c..0000000 --- a/cloudflared-config.yml +++ /dev/null @@ -1,33 +0,0 @@ -tunnel: a6813327-f880-485d-a9d1-376e6e3df8ad -credentials-file: /Users/syui/.cloudflared/a6813327-f880-485d-a9d1-376e6e3df8ad.json - -ingress: - # API backend routes (more specific paths first) - - hostname: xxxcard.syui.ai - path: /api/* - service: http://localhost:8000 - originRequest: - noHappyEyeballs: true - - # Health check - - hostname: xxxcard.syui.ai - path: /health - service: http://localhost:8000 - originRequest: - noHappyEyeballs: true - - # MCP endpoint - - hostname: xxxcard.syui.ai - path: /mcp* - service: http://localhost:8000 - originRequest: - noHappyEyeballs: true - - # Web frontend (all other routes) - - hostname: xxxcard.syui.ai - service: http://localhost:4173 - originRequest: - noHappyEyeballs: true - - # Catch-all rule - - service: http_status:404 \ No newline at end of file diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml deleted file mode 100644 index 6ede753..0000000 --- a/docker-compose.dev.yml +++ /dev/null @@ -1,23 +0,0 @@ -# 開発用: PostgreSQLのみ起動 -version: '3.8' - -services: - postgres: - image: postgres:16-alpine - container_name: aicard_postgres_dev - environment: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: aicard - ports: - - "5432:5432" - volumes: - - postgres_dev_data:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres"] - interval: 10s - timeout: 5s - retries: 5 - -volumes: - postgres_dev_data: \ No newline at end of file diff --git a/docker-compose.production.yml b/docker-compose.production.yml deleted file mode 100644 index 594fa1e..0000000 --- a/docker-compose.production.yml +++ /dev/null @@ -1,54 +0,0 @@ -version: '3.8' - -# Production configuration with Cloudflare Tunnel -services: - api: - build: - context: ./api - dockerfile: Dockerfile - restart: unless-stopped - environment: - DATABASE_URL: ${DATABASE_URL} - DATABASE_URL_SUPABASE: ${DATABASE_URL_SUPABASE} - USE_SUPABASE: ${USE_SUPABASE:-false} - PYTHONPATH: /app - networks: - - internal - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8000/health"] - interval: 30s - timeout: 10s - retries: 3 - - web: - build: - context: ./web - dockerfile: Dockerfile - restart: unless-stopped - networks: - - internal - environment: - - VITE_API_URL=http://api:8000 - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:3000"] - interval: 30s - timeout: 10s - retries: 3 - - cloudflared: - image: cloudflare/cloudflared:latest - restart: unless-stopped - command: tunnel --no-autoupdate run - environment: - - TUNNEL_TOKEN=${CLOUDFLARE_TUNNEL_TOKEN} - networks: - - internal - depends_on: - api: - condition: service_healthy - web: - condition: service_healthy - -networks: - internal: - driver: bridge \ No newline at end of file diff --git a/python/api/.env.example b/python/api/.env.example deleted file mode 100644 index 55de024..0000000 --- a/python/api/.env.example +++ /dev/null @@ -1,32 +0,0 @@ -# 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 \ No newline at end of file diff --git a/python/api/Dockerfile b/python/api/Dockerfile deleted file mode 100644 index 023a58a..0000000 --- a/python/api/Dockerfile +++ /dev/null @@ -1,24 +0,0 @@ -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"] \ No newline at end of file diff --git a/python/api/alembic.ini b/python/api/alembic.ini deleted file mode 100644 index fb1bbb0..0000000 --- a/python/api/alembic.ini +++ /dev/null @@ -1,47 +0,0 @@ -# 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 \ No newline at end of file diff --git a/python/api/alembic/env.py b/python/api/alembic/env.py deleted file mode 100644 index e78a11a..0000000 --- a/python/api/alembic/env.py +++ /dev/null @@ -1,82 +0,0 @@ -"""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() \ No newline at end of file diff --git a/python/api/alembic/script.py.mako b/python/api/alembic/script.py.mako deleted file mode 100644 index 37d0cac..0000000 --- a/python/api/alembic/script.py.mako +++ /dev/null @@ -1,24 +0,0 @@ -"""${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"} \ No newline at end of file diff --git a/python/api/app/__init__.py b/python/api/app/__init__.py deleted file mode 100644 index 0413598..0000000 --- a/python/api/app/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# ai.card API Package \ No newline at end of file diff --git a/python/api/app/ai_provider.py b/python/api/app/ai_provider.py deleted file mode 100644 index 070e0eb..0000000 --- a/python/api/app/ai_provider.py +++ /dev/null @@ -1,290 +0,0 @@ -"""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}") \ No newline at end of file diff --git a/python/api/app/auth/__init__.py b/python/api/app/auth/__init__.py deleted file mode 100644 index 5f5f9e1..0000000 --- a/python/api/app/auth/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Auth Package \ No newline at end of file diff --git a/python/api/app/auth/dependencies.py b/python/api/app/auth/dependencies.py deleted file mode 100644 index c1d9226..0000000 --- a/python/api/app/auth/dependencies.py +++ /dev/null @@ -1,116 +0,0 @@ -"""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 \ No newline at end of file diff --git a/python/api/app/core/__init__.py b/python/api/app/core/__init__.py deleted file mode 100644 index e69905e..0000000 --- a/python/api/app/core/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Core Package \ No newline at end of file diff --git a/python/api/app/core/config.py b/python/api/app/core/config.py deleted file mode 100644 index 30ec701..0000000 --- a/python/api/app/core/config.py +++ /dev/null @@ -1,59 +0,0 @@ -"""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() \ No newline at end of file diff --git a/python/api/app/db/__init__.py b/python/api/app/db/__init__.py deleted file mode 100644 index 425d847..0000000 --- a/python/api/app/db/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Database Package \ No newline at end of file diff --git a/python/api/app/db/base.py b/python/api/app/db/base.py deleted file mode 100644 index c3210a0..0000000 --- a/python/api/app/db/base.py +++ /dev/null @@ -1,53 +0,0 @@ -"""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 \ No newline at end of file diff --git a/python/api/app/db/models.py b/python/api/app/db/models.py deleted file mode 100644 index eee822f..0000000 --- a/python/api/app/db/models.py +++ /dev/null @@ -1,121 +0,0 @@ -"""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) \ No newline at end of file diff --git a/python/api/app/main.py b/python/api/app/main.py deleted file mode 100644 index 16b81e8..0000000 --- a/python/api/app/main.py +++ /dev/null @@ -1,65 +0,0 @@ -"""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 - ) \ No newline at end of file diff --git a/python/api/app/mcp_server.py b/python/api/app/mcp_server.py deleted file mode 100644 index 974a176..0000000 --- a/python/api/app/mcp_server.py +++ /dev/null @@ -1,290 +0,0 @@ -"""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 \ No newline at end of file diff --git a/python/api/app/models/__init__.py b/python/api/app/models/__init__.py deleted file mode 100644 index fef1916..0000000 --- a/python/api/app/models/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Models Package \ No newline at end of file diff --git a/python/api/app/models/card.py b/python/api/app/models/card.py deleted file mode 100644 index 24a2d0e..0000000 --- a/python/api/app/models/card.py +++ /dev/null @@ -1,57 +0,0 @@ -"""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 \ No newline at end of file diff --git a/python/api/app/repositories/__init__.py b/python/api/app/repositories/__init__.py deleted file mode 100644 index ee98481..0000000 --- a/python/api/app/repositories/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Repositories Package \ No newline at end of file diff --git a/python/api/app/repositories/base.py b/python/api/app/repositories/base.py deleted file mode 100644 index 38d0ba3..0000000 --- a/python/api/app/repositories/base.py +++ /dev/null @@ -1,65 +0,0 @@ -"""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 \ No newline at end of file diff --git a/python/api/app/repositories/card.py b/python/api/app/repositories/card.py deleted file mode 100644 index 2d4c9e9..0000000 --- a/python/api/app/repositories/card.py +++ /dev/null @@ -1,181 +0,0 @@ -"""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 \ No newline at end of file diff --git a/python/api/app/repositories/user.py b/python/api/app/repositories/user.py deleted file mode 100644 index a670ecd..0000000 --- a/python/api/app/repositories/user.py +++ /dev/null @@ -1,38 +0,0 @@ -"""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() \ No newline at end of file diff --git a/python/api/app/routes/__init__.py b/python/api/app/routes/__init__.py deleted file mode 100644 index 0321c28..0000000 --- a/python/api/app/routes/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Routes Package \ No newline at end of file diff --git a/python/api/app/routes/auth.py b/python/api/app/routes/auth.py deleted file mode 100644 index 75f7bec..0000000 --- a/python/api/app/routes/auth.py +++ /dev/null @@ -1,133 +0,0 @@ -"""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 \ No newline at end of file diff --git a/python/api/app/routes/cards.py b/python/api/app/routes/cards.py deleted file mode 100644 index 1f3b2ce..0000000 --- a/python/api/app/routes/cards.py +++ /dev/null @@ -1,173 +0,0 @@ -"""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)}") \ No newline at end of file diff --git a/python/api/app/routes/sync.py b/python/api/app/routes/sync.py deleted file mode 100644 index 5ee3072..0000000 --- a/python/api/app/routes/sync.py +++ /dev/null @@ -1,152 +0,0 @@ -"""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 \ No newline at end of file diff --git a/python/api/app/services/__init__.py b/python/api/app/services/__init__.py deleted file mode 100644 index dd3b0fe..0000000 --- a/python/api/app/services/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Services Package \ No newline at end of file diff --git a/python/api/app/services/atproto.py b/python/api/app/services/atproto.py deleted file mode 100644 index 70c536a..0000000 --- a/python/api/app/services/atproto.py +++ /dev/null @@ -1,288 +0,0 @@ -"""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" - } - } - } - } - } - } \ No newline at end of file diff --git a/python/api/app/services/card_master.py b/python/api/app/services/card_master.py deleted file mode 100644 index bc268e0..0000000 --- a/python/api/app/services/card_master.py +++ /dev/null @@ -1,142 +0,0 @@ -""" -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() \ No newline at end of file diff --git a/python/api/app/services/card_sync.py b/python/api/app/services/card_sync.py deleted file mode 100644 index 981930f..0000000 --- a/python/api/app/services/card_sync.py +++ /dev/null @@ -1,184 +0,0 @@ -"""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 \ No newline at end of file diff --git a/python/api/app/services/gacha.py b/python/api/app/services/gacha.py deleted file mode 100644 index 7fc9de9..0000000 --- a/python/api/app/services/gacha.py +++ /dev/null @@ -1,164 +0,0 @@ -"""ガチャシステムのロジック""" -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 - diff --git a/python/api/app/tests/__init__.py b/python/api/app/tests/__init__.py deleted file mode 100644 index 7945106..0000000 --- a/python/api/app/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Test Package \ No newline at end of file diff --git a/python/api/app/tests/test_gacha.py b/python/api/app/tests/test_gacha.py deleted file mode 100644 index 081b151..0000000 --- a/python/api/app/tests/test_gacha.py +++ /dev/null @@ -1,57 +0,0 @@ -"""ガチャシステムのテスト""" -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 \ No newline at end of file diff --git a/python/api/init_db.py b/python/api/init_db.py deleted file mode 100644 index 142c519..0000000 --- a/python/api/init_db.py +++ /dev/null @@ -1,76 +0,0 @@ -"""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()) \ No newline at end of file diff --git a/python/api/requirements.txt b/python/api/requirements.txt deleted file mode 100644 index 549e61b..0000000 --- a/python/api/requirements.txt +++ /dev/null @@ -1,20 +0,0 @@ -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 \ No newline at end of file diff --git a/scripts/setup.sh b/scripts/setup.sh deleted file mode 100755 index 87ad073..0000000 --- a/scripts/setup.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/bin/bash - -echo "🎴 ai.card セットアップスクリプト" -echo "================================" - -# APIセットアップ -echo "📦 API セットアップ中..." -cd api -python -m venv venv -source venv/bin/activate -pip install -r requirements.txt -cp .env.example .env -echo "✅ API セットアップ完了" - -# Webセットアップ -echo "📦 Web セットアップ中..." -cd ../web -npm install -echo "✅ Web セットアップ完了" - -echo "================================" -echo "🚀 セットアップ完了!" -echo "" -echo "開発サーバーの起動:" -echo " API: cd api && uvicorn app.main:app --reload" -echo " Web: cd web && npm run dev" \ No newline at end of file diff --git a/scripts/setup_venv.sh b/scripts/setup_venv.sh deleted file mode 100755 index 00081f0..0000000 --- a/scripts/setup_venv.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/bin/bash - -# ai.card Python仮想環境セットアップスクリプト -# 新しいconfig規則に従って ~/.config/syui/ai/card/ 配下に構築 - -CONFIG_DIR="$HOME/.config/syui/ai/card" -VENV_DIR="$CONFIG_DIR/venv" - -echo "🔧 ai.card Python環境セットアップ開始..." - -# configディレクトリ作成 -echo "📁 設定ディレクトリ作成: $CONFIG_DIR" -mkdir -p "$CONFIG_DIR" - -# 仮想環境作成 -echo "🐍 Python仮想環境作成: $VENV_DIR" -python -m venv "$VENV_DIR" - -# requirements.txtをconfigディレクトリにコピー -echo "📋 requirements.txt をconfigディレクトリにコピー" -cp api/requirements.txt "$CONFIG_DIR/" - -# 仮想環境アクティベート用エイリアス情報表示 -echo "" -echo "✅ セットアップ完了!" -echo "" -echo "🚀 使用方法:" -echo " # 仮想環境アクティベート" -echo " source ~/.config/syui/ai/card/venv/bin/activate" -echo "" -echo " # パッケージインストール" -echo " pip install -r ~/.config/syui/ai/card/requirements.txt" -echo "" -echo " # デアクティベート" -echo " deactivate" -echo "" -echo "💡 .bashrc/.zshrcにエイリアスを追加することを推奨:" -echo " alias aicard-env='source ~/.config/syui/ai/card/venv/bin/activate'" \ No newline at end of file diff --git a/setup_venv.sh b/setup_venv.sh deleted file mode 100755 index b7c4db0..0000000 --- a/setup_venv.sh +++ /dev/null @@ -1,116 +0,0 @@ -#!/bin/bash -set -e - -# ai.card virtual environment setup script -# This script sets up the Python virtual environment for ai.card MCP server - -# Configuration -CARD_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -API_DIR="$CARD_DIR/api" -VENV_DIR="$HOME/.config/syui/ai/card/venv" -REQUIREMENTS_FILE="$API_DIR/requirements.txt" - -echo "🎴 ai.card Virtual Environment Setup" -echo "======================================" -echo "Card directory: $CARD_DIR" -echo "API directory: $API_DIR" -echo "Virtual environment: $VENV_DIR" -echo - -# Check if we're in the right directory -if [ ! -f "$API_DIR/requirements.txt" ]; then - echo "❌ Error: requirements.txt not found in $API_DIR" - echo "Make sure you're running this script from the ai.card root directory" - exit 1 -fi - -# Create config directory structure -echo "📁 Creating config directory structure..." -mkdir -p "$(dirname "$VENV_DIR")" - -# Create virtual environment -if [ -d "$VENV_DIR" ]; then - echo "⚠️ Virtual environment already exists at $VENV_DIR" - read -p "Do you want to recreate it? (y/N): " -n 1 -r - echo - if [[ $REPLY =~ ^[Yy]$ ]]; then - echo "🗑️ Removing existing virtual environment..." - rm -rf "$VENV_DIR" - else - echo "ℹ️ Using existing virtual environment" - fi -fi - -if [ ! -d "$VENV_DIR" ]; then - echo "🐍 Creating Python virtual environment..." - python3 -m venv "$VENV_DIR" -fi - -# Activate virtual environment -echo "🔌 Activating virtual environment..." -source "$VENV_DIR/bin/activate" - -# Upgrade pip -echo "⬆️ Upgrading pip..." -python -m pip install --upgrade pip - -# Install dependencies -echo "📦 Installing dependencies from requirements.txt..." -pip install -r "$REQUIREMENTS_FILE" - -# Verify installation -echo "✅ Verifying installation..." -cd "$API_DIR" - -# Test basic imports -echo " Testing FastAPI import..." -python -c "import fastapi; print(f'✓ FastAPI {fastapi.__version__}')" || { - echo "❌ FastAPI import failed" - exit 1 -} - -# Test fastapi-mcp import -echo " Testing fastapi-mcp import..." -python -c "from mcp.server.fastmcp import FastMCP; print('✓ FastMCP')" || { - echo "❌ FastMCP import failed" - exit 1 -} - -# Test ai.card MCP server import -echo " Testing ai.card MCP server import..." -python -c "from app.mcp_server import AICardMcpServer; print('✓ AICardMcpServer')" || { - echo "❌ AICardMcpServer import failed" - exit 1 -} - -echo -echo "🎉 Setup completed successfully!" -echo -echo "Usage:" -echo "------" -echo "# Activate virtual environment:" -echo "source $VENV_DIR/bin/activate" -echo -echo "# Start ai.card MCP server:" -echo "cd $API_DIR" -echo "uvicorn app.main:app --host localhost --port 8000 --reload" -echo -echo "# Test server:" -echo "curl http://localhost:8000/health" -echo -echo "# Deactivate when done:" -echo "deactivate" -echo -echo "MCP Server Features:" -echo "-------------------" -echo "• 9 MCP tools for card game functionality" -echo "• FastAPI REST API (/api/v1/*)" -echo "• Environment variable control (ENABLE_MCP=true/false)" -echo "• Integration with ai.gpt MCP server" -echo -echo "Configuration:" -echo "-------------" -echo "• Virtual environment: $VENV_DIR" -echo "• Config directory: ~/.config/syui/ai/card/" -echo "• API documentation: http://localhost:8000/docs" -echo "• MCP endpoint: http://localhost:8000/mcp" \ No newline at end of file diff --git a/start_server.sh b/start_server.sh deleted file mode 100755 index 8059f4a..0000000 --- a/start_server.sh +++ /dev/null @@ -1,43 +0,0 @@ -#!/bin/bash -set -e - -# ai.card MCP server startup script - -# Configuration -CARD_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -API_DIR="$CARD_DIR/python/api" -VENV_DIR="$HOME/.config/syui/ai/card/venv" -PYTHON="$VENV_DIR/bin/python" - -# Default settings -HOST="${HOST:-localhost}" -PORT="${PORT:-8001}" -RELOAD="${RELOAD:-true}" - -echo "🎴 Starting ai.card MCP Server" -echo "================================" -echo "Directory: $API_DIR" -echo "Python: $PYTHON" -echo "Host: $HOST" -echo "Port: $PORT" -echo "Reload: $RELOAD" -echo - -# Check virtual environment -if [ ! -f "$PYTHON" ]; then - echo "❌ Error: Virtual environment not found at $VENV_DIR" - echo "Please run ./setup_venv.sh first" - exit 1 -fi - -# Change to API directory -cd "$API_DIR" - -# Start server -if [ "$RELOAD" = "true" ]; then - echo "🚀 Starting server with auto-reload..." - exec "$PYTHON" -m uvicorn app.main:app --host "$HOST" --port "$PORT" --reload -else - echo "🚀 Starting server..." - exec "$PYTHON" -m uvicorn app.main:app --host "$HOST" --port "$PORT" -fi \ No newline at end of file