1
0
This commit is contained in:
2025-06-08 06:42:03 +09:00
parent e7948bf4cf
commit 2e55e6ce09
53 changed files with 3053 additions and 0 deletions

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