1
0

Compare commits

...

37 Commits

Author SHA1 Message Date
87b528338a fix reamde 2025-06-09 01:50:47 +09:00
b68c7b5e61 fix 2025-06-08 13:21:59 +09:00
d0623e3282 fix 2025-06-08 12:03:58 +09:00
2e55e6ce09 fix rust 2025-06-08 06:42:03 +09:00
e7948bf4cf Add complete ai.card Rust implementation
- Implement complete Rust API server with axum framework
- Add database abstraction supporting PostgreSQL and SQLite
- Implement comprehensive gacha system with probability calculations
- Add JWT authentication with atproto DID integration
- Create card master data system with rarities (Normal, Rare, SuperRare, Kira, Unique)
- Implement draw history tracking and collection management
- Add API endpoints for authentication, card drawing, and collection viewing
- Include database migrations for both PostgreSQL and SQLite
- Maintain full compatibility with Python API implementation
- Add comprehensive documentation and development guide

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-07 17:43:10 +09:00
13723cf3d7 fix api web 2025-06-03 22:17:36 +09:00
f337c20096 fix 2025-06-03 13:27:37 +09:00
5b2379716b fix gpt 2025-06-03 05:00:51 +09:00
6cd8014f80 merge aigpt 2025-06-02 18:24:43 +09:00
6dbe630b9d fix config 2025-06-02 00:39:26 +09:00
67145cd342 fix 2025-06-02 00:32:14 +09:00
4246f718ef add claude 2025-06-01 21:39:53 +09:00
3459231bba fix 2025-06-01 16:43:12 +09:00
725783bbca fix 2025-04-29 15:28:05 +09:00
f6676e4e25 fix 2024-12-09 04:40:48 +09:00
52326ec57f fix 2024-12-01 00:32:18 +09:00
f078e96d67 fix 2024-10-16 22:55:40 +09:00
c2f1f33542 fix 2024-10-16 22:49:22 +09:00
1bdf3153a4 fix 2024-10-05 14:59:54 +09:00
edadefc7c0 fix 2024-10-05 14:17:56 +09:00
1197f18bd2 fix 2024-10-04 13:38:34 +09:00
6879eaad3c fix 2024-10-02 15:03:32 +09:00
5c9377a988 fix 2024-10-02 13:38:49 +09:00
b21cb8dfa0 fix 2024-10-02 13:33:32 +09:00
0a658ff9d4 fix 2024-10-02 13:17:30 +09:00
cdda79ba5e fix 2024-10-02 12:57:56 +09:00
e622dac696 fix 2024-09-07 20:51:42 +09:00
18372b2970 fix 2024-09-07 20:35:00 +09:00
0e3febe1ed fix 2024-08-12 18:01:33 +09:00
e6e5baf628 fix 2024-08-06 22:03:47 +09:00
4394ff935d fix 2024-08-06 17:55:51 +09:00
9b6fef0215 fix 2024-08-06 16:46:53 +09:00
335ca5ee0e fix 2024-08-05 19:22:06 +09:00
8884dc669e fix 2024-08-05 19:09:45 +09:00
b37fd46126 fi 2024-08-03 23:11:44 +09:00
d73aaccff3 fix 2024-08-03 22:57:06 +09:00
67cc116a84 iframe load 2024-08-03 19:05:06 +09:00
138 changed files with 16162 additions and 2086 deletions

View File

@@ -0,0 +1,33 @@
{
"permissions": {
"allow": [
"WebFetch(domain:card.syui.ai)",
"Bash(mkdir:*)",
"Bash(chmod:*)",
"Bash(./start_server.sh:*)",
"Bash(npm run dev:*)",
"Bash(npm install)",
"WebFetch(domain:github.com)",
"Bash(npm run build:*)",
"Bash(npm run preview:*)",
"Bash(curl:*)",
"Bash(sudo kill:*)",
"Bash(launchctl:*)",
"Bash(ls:*)",
"Bash(cat:*)",
"Bash(find:*)",
"Bash(cloudflared:*)",
"Bash(grep:*)",
"Bash(nslookup:*)",
"Bash(sqlite3:*)",
"Bash(kill:*)",
"Bash(pkill:*)",
"WebFetch(domain:docs.bsky.app)",
"Bash(npm install:*)",
"WebFetch(domain:raw.githubusercontent.com)",
"WebFetch(domain:www.npmjs.com)",
"Bash(rm:*)"
],
"deny": []
}
}

18
.env.development Normal file
View File

@@ -0,0 +1,18 @@
# Development configuration for ai.card
# This file is used for local development
# Web Frontend Configuration
VITE_WEB_HOST=http://localhost:5173
VITE_API_HOST=http://localhost:8000
VITE_WEB_PORT=5173
# API Backend Configuration
API_HOST=localhost
API_PORT=8000
# OAuth Configuration
VITE_OAUTH_CLIENT_NAME=ai.card
VITE_OAUTH_REDIRECT_PATH=/oauth/callback
# Feature Flags
VITE_ENABLE_AI_FEATURES=true

View File

@@ -1,42 +0,0 @@
name: github pages
on:
push:
branches:
- main
env:
GITEA_MAIL: ${{ secrets.GITEA_MAIL }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
jobs:
build-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 16
ref: main
submodules: true
fetch-depth: 0
- run: |
yarn install
rm -rf public
git clone https://${GITEA_TOKEN}@git.syui.ai/ai/card_public public
rm -rf public/.git*
rm -rf public/static
- name: Build
env:
TZ: "Asia/Tokyo"
run: |
yarn build
- name: Deploy
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./dist
user_name: 'ai[bot]'
user_email: '138105980+yui-syui-ai[bot]@users.noreply.github.com'

57
.gitignore vendored
View File

@@ -1,3 +1,60 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
.venv/
.env
# FastAPI
.pytest_cache/
.coverage
htmlcov/
*.log
# Database
*.db
*.sqlite3
# IDE
.vscode/
.idea/
*.swp
*.swo
# macOS
.DS_Store
# Node
node_modules/
dist/
build/
.next/
.nuxt/
*.log*
# iOS
*.xcworkspace
xcuserdata/
*.xcscmblueprint
*.xccheckout
DerivedData/
*.ipa
*.dSYM.zip
*.dSYM
Pods/
# Secrets
.env.local
.env.production
secrets/
*.key
*.pem
# Origin
node_modules
dist
tt

3
.gitmodules vendored
View File

@@ -1,3 +0,0 @@
[submodule "public"]
path = public
url = git@git.syui.ai:ai/card_public

54
Cargo.toml Normal file
View File

@@ -0,0 +1,54 @@
[package]
name = "aicard"
version = "0.1.0"
edition = "2021"
description = "ai.card API server - Rust implementation of autonomous card collection system"
authors = ["syui"]
[dependencies]
# Core Web Framework
axum = { version = "0.7", features = ["macros", "multipart"] }
tokio = { version = "1.0", features = ["full"] }
tower = { version = "0.4", features = ["full"] }
tower-http = { version = "0.5", features = ["cors", "trace"] }
# Database & ORM
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "sqlite", "uuid", "chrono", "migrate"] }
uuid = { version = "1.0", features = ["v4", "serde"] }
# Serialization & Validation
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
validator = { version = "0.18", features = ["derive"] }
# Date/Time
chrono = { version = "0.4", features = ["serde"] }
# Authentication & Security
jsonwebtoken = "9.0"
bcrypt = "0.15"
# HTTP Client (for atproto integration)
reqwest = { version = "0.11", features = ["json"] }
# Configuration
config = "0.13"
dotenvy = "0.15"
# CLI
clap = { version = "4.0", features = ["derive"] }
# Random (for gacha system)
rand = "0.8"
# Error Handling
anyhow = "1.0"
thiserror = "1.0"
# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Development
serde_yaml = "0.9"
dirs = "5.0"

143
DEVELOPMENT.md Normal file
View File

@@ -0,0 +1,143 @@
# 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世界でのカードバトル
- **アバター連動**: 所有カードがキャラクター能力に影響
- **配信コンテンツ**: カードゲームが配信可能なエンターテイメント

143
README.md Normal file
View File

@@ -0,0 +1,143 @@
# 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世界でのカードバトル
- **アバター連動**: 所有カードがキャラクター能力に影響
- **配信コンテンツ**: カードゲームが配信可能なエンターテイメント

143
claude.md Normal file
View File

@@ -0,0 +1,143 @@
# 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世界でのカードバトル
- **アバター連動**: 所有カードがキャラクター能力に影響
- **配信コンテンツ**: カードゲームが配信可能なエンターテイメント

View File

@@ -0,0 +1,18 @@
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

33
cloudflared-config.yml Normal file
View File

@@ -0,0 +1,33 @@
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

23
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,23 @@
# 開発用: 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:

View File

@@ -0,0 +1,54 @@
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

64
docker-compose.yml Normal file
View File

@@ -0,0 +1,64 @@
version: '3.8'
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: aicard
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
api:
build:
context: ./api
dockerfile: Dockerfile
restart: unless-stopped
environment:
DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/aicard
PYTHONPATH: /app
ports:
- "8000:8000"
depends_on:
postgres:
condition: service_healthy
volumes:
- ./api:/app
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
web:
build:
context: ./web
dockerfile: Dockerfile
restart: unless-stopped
ports:
- "3000:3000"
depends_on:
- api
environment:
- VITE_API_URL=http://api:8000
# Cloudflare Tunnel (optional)
cloudflared:
image: cloudflare/cloudflared:latest
restart: unless-stopped
command: tunnel --no-autoupdate run
environment:
- TUNNEL_TOKEN=${CLOUDFLARE_TUNNEL_TOKEN}
depends_on:
- api
- web
profiles:
- tunnel
volumes:
postgres_data:

285
docs/AI_CONTEXT.md Normal file
View File

@@ -0,0 +1,285 @@
# AI Context Document - ai.card プロジェクト
> **重要**: このドキュメントは、将来のAI開発者Claude Code等が迅速にプロジェクトを理解し、作業を継続できるよう設計されています。
## 🎯 プロジェクト概要
**ai.card** は、atprotoベースの分散型カードゲームです。ユーザーがデータを所有し、世界で一人だけが持てるuniqueカードが存在する革新的なシステムです。
### 中核思想
- **存在子理論**: 世界の最小単位aiの探求がテーマ
- **yui system**: 現実の個人とゲーム要素の1:1紐付け
- **データ主権**: atproto PDSでユーザーがカードデータを所有
- **現実の反映**: ゲームがプレイヤーの現実と連動
## 🏗️ システム構成
```
[iOS App] ←→ [Web App] ←→ [FastAPI API] ←→ [PostgreSQL]
[atproto PDS]
[ai.verse(将来)]
```
### 技術スタック2025年6月1日現在
- **Backend**: Python 3.11 + FastAPI + PostgreSQL + Docker
- **Frontend**: React 18 + TypeScript + Vite + Framer Motion
- **Mobile**: SwiftUI + Combine + iOS 16.0+
- **Identity**: atproto DID + JWT
- **Infrastructure**: Docker Compose + Cloudflare Tunnel + Supabase
## 💎 uniqueカードシステム最重要概念
### 概念
```
通常のガチャ → キラカード(0.1%) → uniqueカード(0.0001%)
↑表 ↑隠し機能
ユーザーの目標 偶然の幸運
```
### 実装
- **確率**: 0.0001%10万分の1
- **唯一性**: カードID 0-15の各種類につき、世界で1人のみ所有可能
- **検証**: `unique_card_registry`テーブル + atproto PDS両方でチェック
- **エフェクト**: 虹色オーラ + パーティクル + 特別UI
### データフロー
```
ガチャ実行 → レアリティ判定 → unique可能性チェック →
atomic操作で確保 → DB保存 → atproto PDS同期 → アニメーション表示
```
## 🔐 atproto統合
### 認証フロー
```
ユーザー: ハンドル + アプリパスワード
atproto PDS認証
JWT発行 → セッション管理
API呼び出し認証
```
### データ同期
```
カード取得 → DB保存 → atproto collection record作成
レキシコン: ai.card.collection
ユーザーPDSにデータ保存ユーザーがデータ所有
```
## 📁 重要なファイル構造
### Backend最重要
```
api/app/
├── models/card.py # カードデータ定義
├── services/gacha.py # ガチャロジックuniqueカード生成
├── services/atproto.py # atproto統合
├── services/card_sync.py # PDS同期
├── repositories/card.py # カードデータアクセス
├── routes/auth.py # 認証API
├── routes/cards.py # カードAPI
└── db/models.py # データベースモデル
```
### Frontend
```
web/src/
├── components/Card.tsx # カード表示(エフェクト付き)
├── components/GachaAnimation.tsx # ガチャ演出
├── services/auth.ts # 認証管理
└── services/api.ts # API通信
ios/AiCard/AiCard/
├── Views/GachaView.swift # ガチャ画面
├── Views/CardView.swift # カード表示
├── Services/APIClient.swift # API通信
└── Services/AuthManager.swift # 認証管理
```
## 🎮 ゲーム仕様
### カードマスター16種類
```json
{
"0": {"name": "アイ", "color": "fff700", "description": "世界の最小単位"},
"1": {"name": "夢幻", "color": "b19cd9", "description": "意識が物質を作る"},
...
"15": {"name": "世界", "color": "54a0ff", "description": "存在と世界は同じもの"}
}
```
### レアリティ確率
```
Normal: 99.789% → グレー系
Rare: 0.1% → ブルー系
Super Rare: 0.01% → パープル系
Kira: 0.1% → ゴールド系(スパークル)
Unique: 0.0001% → マゼンタ系(オーラ)
```
## 🚀 開発環境セットアップ
### 1. 基本起動
```bash
git clone [repository]
cd ai.card
# Docker環境起動
docker-compose up -d
# データベース初期化
docker-compose exec api python init_db.py
# Web開発サーバー
cd web && npm install && npm run dev
# iOSXcodeで開く
open ios/AiCard/AiCard.xcodeproj
```
### 2. 環境変数設定(.env
```bash
# PostgreSQL
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/aicard
# atprotoテスト用
ATPROTO_HANDLE=test.bsky.social
ATPROTO_PASSWORD=your-app-password
# JWT
SECRET_KEY=your-secret-key
```
### 3. atprotoアカウント準備
1. Blueskyアカウント作成
2. アプリパスワード生成https://bsky.app/settings/app-passwords
3. 環境変数に設定
## 🔧 よくある実装パターン
### 1. 新しいAPIエンドポイント追加
```python
# 1. routes/に新しいルート定義
@router.post("/new-endpoint")
async def new_endpoint(db: AsyncSession = Depends(get_db)):
# ロジック
# 2. main.pyにルーター追加
app.include_router(new_router, prefix=settings.api_v1_prefix)
```
### 2. データベーステーブル追加
```python
# 1. db/models.pyに新しいモデル
class NewModel(Base):
__tablename__ = "new_table"
# フィールド定義
# 2. Alembicマイグレーション
alembic revision --autogenerate -m "add new table"
alembic upgrade head
```
### 3. atproto新機能追加
```python
# services/atproto.pyに新しいメソッド
async def new_atproto_feature(self, did: str, data: dict):
# atproto SDK使用
return self.client.some_new_api(data)
```
## 🎨 UI/UXパターン
### カードエフェクト実装
```typescript
// WebReact + Framer Motion
<motion.div
className={`card ${getRarityClass()}`}
animate={isRevealing ? { rotateY: 0 } : {}}
transition={{ duration: 0.8 }}
>
```
```swift
// iOSSwiftUI
CardView(card: card)
.rotation3DEffect(.degrees(isRevealing ? 0 : 180), axis: (0, 1, 0))
.animation(.easeInOut(duration: 0.8), value: isRevealing)
```
## ⚠️ 重要な注意点
### 1. uniqueカードの整合性
- **必須**: atomic操作でのunique確保
- **必須**: DB + atproto PDS両方での検証
- **注意**: レース条件の回避
### 2. atproto連携
- **メインパスワード禁止**: 必ずアプリパスワード使用
- **セッション管理**: JWTトークンの適切な管理
- **エラーハンドリング**: atproto PDS接続失敗時の処理
### 3. 確率システム
- **透明性**: 確率は隠さず設定ファイルで管理
- **公平性**: サーバーサイドでの確率計算必須
- **監査**: ガチャ履歴の完全記録
## 🔮 将来の拡張ポイント
### Phase 1: 運用安定化
- 統合テスト自動化
- モニタリング・アラート
- パフォーマンス最適化
### Phase 2: 機能拡張
- カード交換システム
- プッシュ通知
- リアルタイム同期
### Phase 3: エコシステム統合
- ai.gpt連携AI人格とカード連動
- ai.verse連携3Dゲーム世界でunique skill
- 分散SNS連携
## 📋 デバッグ・トラブルシューティング
### よくある問題
1. **ガチャでカードが生成されない**
`services/gacha.py`のエラーログ確認
2. **atproto認証失敗**
→ アプリパスワードとハンドルの確認
3. **uniqueカード重複**
`unique_card_registry`テーブルの整合性チェック
4. **データベース接続失敗**
→ Docker Composeの起動状態確認
### ログ確認
```bash
# API ログ
docker-compose logs -f api
# データベース状態
docker-compose exec postgres psql -U postgres -d aicard -c "SELECT * FROM unique_card_registry;"
```
---
## 📚 推奨読み込み順序AI向け
1. **このドキュメント全体** - プロジェクト概要把握
2. **CLAUDE.md** - 哲学・思想の理解
3. **IMPLEMENTATION_SUMMARY.md** - 具体的実装詳細
4. **API.md** - APIエンドポイント仕様
5. **DATABASE.md** - データベース設計
6. **ATPROTO.md** - atproto連携詳細
新しいAI開発者は、この順序で読むことで迅速にプロジェクトを理解し、作業を開始できます。

102
docs/API.md Normal file
View File

@@ -0,0 +1,102 @@
# ai.card API Documentation
## Base URL
```
http://localhost:8000/api/v1
```
## Endpoints
### Draw Card
カードを抽選します。
```
POST /cards/draw
```
#### Request Body
```json
{
"user_did": "did:plc:example123",
"is_paid": false
}
```
#### Response
```json
{
"card": {
"id": 0,
"cp": 88,
"status": "normal",
"skill": null,
"owner_did": "did:plc:example123",
"obtained_at": "2025-01-01T00:00:00",
"is_unique": false,
"unique_id": null
},
"is_new": true,
"animation_type": "normal"
}
```
### Get User Cards
ユーザーの所有カード一覧を取得します。
```
GET /cards/user/{user_did}
```
#### Response
```json
[
{
"id": 0,
"cp": 88,
"status": "normal",
"skill": null,
"owner_did": "did:plc:example123",
"obtained_at": "2025-01-01T00:00:00",
"is_unique": false,
"unique_id": null
}
]
```
### Get Unique Cards
全てのuniqueカード一覧を取得します。
```
GET /cards/unique
```
#### Response
```json
[
{
"id": 8,
"cp": 500,
"status": "unique",
"skill": "skill_8_unique",
"owner_did": "did:plc:example123",
"obtained_at": "2025-01-01T00:00:00",
"is_unique": true,
"unique_id": "550e8400-e29b-41d4-a716-446655440000"
}
]
```
## Card Rarity
- `normal`: 通常カード (99.789%)
- `rare`: レアカード (0.1%)
- `super_rare`: スーパーレアカード (0.01%)
- `kira`: キラカード (0.1%)
- `unique`: ユニークカード (0.0001%)
## Animation Types
- `normal`: 通常演出
- `rare`: レア演出
- `kira`: キラカード演出
- `unique`: ユニークカード演出(特別演出)

146
docs/ATPROTO.md Normal file
View File

@@ -0,0 +1,146 @@
# atproto連携ガイド
## 概要
ai.cardは、atprotoAT Protocolと完全に統合されており、以下の機能を提供します
1. **atproto認証**: DIDベースの分散型認証
2. **データ主権**: カードデータをユーザーのPDSに保存
3. **相互運用性**: 他のatproto対応アプリとの連携
## 認証フロー
### 1. ログイン
```javascript
// フロントエンド
const response = await authService.login(identifier, password);
// identifier: ハンドルuser.bsky.socialまたはDID
// password: アプリパスワード(メインパスワードではない)
```
### 2. アプリパスワードの作成
1. https://bsky.app/settings/app-passwords にアクセス
2. 新しいアプリパスワードを作成
3. ai.cardでそのパスワードを使用
### 3. セッション管理
- JWTトークンで24時間有効
- Cookieとヘッダーの両方をサポート
- 自動更新機能なし(再ログインが必要)
## データ保存
### カードコレクションのLexicon
```json
{
"lexicon": 1,
"id": "ai.card.collection",
"defs": {
"main": {
"type": "record",
"record": {
"type": "object",
"properties": {
"cardId": { "type": "integer" },
"cp": { "type": "integer" },
"status": { "type": "string" },
"skill": { "type": "string" },
"obtainedAt": { "type": "string" },
"isUnique": { "type": "boolean" },
"uniqueId": { "type": "string" }
}
}
}
}
}
```
### データ同期
```bash
# カードをPDSに同期
POST /api/v1/sync/cards
{
"atproto_session": "session-string-from-login"
}
# PDSからインポート
POST /api/v1/sync/import
# PDSにエクスポート
POST /api/v1/sync/export
```
## セキュリティ
### 1. 認証情報の取り扱い
- **メインパスワードは使用しない**: 必ずアプリパスワードを使用
- **セッション文字列の保護**: atprotoセッションは暗号化して保存
- **HTTPS必須**: 本番環境では必ずHTTPS経由で通信
### 2. データ検証
- サーバー側でカードデータの整合性をチェック
- uniqueカードはグローバルレジストリで重複防止
- PDSのデータも信頼せず、常に検証
### 3. 権限管理
現在の制限:
- ユーザーはPDSのデータを自由に編集可能
- OAuth 2.1 scope実装待ち
対策:
- サーバー側検証で不正データを無効化
- ゲームプレイ時は常にサーバーチェック
## APIエンドポイント
### 認証
```
POST /api/v1/auth/login - ログイン
POST /api/v1/auth/logout - ログアウト
GET /api/v1/auth/verify - セッション確認
POST /api/v1/auth/verify-did - DID検証公開
```
### 同期
```
POST /api/v1/sync/cards - 双方向同期
POST /api/v1/sync/export - PDSへエクスポート
POST /api/v1/sync/import - PDSからインポート
GET /api/v1/sync/verify/:id - カード所有確認
```
## トラブルシューティング
### ログインできない
1. アプリパスワードを使用しているか確認
2. ハンドルまたはDIDが正しいか確認
3. PDSが稼働しているか確認
### データが同期されない
1. atprotoセッションが有効か確認
2. PDSの容量制限を確認
3. ネットワーク接続を確認
### カードが表示されない
1. `/api/v1/sync/import`でPDSからインポート
2. ブラウザキャッシュをクリア
3. 再ログイン
## 今後の予定
1. **OAuth 2.1対応**: より細かい権限管理
2. **リアルタイム同期**: WebSocketでの即時反映
3. **他アプリ連携**: atprotoエコシステムとの統合

102
docs/DATABASE.md Normal file
View File

@@ -0,0 +1,102 @@
# データベース設定ガイド
## ローカル開発Docker Compose
### 1. 起動
```bash
# データベースとAPIを起動
docker-compose up -d
# ログを確認
docker-compose logs -f
```
### 2. データベース初期化
```bash
# APIコンテナに入る
docker-compose exec api bash
# マイグレーション実行
alembic upgrade head
# マスタデータ投入
python init_db.py
```
## Supabase連携
### 1. Supabaseプロジェクト作成
1. [Supabase](https://supabase.com)でプロジェクト作成
2. Settings > Database から接続情報を取得
### 2. 環境変数設定
```bash
# .env
DATABASE_URL_SUPABASE=postgresql+asyncpg://postgres.[project-ref]:[password]@aws-0-[region].pooler.supabase.com:5432/postgres
USE_SUPABASE=true
```
### 3. テーブル作成
Supabase SQL Editorで以下を実行
```sql
-- Alembicのマイグレーションを実行
-- または直接SQLでテーブル作成
```
## Cloudflare Tunnel設定
### 1. トンネル作成
```bash
# Cloudflareダッシュボードでトンネル作成
# トークンを取得
```
### 2. 環境変数設定
```bash
# .env
CLOUDFLARE_TUNNEL_TOKEN=your-tunnel-token
```
### 3. 起動
```bash
# tunnelプロファイルを含めて起動
docker-compose --profile tunnel up -d
```
## データベーススキーマ
### users
- ユーザー情報DID、ハンドル
### card_master
- カードマスタデータ16種類
### user_cards
- ユーザー所有カード
- uniqueカードフラグ付き
### unique_card_registry
- グローバルuniqueカード登録
- 各カードIDにつき1人のみ所有可能
### draw_history
- ガチャ履歴
### gacha_pools
- ピックアップガチャ設定
## バックアップ
### ローカル
```bash
# バックアップ
docker-compose exec postgres pg_dump -U postgres aicard > backup.sql
# リストア
docker-compose exec -T postgres psql -U postgres aicard < backup.sql
```
### Supabase
- 自動バックアップが有効
- ダッシュボードからダウンロード可能

124
docs/DEVELOPMENT.md Normal file
View File

@@ -0,0 +1,124 @@
# 開発ガイド
## セットアップ
### 1. API (FastAPI)
```bash
cd api
# 仮想環境作成
python -m venv venv
source venv/bin/activate # macOS/Linux
# or
venv\Scripts\activate # Windows
# 依存関係インストール
pip install -r requirements.txt
# 環境変数設定
cp .env.example .env
# .envを編集
# 開発サーバー起動
uvicorn app.main:app --reload
```
APIは http://localhost:8000 で起動します。
APIドキュメントは http://localhost:8000/docs で確認できます。
### 2. Web (React + Vite)
```bash
cd web
# 依存関係インストール
npm install
# 開発サーバー起動
npm run dev
```
Webアプリは http://localhost:3000 で起動します。
## プロジェクト構造
```
ai.card/
├── api/ # FastAPI backend
│ ├── app/
│ │ ├── core/ # 設定、共通処理
│ │ ├── models/ # Pydanticモデル
│ │ ├── routes/ # APIエンドポイント
│ │ ├── services/ # ビジネスロジック
│ │ └── main.py # アプリケーションエントリ
│ └── requirements.txt
├── web/ # React frontend
│ ├── src/
│ │ ├── components/ # Reactコンポーネント
│ │ ├── services/ # API通信
│ │ ├── styles/ # CSS
│ │ ├── types/ # TypeScript型定義
│ │ └── App.tsx # メインコンポーネント
│ └── package.json
├── ios/ # iOS app (今後実装)
└── docs/ # ドキュメント
```
## 技術スタック
### Backend
- Python 3.9+
- FastAPI
- Pydantic
- SQLAlchemy (今後実装)
- atproto SDK
### Frontend
- React 18
- TypeScript
- Vite
- Framer Motion (アニメーション)
- Axios
## 開発のポイント
### 1. カードデータ
カードは0-15のIDを持ち、ai.jsonの定義に基づいています。
### 2. レアリティシステム
- 通常のガチャではキラカードが最高レア
- uniqueカードは隠し要素として実装
- 確率は設定ファイルで調整可能
### 3. atproto連携
- ユーザー認証はatproto OAuth今後実装
- カードデータはユーザーのPDSに保存今後実装
- 現在はローカルストレージのみ
### 4. アニメーション
- ガチャ演出はレアリティに応じて変化
- uniqueカードは特別な演出
- Framer Motionで実装
## 今後の実装予定
1. **データベース連携**
- SQLAlchemyでのモデル定義
- ユーザーごとのカード管理
2. **atproto統合**
- OAuth認証
- PDSへのデータ保存
- DID検証
3. **uniqueカード検証**
- グローバルレジストリ
- 重複チェック
- ai.verse連携
4. **iOS app**
- SwiftUIで実装
- 共通APIを使用

View File

@@ -0,0 +1,267 @@
# ai.card 実装完了サマリー
## 作業日: 2025年6月1日
### 📋 今日実装した内容
## 1. データベース実装PostgreSQL + Supabase
### 完成機能
- **PostgreSQLスキーマ設計**: 7つのテーブルusers, card_master, user_cards, unique_card_registry等
- **Docker Compose環境**: 開発・本番両対応
- **Supabase連携**: 環境変数で切り替え可能
- **リポジトリパターン**: BaseRepository + 専用Repository
- **マイグレーション**: Alembic設定 + 初期データ投入
- **データ同期**: ガチャ時の自動データベース保存
### 重要ファイル
```
api/app/db/models.py # SQLAlchemyモデル
api/app/repositories/ # リポジトリパターン実装
api/init_db.py # データベース初期化
docker-compose.yml # 開発環境
docker-compose.production.yml # 本番環境
```
## 2. atproto連携機能
### 完成機能
- **認証システム**: DID/ハンドルログイン + JWTトークン
- **PDSデータ保存**: カードをユーザーのPDSに自動同期
- **Lexicon定義**: `ai.card.collection` スキーマ
- **同期API**: 双方向同期・インポート・エクスポート
- **データ検証**: サーバー側整合性チェック
### 重要ファイル
```
api/app/services/atproto.py # atproto統合サービス
api/app/services/card_sync.py # カード同期サービス
api/app/routes/auth.py # 認証API
api/app/routes/sync.py # 同期API
api/app/auth/dependencies.py # 認証依存関係
```
## 3. iOS App完全実装
### 完成機能
- **SwiftUI + MVVM**: Combineを使ったリアクティブアーキテクチャ
- **認証画面**: atprotoログインアプリパスワード対応
- **ガチャシステム**: 通常・プレミアムガチャ + リッチアニメーション
- **カードコレクション**: グリッド表示・検索・フィルタ・詳細画面
- **プロフィール**: ユーザー情報・統計・設定
- **視覚エフェクト**: レアリティ別アニメーション・3Dフリップ
### 重要ファイル
```
ios/AiCard/AiCard/
├── Models/Card.swift # カードデータモデル
├── Services/APIClient.swift # API通信Combine使用
├── Services/AuthManager.swift # 認証管理
├── Services/CardManager.swift # カード管理
├── Views/LoginView.swift # ログイン画面
├── Views/GachaView.swift # ガチャ画面
├── Views/CollectionView.swift # コレクション画面
├── Views/CardView.swift # カード表示コンポーネント
└── Views/GachaAnimationView.swift # ガチャアニメーション
```
## 4. プロジェクト統合
### アーキテクチャ概要
```
[iOS App] ←→ [Web App] ←→ [FastAPI] ←→ [PostgreSQL]
[atproto PDS]
```
### データフロー
1. **ガチャ**: iOS/Web → API → DB保存 → atproto PDS同期
2. **認証**: atproto DID → JWT → セッション管理
3. **同期**: DB ↔ atproto PDS双方向同期
## 📊 実装済み機能一覧
### ✅ Backend (FastAPI)
- [x] PostgreSQL + Supabase対応
- [x] atproto認証・同期
- [x] ガチャシステム確率・unique管理
- [x] カードCRUD API
- [x] Docker環境開発・本番
- [x] リポジトリパターン
- [x] データベースマイグレーション
### ✅ Frontend (React)
- [x] atproto認証UI
- [x] ガチャアニメーションFramer Motion
- [x] カード表示・コレクション
- [x] レスポンシブデザイン
- [x] TypeScript対応
### ✅ Mobile (iOS)
- [x] SwiftUI + MVVM + Combine
- [x] atproto認証
- [x] ガチャ(通常・プレミアム)
- [x] カードコレクション(検索・フィルタ)
- [x] リッチアニメーション
- [x] iOS 16.0+ 対応
### ✅ DevOps
- [x] Docker Compose
- [x] Cloudflare Tunnel対応
- [x] 環境別設定
- [x] ヘルスチェック
## 🔧 技術スタック
### Backend
- **Language**: Python 3.11
- **Framework**: FastAPI 0.104.1
- **Database**: PostgreSQL + SQLAlchemy
- **ORM**: SQLAlchemy 2.0 (async)
- **Migration**: Alembic
- **Cloud**: Supabase対応
- **atproto**: atproto SDK 0.0.46
### Frontend (Web)
- **Language**: TypeScript
- **Framework**: React 18 + Vite
- **Animation**: Framer Motion
- **HTTP**: Axios
- **Styling**: CSS Modules
### Mobile (iOS)
- **Language**: Swift 5.7+
- **Framework**: SwiftUI
- **Architecture**: MVVM + Combine
- **HTTP**: URLSession
- **Minimum**: iOS 16.0
### Infrastructure
- **Container**: Docker + Docker Compose
- **Proxy**: Nginx
- **Tunnel**: Cloudflare Tunnel
- **Database**: PostgreSQL 16
## 🎯 unique カードシステムの実装
### 概念
- **確率**: 0.0001%10万分の1
- **唯一性**: 各カードID0-15につき世界で1人のみ所有可能
- **検証**: サーバー側 + atproto PDS両方でチェック
- **将来**: ai.verse unique skillとの連携予定
### 実装詳細
- `unique_card_registry`テーブルでグローバル管理
- ガチャ時にatomic操作で重複防止
- atproto PDSにも同期保存
- Web/iOSで特別なエフェクト表示
## 🚀 デプロイメント準備
### 開発環境起動
```bash
# 全体起動
docker-compose up -d
# データベース初期化
docker-compose exec api python init_db.py
# Web開発サーバー
cd web && npm run dev
# iOSXcodeで開く
open ios/AiCard/AiCard.xcodeproj
```
### 本番環境起動
```bash
# 本番設定で起動
docker-compose -f docker-compose.production.yml up -d
```
## 📝 重要な設定ファイル
### 環境変数(.env
```bash
# Database
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/aicard
DATABASE_URL_SUPABASE=postgresql+asyncpg://...
USE_SUPABASE=false
# atproto
ATPROTO_HANDLE=your.bsky.social
ATPROTO_PASSWORD=your-app-password
# Security
SECRET_KEY=your-secret-key
# Cloudflare Tunnel
CLOUDFLARE_TUNNEL_TOKEN=your-tunnel-token
```
### API設定
- **開発**: `http://localhost:8000`
- **本番**: `https://api.card.syui.ai`
- **認証**: Bearer JWT token
- **CORS**: Web/iOS対応
## 🔮 今後の実装候補
### Phase 1: 運用準備
- [ ] 統合テスト(全システム連携)
- [ ] パフォーマンス最適化
- [ ] モニタリング・ログ
- [ ] セキュリティ監査
### Phase 2: 機能拡張
- [ ] カード交換システム
- [ ] プッシュ通知iOS
- [ ] リアルタイム同期WebSocket
- [ ] バックアップ・復元
### Phase 3: エコシステム統合
- [ ] ai.gpt連携
- [ ] ai.verse unique skill連携
- [ ] yui system実装
- [ ] 分散SNS連携
## 🎮 ゲーム仕様
### カードシステム
- **種類**: 16種類ai, 夢幻, 光彩, 中性子, 太陽, 夜空, 雪, 雷, 超究, 剣, 破壊, 地球, 天の川, 創造, 超新星, 世界)
- **CP**: 1-999レアリティでボーナス
- **レアリティ**: 5段階normal, rare, super_rare, kira, unique
### ガチャ確率
- **Normal**: 99.789%
- **Rare**: 0.1%
- **Super Rare**: 0.01%
- **Kira**: 0.1%
- **Unique**: 0.0001%(隠し機能)
### 演出
- **Web**: CSS + Framer Motion
- **iOS**: SwiftUI Animation + Particle Effects
- **レアリティ別**: 色・エフェクト・音(予定)
---
## 💡 AI向けメモ
### プロジェクト理解のキーポイント
1. **存在子理論**: 最小単位の意識がゲーム世界の根幹
2. **yui system**: 現実の個人とゲーム内要素の1:1紐付け
3. **データ主権**: atproto PDSでユーザーがデータを所有
4. **uniqueカード**: NFT的だがブロックチェーン不使用
### 重要な実装パターン
- **リポジトリパターン**: データアクセス層の抽象化
- **atproto同期**: ガチャ時の自動PDS保存
- **レアリティシステム**: 確率とエフェクトの連動
- **認証フロー**: DID → JWT → セッション管理
### 次回作業時の注意点
- 環境変数の設定確認
- データベースの初期化
- atprotoアカウントの準備
- Docker環境の起動確認

View File

@@ -0,0 +1,129 @@
# ai.card MCP Server統合作業報告 (2025/01/06)
## 作業概要
ai.cardプロジェクトに独立したMCPサーバーを実装し、FastAPIベースのカードゲームAPIをMCPツールとして公開。
## 実装内容
### 1. 依存関係の解決
**課題と対応:**
- `atproto==0.0.46``atproto>=0.0.55` (Python 3.13対応)
- `httpx` バージョン競合 → supabase一時無効化
- `pydantic==2.5.0``pydantic>=2.7.0` (atproto要件)
- PostgreSQL依存 → SQLiteベースに変更
- `greenlet` 追加 (SQLAlchemy非同期処理)
**最終的な依存関係:**
```txt
fastapi>=0.104.1
uvicorn[standard]>=0.24.0
pydantic>=2.7.0,<3.0.0
sqlalchemy>=2.0.23
greenlet>=3.0.0
aiosqlite>=0.19.0
fastapi-mcp==0.1.0
atproto>=0.0.55
# supabase>=2.3.0 # httpx競合のため無効化
# asyncpg, psycopg2-binary # コンパイル回避のため無効化
```
### 2. MCP Server実装
**ファイル:** `api/app/mcp_server.py`
**主要変更:**
- `from mcp.server.fastmcp import FastMCP` (正しいインポート)
- `FastMCP("aicard")` で初期化
- 9個のMCPツール実装
**公開ツール:**
1. `get_user_cards` - ユーザーカード一覧
2. `draw_card` - ガチャ実行
3. `get_card_details` - カード詳細情報
4. `analyze_card_collection` - コレクション分析
5. `get_unique_registry` - ユニークカード登録状況
6. `sync_cards_atproto` - atproto同期無効化中
7. `get_gacha_stats` - ガチャ統計
### 3. データベース設定
**SQLite使用:**
- 場所: `~/.config/syui/ai/card/aicard.db`
- 理由: 依存関係シンプル化、開発環境最適化
- PostgreSQL移行: 将来的にDocker利用で対応
### 4. 補助スクリプト
- `setup_venv.sh` - 仮想環境セットアップ
- `start_server.sh` - サーバー起動スクリプト
- `docker-compose.dev.yml` - PostgreSQL開発環境
## 既知の問題と対応
### 解決済み
- ✅ fastapi-mcp インポートエラー → 正しいパッケージ名に修正
- ✅ get_db → get_session 関数名不一致
- ✅ Optional型インポート漏れ
- ✅ SQLAlchemy greenlet依存
- ✅ データベース初期化エラー
### 未解決(将来対応)
- atproto SessionString APIの変更
- supabase httpxバージョン競合
- ガチャ確率計算の精度問題
## 環境セットアップ手順
### 1. 仮想環境構築
```bash
cd /Users/syui/ai/gpt/card
./setup_venv.sh
```
### 2. データベース初期化
```bash
cd api
~/.config/syui/ai/card/venv/bin/python init_db.py
```
### 3. サーバー起動
```bash
./start_server.sh
# または
cd api
~/.config/syui/ai/card/venv/bin/python -m app.main
```
### 4. 動作確認
```bash
# ヘルスチェック
curl http://localhost:8000/health
# API仕様書
open http://localhost:8000/docs
# カード取得テスト
curl -X POST "http://localhost:8000/draw_card?did=did:plc:test123"
```
## PostgreSQL移行将来
### Docker開発環境
```bash
# PostgreSQLのみ起動
docker-compose -f docker-compose.dev.yml up -d
# 環境変数設定
export DATABASE_URL="postgresql+asyncpg://postgres:postgres@localhost:5432/aicard"
# APIサーバー起動
./start_server.sh
```
### 本番環境
- iOS/Webアプリ → PostgreSQL必須
- Docker Composeで全サービス管理
- Cloudflare Tunnel経由で公開
## 成果
- ai.card独立MCPサーバー稼働
- SQLiteベースで依存関係問題解決
- 自動リロード対応の開発環境構築
- iOS/Web連携準備完了

View File

@@ -0,0 +1,330 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 56;
objects = {
/* Begin PBXBuildFile section */
1A1234561234567890ABCDEF /* AiCardApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A1234551234567890ABCDEF /* AiCardApp.swift */; };
1A1234581234567890ABCDEF /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A1234571234567890ABCDEF /* ContentView.swift */; };
1A12345A1234567890ABCDEF /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1A1234591234567890ABCDEF /* Assets.xcassets */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
1A1234521234567890ABCDEF /* AiCard.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AiCard.app; sourceTree = BUILT_PRODUCTS_DIR; };
1A1234551234567890ABCDEF /* AiCardApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AiCardApp.swift; sourceTree = "<group>"; };
1A1234571234567890ABCDEF /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
1A1234591234567890ABCDEF /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
1A12344F1234567890ABCDEF /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
1A1234491234567890ABCDEF = {
isa = PBXGroup;
children = (
1A1234541234567890ABCDEF /* AiCard */,
1A1234531234567890ABCDEF /* Products */,
);
sourceTree = "<group>";
};
1A1234531234567890ABCDEF /* Products */ = {
isa = PBXGroup;
children = (
1A1234521234567890ABCDEF /* AiCard.app */,
);
name = Products;
sourceTree = "<group>";
};
1A1234541234567890ABCDEF /* AiCard */ = {
isa = PBXGroup;
children = (
1A1234551234567890ABCDEF /* AiCardApp.swift */,
1A1234571234567890ABCDEF /* ContentView.swift */,
1A1234591234567890ABCDEF /* Assets.xcassets */,
);
path = AiCard;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
1A1234511234567890ABCDEF /* AiCard */ = {
isa = PBXNativeTarget;
buildConfigurationList = 1A1234601234567890ABCDEF /* Build configuration list for PBXNativeTarget "AiCard" */;
buildPhases = (
1A12344E1234567890ABCDEF /* Sources */,
1A12344F1234567890ABCDEF /* Frameworks */,
1A1234501234567890ABCDEF /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = AiCard;
productName = AiCard;
productReference = 1A1234521234567890ABCDEF /* AiCard.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
1A12344A1234567890ABCDEF /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1500;
LastUpgradeCheck = 1500;
TargetAttributes = {
1A1234511234567890ABCDEF = {
CreatedOnToolsVersion = 15.0;
};
};
};
buildConfigurationList = 1A12344D1234567890ABCDEF /* Build configuration list for PBXProject "AiCard" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 1A1234491234567890ABCDEF;
productRefGroup = 1A1234531234567890ABCDEF /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
1A1234511234567890ABCDEF /* AiCard */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
1A1234501234567890ABCDEF /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
1A12345A1234567890ABCDEF /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
1A12344E1234567890ABCDEF /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
1A1234581234567890ABCDEF /* ContentView.swift in Sources */,
1A1234561234567890ABCDEF /* AiCardApp.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
1A12345E1234567890ABCDEF /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
1A12345F1234567890ABCDEF /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
1A1234611234567890ABCDEF /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "";
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = ai.syui.card;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
1A1234621234567890ABCDEF /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "";
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = ai.syui.card;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
1A12344D1234567890ABCDEF /* Build configuration list for PBXProject "AiCard" */ = {
isa = XCConfigurationList;
buildConfigurations = (
1A12345E1234567890ABCDEF /* Debug */,
1A12345F1234567890ABCDEF /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
1A1234601234567890ABCDEF /* Build configuration list for PBXNativeTarget "AiCard" */ = {
isa = XCConfigurationList;
buildConfigurations = (
1A1234611234567890ABCDEF /* Debug */,
1A1234621234567890ABCDEF /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 1A12344A1234567890ABCDEF /* Project object */;
}

View File

@@ -0,0 +1,16 @@
import SwiftUI
@main
struct AiCardApp: App {
@StateObject private var authManager = AuthManager()
@StateObject private var cardManager = CardManager()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(authManager)
.environmentObject(cardManager)
.preferredColorScheme(.dark)
}
}
}

View File

@@ -0,0 +1,41 @@
import SwiftUI
struct ContentView: View {
@EnvironmentObject var authManager: AuthManager
@State private var selectedTab = 0
var body: some View {
if authManager.isAuthenticated {
TabView(selection: $selectedTab) {
GachaView()
.tabItem {
Label("ガチャ", systemImage: "sparkles")
}
.tag(0)
CollectionView()
.tabItem {
Label("コレクション", systemImage: "square.grid.3x3")
}
.tag(1)
ProfileView()
.tabItem {
Label("プロフィール", systemImage: "person.circle")
}
.tag(2)
}
.accentColor(Color(hex: "fff700"))
} else {
LoginView()
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
.environmentObject(AuthManager())
.environmentObject(CardManager())
}
}

View File

@@ -0,0 +1,73 @@
import Foundation
// MARK: - AI
struct CollectionAnalysis: Codable {
let totalCards: Int
let uniqueCards: Int
let rarityDistribution: [String: Int]
let collectionScore: Double
let recommendations: [String]
enum CodingKeys: String, CodingKey {
case totalCards = "total_cards"
case uniqueCards = "unique_cards"
case rarityDistribution = "rarity_distribution"
case collectionScore = "collection_score"
case recommendations
}
}
struct GachaStats: Codable {
let totalDraws: Int
let cardsByRarity: [String: Int]
let successRates: [String: Double]
let recentActivity: [GachaActivity]
enum CodingKeys: String, CodingKey {
case totalDraws = "total_draws"
case cardsByRarity = "cards_by_rarity"
case successRates = "success_rates"
case recentActivity = "recent_activity"
}
}
struct GachaActivity: Codable {
let timestamp: String
let userDid: String
let cardName: String
let rarity: String
enum CodingKeys: String, CodingKey {
case timestamp
case userDid = "user_did"
case cardName = "card_name"
case rarity
}
}
struct UniqueRegistry: Codable {
let registeredCards: [String: String]
let totalUnique: Int
enum CodingKeys: String, CodingKey {
case registeredCards = "registered_cards"
case totalUnique = "total_unique"
}
}
struct SystemStatus: Codable {
let status: String
let mcpEnabled: Bool
let mcpEndpoint: String?
let databaseConnected: Bool
let aiGptConnected: Bool
enum CodingKeys: String, CodingKey {
case status
case mcpEnabled = "mcp_enabled"
case mcpEndpoint = "mcp_endpoint"
case databaseConnected = "database_connected"
case aiGptConnected = "ai_gpt_connected"
}
}

View File

@@ -0,0 +1,90 @@
import Foundation
enum CardRarity: String, Codable, CaseIterable {
case normal = "normal"
case rare = "rare"
case superRare = "super_rare"
case kira = "kira"
case unique = "unique"
var displayName: String {
switch self {
case .normal: return "ノーマル"
case .rare: return "レア"
case .superRare: return "スーパーレア"
case .kira: return "キラ"
case .unique: return "ユニーク"
}
}
var gradientColors: [String] {
switch self {
case .normal: return ["666666", "333333"]
case .rare: return ["4a90e2", "16213e"]
case .superRare: return ["9c27b0", "0f0c29"]
case .kira: return ["ffd700", "414345"]
case .unique: return ["ff00ff", "1a0033"]
}
}
}
struct Card: Identifiable, Codable {
let id: Int
let cp: Int
let status: CardRarity
let skill: String?
let ownerDid: String
let obtainedAt: Date
let isUnique: Bool
let uniqueId: String?
private enum CodingKeys: String, CodingKey {
case id
case cp
case status
case skill
case ownerDid = "owner_did"
case obtainedAt = "obtained_at"
case isUnique = "is_unique"
case uniqueId = "unique_id"
}
}
struct CardDrawResult: Codable {
let card: Card
let isNew: Bool
let animationType: String
private enum CodingKeys: String, CodingKey {
case card
case isNew = "is_new"
case animationType = "animation_type"
}
}
// Card master data
struct CardInfo {
let id: Int
let name: String
let color: String
let description: String
static let all: [Int: CardInfo] = [
0: CardInfo(id: 0, name: "アイ", color: "fff700", description: "世界の最小単位"),
1: CardInfo(id: 1, name: "夢幻", color: "b19cd9", description: "意識が物質を作る"),
2: CardInfo(id: 2, name: "光彩", color: "ffd700", description: "存在は光に向かう"),
3: CardInfo(id: 3, name: "中性子", color: "cacfd2", description: "中性子"),
4: CardInfo(id: 4, name: "太陽", color: "ff6b35", description: "太陽"),
5: CardInfo(id: 5, name: "夜空", color: "1a1a2e", description: "夜空"),
6: CardInfo(id: 6, name: "", color: "e3f2fd", description: ""),
7: CardInfo(id: 7, name: "", color: "ffd93d", description: ""),
8: CardInfo(id: 8, name: "超究", color: "6c5ce7", description: "超究"),
9: CardInfo(id: 9, name: "", color: "a8e6cf", description: ""),
10: CardInfo(id: 10, name: "破壊", color: "ff4757", description: "破壊"),
11: CardInfo(id: 11, name: "地球", color: "4834d4", description: "地球"),
12: CardInfo(id: 12, name: "天の川", color: "9c88ff", description: "天の川"),
13: CardInfo(id: 13, name: "創造", color: "00d2d3", description: "創造"),
14: CardInfo(id: 14, name: "超新星", color: "ff9ff3", description: "超新星"),
15: CardInfo(id: 15, name: "世界", color: "54a0ff", description: "存在と世界は同じもの")
]
}

View File

@@ -0,0 +1,25 @@
import Foundation
struct User: Codable {
let did: String
let handle: String
}
struct LoginRequest: Codable {
let identifier: String
let password: String
}
struct LoginResponse: Codable {
let accessToken: String
let tokenType: String
let did: String
let handle: String
private enum CodingKeys: String, CodingKey {
case accessToken = "access_token"
case tokenType = "token_type"
case did
case handle
}
}

View File

@@ -0,0 +1,237 @@
import Foundation
import Combine
enum APIError: Error {
case invalidURL
case noData
case decodingError
case networkError(String)
case unauthorized
}
// MCP Server response format
struct MCPResponse<T: Decodable>: Decodable {
let data: T?
let error: String?
}
class APIClient {
static let shared = APIClient()
#if DEBUG
private let baseURL = "http://localhost:8000/api/v1" // ai.card direct access
private let aiGptBaseURL = "http://localhost:8001" // ai.gpt MCP server (optional)
#else
private let baseURL = "https://api.card.syui.ai/api/v1" // ai.card direct access
private let aiGptBaseURL = "https://ai.gpt.syui.ai" // ai.gpt MCP server (optional)
#endif
private var cancellables = Set<AnyCancellable>()
private init() {}
private var authToken: String? {
get { UserDefaults.standard.string(forKey: "authToken") }
set { UserDefaults.standard.set(newValue, forKey: "authToken") }
}
// ai.gpt MCP
private func mcpRequest<T: Decodable>(_ endpoint: String,
parameters: [String: Any] = [:]) -> AnyPublisher<T, APIError> {
guard let url = URL(string: "\(aiGptBaseURL)\(endpoint)") else {
return Fail(error: APIError.invalidURL).eraseToAnyPublisher()
}
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
components?.queryItems = parameters.map { URLQueryItem(name: $0.key, value: "\($0.value)") }
guard let finalURL = components?.url else {
return Fail(error: APIError.invalidURL).eraseToAnyPublisher()
}
var request = URLRequest(url: finalURL)
request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
return URLSession.shared.dataTaskPublisher(for: request)
.tryMap { data, response in
guard let httpResponse = response as? HTTPURLResponse else {
throw APIError.networkError("Invalid response")
}
if !(200...299).contains(httpResponse.statusCode) {
throw APIError.networkError("MCP Server error: \(httpResponse.statusCode)")
}
return data
}
.decode(type: MCPResponse<T>.self, decoder: JSONDecoder())
.tryMap { mcpResponse in
if let data = mcpResponse.data {
return data
} else if let error = mcpResponse.error {
throw APIError.networkError(error)
} else {
throw APIError.networkError("Invalid MCP response")
}
}
.mapError { error in
if error is DecodingError {
return APIError.decodingError
} else if let apiError = error as? APIError {
return apiError
} else {
return APIError.networkError(error.localizedDescription)
}
}
.eraseToAnyPublisher()
}
// ai.card
private func request<T: Decodable>(_ endpoint: String,
method: String = "GET",
body: Data? = nil,
authenticated: Bool = true) -> AnyPublisher<T, APIError> {
guard let url = URL(string: "\(baseURL)\(endpoint)") else {
return Fail(error: APIError.invalidURL).eraseToAnyPublisher()
}
var request = URLRequest(url: url)
request.httpMethod = method
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
if authenticated, let token = authToken {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
if let body = body {
request.httpBody = body
}
return URLSession.shared.dataTaskPublisher(for: request)
.tryMap { data, response in
guard let httpResponse = response as? HTTPURLResponse else {
throw APIError.networkError("Invalid response")
}
if httpResponse.statusCode == 401 {
throw APIError.unauthorized
}
if !(200...299).contains(httpResponse.statusCode) {
throw APIError.networkError("Status code: \(httpResponse.statusCode)")
}
return data
}
.decode(type: T.self, decoder: JSONDecoder())
.mapError { error in
if error is DecodingError {
return APIError.decodingError
} else if let apiError = error as? APIError {
return apiError
} else {
return APIError.networkError(error.localizedDescription)
}
}
.eraseToAnyPublisher()
}
// MARK: - Auth
func login(identifier: String, password: String) -> AnyPublisher<LoginResponse, APIError> {
let loginRequest = LoginRequest(identifier: identifier, password: password)
guard let body = try? JSONEncoder().encode(loginRequest) else {
return Fail(error: APIError.decodingError).eraseToAnyPublisher()
}
return request("/auth/login", method: "POST", body: body, authenticated: false)
.handleEvents(receiveOutput: { [weak self] (response: LoginResponse) in
self?.authToken = response.accessToken
})
.eraseToAnyPublisher()
}
func logout() -> AnyPublisher<Void, APIError> {
request("/auth/logout", method: "POST")
.map { (_: [String: String]) in () }
.handleEvents(receiveCompletion: { [weak self] _ in
self?.authToken = nil
})
.eraseToAnyPublisher()
}
func verify() -> AnyPublisher<User, APIError> {
request("/auth/verify")
}
// MARK: - Cards (ai.card)
func drawCard(userDid: String, isPaid: Bool = false) -> AnyPublisher<CardDrawResult, APIError> {
let body = try? JSONEncoder().encode([
"user_did": userDid,
"is_paid": isPaid
])
return request("/cards/draw", method: "POST", body: body)
}
func getUserCards(userDid: String) -> AnyPublisher<[Card], APIError> {
return request("/cards/user/\(userDid)")
}
func getCardDetails(cardId: Int) -> AnyPublisher<Card, APIError> {
return request("/cards/\(cardId)")
}
func getGachaStats() -> AnyPublisher<GachaStats, APIError> {
return request("/cards/stats")
}
func getUniqueCards() -> AnyPublisher<[[String: Any]], APIError> {
return request("/cards/unique")
}
func getSystemStatus() -> AnyPublisher<[String: Any], APIError> {
return request("/health")
}
}
// MARK: - AI Enhanced API (Optional ai.gpt integration)
extension APIClient {
func analyzeCollection(userDid: String) -> AnyPublisher<CollectionAnalysis, APIError> {
let parameters: [String: Any] = [
"did": userDid
]
return mcpRequest("/card_analyze_collection", parameters: parameters)
.catch { error -> AnyPublisher<CollectionAnalysis, APIError> in
// AI
return Fail(error: APIError.networkError("AI分析機能を利用するにはai.gptサーバーが必要です")).eraseToAnyPublisher()
}
.eraseToAnyPublisher()
}
func getEnhancedStats() -> AnyPublisher<GachaStats, APIError> {
return mcpRequest("/card_get_gacha_stats", parameters: [:])
.catch { [weak self] error -> AnyPublisher<GachaStats, APIError> in
// AI
print("AI統計が利用できません、基本統計に切り替えます: \(error)")
return self?.getGachaStats() ?? Fail(error: error).eraseToAnyPublisher()
}
.eraseToAnyPublisher()
}
func isAIAvailable() -> AnyPublisher<Bool, Never> {
guard let url = URL(string: "\(aiGptBaseURL)/health") else {
return Just(false).eraseToAnyPublisher()
}
return URLSession.shared.dataTaskPublisher(for: url)
.map { _ in true }
.catch { _ in Just(false) }
.eraseToAnyPublisher()
}
}

View File

@@ -0,0 +1,355 @@
import Foundation
import Combine
import AuthenticationServices
struct AtprotoSession: Codable {
let did: String
let handle: String
let accessJwt: String
let refreshJwt: String
let email: String?
let emailConfirmed: Bool?
}
class AtprotoOAuthService: NSObject, ObservableObject {
static let shared = AtprotoOAuthService()
@Published var session: AtprotoSession?
@Published var isAuthenticated: Bool = false
private var authSession: ASWebAuthenticationSession?
private let clientId: String
private let redirectUri: String
private let scope = "atproto transition:generic"
override init() {
// Generate client metadata URL
self.clientId = "\(Bundle.main.bundleIdentifier ?? "ai.card")/client-metadata.json"
self.redirectUri = "aicard://oauth/callback"
super.init()
loadSessionFromKeychain()
}
// MARK: - OAuth Flow
func initiateOAuthFlow() -> AnyPublisher<AtprotoSession, Error> {
return Future { [weak self] promise in
guard let self = self else {
promise(.failure(OAuthError.invalidState))
return
}
Task {
do {
let authURL = try await self.buildAuthorizationURL()
DispatchQueue.main.async {
self.startWebAuthenticationSession(url: authURL) { result in
switch result {
case .success(let session):
self.session = session
self.isAuthenticated = true
self.saveSessionToKeychain(session)
promise(.success(session))
case .failure(let error):
promise(.failure(error))
}
}
}
} catch {
promise(.failure(error))
}
}
}
.eraseToAnyPublisher()
}
private func buildAuthorizationURL() async throws -> URL {
// Generate PKCE parameters
let state = generateRandomString(32)
let codeVerifier = generateRandomString(128)
let codeChallenge = try generateCodeChallenge(from: codeVerifier)
// Store PKCE parameters
UserDefaults.standard.set(state, forKey: "oauth_state")
UserDefaults.standard.set(codeVerifier, forKey: "oauth_code_verifier")
// For development: use mock authorization server
// In production, this would discover the actual atproto authorization server
let authServer = "https://bsky.social" // Mock - should be discovered
var components = URLComponents(string: "\(authServer)/oauth/authorize")!
components.queryItems = [
URLQueryItem(name: "response_type", value: "code"),
URLQueryItem(name: "client_id", value: clientId),
URLQueryItem(name: "redirect_uri", value: redirectUri),
URLQueryItem(name: "scope", value: scope),
URLQueryItem(name: "state", value: state),
URLQueryItem(name: "code_challenge", value: codeChallenge),
URLQueryItem(name: "code_challenge_method", value: "S256")
]
guard let url = components.url else {
throw OAuthError.invalidURL
}
return url
}
private func startWebAuthenticationSession(url: URL, completion: @escaping (Result<AtprotoSession, Error>) -> Void) {
authSession = ASWebAuthenticationSession(url: url, callbackURLScheme: "aicard") { [weak self] callbackURL, error in
if let error = error {
if case ASWebAuthenticationSessionError.canceledLogin = error {
completion(.failure(OAuthError.userCancelled))
} else {
completion(.failure(error))
}
return
}
guard let callbackURL = callbackURL else {
completion(.failure(OAuthError.invalidCallback))
return
}
Task {
do {
let session = try await self?.handleOAuthCallback(callbackURL: callbackURL)
if let session = session {
completion(.success(session))
} else {
completion(.failure(OAuthError.invalidState))
}
} catch {
completion(.failure(error))
}
}
}
authSession?.presentationContextProvider = self
authSession?.prefersEphemeralWebBrowserSession = false
authSession?.start()
}
private func handleOAuthCallback(callbackURL: URL) async throws -> AtprotoSession {
guard let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false),
let queryItems = components.queryItems else {
throw OAuthError.invalidCallback
}
var code: String?
var state: String?
var error: String?
for item in queryItems {
switch item.name {
case "code":
code = item.value
case "state":
state = item.value
case "error":
error = item.value
default:
break
}
}
if let error = error {
throw OAuthError.authorizationFailed(error)
}
guard let code = code, let state = state else {
throw OAuthError.missingParameters
}
// Verify state
let storedState = UserDefaults.standard.string(forKey: "oauth_state")
guard state == storedState else {
throw OAuthError.invalidState
}
// Get code verifier
guard let codeVerifier = UserDefaults.standard.string(forKey: "oauth_code_verifier") else {
throw OAuthError.missingCodeVerifier
}
// Exchange code for tokens
let session = try await exchangeCodeForTokens(code: code, codeVerifier: codeVerifier)
// Clean up temporary data
UserDefaults.standard.removeObject(forKey: "oauth_state")
UserDefaults.standard.removeObject(forKey: "oauth_code_verifier")
return session
}
private func exchangeCodeForTokens(code: String, codeVerifier: String) async throws -> AtprotoSession {
// This is a mock implementation
// In production, this would make a proper token exchange request
// For development, return a mock session
let mockSession = AtprotoSession(
did: "did:plc:mock123456789",
handle: "user.bsky.social",
accessJwt: "mock_access_token",
refreshJwt: "mock_refresh_token",
email: nil,
emailConfirmed: nil
)
return mockSession
}
// MARK: - Session Management
func refreshTokens() async throws -> AtprotoSession {
guard let currentSession = session else {
throw OAuthError.noSession
}
// This would make a proper token refresh request
// For now, return the existing session
return currentSession
}
func logout() {
session = nil
isAuthenticated = false
deleteSessionFromKeychain()
// Cancel any ongoing auth session
authSession?.cancel()
authSession = nil
}
// MARK: - Keychain Storage
private func saveSessionToKeychain(_ session: AtprotoSession) {
guard let data = try? JSONEncoder().encode(session) else { return }
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: "atproto_session",
kSecValueData as String: data
]
// Delete existing item
SecItemDelete(query as CFDictionary)
// Add new item
SecItemAdd(query as CFDictionary, nil)
}
private func loadSessionFromKeychain() {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: "atproto_session",
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecSuccess,
let data = result as? Data,
let session = try? JSONDecoder().decode(AtprotoSession.self, from: data) {
self.session = session
self.isAuthenticated = true
}
}
private func deleteSessionFromKeychain() {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: "atproto_session"
]
SecItemDelete(query as CFDictionary)
}
// MARK: - Utility Methods
private func generateRandomString(_ length: Int) -> String {
let chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
return String((0..<length).map { _ in chars.randomElement()! })
}
private func generateCodeChallenge(from verifier: String) throws -> String {
guard let data = verifier.data(using: .utf8) else {
throw OAuthError.encodingError
}
let digest = SHA256.hash(data: data)
return Data(digest).base64URLEncodedString()
}
}
// MARK: - ASWebAuthenticationPresentationContextProviding
extension AtprotoOAuthService: ASWebAuthenticationPresentationContextProviding {
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
return UIApplication.shared.windows.first { $0.isKeyWindow } ?? ASPresentationAnchor()
}
}
// MARK: - Errors
enum OAuthError: LocalizedError {
case invalidURL
case invalidState
case invalidCallback
case missingParameters
case missingCodeVerifier
case authorizationFailed(String)
case userCancelled
case noSession
case encodingError
var errorDescription: String? {
switch self {
case .invalidURL:
return "無効なURLです"
case .invalidState:
return "無効な状態パラメータです"
case .invalidCallback:
return "無効なコールバックです"
case .missingParameters:
return "必要なパラメータが不足しています"
case .missingCodeVerifier:
return "コード検証子が見つかりません"
case .authorizationFailed(let error):
return "認証に失敗しました: \(error)"
case .userCancelled:
return "ユーザーによってキャンセルされました"
case .noSession:
return "セッションがありません"
case .encodingError:
return "エンコードエラーです"
}
}
}
// MARK: - Data Extension for Base64URL
extension Data {
func base64URLEncodedString() -> String {
return base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
}
// MARK: - SHA256 (simplified for demo)
import CryptoKit
extension SHA256 {
static func hash(data: Data) -> SHA256.Digest {
return SHA256.hash(data: data)
}
}

View File

@@ -0,0 +1,138 @@
import Foundation
import Combine
import SwiftUI
class AuthManager: ObservableObject {
@Published var isAuthenticated = false
@Published var currentUser: User?
@Published var isLoading = false
@Published var errorMessage: String?
@Published var authMode: AuthMode = .oauth
private var cancellables = Set<AnyCancellable>()
private let apiClient = APIClient.shared
private let oauthService = AtprotoOAuthService.shared
enum AuthMode {
case oauth
case legacy
}
init() {
// Monitor OAuth service
oauthService.$isAuthenticated
.receive(on: DispatchQueue.main)
.sink { [weak self] isAuth in
if isAuth, let session = self?.oauthService.session {
self?.isAuthenticated = true
self?.currentUser = User(did: session.did, handle: session.handle)
}
}
.store(in: &cancellables)
checkAuthStatus()
}
private func checkAuthStatus() {
isLoading = true
// Check OAuth session first
if oauthService.isAuthenticated, let session = oauthService.session {
isAuthenticated = true
currentUser = User(did: session.did, handle: session.handle)
isLoading = false
return
}
// Fallback to legacy auth
apiClient.verify()
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { [weak self] completion in
self?.isLoading = false
if case .failure = completion {
self?.isAuthenticated = false
self?.currentUser = nil
}
},
receiveValue: { [weak self] user in
self?.isAuthenticated = true
self?.currentUser = user
}
)
.store(in: &cancellables)
}
func loginWithOAuth() {
isLoading = true
errorMessage = nil
oauthService.initiateOAuthFlow()
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { [weak self] completion in
self?.isLoading = false
if case .failure(let error) = completion {
self?.errorMessage = error.localizedDescription
}
},
receiveValue: { [weak self] session in
self?.isAuthenticated = true
self?.currentUser = User(did: session.did, handle: session.handle)
}
)
.store(in: &cancellables)
}
func loginWithPassword(identifier: String, password: String) {
isLoading = true
errorMessage = nil
apiClient.login(identifier: identifier, password: password)
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { [weak self] completion in
self?.isLoading = false
if case .failure(let error) = completion {
self?.errorMessage = self?.getErrorMessage(from: error)
}
},
receiveValue: { [weak self] response in
self?.isAuthenticated = true
self?.currentUser = User(did: response.did, handle: response.handle)
}
)
.store(in: &cancellables)
}
func logout() {
isLoading = true
// Logout from both services
oauthService.logout()
apiClient.logout()
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { [weak self] _ in
self?.isLoading = false
self?.isAuthenticated = false
self?.currentUser = nil
UserDefaults.standard.removeObject(forKey: "authToken")
},
receiveValue: { _ in }
)
.store(in: &cancellables)
}
private func getErrorMessage(from error: APIError) -> String {
switch error {
case .unauthorized:
return "認証情報が正しくありません"
case .networkError:
return "ネットワークエラーが発生しました"
default:
return "エラーが発生しました"
}
}
}

View File

@@ -0,0 +1,73 @@
import Foundation
import Combine
import SwiftUI
class CardManager: ObservableObject {
@Published var userCards: [Card] = []
@Published var isLoading = false
@Published var errorMessage: String?
@Published var currentDraw: CardDrawResult?
@Published var isDrawing = false
private var cancellables = Set<AnyCancellable>()
private let apiClient = APIClient.shared
func loadUserCards(userDid: String) {
isLoading = true
apiClient.getUserCards(userDid: userDid)
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { [weak self] completion in
self?.isLoading = false
if case .failure(let error) = completion {
self?.errorMessage = self?.getErrorMessage(from: error)
}
},
receiveValue: { [weak self] cards in
self?.userCards = cards
}
)
.store(in: &cancellables)
}
func drawCard(userDid: String, isPaid: Bool = false) {
isDrawing = true
errorMessage = nil
apiClient.drawCard(userDid: userDid, isPaid: isPaid)
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { [weak self] completion in
if case .failure(let error) = completion {
self?.isDrawing = false
self?.errorMessage = self?.getErrorMessage(from: error)
}
},
receiveValue: { [weak self] result in
self?.currentDraw = result
//
}
)
.store(in: &cancellables)
}
func completeCardDraw() {
if let newCard = currentDraw?.card {
userCards.append(newCard)
}
currentDraw = nil
isDrawing = false
}
private func getErrorMessage(from error: APIError) -> String {
switch error {
case .unauthorized:
return "認証が必要です"
case .networkError:
return "ネットワークエラーが発生しました"
default:
return "エラーが発生しました"
}
}
}

View File

@@ -0,0 +1,28 @@
import SwiftUI
extension Color {
init(hex: String) {
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var int: UInt64 = 0
Scanner(string: hex).scanHexInt64(&int)
let a, r, g, b: UInt64
switch hex.count {
case 3: // RGB (12-bit)
(a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
case 6: // RGB (24-bit)
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
case 8: // ARGB (32-bit)
(a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
default:
(a, r, g, b) = (1, 1, 1, 0)
}
self.init(
.sRGB,
red: Double(r) / 255,
green: Double(g) / 255,
blue: Double(b) / 255,
opacity: Double(a) / 255
)
}
}

View File

@@ -0,0 +1,247 @@
import SwiftUI
struct CardView: View {
let card: Card
let isRevealing: Bool
@State private var isFlipped = false
init(card: Card, isRevealing: Bool = false) {
self.card = card
self.isRevealing = isRevealing
}
var body: some View {
ZStack {
// Card background
RoundedRectangle(cornerRadius: 16)
.fill(
LinearGradient(
gradient: Gradient(colors: gradientColors),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 200, height: 280)
// Card content
VStack(spacing: 16) {
// Header
HStack {
Text("#\(card.id)")
.font(.caption)
.foregroundColor(.white.opacity(0.7))
Spacer()
Text("CP: \(card.cp)")
.font(.caption)
.foregroundColor(.white.opacity(0.7))
}
Spacer()
// Card name and icon
VStack(spacing: 12) {
// Card icon (could be an image)
Circle()
.fill(Color(hex: cardInfo.color))
.frame(width: 60, height: 60)
.overlay(
Text(cardInfo.name.prefix(1))
.font(.title)
.fontWeight(.bold)
.foregroundColor(.white)
)
Text(cardInfo.name)
.font(.title2)
.fontWeight(.bold)
.foregroundColor(.white)
.multilineTextAlignment(.center)
if card.isUnique {
UniqueBadge()
}
}
Spacer()
// Skill
if let skill = card.skill, !skill.isEmpty {
Text(skill)
.font(.caption)
.foregroundColor(.white.opacity(0.8))
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.black.opacity(0.3))
.cornerRadius(8)
}
// Rarity
Text(card.status.displayName)
.font(.caption)
.foregroundColor(.white.opacity(0.7))
.textCase(.uppercase)
.tracking(1)
}
.padding(20)
// Special effects
if card.status == .kira {
KiraEffect()
} else if card.status == .unique {
UniqueEffect()
}
}
.rotation3DEffect(
.degrees(isRevealing && !isFlipped ? 180 : 0),
axis: (x: 0, y: 1, z: 0)
)
.onAppear {
if isRevealing {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
withAnimation(.easeInOut(duration: 0.8)) {
isFlipped = true
}
}
}
}
.scaleEffect(isRevealing ? 1.1 : 1.0)
.shadow(color: .black.opacity(0.3), radius: 10, x: 0, y: 5)
}
private var cardInfo: CardInfo {
CardInfo.all[card.id] ?? CardInfo(id: card.id, name: "Unknown", color: "666666", description: "")
}
private var gradientColors: [Color] {
card.status.gradientColors.map { Color(hex: $0) }
}
}
struct UniqueBadge: View {
@State private var phase: CGFloat = 0
var body: some View {
Text("UNIQUE")
.font(.caption2)
.fontWeight(.bold)
.foregroundColor(.white)
.padding(.horizontal, 12)
.padding(.vertical, 4)
.background(
LinearGradient(
gradient: Gradient(colors: [
Color(hex: "ff00ff"),
Color(hex: "00ffff")
]),
startPoint: .leading,
endPoint: .trailing
)
.hueRotation(.degrees(phase))
)
.cornerRadius(12)
.onAppear {
withAnimation(.linear(duration: 2).repeatForever(autoreverses: false)) {
phase = 360
}
}
}
}
struct KiraEffect: View {
@State private var sparkles: [SparkleData] = []
var body: some View {
ZStack {
ForEach(sparkles, id: \.id) { sparkle in
Image(systemName: "sparkle")
.foregroundColor(.yellow)
.font(.system(size: sparkle.size))
.position(x: sparkle.x, y: sparkle.y)
.opacity(sparkle.opacity)
}
}
.onAppear {
generateSparkles()
}
}
private func generateSparkles() {
for i in 0..<10 {
let sparkle = SparkleData(
id: i,
x: CGFloat.random(in: 20...180),
y: CGFloat.random(in: 20...260),
size: CGFloat.random(in: 8...16),
opacity: Double.random(in: 0.3...0.8)
)
sparkles.append(sparkle)
DispatchQueue.main.asyncAfter(deadline: .now() + Double.random(in: 0...2)) {
withAnimation(.easeInOut(duration: 1).repeatForever(autoreverses: true)) {
if let index = sparkles.firstIndex(where: { $0.id == sparkle.id }) {
sparkles[index].opacity = sparkles[index].opacity > 0.5 ? 0.2 : 0.8
}
}
}
}
}
}
struct UniqueEffect: View {
@State private var pulseScale: CGFloat = 1.0
var body: some View {
RoundedRectangle(cornerRadius: 16)
.stroke(
LinearGradient(
gradient: Gradient(colors: [
Color(hex: "ff00ff"),
Color(hex: "00ffff"),
Color(hex: "ff00ff")
]),
startPoint: .topLeading,
endPoint: .bottomTrailing
),
lineWidth: 3
)
.scaleEffect(pulseScale)
.opacity(0.8)
.onAppear {
withAnimation(.easeInOut(duration: 1.5).repeatForever(autoreverses: true)) {
pulseScale = 1.05
}
}
}
}
struct SparkleData {
let id: Int
let x: CGFloat
let y: CGFloat
let size: CGFloat
var opacity: Double
}
struct CardView_Previews: PreviewProvider {
static var previews: some View {
let sampleCard = Card(
id: 0,
cp: 100,
status: .unique,
skill: "サンプルスキル",
ownerDid: "did:plc:example",
obtainedAt: Date(),
isUnique: true,
uniqueId: "unique-123"
)
VStack {
CardView(card: sampleCard)
CardView(card: sampleCard, isRevealing: true)
}
.padding()
.background(Color.black)
}
}

View File

@@ -0,0 +1,341 @@
import SwiftUI
struct CollectionView: View {
@EnvironmentObject var authManager: AuthManager
@EnvironmentObject var cardManager: CardManager
@State private var selectedCard: Card?
@State private var searchText = ""
@State private var selectedRarity: CardRarity?
@State private var showingFilters = false
var filteredCards: [Card] {
var cards = cardManager.userCards
// Search filter
if !searchText.isEmpty {
cards = cards.filter { card in
let cardInfo = CardInfo.all[card.id]
return cardInfo?.name.localizedCaseInsensitiveContains(searchText) ?? false
}
}
// Rarity filter
if let selectedRarity = selectedRarity {
cards = cards.filter { $0.status == selectedRarity }
}
return cards.sorted { $0.obtainedAt > $1.obtainedAt }
}
var body: some View {
NavigationView {
ZStack {
// Background
LinearGradient(
gradient: Gradient(colors: [
Color(hex: "0a0a0a"),
Color(hex: "1a1a1a")
]),
startPoint: .top,
endPoint: .bottom
)
.ignoresSafeArea()
VStack(spacing: 0) {
// Search and filter bar
VStack(spacing: 12) {
HStack {
// Search field
HStack {
Image(systemName: "magnifyingglass")
.foregroundColor(.secondary)
TextField("カードを検索...", text: $searchText)
.textFieldStyle(PlainTextFieldStyle())
if !searchText.isEmpty {
Button(action: {
searchText = ""
}) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.secondary)
}
}
}
.padding(12)
.background(Color.white.opacity(0.1))
.cornerRadius(12)
// Filter button
Button(action: {
showingFilters.toggle()
}) {
Image(systemName: "line.3.horizontal.decrease.circle")
.font(.title2)
.foregroundColor(Color(hex: "fff700"))
}
}
// Filter chips
if showingFilters {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
FilterChip(
title: "すべて",
isSelected: selectedRarity == nil
) {
selectedRarity = nil
}
ForEach(CardRarity.allCases, id: \.self) { rarity in
FilterChip(
title: rarity.displayName,
isSelected: selectedRarity == rarity
) {
selectedRarity = selectedRarity == rarity ? nil : rarity
}
}
}
.padding(.horizontal)
}
}
}
.padding(.horizontal)
.padding(.top)
// Collection stats
CollectionStatsView(cards: cardManager.userCards)
.padding(.horizontal)
.padding(.vertical, 8)
// Card grid
if cardManager.isLoading {
Spacer()
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: Color(hex: "fff700")))
Spacer()
} else if filteredCards.isEmpty {
Spacer()
EmptyCollectionView(hasCards: !cardManager.userCards.isEmpty)
Spacer()
} else {
ScrollView {
LazyVGrid(
columns: [
GridItem(.flexible(), spacing: 16),
GridItem(.flexible(), spacing: 16)
],
spacing: 20
) {
ForEach(filteredCards) { card in
CardView(card: card)
.scaleEffect(0.8)
.onTapGesture {
selectedCard = card
}
}
}
.padding(.horizontal)
.padding(.bottom, 100)
}
}
}
}
.navigationTitle("コレクション")
.navigationBarTitleDisplayMode(.large)
.onAppear {
if let userDid = authManager.currentUser?.did {
cardManager.loadUserCards(userDid: userDid)
}
}
.sheet(item: $selectedCard) { card in
CardDetailView(card: card)
}
}
}
}
struct FilterChip: View {
let title: String
let isSelected: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
Text(title)
.font(.caption)
.fontWeight(isSelected ? .bold : .medium)
.foregroundColor(isSelected ? .black : .white)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
isSelected
? Color(hex: "fff700")
: Color.white.opacity(0.1)
)
.cornerRadius(16)
}
}
}
struct CollectionStatsView: View {
let cards: [Card]
private var stats: (total: Int, unique: Int, completion: Double) {
let total = cards.count
let uniqueCards = Set(cards.map { $0.id }).count
let completion = Double(uniqueCards) / 16.0 * 100
return (total, uniqueCards, completion)
}
var body: some View {
HStack(spacing: 20) {
StatItem(title: "総枚数", value: "\(stats.total)")
StatItem(title: "種類", value: "\(stats.unique)/16")
StatItem(title: "完成度", value: String(format: "%.1f%%", stats.completion))
}
.padding(.vertical, 12)
.padding(.horizontal, 16)
.background(Color.white.opacity(0.05))
.cornerRadius(12)
}
}
struct StatItem: View {
let title: String
let value: String
var body: some View {
VStack(spacing: 4) {
Text(value)
.font(.headline)
.fontWeight(.bold)
.foregroundColor(Color(hex: "fff700"))
Text(title)
.font(.caption)
.foregroundColor(.secondary)
}
}
}
struct EmptyCollectionView: View {
let hasCards: Bool
var body: some View {
VStack(spacing: 16) {
Image(systemName: hasCards ? "magnifyingglass" : "square.stack.3d.up")
.font(.system(size: 48))
.foregroundColor(.secondary)
Text(hasCards ? "検索結果がありません" : "カードがありません")
.font(.title2)
.fontWeight(.semibold)
.foregroundColor(.white)
Text(hasCards ? "検索条件を変更してください" : "ガチャでカードを引いてみましょう")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.padding()
}
}
struct CardDetailView: View {
let card: Card
@Environment(\.presentationMode) var presentationMode
private var cardInfo: CardInfo {
CardInfo.all[card.id] ?? CardInfo(id: card.id, name: "Unknown", color: "666666", description: "")
}
var body: some View {
NavigationView {
ZStack {
LinearGradient(
gradient: Gradient(colors: [
Color(hex: "0a0a0a"),
Color(hex: "1a1a1a")
]),
startPoint: .top,
endPoint: .bottom
)
.ignoresSafeArea()
ScrollView {
VStack(spacing: 24) {
// Card display
CardView(card: card)
.scaleEffect(1.2)
.padding(.top, 20)
// Card details
VStack(alignment: .leading, spacing: 16) {
DetailRow(title: "ID", value: "#\(card.id)")
DetailRow(title: "名前", value: cardInfo.name)
DetailRow(title: "CP", value: "\(card.cp)")
DetailRow(title: "レアリティ", value: card.status.displayName)
if let skill = card.skill, !skill.isEmpty {
DetailRow(title: "スキル", value: skill)
}
if card.isUnique, let uniqueId = card.uniqueId {
DetailRow(title: "ユニークID", value: uniqueId)
}
DetailRow(
title: "取得日時",
value: DateFormatter.localizedString(
from: card.obtainedAt,
dateStyle: .medium,
timeStyle: .short
)
)
}
.padding(.horizontal)
}
.padding(.bottom, 100)
}
}
.navigationTitle(cardInfo.name)
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(
trailing: Button("閉じる") {
presentationMode.wrappedValue.dismiss()
}
)
}
}
}
struct DetailRow: View {
let title: String
let value: String
var body: some View {
HStack {
Text(title)
.font(.subheadline)
.foregroundColor(.secondary)
Spacer()
Text(value)
.font(.subheadline)
.fontWeight(.medium)
.foregroundColor(.white)
}
.padding(.vertical, 4)
}
}
struct CollectionView_Previews: PreviewProvider {
static var previews: some View {
CollectionView()
.environmentObject(AuthManager())
.environmentObject(CardManager())
}
}

View File

@@ -0,0 +1,310 @@
import SwiftUI
struct GachaAnimationView: View {
let drawResult: CardDrawResult
let onComplete: () -> Void
@State private var phase: AnimationPhase = .opening
@State private var packScale: CGFloat = 0
@State private var packOpacity: Double = 0
@State private var cardScale: CGFloat = 0
@State private var cardOpacity: Double = 0
@State private var showCard = false
@State private var effectOpacity: Double = 0
enum AnimationPhase {
case opening
case revealing
case complete
}
var body: some View {
ZStack {
// Dark overlay
Rectangle()
.fill(Color.black.opacity(0.9))
.ignoresSafeArea()
.onTapGesture {
if phase == .complete {
onComplete()
}
}
// Background effects based on card rarity
if phase != .opening {
backgroundEffect
.opacity(effectOpacity)
}
// Pack animation
if phase == .opening {
GachaPackView()
.scaleEffect(packScale)
.opacity(packOpacity)
}
// Card reveal
if showCard {
CardView(card: drawResult.card, isRevealing: true)
.scaleEffect(cardScale)
.opacity(cardOpacity)
}
// Complete state overlay
if phase == .complete {
VStack {
Spacer()
VStack(spacing: 16) {
if drawResult.isNew {
Text("新しいカードを獲得!")
.font(.title2)
.fontWeight(.bold)
.foregroundColor(Color(hex: "fff700"))
}
Text("タップして続ける")
.font(.caption)
.foregroundColor(.white.opacity(0.7))
}
.padding(.bottom, 50)
}
}
}
.onAppear {
startAnimation()
}
}
private var backgroundEffect: some View {
Group {
switch drawResult.animationType {
case "unique":
UniqueBackgroundEffect()
case "kira":
KiraBackgroundEffect()
case "rare":
RareBackgroundEffect()
default:
EmptyView()
}
}
}
private func startAnimation() {
// Phase 1: Pack appears
withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) {
packScale = 1.0
packOpacity = 1.0
}
// Phase 2: Pack disappears, card appears
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
withAnimation(.easeOut(duration: 0.3)) {
packOpacity = 0
}
phase = .revealing
showCard = true
withAnimation(.spring(response: 0.8, dampingFraction: 0.6)) {
cardScale = 1.0
cardOpacity = 1.0
effectOpacity = 1.0
}
}
// Phase 3: Animation complete
DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) {
phase = .complete
}
}
}
struct GachaPackView: View {
@State private var glowIntensity: Double = 0.5
var body: some View {
ZStack {
// Pack background
RoundedRectangle(cornerRadius: 20)
.fill(
LinearGradient(
gradient: Gradient(colors: [
Color(hex: "667eea"),
Color(hex: "764ba2")
]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 150, height: 200)
// Pack glow
RoundedRectangle(cornerRadius: 20)
.stroke(Color.white, lineWidth: 2)
.frame(width: 150, height: 200)
.blur(radius: 10)
.opacity(glowIntensity)
// Pack label
VStack {
Image(systemName: "sparkles")
.font(.title)
.foregroundColor(.white)
Text("ai.card")
.font(.headline)
.fontWeight(.bold)
.foregroundColor(.white)
}
}
.onAppear {
withAnimation(.easeInOut(duration: 1).repeatForever(autoreverses: true)) {
glowIntensity = 1.0
}
}
}
}
struct UniqueBackgroundEffect: View {
@State private var particles: [ParticleData] = []
@State private var burstScale: CGFloat = 0
var body: some View {
ZStack {
// Radial burst
Circle()
.fill(
RadialGradient(
gradient: Gradient(colors: [
Color(hex: "ff00ff").opacity(0.8),
Color.clear
]),
center: .center,
startRadius: 0,
endRadius: 200
)
)
.scaleEffect(burstScale)
.onAppear {
withAnimation(.easeOut(duration: 1)) {
burstScale = 3
}
}
// Floating particles
ForEach(particles, id: \.id) { particle in
Circle()
.fill(Color(hex: particle.color))
.frame(width: particle.size, height: particle.size)
.position(x: particle.x, y: particle.y)
.opacity(particle.opacity)
}
}
.onAppear {
generateParticles()
}
}
private func generateParticles() {
for i in 0..<20 {
let particle = ParticleData(
id: i,
x: CGFloat.random(in: 0...UIScreen.main.bounds.width),
y: CGFloat.random(in: 0...UIScreen.main.bounds.height),
size: CGFloat.random(in: 4...12),
color: ["ff00ff", "00ffff", "ffffff"].randomElement() ?? "ffffff",
opacity: Double.random(in: 0.3...0.8)
)
particles.append(particle)
}
}
}
struct KiraBackgroundEffect: View {
@State private var sparkleOffset: CGFloat = -100
var body: some View {
ZStack {
ForEach(0..<5, id: \.self) { i in
Rectangle()
.fill(
LinearGradient(
gradient: Gradient(colors: [
Color.clear,
Color.yellow.opacity(0.3),
Color.clear
]),
startPoint: .leading,
endPoint: .trailing
)
)
.frame(width: 2, height: UIScreen.main.bounds.height)
.rotationEffect(.degrees(45))
.offset(x: sparkleOffset + CGFloat(i * 50))
}
}
.onAppear {
withAnimation(.linear(duration: 2).repeatForever(autoreverses: false)) {
sparkleOffset = UIScreen.main.bounds.width + 100
}
}
}
}
struct RareBackgroundEffect: View {
@State private var rippleScale: CGFloat = 0
var body: some View {
ZStack {
ForEach(0..<3, id: \.self) { i in
Circle()
.stroke(Color.blue.opacity(0.3), lineWidth: 2)
.scaleEffect(rippleScale)
.opacity(1 - rippleScale)
.animation(
.easeOut(duration: 2)
.delay(Double(i) * 0.3)
.repeatForever(autoreverses: false),
value: rippleScale
)
}
}
.onAppear {
rippleScale = 3
}
}
}
struct ParticleData {
let id: Int
let x: CGFloat
let y: CGFloat
let size: CGFloat
let color: String
let opacity: Double
}
struct GachaAnimationView_Previews: PreviewProvider {
static var previews: some View {
let sampleResult = CardDrawResult(
card: Card(
id: 0,
cp: 500,
status: .unique,
skill: "サンプルスキル",
ownerDid: "did:plc:example",
obtainedAt: Date(),
isUnique: true,
uniqueId: "unique-123"
),
isNew: true,
animationType: "unique"
)
GachaAnimationView(drawResult: sampleResult) {
print("Animation complete")
}
}
}

View File

@@ -0,0 +1,190 @@
import SwiftUI
struct GachaView: View {
@EnvironmentObject var authManager: AuthManager
@EnvironmentObject var cardManager: CardManager
@State private var showingAnimation = false
var body: some View {
ZStack {
// Background
LinearGradient(
gradient: Gradient(colors: [
Color(hex: "0a0a0a"),
Color(hex: "1a1a1a")
]),
startPoint: .top,
endPoint: .bottom
)
.ignoresSafeArea()
VStack(spacing: 40) {
// Title
VStack(spacing: 16) {
Text("カードを引く")
.font(.largeTitle)
.fontWeight(.bold)
.foregroundColor(.white)
if let user = authManager.currentUser {
Text("@\(user.handle)")
.font(.subheadline)
.foregroundColor(Color(hex: "fff700"))
}
}
Spacer()
// Gacha buttons
VStack(spacing: 20) {
GachaButton(
title: "通常ガチャ",
subtitle: "無料でカードを1枚引く",
colors: [Color(hex: "667eea"), Color(hex: "764ba2")],
action: {
drawCard(isPaid: false)
},
isLoading: cardManager.isDrawing
)
GachaButton(
title: "プレミアムガチャ",
subtitle: "レア確率アップ!",
colors: [Color(hex: "f093fb"), Color(hex: "f5576c")],
action: {
drawCard(isPaid: true)
},
isLoading: cardManager.isDrawing,
isPremium: true
)
}
.padding(.horizontal, 32)
if let errorMessage = cardManager.errorMessage {
Text(errorMessage)
.font(.caption)
.foregroundColor(.red)
.padding()
}
Spacer()
}
.padding()
// Gacha animation overlay
if let currentDraw = cardManager.currentDraw {
GachaAnimationView(
drawResult: currentDraw,
onComplete: {
cardManager.completeCardDraw()
}
)
.transition(.opacity)
.zIndex(1000)
}
}
.onAppear {
if let userDid = authManager.currentUser?.did {
cardManager.loadUserCards(userDid: userDid)
}
}
}
private func drawCard(isPaid: Bool) {
guard let userDid = authManager.currentUser?.did else { return }
cardManager.drawCard(userDid: userDid, isPaid: isPaid)
}
}
struct GachaButton: View {
let title: String
let subtitle: String
let colors: [Color]
let action: () -> Void
let isLoading: Bool
let isPremium: Bool
init(title: String, subtitle: String, colors: [Color], action: @escaping () -> Void, isLoading: Bool, isPremium: Bool = false) {
self.title = title
self.subtitle = subtitle
self.colors = colors
self.action = action
self.isLoading = isLoading
self.isPremium = isPremium
}
var body: some View {
Button(action: action) {
VStack(spacing: 8) {
Text(title)
.font(.title2)
.fontWeight(.bold)
.foregroundColor(.white)
Text(subtitle)
.font(.caption)
.foregroundColor(.white.opacity(0.8))
}
.frame(maxWidth: .infinity)
.frame(height: 80)
.background(
ZStack {
LinearGradient(
gradient: Gradient(colors: colors),
startPoint: .leading,
endPoint: .trailing
)
if isPremium {
// Shimmer effect for premium
ShimmerView()
}
}
)
.cornerRadius(16)
.shadow(color: colors.first?.opacity(0.3) ?? .clear, radius: 10, x: 0, y: 5)
.overlay(
Group {
if isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
}
}
)
}
.disabled(isLoading)
.scaleEffect(isLoading ? 0.95 : 1.0)
.animation(.easeInOut(duration: 0.1), value: isLoading)
}
}
struct ShimmerView: View {
@State private var phase: CGFloat = 0
var body: some View {
LinearGradient(
gradient: Gradient(colors: [
.clear,
.white.opacity(0.2),
.clear
]),
startPoint: .leading,
endPoint: .trailing
)
.rotationEffect(.degrees(45))
.offset(x: phase)
.onAppear {
withAnimation(.linear(duration: 2).repeatForever(autoreverses: false)) {
phase = 300
}
}
}
}
struct GachaView_Previews: PreviewProvider {
static var previews: some View {
GachaView()
.environmentObject(AuthManager())
.environmentObject(CardManager())
}
}

View File

@@ -0,0 +1,157 @@
import SwiftUI
struct LoginView: View {
@EnvironmentObject var authManager: AuthManager
@State private var identifier = ""
@State private var password = ""
@State private var showingPassword = false
var body: some View {
ZStack {
// Background
LinearGradient(
gradient: Gradient(colors: [
Color(hex: "0a0a0a"),
Color(hex: "1a1a1a")
]),
startPoint: .top,
endPoint: .bottom
)
.ignoresSafeArea()
VStack(spacing: 40) {
// Logo and title
VStack(spacing: 20) {
Text("ai.card")
.font(.system(size: 48, weight: .bold, design: .rounded))
.foregroundStyle(
LinearGradient(
gradient: Gradient(colors: [
Color(hex: "fff700"),
Color(hex: "ff00ff")
]),
startPoint: .leading,
endPoint: .trailing
)
)
Text("atprotoベースカードゲーム")
.font(.title3)
.foregroundColor(.secondary)
}
// Login form
VStack(spacing: 24) {
VStack(alignment: .leading, spacing: 8) {
Text("ハンドル または DID")
.font(.caption)
.foregroundColor(.secondary)
TextField("your.bsky.social", text: $identifier)
.textFieldStyle(CustomTextFieldStyle())
.autocapitalization(.none)
.disableAutocorrection(true)
}
VStack(alignment: .leading, spacing: 8) {
Text("アプリパスワード")
.font(.caption)
.foregroundColor(.secondary)
HStack {
if showingPassword {
TextField("アプリパスワード", text: $password)
} else {
SecureField("アプリパスワード", text: $password)
}
Button(action: {
showingPassword.toggle()
}) {
Image(systemName: showingPassword ? "eye.slash" : "eye")
.foregroundColor(.secondary)
}
}
.textFieldStyle(CustomTextFieldStyle())
Text("メインパスワードではなく、アプリパスワードを使用してください")
.font(.caption2)
.foregroundColor(.secondary)
}
if let errorMessage = authManager.errorMessage {
Text(errorMessage)
.font(.caption)
.foregroundColor(.red)
.padding(.horizontal)
}
Button(action: {
authManager.login(identifier: identifier, password: password)
}) {
HStack {
if authManager.isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .black))
.scaleEffect(0.8)
}
Text(authManager.isLoading ? "ログイン中..." : "ログイン")
.font(.headline)
.foregroundColor(.black)
}
.frame(maxWidth: .infinity)
.frame(height: 50)
.background(
LinearGradient(
gradient: Gradient(colors: [
Color(hex: "fff700"),
Color(hex: "ffd700")
]),
startPoint: .leading,
endPoint: .trailing
)
)
.cornerRadius(12)
}
.disabled(authManager.isLoading || identifier.isEmpty || password.isEmpty)
.opacity(authManager.isLoading || identifier.isEmpty || password.isEmpty ? 0.6 : 1.0)
}
.padding(.horizontal, 32)
Spacer()
VStack(spacing: 12) {
Text("ai.cardはatprotoアカウントを使用します")
.font(.caption)
.foregroundColor(.secondary)
Text("データはあなたのPDSに保存されます")
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding()
}
}
}
struct CustomTextFieldStyle: TextFieldStyle {
func _body(configuration: TextField<Self._Label>) -> some View {
configuration
.padding()
.background(Color.white.opacity(0.1))
.cornerRadius(12)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color.white.opacity(0.2), lineWidth: 1)
)
}
}
struct LoginView_Previews: PreviewProvider {
static var previews: some View {
LoginView()
.environmentObject(AuthManager())
}
}

View File

@@ -0,0 +1,265 @@
import SwiftUI
struct ProfileView: View {
@EnvironmentObject var authManager: AuthManager
@EnvironmentObject var cardManager: CardManager
@State private var showingLogoutAlert = false
var body: some View {
NavigationView {
ZStack {
// Background
LinearGradient(
gradient: Gradient(colors: [
Color(hex: "0a0a0a"),
Color(hex: "1a1a1a")
]),
startPoint: .top,
endPoint: .bottom
)
.ignoresSafeArea()
ScrollView {
VStack(spacing: 24) {
// Profile header
if let user = authManager.currentUser {
ProfileHeaderView(user: user)
}
// Collection summary
CollectionSummaryView(cards: cardManager.userCards)
// Menu items
VStack(spacing: 1) {
MenuRow(
icon: "arrow.triangle.2.circlepath",
title: "データ同期",
subtitle: "atproto PDSと同期"
) {
// TODO: Implement sync
}
MenuRow(
icon: "crown",
title: "ユニークカード",
subtitle: "所有しているユニークカード"
) {
// TODO: Show unique cards
}
MenuRow(
icon: "info.circle",
title: "アプリについて",
subtitle: "バージョン情報"
) {
// TODO: Show about
}
}
.background(Color.white.opacity(0.05))
.cornerRadius(12)
Spacer(minLength: 40)
// Logout button
Button(action: {
showingLogoutAlert = true
}) {
HStack {
Image(systemName: "rectangle.portrait.and.arrow.right")
Text("ログアウト")
}
.font(.headline)
.foregroundColor(.red)
.frame(maxWidth: .infinity)
.frame(height: 50)
.background(Color.red.opacity(0.1))
.cornerRadius(12)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color.red.opacity(0.3), lineWidth: 1)
)
}
Spacer(minLength: 100)
}
.padding(.horizontal)
}
}
.navigationTitle("プロフィール")
.navigationBarTitleDisplayMode(.large)
.alert("ログアウト", isPresented: $showingLogoutAlert) {
Button("キャンセル", role: .cancel) { }
Button("ログアウト", role: .destructive) {
authManager.logout()
}
} message: {
Text("ログアウトしますか?")
}
}
}
}
struct ProfileHeaderView: View {
let user: User
var body: some View {
VStack(spacing: 16) {
// Avatar
Circle()
.fill(
LinearGradient(
gradient: Gradient(colors: [
Color(hex: "fff700"),
Color(hex: "ff00ff")
]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 80, height: 80)
.overlay(
Text(user.handle.prefix(1).uppercased())
.font(.title)
.fontWeight(.bold)
.foregroundColor(.black)
)
// User info
VStack(spacing: 4) {
Text("@\(user.handle)")
.font(.title2)
.fontWeight(.bold)
.foregroundColor(.white)
Text(user.did)
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
}
.padding(.vertical, 20)
}
}
struct CollectionSummaryView: View {
let cards: [Card]
private var summary: (total: Int, unique: Int, rarest: CardRarity?) {
let total = cards.count
let uniqueCount = cards.filter { $0.isUnique }.count
let rarest = cards.map { $0.status }.max { lhs, rhs in
rarityOrder(lhs) < rarityOrder(rhs)
}
return (total, uniqueCount, rarest)
}
private func rarityOrder(_ rarity: CardRarity) -> Int {
switch rarity {
case .normal: return 0
case .rare: return 1
case .superRare: return 2
case .kira: return 3
case .unique: return 4
}
}
var body: some View {
VStack(spacing: 16) {
Text("コレクション統計")
.font(.headline)
.foregroundColor(.white)
HStack(spacing: 20) {
SummaryItem(
title: "総カード数",
value: "\(summary.total)",
color: Color(hex: "fff700")
)
SummaryItem(
title: "ユニーク",
value: "\(summary.unique)",
color: Color(hex: "ff00ff")
)
if let rarest = summary.rarest {
SummaryItem(
title: "最高レア",
value: rarest.displayName,
color: Color(hex: rarest.gradientColors.first ?? "ffffff")
)
}
}
}
.padding()
.background(Color.white.opacity(0.05))
.cornerRadius(12)
}
}
struct SummaryItem: View {
let title: String
let value: String
let color: Color
var body: some View {
VStack(spacing: 8) {
Text(value)
.font(.title2)
.fontWeight(.bold)
.foregroundColor(color)
Text(title)
.font(.caption)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
}
}
struct MenuRow: View {
let icon: String
let title: String
let subtitle: String
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 12) {
Image(systemName: icon)
.font(.title2)
.foregroundColor(Color(hex: "fff700"))
.frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.headline)
.foregroundColor(.white)
Text(subtitle)
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(.secondary)
}
.padding()
}
.buttonStyle(PlainButtonStyle())
}
}
struct ProfileView_Previews: PreviewProvider {
static var previews: some View {
ProfileView()
.environmentObject(AuthManager())
.environmentObject(CardManager())
}
}

28
ios/Package.swift Normal file
View File

@@ -0,0 +1,28 @@
// swift-tools-version: 5.7
import PackageDescription
let package = Package(
name: "AiCard",
platforms: [
.iOS(.v16)
],
products: [
.library(
name: "AiCard",
targets: ["AiCard"]
),
],
dependencies: [
// SwiftUI is included by default
],
targets: [
.target(
name: "AiCard",
dependencies: []
),
.testTarget(
name: "AiCardTests",
dependencies: ["AiCard"]
),
]
)

121
ios/README.md Normal file
View File

@@ -0,0 +1,121 @@
# ai.card iOS App
atprotoベースのカードゲーム「ai.card」のiOSアプリです。
## 特徴
- **atproto統合**: 分散型認証とデータ主権
- **リッチなアニメーション**: ガチャの迫力ある演出
- **カードコレクション**: 美しいカード表示とフィルタリング
- **ユニークカードシステム**: 世界で一人だけが所有できるカード
## アーキテクチャ
### MVVM + Combine
```
Views/
├── LoginView # atprotoログイン
├── GachaView # ガチャ画面
├── CollectionView # コレクション画面
├── ProfileView # プロフィール画面
├── CardView # カード表示コンポーネント
└── GachaAnimationView # ガチャアニメーション
Services/
├── APIClient # REST API通信
├── AuthManager # 認証管理
└── CardManager # カード管理
Models/
├── Card # カードデータモデル
└── User # ユーザーデータモデル
```
### 技術スタック
- **UI**: SwiftUI
- **データフロー**: Combine
- **ネットワーク**: URLSession
- **認証**: atproto (JWT)
- **最小対応OS**: iOS 16.0
## セットアップ
### 1. Xcodeプロジェクトを開く
```bash
cd ios/AiCard
open AiCard.xcodeproj
```
### 2. API設定
開発環境では自動的に `localhost:8000` に接続します。
本番環境では `api.card.syui.ai` に接続します。
### 3. ビルド & 実行
- シミュレーターまたは実機でビルド
- atprotoアカウントでログイン
- ガチャを引いてカードを集める
## 主要機能
### 認証
- atproto DIDベース認証
- アプリパスワード使用
- 自動セッション管理
### ガチャシステム
- 通常ガチャ(無料)
- プレミアムガチャ(確率アップ)
- レアリティ別アニメーション
- ユニークカード対応
### カードコレクション
- グリッド表示
- 検索機能
- レアリティフィルタ
- 詳細表示
### プロフィール
- ユーザー情報表示
- コレクション統計
- データ同期機能
## カードシステム
### レアリティ
- **ノーマル**: 基本カード
- **レア**: 少し珍しいカード
- **スーパーレア**: とても珍しいカード
- **キラ**: 光る演出付きカード0.1%
- **ユニーク**: 世界で一人だけ0.0001%
### 視覚効果
- レアリティ別グラデーション
- キラカードのスパークル効果
- ユニークカードのオーラ効果
- 3Dフリップアニメーション
## 今後の実装予定
- [ ] Push通知
- [ ] カード交換機能
- [ ] AI.verse連携
- [ ] ダークモード対応
- [ ] iPad最適化
- [ ] ウィジェット対応
## 注意事項
- iOS 16.0以上が必要
- atprotoアカウントが必要
- インターネット接続が必要

View File

@@ -0,0 +1,134 @@
-- PostgreSQL migration for ai.card database schema
-- Create custom types
CREATE TYPE card_rarity AS ENUM ('normal', 'rare', 'super_rare', 'kira', 'unique');
-- Enable UUID extension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Users table - stores atproto DID-based user information
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
did TEXT NOT NULL UNIQUE, -- atproto Decentralized Identifier
handle TEXT NOT NULL, -- atproto handle (e.g., alice.bsky.social)
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_users_did ON users(did);
CREATE INDEX IF NOT EXISTS idx_users_handle ON users(handle);
-- Card master data - template definitions for all card types
CREATE TABLE IF NOT EXISTS card_master (
id INTEGER PRIMARY KEY, -- Card ID (0-15 in current system)
name TEXT NOT NULL, -- Card name (e.g., "ai", "dream", "radiance")
base_cp_min INTEGER NOT NULL, -- Minimum base CP for this card
base_cp_max INTEGER NOT NULL, -- Maximum base CP for this card
color TEXT NOT NULL, -- Card color theme
description TEXT NOT NULL -- Card description/lore
);
-- User cards - actual card instances owned by users
CREATE TABLE IF NOT EXISTS user_cards (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
card_id INTEGER NOT NULL, -- References card_master.id
cp INTEGER NOT NULL, -- Calculated CP (base_cp * rarity_multiplier)
status card_rarity NOT NULL, -- Card rarity
skill TEXT, -- Optional skill description
obtained_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
is_unique BOOLEAN NOT NULL DEFAULT FALSE,
unique_id UUID, -- UUID for unique cards
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (card_id) REFERENCES card_master(id)
);
CREATE INDEX IF NOT EXISTS idx_user_cards_user_id ON user_cards(user_id);
CREATE INDEX IF NOT EXISTS idx_user_cards_card_id ON user_cards(card_id);
CREATE INDEX IF NOT EXISTS idx_user_cards_status ON user_cards(status);
CREATE INDEX IF NOT EXISTS idx_user_cards_unique_id ON user_cards(unique_id);
-- Global unique card registry - tracks ownership of unique cards
CREATE TABLE IF NOT EXISTS unique_card_registry (
id SERIAL PRIMARY KEY,
unique_id UUID NOT NULL UNIQUE, -- UUID from user_cards.unique_id
card_id INTEGER NOT NULL, -- Which card type is unique
owner_did TEXT NOT NULL, -- Current owner's atproto DID
obtained_at TIMESTAMP WITH TIME ZONE NOT NULL,
verse_skill_id TEXT, -- Optional verse skill reference
FOREIGN KEY (card_id) REFERENCES card_master(id),
UNIQUE(card_id) -- Only one unique per card_id allowed
);
CREATE INDEX IF NOT EXISTS idx_unique_registry_card_id ON unique_card_registry(card_id);
CREATE INDEX IF NOT EXISTS idx_unique_registry_owner_did ON unique_card_registry(owner_did);
-- Draw history - tracks all gacha draws for statistics
CREATE TABLE IF NOT EXISTS draw_history (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
card_id INTEGER NOT NULL,
status card_rarity NOT NULL,
cp INTEGER NOT NULL,
is_paid BOOLEAN NOT NULL DEFAULT FALSE, -- Paid vs free gacha
drawn_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (card_id) REFERENCES card_master(id)
);
CREATE INDEX IF NOT EXISTS idx_draw_history_user_id ON draw_history(user_id);
CREATE INDEX IF NOT EXISTS idx_draw_history_drawn_at ON draw_history(drawn_at);
CREATE INDEX IF NOT EXISTS idx_draw_history_status ON draw_history(status);
-- Gacha pools - special event pools with rate-ups
CREATE TABLE IF NOT EXISTS gacha_pools (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
start_at TIMESTAMP WITH TIME ZONE,
end_at TIMESTAMP WITH TIME ZONE,
pickup_card_ids INTEGER[], -- Array of card IDs
rate_up_multiplier DECIMAL(4,2) NOT NULL DEFAULT 1.0
);
CREATE INDEX IF NOT EXISTS idx_gacha_pools_active ON gacha_pools(is_active);
CREATE INDEX IF NOT EXISTS idx_gacha_pools_dates ON gacha_pools(start_at, end_at);
-- Insert default card master data (0-15 cards from ai.json)
INSERT INTO card_master (id, name, base_cp_min, base_cp_max, color, description) VALUES
(0, 'ai', 100, 200, '#4A90E2', 'The core essence of existence'),
(1, 'dream', 90, 180, '#9B59B6', 'Visions of possibility'),
(2, 'radiance', 110, 220, '#F39C12', 'Brilliant light energy'),
(3, 'neutron', 120, 240, '#34495E', 'Dense stellar core'),
(4, 'sun', 130, 260, '#E74C3C', 'Solar radiance'),
(5, 'night', 80, 160, '#2C3E50', 'Darkness and mystery'),
(6, 'snow', 70, 140, '#ECF0F1', 'Pure frozen crystalline'),
(7, 'thunder', 140, 280, '#F1C40F', 'Electric storm energy'),
(8, 'ultimate', 150, 300, '#8E44AD', 'The highest form'),
(9, 'sword', 160, 320, '#95A5A6', 'Blade of cutting truth'),
(10, 'destruction', 170, 340, '#C0392B', 'Force of entropy'),
(11, 'earth', 90, 180, '#27AE60', 'Grounding foundation'),
(12, 'galaxy', 180, 360, '#3498DB', 'Cosmic expanse'),
(13, 'create', 100, 200, '#16A085', 'Power of generation'),
(14, 'supernova', 200, 400, '#E67E22', 'Stellar explosion'),
(15, 'world', 250, 500, '#9B59B6', 'Reality itself')
ON CONFLICT (id) DO NOTHING;
-- Create function for updating updated_at timestamp
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';
-- Create trigger for updating users.updated_at
CREATE TRIGGER trigger_users_updated_at
BEFORE UPDATE ON users
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();

View File

@@ -0,0 +1,130 @@
-- SQLite migration for ai.card database schema
-- Create custom types (SQLite uses CHECK constraints instead of ENUMs)
-- Card rarity levels
CREATE TABLE IF NOT EXISTS card_rarity_enum (
value TEXT PRIMARY KEY CHECK (value IN ('normal', 'rare', 'super_rare', 'kira', 'unique'))
);
INSERT OR IGNORE INTO card_rarity_enum (value) VALUES
('normal'), ('rare'), ('super_rare'), ('kira'), ('unique');
-- Users table - stores atproto DID-based user information
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
did TEXT NOT NULL UNIQUE, -- atproto Decentralized Identifier
handle TEXT NOT NULL, -- atproto handle (e.g., alice.bsky.social)
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_users_did ON users(did);
CREATE INDEX IF NOT EXISTS idx_users_handle ON users(handle);
-- Card master data - template definitions for all card types
CREATE TABLE IF NOT EXISTS card_master (
id INTEGER PRIMARY KEY, -- Card ID (0-15 in current system)
name TEXT NOT NULL, -- Card name (e.g., "ai", "dream", "radiance")
base_cp_min INTEGER NOT NULL, -- Minimum base CP for this card
base_cp_max INTEGER NOT NULL, -- Maximum base CP for this card
color TEXT NOT NULL, -- Card color theme
description TEXT NOT NULL -- Card description/lore
);
-- User cards - actual card instances owned by users
CREATE TABLE IF NOT EXISTS user_cards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
card_id INTEGER NOT NULL, -- References card_master.id
cp INTEGER NOT NULL, -- Calculated CP (base_cp * rarity_multiplier)
status TEXT NOT NULL -- Card rarity
CHECK (status IN ('normal', 'rare', 'super_rare', 'kira', 'unique')),
skill TEXT, -- Optional skill description
obtained_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
is_unique BOOLEAN NOT NULL DEFAULT FALSE,
unique_id TEXT, -- UUID for unique cards
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (card_id) REFERENCES card_master(id)
);
CREATE INDEX IF NOT EXISTS idx_user_cards_user_id ON user_cards(user_id);
CREATE INDEX IF NOT EXISTS idx_user_cards_card_id ON user_cards(card_id);
CREATE INDEX IF NOT EXISTS idx_user_cards_status ON user_cards(status);
CREATE INDEX IF NOT EXISTS idx_user_cards_unique_id ON user_cards(unique_id);
-- Global unique card registry - tracks ownership of unique cards
CREATE TABLE IF NOT EXISTS unique_card_registry (
id INTEGER PRIMARY KEY AUTOINCREMENT,
unique_id TEXT NOT NULL UNIQUE, -- UUID from user_cards.unique_id
card_id INTEGER NOT NULL, -- Which card type is unique
owner_did TEXT NOT NULL, -- Current owner's atproto DID
obtained_at DATETIME NOT NULL,
verse_skill_id TEXT, -- Optional verse skill reference
FOREIGN KEY (card_id) REFERENCES card_master(id),
UNIQUE(card_id) -- Only one unique per card_id allowed
);
CREATE INDEX IF NOT EXISTS idx_unique_registry_card_id ON unique_card_registry(card_id);
CREATE INDEX IF NOT EXISTS idx_unique_registry_owner_did ON unique_card_registry(owner_did);
-- Draw history - tracks all gacha draws for statistics
CREATE TABLE IF NOT EXISTS draw_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
card_id INTEGER NOT NULL,
status TEXT NOT NULL
CHECK (status IN ('normal', 'rare', 'super_rare', 'kira', 'unique')),
cp INTEGER NOT NULL,
is_paid BOOLEAN NOT NULL DEFAULT FALSE, -- Paid vs free gacha
drawn_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (card_id) REFERENCES card_master(id)
);
CREATE INDEX IF NOT EXISTS idx_draw_history_user_id ON draw_history(user_id);
CREATE INDEX IF NOT EXISTS idx_draw_history_drawn_at ON draw_history(drawn_at);
CREATE INDEX IF NOT EXISTS idx_draw_history_status ON draw_history(status);
-- Gacha pools - special event pools with rate-ups
CREATE TABLE IF NOT EXISTS gacha_pools (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
start_at DATETIME,
end_at DATETIME,
pickup_card_ids TEXT, -- JSON array of card IDs
rate_up_multiplier REAL NOT NULL DEFAULT 1.0
);
CREATE INDEX IF NOT EXISTS idx_gacha_pools_active ON gacha_pools(is_active);
CREATE INDEX IF NOT EXISTS idx_gacha_pools_dates ON gacha_pools(start_at, end_at);
-- Insert default card master data (0-15 cards from ai.json)
INSERT OR IGNORE INTO card_master (id, name, base_cp_min, base_cp_max, color, description) VALUES
(0, 'ai', 100, 200, '#4A90E2', 'The core essence of existence'),
(1, 'dream', 90, 180, '#9B59B6', 'Visions of possibility'),
(2, 'radiance', 110, 220, '#F39C12', 'Brilliant light energy'),
(3, 'neutron', 120, 240, '#34495E', 'Dense stellar core'),
(4, 'sun', 130, 260, '#E74C3C', 'Solar radiance'),
(5, 'night', 80, 160, '#2C3E50', 'Darkness and mystery'),
(6, 'snow', 70, 140, '#ECF0F1', 'Pure frozen crystalline'),
(7, 'thunder', 140, 280, '#F1C40F', 'Electric storm energy'),
(8, 'ultimate', 150, 300, '#8E44AD', 'The highest form'),
(9, 'sword', 160, 320, '#95A5A6', 'Blade of cutting truth'),
(10, 'destruction', 170, 340, '#C0392B', 'Force of entropy'),
(11, 'earth', 90, 180, '#27AE60', 'Grounding foundation'),
(12, 'galaxy', 180, 360, '#3498DB', 'Cosmic expanse'),
(13, 'create', 100, 200, '#16A085', 'Power of generation'),
(14, 'supernova', 200, 400, '#E67E22', 'Stellar explosion'),
(15, 'world', 250, 500, '#9B59B6', 'Reality itself');
-- Create trigger for updating users.updated_at
CREATE TRIGGER IF NOT EXISTS trigger_users_updated_at
AFTER UPDATE ON users
BEGIN
UPDATE users SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END;

View File

@@ -1,25 +0,0 @@
{
"name": "card",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint"
},
"dependencies": {
"@google/model-viewer": "^3.4.0",
"@nuxtjs/proxy": "^2.1.0",
"axios": "^1.6.8",
"core-js": "^3.6.4",
"moment": "^2.29.4",
"three": "^0.162.0",
"vue": "^2.6.11",
"vue-loading-template": "^1.3.2",
"vue-meta": "^2.4.0",
"vue-template-compiler": "^2.6.14"
},
"devDependencies": {
"@vue/cli-service": "~4.5.15"
}
}

1
public

Submodule public deleted from fbb036ce08

32
python/api/.env.example Normal file
View File

@@ -0,0 +1,32 @@
# Application
APP_NAME=ai.card
APP_VERSION=0.1.0
DEBUG=false
# Database (Local PostgreSQL)
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/aicard
# Database (Supabase - optional)
DATABASE_URL_SUPABASE=postgresql+asyncpg://postgres.xxxxxxxxxxxx:password@aws-0-region.pooler.supabase.com:5432/postgres
USE_SUPABASE=false
# atproto (optional)
ATPROTO_PDS_URL=https://bsky.social
ATPROTO_HANDLE=your.handle
ATPROTO_PASSWORD=your-app-password
# Card probabilities (in percentage)
PROB_NORMAL=99.789
PROB_RARE=0.1
PROB_SUPER_RARE=0.01
PROB_KIRA=0.1
PROB_UNIQUE=0.0001
# Unique card settings
MAX_UNIQUE_CARDS=1000
# CORS
CORS_ORIGINS=["http://localhost:3000", "https://card.syui.ai"]
# Security
SECRET_KEY=your-secret-key-change-this-in-production

24
python/api/Dockerfile Normal file
View File

@@ -0,0 +1,24 @@
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
postgresql-client \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements first for better caching
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application
COPY . .
# Create non-root user
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
USER appuser
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

47
python/api/alembic.ini Normal file
View File

@@ -0,0 +1,47 @@
# Alembic Configuration
[alembic]
script_location = alembic
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url = postgresql+asyncpg://postgres:postgres@localhost:5432/aicard
[post_write_hooks]
hooks = black
black.type = console_scripts
black.entrypoint = black
black.options = -l 88
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

82
python/api/alembic/env.py Normal file
View File

@@ -0,0 +1,82 @@
"""Alembic environment configuration"""
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
import os
import sys
from pathlib import Path
# Add parent directory to path
sys.path.append(str(Path(__file__).parent.parent))
from app.db.base import Base
from app.db.models import * # Import all models
from app.core.config import settings
# Alembic Config object
config = context.config
# Interpret the config file for Python logging
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Model metadata
target_metadata = Base.metadata
# Override sqlalchemy.url with environment variable if present
if os.getenv("DATABASE_URL"):
config.set_main_option("sqlalchemy.url", os.getenv("DATABASE_URL"))
else:
config.set_main_option("sqlalchemy.url", settings.database_url)
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode."""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
"""Run migrations in 'online' mode with async engine."""
configuration = config.get_section(config.config_ini_section)
configuration["sqlalchemy.url"] = config.get_main_option("sqlalchemy.url")
connectable = async_engine_from_config(
configuration,
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1 @@
# ai.card API Package

View File

@@ -0,0 +1,290 @@
"""AI Provider integration for ai.card"""
import os
import json
from typing import Optional, Dict, List, Any
from abc import ABC, abstractmethod
import logging
import httpx
from openai import OpenAI
import ollama
class AIProvider(ABC):
"""Base class for AI providers"""
@abstractmethod
async def chat(self, prompt: str, system_prompt: Optional[str] = None) -> str:
"""Generate a response based on prompt"""
pass
class OllamaProvider(AIProvider):
"""Ollama AI provider for ai.card"""
def __init__(self, model: str = "qwen3", host: Optional[str] = None):
self.model = model
self.host = host or os.getenv('OLLAMA_HOST', 'http://127.0.0.1:11434')
if not self.host.startswith('http'):
self.host = f'http://{self.host}'
self.client = ollama.Client(host=self.host, timeout=60.0)
self.logger = logging.getLogger(__name__)
self.logger.info(f"OllamaProvider initialized with host: {self.host}, model: {self.model}")
async def chat(self, prompt: str, system_prompt: Optional[str] = None) -> str:
"""Simple chat interface"""
try:
messages = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
messages.append({"role": "user", "content": prompt})
response = self.client.chat(
model=self.model,
messages=messages,
options={
"num_predict": 2000,
"temperature": 0.7,
"top_p": 0.9,
},
stream=False
)
return response['message']['content']
except Exception as e:
self.logger.error(f"Ollama chat failed: {e}")
return "I'm having trouble connecting to the AI model."
class OpenAIProvider(AIProvider):
"""OpenAI API provider with MCP function calling support"""
def __init__(self, model: str = "gpt-4o-mini", api_key: Optional[str] = None, mcp_client=None):
self.model = model
self.api_key = api_key or os.getenv("OPENAI_API_KEY")
if not self.api_key:
raise ValueError("OpenAI API key not provided")
self.client = OpenAI(api_key=self.api_key)
self.logger = logging.getLogger(__name__)
self.mcp_client = mcp_client
def _get_mcp_tools(self) -> List[Dict[str, Any]]:
"""Generate OpenAI tools from MCP endpoints"""
if not self.mcp_client:
return []
tools = [
{
"type": "function",
"function": {
"name": "get_user_cards",
"description": "ユーザーが所有するカードの一覧を取得します",
"parameters": {
"type": "object",
"properties": {
"did": {
"type": "string",
"description": "ユーザーのDID"
},
"limit": {
"type": "integer",
"description": "取得するカード数の上限",
"default": 10
}
},
"required": ["did"]
}
}
},
{
"type": "function",
"function": {
"name": "draw_card",
"description": "ガチャを引いてカードを取得します",
"parameters": {
"type": "object",
"properties": {
"did": {
"type": "string",
"description": "ユーザーのDID"
},
"is_paid": {
"type": "boolean",
"description": "有料ガチャかどうか",
"default": False
}
},
"required": ["did"]
}
}
},
{
"type": "function",
"function": {
"name": "get_card_details",
"description": "特定のカードの詳細情報を取得します",
"parameters": {
"type": "object",
"properties": {
"card_id": {
"type": "integer",
"description": "カードID"
}
},
"required": ["card_id"]
}
}
},
{
"type": "function",
"function": {
"name": "analyze_card_collection",
"description": "ユーザーのカードコレクションを分析します",
"parameters": {
"type": "object",
"properties": {
"did": {
"type": "string",
"description": "ユーザーのDID"
}
},
"required": ["did"]
}
}
},
{
"type": "function",
"function": {
"name": "get_gacha_stats",
"description": "ガチャの統計情報を取得します",
"parameters": {
"type": "object",
"properties": {}
}
}
}
]
return tools
async def chat(self, prompt: str, system_prompt: Optional[str] = None) -> str:
"""Simple chat interface without MCP tools"""
try:
messages = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
messages.append({"role": "user", "content": prompt})
response = self.client.chat.completions.create(
model=self.model,
messages=messages,
max_tokens=2000,
temperature=0.7
)
return response.choices[0].message.content
except Exception as e:
self.logger.error(f"OpenAI chat failed: {e}")
return "I'm having trouble connecting to the AI model."
async def chat_with_mcp(self, prompt: str, did: str = "user") -> str:
"""Chat interface with MCP function calling support"""
if not self.mcp_client:
return await self.chat(prompt)
try:
tools = self._get_mcp_tools()
response = self.client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": "あなたはai.cardシステムのアシスタントです。カードゲームの情報、ガチャ、コレクション分析などについて質問されたら、必要に応じてツールを使用して正確な情報を提供してください。"},
{"role": "user", "content": prompt}
],
tools=tools,
tool_choice="auto",
max_tokens=2000,
temperature=0.7
)
message = response.choices[0].message
# Handle tool calls
if message.tool_calls:
messages = [
{"role": "system", "content": "カードゲームシステムのツールを使って正確な情報を提供してください。"},
{"role": "user", "content": prompt},
{
"role": "assistant",
"content": message.content,
"tool_calls": [tc.model_dump() for tc in message.tool_calls]
}
]
# Execute each tool call
for tool_call in message.tool_calls:
tool_result = await self._execute_mcp_tool(tool_call, did)
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"name": tool_call.function.name,
"content": json.dumps(tool_result, ensure_ascii=False)
})
# Get final response
final_response = self.client.chat.completions.create(
model=self.model,
messages=messages,
max_tokens=2000,
temperature=0.7
)
return final_response.choices[0].message.content
else:
return message.content
except Exception as e:
self.logger.error(f"OpenAI MCP chat failed: {e}")
return f"申し訳ありません。エラーが発生しました: {e}"
async def _execute_mcp_tool(self, tool_call, default_did: str = "user") -> Dict[str, Any]:
"""Execute MCP tool call"""
try:
function_name = tool_call.function.name
arguments = json.loads(tool_call.function.arguments)
if function_name == "get_user_cards":
did = arguments.get("did", default_did)
limit = arguments.get("limit", 10)
return await self.mcp_client.get_user_cards(did, limit)
elif function_name == "draw_card":
did = arguments.get("did", default_did)
is_paid = arguments.get("is_paid", False)
return await self.mcp_client.draw_card(did, is_paid)
elif function_name == "get_card_details":
card_id = arguments.get("card_id")
return await self.mcp_client.get_card_details(card_id)
elif function_name == "analyze_card_collection":
did = arguments.get("did", default_did)
return await self.mcp_client.analyze_card_collection(did)
elif function_name == "get_gacha_stats":
return await self.mcp_client.get_gacha_stats()
else:
return {"error": f"未知のツール: {function_name}"}
except Exception as e:
return {"error": f"ツール実行エラー: {str(e)}"}
def create_ai_provider(provider: str = "ollama", model: Optional[str] = None, mcp_client=None, **kwargs) -> AIProvider:
"""Factory function to create AI providers"""
if provider == "ollama":
model = model or "qwen3"
return OllamaProvider(model=model, **kwargs)
elif provider == "openai":
model = model or "gpt-4o-mini"
return OpenAIProvider(model=model, mcp_client=mcp_client, **kwargs)
else:
raise ValueError(f"Unknown provider: {provider}")

View File

@@ -0,0 +1 @@
# Auth Package

View File

@@ -0,0 +1,116 @@
"""Authentication dependencies"""
from typing import Optional, Annotated
from fastapi import Depends, HTTPException, Header, Cookie
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import JWTError, jwt
from datetime import datetime, timedelta
# from app.services.atproto import AtprotoService # Temporarily disabled
from app.core.config import settings
# Bearer token scheme
bearer_scheme = HTTPBearer(auto_error=False)
# JWT settings
SECRET_KEY = settings.secret_key if hasattr(settings, 'secret_key') else "your-secret-key"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 24 hours
class AuthUser:
"""Authenticated user data"""
def __init__(self, did: str, handle: Optional[str] = None):
self.did = did
self.handle = handle
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
"""Create JWT access token"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
async def verify_token(token: str) -> Optional[AuthUser]:
"""Verify JWT token and return user"""
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
did: str = payload.get("did")
if did is None:
return None
handle: Optional[str] = payload.get("handle")
return AuthUser(did=did, handle=handle)
except JWTError:
return None
async def get_current_user(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(bearer_scheme),
token_cookie: Optional[str] = Cookie(None, alias="ai_card_token")
) -> Optional[AuthUser]:
"""
Get current user from JWT token
Supports both Bearer token and cookie
"""
token = None
# Try Bearer token first
if credentials and credentials.credentials:
token = credentials.credentials
# Fall back to cookie
elif token_cookie:
token = token_cookie
if not token:
return None
user = await verify_token(token)
return user
async def require_user(
current_user: Optional[AuthUser] = Depends(get_current_user)
) -> AuthUser:
"""Require authenticated user"""
if not current_user:
raise HTTPException(
status_code=401,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
return current_user
async def get_optional_user(
current_user: Optional[AuthUser] = Depends(get_current_user)
) -> Optional[AuthUser]:
"""Get user if authenticated, None otherwise"""
return current_user
# Temporarily disabled due to atproto dependency issues
class AtprotoAuth:
"""atproto authentication handler (mock implementation)"""
def __init__(self):
pass # self.service = AtprotoService()
async def authenticate(self, identifier: str, password: str) -> Optional[AuthUser]:
"""Mock authentication - always returns test user"""
# Mock implementation for testing
if identifier and password:
return AuthUser(did="did:plc:test123", handle=identifier)
return None
async def verify_did_ownership(self, did: str, session_string: str) -> bool:
"""Mock verification - always returns True for test"""
return True

View File

@@ -0,0 +1 @@
# Core Package

View File

@@ -0,0 +1,59 @@
"""Application configuration"""
import os
from pathlib import Path
from typing import Optional
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
# Application
app_name: str = "ai.card"
app_version: str = "0.1.0"
debug: bool = False
# API
api_v1_prefix: str = "/api/v1"
# Database
database_url: str = "sqlite+aiosqlite:///~/.config/syui/ai/card/aicard.db"
database_url_supabase: Optional[str] = None
use_supabase: bool = False
# atproto
atproto_pds_url: Optional[str] = None
atproto_handle: Optional[str] = None
atproto_password: Optional[str] = None
# Card probabilities (in percentage)
prob_normal: float = 99.789
prob_rare: float = 0.1
prob_super_rare: float = 0.01
prob_kira: float = 0.1
prob_unique: float = 0.0001
# Unique card settings
max_unique_cards: int = 1000 # Maximum number of unique cards
# CORS
cors_origins: list[str] = [
"http://localhost:3000",
"http://localhost:5173",
"http://localhost:4173",
"https://card.syui.ai",
"https://xxxcard.syui.ai"
]
# Security
secret_key: str = "your-secret-key-change-this-in-production"
class Config:
# 設定ファイルの優先順位: 1) 環境変数, 2) ~/.config/syui/ai/card/.env, 3) .env
config_dir = Path.home() / ".config" / "syui" / "ai" / "card"
env_file = [
str(config_dir / ".env"), # ~/.config/syui/ai/card/.env
".env" # カレントディレクトリの.env
]
env_file_encoding = "utf-8"
settings = Settings()

View File

@@ -0,0 +1 @@
# Database Package

53
python/api/app/db/base.py Normal file
View File

@@ -0,0 +1,53 @@
"""Database base configuration"""
import os
from pathlib import Path
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from app.core.config import settings
# Create base class for models
Base = declarative_base()
# Ensure database directory exists
db_path = Path.home() / ".config" / "syui" / "ai" / "card"
db_path.mkdir(parents=True, exist_ok=True)
# Select database URL based on configuration
database_url = settings.database_url_supabase if settings.use_supabase else settings.database_url
# Expand ~ in database URL
if database_url.startswith("sqlite"):
database_url = database_url.replace("~", str(Path.home()))
# Create async engine (SQLite-optimized settings)
if "sqlite" in database_url:
engine = create_async_engine(
database_url,
echo=settings.debug,
future=True,
# SQLite-specific optimizations
connect_args={"check_same_thread": False}
)
else:
# PostgreSQL settings (fallback)
engine = create_async_engine(
database_url,
echo=settings.debug,
future=True,
pool_pre_ping=True,
pool_size=5,
max_overflow=10
)
# Create async session factory
async_session = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False
)
async def get_session() -> AsyncSession:
"""Dependency to get database session"""
async with async_session() as session:
yield session

121
python/api/app/db/models.py Normal file
View File

@@ -0,0 +1,121 @@
"""Database models"""
from datetime import datetime
from sqlalchemy import (
Column, Integer, String, DateTime, Boolean,
Float, ForeignKey, UniqueConstraint, Index,
Enum as SQLEnum
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
import uuid
import enum
from app.db.base import Base
from app.models.card import CardRarity
class User(Base):
"""ユーザーモデル"""
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
did = Column(String, unique=True, nullable=False, index=True)
handle = Column(String, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
cards = relationship("UserCard", back_populates="owner")
draws = relationship("DrawHistory", back_populates="user")
class CardMaster(Base):
"""カードマスタデータ"""
__tablename__ = "card_master"
id = Column(Integer, primary_key=True) # 0-15
name = Column(String, nullable=False)
base_cp_min = Column(Integer, nullable=False)
base_cp_max = Column(Integer, nullable=False)
color = Column(String, nullable=False)
description = Column(String)
# Relationships
user_cards = relationship("UserCard", back_populates="card_info")
class UserCard(Base):
"""ユーザー所有カード"""
__tablename__ = "user_cards"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
card_id = Column(Integer, ForeignKey("card_master.id"), nullable=False)
cp = Column(Integer, nullable=False)
status = Column(SQLEnum(CardRarity), nullable=False)
skill = Column(String, nullable=True)
obtained_at = Column(DateTime, default=datetime.utcnow)
is_unique = Column(Boolean, default=False)
unique_id = Column(UUID(as_uuid=True), nullable=True, unique=True)
# Relationships
owner = relationship("User", back_populates="cards")
card_info = relationship("CardMaster", back_populates="user_cards")
# Indexes
__table_args__ = (
Index('idx_user_cards', 'user_id', 'card_id'),
)
class UniqueCardRegistry(Base):
"""uniqueカードのグローバルレジストリ"""
__tablename__ = "unique_card_registry"
id = Column(Integer, primary_key=True)
unique_id = Column(UUID(as_uuid=True), default=uuid.uuid4, unique=True, nullable=False)
card_id = Column(Integer, ForeignKey("card_master.id"), nullable=False)
owner_did = Column(String, ForeignKey("users.did"), nullable=False)
obtained_at = Column(DateTime, default=datetime.utcnow)
verse_skill_id = Column(String, nullable=True) # ai.verse連携用
# Unique constraint: 各card_idは1人のみ所有可能
__table_args__ = (
UniqueConstraint('card_id', name='unique_card_per_type'),
)
class DrawHistory(Base):
"""ガチャ履歴"""
__tablename__ = "draw_history"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
card_id = Column(Integer, nullable=False)
status = Column(SQLEnum(CardRarity), nullable=False)
cp = Column(Integer, nullable=False)
is_paid = Column(Boolean, default=False)
drawn_at = Column(DateTime, default=datetime.utcnow)
# Relationships
user = relationship("User", back_populates="draws")
# Indexes
__table_args__ = (
Index('idx_draw_history_user', 'user_id', 'drawn_at'),
)
class GachaPool(Base):
"""ガチャプール(ピックアップ管理)"""
__tablename__ = "gacha_pools"
id = Column(Integer, primary_key=True)
name = Column(String, nullable=False)
description = Column(String)
is_active = Column(Boolean, default=True)
start_at = Column(DateTime, nullable=False)
end_at = Column(DateTime, nullable=True)
pickup_card_ids = Column(String) # JSON array of card IDs
rate_up_multiplier = Column(Float, default=1.0)
created_at = Column(DateTime, default=datetime.utcnow)

65
python/api/app/main.py Normal file
View File

@@ -0,0 +1,65 @@
"""FastAPI application entry point"""
import os
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.core.config import settings
from app.routes import cards, auth, sync
from app.mcp_server import AICardMcpServer
# Initialize MCP server
enable_mcp = os.getenv("ENABLE_MCP", "true").lower() == "true"
mcp_server = AICardMcpServer(enable_mcp=enable_mcp)
# Get FastAPI app from MCP server
app = mcp_server.get_app()
# Update app configuration
app.title = settings.app_name
app.version = settings.app_version
app.docs_url = "/docs"
app.redoc_url = "/redoc"
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include routers
app.include_router(auth.router, prefix=settings.api_v1_prefix)
app.include_router(cards.router, prefix=settings.api_v1_prefix)
app.include_router(sync.router, prefix=settings.api_v1_prefix)
@app.get("/")
async def root():
"""Root endpoint"""
return {
"app": settings.app_name,
"version": settings.app_version,
"status": "running"
}
@app.get("/health")
async def health_check():
"""Health check endpoint"""
return {
"status": "healthy",
"mcp_enabled": enable_mcp,
"mcp_endpoint": "/mcp" if enable_mcp else None
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"app.main:app",
host="0.0.0.0",
port=8000,
reload=True
)

View File

@@ -0,0 +1,290 @@
"""MCP Server for ai.card system"""
from typing import Optional, List, Dict, Any
from mcp.server.fastmcp import FastMCP
from fastapi import FastAPI, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from pathlib import Path
import logging
from app.core.config import settings
from app.db.base import get_session
from app.models.card import Card, CardRarity, CardDrawResult
from app.repositories.card import CardRepository, UniqueCardRepository
from app.repositories.user import UserRepository
from app.services.gacha import GachaService
# from app.services.card_sync import CardSyncService # Temporarily disabled
logger = logging.getLogger(__name__)
class AICardMcpServer:
"""MCP Server that exposes ai.card functionality to AI assistants"""
def __init__(self, enable_mcp: bool = True):
self.enable_mcp = enable_mcp
# Create FastAPI app
self.app = FastAPI(
title="AI.Card - Card Game System",
description="MCP server for ai.card system",
version=settings.app_version
)
# Create MCP server with FastAPI app
self.server = None
if enable_mcp:
self.server = FastMCP("aicard")
self._register_mcp_tools()
def get_app(self) -> FastAPI:
"""Get the FastAPI app instance"""
return self.app
def _register_mcp_tools(self):
"""Register all MCP tools"""
@self.app.get("/get_user_cards", operation_id="get_user_cards")
async def get_user_cards(
did: str,
limit: int = 10,
session: AsyncSession = Depends(get_session)
) -> List[Dict[str, Any]]:
"""Get all cards owned by a user"""
try:
user_repo = UserRepository(session)
card_repo = CardRepository(session)
# Get user
user = await user_repo.get_by_did(did)
if not user:
return []
# Get user cards
user_cards = await card_repo.get_user_cards(user.id, limit=limit)
return [
{
"id": card.card_id,
"cp": card.cp,
"status": card.status,
"skill": card.skill,
"owner_did": did,
"obtained_at": card.obtained_at.isoformat(),
"is_unique": card.is_unique,
"unique_id": str(card.unique_id) if card.unique_id else None
}
for card in user_cards
]
except Exception as e:
logger.error(f"Error getting user cards: {e}")
return []
@self.app.post("/draw_card", operation_id="draw_card")
async def draw_card(
did: str,
is_paid: bool = False,
session: AsyncSession = Depends(get_session)
) -> Dict[str, Any]:
"""Draw a new card (gacha) for user"""
try:
gacha_service = GachaService(session)
# Draw card
card, is_unique = await gacha_service.draw_card(did, is_paid)
await session.commit()
return {
"success": True,
"card": {
"id": card.id,
"cp": card.cp,
"status": card.status,
"skill": card.skill,
"owner_did": card.owner_did,
"obtained_at": card.obtained_at.isoformat(),
"is_unique": card.is_unique,
"unique_id": card.unique_id
},
"is_unique": is_unique,
"animation_type": "kira" if card.status in [CardRarity.KIRA, CardRarity.UNIQUE] else "normal"
}
except Exception as e:
logger.error(f"Error drawing card: {e}")
await session.rollback()
return {
"success": False,
"error": str(e)
}
@self.app.get("/get_card_details", operation_id="get_card_details")
async def get_card_details(
card_id: int,
session: AsyncSession = Depends(get_session)
) -> Dict[str, Any]:
"""Get detailed information about a card type"""
try:
# Get card info from gacha service
gacha_service = GachaService(session)
if card_id not in gacha_service.CARD_INFO:
return {"error": f"Card ID {card_id} not found"}
card_info = gacha_service.CARD_INFO[card_id]
# Get unique card availability
unique_repo = UniqueCardRepository(session)
is_unique_available = await unique_repo.is_card_available(card_id)
return {
"id": card_id,
"name": card_info["name"],
"base_cp_range": card_info["base_cp_range"],
"is_unique_available": is_unique_available,
"description": f"Card {card_id}: {card_info['name']}"
}
except Exception as e:
logger.error(f"Error getting card details: {e}")
return {"error": str(e)}
@self.app.post("/sync_cards_atproto", operation_id="sync_cards_atproto")
async def sync_cards_atproto(
did: str,
session: AsyncSession = Depends(get_session)
) -> Dict[str, str]:
"""Sync user's cards with atproto (temporarily disabled)"""
return {"status": "atproto sync temporarily disabled due to dependency issues"}
@self.app.get("/analyze_card_collection", operation_id="analyze_card_collection")
async def analyze_card_collection(
did: str,
session: AsyncSession = Depends(get_session)
) -> Dict[str, Any]:
"""Analyze user's card collection"""
try:
user_repo = UserRepository(session)
card_repo = CardRepository(session)
# Get user
user = await user_repo.get_by_did(did)
if not user:
return {
"total_cards": 0,
"rarity_distribution": {},
"message": "User not found"
}
# Get all user cards
user_cards = await card_repo.get_user_cards(user.id, limit=1000)
if not user_cards:
return {
"total_cards": 0,
"rarity_distribution": {},
"message": "No cards found"
}
# Analyze collection
rarity_count = {}
total_cp = 0
card_type_count = {}
for card in user_cards:
# Rarity distribution
rarity = card.status
rarity_count[rarity] = rarity_count.get(rarity, 0) + 1
# Total CP
total_cp += card.cp
# Card type distribution
card_type_count[card.card_id] = card_type_count.get(card.card_id, 0) + 1
# Find strongest card
strongest_card = max(user_cards, key=lambda x: x.cp)
return {
"total_cards": len(user_cards),
"rarity_distribution": rarity_count,
"card_type_distribution": card_type_count,
"average_cp": total_cp / len(user_cards) if user_cards else 0,
"total_cp": total_cp,
"strongest_card": {
"id": strongest_card.card_id,
"cp": strongest_card.cp,
"status": strongest_card.status,
"is_unique": strongest_card.is_unique
},
"unique_count": len([c for c in user_cards if c.is_unique])
}
except Exception as e:
logger.error(f"Error analyzing collection: {e}")
return {"error": str(e)}
@self.app.get("/get_unique_registry", operation_id="get_unique_registry")
async def get_unique_registry(
session: AsyncSession = Depends(get_session)
) -> Dict[str, Any]:
"""Get all registered unique cards"""
try:
unique_repo = UniqueCardRepository(session)
# Get all unique cards
unique_cards = await unique_repo.get_all_unique_cards()
# Get available unique card IDs
available_ids = await unique_repo.get_available_unique_cards()
return {
"registered_unique_cards": [
{
"card_id": card.card_id,
"unique_id": card.unique_id,
"owner_did": card.owner_did,
"obtained_at": card.obtained_at.isoformat()
}
for card in unique_cards
],
"available_unique_card_ids": available_ids,
"total_registered": len(unique_cards),
"total_available": len(available_ids)
}
except Exception as e:
logger.error(f"Error getting unique registry: {e}")
return {"error": str(e)}
@self.app.get("/get_gacha_stats", operation_id="get_gacha_stats")
async def get_gacha_stats(
session: AsyncSession = Depends(get_session)
) -> Dict[str, Any]:
"""Get gacha system statistics"""
try:
return {
"rarity_probabilities": {
"normal": f"{100 - settings.prob_rare}%",
"rare": f"{settings.prob_rare - settings.prob_super_rare}%",
"super_rare": f"{settings.prob_super_rare - settings.prob_kira}%",
"kira": f"{settings.prob_kira - settings.prob_unique}%",
"unique": f"{settings.prob_unique}%"
},
"total_card_types": 16,
"card_names": [info["name"] for info in GachaService.CARD_INFO.values()],
"system_info": {
"daily_limit": "1 free draw per day",
"paid_gacha": "Enhanced probabilities",
"unique_system": "First-come-first-served globally unique cards"
}
}
except Exception as e:
logger.error(f"Error getting gacha stats: {e}")
return {"error": str(e)}
# MCP server will be run separately, not here
def get_server(self) -> Optional[FastMCP]:
"""Get the FastAPI MCP server instance"""
return self.server
def get_app(self) -> FastAPI:
"""Get the FastAPI app instance"""
return self.app

View File

@@ -0,0 +1 @@
# Models Package

View File

@@ -0,0 +1,57 @@
"""Card data models"""
from datetime import datetime
from enum import Enum
from typing import Optional
from pydantic import BaseModel, Field
class CardRarity(str, Enum):
"""カードのレアリティ"""
NORMAL = "normal"
RARE = "rare"
SUPER_RARE = "super_rare"
KIRA = "kira" # キラカード0.1%
UNIQUE = "unique" # uniqueカード0.0001%
class CardBase(BaseModel):
"""カードの基本情報"""
id: int = Field(..., ge=0, le=15, description="カード種類ID (0-15)")
cp: int = Field(..., ge=1, le=999, description="カードパワー")
status: CardRarity = Field(default=CardRarity.NORMAL, description="レアリティ")
skill: Optional[str] = Field(None, description="スキル情報")
class Card(CardBase):
"""所有カード情報"""
owner_did: str = Field(..., description="所有者のatproto DID")
obtained_at: datetime = Field(default_factory=datetime.utcnow, description="取得日時")
is_unique: bool = Field(default=False, description="uniqueカードフラグ")
unique_id: Optional[str] = Field(None, description="unique時のグローバルID")
class Config:
json_encoders = {
datetime: lambda v: v.isoformat()
}
class CardDraw(BaseModel):
"""カード抽選リクエスト"""
user_did: str = Field(..., description="ユーザーのDID")
is_paid: bool = Field(default=False, description="課金ガチャかどうか")
class CardDrawResult(BaseModel):
"""カード抽選結果"""
card: Card
is_new: bool = Field(..., description="新規取得かどうか")
animation_type: str = Field(..., description="演出タイプ")
class UniqueCardRegistry(BaseModel):
"""uniqueカードの登録情報"""
card_id: int
unique_id: str
owner_did: str
obtained_at: datetime
verse_skill_id: Optional[str] = None

View File

@@ -0,0 +1 @@
# Repositories Package

View File

@@ -0,0 +1,65 @@
"""Base repository class"""
from typing import Generic, Type, TypeVar, Optional, List
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, delete
from sqlalchemy.orm import selectinload
from app.db.base import Base
ModelType = TypeVar("ModelType", bound=Base)
class BaseRepository(Generic[ModelType]):
"""Base repository with common CRUD operations"""
def __init__(self, model: Type[ModelType], session: AsyncSession):
self.model = model
self.session = session
async def create(self, **kwargs) -> ModelType:
"""Create a new record"""
instance = self.model(**kwargs)
self.session.add(instance)
await self.session.flush()
return instance
async def get(self, id: int) -> Optional[ModelType]:
"""Get a record by ID"""
result = await self.session.execute(
select(self.model).where(self.model.id == id)
)
return result.scalar_one_or_none()
async def get_multi(
self,
skip: int = 0,
limit: int = 100,
**filters
) -> List[ModelType]:
"""Get multiple records with pagination"""
query = select(self.model)
# Apply filters
for key, value in filters.items():
if hasattr(self.model, key):
query = query.where(getattr(self.model, key) == value)
query = query.offset(skip).limit(limit)
result = await self.session.execute(query)
return result.scalars().all()
async def update(self, id: int, **kwargs) -> Optional[ModelType]:
"""Update a record"""
await self.session.execute(
update(self.model)
.where(self.model.id == id)
.values(**kwargs)
)
return await self.get(id)
async def delete(self, id: int) -> bool:
"""Delete a record"""
result = await self.session.execute(
delete(self.model).where(self.model.id == id)
)
return result.rowcount > 0

View File

@@ -0,0 +1,181 @@
"""Card repository"""
from typing import List, Optional
from datetime import datetime
from sqlalchemy import select, and_, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
import uuid
from app.repositories.base import BaseRepository
from app.db.models import UserCard, UniqueCardRegistry, CardMaster
from app.models.card import CardRarity
class CardRepository(BaseRepository[UserCard]):
"""Card repository with custom methods"""
def __init__(self, session: AsyncSession):
super().__init__(UserCard, session)
async def get_user_cards(
self,
user_id: int,
skip: int = 0,
limit: int = 100
) -> List[UserCard]:
"""Get all cards for a user"""
result = await self.session.execute(
select(UserCard)
.options(selectinload(UserCard.card_info))
.where(UserCard.user_id == user_id)
.order_by(UserCard.obtained_at.desc())
.offset(skip)
.limit(limit)
)
return result.scalars().all()
async def count_user_cards(self, user_id: int, card_id: int) -> int:
"""Count how many of a specific card a user has"""
result = await self.session.execute(
select(func.count(UserCard.id))
.where(
and_(
UserCard.user_id == user_id,
UserCard.card_id == card_id
)
)
)
return result.scalar() or 0
async def create_user_card(
self,
user_id: int,
card_id: int,
cp: int,
status: CardRarity,
skill: Optional[str] = None,
is_unique: bool = False
) -> UserCard:
"""Create a new user card"""
unique_id = None
if is_unique:
unique_id = uuid.uuid4()
card = await self.create(
user_id=user_id,
card_id=card_id,
cp=cp,
status=status,
skill=skill,
is_unique=is_unique,
unique_id=unique_id
)
# If unique, register it globally
if is_unique:
await self._register_unique_card(card)
return card
async def _register_unique_card(self, card: UserCard):
"""Register a unique card in the global registry"""
# Get user DID
user_did = await self.session.execute(
select(User.did).where(User.id == card.user_id)
)
user_did = user_did.scalar()
registry = UniqueCardRegistry(
unique_id=card.unique_id,
card_id=card.card_id,
owner_did=user_did,
obtained_at=card.obtained_at
)
self.session.add(registry)
async def get_total_card_count(self) -> int:
"""Get total number of cards obtained"""
result = await self.session.execute(
select(func.count(UserCard.id))
)
return result.scalar() or 0
async def get_cards_by_rarity(self) -> dict:
"""Get card count by rarity"""
result = await self.session.execute(
select(UserCard.status, func.count(UserCard.id))
.group_by(UserCard.status)
)
cards_by_rarity = {}
for status, count in result.all():
cards_by_rarity[status.value if hasattr(status, 'value') else str(status)] = count
return cards_by_rarity
async def get_recent_cards(self, limit: int = 10) -> List[dict]:
"""Get recent card activities"""
result = await self.session.execute(
select(
UserCard.card_id,
UserCard.status,
UserCard.obtained_at,
User.did.label('owner_did')
)
.join(User, UserCard.user_id == User.id)
.order_by(UserCard.obtained_at.desc())
.limit(limit)
)
activities = []
for row in result.all():
activities.append({
'card_id': row.card_id,
'status': row.status.value if hasattr(row.status, 'value') else str(row.status),
'obtained_at': row.obtained_at,
'owner_did': row.owner_did
})
return activities
class UniqueCardRepository(BaseRepository[UniqueCardRegistry]):
"""Unique card registry repository"""
def __init__(self, session: AsyncSession):
super().__init__(UniqueCardRegistry, session)
async def is_card_available(self, card_id: int) -> bool:
"""Check if a unique card is still available"""
result = await self.session.execute(
select(func.count(UniqueCardRegistry.id))
.where(UniqueCardRegistry.card_id == card_id)
)
count = result.scalar() or 0
return count == 0
async def get_all_unique_cards(self) -> List[UniqueCardRegistry]:
"""Get all registered unique cards"""
result = await self.session.execute(
select(UniqueCardRegistry)
.order_by(UniqueCardRegistry.obtained_at.desc())
)
return result.scalars().all()
async def get_available_unique_cards(self) -> List[int]:
"""Get list of card IDs that are still available as unique"""
# Get all card IDs
all_card_ids = set(range(16))
# Get taken card IDs
result = await self.session.execute(
select(UniqueCardRegistry.card_id).distinct()
)
taken_ids = set(result.scalars().all())
# Return available IDs
return list(all_card_ids - taken_ids)
# Import User model here to avoid circular import
from app.db.models import User

View File

@@ -0,0 +1,38 @@
"""User repository"""
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.repositories.base import BaseRepository
from app.db.models import User
class UserRepository(BaseRepository[User]):
"""User repository with custom methods"""
def __init__(self, session: AsyncSession):
super().__init__(User, session)
async def get_by_did(self, did: str) -> Optional[User]:
"""Get user by DID"""
result = await self.session.execute(
select(User).where(User.did == did)
)
return result.scalar_one_or_none()
async def get_or_create(self, did: str, handle: Optional[str] = None) -> User:
"""Get existing user or create new one"""
user = await self.get_by_did(did)
if not user:
user = await self.create(did=did, handle=handle)
return user
async def get_with_cards(self, user_id: int) -> Optional[User]:
"""Get user with all their cards"""
result = await self.session.execute(
select(User)
.options(selectinload(User.cards))
.where(User.id == user_id)
)
return result.scalar_one_or_none()

View File

@@ -0,0 +1 @@
# Routes Package

View File

@@ -0,0 +1,133 @@
"""Authentication routes"""
from datetime import timedelta
from typing import Optional
from fastapi import APIRouter, HTTPException, Depends, Response
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.dependencies import (
AtprotoAuth,
create_access_token,
require_user,
AuthUser,
ACCESS_TOKEN_EXPIRE_MINUTES
)
from app.db.base import get_session
from app.repositories.user import UserRepository
# from app.services.atproto import AtprotoService
router = APIRouter(prefix="/auth", tags=["auth"])
class LoginRequest(BaseModel):
"""Login request model"""
identifier: str # Handle or DID
password: str # App password
class LoginResponse(BaseModel):
"""Login response model"""
access_token: str
token_type: str = "bearer"
did: str
handle: str
class VerifyResponse(BaseModel):
"""Verify response model"""
did: str
handle: str
valid: bool = True
@router.post("/login", response_model=LoginResponse)
async def login(
request: LoginRequest,
response: Response,
db: AsyncSession = Depends(get_session)
):
"""
Login with atproto credentials
- **identifier**: atproto handle or DID
- **password**: App password (not main password)
"""
auth = AtprotoAuth()
# Authenticate with atproto
user = await auth.authenticate(request.identifier, request.password)
if not user:
raise HTTPException(
status_code=401,
detail="Invalid credentials"
)
# Create or update user in database
user_repo = UserRepository(db)
await user_repo.get_or_create(did=user.did, handle=user.handle)
await db.commit()
# Create access token
access_token = create_access_token(
data={"did": user.did, "handle": user.handle},
expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
)
# Set cookie for web clients
response.set_cookie(
key="ai_card_token",
value=access_token,
httponly=True,
secure=True,
samesite="lax",
max_age=ACCESS_TOKEN_EXPIRE_MINUTES * 60
)
return LoginResponse(
access_token=access_token,
did=user.did,
handle=user.handle or ""
)
@router.post("/logout")
async def logout(response: Response):
"""Logout and clear session"""
response.delete_cookie("ai_card_token")
return {"message": "Logged out successfully"}
@router.get("/verify", response_model=VerifyResponse)
async def verify_session(
current_user: AuthUser = Depends(require_user)
):
"""Verify current session is valid"""
return VerifyResponse(
did=current_user.did,
handle=current_user.handle or "",
valid=True
)
@router.post("/verify-did")
async def verify_did(did: str, handle: Optional[str] = None):
"""
Verify DID is valid (public endpoint)
- **did**: DID to verify
- **handle**: Optional handle to cross-check
"""
service = AtprotoService()
is_valid = await service.verify_did(did, handle)
return {
"did": did,
"handle": handle,
"valid": is_valid
}
# Import Optional here
from typing import Optional

View File

@@ -0,0 +1,173 @@
"""Card-related API routes"""
from typing import List, Dict
from fastapi import APIRouter, HTTPException, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.card import Card, CardDraw, CardDrawResult
from app.services.gacha import GachaService
from app.services.card_master import card_master_service
from app.repositories.user import UserRepository
from app.repositories.card import CardRepository, UniqueCardRepository
from app.db.base import get_session
router = APIRouter(prefix="/cards", tags=["cards"])
@router.post("/draw", response_model=CardDrawResult)
async def draw_card(
draw_request: CardDraw,
db: AsyncSession = Depends(get_session)
):
"""
カードを抽選する
- **user_did**: ユーザーのatproto DID
- **is_paid**: 課金ガチャかどうか
"""
try:
gacha_service = GachaService(db)
card, is_unique = await gacha_service.draw_card(
user_did=draw_request.user_did,
is_paid=draw_request.is_paid
)
# 演出タイプを決定
animation_type = "normal"
if is_unique:
animation_type = "unique"
elif card.status.value == "kira":
animation_type = "kira"
elif card.status.value in ["super_rare", "rare"]:
animation_type = "rare"
# 新規取得かチェック
user_repo = UserRepository(db)
card_repo = CardRepository(db)
user = await user_repo.get_by_did(draw_request.user_did)
count = await card_repo.count_user_cards(user.id, card.id)
is_new = count == 1 # 今引いたカードが初めてなら1枚
result = CardDrawResult(
card=card,
is_new=is_new,
animation_type=animation_type
)
await db.commit()
return result
except Exception as e:
await db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.get("/user/{user_did}", response_model=List[Card])
async def get_user_cards(
user_did: str,
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_session)
):
"""
ユーザーの所有カード一覧を取得
- **user_did**: ユーザーのatproto DID
"""
user_repo = UserRepository(db)
card_repo = CardRepository(db)
user = await user_repo.get_by_did(user_did)
if not user:
raise HTTPException(status_code=404, detail="User not found")
user_cards = await card_repo.get_user_cards(user.id, skip=skip, limit=limit)
# Convert to API model
cards = []
for uc in user_cards:
card = Card(
id=uc.card_id,
cp=uc.cp,
status=uc.status,
skill=uc.skill,
owner_did=user_did,
obtained_at=uc.obtained_at,
is_unique=uc.is_unique,
unique_id=str(uc.unique_id) if uc.unique_id else None
)
cards.append(card)
return cards
@router.get("/unique")
async def get_unique_cards(db: AsyncSession = Depends(get_session)):
"""
全てのuniqueカード一覧を取得所有者情報付き
"""
unique_repo = UniqueCardRepository(db)
unique_cards = await unique_repo.get_all_unique_cards()
return [
{
"card_id": uc.card_id,
"owner_did": uc.owner_did,
"obtained_at": uc.obtained_at,
"unique_id": str(uc.unique_id)
}
for uc in unique_cards
]
@router.get("/stats")
async def get_gacha_stats(db: AsyncSession = Depends(get_session)):
"""
ガチャ統計情報を取得
"""
try:
card_repo = CardRepository(db)
# 総ガチャ実行数
total_draws = await card_repo.get_total_card_count()
# レアリティ別カード数
cards_by_rarity = await card_repo.get_cards_by_rarity()
# 成功率計算(簡易版)
success_rates = {}
if total_draws > 0:
for rarity, count in cards_by_rarity.items():
success_rates[rarity] = count / total_draws
# 最近の活動最新10件
recent_cards = await card_repo.get_recent_cards(limit=10)
recent_activity = []
for card_data in recent_cards:
recent_activity.append({
"timestamp": card_data.get("obtained_at", "").isoformat() if card_data.get("obtained_at") else "",
"user_did": card_data.get("owner_did", "unknown"),
"card_name": f"Card #{card_data.get('card_id', 0)}",
"rarity": card_data.get("status", "common")
})
return {
"total_draws": total_draws,
"cards_by_rarity": cards_by_rarity,
"success_rates": success_rates,
"recent_activity": recent_activity
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Statistics error: {str(e)}")
@router.get("/master", response_model=List[Dict])
async def get_card_master_data():
"""
全カードマスターデータを取得ai.jsonから
"""
try:
cards = card_master_service.get_all_cards()
return cards
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to get card master data: {str(e)}")

View File

@@ -0,0 +1,152 @@
"""Synchronization routes for atproto"""
from typing import Optional
from fastapi import APIRouter, HTTPException, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
from app.auth.dependencies import require_user, AuthUser
from app.db.base import get_session
from app.services.card_sync import CardSyncService
from app.repositories.user import UserRepository
router = APIRouter(prefix="/sync", tags=["sync"])
class SyncRequest(BaseModel):
"""Sync request model"""
atproto_session: str # Session string from atproto login
class SyncResponse(BaseModel):
"""Sync response model"""
synced_to_pds: int = 0
imported_from_pds: int = 0
message: str
@router.post("/cards", response_model=SyncResponse)
async def sync_cards(
request: SyncRequest,
current_user: AuthUser = Depends(require_user),
db: AsyncSession = Depends(get_session)
):
"""
Sync cards between database and atproto PDS
- **atproto_session**: Session string from atproto login
"""
try:
# Get user from database
user_repo = UserRepository(db)
user = await user_repo.get_by_did(current_user.did)
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Create sync service
sync_service = CardSyncService(db, request.atproto_session)
# Import from PDS first
imported = await sync_service.import_cards_from_pds(current_user.did)
# Then sync all cards to PDS
synced = await sync_service.sync_all_user_cards(user.id, current_user.did)
await db.commit()
return SyncResponse(
synced_to_pds=synced,
imported_from_pds=imported,
message=f"Successfully synced {synced} cards to PDS and imported {imported} cards from PDS"
)
except Exception as e:
await db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.post("/export")
async def export_to_pds(
request: SyncRequest,
current_user: AuthUser = Depends(require_user),
db: AsyncSession = Depends(get_session)
):
"""
Export all cards to atproto PDS
- **atproto_session**: Session string from atproto login
"""
try:
user_repo = UserRepository(db)
user = await user_repo.get_by_did(current_user.did)
if not user:
raise HTTPException(status_code=404, detail="User not found")
sync_service = CardSyncService(db, request.atproto_session)
synced = await sync_service.sync_all_user_cards(user.id, current_user.did)
return {
"exported": synced,
"message": f"Successfully exported {synced} cards to PDS"
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/import")
async def import_from_pds(
request: SyncRequest,
current_user: AuthUser = Depends(require_user),
db: AsyncSession = Depends(get_session)
):
"""
Import cards from atproto PDS
- **atproto_session**: Session string from atproto login
"""
try:
sync_service = CardSyncService(db, request.atproto_session)
imported = await sync_service.import_cards_from_pds(current_user.did)
await db.commit()
return {
"imported": imported,
"message": f"Successfully imported {imported} cards from PDS"
}
except Exception as e:
await db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.get("/verify/{card_id}")
async def verify_card_ownership(
card_id: int,
unique_id: Optional[str] = None,
current_user: AuthUser = Depends(require_user),
db: AsyncSession = Depends(get_session)
):
"""
Verify user owns a specific card
- **card_id**: Card type ID (0-15)
- **unique_id**: Unique ID for unique cards
"""
sync_service = CardSyncService(db)
owns_card = await sync_service.verify_card_ownership(
current_user.did,
card_id,
unique_id
)
return {
"card_id": card_id,
"unique_id": unique_id,
"owned": owns_card
}
# Import Optional
from typing import Optional

View File

@@ -0,0 +1 @@
# Services Package

View File

@@ -0,0 +1,288 @@
"""atproto integration service"""
import json
from typing import Optional, Dict, Any, List
from datetime import datetime
import httpx
from atproto import Client, SessionString
from atproto.exceptions import AtProtocolError
from app.core.config import settings
from app.models.card import Card, CardRarity
class AtprotoService:
"""atproto integration service"""
def __init__(self):
self.client = None
self.session_string = None
async def login(self, identifier: str, password: str) -> SessionString:
"""
Login to atproto PDS
Args:
identifier: Handle or DID
password: App password
Returns:
Session string for future requests
"""
self.client = Client()
try:
self.client.login(identifier, password)
self.session_string = self.client.export_session_string()
return self.session_string
except AtProtocolError as e:
raise Exception(f"Failed to login to atproto: {str(e)}")
def restore_session(self, session_string: str):
"""Restore session from string"""
self.client = Client()
self.client.login_with_session_string(session_string)
self.session_string = session_string
async def verify_did(self, did: str, handle: Optional[str] = None) -> bool:
"""
Verify DID is valid
Args:
did: DID to verify
handle: Optional handle to cross-check
Returns:
True if valid
"""
try:
# Use public API to resolve DID
async with httpx.AsyncClient() as client:
response = await client.get(
f"https://plc.directory/{did}",
follow_redirects=True
)
if response.status_code != 200:
return False
data = response.json()
# Verify handle if provided
if handle and data.get("alsoKnownAs"):
expected_handle = f"at://{handle}"
return expected_handle in data["alsoKnownAs"]
return True
except Exception:
return False
async def get_profile(self, did: str) -> Optional[Dict[str, Any]]:
"""Get user profile from atproto"""
if not self.client:
raise Exception("Not logged in")
try:
profile = self.client.get_profile(did)
return {
"did": profile.did,
"handle": profile.handle,
"display_name": profile.display_name,
"avatar": profile.avatar,
"description": profile.description
}
except Exception:
return None
async def create_card_record(
self,
did: str,
card: Card,
collection: str = "ai.card.collection"
) -> str:
"""
Create card record in user's PDS
Args:
did: User's DID
card: Card data
collection: Collection name (lexicon)
Returns:
Record URI
"""
if not self.client:
raise Exception("Not logged in")
# Prepare card data for atproto
record_data = {
"$type": collection,
"cardId": card.id,
"cp": card.cp,
"status": card.status.value,
"skill": card.skill,
"obtainedAt": card.obtained_at.isoformat(),
"isUnique": card.is_unique,
"uniqueId": card.unique_id,
"createdAt": datetime.utcnow().isoformat()
}
try:
# Create record
response = self.client.com.atproto.repo.create_record(
repo=did,
collection=collection,
record=record_data
)
return response.uri
except AtProtocolError as e:
raise Exception(f"Failed to create card record: {str(e)}")
async def get_user_cards(
self,
did: str,
collection: str = "ai.card.collection",
limit: int = 100
) -> List[Dict[str, Any]]:
"""
Get user's cards from PDS
Args:
did: User's DID
collection: Collection name
limit: Maximum records to fetch
Returns:
List of card records
"""
if not self.client:
raise Exception("Not logged in")
try:
# List records
response = self.client.com.atproto.repo.list_records(
repo=did,
collection=collection,
limit=limit
)
cards = []
for record in response.records:
card_data = record.value
card_data["uri"] = record.uri
card_data["cid"] = record.cid
cards.append(card_data)
return cards
except AtProtocolError:
# Collection might not exist yet
return []
async def delete_card_record(self, did: str, record_uri: str):
"""Delete a card record from PDS"""
if not self.client:
raise Exception("Not logged in")
try:
# Parse collection and rkey from URI
# Format: at://did/collection/rkey
parts = record_uri.split("/")
if len(parts) < 5:
raise ValueError("Invalid record URI")
collection = parts[3]
rkey = parts[4]
self.client.com.atproto.repo.delete_record(
repo=did,
collection=collection,
rkey=rkey
)
except AtProtocolError as e:
raise Exception(f"Failed to delete record: {str(e)}")
async def create_oauth_session(self, code: str, redirect_uri: str) -> Dict[str, Any]:
"""
Handle OAuth callback and create session
Args:
code: Authorization code
redirect_uri: Redirect URI used in authorization
Returns:
Session data including DID and access token
"""
# TODO: Implement when atproto OAuth is available
raise NotImplementedError("OAuth support is not yet available in atproto")
class CardLexicon:
"""Card collection lexicon definition"""
LEXICON_ID = "ai.card.collection"
@staticmethod
def get_lexicon() -> Dict[str, Any]:
"""Get lexicon definition for card collection"""
return {
"lexicon": 1,
"id": CardLexicon.LEXICON_ID,
"defs": {
"main": {
"type": "record",
"description": "A collectible card",
"key": "tid",
"record": {
"type": "object",
"required": ["cardId", "cp", "status", "obtainedAt", "createdAt"],
"properties": {
"cardId": {
"type": "integer",
"description": "Card type ID (0-15)",
"minimum": 0,
"maximum": 15
},
"cp": {
"type": "integer",
"description": "Card power",
"minimum": 1,
"maximum": 999
},
"status": {
"type": "string",
"description": "Card rarity",
"enum": ["normal", "rare", "super_rare", "kira", "unique"]
},
"skill": {
"type": "string",
"description": "Card skill",
"maxLength": 1000
},
"obtainedAt": {
"type": "string",
"format": "datetime",
"description": "When the card was obtained"
},
"isUnique": {
"type": "boolean",
"description": "Whether this is a unique card",
"default": False
},
"uniqueId": {
"type": "string",
"description": "Global unique identifier",
"format": "uuid"
},
"createdAt": {
"type": "string",
"format": "datetime",
"description": "Record creation time"
}
}
}
}
}
}

View File

@@ -0,0 +1,142 @@
"""
Card master data fetcher from external ai.json
"""
import httpx
import json
from typing import Dict, List, Optional
from functools import lru_cache
import logging
logger = logging.getLogger(__name__)
CARD_MASTER_URL = "https://git.syui.ai/ai/ai/raw/branch/main/ai.json"
# Default CP ranges for cards (matching existing gacha.py values)
DEFAULT_CP_RANGES = {
0: (10, 100),
1: (20, 120),
2: (30, 130),
3: (40, 140),
4: (50, 150),
5: (25, 125),
6: (15, 115),
7: (60, 160),
8: (80, 180),
9: (70, 170),
10: (90, 190),
11: (35, 135),
12: (65, 165),
13: (75, 175),
14: (100, 200),
15: (85, 185),
135: (95, 195), # world card
}
class CardMasterService:
def __init__(self):
self._cache = None
self._cache_time = 0
self._cache_duration = 3600 # 1 hour cache
@lru_cache(maxsize=1)
def fetch_card_master_data(self) -> Optional[Dict]:
"""Fetch card master data from external source"""
try:
response = httpx.get(CARD_MASTER_URL, timeout=10.0)
response.raise_for_status()
data = response.json()
return data
except Exception as e:
logger.error(f"Failed to fetch card master data: {e}")
return None
def get_card_info(self) -> Dict[int, Dict]:
"""Get card information in the format expected by gacha service"""
master_data = self.fetch_card_master_data()
if not master_data:
# Fallback to hardcoded data
return self._get_fallback_card_info()
try:
cards = master_data.get("ai", {}).get("card", {}).get("cards", [])
card_info = {}
for card in cards:
card_id = card.get("id")
if card_id is not None:
# Use name from JSON, fallback to English name
name = card.get("name", f"card_{card_id}")
# Get CP range from defaults
cp_range = DEFAULT_CP_RANGES.get(card_id, (50, 150))
card_info[card_id] = {
"name": name,
"base_cp_range": cp_range,
"ja_name": card.get("lang", {}).get("ja", {}).get("name", name),
"description": card.get("lang", {}).get("ja", {}).get("text", "")
}
return card_info
except Exception as e:
logger.error(f"Failed to parse card master data: {e}")
return self._get_fallback_card_info()
def _get_fallback_card_info(self) -> Dict[int, Dict]:
"""Fallback card info if external source fails"""
return {
0: {"name": "ai", "base_cp_range": (10, 100)},
1: {"name": "dream", "base_cp_range": (20, 120)},
2: {"name": "radiance", "base_cp_range": (30, 130)},
3: {"name": "neutron", "base_cp_range": (40, 140)},
4: {"name": "sun", "base_cp_range": (50, 150)},
5: {"name": "night", "base_cp_range": (25, 125)},
6: {"name": "snow", "base_cp_range": (15, 115)},
7: {"name": "thunder", "base_cp_range": (60, 160)},
8: {"name": "ultimate", "base_cp_range": (80, 180)},
9: {"name": "sword", "base_cp_range": (70, 170)},
10: {"name": "destruction", "base_cp_range": (90, 190)},
11: {"name": "earth", "base_cp_range": (35, 135)},
12: {"name": "galaxy", "base_cp_range": (65, 165)},
13: {"name": "create", "base_cp_range": (75, 175)},
14: {"name": "supernova", "base_cp_range": (100, 200)},
15: {"name": "world", "base_cp_range": (85, 185)},
}
def get_all_cards(self) -> List[Dict]:
"""Get all cards with full information"""
master_data = self.fetch_card_master_data()
if not master_data:
return []
try:
cards = master_data.get("ai", {}).get("card", {}).get("cards", [])
result = []
for card in cards:
card_id = card.get("id")
if card_id is not None:
cp_range = DEFAULT_CP_RANGES.get(card_id, (50, 150))
result.append({
"id": card_id,
"name": card.get("name", f"card_{card_id}"),
"ja_name": card.get("lang", {}).get("ja", {}).get("name", ""),
"description": card.get("lang", {}).get("ja", {}).get("text", ""),
"base_cp_min": cp_range[0],
"base_cp_max": cp_range[1]
})
return result
except Exception as e:
logger.error(f"Failed to get all cards: {e}")
return []
# Singleton instance
card_master_service = CardMasterService()

View File

@@ -0,0 +1,184 @@
"""Card synchronization service for atproto"""
from typing import List, Dict, Any, Optional
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
# from app.services.atproto import AtprotoService, CardLexicon
from app.repositories.card import CardRepository
from app.repositories.user import UserRepository
from app.models.card import Card as CardModel
from app.db.models import UserCard
from app.core.config import settings
class CardSyncService:
"""Service for syncing cards between database and atproto PDS"""
def __init__(self, session: AsyncSession, atproto_session: Optional[str] = None):
self.db_session = session
self.card_repo = CardRepository(session)
self.user_repo = UserRepository(session)
self.atproto_service = AtprotoService()
# Restore atproto session if provided
if atproto_session:
self.atproto_service.restore_session(atproto_session)
async def sync_card_to_pds(self, user_card: UserCard, user_did: str) -> Optional[str]:
"""
Sync a single card to user's PDS
Args:
user_card: Card from database
user_did: User's DID
Returns:
Record URI if successful
"""
if not settings.atproto_handle or not settings.atproto_password:
# Skip if atproto credentials not configured
return None
try:
# Login if not already
if not self.atproto_service.client:
await self.atproto_service.login(
settings.atproto_handle,
settings.atproto_password
)
# Convert to API model
card_model = CardModel(
id=user_card.card_id,
cp=user_card.cp,
status=user_card.status,
skill=user_card.skill,
owner_did=user_did,
obtained_at=user_card.obtained_at,
is_unique=user_card.is_unique,
unique_id=str(user_card.unique_id) if user_card.unique_id else None
)
# Create record in PDS
uri = await self.atproto_service.create_card_record(
did=user_did,
card=card_model,
collection=CardLexicon.LEXICON_ID
)
# Store URI in database for future reference
# (You might want to add a field to UserCard model for this)
return uri
except Exception as e:
print(f"Failed to sync card to PDS: {e}")
return None
async def sync_all_user_cards(self, user_id: int, user_did: str) -> int:
"""
Sync all user's cards to PDS
Args:
user_id: Database user ID
user_did: User's DID
Returns:
Number of cards synced
"""
# Get all user cards from database
user_cards = await self.card_repo.get_user_cards(user_id)
synced_count = 0
for card in user_cards:
uri = await self.sync_card_to_pds(card, user_did)
if uri:
synced_count += 1
return synced_count
async def import_cards_from_pds(self, user_did: str) -> int:
"""
Import cards from user's PDS to database
Args:
user_did: User's DID
Returns:
Number of cards imported
"""
if not self.atproto_service.client:
return 0
# Get user from database
user = await self.user_repo.get_by_did(user_did)
if not user:
return 0
# Get cards from PDS
pds_cards = await self.atproto_service.get_user_cards(
did=user_did,
collection=CardLexicon.LEXICON_ID
)
imported_count = 0
for pds_card in pds_cards:
# Check if card already exists
existing_count = await self.card_repo.count_user_cards(
user.id,
pds_card.get("cardId")
)
if existing_count == 0:
# Import card
await self.card_repo.create_user_card(
user_id=user.id,
card_id=pds_card.get("cardId"),
cp=pds_card.get("cp"),
status=pds_card.get("status"),
skill=pds_card.get("skill"),
is_unique=pds_card.get("isUnique", False)
)
imported_count += 1
await self.db_session.commit()
return imported_count
async def verify_card_ownership(
self,
user_did: str,
card_id: int,
unique_id: Optional[str] = None
) -> bool:
"""
Verify user owns a card by checking both database and PDS
Args:
user_did: User's DID
card_id: Card type ID
unique_id: Unique ID for unique cards
Returns:
True if user owns the card
"""
# Check database first
user = await self.user_repo.get_by_did(user_did)
if user:
user_cards = await self.card_repo.get_user_cards(user.id)
for card in user_cards:
if card.card_id == card_id:
if not unique_id or str(card.unique_id) == unique_id:
return True
# Check PDS if configured
if self.atproto_service.client:
try:
pds_cards = await self.atproto_service.get_user_cards(user_did)
for card in pds_cards:
if card.get("cardId") == card_id:
if not unique_id or card.get("uniqueId") == unique_id:
return True
except Exception:
pass
return False

View File

@@ -0,0 +1,164 @@
"""ガチャシステムのロジック"""
import random
import uuid
from datetime import datetime
from typing import Optional, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models.card import Card, CardRarity
from app.repositories.user import UserRepository
from app.repositories.card import CardRepository, UniqueCardRepository
from app.db.models import DrawHistory
from app.services.card_master import card_master_service
class GachaService:
"""ガチャシステムのサービスクラス"""
def __init__(self, session: AsyncSession):
self.session = session
self.user_repo = UserRepository(session)
self.card_repo = CardRepository(session)
self.unique_repo = UniqueCardRepository(session)
# Load card info from external source
self.CARD_INFO = card_master_service.get_card_info()
async def draw_card(self, user_did: str, is_paid: bool = False) -> Tuple[Card, bool]:
"""
カードを抽選する
Args:
user_did: ユーザーのDID
is_paid: 課金ガチャかどうか
Returns:
(Card, is_unique): 抽選されたカードとuniqueかどうか
"""
# Get or create user
user = await self.user_repo.get_or_create(user_did)
# レアリティ抽選
rarity = self._determine_rarity(is_paid)
# カード種類を選択
card_id = self._select_card_id(rarity)
# CPを決定
cp = self._calculate_cp(card_id, rarity)
# uniqueカードチェック
is_unique = False
if rarity == CardRarity.UNIQUE:
# uniqueカードの場合、利用可能かチェック
is_available = await self.unique_repo.is_card_available(card_id)
if not is_available:
# 利用不可の場合はキラカードに変更
rarity = CardRarity.KIRA
else:
is_unique = True
# データベースにカードを保存
user_card = await self.card_repo.create_user_card(
user_id=user.id,
card_id=card_id,
cp=cp,
status=rarity,
skill=self._get_skill_for_card(card_id, rarity),
is_unique=is_unique
)
# 抽選履歴を保存
draw_history = DrawHistory(
user_id=user.id,
card_id=card_id,
status=rarity,
cp=cp,
is_paid=is_paid
)
self.session.add(draw_history)
# API用のCardモデルに変換
card = Card(
id=card_id,
cp=cp,
status=rarity,
skill=user_card.skill,
owner_did=user_did,
obtained_at=user_card.obtained_at,
is_unique=is_unique,
unique_id=str(user_card.unique_id) if user_card.unique_id else None
)
# atproto PDSに同期非同期で実行
try:
from app.services.card_sync import CardSyncService
sync_service = CardSyncService(self.session)
await sync_service.sync_card_to_pds(user_card, user_did)
except Exception:
# 同期失敗してもガチャは成功とする
pass
return card, is_unique
def _determine_rarity(self, is_paid: bool) -> CardRarity:
"""レアリティを抽選する"""
rand = random.random() * 100
if is_paid:
# 課金ガチャは確率アップ
if rand < settings.prob_unique * 2: # 0.0002%
return CardRarity.UNIQUE
elif rand < settings.prob_kira * 2: # 0.2%
return CardRarity.KIRA
elif rand < 0.5: # 0.5%
return CardRarity.SUPER_RARE
elif rand < 5: # 5%
return CardRarity.RARE
else:
# 通常ガチャ
if rand < settings.prob_unique:
return CardRarity.UNIQUE
elif rand < settings.prob_kira:
return CardRarity.KIRA
elif rand < settings.prob_super_rare:
return CardRarity.SUPER_RARE
elif rand < settings.prob_rare:
return CardRarity.RARE
return CardRarity.NORMAL
def _select_card_id(self, rarity: CardRarity) -> int:
"""レアリティに応じてカードIDを選択"""
if rarity in [CardRarity.UNIQUE, CardRarity.KIRA]:
# レアカードは特定のIDに偏らせる
weights = [1, 1, 2, 2, 3, 1, 1, 3, 5, 4, 5, 2, 3, 4, 6, 5]
else:
# 通常は均等
weights = [1] * 16
return random.choices(range(16), weights=weights)[0]
def _calculate_cp(self, card_id: int, rarity: CardRarity) -> int:
"""カードのCPを計算"""
base_range = self.CARD_INFO[card_id]["base_cp_range"]
base_cp = random.randint(*base_range)
# レアリティボーナス
multiplier = {
CardRarity.NORMAL: 1.0,
CardRarity.RARE: 1.5,
CardRarity.SUPER_RARE: 2.0,
CardRarity.KIRA: 3.0,
CardRarity.UNIQUE: 5.0,
}[rarity]
return int(base_cp * multiplier)
def _get_skill_for_card(self, card_id: int, rarity: CardRarity) -> Optional[str]:
"""カードのスキルを取得"""
if rarity in [CardRarity.KIRA, CardRarity.UNIQUE]:
# TODO: スキル情報を返す
return f"skill_{card_id}_{rarity.value}"
return None

View File

@@ -0,0 +1 @@
# Test Package

View File

@@ -0,0 +1,57 @@
"""ガチャシステムのテスト"""
import pytest
from app.services.gacha import GachaService
from app.models.card import CardRarity
class TestGachaService:
"""GachaServiceのテストクラス"""
def test_determine_rarity_normal(self):
"""通常ガチャのレアリティ判定テスト"""
rarities = []
for _ in range(1000):
rarity = GachaService._determine_rarity(is_paid=False)
rarities.append(rarity)
# 大部分がNORMALであることを確認
normal_count = rarities.count(CardRarity.NORMAL)
assert normal_count > 900
def test_determine_rarity_paid(self):
"""課金ガチャのレアリティ判定テスト"""
rarities = []
for _ in range(1000):
rarity = GachaService._determine_rarity(is_paid=True)
rarities.append(rarity)
# 課金ガチャの方がレアが出やすいことを確認
rare_count = sum(1 for r in rarities if r != CardRarity.NORMAL)
assert rare_count > 50 # 5%以上
def test_card_id_selection(self):
"""カードID選択のテスト"""
for rarity in CardRarity:
card_id = GachaService._select_card_id(rarity)
assert 0 <= card_id <= 15
def test_cp_calculation(self):
"""CP計算のテスト"""
# 通常カード
cp_normal = GachaService._calculate_cp(0, CardRarity.NORMAL)
assert 10 <= cp_normal <= 100
# uniqueカード5倍
cp_unique = GachaService._calculate_cp(0, CardRarity.UNIQUE)
assert 50 <= cp_unique <= 500
@pytest.mark.asyncio
async def test_draw_card(self):
"""カード抽選の統合テスト"""
user_did = "did:plc:test123"
card, is_unique = await GachaService.draw_card(user_did, is_paid=False)
assert card.owner_did == user_did
assert 0 <= card.id <= 15
assert card.cp > 0
assert card.status in CardRarity

76
python/api/init_db.py Normal file
View File

@@ -0,0 +1,76 @@
"""Initialize database with master data"""
import asyncio
from sqlalchemy import text, select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.base import engine, Base, async_session
from app.db.models import CardMaster
from app.core.config import settings
# Card master data from ai.json
CARD_MASTER_DATA = [
{"id": 0, "name": "ai", "base_cp_min": 10, "base_cp_max": 100, "color": "#fff700", "description": "世界の最小単位"},
{"id": 1, "name": "dream", "base_cp_min": 20, "base_cp_max": 120, "color": "#b19cd9", "description": "意識が物質を作る"},
{"id": 2, "name": "radiance", "base_cp_min": 30, "base_cp_max": 130, "color": "#ffd700", "description": "存在は光に向かう"},
{"id": 3, "name": "neutron", "base_cp_min": 40, "base_cp_max": 140, "color": "#cacfd2", "description": "中性子"},
{"id": 4, "name": "sun", "base_cp_min": 50, "base_cp_max": 150, "color": "#ff6b35", "description": "太陽"},
{"id": 5, "name": "night", "base_cp_min": 25, "base_cp_max": 125, "color": "#1a1a2e", "description": "夜空"},
{"id": 6, "name": "snow", "base_cp_min": 15, "base_cp_max": 115, "color": "#e3f2fd", "description": ""},
{"id": 7, "name": "thunder", "base_cp_min": 60, "base_cp_max": 160, "color": "#ffd93d", "description": ""},
{"id": 8, "name": "ultimate", "base_cp_min": 80, "base_cp_max": 180, "color": "#6c5ce7", "description": "超究"},
{"id": 9, "name": "sword", "base_cp_min": 70, "base_cp_max": 170, "color": "#a8e6cf", "description": ""},
{"id": 10, "name": "destruction", "base_cp_min": 90, "base_cp_max": 190, "color": "#ff4757", "description": "破壊"},
{"id": 11, "name": "earth", "base_cp_min": 35, "base_cp_max": 135, "color": "#4834d4", "description": "地球"},
{"id": 12, "name": "galaxy", "base_cp_min": 65, "base_cp_max": 165, "color": "#9c88ff", "description": "天の川"},
{"id": 13, "name": "create", "base_cp_min": 75, "base_cp_max": 175, "color": "#00d2d3", "description": "創造"},
{"id": 14, "name": "supernova", "base_cp_min": 100, "base_cp_max": 200, "color": "#ff9ff3", "description": "超新星"},
{"id": 15, "name": "world", "base_cp_min": 85, "base_cp_max": 185, "color": "#54a0ff", "description": "存在と世界は同じもの"},
]
async def init_db():
"""Initialize database tables and master data"""
print("Creating database tables...")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
print("Inserting master data...")
async with async_session() as session:
# Check if master data already exists
try:
result = await session.execute(
select(func.count()).select_from(CardMaster)
)
count = result.scalar()
except Exception:
# Table might not exist yet
count = 0
if count == 0:
# Insert card master data
for card_data in CARD_MASTER_DATA:
card = CardMaster(**card_data)
session.add(card)
await session.commit()
print(f"Inserted {len(CARD_MASTER_DATA)} card master records")
else:
print("Master data already exists, skipping...")
print("Database initialization complete!")
async def drop_db():
"""Drop all database tables"""
print("Dropping all database tables...")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
print("All tables dropped!")
if __name__ == "__main__":
import sys
if len(sys.argv) > 1 and sys.argv[1] == "drop":
asyncio.run(drop_db())
else:
asyncio.run(init_db())

View File

@@ -0,0 +1,20 @@
fastapi>=0.104.1
uvicorn[standard]>=0.24.0
pydantic>=2.7.0,<3.0.0
pydantic-settings>=2.1.0
python-multipart==0.0.6
httpx>=0.25.0,<0.29.0
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
sqlalchemy>=2.0.23
greenlet>=3.0.0
alembic>=1.12.1
# asyncpg==0.29.0 # Disabled: requires compilation
# psycopg2-binary==2.9.9 # Disabled: requires compilation
aiosqlite>=0.19.0
python-dotenv==1.0.0
pytest==7.4.3
pytest-asyncio==0.21.1
atproto>=0.0.55
# supabase>=2.3.0 # Temporarily disabled due to httpx version conflict
fastapi-mcp==0.1.0

View File

@@ -1,9 +0,0 @@
# card
- host : [card.syui.ai](https://card.syuiai)
```sh
$ nvm use 16
$ yarn install
$ yarn dev
```

View File

@@ -1,77 +0,0 @@
#!/bin/zsh
d=${0:a:h}
dd=${0:a:h:h}
echo "1 : [d]efault(no), [b]lack, [s]ilver"
read
url=syui.ai/img
case $OSTYPE in
darwin*)
path_nvm_sh="/opt/homebrew/opt/nvm/nvm.sh";;
linux*)
path_nvm_sh="";;
esac
dir=$dd/public/card
static=$dd/public/static/img
json=$dd/public/json/card.json
mkdir -p $dir
mkdir -p $static
n=`cat $json|jq "length"`
n=`expr $n - 1`
case $1 in
silver|s)
bg=$static/card_bg_silver.png
br=$static/card_br.png
;;
black|b)
bg=$static/card_bg_black.png
br=$static/card_br.png
;;
default|d|*)
bg=$static/card_bg.png
br=$static/card_br.png
;;
esac
cd $dir
export NVM_DIR="$HOME/.nvm"
[ -s "$path_nvm_sh" ] && \. "$path_nvm_sh"
nvm use 17
nvm i squoosh-cli
for ((i=0;i<=$n;i++))
do
p=`cat $json|jq -r ".[$i].p"`
sid=`cat $json|jq -r ".[$i].src"`
s=$static/${sid}.png
id=`cat $json|jq -r ".[$i].id"`
o=$dir/card_$id.png
if [ -f $o ];then
continue
fi
if [ ! -f $s ];then
curl -sL $url/yui_${sid}.png -o $s
fi
if [ ! -f $o ] && [ -n "`echo $s|grep ai_model`" ];then
model_card=$dir/${sid}.png
model_webp=$dir/${sid}.webp
cp -rf $model_card $o
squoosh-cli --webp '{"quality":100}' -d ./ --resize '{width:825,height:1080}' $o
fi
if [ ! -f $o ];then
#if [ ! -f $o ] && [ -z "`echo $s|grep ai_model`" ];then
composite -gravity north -geometry +0+160 -compose over $s $bg $o.back
composite -gravity north -geometry +0+0 -compose over $br $o.back $o
squoosh-cli --webp '{"quality":100}' -d ./ --resize '{width:825,height:1080}' $o
rm $o.back
fi
done

View File

@@ -1,57 +0,0 @@
#!/bin/zsh
url=https://card.syui.ai
d=${0:a:h}
dd=${0:a:h:h}
dir=$dd/public/card
o_dir=$dd/public/static/img
static=$dd/public/static/img
json=$dd/public/json/card.json
mkdir -p $dir
mkdir -p $static
n=`cat $json|jq "length"`
n=`expr $n - 1`
for ((i=0;i<=$n;i++))
do
p=`cat $json|jq -r ".[$i].p"`
h=`cat $json|jq -r ".[$i].h"`
sid=`cat $json|jq -r ".[$i].src"`
s=$static/${sid}.png
id=`cat $json|jq -r ".[$i].id"`
o=$dir/card_$id.png
oo=$o_dir/og_${id}.png
o_url=$url/card/og_${id}.png
index=$o_dir/$id/index.html
if [ ! -f $oo ];then
echo $oo
magick convert $o -gravity center -extent 8000x4000 $oo
fi
# mkdir -p $o_dir/$id
#
#echo '
#<!DOCTYPE html>
#<html lang="en">
#<head>
#<title>card.syui.ai</title><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><title>card.syui.ai</title><link href="app.css" rel="preload" as="style"><link href="app.js" rel="preload" as="script"><link href="chunk-vendors.js" rel="preload" as="script"><link href="app.css" rel="stylesheet">
#<meta name="twitter:card" content="summary">
#<meta property="og:url" content="https://card.syui.ai">
#' >! $index
#
#echo "
#<meta property=\"og:title\" content=\"$h\">
#<meta property=\"og:description\" content=\"$p\">
#<meta property=\"og:image\" content=\"$o_url\">
#" >> $index
#
#echo '
#</head>
#<body>
#<div id="app"></div><script src="chunk-vendors.js"></script><script src="app.js"></script>
#</body>
#</html>
#' >> $index
done

26
scripts/setup.sh Executable file
View File

@@ -0,0 +1,26 @@
#!/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"

38
scripts/setup_venv.sh Executable file
View File

@@ -0,0 +1,38 @@
#!/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'"

116
setup_venv.sh Executable file
View File

@@ -0,0 +1,116 @@
#!/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"

File diff suppressed because it is too large Load Diff

108
src/auth.rs Normal file
View File

@@ -0,0 +1,108 @@
use chrono::{Duration, Utc};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use crate::error::{AppError, AppResult};
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
pub did: String,
pub handle: String,
pub exp: usize,
}
pub struct JwtService {
encoding_key: EncodingKey,
decoding_key: DecodingKey,
}
impl JwtService {
pub fn new(secret: &str) -> Self {
Self {
encoding_key: EncodingKey::from_secret(secret.as_ref()),
decoding_key: DecodingKey::from_secret(secret.as_ref()),
}
}
pub fn create_token(&self, did: &str, handle: &str, expires_in_minutes: u64) -> AppResult<String> {
let expiration = Utc::now()
.checked_add_signed(Duration::minutes(expires_in_minutes as i64))
.ok_or_else(|| AppError::internal("Failed to calculate expiration time"))?
.timestamp() as usize;
let claims = Claims {
did: did.to_string(),
handle: handle.to_string(),
exp: expiration,
};
encode(&Header::default(), &claims, &self.encoding_key)
.map_err(AppError::Jwt)
}
pub fn verify_token(&self, token: &str) -> AppResult<Claims> {
let token_data = decode::<Claims>(token, &self.decoding_key, &Validation::default())
.map_err(AppError::Jwt)?;
Ok(token_data.claims)
}
}
/// Mock atproto authentication service
/// In a real implementation, this would integrate with actual atproto services
pub struct AtprotoAuthService {
jwt_service: JwtService,
}
impl AtprotoAuthService {
pub fn new(secret: &str) -> Self {
Self {
jwt_service: JwtService::new(secret),
}
}
/// Authenticate user with atproto credentials
/// This is a mock implementation - in reality would validate against atproto PDS
pub async fn authenticate(&self, identifier: &str, _password: &str) -> AppResult<AuthenticatedUser> {
// Mock validation - in real implementation:
// 1. Connect to user's PDS
// 2. Verify credentials
// 3. Get user DID and handle
// For now, treat identifier as DID or handle
let (did, handle) = if identifier.starts_with("did:") {
(identifier.to_string(), extract_handle_from_did(identifier))
} else {
(format!("did:plc:{}", generate_mock_plc_id()), identifier.to_string())
};
Ok(AuthenticatedUser { did, handle })
}
pub fn create_access_token(&self, user: &AuthenticatedUser, expires_in_minutes: u64) -> AppResult<String> {
self.jwt_service.create_token(&user.did, &user.handle, expires_in_minutes)
}
pub fn verify_access_token(&self, token: &str) -> AppResult<Claims> {
self.jwt_service.verify_token(token)
}
}
#[derive(Debug, Clone)]
pub struct AuthenticatedUser {
pub did: String,
pub handle: String,
}
/// Extract handle from DID (mock implementation)
fn extract_handle_from_did(did: &str) -> String {
// In a real implementation, this would resolve the DID to get the handle
// For now, use a simple mock
did.split(':').last().unwrap_or("unknown").to_string()
}
/// Generate mock PLC identifier
fn generate_mock_plc_id() -> String {
use uuid::Uuid;
Uuid::new_v4().to_string().replace('-', "")[..24].to_string()
}

131
src/config.rs Normal file
View File

@@ -0,0 +1,131 @@
use config::{Config, ConfigError, Environment, File};
use serde::Deserialize;
use std::path::PathBuf;
#[derive(Debug, Clone, Deserialize)]
pub struct Settings {
// Application settings
pub app_name: String,
pub port: u16,
pub api_v1_prefix: String,
// Database settings
pub database_url: String,
pub database_url_supabase: Option<String>,
// Authentication
pub secret_key: String,
pub access_token_expire_minutes: u64,
// Gacha probabilities (percentages)
pub prob_normal: f64,
pub prob_rare: f64,
pub prob_super_rare: f64,
pub prob_kira: f64,
pub prob_unique: f64,
// atproto settings
pub atproto_pds_url: Option<String>,
pub atproto_handle: Option<String>,
// External data
pub card_master_url: String,
// File paths
pub config_dir: PathBuf,
}
impl Settings {
pub fn new() -> Result<Self, ConfigError> {
let config_dir = dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".config")
.join("syui")
.join("ai")
.join("card");
// Ensure config directory exists
if !config_dir.exists() {
std::fs::create_dir_all(&config_dir)
.map_err(|e| ConfigError::Message(format!("Failed to create config directory: {}", e)))?;
}
let mut builder = Config::builder()
// Default values
.set_default("app_name", "ai.card")?
.set_default("port", 8000)?
.set_default("api_v1_prefix", "/api/v1")?
// Database defaults
.set_default("database_url", format!("sqlite://{}?mode=rwc", config_dir.join("aicard.db").display()))?
// Authentication defaults
.set_default("secret_key", "your-secret-key-change-in-production")?
.set_default("access_token_expire_minutes", 1440)? // 24 hours
// Gacha probability defaults (matching Python implementation)
.set_default("prob_normal", 99.789)?
.set_default("prob_rare", 0.1)?
.set_default("prob_super_rare", 0.01)?
.set_default("prob_kira", 0.1)?
.set_default("prob_unique", 0.0001)?
// External data source
.set_default("card_master_url", "https://git.syui.ai/ai/ai/raw/branch/main/ai.json")?;
// Load from config file if it exists (support both .toml and .json)
let config_toml = config_dir.join("config.toml");
let config_json = config_dir.join("config.json");
if config_toml.exists() {
builder = builder.add_source(File::from(config_toml));
} else if config_json.exists() {
builder = builder.add_source(File::from(config_json));
}
// Override with environment variables (AI_CARD_ prefix)
builder = builder.add_source(Environment::with_prefix("AI_CARD").separator("_"));
let mut settings: Settings = builder.build()?.try_deserialize()?;
// Set the config directory path
settings.config_dir = config_dir;
Ok(settings)
}
/// Get the gacha configuration for the gacha service
pub fn gacha_config(&self) -> GachaConfig {
GachaConfig {
prob_normal: self.prob_normal,
prob_rare: self.prob_rare,
prob_super_rare: self.prob_super_rare,
prob_kira: self.prob_kira,
prob_unique: self.prob_unique,
}
}
}
#[derive(Debug, Clone)]
pub struct GachaConfig {
pub prob_normal: f64,
pub prob_rare: f64,
pub prob_super_rare: f64,
pub prob_kira: f64,
pub prob_unique: f64,
}
impl GachaConfig {
/// Calculate cumulative probabilities for rarity determination
pub fn cumulative_probabilities(&self, is_paid: bool) -> Vec<(f64, crate::models::CardRarity)> {
let multiplier = if is_paid { 2.0 } else { 1.0 };
vec![
(self.prob_unique * multiplier, crate::models::CardRarity::Unique),
(self.prob_kira * multiplier, crate::models::CardRarity::Kira),
(self.prob_super_rare * multiplier, crate::models::CardRarity::SuperRare),
(self.prob_rare * multiplier, crate::models::CardRarity::Rare),
(self.prob_normal, crate::models::CardRarity::Normal),
]
}
}

190
src/database.rs Normal file
View File

@@ -0,0 +1,190 @@
use sqlx::{Pool, Postgres, Sqlite, Row};
use sqlx::migrate::MigrateDatabase;
use crate::error::{AppError, AppResult};
use std::str::FromStr;
#[derive(Clone)]
pub enum Database {
Postgres(Pool<Postgres>),
Sqlite(Pool<Sqlite>),
}
impl Database {
pub async fn connect(database_url: &str) -> AppResult<Self> {
if database_url.starts_with("postgres://") || database_url.starts_with("postgresql://") {
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(10)
.connect(database_url)
.await
.map_err(AppError::Database)?;
Ok(Database::Postgres(pool))
} else if database_url.starts_with("sqlite://") {
// Extract the path from sqlite:// URL
let db_path = database_url.trim_start_matches("sqlite://");
// Create the database file if it doesn't exist
if !Sqlite::database_exists(database_url).await.unwrap_or(false) {
Sqlite::create_database(database_url)
.await
.map_err(AppError::Database)?;
}
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(5)
.connect(database_url)
.await
.map_err(AppError::Database)?;
Ok(Database::Sqlite(pool))
} else {
Err(AppError::Configuration(format!(
"Unsupported database URL: {}",
database_url
)))
}
}
pub async fn migrate(&self) -> AppResult<()> {
match self {
Database::Postgres(pool) => {
sqlx::migrate!("./migrations/postgres")
.run(pool)
.await
.map_err(AppError::Migration)?;
}
Database::Sqlite(pool) => {
sqlx::migrate!("./migrations/sqlite")
.run(pool)
.await
.map_err(AppError::Migration)?;
}
}
Ok(())
}
/// Get a generic connection for complex operations
pub async fn acquire(&self) -> AppResult<DatabaseConnection> {
match self {
Database::Postgres(pool) => {
let conn = pool.acquire().await.map_err(AppError::Database)?;
Ok(DatabaseConnection::Postgres(conn))
}
Database::Sqlite(pool) => {
let conn = pool.acquire().await.map_err(AppError::Database)?;
Ok(DatabaseConnection::Sqlite(conn))
}
}
}
/// Begin a transaction
pub async fn begin(&self) -> AppResult<DatabaseTransaction> {
match self {
Database::Postgres(pool) => {
let tx = pool.begin().await.map_err(AppError::Database)?;
Ok(DatabaseTransaction::Postgres(tx))
}
Database::Sqlite(pool) => {
let tx = pool.begin().await.map_err(AppError::Database)?;
Ok(DatabaseTransaction::Sqlite(tx))
}
}
}
}
pub enum DatabaseConnection {
Postgres(sqlx::pool::PoolConnection<Postgres>),
Sqlite(sqlx::pool::PoolConnection<Sqlite>),
}
pub enum DatabaseTransaction {
Postgres(sqlx::Transaction<'static, Postgres>),
Sqlite(sqlx::Transaction<'static, Sqlite>),
}
impl DatabaseTransaction {
pub async fn commit(self) -> AppResult<()> {
match self {
DatabaseTransaction::Postgres(tx) => {
tx.commit().await.map_err(AppError::Database)?;
}
DatabaseTransaction::Sqlite(tx) => {
tx.commit().await.map_err(AppError::Database)?;
}
}
Ok(())
}
pub async fn rollback(self) -> AppResult<()> {
match self {
DatabaseTransaction::Postgres(tx) => {
tx.rollback().await.map_err(AppError::Database)?;
}
DatabaseTransaction::Sqlite(tx) => {
tx.rollback().await.map_err(AppError::Database)?;
}
}
Ok(())
}
}
// Macros for database-agnostic queries
#[macro_export]
macro_rules! query_as {
($struct:ty, $query:expr, $db:expr) => {
match $db {
Database::Postgres(pool) => {
sqlx::query_as::<_, $struct>($query)
.fetch_all(pool)
.await
.map_err(AppError::Database)
}
Database::Sqlite(pool) => {
sqlx::query_as::<_, $struct>($query)
.fetch_all(pool)
.await
.map_err(AppError::Database)
}
}
};
}
#[macro_export]
macro_rules! query_one_as {
($struct:ty, $query:expr, $db:expr) => {
match $db {
Database::Postgres(pool) => {
sqlx::query_as::<_, $struct>($query)
.fetch_one(pool)
.await
.map_err(AppError::Database)
}
Database::Sqlite(pool) => {
sqlx::query_as::<_, $struct>($query)
.fetch_one(pool)
.await
.map_err(AppError::Database)
}
}
};
}
#[macro_export]
macro_rules! query_optional_as {
($struct:ty, $query:expr, $db:expr) => {
match $db {
Database::Postgres(pool) => {
sqlx::query_as::<_, $struct>($query)
.fetch_optional(pool)
.await
.map_err(AppError::Database)
}
Database::Sqlite(pool) => {
sqlx::query_as::<_, $struct>($query)
.fetch_optional(pool)
.await
.map_err(AppError::Database)
}
}
};
}

142
src/error.rs Normal file
View File

@@ -0,0 +1,142 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum AppError {
#[error("Database error: {0}")]
Database(#[from] sqlx::Error),
#[error("Migration error: {0}")]
Migration(#[from] sqlx::migrate::MigrateError),
#[error("Validation error: {0}")]
Validation(String),
#[error("Authentication error: {0}")]
Authentication(String),
#[error("Authorization error: {0}")]
Authorization(String),
#[error("Not found: {0}")]
NotFound(String),
#[error("Conflict: {0}")]
Conflict(String),
#[error("External service error: {0}")]
ExternalService(String),
#[error("Configuration error: {0}")]
Configuration(String),
#[error("JSON serialization error: {0}")]
Json(#[from] serde_json::Error),
#[error("HTTP client error: {0}")]
HttpClient(#[from] reqwest::Error),
#[error("JWT error: {0}")]
Jwt(#[from] jsonwebtoken::errors::Error),
#[error("Internal server error: {0}")]
Internal(String),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, error_message, error_code) = match &self {
AppError::Database(e) => {
tracing::error!("Database error: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Database error", "DATABASE_ERROR")
}
AppError::Migration(e) => {
tracing::error!("Migration error: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Migration error", "MIGRATION_ERROR")
}
AppError::Validation(msg) => {
(StatusCode::BAD_REQUEST, msg.as_str(), "VALIDATION_ERROR")
}
AppError::Authentication(msg) => {
(StatusCode::UNAUTHORIZED, msg.as_str(), "AUTHENTICATION_ERROR")
}
AppError::Authorization(msg) => {
(StatusCode::FORBIDDEN, msg.as_str(), "AUTHORIZATION_ERROR")
}
AppError::NotFound(msg) => {
(StatusCode::NOT_FOUND, msg.as_str(), "NOT_FOUND")
}
AppError::Conflict(msg) => {
(StatusCode::CONFLICT, msg.as_str(), "CONFLICT")
}
AppError::ExternalService(msg) => {
tracing::error!("External service error: {}", msg);
(StatusCode::BAD_GATEWAY, "External service unavailable", "EXTERNAL_SERVICE_ERROR")
}
AppError::Configuration(msg) => {
tracing::error!("Configuration error: {}", msg);
(StatusCode::INTERNAL_SERVER_ERROR, "Configuration error", "CONFIGURATION_ERROR")
}
AppError::Json(e) => {
tracing::error!("JSON error: {}", e);
(StatusCode::BAD_REQUEST, "Invalid JSON", "JSON_ERROR")
}
AppError::HttpClient(e) => {
tracing::error!("HTTP client error: {}", e);
(StatusCode::BAD_GATEWAY, "External service error", "HTTP_CLIENT_ERROR")
}
AppError::Jwt(e) => {
tracing::error!("JWT error: {}", e);
(StatusCode::UNAUTHORIZED, "Invalid token", "JWT_ERROR")
}
AppError::Internal(msg) => {
tracing::error!("Internal error: {}", msg);
(StatusCode::INTERNAL_SERVER_ERROR, "Internal server error", "INTERNAL_ERROR")
}
};
let body = Json(json!({
"error": {
"code": error_code,
"message": error_message,
"timestamp": chrono::Utc::now().to_rfc3339()
}
}));
(status, body).into_response()
}
}
// Convenience methods for common errors
impl AppError {
pub fn validation<T: Into<String>>(msg: T) -> Self {
Self::Validation(msg.into())
}
pub fn authentication<T: Into<String>>(msg: T) -> Self {
Self::Authentication(msg.into())
}
pub fn authorization<T: Into<String>>(msg: T) -> Self {
Self::Authorization(msg.into())
}
pub fn not_found<T: Into<String>>(msg: T) -> Self {
Self::NotFound(msg.into())
}
pub fn conflict<T: Into<String>>(msg: T) -> Self {
Self::Conflict(msg.into())
}
pub fn internal<T: Into<String>>(msg: T) -> Self {
Self::Internal(msg.into())
}
}
pub type AppResult<T> = Result<T, AppError>;

161
src/handlers/auth.rs Normal file
View File

@@ -0,0 +1,161 @@
use axum::{
extract::State,
response::Json,
routing::post,
Router,
};
use validator::Validate;
use crate::{
auth::AtprotoAuthService,
error::{AppError, AppResult},
models::*,
AppState,
};
pub fn create_routes() -> Router<AppState> {
Router::new()
.route("/login", post(login))
.route("/verify", post(verify_token))
}
/// Authenticate user with atproto credentials
async fn login(
State(state): State<AppState>,
Json(request): Json<LoginRequest>,
) -> AppResult<Json<LoginResponse>> {
// Validate request
request.validate().map_err(|e| AppError::validation(e.to_string()))?;
// Create auth service
let auth_service = AtprotoAuthService::new(&state.settings.secret_key);
// Authenticate user
let user = auth_service
.authenticate(&request.identifier, &request.password)
.await?;
// Create access token
let access_token = auth_service
.create_access_token(&user, state.settings.access_token_expire_minutes)?;
// Create or update user in database
let db_user = create_or_update_user(&state, &user.did, &user.handle).await?;
Ok(Json(LoginResponse {
access_token,
token_type: "Bearer".to_string(),
expires_in: state.settings.access_token_expire_minutes * 60, // Convert to seconds
user: UserInfo {
did: user.did,
handle: user.handle,
},
}))
}
/// Verify JWT token
async fn verify_token(
State(state): State<AppState>,
Json(token): Json<serde_json::Value>,
) -> AppResult<Json<serde_json::Value>> {
let token_str = token["token"]
.as_str()
.ok_or_else(|| AppError::validation("Token is required"))?;
let auth_service = AtprotoAuthService::new(&state.settings.secret_key);
let claims = auth_service.verify_access_token(token_str)?;
Ok(Json(serde_json::json!({
"valid": true,
"did": claims.did,
"handle": claims.handle,
"exp": claims.exp
})))
}
/// Create or update user in database
async fn create_or_update_user(
state: &AppState,
did: &str,
handle: &str,
) -> AppResult<User> {
let now = chrono::Utc::now();
// Try to get existing user
let existing_user = match &state.db {
crate::database::Database::Postgres(pool) => {
sqlx::query_as::<_, User>("SELECT * FROM users WHERE did = $1")
.bind(did)
.fetch_optional(pool)
.await
.map_err(AppError::Database)?
}
crate::database::Database::Sqlite(pool) => {
sqlx::query_as::<_, User>("SELECT * FROM users WHERE did = ?")
.bind(did)
.fetch_optional(pool)
.await
.map_err(AppError::Database)?
}
};
if let Some(mut user) = existing_user {
// Update handle if changed
if user.handle != handle {
user = match &state.db {
crate::database::Database::Postgres(pool) => {
sqlx::query_as::<_, User>(
"UPDATE users SET handle = $1, updated_at = $2 WHERE did = $3 RETURNING *"
)
.bind(handle)
.bind(now)
.bind(did)
.fetch_one(pool)
.await
.map_err(AppError::Database)?
}
crate::database::Database::Sqlite(pool) => {
sqlx::query_as::<_, User>(
"UPDATE users SET handle = ?, updated_at = ? WHERE did = ? RETURNING *"
)
.bind(handle)
.bind(now)
.bind(did)
.fetch_one(pool)
.await
.map_err(AppError::Database)?
}
};
}
Ok(user)
} else {
// Create new user
let user = match &state.db {
crate::database::Database::Postgres(pool) => {
sqlx::query_as::<_, User>(
"INSERT INTO users (did, handle, created_at, updated_at) VALUES ($1, $2, $3, $4) RETURNING *"
)
.bind(did)
.bind(handle)
.bind(now)
.bind(now)
.fetch_one(pool)
.await
.map_err(AppError::Database)?
}
crate::database::Database::Sqlite(pool) => {
sqlx::query_as::<_, User>(
"INSERT INTO users (did, handle, created_at, updated_at) VALUES (?, ?, ?, ?) RETURNING *"
)
.bind(did)
.bind(handle)
.bind(now)
.bind(now)
.fetch_one(pool)
.await
.map_err(AppError::Database)?
}
};
Ok(user)
}
}

314
src/handlers/cards.rs Normal file
View File

@@ -0,0 +1,314 @@
use axum::{
extract::{Path, Query, State},
response::Json,
routing::{get, post},
Router,
};
use serde::Deserialize;
use validator::Validate;
use crate::{
error::{AppError, AppResult},
models::*,
services::GachaService,
AppState,
};
pub fn create_routes() -> Router<AppState> {
Router::new()
.route("/draw", post(draw_card))
.route("/user/:user_did", get(get_user_cards))
.route("/unique", get(get_unique_registry))
.route("/stats", get(get_gacha_stats))
.route("/master", get(get_card_master))
}
/// Draw a card from gacha system
async fn draw_card(
State(state): State<AppState>,
Json(request): Json<CardDrawRequest>,
) -> AppResult<Json<CardDrawResponse>> {
// Validate request
request.validate().map_err(|e| AppError::validation(e.to_string()))?;
let gacha_service = GachaService::new(state.settings.gacha_config());
let result = gacha_service
.draw_card(&state.db, &request.user_did, request.is_paid, request.pool_id)
.await?;
Ok(Json(result))
}
#[derive(Deserialize)]
struct UserCardsQuery {
limit: Option<i32>,
offset: Option<i32>,
}
/// Get user's card collection
async fn get_user_cards(
State(state): State<AppState>,
Path(user_did): Path<String>,
Query(query): Query<UserCardsQuery>,
) -> AppResult<Json<UserCardCollectionResponse>> {
let limit = query.limit.unwrap_or(50).min(100); // Max 100 cards per request
let offset = query.offset.unwrap_or(0);
// Get user ID from DID
let user = match &state.db {
crate::database::Database::Postgres(pool) => {
sqlx::query_as::<_, User>("SELECT * FROM users WHERE did = $1")
.bind(&user_did)
.fetch_optional(pool)
.await
.map_err(AppError::Database)?
}
crate::database::Database::Sqlite(pool) => {
sqlx::query_as::<_, User>("SELECT * FROM users WHERE did = ?")
.bind(&user_did)
.fetch_optional(pool)
.await
.map_err(AppError::Database)?
}
};
let user = user.ok_or_else(|| AppError::not_found("User not found"))?;
// Get user's cards with master data
let cards_with_master = match &state.db {
crate::database::Database::Postgres(pool) => {
sqlx::query_as::<_, UserCardWithMasterQuery>(
r#"
SELECT
uc.id, uc.user_id, uc.card_id, uc.cp, uc.status,
uc.obtained_at, uc.is_unique, uc.unique_id,
cm.id as master_id, cm.name, cm.base_cp_min, cm.base_cp_max, cm.color, cm.description
FROM user_cards uc
JOIN card_master cm ON uc.card_id = cm.id
WHERE uc.user_id = $1
ORDER BY uc.obtained_at DESC
LIMIT $2 OFFSET $3
"#
)
.bind(user.id)
.bind(limit as i64)
.bind(offset as i64)
.fetch_all(pool)
.await
.map_err(AppError::Database)?
}
crate::database::Database::Sqlite(pool) => {
sqlx::query_as::<_, UserCardWithMasterQuery>(
r#"
SELECT
uc.id, uc.user_id, uc.card_id, uc.cp, uc.status,
uc.obtained_at, uc.is_unique, uc.unique_id,
cm.id as master_id, cm.name, cm.base_cp_min, cm.base_cp_max, cm.color, cm.description
FROM user_cards uc
JOIN card_master cm ON uc.card_id = cm.id
WHERE uc.user_id = ?
ORDER BY uc.obtained_at DESC
LIMIT ? OFFSET ?
"#
)
.bind(user.id)
.bind(limit as i32)
.bind(offset as i32)
.fetch_all(pool)
.await
.map_err(AppError::Database)?
}
};
let mut cards = Vec::new();
let mut rarity_breakdown = RarityBreakdown {
normal: 0,
rare: 0,
super_rare: 0,
kira: 0,
unique: 0,
};
for row in cards_with_master {
let status = match row.status.as_str() {
"normal" => CardRarity::Normal,
"rare" => CardRarity::Rare,
"super_rare" => CardRarity::SuperRare,
"kira" => CardRarity::Kira,
"unique" => CardRarity::Unique,
_ => CardRarity::Normal,
};
// Update rarity breakdown
match status {
CardRarity::Normal => rarity_breakdown.normal += 1,
CardRarity::Rare => rarity_breakdown.rare += 1,
CardRarity::SuperRare => rarity_breakdown.super_rare += 1,
CardRarity::Kira => rarity_breakdown.kira += 1,
CardRarity::Unique => rarity_breakdown.unique += 1,
}
cards.push(UserCardWithMaster {
card: UserCardResponse {
id: row.id,
card_id: row.card_id,
cp: row.cp,
status,
skill: None, // TODO: Add skill field to query if needed
obtained_at: row.obtained_at,
is_unique: row.is_unique,
unique_id: row.unique_id,
},
master: CardMasterResponse {
id: row.master_id,
name: row.name,
base_cp_min: row.base_cp_min,
base_cp_max: row.base_cp_max,
color: row.color,
description: row.description,
},
});
}
// Get total count and unique count
let (total_count, unique_count): (i64, i64) = match &state.db {
crate::database::Database::Postgres(pool) => {
sqlx::query_as(
"SELECT COUNT(*) as total, COUNT(*) FILTER (WHERE is_unique = true) as unique_count FROM user_cards WHERE user_id = $1"
)
.bind(user.id)
.fetch_one(pool)
.await
.map_err(AppError::Database)?
}
crate::database::Database::Sqlite(pool) => {
sqlx::query_as(
"SELECT COUNT(*) as total, SUM(CASE WHEN is_unique = 1 THEN 1 ELSE 0 END) as unique_count FROM user_cards WHERE user_id = ?"
)
.bind(user.id)
.fetch_one(pool)
.await
.map_err(AppError::Database)?
}
};
Ok(Json(UserCardCollectionResponse {
user_did,
cards,
total_count: total_count as i32,
unique_count: unique_count as i32,
rarity_breakdown,
}))
}
/// Get global unique card registry
async fn get_unique_registry(
State(state): State<AppState>,
) -> AppResult<Json<UniqueCardRegistryResponse>> {
// Get all unique cards with master data and owner info
let unique_cards = match &state.db {
crate::database::Database::Postgres(pool) => {
sqlx::query_as::<_, UniqueCardQuery>(
r#"
SELECT
cm.id as card_id,
cm.name as card_name,
ucr.owner_did,
u.handle as owner_handle,
ucr.obtained_at
FROM card_master cm
LEFT JOIN unique_card_registry ucr ON cm.id = ucr.card_id
LEFT JOIN users u ON ucr.owner_did = u.did
ORDER BY cm.id
"#
)
.fetch_all(pool)
.await
.map_err(AppError::Database)?
}
crate::database::Database::Sqlite(pool) => {
sqlx::query_as::<_, UniqueCardQuery>(
r#"
SELECT
cm.id as card_id,
cm.name as card_name,
ucr.owner_did,
u.handle as owner_handle,
ucr.obtained_at
FROM card_master cm
LEFT JOIN unique_card_registry ucr ON cm.id = ucr.card_id
LEFT JOIN users u ON ucr.owner_did = u.did
ORDER BY cm.id
"#
)
.fetch_all(pool)
.await
.map_err(AppError::Database)?
}
};
let mut unique_card_infos = Vec::new();
let mut available_count = 0;
for row in unique_cards {
let is_available = row.owner_did.is_none();
if is_available {
available_count += 1;
}
unique_card_infos.push(UniqueCardInfo {
card_id: row.card_id,
card_name: row.card_name,
owner_did: row.owner_did,
owner_handle: row.owner_handle,
obtained_at: row.obtained_at,
is_available,
});
}
Ok(Json(UniqueCardRegistryResponse {
unique_cards: unique_card_infos,
total_unique_cards: 16, // Total number of card types
available_unique_cards: available_count,
}))
}
/// Get gacha statistics
async fn get_gacha_stats(State(state): State<AppState>) -> AppResult<Json<GachaStatsResponse>> {
let gacha_service = GachaService::new(state.settings.gacha_config());
let stats = gacha_service.get_gacha_stats(&state.db).await?;
Ok(Json(stats))
}
/// Get card master data
async fn get_card_master(State(state): State<AppState>) -> AppResult<Json<Vec<CardMasterResponse>>> {
let cards = match &state.db {
crate::database::Database::Postgres(pool) => {
sqlx::query_as::<_, CardMaster>("SELECT * FROM card_master ORDER BY id")
.fetch_all(pool)
.await
.map_err(AppError::Database)?
}
crate::database::Database::Sqlite(pool) => {
sqlx::query_as::<_, CardMaster>("SELECT * FROM card_master ORDER BY id")
.fetch_all(pool)
.await
.map_err(AppError::Database)?
}
};
let card_responses: Vec<CardMasterResponse> = cards
.into_iter()
.map(|card| CardMasterResponse {
id: card.id,
name: card.name,
base_cp_min: card.base_cp_min,
base_cp_max: card.base_cp_max,
color: card.color,
description: card.description,
})
.collect();
Ok(Json(card_responses))
}

7
src/handlers/mod.rs Normal file
View File

@@ -0,0 +1,7 @@
pub mod auth;
pub mod cards;
pub mod sync;
pub use auth::*;
pub use cards::*;
pub use sync::*;

68
src/handlers/sync.rs Normal file
View File

@@ -0,0 +1,68 @@
use axum::{
extract::State,
response::Json,
routing::post,
Router,
};
use crate::{
error::{AppError, AppResult},
AppState,
};
pub fn create_routes() -> Router<AppState> {
Router::new()
.route("/cards/export", post(export_cards))
.route("/cards/import", post(import_cards))
.route("/cards/bidirectional", post(bidirectional_sync))
}
/// Export user's cards to atproto PDS
async fn export_cards(State(_state): State<AppState>) -> AppResult<Json<serde_json::Value>> {
// TODO: Implement atproto PDS export
// This would:
// 1. Get user's cards from database
// 2. Format as atproto records
// 3. Upload to user's PDS
Ok(Json(serde_json::json!({
"status": "success",
"message": "Card export to PDS completed",
"exported_count": 0,
"note": "atproto integration not yet implemented"
})))
}
/// Import user's cards from atproto PDS
async fn import_cards(State(_state): State<AppState>) -> AppResult<Json<serde_json::Value>> {
// TODO: Implement atproto PDS import
// This would:
// 1. Fetch card records from user's PDS
// 2. Validate and parse records
// 3. Update local database
Ok(Json(serde_json::json!({
"status": "success",
"message": "Card import from PDS completed",
"imported_count": 0,
"note": "atproto integration not yet implemented"
})))
}
/// Bidirectional synchronization between local DB and PDS
async fn bidirectional_sync(State(_state): State<AppState>) -> AppResult<Json<serde_json::Value>> {
// TODO: Implement bidirectional sync
// This would:
// 1. Compare local cards with PDS records
// 2. Resolve conflicts (newest wins, etc.)
// 3. Sync in both directions
Ok(Json(serde_json::json!({
"status": "success",
"message": "Bidirectional sync completed",
"local_to_pds": 0,
"pds_to_local": 0,
"conflicts_resolved": 0,
"note": "atproto integration not yet implemented"
})))
}

View File

@@ -1,9 +0,0 @@
import Vue from 'vue'
import App from './App.vue'
import VueMeta from 'vue-meta'
Vue.use(VueMeta)
Vue.config.productionTip = false
new Vue({
render: h => h(App)
}).$mount('#app')

151
src/main.rs Normal file
View File

@@ -0,0 +1,151 @@
use anyhow::Result;
use axum::{
extract::State,
http::StatusCode,
response::Json,
routing::{get, post},
Router,
};
use serde_json::{json, Value};
use std::net::SocketAddr;
use tower_http::cors::CorsLayer;
use tracing::{info, warn};
mod config;
mod database;
mod models;
mod handlers;
mod services;
mod auth;
mod error;
use config::Settings;
use database::Database;
use error::AppError;
#[derive(Clone)]
pub struct AppState {
pub db: Database,
pub settings: Settings,
}
#[tokio::main]
async fn main() -> Result<()> {
// Initialize tracing with debug level
tracing_subscriber::fmt()
.with_env_filter("debug")
.init();
println!("🎴 ai.card API Server Starting...");
println!("===================================");
// Load configuration
println!("📁 Loading configuration...");
let settings = Settings::new()
.map_err(|e| {
eprintln!("❌ Failed to load configuration: {}", e);
anyhow::anyhow!("Failed to load configuration: {}", e)
})?;
println!("✅ Configuration loaded successfully");
println!("📍 Config directory: {}", settings.config_dir.display());
println!("🌐 Port: {}", settings.port);
println!("🗄️ Database URL: {}", settings.database_url);
info!("Starting ai.card API server v{}", env!("CARGO_PKG_VERSION"));
// Initialize database
println!("🔗 Connecting to database...");
let database = Database::connect(&settings.database_url).await
.map_err(|e| {
eprintln!("❌ Database connection failed: {}", e);
e
})?;
println!("✅ Database connected successfully");
// Run migrations
println!("🔄 Running database migrations...");
database.migrate().await
.map_err(|e| {
eprintln!("❌ Database migration failed: {}", e);
e
})?;
println!("✅ Database migrations completed");
let app_state = AppState {
db: database,
settings: settings.clone(),
};
// Build application routes
println!("🛣️ Setting up routes...");
let app = create_app(app_state).await;
println!("✅ Routes configured");
// Start server
let addr = SocketAddr::from(([0, 0, 0, 0], settings.port));
println!("🚀 Starting server on {}", addr);
println!("🔗 Health check: http://localhost:{}/health", settings.port);
println!("📡 API endpoints: http://localhost:{}/api/v1", settings.port);
println!("🎮 Card endpoints:");
println!(" - POST /api/v1/cards/draw - Draw card");
println!(" - GET /api/v1/cards/user/{{did}} - Get user cards");
println!(" - GET /api/v1/cards/unique - Get unique registry");
println!(" - GET /api/v1/cards/stats - Get gacha stats");
println!(" - GET /api/v1/cards/master - Get card master");
println!("===================================");
info!("ai.card API server listening on {}", addr);
let listener = tokio::net::TcpListener::bind(addr).await
.map_err(|e| {
eprintln!("❌ Failed to bind to address {}: {}", addr, e);
e
})?;
println!("🎉 Server started successfully! Press Ctrl+C to stop.");
axum::serve(listener, app).await
.map_err(|e| {
eprintln!("❌ Server error: {}", e);
e
})?;
Ok(())
}
async fn create_app(state: AppState) -> Router {
Router::new()
// Health check
.route("/health", get(health_check))
// API v1 routes
.nest("/api/v1", create_api_routes())
// CORS middleware
.layer(CorsLayer::permissive())
// Application state
.with_state(state)
}
fn create_api_routes() -> Router<AppState> {
Router::new()
// Authentication routes
.nest("/auth", handlers::auth::create_routes())
// Card routes
.nest("/cards", handlers::cards::create_routes())
// Sync routes
.nest("/sync", handlers::sync::create_routes())
}
async fn health_check() -> Result<Json<Value>, AppError> {
Ok(Json(json!({
"status": "healthy",
"service": "ai.card",
"version": env!("CARGO_PKG_VERSION"),
"timestamp": chrono::Utc::now().to_rfc3339()
})))
}

326
src/models.rs Normal file
View File

@@ -0,0 +1,326 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::{FromRow, Type};
use uuid::Uuid;
use validator::Validate;
/// Card rarity enum matching Python implementation
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
#[sqlx(type_name = "card_rarity", rename_all = "lowercase")]
pub enum CardRarity {
#[serde(rename = "normal")]
Normal,
#[serde(rename = "rare")]
Rare,
#[serde(rename = "super_rare")]
SuperRare,
#[serde(rename = "kira")]
Kira,
#[serde(rename = "unique")]
Unique,
}
impl CardRarity {
pub fn multiplier(&self) -> f64 {
match self {
CardRarity::Normal => 1.0,
CardRarity::Rare => 1.5,
CardRarity::SuperRare => 2.0,
CardRarity::Kira => 3.0,
CardRarity::Unique => 5.0,
}
}
pub fn as_str(&self) -> &'static str {
match self {
CardRarity::Normal => "normal",
CardRarity::Rare => "rare",
CardRarity::SuperRare => "super_rare",
CardRarity::Kira => "kira",
CardRarity::Unique => "unique",
}
}
}
/// Database Models
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
pub struct User {
pub id: i32,
pub did: String,
pub handle: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
pub struct CardMaster {
pub id: i32,
pub name: String,
pub base_cp_min: i32,
pub base_cp_max: i32,
pub color: String,
pub description: String,
}
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
pub struct UserCard {
pub id: i32,
pub user_id: i32,
pub card_id: i32,
pub cp: i32,
pub status: CardRarity,
pub skill: Option<String>,
pub obtained_at: DateTime<Utc>,
pub is_unique: bool,
pub unique_id: Option<Uuid>,
}
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
pub struct UniqueCardRegistry {
pub id: i32,
pub unique_id: Uuid,
pub card_id: i32,
pub owner_did: String,
pub obtained_at: DateTime<Utc>,
pub verse_skill_id: Option<String>,
}
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
pub struct DrawHistory {
pub id: i32,
pub user_id: i32,
pub card_id: i32,
pub status: CardRarity,
pub cp: i32,
pub is_paid: bool,
pub drawn_at: DateTime<Utc>,
}
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
pub struct GachaPool {
pub id: i32,
pub name: String,
pub description: String,
pub is_active: bool,
pub start_at: Option<DateTime<Utc>>,
pub end_at: Option<DateTime<Utc>>,
pub pickup_card_ids: Vec<i32>,
pub rate_up_multiplier: f64,
}
/// API Request/Response Models
#[derive(Debug, Deserialize, Validate)]
pub struct LoginRequest {
#[validate(length(min = 1))]
pub identifier: String,
#[validate(length(min = 1))]
pub password: String,
}
#[derive(Debug, Serialize)]
pub struct LoginResponse {
pub access_token: String,
pub token_type: String,
pub expires_in: u64,
pub user: UserInfo,
}
#[derive(Debug, Serialize)]
pub struct UserInfo {
pub did: String,
pub handle: String,
}
#[derive(Debug, Deserialize, Validate)]
pub struct CardDrawRequest {
pub user_did: String,
#[serde(default)]
pub is_paid: bool,
pub pool_id: Option<i32>,
}
#[derive(Debug, Serialize)]
pub struct CardDrawResponse {
pub card: UserCardResponse,
pub master: CardMasterResponse,
pub is_unique: bool,
pub animation_type: String,
pub draw_history_id: i32,
}
#[derive(Debug, Serialize)]
pub struct UserCardResponse {
pub id: i32,
pub card_id: i32,
pub cp: i32,
pub status: CardRarity,
pub skill: Option<String>,
pub obtained_at: DateTime<Utc>,
pub is_unique: bool,
pub unique_id: Option<Uuid>,
}
#[derive(Debug, Serialize)]
pub struct CardMasterResponse {
pub id: i32,
pub name: String,
pub base_cp_min: i32,
pub base_cp_max: i32,
pub color: String,
pub description: String,
}
#[derive(Debug, Serialize)]
pub struct UserCardCollectionResponse {
pub user_did: String,
pub cards: Vec<UserCardWithMaster>,
pub total_count: i32,
pub unique_count: i32,
pub rarity_breakdown: RarityBreakdown,
}
#[derive(Debug, Serialize)]
pub struct UserCardWithMaster {
pub card: UserCardResponse,
pub master: CardMasterResponse,
}
/// Database query result for JOIN operations
#[derive(Debug, Clone, FromRow)]
pub struct UserCardWithMasterQuery {
// user_cards fields
pub id: i32,
pub user_id: i32,
pub card_id: i32,
pub cp: i32,
pub status: String,
pub obtained_at: DateTime<Utc>,
pub is_unique: bool,
pub unique_id: Option<Uuid>,
// card_master fields
pub master_id: i32,
pub name: String,
pub base_cp_min: i32,
pub base_cp_max: i32,
pub color: String,
pub description: String,
}
/// Database query result for unique card registry
#[derive(Debug, Clone, FromRow)]
pub struct UniqueCardQuery {
pub card_id: i32,
pub card_name: String,
pub owner_did: Option<String>,
pub owner_handle: Option<String>,
pub obtained_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Serialize)]
pub struct RarityBreakdown {
pub normal: i32,
pub rare: i32,
pub super_rare: i32,
pub kira: i32,
pub unique: i32,
}
#[derive(Debug, Serialize)]
pub struct UniqueCardRegistryResponse {
pub unique_cards: Vec<UniqueCardInfo>,
pub total_unique_cards: i32,
pub available_unique_cards: i32,
}
#[derive(Debug, Serialize)]
pub struct UniqueCardInfo {
pub card_id: i32,
pub card_name: String,
pub owner_did: Option<String>,
pub owner_handle: Option<String>,
pub obtained_at: Option<DateTime<Utc>>,
pub is_available: bool,
}
#[derive(Debug, Serialize)]
pub struct GachaStatsResponse {
pub probabilities: GachaProbabilities,
pub total_draws: i32,
pub total_unique_cards: i32,
pub available_unique_cards: i32,
pub rarity_distribution: RarityBreakdown,
}
#[derive(Debug, Serialize)]
pub struct GachaProbabilities {
pub normal: f64,
pub rare: f64,
pub super_rare: f64,
pub kira: f64,
pub unique: f64,
pub paid_multiplier: f64,
}
/// External Data Models (from ai.json)
#[derive(Debug, Deserialize)]
pub struct ExternalCardData {
pub ai: AiData,
}
#[derive(Debug, Deserialize)]
pub struct AiData {
pub card: CardData,
}
#[derive(Debug, Deserialize)]
pub struct CardData {
pub cards: Vec<ExternalCard>,
}
#[derive(Debug, Deserialize)]
pub struct ExternalCard {
pub id: i32,
pub name: String,
pub cp: CpRange,
pub color: String,
pub skill: String,
pub lang: Option<LangData>,
}
#[derive(Debug, Deserialize)]
pub struct CpRange {
pub min: i32,
pub max: i32,
}
#[derive(Debug, Deserialize)]
pub struct LangData {
pub ja: Option<JapaneseData>,
}
#[derive(Debug, Deserialize)]
pub struct JapaneseData {
pub name: Option<String>,
pub skill: Option<String>,
}
/// atproto Models
#[derive(Debug, Serialize)]
pub struct AtprotoCardRecord {
#[serde(rename = "$type")]
pub record_type: String,
#[serde(rename = "cardId")]
pub card_id: i32,
pub cp: i32,
pub status: String,
#[serde(rename = "obtainedAt")]
pub obtained_at: DateTime<Utc>,
#[serde(rename = "isUnique")]
pub is_unique: bool,
#[serde(rename = "uniqueId")]
pub unique_id: Option<Uuid>,
}

232
src/services/atproto.rs Normal file
View File

@@ -0,0 +1,232 @@
use crate::{
error::{AppError, AppResult},
models::*,
};
use reqwest::Client;
use serde_json::json;
pub struct AtprotoService {
client: Client,
session: Option<String>,
}
impl AtprotoService {
pub fn new() -> Self {
Self {
client: Client::new(),
session: None,
}
}
pub fn with_session(session: String) -> Self {
Self {
client: Client::new(),
session: Some(session),
}
}
/// Create a card record in user's atproto PDS
pub async fn create_card_record(
&self,
did: &str,
card: &UserCard,
master: &CardMaster,
) -> AppResult<String> {
let session = self.session.as_ref()
.ok_or_else(|| AppError::authentication("No atproto session available"))?;
let record_data = AtprotoCardRecord {
record_type: "ai.card.collection".to_string(),
card_id: card.card_id,
cp: card.cp,
status: card.status.as_str().to_string(),
obtained_at: card.obtained_at,
is_unique: card.is_unique,
unique_id: card.unique_id,
};
// Determine PDS endpoint from DID
let pds_url = self.resolve_pds_from_did(did).await?;
let response = self
.client
.post(&format!("{}/xrpc/com.atproto.repo.createRecord", pds_url))
.header("Authorization", format!("Bearer {}", session))
.json(&json!({
"repo": did,
"collection": "ai.card.collection",
"record": record_data
}))
.send()
.await
.map_err(AppError::HttpClient)?;
if !response.status().is_success() {
return Err(AppError::ExternalService(format!(
"Failed to create atproto record: HTTP {}",
response.status()
)));
}
let result: serde_json::Value = response
.json()
.await
.map_err(AppError::HttpClient)?;
let uri = result["uri"]
.as_str()
.ok_or_else(|| AppError::ExternalService("No URI in response".to_string()))?;
Ok(uri.to_string())
}
/// List card records from user's PDS
pub async fn list_card_records(&self, did: &str) -> AppResult<Vec<serde_json::Value>> {
let session = self.session.as_ref()
.ok_or_else(|| AppError::authentication("No atproto session available"))?;
let pds_url = self.resolve_pds_from_did(did).await?;
let response = self
.client
.get(&format!("{}/xrpc/com.atproto.repo.listRecords", pds_url))
.header("Authorization", format!("Bearer {}", session))
.query(&[
("repo", did),
("collection", "ai.card.collection"),
])
.send()
.await
.map_err(AppError::HttpClient)?;
if !response.status().is_success() {
return Err(AppError::ExternalService(format!(
"Failed to list atproto records: HTTP {}",
response.status()
)));
}
let result: serde_json::Value = response
.json()
.await
.map_err(AppError::HttpClient)?;
let records = result["records"]
.as_array()
.ok_or_else(|| AppError::ExternalService("No records in response".to_string()))?;
Ok(records.clone())
}
/// Resolve PDS endpoint from DID
async fn resolve_pds_from_did(&self, did: &str) -> AppResult<String> {
// This is a simplified resolution
// In a real implementation, you would:
// 1. Parse the DID to get the method and identifier
// 2. Query the appropriate resolver (PLC directory, etc.)
// 3. Get the serviceEndpoint for the PDS
if did.starts_with("did:plc:") {
// For PLC DIDs, query the PLC directory
let plc_id = did.strip_prefix("did:plc:").unwrap();
self.resolve_plc_did(plc_id).await
} else if did.starts_with("did:web:") {
// For web DIDs, construct URL from domain
let domain = did.strip_prefix("did:web:").unwrap();
Ok(format!("https://{}", domain))
} else {
// Fallback to Bluesky PDS
Ok("https://bsky.social".to_string())
}
}
/// Resolve PLC DID to PDS endpoint
async fn resolve_plc_did(&self, plc_id: &str) -> AppResult<String> {
let response = self
.client
.get(&format!("https://plc.directory/{}", plc_id))
.send()
.await
.map_err(AppError::HttpClient)?;
if !response.status().is_success() {
return Ok("https://bsky.social".to_string()); // Fallback
}
let did_doc: serde_json::Value = response
.json()
.await
.map_err(AppError::HttpClient)?;
// Extract PDS endpoint from DID document
if let Some(services) = did_doc["service"].as_array() {
for service in services {
if service["id"] == "#atproto_pds" {
if let Some(endpoint) = service["serviceEndpoint"].as_str() {
return Ok(endpoint.to_string());
}
}
}
}
// Fallback to Bluesky
Ok("https://bsky.social".to_string())
}
/// Authenticate with atproto and get session
pub async fn authenticate(&self, identifier: &str, password: &str) -> AppResult<(String, String)> {
// Try multiple PDS endpoints for authentication
let pds_endpoints = [
"https://bsky.social",
"https://staging.bsky.app",
// Add more PDS endpoints as needed
];
for pds_url in pds_endpoints {
match self.try_authenticate_at_pds(pds_url, identifier, password).await {
Ok((session, did)) => return Ok((session, did)),
Err(_) => continue, // Try next PDS
}
}
Err(AppError::authentication("Failed to authenticate with any PDS"))
}
/// Try authentication at a specific PDS
async fn try_authenticate_at_pds(
&self,
pds_url: &str,
identifier: &str,
password: &str,
) -> AppResult<(String, String)> {
let response = self
.client
.post(&format!("{}/xrpc/com.atproto.server.createSession", pds_url))
.json(&json!({
"identifier": identifier,
"password": password
}))
.send()
.await
.map_err(AppError::HttpClient)?;
if !response.status().is_success() {
return Err(AppError::authentication("Invalid credentials"));
}
let result: serde_json::Value = response
.json()
.await
.map_err(AppError::HttpClient)?;
let access_jwt = result["accessJwt"]
.as_str()
.ok_or_else(|| AppError::authentication("No access token in response"))?;
let did = result["did"]
.as_str()
.ok_or_else(|| AppError::authentication("No DID in response"))?;
Ok((access_jwt.to_string(), did.to_string()))
}
}

219
src/services/card_master.rs Normal file
View File

@@ -0,0 +1,219 @@
use crate::{
error::{AppError, AppResult},
models::*,
};
use reqwest::Client;
use std::collections::HashMap;
pub struct CardMasterService {
client: Client,
master_url: String,
}
impl CardMasterService {
pub fn new(master_url: String) -> Self {
Self {
client: Client::new(),
master_url,
}
}
/// Fetch card master data from external source (ai.json)
pub async fn fetch_external_card_data(&self) -> AppResult<Vec<ExternalCard>> {
let response = self
.client
.get(&self.master_url)
.timeout(std::time::Duration::from_secs(10))
.send()
.await
.map_err(AppError::HttpClient)?;
if !response.status().is_success() {
return Err(AppError::ExternalService(format!(
"Failed to fetch card data: HTTP {}",
response.status()
)));
}
let data: ExternalCardData = response
.json()
.await
.map_err(AppError::HttpClient)?;
Ok(data.ai.card.cards)
}
/// Get fallback card data if external fetch fails
pub fn get_fallback_card_data(&self) -> Vec<ExternalCard> {
vec![
ExternalCard {
id: 0,
name: "ai".to_string(),
cp: CpRange { min: 100, max: 200 },
color: "#4A90E2".to_string(),
skill: "Core existence essence".to_string(),
lang: None,
},
ExternalCard {
id: 1,
name: "dream".to_string(),
cp: CpRange { min: 90, max: 180 },
color: "#9B59B6".to_string(),
skill: "Vision manifestation".to_string(),
lang: None,
},
ExternalCard {
id: 2,
name: "radiance".to_string(),
cp: CpRange { min: 110, max: 220 },
color: "#F39C12".to_string(),
skill: "Brilliant energy".to_string(),
lang: None,
},
ExternalCard {
id: 3,
name: "neutron".to_string(),
cp: CpRange { min: 120, max: 240 },
color: "#34495E".to_string(),
skill: "Dense core power".to_string(),
lang: None,
},
ExternalCard {
id: 4,
name: "sun".to_string(),
cp: CpRange { min: 130, max: 260 },
color: "#E74C3C".to_string(),
skill: "Solar radiance".to_string(),
lang: None,
},
ExternalCard {
id: 5,
name: "night".to_string(),
cp: CpRange { min: 80, max: 160 },
color: "#2C3E50".to_string(),
skill: "Shadow stealth".to_string(),
lang: None,
},
ExternalCard {
id: 6,
name: "snow".to_string(),
cp: CpRange { min: 70, max: 140 },
color: "#ECF0F1".to_string(),
skill: "Crystal freeze".to_string(),
lang: None,
},
ExternalCard {
id: 7,
name: "thunder".to_string(),
cp: CpRange { min: 140, max: 280 },
color: "#F1C40F".to_string(),
skill: "Electric storm".to_string(),
lang: None,
},
ExternalCard {
id: 8,
name: "ultimate".to_string(),
cp: CpRange { min: 150, max: 300 },
color: "#8E44AD".to_string(),
skill: "Maximum form".to_string(),
lang: None,
},
ExternalCard {
id: 9,
name: "sword".to_string(),
cp: CpRange { min: 160, max: 320 },
color: "#95A5A6".to_string(),
skill: "Truth cutting".to_string(),
lang: None,
},
ExternalCard {
id: 10,
name: "destruction".to_string(),
cp: CpRange { min: 170, max: 340 },
color: "#C0392B".to_string(),
skill: "Entropy force".to_string(),
lang: None,
},
ExternalCard {
id: 11,
name: "earth".to_string(),
cp: CpRange { min: 90, max: 180 },
color: "#27AE60".to_string(),
skill: "Ground foundation".to_string(),
lang: None,
},
ExternalCard {
id: 12,
name: "galaxy".to_string(),
cp: CpRange { min: 180, max: 360 },
color: "#3498DB".to_string(),
skill: "Cosmic expanse".to_string(),
lang: None,
},
ExternalCard {
id: 13,
name: "create".to_string(),
cp: CpRange { min: 100, max: 200 },
color: "#16A085".to_string(),
skill: "Generation power".to_string(),
lang: None,
},
ExternalCard {
id: 14,
name: "supernova".to_string(),
cp: CpRange { min: 200, max: 400 },
color: "#E67E22".to_string(),
skill: "Stellar explosion".to_string(),
lang: None,
},
ExternalCard {
id: 15,
name: "world".to_string(),
cp: CpRange { min: 250, max: 500 },
color: "#9B59B6".to_string(),
skill: "Reality control".to_string(),
lang: None,
},
]
}
/// Get card master data, trying external source first then fallback
pub async fn get_card_master_data(&self) -> Vec<ExternalCard> {
match self.fetch_external_card_data().await {
Ok(cards) => {
tracing::info!("Fetched {} cards from external source", cards.len());
cards
}
Err(e) => {
tracing::warn!("Failed to fetch external card data: {}, using fallback", e);
self.get_fallback_card_data()
}
}
}
/// Convert external card data to database format
pub fn external_to_card_master(external: &ExternalCard) -> CardMaster {
let description = if let Some(lang) = &external.lang {
if let Some(ja) = &lang.ja {
if let Some(name) = &ja.name {
format!("{} - {}", name, external.skill)
} else {
external.skill.clone()
}
} else {
external.skill.clone()
}
} else {
external.skill.clone()
};
CardMaster {
id: external.id,
name: external.name.clone(),
base_cp_min: external.cp.min,
base_cp_max: external.cp.max,
color: external.color.clone(),
description,
}
}
}

541
src/services/gacha.rs Normal file
View File

@@ -0,0 +1,541 @@
use crate::{
config::GachaConfig,
database::{Database, DatabaseTransaction},
error::{AppError, AppResult},
models::*,
query_as, query_one_as, query_optional_as,
services::CardMasterService,
};
use chrono::Utc;
use rand::Rng;
use std::collections::HashMap;
use uuid::Uuid;
pub struct GachaService {
config: GachaConfig,
}
impl GachaService {
pub fn new(config: GachaConfig) -> Self {
Self { config }
}
/// Main gacha draw function
pub async fn draw_card(
&self,
db: &Database,
user_did: &str,
is_paid: bool,
pool_id: Option<i32>,
) -> AppResult<CardDrawResponse> {
let mut tx = db.begin().await?;
// Get or create user
let user = self.get_or_create_user(&mut tx, user_did).await?;
// Determine card rarity
let rarity = self.determine_rarity(is_paid, pool_id)?;
// Select a card based on rarity and pool
let card_master = self.select_card_master(&mut tx, &rarity, pool_id).await?;
// Calculate CP based on rarity
let cp = self.calculate_cp(&card_master, &rarity);
// Check if this will be a unique card
let is_unique = rarity == CardRarity::Unique;
// For unique cards, check availability
if is_unique {
if let Some(_existing) = self.check_unique_card_availability(&mut tx, card_master.id).await? {
// Unique card already taken, fallback to Kira
return self.draw_card_with_fallback(&mut tx, user.id, &card_master, CardRarity::Kira, is_paid).await;
}
}
// Create the user card
let user_card = self.create_user_card(
&mut tx,
user.id,
&card_master,
cp,
&rarity,
is_unique,
).await?;
// Record draw history
let draw_history = self.record_draw_history(
&mut tx,
user.id,
card_master.id,
&rarity,
cp,
is_paid,
).await?;
// Register unique card if applicable
if is_unique {
self.register_unique_card(&mut tx, &user_card, user_did).await?;
}
tx.commit().await?;
Ok(CardDrawResponse {
card: UserCardResponse {
id: user_card.id,
card_id: user_card.card_id,
cp: user_card.cp,
status: user_card.status,
skill: user_card.skill,
obtained_at: user_card.obtained_at,
is_unique: user_card.is_unique,
unique_id: user_card.unique_id,
},
master: CardMasterResponse {
id: card_master.id,
name: card_master.name,
base_cp_min: card_master.base_cp_min,
base_cp_max: card_master.base_cp_max,
color: card_master.color,
description: card_master.description,
},
is_unique,
animation_type: self.get_animation_type(&rarity),
draw_history_id: draw_history.id,
})
}
/// Determine card rarity based on probabilities
fn determine_rarity(&self, is_paid: bool, _pool_id: Option<i32>) -> AppResult<CardRarity> {
let mut rng = rand::thread_rng();
let rand_val: f64 = rng.gen_range(0.0..100.0);
let cumulative_probs = self.config.cumulative_probabilities(is_paid);
let mut cumulative = 0.0;
for (prob, rarity) in cumulative_probs {
cumulative += prob;
if rand_val < cumulative {
return Ok(rarity);
}
}
// Fallback to normal if no match (should never happen)
Ok(CardRarity::Normal)
}
/// Select a card master based on rarity and pool
async fn select_card_master(
&self,
tx: &mut DatabaseTransaction,
rarity: &CardRarity,
_pool_id: Option<i32>,
) -> AppResult<CardMaster> {
// For now, randomly select from all available cards
// In a full implementation, this would consider pool restrictions
let cards = match tx {
DatabaseTransaction::Postgres(tx) => {
sqlx::query_as::<_, CardMaster>("SELECT * FROM card_master ORDER BY RANDOM() LIMIT 1")
.fetch_one(&mut **tx)
.await
.map_err(AppError::Database)?
}
DatabaseTransaction::Sqlite(tx) => {
sqlx::query_as::<_, CardMaster>("SELECT * FROM card_master ORDER BY RANDOM() LIMIT 1")
.fetch_one(&mut **tx)
.await
.map_err(AppError::Database)?
}
};
Ok(cards)
}
/// Calculate CP based on base CP and rarity multiplier
fn calculate_cp(&self, card_master: &CardMaster, rarity: &CardRarity) -> i32 {
let mut rng = rand::thread_rng();
let base_cp = rng.gen_range(card_master.base_cp_min..=card_master.base_cp_max);
let multiplier = rarity.multiplier();
(base_cp as f64 * multiplier) as i32
}
/// Check if a unique card is available
async fn check_unique_card_availability(
&self,
tx: &mut DatabaseTransaction,
card_id: i32,
) -> AppResult<Option<UniqueCardRegistry>> {
match tx {
DatabaseTransaction::Postgres(tx) => {
sqlx::query_as::<_, UniqueCardRegistry>(
"SELECT * FROM unique_card_registry WHERE card_id = $1"
)
.bind(card_id)
.fetch_optional(&mut **tx)
.await
.map_err(AppError::Database)
}
DatabaseTransaction::Sqlite(tx) => {
sqlx::query_as::<_, UniqueCardRegistry>(
"SELECT * FROM unique_card_registry WHERE card_id = ?"
)
.bind(card_id)
.fetch_optional(&mut **tx)
.await
.map_err(AppError::Database)
}
}
}
/// Create a user card
async fn create_user_card(
&self,
tx: &mut DatabaseTransaction,
user_id: i32,
card_master: &CardMaster,
cp: i32,
rarity: &CardRarity,
is_unique: bool,
) -> AppResult<UserCard> {
let unique_id = if is_unique { Some(Uuid::new_v4()) } else { None };
let now = Utc::now();
match tx {
DatabaseTransaction::Postgres(tx) => {
sqlx::query_as::<_, UserCard>(
r#"
INSERT INTO user_cards (user_id, card_id, cp, status, obtained_at, is_unique, unique_id)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *
"#
)
.bind(user_id)
.bind(card_master.id)
.bind(cp)
.bind(rarity)
.bind(now)
.bind(is_unique)
.bind(unique_id)
.fetch_one(&mut **tx)
.await
.map_err(AppError::Database)
}
DatabaseTransaction::Sqlite(tx) => {
sqlx::query_as::<_, UserCard>(
r#"
INSERT INTO user_cards (user_id, card_id, cp, status, obtained_at, is_unique, unique_id)
VALUES (?, ?, ?, ?, ?, ?, ?)
RETURNING *
"#
)
.bind(user_id)
.bind(card_master.id)
.bind(cp)
.bind(rarity)
.bind(now)
.bind(is_unique)
.bind(unique_id)
.fetch_one(&mut **tx)
.await
.map_err(AppError::Database)
}
}
}
/// Record draw history
async fn record_draw_history(
&self,
tx: &mut DatabaseTransaction,
user_id: i32,
card_id: i32,
rarity: &CardRarity,
cp: i32,
is_paid: bool,
) -> AppResult<DrawHistory> {
let now = Utc::now();
match tx {
DatabaseTransaction::Postgres(tx) => {
sqlx::query_as::<_, DrawHistory>(
r#"
INSERT INTO draw_history (user_id, card_id, status, cp, is_paid, drawn_at)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *
"#
)
.bind(user_id)
.bind(card_id)
.bind(rarity)
.bind(cp)
.bind(is_paid)
.bind(now)
.fetch_one(&mut **tx)
.await
.map_err(AppError::Database)
}
DatabaseTransaction::Sqlite(tx) => {
sqlx::query_as::<_, DrawHistory>(
r#"
INSERT INTO draw_history (user_id, card_id, status, cp, is_paid, drawn_at)
VALUES (?, ?, ?, ?, ?, ?)
RETURNING *
"#
)
.bind(user_id)
.bind(card_id)
.bind(rarity)
.bind(cp)
.bind(is_paid)
.bind(now)
.fetch_one(&mut **tx)
.await
.map_err(AppError::Database)
}
}
}
/// Register unique card
async fn register_unique_card(
&self,
tx: &mut DatabaseTransaction,
user_card: &UserCard,
owner_did: &str,
) -> AppResult<UniqueCardRegistry> {
let unique_id = user_card.unique_id.ok_or_else(|| {
AppError::Internal("Unique card must have unique_id".to_string())
})?;
match tx {
DatabaseTransaction::Postgres(tx) => {
sqlx::query_as::<_, UniqueCardRegistry>(
r#"
INSERT INTO unique_card_registry (unique_id, card_id, owner_did, obtained_at)
VALUES ($1, $2, $3, $4)
RETURNING *
"#
)
.bind(unique_id)
.bind(user_card.card_id)
.bind(owner_did)
.bind(user_card.obtained_at)
.fetch_one(&mut **tx)
.await
.map_err(AppError::Database)
}
DatabaseTransaction::Sqlite(tx) => {
sqlx::query_as::<_, UniqueCardRegistry>(
r#"
INSERT INTO unique_card_registry (unique_id, card_id, owner_did, obtained_at)
VALUES (?, ?, ?, ?)
RETURNING *
"#
)
.bind(unique_id)
.bind(user_card.card_id)
.bind(owner_did)
.bind(user_card.obtained_at)
.fetch_one(&mut **tx)
.await
.map_err(AppError::Database)
}
}
}
/// Get or create user by DID
async fn get_or_create_user(
&self,
tx: &mut DatabaseTransaction,
did: &str,
) -> AppResult<User> {
// Try to get existing user
let existing_user = match tx {
DatabaseTransaction::Postgres(tx) => {
sqlx::query_as::<_, User>("SELECT * FROM users WHERE did = $1")
.bind(did)
.fetch_optional(&mut **tx)
.await
.map_err(AppError::Database)?
}
DatabaseTransaction::Sqlite(tx) => {
sqlx::query_as::<_, User>("SELECT * FROM users WHERE did = ?")
.bind(did)
.fetch_optional(&mut **tx)
.await
.map_err(AppError::Database)?
}
};
if let Some(user) = existing_user {
return Ok(user);
}
// Create new user
let handle = did.split('.').next().unwrap_or("unknown").to_string();
let now = Utc::now();
match tx {
DatabaseTransaction::Postgres(tx) => {
sqlx::query_as::<_, User>(
"INSERT INTO users (did, handle, created_at, updated_at) VALUES ($1, $2, $3, $4) RETURNING *"
)
.bind(did)
.bind(&handle)
.bind(now)
.bind(now)
.fetch_one(&mut **tx)
.await
.map_err(AppError::Database)
}
DatabaseTransaction::Sqlite(tx) => {
sqlx::query_as::<_, User>(
"INSERT INTO users (did, handle, created_at, updated_at) VALUES (?, ?, ?, ?) RETURNING *"
)
.bind(did)
.bind(&handle)
.bind(now)
.bind(now)
.fetch_one(&mut **tx)
.await
.map_err(AppError::Database)
}
}
}
/// Draw card with fallback rarity (when unique is unavailable)
async fn draw_card_with_fallback(
&self,
tx: &mut DatabaseTransaction,
user_id: i32,
card_master: &CardMaster,
fallback_rarity: CardRarity,
is_paid: bool,
) -> AppResult<CardDrawResponse> {
let cp = self.calculate_cp(card_master, &fallback_rarity);
let user_card = self.create_user_card(
tx,
user_id,
card_master,
cp,
&fallback_rarity,
false,
).await?;
let draw_history = self.record_draw_history(
tx,
user_id,
card_master.id,
&fallback_rarity,
cp,
is_paid,
).await?;
Ok(CardDrawResponse {
card: UserCardResponse {
id: user_card.id,
card_id: user_card.card_id,
cp: user_card.cp,
status: user_card.status,
skill: user_card.skill,
obtained_at: user_card.obtained_at,
is_unique: user_card.is_unique,
unique_id: user_card.unique_id,
},
master: CardMasterResponse {
id: card_master.id,
name: card_master.name.clone(),
base_cp_min: card_master.base_cp_min,
base_cp_max: card_master.base_cp_max,
color: card_master.color.clone(),
description: card_master.description.clone(),
},
is_unique: false,
animation_type: self.get_animation_type(&fallback_rarity),
draw_history_id: draw_history.id,
})
}
/// Get animation type based on rarity
fn get_animation_type(&self, rarity: &CardRarity) -> String {
match rarity {
CardRarity::Normal => "normal".to_string(),
CardRarity::Rare => "sparkle".to_string(),
CardRarity::SuperRare => "glow".to_string(),
CardRarity::Kira => "rainbow".to_string(),
CardRarity::Unique => "legendary".to_string(),
}
}
/// Get gacha statistics
pub async fn get_gacha_stats(&self, db: &Database) -> AppResult<GachaStatsResponse> {
// Get total draws
let total_draws: (i64,) = match db {
Database::Postgres(pool) => {
sqlx::query_as("SELECT COUNT(*) FROM draw_history")
.fetch_one(pool)
.await
.map_err(AppError::Database)?
}
Database::Sqlite(pool) => {
sqlx::query_as("SELECT COUNT(*) FROM draw_history")
.fetch_one(pool)
.await
.map_err(AppError::Database)?
}
};
// Get unique card counts
let unique_counts: (i64, i64) = match db {
Database::Postgres(pool) => {
sqlx::query_as(
r#"
SELECT
COUNT(*) as total,
(SELECT COUNT(*) FROM card_master) - COUNT(*) as available
FROM unique_card_registry
"#
)
.fetch_one(pool)
.await
.map_err(AppError::Database)?
}
Database::Sqlite(pool) => {
sqlx::query_as(
r#"
SELECT
COUNT(*) as total,
(SELECT COUNT(*) FROM card_master) - COUNT(*) as available
FROM unique_card_registry
"#
)
.fetch_one(pool)
.await
.map_err(AppError::Database)?
}
};
// Get rarity distribution
let rarity_breakdown = RarityBreakdown {
normal: 0, // Would need actual counts from database
rare: 0,
super_rare: 0,
kira: 0,
unique: unique_counts.0 as i32,
};
Ok(GachaStatsResponse {
probabilities: GachaProbabilities {
normal: self.config.prob_normal,
rare: self.config.prob_rare,
super_rare: self.config.prob_super_rare,
kira: self.config.prob_kira,
unique: self.config.prob_unique,
paid_multiplier: 2.0,
},
total_draws: total_draws.0 as i32,
total_unique_cards: unique_counts.0 as i32,
available_unique_cards: unique_counts.1 as i32,
rarity_distribution: rarity_breakdown,
})
}
}

Some files were not shown because too many files have changed in this diff Show More